yeg
2026-09-15 9d994ea0eee51dbc87fe83ead985580545926b8a
feat(merchant): 完善存量商户微信开通进度闭环
2个文件已添加
12个文件已修改
796 ■■■■■ 已修改文件
.scratch/merchant-wechat-onboarding/config-query-doc.md 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/official-contracts-2026-09-15.md 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/settlement.uts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/config-query-doc.md
New file
@@ -0,0 +1,145 @@
## 微信配置查询
*最近更新时间:2024.12.30*
### 应用场景
提供微信商户配置查询接口。
**适用对象**
开通微信公众号和小程序支付的商户。
### 接口说明
* 请求URL:https://api.huifu.com/v2/merchant/busi/config/query
* 请求方式:POST
* 支持格式:JSON
* 加签验签说明:请参考[接入指引-开发指南](https://paas.huifu.com/open/doc/guide/#/api_v2jqyq)
### 公共参数
* 公共请求参数
| 参数       | 中文名   | <div style="width: 40pt">定义</div>     | <div style="width: 25pt">长度</div> | 必填 | 说明                                                         |
| :--------- | :------- | :----- | :------- | :--- | :-------------------------- |
| sys_id     | 系统号   | String | 32       | Y    | 渠道商或商户的huifu_id; <br />(1)当主体为渠道商时,此字段填写渠道商huifu_id; <br/>(2)当主体为总部商户时,此字段填写商户huifu_id |
| product_id | 产品号   | String | 32       | Y    | 汇付分配的产品号,<font color="green">示例值:YYZY</font>                                      |
| sign       | 加签结果 | String | 512      | Y    | [接口加签验签说明](https://paas.huifu.com/open/doc/guide/#/api_v2jqyq) |
| data       | 数据     | Json   |          | Y    | 业务请求参数,具体值参考API文档                              |
* 公共返回参数
| 参数 | 中文名         | 定义   | 长度 | 必填 | 说明                 |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| sign | 签名           | String | 512  | Y    | 签名,对报文整体签名 |
| data | 交易响应内容体 | String |      | N    | 返回参数 json格式    |
### **请求参数**
<style>
table th:first-of-type {
    width: 17%;
}
table th:nth-of-type(2) {
    width: 15%;
}
table th:nth-of-type(3) {
    width: 8%;
}
table th:nth-of-type(4) {
    width: 5%;
}
table th:nth-of-type(5) {
    width: 5%;
}
table th:nth-of-type(6) {
    width: 49%;
}
</style>
**data**
| 参数            | 中文名     | 定义  | 长度 | 必填  | 说明             |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| req_seq_id      | 请求流水号 | String  | 32 | Y     |<font color="green">示例值:rQ2021121311173944134649875651</font> |
| req_date        | 请求时间   | String  | 8 | Y    | 格式yyyyMMdd,<font color="green">示例值:20210228</font>  |
| huifu_id| 汇付客户Id | String | 18   | Y    |渠道与一级代理商的直属商户ID;<font color="green">示例值:6666000123123123</font>|
| bank_channel_no | 渠道号     | String    | 32| N | <font color="green">示例值:JQF00001</font>;参见[支付渠道号说明](https://paas.huifu.com/open/doc/api/#/czsm/api_czsm_bankChannelNo)  |
##### 请求示例
```json
{
        "sys_id": "6666000108854952",
        "product_id": "YYZY",
        "data": {
        "req_seq_id": "202211111668073286240",
        "req_date": "20221111",
        "huifu_id": "6666000108854952",
        "bank_channel_no": ""
    },
        "sign": "Z1wmTfpNdQcDLVHaF4bKbyjTC+ZhIkRnQS+4G+W2EJTHRTNi5ymjJreTGgXv97iBzP3aOPKcMmJgbjAR97u+E6KGmpN84AMKTmKVread1znrRmeMKVVBsakDU3EEbF8NTQGY1SffxpJY6bb1/w7XmrnzN66Kip6NaluFUnqyQRI="
}
```
### 返回参数
**同步返回参数data**
| 参数                    | 中文名       | 定义   | 必填 | 长度 | 说明                                    |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| resp_code               | 返回码       | String | Y    | 8    | [业务返回码](https://paas.huifu.com/open/doc/api/#/csfl/api_csfl_ywm)     |
| resp_desc               | 返回描述     | String | Y    | 512  | [业务返回描述](https://paas.huifu.com/open/doc/api/#/csfl/api_csfl_ywm) |
| <span class="extend atu_config_bo_list">atu_config_bo_list</span>   | 配置信息     | String   | N    |      | jsonArray格式                                |
| <span class="extend atu_config_bo_fail_list">atu_config_bo_fail_list</span>|配置失败信息 | String   | N    |      | jsonArray格式;配置失败信息仅返回最新一条  |
<!--配置信息-->
<!-- extend:start -->
<!-- div:extend-table atu_config_bo_list-table -->
| 参数             | 中文名             | 定义   | 必填 | 长度 | 说明                                                         |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| fee_type         | 业务开通类型       | String | N    | 2    | <font color="green">示例值:01</font>;参见《[微信业务开通类型](https://paas.huifu.com/open/doc/api/#/api_ggcsbm?id=%e5%be%ae%e4%bf%a1%e4%b8%9a%e5%8a%a1%e5%bc%80%e9%80%9a%e7%b1%bb%e5%9e%8b)》说明|
| wx_applet_app_id | 微信小程序APPID    | String | N    | 20   |<font color="green">示例值:wx8523175fea790f10</font> |
| wx_woa_app_id    | 公众号支付APPID    | String | N    | 512  | <font color="green">示例值:wx3767c5bd01df5061</font>    |
| wx_woa_path      | 微信公众号授权目录 | String | N    | 512  | <font color="green">示例值:https://paas.huifu.com/shouyintai/demo/h5/</font>|
| micro_sub_appid | 微信反扫类交易APPID   | String | 32  | N  | <font color="green">示例值:wx23175fea790f10</font> ;|
| bank_channel_no  | 渠道号             | String | N    | 32   | <font color="green">示例值:JQF00001</font>|
| <span class="extend wx_config_list">wx_config_list</span>   | 微信配置结果集     | String |  N    |    | jsonArray格式 参考:wx_config_list 微信配置结果集            |
<!-- extend:end -->
<!--微信配置结果集 -->
<!-- extend:start -->
<!-- div:extend-table wx_config_list-table -->
| 参数         | 中文名   | 定义   | 长度 | 必填 | 说明 |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| config_type  | 配置类型 | String | 1    | Y    | 1-小程序appId, 2-公众号appId, 3-授权目录, 4-关注的公众号,5-微信反扫类交易appId;<font color="green">示例值:1</font>; |
| config_value | 配置值   | String | 256  | Y    | 对应配置类型参数值 |
| control_flag | 管控标识    | String | 1  | N    | Y-管控中,N-已解控,为空时无管控                                                      |
| control_time | 管控/解控时间 | String | 20 | N    | 管控/解控时间:yyyy-MM-dd HH:mm:ss格式,当controlFlag为Y时为管控时间,当controlFlag为N时为解控时间 |
<!-- extend:end -->
<!--配置失败信息 -->
<!-- extend:start -->
<!-- div:extend-table atu_config_bo_fail_list-table -->
| 参数             | 中文名                    | 定义   | 长度 | 必填 | 说明             |
| :---------- | :-------- | :------ | :---- | :---- | :--------------------- |
| fee_type         | 业务开通类型              | String | 2    | N    | 参见《[微信业务开通类型](https://paas.huifu.com/open/doc/api/#/api_ggcsbm?id=%e5%be%ae%e4%bf%a1%e4%b8%9a%e5%8a%a1%e5%bc%80%e9%80%9a%e7%b1%bb%e5%9e%8b)》说明 |
| wx_applet_app_id | 微信小程序APPID           | String | 20   | N    | <font color="green">示例值:wx8523175fea790f10</font>                                              |
| wx_woa_app_id    | 公众号支付APPID           | String | 512  | N    | <font color="green">示例值:wx3767c5bd01df5061</font>                                              |
| wx_woa_path      | 微信公众号授权目录        | String | 512  | N    | <font color="green">示例值:https://paas.huifu.com/shouyintai/demo/h5/</font>|
| bank_channel_no  | 渠道号                    | String | 32   | N    |<font color="green">示例值:JQF00001</font> |
| business_desc    | 业务失败描述信息          | String | 512  | N    | 业务失败描述信息                                             |
| bank_message     | 微信/支付宝侧失败描述信息 | String | 512  | N    | 微信/支付宝侧失败描述信息                                    |
<!-- extend:end -->
#### 返回示例
```json
{
    "atu_config_bo_fail_list": "[{\"bank_channel_no\":\"10000001\",\"bank_message\":\"{\\\"path\\\":\\\"当前已 配置公众号支付支付域名个数已达上限,如需额外配置,请联系客服处理\\\"}\",\"business_desc\":\"{\\\"path\\\":\\\" 当前已配置公众号支付支付域名个数已达上限,如需额外配置,请联系客服处理\\\"}\",\"fee_type\":\"01\",\"wx_applet_app_id\":\"\",\"wx_woa_app_id\":\"\",\"wx_woa_path\":\"https://api.huifu.com/\"}]",
    "atu_config_bo_list": "[{\"bank_channel_no\":\"10000001\",\"fee_type\":\"01\",\"wx_applet_app_id\":\"wx852v3175fe90f170\",\"wx_config_list\":[{\"config_type\":\"3\",\"config_value\":\"https://api.huifu.com/\"},{\"config_type\":\"3\",\"config_value\":\"https://api.huifu.com/h5/\"},{\"config_type\":\"3\",\"config_value\":\"https://paas.huifu.com/che/\"},{\"config_type\":\"3\",\"config_value\":\"https://paas.huifu.com/che/\"},{\"config_type\":\"3\",\"config_value\":\"https://spin.cloudpnr.com\"},{\"config_type\":\"1\",\"config_value\":\"wx31dfd765c5b00617\"},{\"config_type\":\"1\",\"config_value\":\"wxdd6612b66dd131ab\"},{\"config_type\":\"1\",\"config_value\":\"wx85275fe90a731f10\"}],\"wx_woa_app_id\":\"wx3c5bd0171df50667\",\"wx_woa_path\":\"https://paas.huifu.com/che\"},{\"bank_channel_no\":\"10000001\",\"fee_type\":\"02\",\"wx_applet_app_id\":\"\",\"wx_config_list\":[{\"config_type\":\"3\",\"config_value\":\"https://api.huifu.com/\"},{\"config_type\":\"3\",\"config_value\":\"https://paas.huifu.com/che/\"},{\"config_type\":\"3\",\"config_value\":\"https://paas.huifu.com/che\"},{\"config_type\":\"1\",\"config_value\":\"wx31dc5bd01f506767\"}],\"wx_woa_app_id\":\"wx31d0506176f7c5bd\",\"wx_woa_path\":\"https://paas.huifu.com/che/\"}]",
    "resp_code": "00000000",
    "resp_desc": "成功"
}
```
.scratch/merchant-wechat-onboarding/official-contracts-2026-09-15.md
New file
@@ -0,0 +1,126 @@
# Ticket02 汇付官方接口合同核查(2026-09-15)
本次只读取汇付官方文档及其官方下载表格,不调用任何商户写入接口,不操作商户控台。以下区分文档事实、实现建议和仍未确认事项;不能以文档示例或参考商户配置证明目标商户已开通。
## 来源
- [S1 商户业务开通(2026.09.10)](https://paas.huifu.com/docs/api/#/shgl/shywkt/api_shjj_shywkt_kyc)
- [S2 微信商户配置查询(2024.12.30)](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_wxshpzcx)
- [S3 微信实名认证状态查询(2024.9.1)](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_wxsmrzztcx)
- [S4 微信商户配置(2024.9.1)](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_wxshpz)
- [S5 微信实名认证(2025.4.10)](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_wxsmrz)
- [S6 申请单状态查询(2025.08.21)](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_sqdztcx)
- [S7 公共参数编码:微信支付场景、微信业务开通类型](https://paas.huifu.com/docs/api/#/api_ggcsbm)
- [S8 微信费率规则](https://paas.huifu.com/docs/api/#/csfl/api_csfl_wxflgz)及其[官方 Excel](https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/%E5%BE%AE%E4%BF%A1%E8%B4%B9%E7%8E%87%E8%A7%84%E5%88%99ID.xlsx)
- [S9 支付渠道号说明(2022.9.28)](https://paas.huifu.com/docs/api/#/czsm/api_czsm_bankChannelNo)
- [S10 商户详细信息查询](https://paas.huifu.com/docs/api/#/shgl/shjj/api_shjj_shxxxxcx_kyc)
- [S11 商户业务开通修改](https://paas.huifu.com/docs/api/#/shgl/shywkt/api_shjj_shywktxg_kyc)
- [S12 汇付 MCC 编码](https://paas.huifu.com/docs/api/#/csfl/api_csfl_hfmccbm)及其[官方 CSV](https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/MCC%E7%A0%81/%E6%B1%87%E4%BB%98mcc.csv)
- [S13 KYC 线上业务类型编码及补充材料说明](https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/kyc/KYC-%E7%BA%BF%E4%B8%8A%E4%B8%9A%E5%8A%A1%E7%B1%BB%E5%9E%8B%E7%BC%96%E7%A0%81%E5%8F%8A%E8%A1%A5%E5%85%85%E6%9D%90%E6%96%99%E8%AF%B4%E6%98%8E.xlsx)(S1 的 `online_busi_type` 引用附件)
核查方法:页面由汇付官方文档配置指定 `/partners/api/doc/` 为 Markdown 根路径。例如 S2 的原文为 `https://paas.huifu.com/partners/api/doc/shgl/shjj/api_shjj_wxshpzcx.md`。读取了被页面折叠的子对象字段,而不只读取顶层表格;S8 的结论来自当前下载的 Excel,未把 Markdown 中已注释旧表当现行合同。
## AppID:可直接用于实现的查询与回调合同
`POST /v2/merchant/busi/config/query` 是微信商户配置查询,不是业务开通申请的通用查询。[S2]
请求为 `req_seq_id`、`req_date`、`huifu_id`,可选 `bank_channel_no`。响应包含:
| 字段 | 合同含义 |
| --- | --- |
| `atu_config_bo_list` | 配置信息,JSON 数组字符串,可缺省 |
| `atu_config_bo_fail_list` | 配置失败信息,JSON 数组字符串,仅返回最新一条失败 |
| 成功列表元素 `fee_type` | 微信业务开通类型;`01` 是标准费率线上 |
| 成功列表元素 `wx_applet_app_id` | 微信小程序 AppID |
| 成功列表元素 `wx_woa_app_id` / `wx_woa_path` | 公众号 AppID / 授权目录 |
| 成功列表元素 `micro_sub_appid` | 反扫交易 AppID |
| 成功列表元素 `bank_channel_no` | 渠道号 |
| 成功列表元素 `wx_config_list` | 微信配置结果集;示例中是内嵌数组,字段表标注 JSON 数组字符串,解析需兼容二者 |
| 结果集 `config_type` | `1` 小程序 AppID、`2` 公众号 AppID、`3` 授权目录、`4` 关注公众号、`5` 反扫 AppID |
| 结果集 `config_value` | 对应配置值 |
| 结果集 `control_flag` | `Y` 管控中,`N` 已解控,空值无管控 |
| 失败列表 `business_desc` / `bank_message` | 汇付业务 / 微信侧失败描述 |
实现建议:必须在适用线上类型、渠道的配置信息中找到人才端 `wxcfee477acb54d179`,并检查该 AppID 对应条目的管控状态。只返回 `00000000` 且没有配置列表,不能判定已绑定。查询成功列表本身没有独立 `status=S` 字段;不要自行期待 `wx_appid_status`。若顶层 AppID 与嵌套结果同时存在,嵌套明确 `control_flag=Y` 不能被顶层字段覆盖为可用。[S2][S7]
`POST /v2/merchant/busi/config` 是添加接口,成功后不支持修改和删除;每种参数最多 5 组。文档要求检查商户营业执照全称和 AppID 主体营业执照全称完全一致。请求 `fee_type=01` 对应标准费率线上,使用 `wx_applet_app_id` 配置小程序;不需要填写可选的 AppID secret 来证明绑定。[S4][S7]
配置回调使用 `req_seq_id`、`req_date`、`huifu_id`、可选 `apply_no` 和 `result_list` 关联。回调元素是 `config_type=01`(公众号)或 `02`(小程序),`config_stat=S/F/B`(成功/失败/部分成功)。注意:回调的 `config_type=02` 与查询嵌套列表的 `config_type=1` 才分别表示小程序,不能混用枚举;回调不包含具体 AppID,最终仍应查询目标 AppID。[S4]
## 实名与授权必须分开判定
`POST /v2/merchant/busi/realname/query` 前提为商户已成功入驻微信。必填 `req_seq_id`、`req_date`、`huifu_id`,可选 `pay_channel_id`、`pay_scene`。这里 `pay_scene` 引用的是“微信业务开通类型”,线上应为 `01`,不是业务开通报文中的支付场景 `4/5/12`。[S3][S7]
返回 `applyment_stat`(申请状态)、`authorize_stat`(`1` 授权成功、`0` 授权失败)、`qrcode_data`、`reject_param`、`reject_reason`、`smid`、渠道和场景。[S3]
| `applyment_stat` | 业务含义与下一步 |
| --- | --- |
| `APPLYMENT_STATE_EDITTING` | 编辑中,官方允许同一业务申请编号重新提交 |
| `APPLYMENT_STATE_WAITTING_FOR_AUDIT` | 审核中,官方提示等待 1~2 个工作日 |
| `APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT` | 待联系人确认,扫描返回小程序码 |
| `APPLYMENT_STATE_WAITTING_FOR_CONFIRM_LEGALPERSON` | 待账户验证,扫描返回小程序码 |
| `APPLYMENT_STATE_PASSED` | 审核通过,仍需扫描小程序码完成授权 |
| `APPLYMENT_STATE_REJECTED` | 驳回,按原因补资料并更换业务申请编号 |
| `APPLYMENT_STATE_FREEZED` | 可能主体已入驻,按驳回原因指定联系人扫码授权 |
| `APPLYMENT_STATE_CANCELED` | 已撤销,无需继续该申请 |
| `APPLYMENT_STATE_NOAPPLY` | 未申请 |
`PASSED` 单独不证明授权成功,必须核对 `authorize_stat`。`FREEZED` 也不能简单永久判为失败,应显示官方原因及授权指引;若它同时返回授权成功,需要结合真实主体证据解释,不能只凭英文名推断。[S3]
S3 明确 `qrcode_data` 是 Base64 编码图片,可经 `data:image/png;base64,...` 展示。S6 在类似字段上却给了支付宝 URL 示例,存在文档示例混杂;实现宜只接受真实返回的合规图片数据或明确的 HTTPS 指引链接,不把所有内容拼作任意网页地址,不伪造二维码。[S3][S6]
`POST /v2/merchant/busi/realname` 用于已入驻微信后的实名申请。必须先上传必要证照;必填联系人姓名、手机号、身份证号码、`contact_type`(法人 `LEGAL` 或经办人 `SUPER`)。企业需真实 `owner`(法人是否受益人)和 `identification_address`;若法人非最终受益人,需提交 `ubo_info_list`,不能直接默认法人为受益人。[S5]
受益人包括证件类型、正反面文件 ID、姓名、号码、住址、有效期。经办人还需证件类型/有效期、证件文件以及 `F227` 微信业务办理授权函;特殊行业、金融机构、其他组织依条件追加材料。独立实名接口中的 `finance_institution_flag` 是 `Y/N`;业务开通内 `wx_real_name_info` 的同名字段是 `true/false`,不能复用未经转换的对象。[S1][S5]
业务开通的 `wx_conf_list.is_check_real_name=Y` 可在业务开通后使用法人信息发起实名,并通过 `wx_real_name_info` 补充资料;若微信入驻成功但实名申请失败,文档明确应调用独立实名接口。[S1]
## 微信业务补开合同与未确定项
已完成进件商户可调用 `POST /v2/merchant/busi/open` 补开首次未开通的业务,渠道自身需有对应权限。`upper_huifu_id` 为直属渠道,`sys_id` 主体为渠道时填写;不得关闭重建原商户或复用其他商户独有资料。[S1]
`wx_conf_list` 为 JSON 数组,企业使用 `fee_rule_id` 代替 `mcc`,`mcc` 可不填。每项确定字段如下:[S1][S7]
| 需求 | 确定合同 |
| --- | --- |
| 线上公众号、小程序、反扫 | `pay_scene` 分别为 `4`、`5`、`12` |
| 0.70% | `fee_rate="0.70"`;接口单位为百分数 |
| 默认渠道 | 可省略 `pay_channel_id`,使用渠道默认配置 |
| 手续费内扣 | 顶层 `out_fee_flag="2"`,文档说明它影响微信配置 |
| 延迟但非强制 | 顶层 `delay_flag="Y"`、`forced_delay_flag="N"` |
| 不用上级 AT 商户号 | `use_chains_flag="2"` |
| 不开微信预授权 | `wechatpay_pre_auth_flag="N"` |
以下不能补造合同:
1. **软件开发/技术服务的 CID 尚未确认。** 用户已确认目标商户主营软件开发/技术服务;官方汇付 MCC 表中 `7372` 明确为“计算机软件开发、系统集成、数据处理服务”,`5732` 明确为“电子设备商店”。因此原 `5732` 与本次确认的主营内容不一致,`7372` 是有直接文字依据的基本行业候选,但本研究未修改商户行业。当前微信费率 Excel 中企业线上 `4/5` 对应 `756`,适用行业包含网上交易/信息服务/平台服务,细分如电商平台、就业信息平台、培训、停车等;未列出软件开发/技术服务,也未显式列 `12` 场景。汇付 MCC 与微信 CID 是不同字段,不能从 `7372` 自动推导出 `756`。`758` 是线下实体类规则,也不能配在 `4/5/12` 上当成已核实方案。[S8][S12]
2. **零最低收费的入参尚未确认。** S1 的 `fee_min_amt` 是可选,文字要求“大于 0,需要渠道开通此功能”;与参考商户查询返回 `0.00` 不矛盾,但不能据此证明写入 `0.00` 合法。实现可省略正数最低收费配置并在真实详情查询中核实最终为零;省略的默认结果本次文档未明确承诺。[S1][S10]
3. **实收写入字段未确认。** 详情查询 `qry_wx_conf_list.fee_charge_type` 明确 `1=实收、2=后收`,但当前业务开通和修改文档的微信请求子对象未列 `fee_charge_type`,只在其他业务示例出现;不能把查询输出字段直接当开通入参。必须通过详情确认结果是 `1`,若不是需获得汇付明确修改合同。[S1][S10][S11]
4. **渠道号有依据但默认路由未证明。** S9 明确控台商户支付功能管理详情里的渠道号就是服务商可查看的配置渠道号;`00016904` 又已经由参考商户的真实 `wx_mer_infos.pay_channel_id` 返回,因此不需要臆造 JP 前缀映射。但仅参考商户返回不能证明目标商户默认必定路由到该渠道;目标补开后仍需查询核实。[S1][S9][S10]
## 2026-09-15 续查:软件开发行业与线上反扫
续查重新下载并解包读取 S8 和 S13 的 Excel 全表(含合并单元格范围),另读取 S12 的官方 CSV。得到以下可复核的边界:
- S8 `Sheet1!B3:G9` 是企业 `fee_type=01`、`fee_rule_id=756`,支付场景只写 `4、线上公众号 5、线上小程序`;细分行业共七项:电商平台,婚介平台/就业信息平台/其他信息服务平台,共享服务,机票/票务代理,培训机构,停车缴费,私立/民营口腔及眼科、医疗美容医院/诊所。全表未出现软件开发、技术服务或场景 `12` 的明确 CID 映射。[S8]
- `12` 本身是合法的**线上反扫**枚举;S7 的“商户活动报名手续费类型”又明确将微信 `4/5/12` 都归入报名 `fee_type=1`。这证明线上反扫存在且归于该报名组,但报名手续费类型 `1`、微信业务开通类型 `01`、CID `756` 分属不同合同,前两点不能代替目标行业 `12 + 756` 的适用性确认。[S7][S8]
- S13 的工作表“线上业务类型编码”共 181 行,表头是汇付 MCC code、MCC 名称及补充材料;可见人资 `H7361AL`、众包 `H7361BL`、电商平台 `H5311L` 等,但没有软件开发/技术服务或 `7372` 的条目。该表服务于 `online_busi_type` 及特定业务材料要求,并不是微信 CID 对照表;不能因为表中没列 `7372` 就认定该行业禁止线上收款,也不能借用“人资”来满足人才卡项目名称。[S1][S13]
- 真实补开前应向汇付确认:主营为软件开发/系统集成/技术服务的企业,基本行业是否应修正为 `7372`,微信线上公众号/小程序/线上反扫分别使用哪个 CID,是否允许三场景共用 `756`,以及目标渠道 `00016904` 的额外材料与权限要求。本次只读研究无法确定这些平台规则;不使用参考商户成功配置替代回答。[S1][S8][S12][S13]
对实现的建议:持久化实际行业说明与明确配置的 CID,缺少已确认行业/CID 时给出可补充的业务阻塞原因;查询、实名状态展示、AppID 证据、幂等和模拟验收可以继续完成。这里的待确认是实际资料/接口合同缺口,不是要求为日常代码修改另行申请权限。
## 未决申请查询与幂等
`POST /v2/merchant/basicdata/status/query` 支持业务开通、基本进件、信息修改等申请单。请求必须有 `apply_no`、`huifu_id`、本次查询的 `req_seq_id`、`req_date`;文档没有 `org_req_seq_id` / `org_req_date`,也没有宣称可用原业务流水号代替 `apply_no`。[S6]
因此:已取得 `apply_no` 可查询原申请;请求超时且没有 `apply_no` 时,本次核查**未找到通过原请求号和日期反查业务开通申请单的官方接口**。必须持久化原请求关联并保持未知,继续查询商户实际能力、等待关联回调或人工查单;不能把“查询成功但暂时无配置”当原申请已失败并自动生成新开通请求。独立 AppID 查询可核实是否已经生效,但同样不能证明未出现的配置没有正在途处理。[S2][S6]
S6 业务返回 `apply_status=Y/P/N/F`,微信业务列表 `wx_conf_result_list[].wx_conf_status=S/P/F`,另有 `wx_bank_mer_code`、`pay_scene`、`pay_channel_id`、实名状态和原因。业务结果仅针对原申请提交的业务返回;原基本进件申请缺失 `wx_conf_result_list` 不能当作微信已失败。[S6]
S1 审核回调是 `audit_status=P/Y/N/F/C`;微信业务开通结果另走 `busi_async_return_url`,以 `notify_type=W`、`reg_result_list` 表示。审核通过不等于微信入驻结果成功。[S1]
## 目标与参考商户只读结果的边界
以下来自主线程此次真实只读核验的交接,不是本研究代理重新调用接口:目标 `6666000241335656` 基础审核 Y、注册 S、绑卡 S,详情 `mer_stat=NORMAL`、直属 `6666000220591054`、MCC `5732`;`delay_flag=N`、`forced_delay_flag=N`;D1 结算状态 1、类型 0、起结与留存 0;`qry_wx_conf_list` 缺省,配置查询仅 `00000000/成功`,实名查询 `00000001/AT入驻信息池信息不存在`。这些支持“基础进件已成功,但微信与延迟能力尚无完成证据”,不支持直接标记业务全开通。
参考商户只读微信场景 `4/5/12`、费率 `0.70`、实收 1、内扣 2、最低 `0.00`,微信商户信息渠道 `00016904`。它可作为费率/场景验收模板,不可替代目标的 CID、主体材料、微信子商户号和实名结果。
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
@@ -5,25 +5,72 @@
            <text class="page-title">商户进件</text>
        </view>
        <view class="result-content">
        <scroll-view scroll-y="true" class="result-content">
            <view class="status-icon-wrap"><image class="status-icon" :src="statusIcon" mode="aspectFit"></image></view>
            <text class="result-title">{{statusTitle}}</text>
            <text v-if="statusReason.length > 0" class="result-reason">{{statusReason}}</text>
            <view v-if="refreshable" class="progress-panel">
                <text class="result-reason">最后成功同步:{{lastSyncedAt.length > 0 ? lastSyncedAt : '尚未同步'}}</text>
                <text v-if="syncError.length > 0" class="result-reason">{{syncError}}</text>
                <view v-for="stage in stages" :key="stage.key" class="progress-stage">
                    <text>{{stage.label}} · {{stageLabel(stage.status)}}</text>
                    <text class="result-reason">{{stage.reason}}</text>
                    <image v-if="stage.qrCode.length > 0" :src="stage.qrCode" mode="aspectFit" style="width: 360rpx; height: 360rpx;"></image>
                </view>
                <text class="result-reason">刷新会查询进度并继续办理已满足条件的缺失能力。本人实名或授权办理后,请再次刷新。</text>
                <button :disabled="syncing" :loading="syncing" @tap="refreshProgress(false)">刷新进度</button>
                <button v-if="retryable" :disabled="syncing" @tap="refreshProgress(true)">重试失败步骤</button>
            </view>
            <view v-if="onboardingStatus == 'rejected'" class="return-button" @tap="resubmit"><text>重新提交</text></view>
            <view v-else class="return-button" @tap="goBack"><text>返回商户中心</text></view>
        </view>
        </scroll-view>
    </view>
</template>
<script setup lang="uts">
    import { loadOnboardingStatus, SettlementOnboarding } from '../../services/settlement.uts'
    import { loadOnboardingStatus, refreshOnboardingProgress, SettlementOnboarding, OnboardingStage } from '../../services/settlement.uts'
    import { getMerchantOnboardingDraftStorageKey } from '../../services/onboarding-draft.uts'
    const onboardingStatus = ref('pending')
    const statusReason = ref('')
    const refreshable = ref(false)
    const retryable = ref(false)
    const syncing = ref(false)
    const lastSyncedAt = ref('')
    const syncError = ref('')
    const stages = ref<Array<OnboardingStage>>([])
    const stageLabel = (status: string): string => {
        if (status == 'confirmed') return '已确认'
        if (status == 'pending') return '处理中'
        if (status == 'action_required') return '待办理'
        if (status == 'missing') return '待补齐'
        if (status == 'failed') return '失败'
        return '未确认'
    }
    const applyProgress = (item: SettlementOnboarding) => {
        onboardingStatus.value = item.status
        statusReason.value = item.reason
        refreshable.value = item.refreshable
        retryable.value = item.retryable
        lastSyncedAt.value = item.lastSyncedAt
        syncError.value = item.syncError
        stages.value = item.stages
    }
    const refreshProgress = (retry: boolean) => {
        if (syncing.value) return
        syncing.value = true
        refreshOnboardingProgress(retry, (item: SettlementOnboarding) => {
            applyProgress(item)
            syncing.value = false
        }, (error: any) => {
            syncing.value = false
            uni.showToast({ title: error.message != null ? error.message : '刷新失败,请稍后重试', icon: 'none' })
        })
    }
    const statusTitle = computed((): string => {
        if (onboardingStatus.value == 'approved') return '商户进件已完成'
        if (onboardingStatus.value == 'rejected') return '进件审核未通过'
        if (refreshable.value) return '收款能力开通中'
        return '进件信息审核中'
    })
    const statusIcon = computed((): string => {
@@ -47,14 +94,13 @@
                return
            }
            // 通道侧配置已明确失败的进件应该回到表单重填,而不是停在这里显示"审核中"。
            if (item.retryable) {
            if (item.retryable && !item.refreshable) {
                uni.redirectTo({ url: '/pages/merchant-onboarding/merchant-onboarding' })
                return
            }
            onboardingStatus.value = item.status
            applyProgress(item)
            // 只清当前账号自己的草稿:草稿 key 按操作员分命名空间,不能误删别的账号的。
            if (item.status == 'approved') uni.removeStorageSync(getMerchantOnboardingDraftStorageKey())
            statusReason.value = item.status == 'rejected' ? item.reason : ''
        }, (error: any) => {
            uni.showToast({ title: error.message != null ? error.message : '进件状态加载失败', icon: 'none' })
        })
@@ -73,6 +119,8 @@
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .result-content { display: flex; padding: 70rpx 44rpx 0; flex: 1; flex-direction: column; align-items: center; box-sizing: border-box; }
    .progress-panel { width: 100%; padding-top: 24rpx; padding-bottom: 32rpx; }
    .progress-stage { padding: 20rpx; margin-bottom: 16rpx; background-color: #FFFFFF; border-radius: 16rpx; }
    .status-icon-wrap { display: flex; width: 176rpx; height: 176rpx; justify-content: center; align-items: center; border-radius: 88rpx; background-image: linear-gradient(145deg, #FFF0E6 0%, #FFF8F3 100%); box-shadow: 0 18rpx 38rpx rgba(255, 107, 44, 0.12); }
    .status-icon { width: 82rpx; height: 82rpx; }
    .result-title { margin-top: 40rpx; color: #293547; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
rencaika_merchant/services/settlement.uts
@@ -1,14 +1,16 @@
import { getApiEndpoint } from '../config/api.uts'
import { HttpError, request } from './http.uts'
export type SettlementOnboarding = { id: string, applicationNo: string, status: string, reason: string, retryable: boolean, submittedAt: string }
export type OnboardingStage = { key: string, label: string, status: string, reason: string, qrCode: string }
export type SettlementOnboarding = { id: string, applicationNo: string, status: string, reason: string, retryable: boolean, submittedAt: string, refreshable: boolean, lastSyncedAt: string, syncError: string, stages: Array<OnboardingStage> }
export type SettlementBankCard = { id: string, bankName: string, branchName: string, cardMasked: string, status: string, reason: string, createdAt: string }
export type SettlementPage<T> = { records: Array<T>, page: number, size: number, total: number }
const stringValue = (value: any): string => value == null ? '' : '' + value
const numberValue = (value: any): number => value == null ? 0 : Number(value)
/** retryable:通道侧配置已明确失败,商户可以重新提交;缺省按 false,宁可保守。 */
const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), retryable: value != null && value.retryable == true, submittedAt: stringValue(value != null ? value.submittedAt : '') })
const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), retryable: value != null && value.retryable == true, submittedAt: stringValue(value != null ? value.submittedAt : ''), refreshable: value != null && value.refreshable == true, lastSyncedAt: stringValue(value != null ? value.lastSyncedAt : ''), syncError: stringValue(value != null ? value.syncError : ''), stages: (value != null && value.stages instanceof Array ? value.stages : []).map((stage: any): OnboardingStage => ({ key: stringValue(stage.key), label: stringValue(stage.label), status: stringValue(stage.status), reason: stringValue(stage.reason), qrCode: stringValue(stage.qrCode) })) })
export const refreshOnboardingProgress = (retry: boolean, success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: '/api/merchant/onboarding/refresh?retry=' + (retry ? 'true' : 'false'), method: 'POST' }, (data: any) => success(onboarding(data)), error)
const bankCard = (value: any): SettlementBankCard => ({ id: stringValue(value != null ? value.id : ''), bankName: stringValue(value != null ? value.bankName : ''), branchName: stringValue(value != null ? value.branchName : ''), cardMasked: stringValue(value != null ? value.cardMasked : ''), status: stringValue(value != null ? value.status : 'pending'), reason: stringValue(value != null ? value.reason : ''), createdAt: stringValue(value != null ? value.createdAt : '') })
export const loadOnboardingStatus = (success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantOnboardingStatus') }, (data: any) => success(onboarding(data)), error)
talent-card-admin/src/api/admin.ts
@@ -116,6 +116,7 @@
  return http.get<{ records: Record<string, any>[]; total: number; failures: string[] }, { records: Record<string, any>[]; total: number; failures: string[] }>('/api/admin/management/merchant-settlement/settlements', { params })
}
export function getMerchantOnboardingDetail(id: number | string) { return http.get<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}`) }
export function refreshMerchantOnboarding(id: number | string, retry = false) { return http.post<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}/progress/refresh`, null, { params: { retry } }) }
export function getAdminNews(params: NewsArticleQuery = {}) {
  return http.get<PageResult<NewsArticle>, PageResult<NewsArticle>>('/api/admin/business/news', { params })
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -1,11 +1,22 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getMerchantOnboardingDetail } from '../api/admin'
import { getMerchantOnboardingDetail, refreshMerchantOnboarding } from '../api/admin'
import { hasAdminPermission } from '../utils/permissions'
const route = useRoute()
const loading = ref(false)
const detail = ref<Record<string, any>>()
const syncing = ref(false)
const stageLabel = (value: string) => ({ confirmed: '已确认', pending: '处理中', action_required: '待办理', missing: '待补齐', failed: '失败', unknown: '未确认' }[value] || value)
async function refreshProgress(retry = false) {
  if (syncing.value) return
  syncing.value = true
  try {
    const progress = await refreshMerchantOnboarding(String(route.params.id), retry)
    if (detail.value) detail.value.progress = progress
  } finally { syncing.value = false }
}
function text(value: unknown) {
  return value === undefined || value === null || value === '' ? '-' : String(value)
@@ -54,7 +65,20 @@
  <div v-loading="loading" class="onboarding-detail-page">
    <section v-if="detail" class="detail-panel">
      <h3>进件状态</h3>
      <div class="status-value">{{ statusLabel(detail.status) }}</div>
      <div class="status-value">{{ detail.progress ? (detail.progress.status === 'approved' ? '开通已完成' : '开通尚未完成') : statusLabel(detail.status) }}</div>
      <section v-if="detail.progress" class="progress-panel">
        <p>{{ detail.progress.reason }}</p>
        <p>最后成功同步:{{ text(detail.progress.lastSyncedAt) }}</p>
        <el-alert v-if="detail.progress.syncError" :title="detail.progress.syncError" type="warning" :closable="false" />
        <div v-for="stage in detail.progress.stages" :key="stage.key" class="progress-stage">
          <strong>{{ stage.label }} · {{ stageLabel(stage.status) }}</strong>
          <p>{{ stage.reason }}</p>
          <img v-if="stage.qrCode" :src="stage.qrCode" alt="微信实名授权二维码" width="180" height="180" />
        </div>
        <p>刷新会查询通道进度,并继续办理已满足条件的缺失能力。待本人实名或授权时,请办理后再次刷新。</p>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" type="primary" :loading="syncing" @click="refreshProgress()">刷新进度</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && detail.progress.retryable" :disabled="syncing" @click="refreshProgress(true)">重试失败步骤</el-button>
      </section>
      <h3 class="onboarding-heading">商户进件</h3>
      <div class="detail-grid common-grid">
@@ -101,6 +125,9 @@
<style scoped>
.onboarding-detail-page { min-width: 1040px; }
.progress-panel { margin: 18px 0; padding: 16px; background: #f5f7fa; font-size: 14px; }
.progress-stage { padding: 12px 0; border-bottom: 1px solid #e2e6ec; }
.progress-stage p { margin: 6px 0; color: #606266; }
.detail-panel { min-height: 560px; padding: 18px 20px 54px; background: #fff; border: 1px solid #edf0f5; }
.detail-panel h3 { margin: 0; color: #1d2129; font-size: 15px; }
.status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -31,6 +31,8 @@
  @DeleteMapping("/staff/{id}") public ApiResponse<Map<String,Object>> deleteStaff(@PathVariable Long id) { return ApiResponse.success(service.deleteStaff(id)); }
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.onboardings(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> onboarding(@PathVariable Long id) { return ApiResponse.success(settlementService.onboarding(id)); }
  @GetMapping("/merchant-settlement/onboardings/{id}/progress") public ApiResponse<Map<String,Object>> onboardingProgress(@PathVariable Long id) { return ApiResponse.success(settlementService.onboardingProgress(id)); }
  @PostMapping("/merchant-settlement/onboardings/{id}/progress/refresh") public ApiResponse<Map<String,Object>> refreshOnboarding(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean retry) { return ApiResponse.success(settlementService.refreshOnboarding(id, retry)); }
  @GetMapping("/merchant-settlement/onboardings/{id}/payment-capability") public ApiResponse<Map<String,Object>> paymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.paymentCapability(id)); }
  @PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -30,6 +30,11 @@
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final MerchantPaymentReadinessService readinessService;
    private final HuifuSettlementRecordGateway settlementRecordGateway;
    @org.springframework.beans.factory.annotation.Autowired
    private com.talentcard.server.business.merchant.MerchantOnboardingProgressService progressService;
    public Map<String,Object> onboardingProgress(Long id) { return progressService.view(id); }
    public Map<String,Object> refreshOnboarding(Long id, boolean retry) { return progressService.refresh(id, retry); }
    public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; }
    /**
@@ -123,6 +128,7 @@
        List<Map<String, Object>> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.id = ?", id);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> result = rows.get(0);
        if (progressService.supports(result)) result.put("progress", progressService.view(result));
        // 个人主体的历史记录可能使用 individual、personal 或中文类型值;
        // 只要已保存加密身份证号,后台详情均应解密后完整展示。
        String encrypted = String.valueOf(result.get("subjectIdNoEncrypted"));
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java
@@ -26,12 +26,18 @@
        request.setAttribute(OPERATOR_NAME_ATTRIBUTE, service.operatorName(operatorId));
        String permission = permission(request.getMethod(), request.getRequestURI());
        if (!permission.isBlank() && !service.hasPermission(operatorId, permission)) {
            if (request.getRequestURI().startsWith("/api/admin/management/merchant-settlement/onboardings")) {
                throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.FORBIDDEN, "当前账号没有该操作权限");
            }
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "当前账号没有该操作权限");
        }
        return true;
    }
    private String permission(String method, String uri) {
        if (uri.startsWith("/api/admin/management/merchant-settlement/onboardings")) {
            return "admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit");
        }
        if (uri.startsWith("/api/admin/management/roles")) {
            return managementPermission(method, uri, "roles");
        }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -29,6 +29,13 @@
    public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); }
    public Map<String,Object> view(Long id) {
        Map<String,Object> application = application(id);
        if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
        return view(application);
    }
    @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
    public Map<String,Object> refresh(Long applicationId, boolean retry) {
        Map<String,Object> application=application(applicationId);
        if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
@@ -48,6 +55,28 @@
                jdbc.update("update merchant_onboarding_progress set stages_json=?,last_synced_at=now(),sync_error='' where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                Map<String,Object> capabilities=gateway.queryCapabilities(text(application.get("providerHuifuId")));
                if (capabilities != null && "00000000".equals(text(capabilities.get("resp_code")))) {
                    if (capabilities.containsKey("mer_stat")) stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat"))));
                    if ("S".equals(text(response.get("bind_card_status"))) && capabilities.containsKey("qry_cash_card_info_list") && capabilities.containsKey("qry_settle_config_list")) {
                        stages.put("bank", confirmOriginalCard(application,capabilities,owner));
                    }
                    if (capabilities.containsKey("qry_wx_conf_list")) {
                        List<Map<String,Object>> wechat = configurations(capabilities.get("qry_wx_conf_list"));
                        boolean valid = List.of("4", "5", "12").stream().allMatch(scene -> wechat.stream().anyMatch(item ->
                                scene.equals(text(item.get("pay_scene"))) && decimal(item.get("fee_rate"), "0.70")
                                && zero(item.get("fee_min_amt")) && "1".equals(text(item.get("fee_charge_type")))
                                && "2".equals(text(item.get("out_fee_flag"))) && item.containsKey("wx_mer_infos")
                                && configurations(item.get("wx_mer_infos")).stream().anyMatch(merchant ->
                                        !text(merchant.get("bank_mer_code")).isBlank() && "1".equals(text(merchant.get("is_default"))))));
                        stages.put("wechat", valid ? "confirmed" : "missing");
                    }
                    if (capabilities.containsKey("qry_settle_config_list")) {
                        List<Map<String,Object>> settlements = configurations(capabilities.get("qry_settle_config_list"));
                        boolean valid = settlements.size() == 1 && settlements.stream().allMatch(item ->
                                "1".equals(text(item.get("settle_status"))) && "D1".equals(text(item.get("settle_cycle")))
                                && "P0".equals(text(item.get("settle_pattern")))
                                && zero(item.get("min_amt")) && zero(item.get("remained_amt")));
                        stages.put("settlement", valid ? "confirmed" : "missing");
                    }
                    if (capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
                        stages.put("delay", "Y".equals(capabilities.get("delay_flag")) && "N".equals(capabilities.get("forced_delay_flag")) ? "confirmed" : "missing");
                    } else {
@@ -58,6 +87,46 @@
                    jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(capabilities),applicationId,owner);
                }
                jdbc.update("update merchant_onboarding_progress set last_synced_at=now() where application_id=? and sync_owner=?",applicationId,owner);
                Map<String,Object> appConfig = gateway.queryWechatConfig(text(application.get("providerHuifuId")));
                if (appConfig != null && "00000000".equals(text(appConfig.get("resp_code")))) {
                    boolean bound = false, controlled = false;
                    for (Map<String,Object> config : configurations(appConfig.getOrDefault("atu_config_bo_list", List.of()))) {
                        if (!"01".equals(text(config.get("fee_type")))) continue;
                        for (Map<String,Object> entry : configurations(config.getOrDefault("wx_config_list", List.of()))) {
                            if ("1".equals(text(entry.get("config_type"))) && "wxcfee477acb54d179".equals(text(entry.get("config_value")))) {
                                bound = true;
                                controlled |= "Y".equals(text(entry.get("control_flag")));
                            }
                        }
                    }
                    stages.put("appId", controlled ? "action_required" : bound ? "confirmed" : "missing");
                } else if (appConfig != null && !appConfig.isEmpty()) {
                    jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(appConfig),applicationId,owner);
                }
                Map<String,Object> realname = gateway.queryRealname(text(application.get("providerHuifuId")));
                if (realname != null && "00000000".equals(text(realname.get("resp_code")))) {
                    String status = text(realname.get("applyment_stat"));
                    boolean authorized = "APPLYMENT_STATE_PASSED".equals(status) && "1".equals(text(realname.get("authorize_stat")));
                    if (!status.isBlank()) stages.put("realname", authorized ? "confirmed" :
                            "APPLYMENT_STATE_REJECTED".equals(status) ? "failed" :
                            "APPLYMENT_STATE_WAITTING_FOR_AUDIT".equals(status) ? "pending" : "action_required");
                    stages.put("realnameQr", qrCode(text(realname.get("qrcode_data"))));
                } else if (realname != null && !realname.isEmpty()) {
                    if ("00000001".equals(text(realname.get("resp_code"))) && "AT入驻信息池信息不存在".equals(text(realname.get("resp_desc")))) {
                        if (!"confirmed".equals(stages.get("realname"))) stages.put("realname", "missing");
                    } else jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(realname),applicationId,owner);
                }
                if ("Y".equals(audit) && "S".equals(text(response.get("reg_status"))) && capabilities != null
                        && "00000000".equals(text(capabilities.get("resp_code"))) && "NORMAL".equals(text(capabilities.get("mer_stat")))
                        && capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
                    advanceDelay(applicationId,owner,stages,retry);
                }
                jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                jdbc.update("""
                        update merchant_onboarding_application a join merchant_onboarding_progress p on p.application_id=a.id
                        set a.status=?,a.reviewed_at=case when ? then now() else null end
                        where a.id=? and p.sync_owner=?
                        """,complete(stages) ? "approved" : "pending",complete(stages),applicationId,owner);
            }
        } catch (RuntimeException exception) {
            jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner);
@@ -65,6 +134,75 @@
            jdbc.update("update merchant_onboarding_progress set sync_owner=null,sync_until=null where application_id=? and sync_owner=?",applicationId,owner);
        }
        return view(application(applicationId));
    }
    private boolean confirmOriginalCard(Map<String,Object> application, Map<String,Object> capabilities, String owner) {
        List<Map<String,Object>> original=rows("select * from merchant_bank_card where id=? and merchant_id=?",application.get("initialBankCardId"),application.get("merchantId"));
        if (original.size()!=1) return false;
        Map<String,Object> card=original.get(0);
        for (Map<String,Object> remote:configurations(capabilities.get("qry_cash_card_info_list"))) {
            String number=text(remote.get("card_no")), token=text(remote.get("token_no"));
            if (!number.matches("[0-9]{8,32}") || token.isBlank() || !sha256(number).equals(text(card.get("cardHash")))) continue;
            String previous=text(card.get("providerTokenNo"));
            if (!previous.isBlank() && !previous.equals(token)) continue;
            boolean linked=configurations(capabilities.get("qry_settle_config_list")).stream().anyMatch(settle -> token.equals(text(settle.get("token_no"))));
            if (!linked) continue;
            return jdbc.update("""
                    update merchant_bank_card b join merchant_onboarding_progress p on p.application_id=?
                    set b.status='approved',b.provider_token_no=?,b.reviewed_at=coalesce(b.reviewed_at,now())
                    where b.id=? and p.sync_owner=?
                    """,application.get("id"),token,card.get("id"),owner)>0;
        }
        return false;
    }
    private static String sha256(String value) {
        try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
        catch (java.security.NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
    }
    private static boolean complete(Map<String,Object> stages) {
        return Boolean.TRUE.equals(stages.get("bank")) && Boolean.TRUE.equals(stages.get("merchantNormal"))
                && LABELS.keySet().stream().allMatch(key -> "confirmed".equals(stages.get(key)));
    }
    private void advanceDelay(Long id, String owner, Map<String,Object> stages, boolean retry) {
        List<Map<String,Object>> attempts=rows("select * from merchant_onboarding_attempt where application_id=? and step='delay' order by id desc limit 1",id);
        Map<String,Object> attempt=attempts.isEmpty() ? null : attempts.get(0);
        if ("confirmed".equals(stages.get("delay"))) {
            if (attempt != null) jdbc.update("update merchant_onboarding_attempt set status='confirmed' where id=?",attempt.get("id"));
            return;
        }
        if (attempt != null && !"failed".equals(text(attempt.get("status")))) {
            String applyNo=text(attempt.get("applyNo"));
            if (!applyNo.isBlank() && !List.of("succeeded","confirmed").contains(text(attempt.get("status")))) {
                Map<String,Object> result=gateway.queryApplication(RECOVERY_HUIFU_ID,applyNo);
                if (result != null && "00000000".equals(text(result.get("resp_code"))) && List.of("N","F").contains(text(result.get("apply_status")))) {
                    jdbc.update("update merchant_onboarding_attempt set status='failed',response_code=? where id=? and status<>'confirmed'",text(result.get("resp_code")),attempt.get("id"));
                    attempt.put("status","failed");
                }
            }
            if (!"failed".equals(text(attempt.get("status")))) { stages.put("delay","pending"); return; }
        }
        if (attempt != null && !retry) { stages.put("delay","failed"); return; }
        String requestNo=UUID.randomUUID().toString().replace("-", "");
        String date=java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
        int inserted=jdbc.update("""
                insert into merchant_onboarding_attempt(application_id,step,request_no,request_date,status)
                select ?, 'delay', ?, ?, 'unknown' from merchant_onboarding_progress
                where application_id=? and sync_owner=? and sync_until>now()
                """,id,requestNo,date,id,owner);
        if (inserted==0) return;
        stages.put("delay","pending");
        try {
            Map<String,Object> result=gateway.configureDelay(RECOVERY_HUIFU_ID,requestNo,date);
            String code=result==null ? "" : text(result.get("resp_code"));
            String status=code.isBlank() ? "unknown" : List.of("00000000","00000100","90000000").contains(code) ? "pending" : "failed";
            jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=? where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,requestNo);
            if ("failed".equals(status)) stages.put("delay","failed");
        } catch (RuntimeException ignored) {
            // The durable unknown attempt blocks resubmission after a crash, timeout or invalid response.
        }
    }
    public Map<String,Object> view(Map<String,Object> application) {
@@ -81,9 +219,34 @@
            result.put("syncError",text(saved.get(0).get("syncError")));
        }
        List<Map<String,Object>> progress=new ArrayList<>();
        for (var entry:LABELS.entrySet()) progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",text(stages.getOrDefault(entry.getKey(),"unknown")),"reason","尚未取得通道生效证据"));
        for (var entry:LABELS.entrySet()) {
            String status = text(stages.getOrDefault(entry.getKey(),"unknown"));
            progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",status,"reason",stageReason(entry.getKey(),status),
                    "qrCode", "realname".equals(entry.getKey()) ? text(stages.get("realnameQr")) : ""));
        }
        result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
        result.put("retryable", "failed".equals(stages.get("delay")));
        result.put("status",complete(stages) ? "approved" : "pending");
        result.put("reason",complete(stages) ? "收款所需能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力;微信行业规则仍待平台确认,暂不提交微信补开");
        return result;
    }
    /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */
    public Long acceptDelayCallback(Map<String,Object> data) {
        List<Map<String,Object>> matches=rows("""
                select t.id,t.application_id from merchant_onboarding_attempt t
                join merchant_onboarding_application a on a.id=t.application_id
                where t.step='delay' and t.request_no=? and t.request_date=? and a.provider_huifu_id=?
                """,text(data.get("req_seq_id")),text(data.get("req_date")),RECOVERY_HUIFU_ID);
        if (matches.isEmpty() || !RECOVERY_HUIFU_ID.equals(text(data.get("huifu_id")))) return null;
        Map<String,Object> attempt=matches.get(0);
        String audit=text(data.get("audit_status"));
        String state="Y".equals(audit) ? "succeeded" : List.of("N","F","C").contains(audit) ? "failed" : "pending";
        jdbc.update("""
                update merchant_onboarding_attempt set status=?,apply_no=coalesce(apply_no,nullif(?,'')),response_code=?
                where id=? and status not in ('succeeded','confirmed')
                """,state,text(data.get("apply_no")),text(data.getOrDefault("sub_resp_code",data.get("resp_code"))),attempt.get("id"));
        return ((Number)attempt.get("applicationId")).longValue();
    }
    private Map<String,Object> application(Long id) {
@@ -97,6 +260,33 @@
    }
    private List<Map<String,Object>> rows(String sql,Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql,args)); }
    private static String text(Object value) { return value==null ? "" : String.valueOf(value); }
    private static String stageReason(String key, String status) {
        if ("confirmed".equals(status)) return "已取得通道生效证据";
        if ("realname".equals(key) && "action_required".equals(status)) return "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营";
        if ("appId".equals(key) && "action_required".equals(status)) return "人才端绑定被微信管控,请联系平台运营解除后刷新";
        if ("delay".equals(key) && "missing".equals(status)) return "需开启延迟入账并关闭强制延迟";
        if ("settlement".equals(key) && "missing".equals(status)) return "需开启 D1 批次结算,起结金额与留存金额均为 0";
        if ("pending".equals(status)) return "通道正在审核,请稍后刷新";
        if ("failed".equals(status)) return "该步骤未通过,请联系平台运营核对资料后局部重试";
        return "尚未取得通道生效证据";
    }
    private static String qrCode(String raw) {
        String value = raw.replaceFirst("^data:image/png;base64,", "");
        if (value.length() > 200000 || !value.startsWith("iVBORw0KGgo") || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return "";
        return "data:image/png;base64," + value;
    }
    @SuppressWarnings("unchecked")
    private static List<Map<String,Object>> configurations(Object raw) {
        Object parsed = raw instanceof String value ? JSON.parse(value) : raw;
        if (!(parsed instanceof List<?> list)) throw new IllegalArgumentException("Invalid provider configuration list");
        return list.stream().map(item -> (Map<String,Object>)item).toList();
    }
    private static boolean zero(Object value) {
        return decimal(value, "0");
    }
    private static boolean decimal(Object value, String expected) {
        return value != null && new java.math.BigDecimal(text(value)).compareTo(new java.math.BigDecimal(expected)) == 0;
    }
    private static String queryError(Map<String,Object> response) {
        return response != null && "20002".equals(text(response.get("resp_code")))
                ? "汇付查询权限不足(20002),请联系平台运营核实接口权限"
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -338,6 +338,11 @@
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", requestNo);
        if (applications.isEmpty()) return false;
        Map<String, Object> application = applications.get(0);
        if (onboardingProgressService.supports(application)) {
            if (!clean(data.get("huifu_id")).equals(clean(application.get("providerHuifuId")))) return false;
            refreshRecoveryAfterCommit(((Number)application.get("id")).longValue());
            return true;
        }
        if ("approved".equals(clean(application.get("status"))) || "rejected".equals(clean(application.get("status")))) return true;
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
@@ -382,6 +387,11 @@
    @Transactional
    public boolean handleBusinessOpenCallback(Map<String, Object> callback) {
        Map<String, Object> data = callbackData(callback);
        Long recoveryId=onboardingProgressService.acceptDelayCallback(data);
        if (recoveryId != null) {
            refreshRecoveryAfterCommit(recoveryId);
            return true;
        }
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where business_open_request_no=? limit 1 for update", requestNo);
@@ -567,7 +577,18 @@
    private String provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); }
    private void requireKey(String key) { if (key == null || key.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); }
    private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); }
    private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt"))); }
    private void refreshRecoveryAfterCommit(Long id) {
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override public void afterCommit() { onboardingProgressService.refresh(id, false); }
            });
        } else onboardingProgressService.refresh(id, false);
    }
    private Map<String, Object> onboarding(Map<String, Object> row) {
        if (onboardingProgressService.supports(row)) return onboardingProgressService.view(row);
        return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt")));
    }
    /**
     * 这笔进件是不是「通道侧已经明确失败、可以重新提交」。
@@ -598,6 +619,10 @@
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        if (onboardingProgressService.supports(application)) {
            refreshRecoveryAfterCommit(((Number)application.get("id")).longValue());
            return;
        }
        if (!retryable(application)) return;
        if (onboardingProgressService.supports(application)) return;
        Object bankCardId = application.get("initialBankCardId");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -43,6 +43,9 @@
    @GetMapping("/onboarding/status")
    public ApiResponse<Map<String, Object>> onboardingStatus(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { return ApiResponse.success(merchantSettlementService.onboardingStatus(principal)); }
    @PostMapping("/onboarding/refresh")
    public ApiResponse<Map<String, Object>> refreshOnboarding(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal, @RequestParam(defaultValue = "false") boolean retry) { return ApiResponse.success(merchantSettlementService.refreshOnboarding(principal, retry)); }
    @PostMapping("/onboarding")
    public ApiResponse<Map<String, Object>> submitOnboarding(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal, @Valid @RequestBody MerchantOnboardingRequest request, @RequestHeader(value = "X-Idempotency-Key", required = false) String key) { return ApiResponse.success(merchantSettlementService.submitOnboarding(principal, request, key)); }
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
@@ -30,6 +30,23 @@
        return send("v2.merchant.basicdata.query", queryRequest(huifuId));
    }
    public Map<String,Object> queryWechatConfig(String huifuId) {
        return send("v2.merchant.busi.config.query", queryRequest(huifuId));
    }
    public Map<String,Object> queryRealname(String huifuId) {
        Map<String,Object> request = queryRequest(huifuId);
        request.put("pay_scene", "01");
        return send("v2.merchant.busi.realname.query", request);
    }
    public Map<String,Object> configureDelay(String huifuId, String requestNo, String requestDate) {
        Map<String,Object> request = new LinkedHashMap<>(Map.of("huifu_id",huifuId,"req_seq_id",requestNo,"req_date",requestDate,
                "delay_flag","Y","forced_delay_flag","N"));
        if (!properties.getMerchantBusinessOpenNotifyUrl().isBlank()) request.put("async_return_url",properties.getMerchantBusinessOpenNotifyUrl());
        return send("v2.merchant.busi.modify", request);
    }
    public Map<String,Object> openWechat(String huifuId, String requestNo, Map<String,Object> profile) {
        throw new BusinessException(ErrorCode.CONFLICT, "Wechat business profile is not confirmed");
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -19,6 +19,180 @@
    @Autowired JdbcTemplate jdbc;
    @Autowired MerchantSettlementService service;
    @MockitoBean HuifuOnboardingProgressGateway gateway;
    @Autowired org.springframework.web.context.WebApplicationContext context;
    @Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth;
    @Test
    void successfulDelayCallbackIgnoresLateFailureForTheSameAttempt() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
        when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_no","delay-apply"));
        service.refreshOnboarding(principal,false);
        var number=org.mockito.ArgumentCaptor.forClass(String.class);
        var date=org.mockito.ArgumentCaptor.forClass(String.class);
        verify(gateway).configureDelay(anyString(),number.capture(),date.capture());
        assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","Y","sub_resp_code","00000000"))).isTrue();
        assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","N","sub_resp_code","99999999"))).isTrue();
        assertThat(service.refreshOnboarding(principal,true)).containsEntry("retryable",false);
        verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString());
    }
    @Test
    void completesOnlyAfterEveryCapabilityAndTheOriginalCardAreConfirmed() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        jdbc.update("update merchant_bank_card set card_hash=? where merchant_id=?",sha256("622200001234"),principal.merchantId());
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
        var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
                "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList();
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes,
                "qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","original-card-token")),
                "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","original-card-token"))));
        when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
        when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"));
        assertThat(service.refreshOnboarding(principal,false)).containsEntry("status","approved");
        assertThat(service.onboardingStatus(principal)).containsEntry("status","approved");
        assertThat(service.bankCards(principal)).hasSize(1);
        verify(gateway,never()).configureDelay(anyString(),anyString(),anyString());
    }
    private static String sha256(String value) {
        try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
        catch (Exception exception) { throw new IllegalStateException(exception); }
    }
    @Test
    void concurrentRefreshCannotCreateASecondDelayAttempt() throws Exception {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
        var entered=new java.util.concurrent.CountDownLatch(1);
        var release=new java.util.concurrent.CountDownLatch(1);
        when(gateway.configureDelay(anyString(),anyString(),anyString())).thenAnswer(call -> {
            entered.countDown();
            if (!release.await(10,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test release timed out");
            return Map.of("resp_code","00000000","apply_no","concurrent-apply");
        });
        var executor=java.util.concurrent.Executors.newSingleThreadExecutor();
        try {
            var first=executor.submit(() -> service.refreshOnboarding(principal,false));
            assertThat(entered.await(10,java.util.concurrent.TimeUnit.SECONDS)).isTrue();
            service.refreshOnboarding(principal,true);
            release.countDown();
            first.get(10,java.util.concurrent.TimeUnit.SECONDS);
            verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString());
        } finally { release.countDown(); executor.shutdownNow(); }
    }
    @Test
    void retriesOnlyAnExplicitlyFailedDelayAttemptWithANewRequestNumber() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
        when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","20002"),Map.of("resp_code","00000000","apply_no","retry-apply"));
        assertThat(service.refreshOnboarding(principal,false)).containsEntry("retryable",true);
        service.refreshOnboarding(principal,false);
        service.refreshOnboarding(principal,true);
        var requests=org.mockito.ArgumentCaptor.forClass(String.class);
        verify(gateway,times(2)).configureDelay(eq("6666000241335656"),requests.capture(),anyString());
        assertThat(requests.getAllValues()).doesNotHaveDuplicates();
        verify(gateway,never()).openWechat(anyString(),anyString(),anyMap());
    }
    @Test
    void oldBasicFailureCallbackCannotRejectAnExistingVerifiedMerchantOrCard() {
        Map<String,Object> fixture=fixture();
        MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
        service.refreshOnboarding(principal,false);
        service.handleOnboardingCallback(Map.of("req_seq_id",fixture.get("applicationNo"),"huifu_id","6666000241335656","audit_status","N","sub_resp_code","99999999"));
        assertThat(service.bankCards(principal)).allSatisfy(card -> assertThat(card.get("status")).isEqualTo("approved"));
        assertThat(service.onboardingStatus(principal)).containsEntry("status","pending");
    }
    @Test
    void unknownDelayUpdateIsQueriedBeforeRetryAndNeverSubmittedTwice() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N"));
        when(gateway.configureDelay(anyString(),anyString(),anyString())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null));
        service.refreshOnboarding(principal,false);
        Map<String,Object> result=service.refreshOnboarding(principal,true);
        verify(gateway,times(1)).configureDelay(eq("6666000241335656"),matches("[a-f0-9]{32}"),matches("[0-9]{8}"));
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","pending"));
        assertThat(result).containsEntry("retryable",false).containsEntry("status","pending");
    }
    @Test
    void failedRealnameQueryPreservesConfirmedStateAndReportsQueryFailure() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
        when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"),Map.of("resp_code","20002"));
        service.refreshOnboarding(principal,false);
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","confirmed"));
        assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限");
    }
    @Test
    void realnamePassedStillNeedsAuthorizationAndControlledAppIdIsNotReady() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
        when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y"))))));
        when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","0"));
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","appId").containsEntry("status","action_required"));
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","action_required"));
        assertThat(result).containsEntry("status","pending");
    }
    @Test
    void ordinaryReadsExposeSameProgressWithoutCallingHuifu() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","N","forced_delay_flag","N"));
        Map<String,Object> refreshed=service.refreshOnboarding(principal,false);
        clearInvocations(gateway);
        assertThat(service.onboardingStatus(principal).get("stages")).isEqualTo(refreshed.get("stages"));
        verifyNoInteractions(gateway);
    }
    @Test
    void refreshRequiresSettlementEditPermissionEvenForAnAuthenticatedAdmin() throws Exception {
        String account="t02admin"+System.nanoTime();
        jdbc.update("insert into admin_operator(account,password_hash,display_name,role_code,status,deleted) values (?,?,?,'t02-no-rights',1,0)",account,
                com.talentcard.server.business.merchant.auth.MerchantPasswordHasher.encode("TestPass123"),account);
        String token=String.valueOf(adminAuth.login(new com.talentcard.server.business.admin.auth.AdminLoginRequest(account,"TestPass123"),account).get("accessToken"));
        org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup(context).build().perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/admin/management/merchant-settlement/onboardings/449/progress/refresh")
                .header("Authorization","Bearer "+token)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden());
        verifyNoInteractions(gateway);
    }
    @Test
    void configuredWechatScenesStillRequireTalentAppAndAuthorization() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
                "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList();
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes));
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed"));
        assertThat(result).containsEntry("status","pending");
    }
    @Test
    void confirmsD1OnlyFromEffectiveZeroThresholdBatchSettlement() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
                "qry_settle_config_list","[{\"settle_status\":\"1\",\"settle_cycle\":\"D1\",\"settle_pattern\":\"P0\",\"min_amt\":0,\"remained_amt\":0}]"));
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","settlement").containsEntry("status","confirmed"));
        assertThat(result).containsEntry("status","pending");
    }
    @Test
    void submittingAgainCannotReplaceDesignatedExistingMerchantAfterConfigFailure() {