From 8da5b91f6b805bfc9f187f81373deba72bb7e288 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 11 九月 2026 12:01:46 +0800
Subject: [PATCH] 基础更新

---
 rencaika/config/api.uts                                                               |    2 
 talent-card-server/src/test/java/com/talentcard/server/config/ApiCorsConfigTests.java |   96 ++++++++
 talent-card-server/README.md                                                          |    5 
 .gitignore                                                                            |    2 
 rencaika_merchant/manifest.json                                                       |   10 
 talent-card-admin/vite.config.ts                                                      |    3 
 rencaika_merchant/config/api.uts                                                      |    2 
 talent-card-server/docs/audits/2026-09-10/run-probe.ps1                               |   14 +
 talent-card-server/src/main/resources/application.yml                                 |    3 
 rencaika_merchant/package-lock.json                                                   |    6 
 rencaika/manifest.json                                                                |    5 
 talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java      |   30 ++
 talent-card-server/docs/audits/2026-09-10/PaymentFlowAuditProbe.java                  |  168 ++++++++++++++
 talent-card-server/docs/audits/2026-09-10/order-payment-withdrawal-audit.md           |  320 ++++++++++++++++++++++++++
 14 files changed, 662 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..62a324e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/.idea/
+/uploads/
diff --git a/rencaika/config/api.uts b/rencaika/config/api.uts
index c0b2f29..b6332f8 100644
--- a/rencaika/config/api.uts
+++ b/rencaika/config/api.uts
@@ -6,7 +6,7 @@
 
 // 后台提供合法域名后仅在此处填写,业务页面不得硬编码域名。
 // 微信开发者工具运行在本机,使用回环地址可避免公共网络防火墙拦截局域网转发请求。
-const developmentBaseUrl = 'http://221.182.45.100:11451'
+const developmentBaseUrl = 'http://192.168.110.26:8080'
 const stagingBaseUrl = ''
 const productionBaseUrl = ''
 
diff --git a/rencaika/manifest.json b/rencaika/manifest.json
index 959a7c4..a6e3e7a 100644
--- a/rencaika/manifest.json
+++ b/rencaika/manifest.json
@@ -65,6 +65,9 @@
 	"web": {
 		"router": {
 			"mode": ""
+		},
+		"devServer": {
+			"port": "7893"
 		}
 	}
-}
+}
\ No newline at end of file
diff --git a/rencaika_merchant/config/api.uts b/rencaika_merchant/config/api.uts
index c0c9498..14a8df7 100644
--- a/rencaika_merchant/config/api.uts
+++ b/rencaika_merchant/config/api.uts
@@ -16,7 +16,7 @@
 
 // 后台提供微信小程序合法 HTTPS 域名后,在对应环境填写完整基础地址。
 //const developmentBaseUrl = 'http://192.168.110.13:8080'
-const developmentBaseUrl = 'http://221.182.45.100:11451'
+const developmentBaseUrl = 'http://192.168.110.26:8080'
 const stagingBaseUrl = ''
 const productionBaseUrl = ''
 
diff --git a/rencaika_merchant/manifest.json b/rencaika_merchant/manifest.json
index 7a79c55..193f0ec 100644
--- a/rencaika_merchant/manifest.json
+++ b/rencaika_merchant/manifest.json
@@ -60,5 +60,13 @@
 			"icons": {},
 			"splashScreens": {}
 		}
+	},
+	"web": {
+		"router": {
+			"mode": ""
+		},
+		"devServer": {
+			"port": "7894"
+		}
 	}
