feat(merchant): 分阶段刷新先推进开通步骤,重复提交前先查通道
放开灰度门槛之前必须先补这一步:分阶段模型下刷新是商户与后台推进开通的唯一入口,
而渠道侧的微信商户配置与业务开通原本只由进件审核回调触发——分阶段申请的回调走的是
进度刷新,不在这里补上推进,新商户的微信开通永远不会被发起。先翻门会让这条流程彻底
失效,所以顺序不能颠倒。
- MerchantSettlementService.refreshProgress:先 continuePostOnboardingProvisioning
再回报进度。商户端与后台刷新共用它,两端行为一致。
- 提交前先查通道,已在通道生效的步骤直接按已生效收口,不再重复提交:微信商户配置是
添加接口(成功后不支持改删、每种参数最多 5 组),业务开通是申请型接口,盲目重提会在
通道留下重复配置或第二份申请。本地状态与通道脱节时(通道已配好、本地仍是
not_started)正是最容易踩这个坑的场景。查询失败按未生效处理——宁可多提一次也不漏配。
- 测试跟上:MerchantExistingOnboardingTests 挡住两个开通网关(否则刷新会真的打到汇付),
新增两条——刷新确实推进开通;通道已生效时不重复提交。
验证:MerchantExistingOnboardingTests 19 项全绿。
| | |
| | | - [ ] 一次提交保存唯一申请关联;基础审核未确认成功前不越过前置条件,确认后继续微信与资金能力办理。 |
| | | - [ ] 商户端与后台显示一致的基本审核及后续配置状态,提供刷新、具体失败提示和可执行下一步;普通状态读取保持只读。 |
| | | - [ ] 基础审核拒绝与后续开通失败区分处理;基本进件成功后的局部失败保留原商户号和银行卡,未知结果先查,重复提交不产生重复商户。 |
| | | - [ ] 新商户同样满足三个微信场景、人才端 AppID、适用实名/授权、延迟非强制及 D1 自动结算等完整开通条件;未完成微信实名认证与授权前不解锁收款(见规格「收款就绪的前提」)。 |
| | | - [ ] 新商户同样满足三个微信场景、人才端 AppID、适用实名/授权、延迟非强制及 D1 自动结算等完整开通条件。 |
| | | - [ ] **收款就绪纳入微信业务开通与实名/授权**(从 04 移交):`MerchantPaymentReadinessService` 当前只看进件审核通过、结算卡 token、通道账户状态、自动结算与取现能力。必须**和上面那条放开灰度门槛一起落**——单独落会让所有没有进度记录的存量商户都收不了款,是明确的功能倒退。缺证据时给出具体缺项,不要只说「暂时无法收款」。 |
| | | - [ ] 模拟测试覆盖一次提交后**各阶段依次推进**、基础审核拒绝、材料缺失、待实名、步骤失败、超时、并发、权限及能力不全,并完成两端页面验收。 |
| | | - [ ] 先完成模拟验证,再对实际需要的新企业进件做真实验收,保留原请求与通道证据;禁止反复创建测试商户排错。 |
| | | - [ ] 若没有适合的真实新商户或仍有外部待办,如实记录真实验收未完成,不伪造通过。 |
| | |
| | | |
| | | Blocked by: None —— 可立即开始,与 03 并行。 |
| | | |
| | | Status: ready-for-agent |
| | | Status: ready-for-agent —— 技能部分已交付(见下方勾选项与 Comments);收款就绪门槛按 Comments 的说明移交 03,与灰度门槛一起落。 |
| | | |
| | | - [x] **第一步已完成**:把恢复路径里的单商户硬编码换成申请单自身的 huifu_id——`advanceDelay` 与 `acceptDelayCallback` 改用该申请的 `providerHuifuId`,删除 `HuifuOnboardingProgressGateway.openWechat` 那个抛异常的占位(无调用方),并修掉视图里「微信行业规则仍待平台确认,暂不提交微信补开」的过期文案。 |
| | | - [ ] **`supports()` 的白名单保留不动**:把它换成「有结算卡且未被拒绝」会给所有商户同时切换行为——进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新(`MerchantSettlementCallbackIdempotencyTests`),实测 5 个测试会红。那是 03 票的策略变更,不属本票。本票的实名阶段先只对白名单内的申请生效,由 03 放开范围。 |
| | | - [ ] 新建实名申请通道适配:调用 `v2.merchant.busi.realname` 发起申请,走法人(`contact_type=LEGAL`),必填法人姓名、法人手机号、法人身份证号;企业另填 `owner` 与 `identification_address`。现有仓库只有 `v2.merchant.busi.realname.query` 查询,提交接口需要新建。 |
| | | - [ ] 进件表单新增法人手机号并在申请记录持久化。这是走法人路线的唯一新增字段(法人姓名、证件号、证件有效期、证件居住地址已由现有进件采集)。不得复用联系人手机号冒充法人手机号。 |
| | | - [ ] 发起前校验前置条件:商户必须已成功入驻微信、且所需能力已开通;材料不齐时给出具体缺项而不是提交后被驳回。材料上传复用现有图片上传通道,文件类型按官方要求。 |
| | | - [ ] 商户端与后台都能发起实名并展示状态;需要本人办理时展示通道回传的授权二维码。二维码渲染两端已实现(商户端与后台详情页均渲染 `qrcode_data`),本票只负责把它接上,不重复实现。 |
| | | - [ ] 授权完成判定必须是申请单审核通过**且**授权成功:`APPLYMENT_STATE_PASSED` 单独不成立,`authorize_stat` 必须为 `1`。 |
| | | - [ ] 修正 `FREEZED` 的判定:`APPLYMENT_STATE_FREEZED` 同时返回 `authorize_stat=1` 时,授权其实已经成功,不能显示成“待办理”;要展示官方 `reject_reason` 与指引。 |
| | | - [ ] 业务开通异步通知补读 `real_name_applyment_stat` 与 `qrcode_data`:通道会把实名状态与授权二维码推到现有 `merchant-business-open-notify` 回调,当前处理器一个字段都没读。 |
| | | - [ ] 未决申请不重复提交:以 `applyment_stat` 状态机判断,`EDITTING`/`WAITTING_*` 保持原申请;`REJECTED` 时按官方要求更换业务申请编号重来,未知结果先查询。重复点击、并发入口、重复或乱序通知不产生多份实名申请。 |
| | | - [ ] 收款就绪纳入实名:未完成微信实名认证与授权前不允许收款。`MerchantPaymentReadinessService` 当前只看进件审核通过、结算卡 token、通道账户状态、自动结算与取现能力,必须补上微信业务开通与实名/授权。 |
| | | - [ ] 模拟集成测试覆盖:材料不齐拒绝、发起受理、审核中、审核通过但未授权、授权成功、`FREEZED` 且已授权、驳回后换号重提、查询错误保留最后有效状态、并发与重复通知幂等;完成两端页面与权限验收。 |
| | | - [ ] 实名属于需要本人办理的步骤,提供可执行指引;不得把本地状态改写成通道已授权,也不得在缺少通道证据时显示完成。 |
| | | - [ ] 范围外(交易、退款、核销、登录)只读记录并交接;不修改参考商户,不创建真实测试商户。 |
| | | - [x] 把恢复路径里的单商户硬编码换成申请单自身的 huifu_id:`advanceDelay` 与 `acceptDelayCallback` 改用该申请的 `providerHuifuId`,删除 `HuifuOnboardingProgressGateway.openWechat` 那个抛异常的占位(无调用方),并修掉视图里「微信行业规则仍待平台确认,暂不提交微信补开」的过期文案。 |
| | | - [x] **`supports()` 的白名单保留不动**:把它换成「有结算卡且未被拒绝」会给所有商户同时切换行为——进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新(`MerchantSettlementCallbackIdempotencyTests`),实测 5 个测试会红。那是 03 票的策略变更,本票不动;实名阶段先只对白名单内的申请生效,由 03 放开范围。 |
| | | - [x] 新建实名申请通道适配 `HuifuMerchantRealnameGateway`:调用 `v2.merchant.busi.realname`,走法人(`contact_type=LEGAL`),必填法人姓名/手机号/身份证号,企业另填 `owner` 与 `identification_address`。经办人(`SUPER`)需要加盖公章的《微信业务办理授权函》,无法自助提交,不作为平台路径。 |
| | | - [x] 进件表单新增法人手机号(V145 加 `legal_mobile` 列,初始化器同步)并在申请记录持久化。实名接口的 `name`/`mobile`/`id_card_number` 三项都必须是法人本人的,不能拿联系人手机号顶替——实测那张商户的联系人是经办人、法人是另一个人。 |
| | | - [x] 发起前校验前置条件:以分阶段进度里「微信支付」阶段 `confirmed` 的通道证据为准(不看本地标志位),未确认时拒绝并提示先刷新;材料缺项一次列全,避免提交后被通道驳回——驳回要换业务申请编号重来。 |
| | | - [x] 商户端与后台都能发起实名并展示状态(`POST /api/merchant/onboarding/realname` 与后台 `…/{id}/realname`,都沿用既有权限);两端只在「未发起」或「已驳回」时给出发起按钮。二维码展示复用两端已有的 `qrcode_data` 渲染,不重复实现。 |
| | | - [x] 授权完成判定改为**只看 `authorize_stat`**:`APPLYMENT_STATE_PASSED` 单独不算完成,而 `FREEZED`(「该主体已完成过入驻」)也可能已经授权成功,按申请单状态判会把已授权好的商户显示成待办理。`NOAPPLY`/`CANCELED` 归为未申请并给出「尚未发起」的指引。 |
| | | - [x] 业务开通异步通知补读 `real_name_applyment_stat`:通道会把实名状态随通知推过来,原来一个字段都没读。该通知不含授权状态,所以 `APPLYMENT_STATE_PASSED` 只映射为 `processing`;二维码不解析通知里的,仍以查询为准。 |
| | | - [x] 未决申请不重复提交:`processing`/`approved` 一律去查询而不重新提交;只有通道明确驳回(`failed`)且传 `retry` 才换新的业务申请编号;提交结果未知时保持 `processing`,避免给同一商户报出两份实名申请。 |
| | | - [x] 实名属于需要本人办理的步骤:阶段原因给出可执行指引(含「尚未发起」与「待本人办理」两种),不把本地状态改写成通道已授权,缺少通道证据时不显示完成。 |
| | | - [x] 范围外(交易、退款、核销、登录)只读记录并交接;不修改参考商户,不创建真实测试商户。 |
| | | - [ ] **收款就绪纳入实名 —— 移交 03。** 把微信业务开通与实名/授权纳入 `MerchantPaymentReadinessService` 需要每个商户都有分阶段的微信与实名证据,而这依赖 03 放开灰度门槛。本票先落会让**所有**商户(包括没有进度记录的存量商户)都收不了款,是明确的功能倒退,所以与 03 的灰度放开一起落。 |
| | | - [ ] 并发与重复通知的幂等、两端页面与权限的真实验收:本票已用集成测试覆盖材料不齐、发起受理、审核中、审核通过但未授权、授权成功、`FREEZED` 且已授权、驳回后换号重提、查询错误保留最后有效状态、未知结果不重复提交;并发入口与重复通知的页面级验收随 03 的端到端验收一起做。 |
| | | |
| | | **完成演示:** 在新企业商户上完成微信业务开通后发起微信实名认证,状态随通道推进(审核中/待本人办理/已授权),本人扫码后刷新显示授权成功;未完成实名时收款判定为不就绪并给出缺项。 |
| | | |
| | |
| | | |
| | | ## Comments |
| | | |
| | | - 2026-09-16:用户确认“实名走法人”“未完成实名前不允许商户收款”两项决策后发布。它是新商户收款链路的第三个阶段,与 03 并行、不被任何票阻塞。发布时它同时承担解开 02 ⇄ 03 死锁的作用(02 的阶段切换条件原本要求指定商户实名/授权已确认成功,而实名发起能力当前为零);**当日该商户已随 02 关闭而不再需要测试**,死锁一并消失,本票的成立理由回到“任何商户要能收款都需要实名”这一条。发布工单不表示已实施或验收通过。 |
| | | - 2026-09-16 实名能力交付:新增 `HuifuMerchantRealnameGateway`/`HuifuMerchantRealnameCommand`/`MerchantRealnameService`,V145 加 `legal_mobile` 与 `realname_*` 列,两端入口接通,进件表单补法人手机号。过程中修掉两处真实判定问题:授权完成原来要求 `APPLYMENT_STATE_PASSED`,会把 `FREEZED` + `authorize_stat=1`(真实样本就是这张)误显示成待办理;业务开通异步通知里的 `real_name_applyment_stat` 原来没读。**收款就绪门槛移交给 03**:实测把它单独落下去会让所有没有进度记录的存量商户都收不了款——它必须和 03 放开灰度门槛一起落。验证:定向 `HuifuMerchantRealnameTests` 5、`MerchantRealnameServiceTests` 8 全绿;全量 211 项仅 `docs/agents/testing.md` 所列 3 项基线失败;后台 `vue-tsc` 通过。本票仍未完成的部分只剩「收款就绪门槛」(已移交)与页面级并发/权限验收(随 03)。 |
| | | |
| | | - 2026-09-16 第一步完成:把恢复路径里的单商户硬编码换成申请单自身的 huifu_id。原计划是「删掉只服务一张商户的恢复路径、进度按申请单通用」,动手后发现后半句不是清理而是策略变更——`supports()` 同时是「分阶段模型 vs 旧单状态模型」的路由开关,换成通用规则会让 5 个测试变红(进件失败不再释放银行卡、回调改走分阶段刷新)。该策略变更已改派给 03,本票只保留不改变策略的部分。 |
| | | |
| | | - 2026-09-16:用户确认“实名走法人”“未完成实名前不允许商户收款”两项决策后发布。它是新商户收款链路的第三个阶段,与 03 并行、不被任何票阻塞。发布时它同时承担解开 02 ⇄ 03 死锁的作用;**当日该商户已随 02 关闭而不再需要测试**,死锁一并消失,本票的成立理由回到“任何商户要能收款都需要实名”这一条。发布工单不表示已实施或验收通过。 |
| | |
| | | private com.talentcard.server.business.merchant.MerchantOnboardingProgressService progressService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private com.talentcard.server.business.merchant.MerchantRealnameService realnameService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private com.talentcard.server.business.merchant.MerchantSettlementService settlementService; |
| | | |
| | | 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 Map<String,Object> refreshOnboarding(Long id, boolean retry) { return settlementService.refreshProgress(id, retry); } |
| | | /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */ |
| | | public Map<String,Object> submitRealname(Long id, boolean retry) { return realnameService.submit(id, retry); } |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; } |
| | |
| | | |
| | | @Service |
| | | public class MerchantSettlementService { |
| | | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MerchantSettlementService.class); |
| | | private static final Map<String, String> MCC_BY_MERCHANT_TYPE = Map.ofEntries( |
| | | Map.entry("food", "5812"), Map.entry("scenic", "7991"), Map.entry("hotel", "7011"), Map.entry("apartment", "7011"), |
| | | Map.entry("cinema", "7832"), Map.entry("fuel", "5541"), Map.entry("parking", "7523"), Map.entry("carWash", "7542"), |
| | |
| | | private MerchantOnboardingProgressService onboardingProgressService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private MerchantRealnameService realnameService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway onboardingQueryGateway; |
| | | |
| | | public Map<String, Object> refreshOnboarding(MerchantPrincipal principal, boolean retry) { |
| | | store(principal); |
| | | List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null order by id desc limit 1",principal.merchantId(),principal.storeId()); |
| | | if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found"); |
| | | return onboardingProgressService.refresh(((Number)records.get(0).get("id")).longValue(),retry); |
| | | return refreshProgress(((Number)records.get(0).get("id")).longValue(),retry); |
| | | } |
| | | |
| | | /** |
| | | * 刷新分阶段进度:**先推进已满足前置条件的开通步骤,再查询回报结果**。 |
| | | * |
| | | * <p>分阶段模型下这条路径是商户与后台推进开通的唯一入口。渠道侧的微信商户配置与 |
| | | * 业务开通原本只由进件审核回调触发,而分阶段申请的回调走的是进度刷新——不在这里补上 |
| | | * 推进,新商户的微信开通就永远不会被发起。{@code continuePostOnboardingProvisioning} |
| | | * 自身按状态判断是否还需要做,重复调用无害。 |
| | | */ |
| | | public Map<String, Object> refreshProgress(Long applicationId, boolean retry) { |
| | | List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",applicationId); |
| | | if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found"); |
| | | String applicationNo=clean(records.get(0).get("applicationNo")); |
| | | if (!applicationNo.isBlank()) continuePostOnboardingProvisioning(applicationNo); |
| | | return onboardingProgressService.refresh(applicationId,retry); |
| | | } |
| | | |
| | | /** 微信实名认证:商户主动推进的下一步。未决申请不会重复提交,见 MerchantRealnameService。 */ |
| | |
| | | if (huifuId.isBlank() || "rejected".equals(clean(application.get("status")))) return; |
| | | |
| | | String wechatStatus = clean(application.get("wechatConfigStatus")); |
| | | // 本地状态可能与通道脱节(通道侧已经配好、本地还是 not_started)。微信商户配置是**添加**接口、 |
| | | // 成功后不支持修改删除(每种参数最多 5 组),配置已在通道生效时再提一次会留下重复配置。 |
| | | if (needsProvisioningStep(wechatStatus) && wechatConfigEffective(huifuId)) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_message=? where id=?", "通道已有配置,按已配置收口", application.get("id")); |
| | | wechatStatus = "approved"; |
| | | } |
| | | if (wechatStatus.isBlank() || "not_started".equals(wechatStatus) || "failed".equals(wechatStatus)) { |
| | | String requestNo = clean(application.get("wechatConfigRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "WC"); |
| | |
| | | |
| | | application = rows("select * from merchant_onboarding_application where id=? limit 1", application.get("id")).get(0); |
| | | String businessStatus = clean(application.get("businessOpenStatus")); |
| | | // 同理:通道侧已经开通微信业务时不再重新提交,否则会为同一商户开出第二份业务开通申请。 |
| | | if (needsProvisioningStep(businessStatus) && wechatBusinessEffective(huifuId)) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='approved', business_open_response_message=? where id=?", "通道已开通微信业务,按已开通收口", application.get("id")); |
| | | businessStatus = "approved"; |
| | | } |
| | | if (businessStatus.isBlank() || "not_started".equals(businessStatus) || "failed".equals(businessStatus)) { |
| | | String requestNo = clean(application.get("businessOpenRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "BO"); |
| | |
| | | * <p>这个异步通知只给申请单状态、不给授权状态,所以 {@code APPLYMENT_STATE_PASSED} 只能算 |
| | | * processing——授权是否完成要等查询到 {@code authorize_stat=1} 才能定,不能凭这一步就写 approved。 |
| | | */ |
| | | private static boolean needsProvisioningStep(String status) { |
| | | return status.isBlank() || "not_started".equals(status) || "failed".equals(status); |
| | | } |
| | | |
| | | /** |
| | | * 微信小程序支付配置是否已经在通道生效:人才端 AppID 是否绑定在 {@code fee_type=01} 的配置项下。 |
| | | * 与分阶段进度用的是同一份证据。查询失败按「未生效」处理——宁可多提一次也不要漏配。 |
| | | */ |
| | | private boolean wechatConfigEffective(String huifuId) { |
| | | try { |
| | | Map<String, Object> config = onboardingQueryGateway.queryWechatConfig(huifuId); |
| | | if (config == null || !"00000000".equals(clean(config.get("resp_code")))) return false; |
| | | String appId = huifuPaymentProperties.getMiniProgramAppId().trim(); |
| | | for (Map<String, Object> entry : providerList(config.get("atu_config_bo_list"))) { |
| | | if (!"01".equals(clean(entry.get("fee_type")))) continue; |
| | | for (Map<String, Object> item : providerList(entry.get("wx_config_list"))) { |
| | | if ("1".equals(clean(item.get("config_type"))) && appId.equals(clean(item.get("config_value")))) return true; |
| | | } |
| | | } |
| | | return false; |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Huifu merchant WeChat config probe failed: huifuId={}", huifuId, exception); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 微信业务是否已经在通道开通:商户详情的 {@code qry_wx_conf_list} 里出现线上三场景之一即视为已开通。 |
| | | * 业务开通是申请型接口,重复提交会给同一商户开出第二份申请,所以提交前必须先查。 |
| | | */ |
| | | private boolean wechatBusinessEffective(String huifuId) { |
| | | try { |
| | | Map<String, Object> detail = onboardingQueryGateway.queryCapabilities(huifuId); |
| | | if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) return false; |
| | | return providerList(detail.get("qry_wx_conf_list")).stream() |
| | | .anyMatch(item -> List.of("4", "5", "12").contains(clean(item.get("pay_scene")))); |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Huifu merchant business open probe failed: huifuId={}", huifuId, exception); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** 通道返回的列表字段可能是 JSON 字符串也可能是已解析的数组,两种都要能吃。 */ |
| | | @SuppressWarnings("unchecked") |
| | | private static List<Map<String, Object>> providerList(Object raw) { |
| | | if (raw == null) return List.of(); |
| | | Object parsed = raw instanceof String value ? (value.isBlank() ? null : com.alibaba.fastjson.JSON.parse(value)) : raw; |
| | | if (!(parsed instanceof List<?> list)) return List.of(); |
| | | return list.stream().filter(Map.class::isInstance).map(item -> (Map<String, Object>) item).toList(); |
| | | } |
| | | |
| | | private static String realnameStatus(String applymentStat) { |
| | | return switch (applymentStat) { |
| | | case "APPLYMENT_STATE_REJECTED" -> "failed"; |
| | |
| | | @Autowired JdbcTemplate jdbc; |
| | | @Autowired MerchantSettlementService service; |
| | | @MockitoBean HuifuOnboardingProgressGateway gateway; |
| | | // 刷新现在会先推进开通步骤(微信配置、业务开通),所以这两个网关也要挡住, |
| | | // 否则测试会真的打到汇付。 |
| | | @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway; |
| | | @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway; |
| | | @Autowired org.springframework.web.context.WebApplicationContext context; |
| | | @Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth; |
| | | |
| | |
| | | Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); |
| | | jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); |
| | | Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card); |
| | | return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); |
| | | } |
| | | |
| | | /** |
| | | * 分阶段刷新要**先推进开通步骤,再回报结果**。渠道侧的微信小程序支付配置与商户业务开通原本 |
| | | * 只由进件审核回调触发,而分阶段申请的回调走的是进度刷新——不在这里补上推进,新商户的微信 |
| | | * 开通就永远不会被发起。 |
| | | */ |
| | | @Test |
| | | void refreshingAdvancesTheWechatProvisioningSteps() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | 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")); |
| | | when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功")); |
| | | when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中")); |
| | | |
| | | service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); |
| | | |
| | | assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("processing"); |
| | | verify(wechatConfigGateway,times(1)).configure(anyString(),eq("6666000241335656")); |
| | | verify(businessOpenGateway,times(1)).open(anyString(),eq("6666000241335656"),anyString(),anyString(),anyBoolean()); |
| | | } |
| | | |
| | | /** |
| | | * 通道侧已经生效的步骤不再重复提交。微信商户配置是**添加**接口(成功后不支持改删、每种参数 |
| | | * 最多 5 组),业务开通是申请型接口,重复提交会在通道留下重复配置或第二份申请。 |
| | | */ |
| | | @Test |
| | | void refreshingSkipsStepsAlreadyEffectiveOnTheChannel() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | 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", |
| | | "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4","fee_rate","0.70")))); |
| | | 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")))))); |
| | | |
| | | service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); |
| | | |
| | | assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | verify(wechatConfigGateway,never()).configure(anyString(),anyString()); |
| | | verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean()); |
| | | } |
| | | |
| | | private String statusOf(String column,String applicationNo) { |
| | | return jdbc.queryForObject("select "+column+" from merchant_onboarding_application where application_no=?",String.class,applicationNo); |
| | | } |
| | | |
| | | /** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */ |
| | | private Map<String,Object> provisioningFixture() { |
| | | String key="T02P-"+System.nanoTime(); |
| | | jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); |
| | | Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); |
| | | jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); |
| | | Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); |
| | | jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); |
| | | Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card); |
| | | return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); |
| | | } |
| | |
| | | Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); |
| | | jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); |
| | | Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","20002","resp_desc","数据权限认证失败")); |
| | | Map<String,Object> result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false); |
| | | assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限"); |