3 Commits

Author SHA1 Message Date
GA Pro 1d77671ea0 feat: 리포트 3종 백엔드 구현(실적/조직별/환수위험) + Excel export 트랜잭션 수정
- ReportController/Service/Mapper(.xml) + VO 3종(PerfReportRow/OrgReportRow/ChargebackRiskRow)
  - /api/report/performance: settle_month별 settle_master 집계(설계사수/모집/유지/실지급)
  - /api/report/org: 정산월 기준 organization 조인 조직별 집계
  - /api/report/chargeback-risk: recruit_ledger 계약별 경과월 + chargeback_grade 환수율/위험등급
  - 각 read(List) + export(Cursor→xlsx). 신규 테이블 없음(읽기전용 집계)
- ExcelService.exportLargeExcel @Transactional(readOnly=true) 추가 — Cursor 가 세션 밖에서
  닫혀 모든 export(기존 agent 포함)가 500 나던 잠복버그 수정. 1줄로 export 인프라 전체 복구

검증: 3 read 200(실데이터), 3 export 200(유효 xlsx), agent export 복구 확인.
Playwright 84화면 재실행 FAIL 0 (PASS59/WARN25, WARN은 차트/폼 등 탐지한계)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:51:37 +09:00
GA Pro 81871615cd fix: Playwright 전메뉴 기능테스트로 발견한 화면 버그 4건 수정
- products: /api/products 가 bare 배열 반환 → PageResponse 정렬(PageHelper).
  contracts/rules-commission 의 products?.list.map 크래시 + products 빈목록 동시 해결
- grade-eval: gradeEvalRules 가 PageResponse(.list) 추출하도록 수정 (rowData.map 크래시)
- batch: batch.ts 를 실제 백엔드(/settlement/run body, /jobs/{id}, /jobs)로 정렬 +
  VO필드(jobId/currentStep/processedCount/finishedAt/durationSec) 어댑터 매핑