-}
+}
\ No newline at end of file
diff --git a/rencaika_merchant/package-lock.json b/rencaika_merchant/package-lock.json
new file mode 100644
index 0000000..9ad1643
--- /dev/null
+++ b/rencaika_merchant/package-lock.json
@@ -0,0 +1,6 @@
+{
+  "name": "rencaika_merchant",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {}
+}
diff --git a/talent-card-admin/vite.config.ts b/talent-card-admin/vite.config.ts
index 695911b..2b00d9f 100644
--- a/talent-card-admin/vite.config.ts
+++ b/talent-card-admin/vite.config.ts
@@ -5,6 +5,9 @@
 export default defineConfig({
   plugins: [vue()],
   server: {
+    host: '0.0.0.0',
+    port: 5173,
+    strictPort: true,
     proxy: {
       '/api': {
         target: 'http://localhost:8080',
diff --git a/talent-card-server/README.md b/talent-card-server/README.md
index a9ee888..1944f51 100644
--- a/talent-card-server/README.md
+++ b/talent-card-server/README.md
@@ -37,6 +37,11 @@
 
 服务默认地址:`http://localhost:8080`
 
+HBuilderX 浏览器运行的人才端(7893)和商户端(7894)直接请求后端时,后端允许
+`http://localhost:7893`、`http://localhost:7894` 及对应的 `127.0.0.1` 来源访问 `/api/**`。
+如果使用其他前端域名或端口,通过 `CORS_ALLOWED_ORIGINS` 配置逗号分隔的完整来源地址
+(协议、主机、端口,不含路径),然后重启后端。部署时使用实际前端来源替换本机默认值。
+
 ### 本机旧库迁移修复
 
 如果启动日志提示 `V9__operation_management.sql` 失败,且原因是旧的 `movie_ticket_record` 表缺少 `package_name`,先执行一次兼容修复脚本,再重新启动服务:
diff --git a/talent-card-server/docs/audits/2026-09-10/PaymentFlowAuditProbe.java b/talent-card-server/docs/audits/2026-09-10/PaymentFlowAuditProbe.java
new file mode 100644
index 0000000..0584788
--- /dev/null
+++ b/talent-card-server/docs/audits/2026-09-10/PaymentFlowAuditProbe.java
@@ -0,0 +1,168 @@
+import java.util.Map;
+import java.util.UUID;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+import static org.mockito.Mockito.*;
+import com.talentcard.server.business.merchant.*;
+import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
+import com.talentcard.server.business.admin.MerchantSettlementAdminService;
+import com.talentcard.server.business.payment.*;
+import com.talentcard.server.business.payment.huifu.*;
+
+/** Audit-only characterization probe. REPRODUCED means an unsafe behavior exists, not a safety test passing.
+ * Uses actual service code and SQL in isolated H2; only provider calls/signature verification are mocked.
+ * Does not load application configuration, start Spring, or contact any external service.
+ */
+public class PaymentFlowAuditProbe {
+    private JdbcTemplate jdbc;
+    private TransactionTemplate tx;
+    private MerchantSettlementService settlement;
+    private HuifuMerchantWithdrawalService withdrawals;
+
+    public static void main(String[] args) throws Exception {
+        PaymentFlowAuditProbe p = new PaymentFlowAuditProbe();
+        p.multipleStoresCountSameOrder();
+        p.partialRefundDoesNotReduceAvailable();
+        p.adminFailureReleasesUnconfirmedWithdrawal();
+        p.differentKeysCreateTwoActivePayments();
+        p.expiredOrderAcceptsMoneyButRemainsCancelled();
+        p.callbackDoesNotValidateAmount();
+        p.withdrawalOverwritesPaymentSdkConfiguration();
+        System.out.println("REPRODUCED 7/7 audit scenarios (not evidence of financial safety).");
+    }
+
+    private void setup() {
+        DriverManagerDataSource ds = new DriverManagerDataSource(
+            "jdbc:h2:mem:audit_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
+        jdbc = new JdbcTemplate(ds);
+        tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
+        jdbc.execute("create table merchant_store(id bigint primary key, merchant_id bigint, status int, deleted int)");
+        jdbc.execute("create table talent_user(id bigint primary key, openid varchar(64), deleted int)");
+        jdbc.execute("""
+            create table benefit_order(id bigint primary key, order_no varchar(64), talent_id bigint,
+              merchant_id bigint, store_id bigint, pay_amount bigint, order_status int, verify_status int,
+              deleted int default 0, price_version int default 1, expires_at timestamp,
+              product_name_snapshot varchar(64), order_type varchar(32))
+            """);
+        jdbc.execute("""
+            create table merchant_withdrawal(id bigint primary key, merchant_id bigint, store_id bigint,
+              withdrawal_no varchar(64), amount_cents bigint, status varchar(32), failure_reason varchar(255),
+              reviewed_at timestamp, paid_at timestamp, provider_trade_no varchar(64),
+              provider_response_code varchar(64), provider_response_message varchar(255))
+            """);
+        jdbc.execute("""
+            create table payment_transaction(id bigint auto_increment primary key, payment_no varchar(64) unique,
+              provider varchar(32), order_id bigint, order_no varchar(64), talent_id bigint, merchant_id bigint,
+              amount bigint, status varchar(32), idempotency_key varchar(128), attempt_no int,
+              client_payment_payload varchar(2000), provider_transaction_no varchar(64),
+              provider_response_code varchar(64), provider_response_message varchar(255), paid_at timestamp,
+              unique(idempotency_key, attempt_no))
+            """);
+        jdbc.execute("""
+            create table order_refund(id bigint primary key, order_id bigint, refund_no varchar(64),
+              refund_amount bigint, refund_status int, deleted int default 0)
+            """);
+        jdbc.execute("""
+            create table payment_callback_event(provider varchar(32), provider_request_no varchar(64),
+              payload_sha256 varchar(64), signature_valid int, process_status varchar(32),
+              received_at timestamp, processed_at timestamp, unique(provider, provider_request_no))
+            """);
+        jdbc.update("insert into merchant_store values(10,1,1,0),(20,1,1,0)");
+        jdbc.update("insert into talent_user values(1,'audit-open-id',0)");
+        withdrawals = new HuifuMerchantWithdrawalService(jdbc, mock(HuifuWithdrawalGateway.class));
+        settlement = new MerchantSettlementService(jdbc, null, null, null, null, null, withdrawals, null, null, null);
+    }
+
+    private void order(Integer storeId, int state, int verified) {
+        jdbc.update("""
+            insert into benefit_order(id,order_no,talent_id,merchant_id,store_id,pay_amount,order_status,
+                verify_status,expires_at,product_name_snapshot,order_type)
+            values(1,'AUDIT-O',1,1,?,10000,?,?,dateadd('MINUTE',15,current_timestamp),'Audit product','STORE_CONSUME')
+            """, storeId, state, verified);
+    }
+    private long available(long storeId) {
+        return ((Number) settlement.withdrawalSummary(new MerchantPrincipal(1L, 1L, storeId, "OWNER"))
+            .get("availableAmountCents")).longValue();
+    }
+    private void payment(String state) {
+        jdbc.update("""
+            insert into payment_transaction(payment_no,provider,order_id,order_no,talent_id,merchant_id,
+                amount,status,idempotency_key,attempt_no)
+            values('AUDIT-P','HUIFU',1,'AUDIT-O',1,1,10000,?,'audit-key',1)
+            """, state);
+    }
+    private void paidCallback(String amount) {
+        HuifuSignatureService signature = mock(HuifuSignatureService.class);
+        when(signature.verifyCallbackPayload(anyString(), anyString())).thenReturn(true);
+        HuifuPaymentCallbackService callback = new HuifuPaymentCallbackService(jdbc, signature);
+        tx.executeWithoutResult(s -> callback.handle("{\"req_seq_id\":\"AUDIT-P\",\"trans_stat\":\"S\","
+            + "\"resp_code\":\"00000000\",\"hf_seq_id\":\"AUDIT-HF\",\"trans_amt\":\"" + amount + "\"}", "mocked-valid-signature"));
+    }
+    private void multipleStoresCountSameOrder() {
+        setup(); order(null, 1, 1);
+        require(available(10) == 10000 && available(20) == 10000, "multi-store duplication");
+        System.out.println("REPRODUCED: one 100.00 order -> store A 100.00 + store B 100.00 available.");
+    }
+    private void partialRefundDoesNotReduceAvailable() {
+        setup(); order(10, 1, 1);
+        jdbc.update("insert into order_refund values(1,1,'AUDIT-R',4000,2,0)");
+        require(available(10) == 10000, "refund not deducted");
+        System.out.println("REPRODUCED: paid 100.00, refunded 40.00 -> still 100.00 withdrawable.");
+    }
+    private void adminFailureReleasesUnconfirmedWithdrawal() {
+        setup(); order(10, 1, 1);
+        jdbc.update("insert into merchant_withdrawal(id,merchant_id,store_id,withdrawal_no,amount_cents,status) values(1,1,10,'AUDIT-W',10000,'processing')");
+        require(available(10) == 0, "initial reservation");
+        new MerchantSettlementAdminService(jdbc, withdrawals, null).reviewWithdrawal(1L, "failed", "audit manual action");
+        withdrawals.handleCallback("AUDIT-W", Map.of("resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "AUDIT-PAID"));
+        require(available(10) == 10000, "released balance after provider success");
+        require("failed".equals(jdbc.queryForObject("select status from merchant_withdrawal where id=1", String.class)), "late success ignored");
+        System.out.println("REPRODUCED: admin fails processing withdrawal; provider success ignored; 100.00 released.");
+    }
+    private void differentKeysCreateTwoActivePayments() {
+        setup(); order(10, 0, 0);
+        HuifuMiniProgramPaymentGateway gateway = mock(HuifuMiniProgramPaymentGateway.class);
+        when(gateway.createMiniProgramPayment(any())).thenReturn(Map.of("data", Map.of("trans_stat", "P", "resp_code", "00000000", "pay_info", Map.of("package", "audit"))));
+        HuifuPaymentService service = new HuifuPaymentService(jdbc, tx, gateway);
+        service.beginMiniProgramPayment("AUDIT-O", 1L, 1, "device-a");
+        service.beginMiniProgramPayment("AUDIT-O", 1L, 1, "device-b");
+        require(jdbc.queryForObject("select count(*) from payment_transaction where status='PREPAY_CREATED'", Integer.class) == 2, "two live payment attempts");
+        verify(gateway, times(2)).createMiniProgramPayment(any());
+        System.out.println("REPRODUCED: one order + two request keys -> two provider prepayments.");
+    }
+    private void expiredOrderAcceptsMoneyButRemainsCancelled() {
+        setup(); order(10, 2, 0); payment("PREPAY_CREATED"); paidCallback("100.00");
+        require("SUCCEEDED".equals(jdbc.queryForObject("select status from payment_transaction", String.class)), "payment success");
+        require(jdbc.queryForObject("select order_status from benefit_order", Integer.class) == 2, "order remains cancelled");
+        require(jdbc.queryForObject("select count(*) from order_refund", Integer.class) == 0, "no compensating refund");
+        System.out.println("REPRODUCED: expired order + valid payment success -> SUCCEEDED payment, cancelled order, no refund.");
+    }
+    private void callbackDoesNotValidateAmount() {
+        setup(); order(10, 0, 0); payment("PREPAY_CREATED"); paidCallback("0.01");
+        require(jdbc.queryForObject("select order_status from benefit_order", Integer.class) == 1, "amount mismatch accepted");
+        System.out.println("REPRODUCED: authenticated callback for 0.01 marks a 100.00 order paid (signature mocked, not bypassed).");
+    }
+    private void withdrawalOverwritesPaymentSdkConfiguration() throws Exception {
+        HuifuPaymentProperties properties = new HuifuPaymentProperties();
+        properties.setSystemId("AUDIT-SYSTEM");
+        properties.setProductId("AUDIT-PAYMENT-PRODUCT");
+        properties.setWithdrawalProductId("AUDIT-WITHDRAWAL-PRODUCT");
+        HuifuMiniProgramPaymentGateway payment = new HuifuMiniProgramPaymentGateway(properties);
+        HuifuWithdrawalGateway withdrawal = new HuifuWithdrawalGateway(properties);
+        var paymentInit = HuifuMiniProgramPaymentGateway.class.getDeclaredMethod("initializeSdk");
+        var withdrawalInit = HuifuWithdrawalGateway.class.getDeclaredMethod("initializeSdk");
+        paymentInit.setAccessible(true);
+        withdrawalInit.setAccessible(true);
+        paymentInit.invoke(payment);
+        require("AUDIT-PAYMENT-PRODUCT".equals(com.huifu.bspay.sdk.opps.core.BasePay.getConfig(null).getProductId()), "payment SDK initialized");
+        withdrawalInit.invoke(withdrawal);
+        paymentInit.invoke(payment);
+        require("AUDIT-WITHDRAWAL-PRODUCT".equals(com.huifu.bspay.sdk.opps.core.BasePay.getConfig(null).getProductId()), "payment initialization skips restoring overwritten config");
+        System.out.println("REPRODUCED: payment -> withdrawal -> payment initialization retains WITHDRAWAL product in real SDK default config.");
+    }
+    private void require(boolean value, String name) {
+        if (!value) throw new AssertionError("Observed behavior changed or probe failed: " + name);
+    }
+}
diff --git a/talent-card-server/docs/audits/2026-09-10/order-payment-withdrawal-audit.md b/talent-card-server/docs/audits/2026-09-10/order-payment-withdrawal-audit.md
new file mode 100644
index 0000000..6c972ff
--- /dev/null
+++ b/talent-card-server/docs/audits/2026-09-10/order-payment-withdrawal-audit.md
@@ -0,0 +1,320 @@
+# 订单、支付、核销、结算与提现链路审计
+
+审计日期:2026-09-10。对象:当前工作区代码,包含用户尚未提交的工作区状态;不是针对某个历史版本的差异审查。
+
+**结论:正常流程有实现,但资金闭环不完整,存在重复计算可提现额、退款后额度不扣减、外部出款与本地事务不一致等高风险缺口。不能把“页面显示成功”作为账务正确或资金到账的证明。**
+
+本次没有修改业务代码。编译成功;使用实际 Service、实际 SQL、隔离 H2 数据库及当前汇付 SDK,复现了 7 个异常场景。网关请求和回调验签被模拟,没有向汇付发起支付、退款或提现,没有读取生产账单。H2 结果不能替代 MySQL 并发测试或真实支付机构联调。下文明确区分“已复现”“静态确认”和“需环境核实”。
+
+## 一、实际业务流程
+
+```mermaid
+flowchart TD
+    A[人才端购买商品] --> B[服务端校验人才卡、商品、优惠规则]
+    M[商户扫描人才码并录入消费金额] --> C[生成到店消费订单]
+    B --> D[保存订单价格快照]
+    C --> D
+    D --> E{应付金额为零}
+    E -->|商品零元单| F[直接已支付,待核销]
+    E -->|付费订单| G[待支付,15分钟有效]
+    G --> H[保存支付流水,再请求汇付预支付]
+    H --> I[人才端调起微信支付]
+    I --> J[汇付验签回调或主动查询]
+    J --> K[本地支付成功]
+    K --> L{订单类型}
+    L -->|到店消费| N[自动设为已核销]
+    L -->|团购等| O[商户扫描订单码并确认核销]
+    F --> O
+    N --> P[汇总已支付且已核销订单实付]
+    O --> P
+    P --> Q[减去待处理、处理中及已支付提现金额]
+    Q --> R[展示本地可提现金额]
+    S[商户进件、业务开通、银行卡获批] --> T[提交提现]
+    R --> T
+    T --> U[写提现记录,立即请求汇付出款]
+    U --> V[回调或查询确认 processing、paid、failed]
+    K --> W[后台发起退款]
+    W --> X[退款流水 processing]
+    X --> Y[汇付成功,全额退款将订单改为取消]
+```
+
+图中是本地业务状态流转;汇付实际收款账户如何获得各门店可提现资金,当前代码没有形成完整桥接,见问题 1。商户手工录入的零元到店单先保持待支付,仍需走零元支付确认分支;与商品零元单创建即已支付的行为不同。
+
+### 1. 下单与计价
+
+人才端:`POST /api/app/orders`,Controller 从登录会话取人才 ID。`AppOrderPortalService.create()` 校验个人卡有效期、商品所属商户、上架及审核状态、库存是否为零;根据卡等级、商品权益配置和免费次数计算价格,再校验优惠券。
+
+以原价 100 元、人才价 80 元、优惠券 10 元为例:
+
+| 字段 | 保存值(分) | 含义 |
+|---|---:|---|
+| `order_amount` | 10000 | 原价快照 |
+| `base_pay_amount` | 8000 | 使用优惠券前的应付金额 |
+| `coupon_amount` | 1000 | 优惠券实际抵扣 |
+| `pay_amount` | 7000 | 人才实际应付 |
+| `subsidy_amount` | 3000 | 原价减实付,包括商家让利和券抵扣,并不必然全部是政府补贴 |
+
+付费单保存为 `order_status=0`、`verify_status=0`,有效期 15 分钟;商品零元单直接 `order_status=1`。商品、商户联系信息、价格、卡等级有部分快照,但门店和政府补贴规则快照不完整。
+
+商户端还有 `ManualConsumptionService.create()`:商户扫描人才凭证后选择规则、录入消费金额、生成 `STORE_CONSUME` 订单;该路径保存具体 `store_id`,并按核销尝试 ID 防重复。
+
+### 2. 发起支付
+
+人才端调用 `POST /api/app/pending-orders/{orderNo}/payment`,带会话、价格版本和幂等键。
+
+`HuifuPaymentService` 在事务里锁订单,核对归属、待支付状态、版本、过期时间及 OpenID,生成 `payment_transaction`。提交本地事务后才调用汇付。成功获得预支付参数后,前端 `uni.requestPayment` 调起微信支付。
+
+支付流水主要状态:`CREATED → PREPAY_CREATED → SUCCEEDED`;明确创建失败为 `CREATE_FAILED`,结果不明为 `CREATE_UNKNOWN`,用户取消后可变为 `CLOSED`。同一幂等键可以按 `attempt_no` 重试。
+
+### 3. 确认支付与取消
+
+`HuifuPaymentCallbackService` 验证回调签名,保存回调事件,再更新支付流水和订单。到店消费支付成功时同时设 `verify_status=1`;其他类型需要商户后续核销。
+
+前端通过查询接口读取本地状态,微信客户端成功本身不直接改订单。后台定时对部分悬而未决的支付进行汇付查询。但回调和补查使用两份不同的状态更新逻辑,已经出现不一致。
+
+订单超时由定时器或读取/下单路径改为取消。手动取消只关闭本地支付流水,没有调用汇付关单。`order_status=2` 同时表示取消与全额退款,单看该字段不能区分资金事实。
+
+### 4. 核销
+
+团购等已支付订单通过动态订单码核销。商户侧有门店/商户校验、核销尝试锁、核销码状态更新及订单状态条件更新,正常重复核销有一定保护。
+
+到店消费则依赖支付成功时自动核销。免费入住券另有无 `benefit_order` 的核销记录及线下补贴路径,不能将所有免费权益都视为普通付费订单。
+
+### 5. 结算与可提现
+
+“结算统计”按订单实付加政府补贴展示应结算额;“可提现”走另一套公式:
+
+```text
+可提现 = max(0,
+    当前商户及门店已支付、已核销订单 pay_amount 合计
+    - 当前门店 pending / processing / paid 提现 amount_cents 合计)
+```
+
+这里没有独立的账户余额、入账流水、退款冻结、待清算资金、手续费扣除或负债记录。`frozenAmountCents` 实际包含历史已打款提现,不只是正在冻结的资金。
+
+### 6. 进件、银行卡与提现
+
+商户提交个人/企业进件及初始银行卡,调用汇付进件、微信配置和业务开通等网关,回调推进审批状态;银行卡记录存汇付 `token_no`,进件记录存 `provider_huifu_id`。
+
+提现入口校验门店启用、进件已批准、银行卡属于该门店且已批准、请求金额不超过本地可提额。写入 `merchant_withdrawal` 后,**当前实现立即调用 `dispatch()`,不是先等后台人工审核**。
+
+`dispatch()` 将提现改为 `processing`,使用门店进件的汇付 ID 和银行卡 token 出款;同步返回或异步回调确认 `paid`/`failed`,结果不明保持 `processing`。另有每次最多 100 条的提现补查任务。
+
+### 7. 退款
+
+后台 `OrderService.refund()` 转到 `HuifuRefundService`。退款前锁订单、选取最近一笔成功支付,限制退款金额;一般已核销单禁止退款,但 `STORE_CONSUME` 例外。先提交退款流水,再调用汇付退款。
+
+退款成功时更新 `order_refund`;累计成功退款达到实付才将订单改为取消。部分退款不修改原始 `pay_amount`,提现公式也不减退款。已退款或存在处理中退款后,当前逻辑禁止再次提交退款,包括首笔部分退款后退剩余金额。
+
+## 二、优先处理的资金问题
+
+### 1. 收款与提现使用不同账户,缺少资金归集到门店的闭环
+
+**静态确认,实际后果依赖汇付配置。** [支付网关第 42 行][payment-gateway]统一使用 `properties.getMerchantId()` 收款;[提现网关第 32 行][withdrawal-gateway]使用命令中的门店 `huifuId` 出款。支付命令未携带按订单确定的门店汇付账户,未找到分账、划拨或核销后确认入账实现。
+
+因此,“平台已收到钱”“门店汇付账户有余额”“本地可提现”是三个不同事实。若实际没有外部自动分账或另行入金,就可能本地显示可提,门店汇付余额却不足。需要核实汇付签约模型与实际账单;不能单靠把提现额度算正确解决。
+
+修复方向:确定平台统一收款后分账,或门店直接收款的模式;按订单保存实际收款方、结算方、分账/划拨流水及清算结果。补贴和手续费也必须明确出资方与入账依据。
+
+### 2. 一笔订单被多个门店重复计入可提现额
+
+**已复现。** [人才端下单][app-create]没有写 `store_id`;[商户核销第 201 行][verify-order]只改核销状态,仍不回填订单门店;[提现汇总第 253 行][withdrawal-summary]却使用 `(store_id is null or store_id = 当前门店)`。
+
+例:同一商户两个门店,一笔没有门店归属的已核销 100 元订单,两个门店各显示可提现 100 元。提现扣减又各按门店独立计算,导致重复出款额度。实际能否多出款取决于各汇付账户余额,但本地重复额度已确定。
+
+修复方向:核销后将唯一的结算门店固化到结算流水,或明确按商户统一结算;不得让无门店订单进入每个门店的余额。
+
+### 3. 并发提现可以依据同一笔旧余额各自通过
+
+**静态确认,未做 MySQL 多连接压测。** [createWithdrawal 第 275 行][withdrawal-create]先普通汇总读取余额,再插入提现;没有锁定统一账户余额,也没有带余额条件的原子扣减。不同幂等键只会生成不同提现,不会互斥。
+
+例:可提 100 元,两个请求先后读到 100,各申请 80。即使后面的 `prepare()` 因进件/银行卡锁而串行,后一个请求也没有在拿锁后重算余额,旧判断仍可能被使用。`@Transactional` 本身不能代替额度原子扣减。
+
+修复方向:按明确的结算账户原子冻结额度,余额检查、冻结流水、提现任务入库必须同一事务。
+
+### 4. 退款与提现互不冻结,部分退款后仍能按原实付提现
+
+**部分退款余额错误已复现;提现与退款交错风险静态确认。** [退款准备第 120 行][refund-prepare]允许已核销到店单退款,也不检查该收入是否已提现。退款处理中订单仍可进入提现统计;部分退款成功仍保留原 `pay_amount`。
+
+例:实付 100,退 40,仍显示可提 100。另一种情况是商户已提现 100,随后用户获得退款 100;订单移出收入汇总,可提额通过 `max(0, ...)` 显示为 0,但系统没有明确记录商户欠款、追偿或风险告警。后续收入可能被旧提现合计隐式抵扣,账务依据不清楚。
+
+修复方向:退款申请先冻结应结算资金;成功后冲正,失败才解冻。对已结算资金形成负余额/追偿账,不能只隐藏为零。
+
+### 5. 人工将处理中提现标记失败,会释放可能已经出款的额度
+
+**已复现。** [后台审核][withdrawal-review]允许把 `pending` 或 `processing` 直接改成 `failed`;额度公式不再扣这笔钱。[提现成功处理][withdrawal-complete]只接受 `pending/processing`,所以后续汇付成功回调也不能把 `failed` 改正。补查只扫 `processing`,同样失去修复机会。
+
+复现:冻结 100 → 后台标失败 → 可提恢复 100 → 汇付返回成功 → 本地仍失败、可提仍 100。
+
+修复方向:已发往通道且结果未定的提现,不允许人工直接释放额度;人工处理应触发核查流程,以可靠终态证据驱动账务。
+
+### 6. 提现外部请求发生在本地大事务提交之前
+
+**静态确认。** [createWithdrawal][withdrawal-create]标注 `@Transactional`,写提现后在同一调用内执行外部出款;失败后数据库回滚并不能撤销汇付已受理的提现。
+
+例:汇付接单成功,随后本地写状态失败、查询失败、数据库连接断开或进程退出,整笔本地事务可能没提交;用户再次提交可能使用新提现号。回调找不到本地记录,补查也没有记录可查。
+
+此外 `HuifuMerchantWithdrawalService.dispatch()` 同类直接调用 `prepare()`,后者的 `@Transactional` 在独立后台调用时不会自动经代理生效。商户提交路径反而由外层事务覆盖,形成两种不同的事务语义。
+
+修复方向:短事务原子冻结并保存持久出款任务,提交后再发通道请求,结果用另一个短事务落库;所有重试沿用已落库的通道请求标识。
+
+### 7. 后台仍有跳过真实支付、直接置为已支付的入口
+
+**静态确认。** [OrderService.pay 第 65 行][admin-pay]仅更新 `order_status=1`,不要求支付流水或汇付结果。该订单再被核销便进入提现公式。
+
+这不是匿名接口:`AdminAuthWebConfig` 保护 `/api/orders/**`。但 `AdminAuthInterceptor.page()` 没有匹配 `/api/orders/**`,因此这一路径只要求有效后台登录,没有对应订单功能权限检查。人才端旧 `/pay` 虽然已禁用,不能据此推断全部手动支付入口都禁用了。
+
+修复方向:移除一般业务可调用的直接置付接口;如确需线下补录,采用独立资金来源、凭证、权限及审核流程,不把无依据订单作为线上可提现收入。
+
+## 三、支付及业务一致性问题
+
+### 8. 本地取消/超时没有通道关单,晚到成功没有退款补偿
+
+**超时分支已复现;主动取消分支静态确认。** [PendingOrderPortalService.cancel 第 215 行][cancel]仅将本地支付流水置 `CLOSED`;超时仅将订单置取消。[回调第 57 行][payment-callback]对超时单可能把支付流水改成功,却因订单已经取消而更新零行,仍记录处理完成。
+
+主动取消场景则忽略 `CLOSED` 流水的成功通知。两种情况均没有自动退款或必须处理的差错账。退款入口又要求订单 `order_status=1`,因此取消订单的真实收款无法直接走正常退款。
+
+修复方向:关单中状态、通道关单与主动查单、成功事实落账、取消后成功的退款补偿,以及差错告警必须共同存在。
+
+### 9. 支付防重依赖请求键,未保证一个订单只有一个有效支付
+
+**已复现。** [HuifuPaymentService 第 111 行][payment-prepare]按 `idempotency_key` 查已有流水,未按订单阻止另一个键创建有效流水。一次订单、两个键,实际调用两次网关创建预支付。
+
+当前官方前端按“订单号+价格版本”生成固定键,降低普通重复点击概率;但不同客户端、价格版本变化或直接调用接口仍能触发。两笔都成功时没有多收款退回流程,退款又只选择最近成功支付。
+
+修复方向:幂等同时覆盖业务订单和请求重试;只允许单一可成功支付意图,对多笔成功按资金事实入账并补偿。
+
+### 10. 回调验签后不比对金额与收款主体
+
+**金额不一致接受行为已复现。** [回调第 44 行][payment-callback]只查流水 ID、订单 ID、状态,未取金额比对。隔离测试中,模拟“签名已通过”的 0.01 元成功回调,可以将 100 元订单改为已支付。
+
+这不代表攻击者能伪造汇付签名;测试明确模拟验签成功。问题是合法来源的数据仍可能因配置、串单或上下游缺陷与本地业务不匹配,当前缺少第二层核对。提现回调也未核对金额、汇付账户、银行卡/通道关联信息。
+
+修复方向:校验可由通道提供的请求号、金额、币种、商户账户、通道流水及原交易关系;不匹配的成功事实进入异常账,不自动履约。
+
+### 11. 主动补查成功后,到店订单没有自动核销
+
+**静态确认。** [PaymentReconciliationService 第 54 行][payment-reconcile]更新支付成功、订单已支付,但未像回调那样更新 `STORE_CONSUME.verify_status=1`。随后回调因为流水已经 `SUCCEEDED`,走幂等分支,也不会补核销。
+
+结果:正常回调的到店单可以提现,漏回调后查单成功的同类订单可能长期不能计入可提现。
+
+修复方向:回调、查询与人工核查共用一个幂等的成功处理入口,并能修复部分已成功状态。
+
+### 12. 改券接口缺少身份校验,还可能与支付准备发生竞态
+
+**静态确认。** [PendingOrderPortalController 第 46 行][coupon-controller]的改券接口仅使用请求体 `talentId`,未调用会话校验;读取详情/可用券同样如此,前端明确使用 `skipAuth: true`。应用当前也没有覆盖这些路径的统一人才鉴权拦截器。
+
+并发上,改券先查无支付流水,再条件更新订单;支付准备锁订单与落流水可以在中间穿插,改券更新没有再次核对有效支付流水。已有 `CREATE_UNKNOWN` 也不在改券阻止列表中,通道结果不明时可改变金额、版本并产生另一笔支付。
+
+修复方向:全链路从会话获取人才身份,按订单统一加锁,支付意图存在或结果不明时禁止改价,检查更新行数。
+
+### 13. 商品订单幂等键未使用、库存未扣减、免费次数非原子
+
+**静态确认。** [AppOrderPortalService.create][app-create]接收 `rawIdempotencyKey` 但未存入 `client_idempotency_key`;仅查同商品同券的待支付单复用。零元单立即已支付、付费单支付完成后,都无法依靠这段查询阻止同一请求重试创建新订单。现有测试只验证“尚待支付时重复调用”,不能证明完整请求幂等。
+
+库存只是检查 `stock != 0`,该商品购买链路未发现冻结/扣减;免费次数先计数再插入,没有额度锁,并发请求可共用最后一次免费资格。
+
+优惠券这里按模板规则使用,代码注释明确允许支付后再买,故不能仅凭“券重复使用”判定缺陷;若业务要求领券后每人限次,需要另行确认并接入领取/消耗账。
+
+修复方向:请求幂等结果持久化、校验请求摘要;按业务需要建立库存和免费权益原子预占、释放及核销机制。
+
+### 14. 退款处理中仍可核销;首笔部分退款后不能再退剩余金额
+
+**静态确认。** 退款只新增退款记录,未冻结履约;[商户核销][verify-order]仅判断订单支付和核销状态,不检查处理中退款。退款请求提交后、通道回调前,可以核销并计入可提现,之后退款再成功。
+
+[退款第 125 行][refund-prepare]发现任一 `refund_status in (1,2)` 就阻止新退款。实付 100、已退 40 的订单不能再正常退 60,与后面的剩余可退款金额计算并不一致。
+
+修复方向:退款申请与核销用相同订单/权益锁并互斥,部分退款按已成功加处理中金额控制,而不是禁止所有后续退款。
+
+## 四、较隐蔽的跨模块问题
+
+### 15. 提现会污染全局 SDK 配置,后续支付可能用错产品号
+
+**使用当前实际 SDK 已复现。** [支付网关第 108 行][payment-sdk]仅初始化一次,之后 `initialized=true` 直接返回;[提现网关][withdrawal-sdk]每次请求都用提现产品号调用 `BasePay.initWithMerConfig()`。两个类各用自己的锁,不能隔离 SDK 的全局配置。
+
+对项目实际依赖 `dg-java-sdk 3.0.40` 的字节码检查确认:`initWithMerConfig` 写静态 `configMap["default"]`,未指定配置名的请求读取该默认配置。
+
+隔离复现顺序:支付初始化为产品 A → 提现初始化为产品 B → 再执行支付初始化,默认产品仍为 B。支付和提现产品号不同的部署中,一次提现后即可影响后续支付/查询/退款;并发还可能造成交错。无需高并发也能触发,是“单测各自正常,合起来异常”的典型问题。
+
+修复方向:使用 SDK 命名配置并在每次请求明确传入配置名,或统一封装真正隔离的客户端;仅加各类自己的锁无效。
+
+### 16. 政府补贴用当前配置重算,历史应结算额会漂移
+
+**静态确认。** [GovernmentSubsidySql][subsidy-sql]读取当前商品 `rights_config`、商户 `merchant_rights_config`、优惠券 `government_subsidy`、人才当前属地,而不是下单时完整快照。
+
+例如,历史订单原本不享受政府补贴,后来开启商品/优惠券补贴开关,历史报表可能把旧优惠计为政府应承担金额;关闭开关或软删除商品/券又可能让历史补贴消失。卡等级虽已有快照,仍不足以固定补贴结果。
+
+此外报表“应结算=实付+政府补贴”,提现只按实付,免费入住注释又明确采用线下补贴;需要分开展示线上可提现和线下待结算补贴,不能给出一个含混的余额。
+
+修复方向:订单/核销时固化补贴出资方、规则版本、实际补贴金额和结算方式;历史账通过明确调整单修正,不能由当前配置重新定义。
+
+### 17. 补查队列可能长期只查前 100 条,且不覆盖资金终态差错
+
+**静态确认。** 支付、退款、提现补查均按最早 ID 取前 100 条,没有轮转游标或下一次查询时间。最早一批长期 pending/未知即可挡住后续记录。支付任务默认关闭,提现默认开启,部署时还需核实实际开关。
+
+补查只覆盖特定中间态,不核对成功支付与订单取消、提现失败与通道成功、订单金额与实际金额,也没有通道账单对账。`UNMATCHED` 回调记录仅存摘要并已确认接收,缺少明确重放补偿任务。
+
+修复方向:公平分页、退避调度、每条异常隔离、积压告警;增加交易事实与订单/结算账/通道账单多方核对。
+
+### 18. 日期与流水号的低概率问题需补专项测试
+
+**日期为待通道协议确认的风险;流水号结构静态确认。** 退款查询传 `refundNo`,但请求日期沿用原支付日期([HuifuRefundGateway 第 58 行][refund-query]);支付记录创建时间与实际请求日期也可能跨午夜。如果通道退款查询按退款申请日定位,跨日退款会查错,需要以实际接口契约和脱敏样例验证,不在本次中断言真实通道一定查错。
+
+`BusinessIdGenerator` 是秒级时间戳加 9000 种四位随机后缀,同秒同前缀并非严格唯一;数据库唯一约束可阻止落库重复,但有些路径未重试,可能在业务量上升时出现偶发创建失败。
+
+修复方向:持久化实际通道请求日期及原交易日期两个独立字段;使用足够强且可追踪的请求编号方案,并对唯一冲突做有界重试。
+
+## 五、修复顺序与验收条件
+
+1. **先守住资金边界**:明确收款/结算账户路径,修复多门店归属、并发冻结、退款与提现互斥、处理中提现错误释放,关闭无依据的手动置付入口。
+2. **再统一交易状态机**:外部请求移到本地任务提交之后;统一回调/查单成功处理;支持取消后成功补偿、重复收款补偿;校验金额和主体;隔离 SDK 配置。
+3. **固化账务与权益事实**:增加结算流水、补贴规则快照、手续费及负债记录;完善订单幂等、库存和免费额度。
+4. **补齐对账与压测**:MySQL 多连接并发、断网/超时/进程退出/数据库回滚、乱序/重复回调、跨日退款、100 条以上积压、公私账户及多门店实账联调。
+
+关键验收不变量:同一收入只能分配给一个结算主体;同一额度不能冻结两次;实际退款加净结算不能无依据超过资金来源;未知结果不释放资金;任何成功资金事实都能定位本地凭证;配置变化不改写历史账;查单和回调最终生成相同业务结果。
+
+## 六、复现方式与结果
+
+在项目内执行:
+
+```powershell
+& 'E:/YeGproject/rck/talent-card/talent-card-server/docs/audits/2026-09-10/run-probe.ps1'
+```
+
+该脚本先编译当前业务代码,生成测试依赖 classpath,然后运行本目录的 `PaymentFlowAuditProbe.java`。仅使用内存数据库与模拟网关;SDK 配置测试只运行本地初始化,没有联网。文件未加入正常测试集,因为它断言的是当前缺陷现象,修复后应改为安全不变量回归测试。
+
+实际输出:
+
+```text
+REPRODUCED: one 100.00 order -> store A 100.00 + store B 100.00 available.
+REPRODUCED: paid 100.00, refunded 40.00 -> still 100.00 withdrawable.
+REPRODUCED: admin fails processing withdrawal; provider success ignored; 100.00 released.
+REPRODUCED: one order + two request keys -> two provider prepayments.
+REPRODUCED: expired order + valid payment success -> SUCCEEDED payment, cancelled order, no refund.
+REPRODUCED: authenticated callback for 0.01 marks a 100.00 order paid (signature mocked, not bypassed).
+REPRODUCED: payment -> withdrawal -> payment initialization retains WITHDRAWAL product in real SDK default config.
+REPRODUCED 7/7 audit scenarios (not evidence of financial safety).
+```
+
+本次没有验证线上是否已经发生损失。尚未发现更多问题,不等于证明不存在;特别是实际清算、补贴拨付、手续费和通道账户关系,需要结合合同配置与脱敏账单继续核实。
+
+[payment-gateway]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java:42
+[withdrawal-gateway]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java:32
+[app-create]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java:33
+[verify-order]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java:201
+[withdrawal-summary]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java:253
+[withdrawal-create]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java:268
+[refund-prepare]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java:120
+[withdrawal-review]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java:60
+[withdrawal-complete]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/merchant/HuifuMerchantWithdrawalService.java:83
+[admin-pay]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java:65
+[cancel]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java:215
+[payment-callback]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentCallbackService.java:44
+[payment-prepare]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java:111
+[payment-reconcile]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java:54
+[coupon-controller]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java:46
+[payment-sdk]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java:108
+[withdrawal-sdk]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java:71
+[subsidy-sql]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/mapper/GovernmentSubsidySql.java:13
+[refund-query]: E:/YeGproject/rck/talent-card/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuRefundGateway.java:58
+
diff --git a/talent-card-server/docs/audits/2026-09-10/run-probe.ps1 b/talent-card-server/docs/audits/2026-09-10/run-probe.ps1
new file mode 100644
index 0000000..564bc8d
--- /dev/null
+++ b/talent-card-server/docs/audits/2026-09-10/run-probe.ps1
@@ -0,0 +1,14 @@
+$ErrorActionPreference = 'Stop'
+$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path
+Push-Location $serverRoot
+try {
+    & mvn -q -DskipTests compile dependency:build-classpath '-Dmdep.outputFile=target/payment-flow-audit/classpath.txt' '-Dmdep.includeScope=test'
+    if ($LASTEXITCODE -ne 0) { throw 'Maven preparation failed' }
+    $auditCp = (Get-Content 'target/payment-flow-audit/classpath.txt' -Raw).Trim()
+    & javac -encoding UTF-8 -cp "target/classes;$auditCp" -d target/payment-flow-audit (Join-Path $PSScriptRoot 'PaymentFlowAuditProbe.java')
+    if ($LASTEXITCODE -ne 0) { throw 'Audit probe compilation failed' }
+    & java -cp "target/payment-flow-audit;target/classes;$auditCp" PaymentFlowAuditProbe
+    if ($LASTEXITCODE -ne 0) { throw 'Audit probe failed or observed behavior changed' }
+} finally {
+    Pop-Location
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java b/talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java
new file mode 100644
index 0000000..1cde454
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java
@@ -0,0 +1,30 @@
+package com.talentcard.server.config;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+@Configuration
+public class ApiCorsConfig {
+    @Bean
+    public CorsFilter apiCorsFilter(@Value("${app.cors.allowed-origins:}") String allowedOrigins) {
+        CorsConfiguration cors = new CorsConfiguration();
+        cors.setAllowedOrigins(Arrays.stream(allowedOrigins.split(","))
+                .map(String::trim).filter(origin -> !origin.isEmpty()).toList());
+        cors.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+        cors.setAllowedHeaders(List.of("Content-Type", "Authorization", "X-Request-Id",
+                "X-Client-Platform", "X-Client-Version", "X-Merchant-Store-Id", "X-Idempotency-Key"));
+        cors.setMaxAge(3600L);
+
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        source.registerCorsConfiguration("/api/**", cors);
+        // Complete browser preflight before MVC authentication interceptors run.
+        return new CorsFilter(source);
+    }
+}
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index 9b7d5bc..0b2a8e2 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -37,6 +37,9 @@
           time_zone: Asia/Shanghai
 
 app:
+  cors:
+    # HBuilderX Web development origins; override with exact deployed frontend origins.
+    allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894}
   privacy:
     # Production must provide APP_PRIVACY_ENCRYPTION_KEY through the deployment secret store.
     encryption-key: ${APP_PRIVACY_ENCRYPTION_KEY:talent-card-local-development-key}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/config/ApiCorsConfigTests.java b/talent-card-server/src/test/java/com/talentcard/server/config/ApiCorsConfigTests.java
new file mode 100644
index 0000000..957a2bd
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/config/ApiCorsConfigTests.java
@@ -0,0 +1,96 @@
+package com.talentcard.server.config;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringJUnitConfig({ApiCorsConfig.class, ApiCorsConfigTests.TestMvcConfig.class})
+@WebAppConfiguration
+@TestPropertySource(properties = "app.cors.allowed-origins=http://localhost:7894,http://127.0.0.1:7893")
+class ApiCorsConfigTests {
+    @Autowired
+    private WebApplicationContext context;
+
+    private MockMvc mvc;
+
+    @BeforeEach
+    void setup() {
+        mvc = MockMvcBuilders.webAppContextSetup(context)
+                .addFilters(context.getBeansOfType(CorsFilter.class).values().toArray(CorsFilter[]::new))
+                .build();
+    }
+
+    @Test
+    void merchantPreflightAllowsLoginAndAuthenticatedHeaders() throws Exception {
+        mvc.perform(options("/api/merchant/auth/login")
+                        .header("Origin", "http://localhost:7894")
+                        .header("Access-Control-Request-Method", "POST")
+                        .header("Access-Control-Request-Headers", "content-type,x-request-id,x-client-platform,x-client-version,authorization,x-merchant-store-id,x-idempotency-key"))
+                .andExpect(status().isOk())
+                .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:7894"));
+    }
+
+    @Test
+    void authenticationErrorsRemainReadableByAllowedFrontend() throws Exception {
+        mvc.perform(post("/api/merchant/auth/login").header("Origin", "http://localhost:7894"))
+                .andExpect(status().isUnauthorized())
+                .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:7894"));
+    }
+
+    @Test
+    void talentFrontendCanPreflightProtectedEndpoint() throws Exception {
+        mvc.perform(options("/api/merchant/auth/login")
+                        .header("Origin", "http://127.0.0.1:7893")
+                        .header("Access-Control-Request-Method", "POST"))
+                .andExpect(status().isOk())
+                .andExpect(header().string("Access-Control-Allow-Origin", "http://127.0.0.1:7893"));
+    }
+
+    @Test
+    void unlistedOriginIsRejected() throws Exception {
+        mvc.perform(post("/api/merchant/auth/login").header("Origin", "https://untrusted.example"))
+                .andExpect(status().isForbidden())
+                .andExpect(header().doesNotExist("Access-Control-Allow-Origin"));
+    }
+
+    @Test
+    void nativeClientStillReachesAuthenticationWithoutOrigin() throws Exception {
+        mvc.perform(post("/api/merchant/auth/login"))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Configuration
+    @EnableWebMvc
+    static class TestMvcConfig {
+        @Bean
+        ProtectedEndpoint endpoint() {
+            return new ProtectedEndpoint();
+        }
+    }
+
+    @RestController
+    static class ProtectedEndpoint {
+        @PostMapping("/api/merchant/auth/login")
+        ResponseEntity<Void> login() {
+            return ResponseEntity.status(401).build();
+        }
+    }
+}

--
Gitblit v1.7.1