package com.talentcard.server.business.merchant; import com.alibaba.fastjson.JSON; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand; import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway; import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.common.SensitiveDataCrypto; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Random; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; /** * 微信实名认证的发起与状态推进(法人路径)。 * *

实名认证是「微信支付权限」的前置:业务开通成功之后才做,而不做完实名与授权就收不了款。 * 平台原先只能查询和展示实名状态({@code v2.merchant.busi.realname.query}),没有任何发起能力, * 于是进度永远停在 {@code APPLYMENT_STATE_NOAPPLY}、二维码永远不出现。本服务补上发起这一环。 * *

授权不是平台能代替的动作。申请单审核通过后,需要本人扫描通道回传的小程序码完成授权; * 本服务只负责发起、推进状态与判断是否真的完成({@code APPLYMENT_STATE_PASSED} **且** * {@code authorize_stat=1}),不会把本地状态改写成已授权。 */ @Service public class MerchantRealnameService { private static final Logger log = LoggerFactory.getLogger(MerchantRealnameService.class); /** 受理类回执:与业务开通一致,90000000=审核中属于已受理。 */ private static final List ACCEPTED_CODES = List.of("00000000","00000100","90000000","10000","0"); /** 未决状态:有这些状态就不要重新提交,先查询。 */ private static final List PENDING_STATES = List.of("processing","approved"); private final JdbcTemplate jdbc; private final SensitiveDataCrypto sensitiveDataCrypto; private final HuifuMerchantRealnameGateway realnameGateway; private final HuifuOnboardingProgressGateway progressGateway; private final MerchantOnboardingProgressService progressService; private final HuifuPictureUploadGateway pictureUploadGateway; private final HuifuPaymentProperties huifuPaymentProperties; public MerchantRealnameService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuMerchantRealnameGateway realnameGateway, HuifuOnboardingProgressGateway progressGateway, MerchantOnboardingProgressService progressService, HuifuPictureUploadGateway pictureUploadGateway, HuifuPaymentProperties huifuPaymentProperties) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.realnameGateway = realnameGateway; this.progressGateway = progressGateway; this.progressService = progressService; this.pictureUploadGateway = pictureUploadGateway; this.huifuPaymentProperties = huifuPaymentProperties; } /** * 发起(或推进)指定申请的微信实名认证。 * * @param retry 只对「通道已明确驳回」的重发起有意义:给新申请单号重来。未决申请无论传什么 * 都不会重复提交——官方明确驳回后才允许更换业务申请编号重新提交。 */ public Map submit(Long applicationId, boolean retry) { Map application = application(applicationId); if (!progressService.supports(application)) throw new BusinessException(ErrorCode.CONFLICT, "该进件申请暂不支持分阶段开通"); String huifuId = clean(application.get("providerHuifuId")); if (huifuId.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户还没有汇付商户号,无法发起实名认证"); String status = clean(application.get("realnameStatus")); if (PENDING_STATES.contains(status)) { // 未决申请不重复提交:已有申请单或结果未知时先查询通道。 return progressService.refresh(applicationId, false); } if ("failed".equals(status) && !retry) { throw new BusinessException(ErrorCode.CONFLICT, "上次实名申请未通过,请按驳回原因补齐资料后重新发起"); } requireWechatBusinessOpen(application); HuifuMerchantRealnameCommand command = command(application, huifuId); String requestNo = requestNo(clean(application.get("applicationNo"))); jdbc.update("update merchant_onboarding_application set realname_status='processing', realname_request_no=?, realname_apply_no=null, realname_response_code=null, realname_response_message=null where id=?", requestNo, applicationId); try { HuifuMerchantRealnameGateway.Result result = realnameGateway.submit(requestNo, huifuId, command); if (!result.accepted()) { jdbc.update("update merchant_onboarding_application set realname_status='failed', realname_apply_no=nullif(?,''), realname_response_code=?, realname_response_message=? where id=?", result.applymentId(), result.code(), result.message(), applicationId); } else { jdbc.update("update merchant_onboarding_application set realname_status='processing', realname_apply_no=nullif(?,''), realname_response_code=?, realname_response_message=? where id=?", result.applymentId(), result.code(), result.message(), applicationId); } } catch (HuifuPaymentGatewayException exception) { // 结果未知时保持 processing:不回退成可重提交,否则可能给同一商户报两份实名申请。 String state = exception.isOutcomeUnknown() ? "processing" : "failed"; log.warn("Huifu merchant realname outcome {}: applicationId={}, requestNo={}", state, applicationId, requestNo, exception); jdbc.update("update merchant_onboarding_application set realname_status=?, realname_response_message=? where id=?", state, exception.getMessage() == null ? "" : truncate(exception.getMessage()), applicationId); } return progressService.refresh(applicationId, true); } /** * 发起实名要求商户已成功入驻微信。以分阶段进度里「微信支付」阶段的证据为准, * 而不是只看本地某个标志位——本地标志位与通道脱节时会把没开通的商户放过去。 */ private void requireWechatBusinessOpen(Map application) { Map view = progressService.view(application); Object stages = view.get("stages"); if (stages instanceof List list) { for (Object item : list) { if (item instanceof Map stage && "wechat".equals(clean(stage.get("key"))) && "confirmed".equals(clean(stage.get("status")))) return; } } throw new BusinessException(ErrorCode.CONFLICT, "微信支付业务开通尚未确认生效,请先刷新进度确认开通结果,再发起实名认证"); } /** 法人资料。缺项要一次说清,不能提交后等通道驳回——驳回要换业务申请编号重来。 */ private HuifuMerchantRealnameCommand command(Map application, String huifuId) { String legalName = clean(application.get("subjectName")); String legalMobile = clean(application.get("legalMobile")); String legalIdNumber = decrypt(application.get("subjectIdNoEncrypted")); String legalAddress = clean(application.get("legalCertificateAddress")); boolean enterprise = "enterprise".equalsIgnoreCase(clean(application.get("subjectType"))); Map store = enterprise ? Map.of() : store(application); List missing = new ArrayList<>(); if (legalName.isBlank()) missing.add("法人姓名"); if (!legalMobile.matches("1\\d{10}")) missing.add("法人手机号(必须是法人本人的号码,不能用联系人手机号顶替)"); if (legalIdNumber.isBlank()) missing.add("法人身份证号"); if (enterprise && legalAddress.isBlank()) missing.add("法人证件居住地址"); if (!enterprise) requireMicroStoreMaterial(application, store, missing); if (!missing.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "发起实名还缺:" + String.join(";", missing)); return new HuifuMerchantRealnameCommand(legalName, legalMobile, legalIdNumber, legalAddress, legalIsBeneficiary(huifuId, legalName), enterprise ? null : microStore(application, store)); } /** * 小微(个人)主体在实名路径上另收一组门店材料,缺项先报出来。 * *

官网把这一组都标成 N,但每一条都注明「小微商户填写」;而实名被驳回要换业务申请编号重来, * 所以缺材料宁可在这里拦住,也不提交一份注定被驳回的申请。 */ private void requireMicroStoreMaterial(Map application, Map store, List missing) { if (clean(application.get("storeHeaderUrl")).isBlank()) missing.add("门店门头照"); if (clean(application.get("storeIndoorUrl")).isBlank()) missing.add("店内环境照"); if (clean(store.get("storeName")).isBlank()) missing.add("门店名称"); if (clean(application.get("legalIdFrontUrl")).isBlank() || clean(application.get("legalIdBackUrl")).isBlank()) missing.add("身份证正反面照片"); if (storeAddress(application, store).isBlank()) missing.add("门店地址"); } /** * 小微(个人)主体的门店材料,图片一律现传现取 fileId。 * *

{@code store_address_code} 用的是进件时选的经营市编码:官网示例 {@code 360400} 就是市编码, * 但字段名写的是「门店省市编码」、长度 16,是否要「省+市」拼接尚无官方说明 * (见交付记录里的待确认项),这里按示例的市编码提交。 */ private HuifuMerchantRealnameCommand.MicroStore microStore(Map application, Map store) { return new HuifuMerchantRealnameCommand.MicroStore( huifuPaymentProperties.getMerchantMicroBusinessType().trim(), clean(store.get("storeName")), pictureUploadGateway.uploadUrl(clean(application.get("storeHeaderUrl")), "F22"), pictureUploadGateway.uploadUrl(clean(application.get("storeIndoorUrl")), "F24"), clean(application.get("registeredCityCode")), storeAddress(application, store), pictureUploadGateway.uploadUrl(clean(application.get("legalIdFrontUrl")), "F40"), pictureUploadGateway.uploadUrl(clean(application.get("legalIdBackUrl")), "F41")); } /** * 门店地址取商家档案上的门店地址;档案没填时退回进件时的地址。 * *

个人主体的 {@code registered_address} 存的是证件地址(进件表单没有单独收经营详细地址), * 拿它当门店地址并不准确,但比留空更可能过审;商户在商家档案里补上门店地址后就会以档案为准。 */ private String storeAddress(Map application, Map store) { String address = clean(store.get("address")); return address.isBlank() ? clean(application.get("registeredAddress")) : address; } private Map store(Map application) { Object storeId = application.get("storeId"); if (storeId == null) return Map.of(); List> records = rows("select store_name, address from merchant_store where id=? and deleted=0 limit 1", storeId); return records.isEmpty() ? Map.of() : records.get(0); } /** * 法人是否最终受益人({@code owner},企业开通微信支付时必填)。 * *

从通道的受益人列表判断:法人姓名出现在 {@code beneficiary_info_list} 里就是本人即受益人。 * 查不到时按官方默认取 N(非受益人),此时通道可能追加受益人材料要求。 */ private boolean legalIsBeneficiary(String huifuId, String legalName) { Map detail = progressGateway.queryCapabilities(huifuId); if (detail == null) return false; Object raw = detail.get("beneficiary_info_list"); if (raw == null || clean(raw).isBlank() || "[]".equals(clean(raw))) return false; for (Map entry : configurations(raw)) { if (legalName.equals(clean(entry.get("bo_name")))) return true; } return false; } /** 业务申请编号要每天每商户唯一;驳回后重发起必须换成新的,所以不能只按申请单号派生。 */ private static String requestNo(String applicationNo) { String base = applicationNo + "RN"; String suffix = String.format("%06d", new Random().nextInt(1_000_000)); String value = base + suffix; return value.length() <= 32 ? value : value.substring(0, 32); } private Map application(Long id) { List> records = rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null", id); if (records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Onboarding application not found"); return records.get(0); } private String decrypt(Object cipherText) { String value = clean(cipherText); if (value.isBlank()) return ""; try { return clean(sensitiveDataCrypto.decrypt(value)); } catch (RuntimeException exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "法人身份证号解密失败,请核对加密配置"); } } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } private static String truncate(String value) { return value.length() <= 512 ? value : value.substring(0, 512); } private static String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); } @SuppressWarnings("unchecked") private static List> configurations(Object raw) { Object parsed = raw instanceof String value ? JSON.parse(value) : raw; if (!(parsed instanceof List list)) throw new IllegalArgumentException("Invalid provider beneficiary list"); return list.stream().map(item -> (Map)item).toList(); } }