검증: Playwright 84화면 재실행 FAIL 7→3 (잔여 3건은 미구현 /api/report/*)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:32:45 +09:00
GA Pro 93ac94558f feat: PWA FCM 클라이언트 실통합 + 어댑터 키 주입 런처
- firebase SDK 통합: initPush getToken 활성화(동적 import) + isSupported 가드
- public/firebase-messaging-sw.js 백그라운드 수신 SW(웹 config 쿼리스트링 전달)
- run-api-with-adapters.sh: adapter.env.local 로드 후 ga-api bootRun
- 키 파일(adapter.env.local / .env.local)은 git-ignored 템플릿으로 별도 제공

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:56:44 +09:00
17 changed files with 1544 additions and 40 deletions
@@ -4,14 +4,13 @@ import com.ga.api.service.product.ProductService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.product.ProductResp;
import com.ga.core.vo.product.ProductVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "상품 관리")
@RestController
@RequestMapping("/api/products")
@@ -22,11 +21,13 @@ public class ProductController {
@GetMapping
@RequirePermission(menu = "PRODUCT", perm = "READ")
public ApiResponse<List<ProductResp>> list(@RequestParam(required = false) Long companyId,
@RequestParam(required = false) String insuranceType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String isActive) {
return ApiResponse.ok(productService.list(companyId, insuranceType, keyword, isActive));
public ApiResponse<PageResponse<ProductResp>> list(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long companyId,
@RequestParam(required = false) String insuranceType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String isActive) {
return ApiResponse.ok(productService.list(pageNum, pageSize, companyId, insuranceType, keyword, isActive));
}
@GetMapping("/{productId}")
@@ -0,0 +1,84 @@
package com.ga.api.controller.report;
import com.ga.api.service.report.ReportService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 리포트 — 실적 / 조직별 / 환수위험. 읽기전용 집계 (신규 테이블 없음).
* 각 화면의 조회 + 엑셀 export 엔드포인트 제공.
*/
@Tag(name = "리포트")
@RestController
@RequestMapping("/api/report")
@RequiredArgsConstructor
public class ReportController {
private final ReportService service;
private final ExcelService excelService;
private String defaultYear(String year) {
return (year != null && !year.isBlank()) ? year : String.valueOf(LocalDate.now().getYear());
}
private String defaultMonth(String settleMonth) {
return (settleMonth != null && !settleMonth.isBlank())
? settleMonth : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
// ── 실적 리포트 ──
@GetMapping("/performance")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public ApiResponse<List<PerfReportRow>> performance(@RequestParam(required = false) String year) {
return ApiResponse.ok(service.performance(defaultYear(year)));
}
@GetMapping("/performance/export")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public void performanceExport(@RequestParam(required = false) String year, HttpServletResponse response) {
String y = defaultYear(year);
excelService.exportLargeExcel("실적리포트", PerfReportRow.class,
() -> service.performanceCursor(y), response);
}
// ── 조직별 리포트 ──
@GetMapping("/org")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public ApiResponse<List<OrgReportRow>> org(@RequestParam(required = false) String settleMonth) {
return ApiResponse.ok(service.org(defaultMonth(settleMonth)));
}
@GetMapping("/org/export")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public void orgExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
String m = defaultMonth(settleMonth);
excelService.exportLargeExcel("조직별리포트", OrgReportRow.class,
() -> service.orgCursor(m), response);
}
// ── 환수위험 리포트 ──
@GetMapping("/chargeback-risk")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public ApiResponse<List<ChargebackRiskRow>> chargebackRisk() {
return ApiResponse.ok(service.chargebackRisk());
}
@GetMapping("/chargeback-risk/export")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public void chargebackRiskExport(HttpServletResponse response) {
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
() -> service.chargebackRiskCursor(), response);
}
}
@@ -2,15 +2,15 @@ package com.ga.api.service.product;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.product.ProductMapper;
import com.ga.core.vo.product.ProductResp;
import com.ga.core.vo.product.ProductVO;
import com.github.pagehelper.PageHelper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
@@ -18,8 +18,9 @@ public class ProductService {
private final ProductMapper mapper;
@Transactional(readOnly = true)
public List<ProductResp> list(Long companyId, String insuranceType, String keyword, String isActive) {
return mapper.selectList(companyId, insuranceType, keyword, isActive);
public PageResponse<ProductResp> list(int pageNum, int pageSize, Long companyId, String insuranceType, String keyword, String isActive) {
PageHelper.startPage(pageNum, pageSize);
return PageResponse.of(mapper.selectList(companyId, insuranceType, keyword, isActive));
}
@Transactional(readOnly = true)
@@ -0,0 +1,50 @@
package com.ga.api.service.report;
import com.ga.core.mapper.report.ReportMapper;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 리포트 서비스 — 기존 정산 데이터 위 읽기전용 집계.
* Cursor 변형은 엑셀 export 용 (ExcelService.exportLargeExcel 가 트랜잭션 내에서 소비).
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private final ReportMapper mapper;
@Transactional(readOnly = true)
public List<PerfReportRow> performance(String year) {
return mapper.selectPerformance(year);
}
public Cursor<PerfReportRow> performanceCursor(String year) {
return mapper.selectPerformanceCursor(year);
}
@Transactional(readOnly = true)
public List<OrgReportRow> org(String settleMonth) {
return mapper.selectOrg(settleMonth);
}
public Cursor<OrgReportRow> orgCursor(String settleMonth) {
return mapper.selectOrgCursor(settleMonth);
}
@Transactional(readOnly = true)
public List<ChargebackRiskRow> chargebackRisk() {
return mapper.selectChargebackRisk();
}
public Cursor<ChargebackRiskRow> chargebackRiskCursor() {
return mapper.selectChargebackRiskCursor();
}
}
@@ -13,6 +13,7 @@ import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
@@ -42,6 +43,7 @@ public class ExcelService {
/* =========================================================
다운로드 (Export) — SXSSF + Cursor 스트리밍
========================================================= */
@Transactional(readOnly = true)
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier,
HttpServletResponse response) {
@@ -0,0 +1,31 @@
package com.ga.core.mapper.report;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.cursor.Cursor;
import java.util.List;
/**
* 리포트 Mapper — 기존 정산 데이터 위 읽기전용 집계 (신규 테이블 없음).
* 실적/조직별 = settle_master 집계, 환수위험 = recruit_ledger + chargeback_grade.
* 각 select 는 조회용(List)과 엑셀 export 용(Cursor) 두 변형을 제공.
*/
@Mapper
public interface ReportMapper {
List<PerfReportRow> selectPerformance(@Param("year") String year);
Cursor<PerfReportRow> selectPerformanceCursor(@Param("year") String year);
List<OrgReportRow> selectOrg(@Param("settleMonth") String settleMonth);
Cursor<OrgReportRow> selectOrgCursor(@Param("settleMonth") String settleMonth);
List<ChargebackRiskRow> selectChargebackRisk();
Cursor<ChargebackRiskRow> selectChargebackRiskCursor();
}
@@ -0,0 +1,43 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 환수위험 리포트 행 — 계약별 경과월 기준 환수 가능성 분석. */
@Data
public class ChargebackRiskRow {
private Long agentId;
@ExcelColumn(header = "설계사", order = 1, width = 12)
private String agentName;
@ExcelColumn(header = "소속", order = 2, width = 18)
private String orgName;
@ExcelColumn(header = "증권번호", order = 3, width = 16)
private String policyNo;
@ExcelColumn(header = "보험사", order = 4, width = 14)
private String companyName;
@ExcelColumn(header = "모집월", order = 5, width = 10)
private String recruitMonth;
@ExcelColumn(header = "경과월", order = 6, width = 8)
private Integer monthsElapsed;
@ExcelColumn(header = "모집수수료", order = 7, width = 14)
private BigDecimal recruitAmount;
@ExcelColumn(header = "환수율", order = 8, width = 8)
private BigDecimal chargebackRate;
@ExcelColumn(header = "예상환수액", order = 9, width = 14)
private BigDecimal estimatedChargeback;
@ExcelColumn(header = "위험등급", order = 10, width = 10)
private String riskLevel;
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 조직별 리포트 행 — 정산월 기준 조직 단위 집계. */
@Data
public class OrgReportRow {
private Long orgId;
@ExcelColumn(header = "조직명", order = 1, width = 18)
private String orgName;
@ExcelColumn(header = "유형", order = 2, width = 10)
private String orgType;
@ExcelColumn(header = "설계사수", order = 3, width = 10)
private Long agentCount;
@ExcelColumn(header = "모집수수료", order = 4, width = 16)
private BigDecimal recruitTotal;
@ExcelColumn(header = "유지수수료", order = 5, width = 16)
private BigDecimal maintainTotal;
@ExcelColumn(header = "실지급합계", order = 6, width = 16)
private BigDecimal netAmount;
}
@@ -0,0 +1,26 @@
package com.ga.core.vo.report;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/** 실적 리포트 행 — 정산월별 집계 (settle_master). */
@Data
public class PerfReportRow {
@ExcelColumn(header = "정산월", order = 1, width = 12)
private String settleMonth;
@ExcelColumn(header = "설계사수", order = 2, width = 10)
private Long agentCount;
@ExcelColumn(header = "모집수수료", order = 3, width = 16)
private BigDecimal recruitTotal;
@ExcelColumn(header = "유지수수료", order = 4, width = 16)
private BigDecimal maintainTotal;
@ExcelColumn(header = "실지급", order = 5, width = 16)
private BigDecimal netAmount;
}
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.report.ReportMapper">
<!--
리포트 Mapper — 읽기전용 집계 (신규 테이블 없음).
mapUnderscoreToCamelCase=true 전제: 집계 컬럼에 snake alias 부여.
-->
<!-- 1. 실적 리포트 — 정산월별 settle_master 집계 -->
<sql id="performanceSql">
SELECT settle_month,
COUNT(DISTINCT agent_id) AS agent_count,
COALESCE(SUM(recruit_total), 0) AS recruit_total,
COALESCE(SUM(maintain_total), 0) AS maintain_total,
COALESCE(SUM(net_amount), 0) AS net_amount
FROM settle_master
WHERE settle_month LIKE #{year} || '%'
GROUP BY settle_month
ORDER BY settle_month
</sql>
<select id="selectPerformance" resultType="com.ga.core.vo.report.PerfReportRow">
<include refid="performanceSql"/>
</select>
<select id="selectPerformanceCursor" resultType="com.ga.core.vo.report.PerfReportRow" fetchSize="500">
<include refid="performanceSql"/>
</select>
<!-- 2. 조직별 리포트 — 정산월 기준 조직 단위 집계 -->
<sql id="orgSql">
SELECT o.org_id,
o.org_name,
o.org_type,
COUNT(DISTINCT sm.agent_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS recruit_total,
COALESCE(SUM(sm.maintain_total), 0) AS maintain_total,
COALESCE(SUM(sm.net_amount), 0) AS net_amount
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
WHERE sm.settle_month = #{settleMonth}
GROUP BY o.org_id, o.org_name, o.org_type
ORDER BY net_amount DESC
</sql>
<select id="selectOrg" resultType="com.ga.core.vo.report.OrgReportRow">
<include refid="orgSql"/>
</select>
<select id="selectOrgCursor" resultType="com.ga.core.vo.report.OrgReportRow" fetchSize="500">
<include refid="orgSql"/>
</select>
<!-- 3. 환수위험 리포트 — 계약별 경과월 기준 환수율 적용 -->
<sql id="chargebackRiskSql">
WITH rl AS (
SELECT contract_id,
agent_id,
MIN(policy_no) AS policy_no,
MIN(company_code) AS company_code,
MIN(settle_month) AS recruit_month,
SUM(agent_amount) AS recruit_amount
FROM recruit_ledger
GROUP BY contract_id, agent_id
)
SELECT rl.agent_id,
a.agent_name,
o.org_name,
rl.policy_no,
ic.company_name,
rl.recruit_month,
elapsed.months_elapsed,
rl.recruit_amount,
COALESCE(cg.chargeback_percent, 0) AS chargeback_rate,
ROUND(rl.recruit_amount * COALESCE(cg.chargeback_percent, 0) / 100.0) AS estimated_chargeback,
CASE WHEN COALESCE(cg.chargeback_percent, 0) >= 50 THEN 'HIGH'
WHEN COALESCE(cg.chargeback_percent, 0) >= 10 THEN 'MEDIUM'
ELSE 'LOW' END AS risk_level
FROM rl
JOIN agent a ON a.agent_id = rl.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN insurance_company ic ON ic.company_code = rl.company_code
CROSS JOIN LATERAL (
SELECT (EXTRACT(YEAR FROM CURRENT_DATE)::int * 12 + EXTRACT(MONTH FROM CURRENT_DATE)::int)
- (substr(rl.recruit_month, 1, 4)::int * 12 + substr(rl.recruit_month, 5, 2)::int) AS months_elapsed
) elapsed
LEFT JOIN chargeback_grade cg
ON cg.is_active = 'Y'
AND elapsed.months_elapsed >= cg.months_from
AND (cg.months_to IS NULL OR elapsed.months_elapsed &lt;= cg.months_to)
ORDER BY estimated_chargeback DESC
</sql>
<select id="selectChargebackRisk" resultType="com.ga.core.vo.report.ChargebackRiskRow">
<include refid="chargebackRiskSql"/>
</select>
<select id="selectChargebackRiskCursor" resultType="com.ga.core.vo.report.ChargebackRiskRow" fetchSize="500">
<include refid="chargebackRiskSql"/>
</select>
</mapper>
+51 -10
View File
@@ -29,19 +29,60 @@ export interface BatchHistoryResp {
errorMessage?: string;
}
/** 백엔드 batch_job_log VO (실제 응답 형태) */
interface BatchJobVO {
jobId: number;
jobName: string;
status: string; // REQUESTED | STARTED | COMPLETED | FAILED
currentStep?: string;
totalCount?: number;
processedCount?: number;
errorCount?: number;
errorMessage?: string;
startedAt?: number[] | string;
finishedAt?: number[] | string;
durationSec?: number;
}
/** LocalDateTime 배열([y,M,d,h,m,s,..]) → 'YYYY-MM-DD HH:mm:ss' */
function fmtTs(v?: number[] | string): string | undefined {
if (v == null) return undefined;
if (typeof v === 'string') return v;
const [y, mo, d, h = 0, mi = 0, s = 0] = v;
const p = (n: number) => String(n).padStart(2, '0');
return `${y}-${p(mo)}-${p(d)} ${p(h)}:${p(mi)}:${p(s)}`;
}
function mapStatus(s: string): BatchJobLogResp['status'] {
if (s === 'STARTED') return 'RUNNING';
if (s === 'REQUESTED' || s === 'RUNNING' || s === 'COMPLETED' || s === 'FAILED') return s;
return 'RUNNING';
}
/** 백엔드 VO → 프론트 BatchJobLogResp 어댑터 */
function toJobLog(v: BatchJobVO): BatchJobLogResp {
return {
jobLogId: v.jobId,
jobName: v.jobName,
status: mapStatus(v.status),
stepName: v.currentStep,
totalCount: v.totalCount,
successCount: v.processedCount,
errorCount: v.errorCount,
startedAt: fmtTs(v.startedAt),
endedAt: fmtTs(v.finishedAt),
durationMs: v.durationSec != null ? v.durationSec * 1000 : undefined,
errorMessage: v.errorMessage,
};
}
export const batchRunApi = {
run: (settleMonth: string, companyCode?: string) =>
unwrap<number>(
api.post('/api/batch/settlement/run', null, {
params: { settleMonth, companyCode },
}),
),
unwrap<number>(api.post('/api/batch/settlement/run', { settleMonth, companyCode })),
status: (jobLogId: number) =>
unwrap<BatchJobLogResp>(
api.get('/api/batch/settlement/status', { params: { jobLogId } }),
),
unwrap<BatchJobVO>(api.get(`/api/batch/jobs/${jobLogId}`)).then(toJobLog),
history: (size = 10) =>
unwrap<BatchHistoryResp[]>(
api.get('/api/batch/settlement/history', { params: { size } }),
unwrap<BatchJobVO[]>(api.get('/api/batch/jobs')).then((rows) =>
rows.slice(0, size).map(toJobLog) as BatchHistoryResp[],
),
};
+2 -2
View File
@@ -153,9 +153,9 @@ export const operationApi = {
hqSettleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/hq-settles/${id}`)),
// GradeEval Rules
// GradeEval Rules — 백엔드는 PageResponse({list}) 반환이므로 .list 추출
gradeEvalRules: () =>
unwrap<GradeEvalRuleRow[]>(api.get('/api/grade-evaluations/rules')),
unwrap<{ list: GradeEvalRuleRow[] }>(api.get('/api/grade-evaluations/rules')).then((r) => r.list ?? []),
gradeEvalRuleGet: (id: number) =>
unwrap<GradeEvalRuleRow>(api.get(`/api/grade-evaluations/rules/${id}`)),
gradeEvalRuleCreate: (body: Partial<GradeEvalRuleRow>) =>
+1037 -4
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -14,6 +14,7 @@
"antd-mobile-icons": "^0.3.0",
"axios": "^1.7.2",
"dayjs": "^1.11.11",
"firebase": "^12.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
@@ -0,0 +1,21 @@
/* FCM 백그라운드 메시지 수신 서비스워커 (vite-plugin-pwa SW 와 별개).
* Firebase 웹 config 는 정적 파일에 박지 않고, initPush 등록 시 쿼리스트링으로 전달받는다. */
importScripts('https://www.gstatic.com/firebasejs/12.14.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.14.0/firebase-messaging-compat.js');
const p = new URL(self.location).searchParams;
firebase.initializeApp({
apiKey: p.get('apiKey'),
projectId: p.get('projectId'),
appId: p.get('appId'),
messagingSenderId: p.get('messagingSenderId'),
});
firebase.messaging().onBackgroundMessage((payload) => {
const n = payload.notification || {};
self.registration.showNotification(n.title || '알림', {
body: n.body || '',
icon: '/pwa-192x192.png',
data: payload.data || {},
});
});
+36 -13
View File
@@ -6,11 +6,10 @@ import { pushApi } from '@/api/push';
* 활성 조건: 빌드 환경변수 `VITE_FCM_*` 가 모두 설정된 경우에만 동작한다.
* 미설정 시 no-op (개발/미연동 환경에서 안전).
*
* <b>운영 활성화 절차</b>:
* 1) Firebase 프로젝트 생성 → 웹 앱 등록 → 설정값을 .env 의 VITE_FCM_* 로 주입
* 2) `npm i firebase` 후 아래 TODO 블록의 firebase/messaging 초기화 활성화
* 3) getToken(VAPID key)으로 FCM 토큰 발급 → pushApi.register(token) 호출
* 4) public/firebase-messaging-sw.js 서비스워커 배치
* firebase SDK 통합 완료 — 활성 조건은 `.env(.local)` 의 VITE_FCM_* 주입뿐이다.
* 1) Firebase 콘솔 → 웹 앱 등록 → 설정값을 .env.local 의 VITE_FCM_* 로 주입
* 2) getToken(VAPID key)으로 FCM 토큰 발급 → pushApi.register(token) 자동 호출
* 3) public/firebase-messaging-sw.js 가 백그라운드 수신 처리
*
* 서버측(PushAdapter=fcm + FCM_PROJECT_ID/FCM_ACCESS_TOKEN)과 함께 활성화해야 실제 발송된다.
*/
@@ -38,14 +37,38 @@ export async function initPush(): Promise<void> {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// TODO(운영): `npm i firebase` 후 활성화.
// const { initializeApp } = await import('firebase/app');
// const { getMessaging, getToken } = await import('firebase/messaging');
// const appFb = initializeApp(cfg);
// const messaging = getMessaging(appFb);
// const token = await getToken(messaging, { vapidKey: cfg.vapidKey });
// if (token) await pushApi.register(token, 'WEB');
void pushApi; // 등록 API 는 토큰 발급 후 호출 (위 TODO 활성화 시)
const { isSupported, getMessaging, getToken } = await import('firebase/messaging');
if (!(await isSupported())) {
if (import.meta.env.DEV) console.info('[push] 이 브라우저는 FCM 미지원 — skip.');
return;
}
const { initializeApp, getApps } = await import('firebase/app');
const appFb = getApps().length
? getApps()[0]
: initializeApp({
apiKey: cfg.apiKey,
projectId: cfg.projectId,
appId: cfg.appId,
messagingSenderId: cfg.messagingSenderId,
});
// FCM 백그라운드 수신용 서비스워커 (vite-plugin-pwa SW 와 별개).
// 웹 config 는 정적 SW 파일에 박지 않고 쿼리스트링으로 전달.
const swQuery = new URLSearchParams({
apiKey: cfg.apiKey,
projectId: cfg.projectId,
appId: cfg.appId,
messagingSenderId: cfg.messagingSenderId ?? '',
}).toString();
const swReg = await navigator.serviceWorker.register(`/firebase-messaging-sw.js?${swQuery}`);
const messaging = getMessaging(appFb);
const token = await getToken(messaging, {
vapidKey: cfg.vapidKey,
serviceWorkerRegistration: swReg,
});
if (token) await pushApi.register(token, 'WEB');
} catch (e) {
if (import.meta.env.DEV) console.warn('[push] init 실패', e);
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# 외부연동 어댑터 키를 주입하여 ga-api 를 기동한다.
# adapter.env.local 의 값을 환경변수로 로드한 뒤 bootRun.
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -f adapter.env.local ]]; then
echo "adapter.env.local 이 없습니다. 키를 먼저 채우세요." >&2
exit 1
fi
set -a; source adapter.env.local; set +a
echo "[adapters] bank=$ADAPTER_BANK message=$ADAPTER_MESSAGE push=$ADAPTER_PUSH"
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'