From 7baa02daf5e08ce4624fc96f60e296e7282be14b Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 16 九月 2026 00:40:28 +0800
Subject: [PATCH] feat(merchant): 补上微信实名认证的发起能力
---
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java | 1
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java | 34 ++
talent-card-server/src/main/resources/db/migration/V145__merchant_onboarding_legal_mobile_and_realname.sql | 20 +
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue | 11
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java | 8
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java | 28 +
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java | 115 ++++++++
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java | 4
talent-card-admin/src/views/MerchantOnboardingDetailView.vue | 26 +
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java | 4
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue | 32 ++
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameCommand.java | 23 +
talent-card-admin/src/api/admin.ts | 2
rencaika_merchant/services/settlement.uts | 3
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantRealnameService.java | 179 +++++++++++++
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java | 4
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantRealnameServiceTests.java | 200 +++++++++++++++
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java | 75 +++++
18 files changed, 759 insertions(+), 10 deletions(-)
diff --git a/rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue b/rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
index 56d87ac..6675e51 100644
--- a/rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
+++ b/rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
@@ -20,6 +20,7 @@
<text class="result-reason">刷新会查询进度并继续办理已满足条件的缺失能力。本人实名或授权办理后,请再次刷新。</text>
<button :disabled="syncing" :loading="syncing" @tap="refreshProgress(false)">刷新进度</button>
<button v-if="retryable" :disabled="syncing" @tap="refreshProgress(true)">重试失败步骤</button>
+ <button v-if="realnameActionable" :disabled="syncing" @tap="startRealname">{{realnameButtonLabel}}</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>
@@ -28,7 +29,7 @@
</template>
<script setup lang="uts">
- import { loadOnboardingStatus, refreshOnboardingProgress, SettlementOnboarding, OnboardingStage } from '../../services/settlement.uts'
+ import { loadOnboardingStatus, refreshOnboardingProgress, submitOnboardingRealname, SettlementOnboarding, OnboardingStage } from '../../services/settlement.uts'
import { getMerchantOnboardingDraftStorageKey } from '../../services/onboarding-draft.uts'
const onboardingStatus = ref('pending')
@@ -67,6 +68,35 @@
uni.showToast({ title: error.message != null ? error.message : '刷新失败,请稍后重试', icon: 'none' })
})
}
+ const realnameStage = computed((): OnboardingStage | null => {
+ for (let index = 0; index < stages.value.length; index++) {
+ if (stages.value[index].key == 'realname') return stages.value[index]
+ }
+ return null
+ })
+ // 只有「还没发起」或「已被通道驳回」才给发起入口。处理中、待本人办理、已确认都不能重复发起——
+ // 未决申请重复提交会给同一商户报出两份实名申请,而驳回后重发起是要换业务申请编号的。
+ const realnameActionable = computed((): boolean => {
+ const stage = realnameStage.value
+ return stage != null && (stage.status == 'missing' || stage.status == 'failed')
+ })
+ const realnameRetry = computed((): boolean => {
+ const stage = realnameStage.value
+ return stage != null && stage.status == 'failed'
+ })
+ const realnameButtonLabel = computed((): string => realnameRetry.value ? '重新发起微信实名认证' : '发起微信实名认证')
+ const startRealname = () => {
+ if (syncing.value) return
+ syncing.value = true
+ submitOnboardingRealname(realnameRetry.value, (item: SettlementOnboarding) => {
+ applyProgress(item)
+ syncing.value = false
+ uni.showToast({ title: '实名认证已提交,请按提示完成办理', icon: 'none' })
+ }, (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 '进件审核未通过'
diff --git a/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue b/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
index 7db3186..f00e5d9 100644
--- a/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
+++ b/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -37,6 +37,7 @@
<view class="field-block"><text class="field-label required">{{merchantType == 'enterprise' ? '法人证件有效期开始日期' : '本人证件有效期开始日期'}}</text><picker class="date-picker" mode="date" :value="subjectStartDate" start="1900-01-01" end="2099-12-31" @change="onSubjectStartChange"><view class="select-shell" :class="invalidField == 'subjectStartDate' ? 'field-invalid' : ''"><text :class="subjectStartDate.length == 0 ? 'select-placeholder' : ''">{{subjectStartDate.length > 0 ? subjectStartDate : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'subjectStartDate'" class="field-error">{{errorMessage}}</text></view>
<view v-if="subjectValidity != '长期有效'" class="field-block"><text class="field-label required">{{merchantType == 'enterprise' ? '法人证件有效期截止日期' : '本人证件有效期截止日期'}}</text><picker class="date-picker" mode="date" :value="subjectEndDate" start="1900-01-01" end="2099-12-31" @change="onSubjectEndChange"><view class="select-shell" :class="invalidField == 'subjectEndDate' ? 'field-invalid' : ''"><text :class="subjectEndDate.length == 0 ? 'select-placeholder' : ''">{{subjectEndDate.length > 0 ? subjectEndDate : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'subjectEndDate'" class="field-error">{{errorMessage}}</text></view>
<view class="field-block"><text class="field-label required">{{merchantType == 'enterprise' ? '法人证件地址' : '本人证件地址'}}</text><view class="input-shell" :class="invalidField == 'legalCertificateAddress' ? 'field-invalid' : ''"><input class="text-input" v-model="legalCertificateAddress" maxlength="255" placeholder="请输入" placeholder-class="input-placeholder" @input="clearFieldError('legalCertificateAddress')" /></view><text class="field-hint">与身份证上的住址一致,至少 4 个字</text><text v-if="invalidField == 'legalCertificateAddress'" class="field-error">{{errorMessage}}</text></view>
+ <view class="field-block"><text class="field-label required">{{merchantType == 'enterprise' ? '法人手机号' : '本人手机号'}}</text><view class="input-shell" :class="invalidField == 'legalMobile' ? 'field-invalid' : ''"><input class="text-input" type="number" v-model="legalMobile" maxlength="11" placeholder="请输入" placeholder-class="input-placeholder" @input="clearFieldError('legalMobile')" /></view><text class="field-hint">微信实名认证要用经营者/法人本人的手机号,不是联系人手机号</text><text v-if="invalidField == 'legalMobile'" class="field-error">{{errorMessage}}</text></view>
</view>
<view v-if="currentStep == 3">
@@ -80,7 +81,7 @@
const draftNotice = ref(false)
const submitError = ref('')
- const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('')
+ const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const legalMobile = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('')
const merchantTypeOptions = ['企业', '个人']
const merchantTypeIndex = ref(0)
const validityOptions = ['固定期限', '长期']
@@ -134,6 +135,9 @@
const address = legalCertificateAddress.value.trim()
if (address.length == 0) return 'legalCertificateAddress|请输入证件地址(与身份证一致)'
if (address.length < 4) return 'legalCertificateAddress|证件地址至少 4 个字,请写完整'
+ const legalMobileValue = legalMobile.value.trim()
+ if (legalMobileValue.length == 0) return 'legalMobile|请输入手机号(微信实名认证要用)'
+ if (!(/^1\d{10}$/).test(legalMobileValue)) return 'legalMobile|请输入正确的 11 位手机号'
}
if (step == 3) {
if (merchantType.value == 'enterprise' && contactName.value.trim().length == 0) return 'contactName|请输入管理员姓名'
@@ -171,7 +175,7 @@
}
const goPrevStep = () => { saveDraft(); if (currentStep.value > 1) currentStep.value = currentStep.value - 1; resetScroll() }
- const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, currentStep: currentStep.value })
+ const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, legalMobile: legalMobile.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, currentStep: currentStep.value })
const saveDraft = () => { uni.setStorageSync(draftStorageKey(), JSON.stringify(draftPayload())) }
const clearDraft = () => { uni.removeStorageSync(draftStorageKey()) }
const restoreDraft = () => {
@@ -211,6 +215,7 @@
if (draft.subjectStartDate != null) subjectStartDate.value = draft.subjectStartDate as string
if (draft.subjectEndDate != null) subjectEndDate.value = draft.subjectEndDate as string
if (draft.legalCertificateAddress != null) legalCertificateAddress.value = draft.legalCertificateAddress as string
+ if (draft.legalMobile != null) legalMobile.value = draft.legalMobile as string
if (draft.contactName != null) contactName.value = draft.contactName as string
if (draft.contactMobile != null) contactMobile.value = draft.contactMobile as string
if (draft.administratorEmail != null) administratorEmail.value = draft.administratorEmail as string
@@ -264,7 +269,7 @@
}
}
submitting.value = true
- submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) })
+ submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), legalMobile: legalMobile.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) })
}
onShow(() => {
diff --git a/rencaika_merchant/services/settlement.uts b/rencaika_merchant/services/settlement.uts
index e784efd..87c910f 100644
--- a/rencaika_merchant/services/settlement.uts
+++ b/rencaika_merchant/services/settlement.uts
@@ -11,6 +11,9 @@
/** 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 : ''), 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)
+// 微信实名认证:业务开通确认生效后由商户主动推进的下一步。未决申请不会重复提交,
+// retry 只对「通道已明确驳回」有意义。
+export const submitOnboardingRealname = (retry: boolean, success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: '/api/merchant/onboarding/realname?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)
diff --git a/talent-card-admin/src/api/admin.ts b/talent-card-admin/src/api/admin.ts
index 7370949..8366cf3 100644
--- a/talent-card-admin/src/api/admin.ts
+++ b/talent-card-admin/src/api/admin.ts
@@ -117,6 +117,8 @@
}
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 } }) }
+// 微信实名认证:平台运营代为发起。未决申请不会重复提交,retry 只对通道已明确驳回时有意义。
+export function submitMerchantRealname(id: number | string, retry = false) { return http.post<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}/realname`, null, { params: { retry } }) }
export function getAdminNews(params: NewsArticleQuery = {}) {
return http.get<PageResult<NewsArticle>, PageResult<NewsArticle>>('/api/admin/business/news', { params })
diff --git a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
index b3cf297..0ea4e33 100644
--- a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
+++ b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
-import { getMerchantOnboardingDetail, refreshMerchantOnboarding } from '../api/admin'
+import { getMerchantOnboardingDetail, refreshMerchantOnboarding, submitMerchantRealname } from '../api/admin'
import { hasAdminPermission } from '../utils/permissions'
+import { ElMessage } from 'element-plus'
const route = useRoute()
const loading = ref(false)
@@ -15,6 +16,28 @@
try {
const progress = await refreshMerchantOnboarding(String(route.params.id), retry)
if (detail.value) detail.value.progress = progress
+ } finally { syncing.value = false }
+}
+
+function realnameStage() {
+ const stages = detail.value?.progress?.stages
+ return Array.isArray(stages) ? stages.find((stage: Record<string, any>) => stage.key === 'realname') : undefined
+}
+// 只有「还没发起」或「已被通道驳回」才给发起入口:处理中、待本人办理、已确认都不能重复发起,
+// 未决申请重复提交会给同一商户报出两份实名申请。
+function realnameActionable() {
+ const status = realnameStage()?.status
+ return status === 'missing' || status === 'failed'
+}
+async function startRealname() {
+ if (syncing.value) return
+ syncing.value = true
+ try {
+ const progress = await submitMerchantRealname(String(route.params.id), realnameStage()?.status === 'failed')
+ if (detail.value) detail.value.progress = progress
+ ElMessage.success('实名认证已提交,请按提示完成办理')
+ } catch (error: any) {
+ ElMessage.error(error?.message || '发起实名失败,请稍后重试')
} finally { syncing.value = false }
}
@@ -78,6 +101,7 @@
<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>
+ <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && realnameActionable()" :disabled="syncing" @click="startRealname()">{{ realnameStage()?.status === 'failed' ? '重新发起微信实名' : '发起微信实名' }}</el-button>
</section>
<h3 class="onboarding-heading">商户进件</h3>
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
index 7b921de..b2e5cb6 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -33,6 +33,7 @@
@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)); }
+ @PostMapping("/merchant-settlement/onboardings/{id}/realname") public ApiResponse<Map<String,Object>> submitRealname(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean retry) { return ApiResponse.success(settlementService.submitRealname(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)); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
index 7bf7c74..cef62c4 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -32,9 +32,13 @@
private final HuifuSettlementRecordGateway settlementRecordGateway;
@org.springframework.beans.factory.annotation.Autowired
private com.talentcard.server.business.merchant.MerchantOnboardingProgressService progressService;
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.talentcard.server.business.merchant.MerchantRealnameService realnameService;
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> 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; }
/**
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
index c9cca38..07fe25b 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -110,10 +110,7 @@
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");
+ if (!status.isBlank()) stages.put("realname", realnameStage(status, text(realname.get("authorize_stat"))));
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")))) {
@@ -270,6 +267,7 @@
private static String stageReason(String key, String status) {
if ("confirmed".equals(status)) return "已取得通道生效证据";
if ("realname".equals(key) && "action_required".equals(status)) return "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营";
+ if ("realname".equals(key) && "missing".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";
@@ -277,6 +275,28 @@
if ("failed".equals(status)) return "该步骤未通过,请联系平台运营核对资料后局部重试";
return "尚未取得通道生效证据";
}
+ /**
+ * 实名阶段的状态映射。微信实名分「实名认证申请」与「商户授权」两个阶段,各有各的状态,
+ * 必须分别看:
+ *
+ * <ul>
+ * <li>{@code NOAPPLY}/{@code CANCELED} 是没申请或已作废,要提示去发起,不能和「需要本人办理」
+ * 混为一谈;</li>
+ * <li>{@code REJECTED} 是驳回,要按原因补资料并更换业务申请编号重来;</li>
+ * <li>授权是否完成**只看 {@code authorize_stat}**,不看申请单状态。{@code PASSED} 单独不等于
+ * 授权完成;反过来 {@code FREEZED}(「该主体已完成过入驻」)也可能已经授权成功——真实样本
+ * 里就是 {@code APPLYMENT_STATE_FREEZED} + {@code authorize_stat=1}。按申请单状态判会把
+ * 已经授权好的商户显示成待办理。</li>
+ * </ul>
+ */
+ private static String realnameStage(String applymentStat, String authorizeStat) {
+ if ("APPLYMENT_STATE_REJECTED".equals(applymentStat)) return "failed";
+ if ("APPLYMENT_STATE_NOAPPLY".equals(applymentStat) || "APPLYMENT_STATE_CANCELED".equals(applymentStat)) return "missing";
+ if ("1".equals(authorizeStat)) return "confirmed";
+ if ("APPLYMENT_STATE_EDITTING".equals(applymentStat) || "APPLYMENT_STATE_WAITTING_FOR_AUDIT".equals(applymentStat)) return "pending";
+ return "action_required";
+ }
+
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 "";
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
index f03023e..d1cc124 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
@@ -29,6 +29,10 @@
@Size(max = 512) String legalIdFrontUrl,
@Size(max = 512) String legalIdBackUrl,
@Size(max = 255) String legalCertificateAddress,
+ // 法人本人手机号。微信实名认证走法人(contact_type=LEGAL)时必填,且必须是法人自己的号码;
+ // 与 contactMobile(联系人)是两个不同的人,不要把联系人号码当作法人号码填进来。
+ // 进件时选填,发起实名时校验必填。
+ @Pattern(regexp = "|1\\d{10}") String legalMobile,
@Size(max = 32) String administratorEmail,
@NotBlank @Size(max = 64) String contactName,
@NotBlank @Pattern(regexp = "1\\d{10}") String contactMobile,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
index dc366f1..7865ee2 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -114,6 +114,14 @@
columns.put("capability_response_code", "varchar(32) default null");
columns.put("capability_response_message", "varchar(512) default null");
columns.put("capability_verified_at", "datetime default null");
+ // 法人手机号:微信实名认证走法人时的必填项,实名接口的 name/mobile/id_card_number
+ // 都必须是法人本人的,不能用联系人手机号顶替。见 V145 的说明。
+ columns.put("legal_mobile", "varchar(20) default null");
+ columns.put("realname_status", "varchar(16) not null default 'not_started'");
+ columns.put("realname_request_no", "varchar(32) default null");
+ columns.put("realname_apply_no", "varchar(32) default null");
+ columns.put("realname_response_code", "varchar(32) default null");
+ columns.put("realname_response_message", "varchar(512) default null");
for (Map.Entry<String, String> column : columns.entrySet()) {
if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue());
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantRealnameService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantRealnameService.java
new file mode 100644
index 0000000..ab3c2f7
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantRealnameService.java
@@ -0,0 +1,179 @@
+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.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;
+
+/**
+ * 微信实名认证的发起与状态推进(法人路径)。
+ *
+ * <p>实名认证是「微信支付权限」的前置:业务开通成功之后才做,而不做完实名与授权就收不了款。
+ * 平台原先只能查询和展示实名状态({@code v2.merchant.busi.realname.query}),没有任何发起能力,
+ * 于是进度永远停在 {@code APPLYMENT_STATE_NOAPPLY}、二维码永远不出现。本服务补上发起这一环。
+ *
+ * <p><b>授权不是平台能代替的动作。</b>申请单审核通过后,需要本人扫描通道回传的小程序码完成授权;
+ * 本服务只负责发起、推进状态与判断是否真的完成({@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<String> ACCEPTED_CODES = List.of("00000000","00000100","90000000","10000","0");
+ /** 未决状态:有这些状态就不要重新提交,先查询。 */
+ private static final List<String> 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;
+
+ public MerchantRealnameService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto,
+ HuifuMerchantRealnameGateway realnameGateway, HuifuOnboardingProgressGateway progressGateway,
+ MerchantOnboardingProgressService progressService) {
+ this.jdbc = jdbc;
+ this.sensitiveDataCrypto = sensitiveDataCrypto;
+ this.realnameGateway = realnameGateway;
+ this.progressGateway = progressGateway;
+ this.progressService = progressService;
+ }
+
+ /**
+ * 发起(或推进)指定申请的微信实名认证。
+ *
+ * @param retry 只对「通道已明确驳回」的重发起有意义:给新申请单号重来。未决申请无论传什么
+ * 都不会重复提交——官方明确驳回后才允许更换业务申请编号重新提交。
+ */
+ public Map<String,Object> submit(Long applicationId, boolean retry) {
+ Map<String,Object> 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<String,Object> application) {
+ Map<String,Object> 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<String,Object> 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")));
+
+ List<String> 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 (!missing.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "发起实名还缺:" + String.join(";", missing));
+
+ return new HuifuMerchantRealnameCommand(legalName, legalMobile, legalIdNumber, legalAddress, legalIsBeneficiary(huifuId, legalName));
+ }
+
+ /**
+ * 法人是否最终受益人({@code owner},企业开通微信支付时必填)。
+ *
+ * <p>从通道的受益人列表判断:法人姓名出现在 {@code beneficiary_info_list} 里就是本人即受益人。
+ * 查不到时按官方默认取 N(非受益人),此时通道可能追加受益人材料要求。
+ */
+ private boolean legalIsBeneficiary(String huifuId, String legalName) {
+ Map<String,Object> 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<String,Object> 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<String,Object> application(Long id) {
+ List<Map<String,Object>> 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<Map<String,Object>> 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<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 beneficiary list");
+ return list.stream().map(item -> (Map<String,Object>)item).toList();
+ }
+}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
index 29b38c8..10884d6 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -55,12 +55,22 @@
private final MerchantPaymentReadinessService paymentReadinessService;
@org.springframework.beans.factory.annotation.Autowired
private MerchantOnboardingProgressService onboardingProgressService;
+ @org.springframework.beans.factory.annotation.Autowired
+ private MerchantRealnameService realnameService;
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);
+ }
+
+ /** 微信实名认证:商户主动推进的下一步。未决申请不会重复提交,见 MerchantRealnameService。 */
+ public Map<String, Object> submitRealname(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 realnameService.submit(((Number)records.get(0).get("id")).longValue(),retry);
}
public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
@@ -120,7 +130,7 @@
String no = BusinessIdGenerator.next("MO");
String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
- jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, administrator_email, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), key.trim());
+ jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), key.trim());
String cardNumber = request.bankCardNumber().trim();
String cardKey = key.trim() + ":initial";
if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
@@ -402,6 +412,14 @@
String auditStatus = clean(data.get("audit_status"));
String businessStatus = wechatBusinessStatus(data);
String applyNo = clean(data.get("apply_no"));
+ // 这个异步通知里还带着微信实名状态与授权二维码(notify_type=W),原来两个字段一个都没读,
+ // 等于把通道主动送来的实名进展丢掉了。有实名状态就落到 realname_status,并借一次进度刷新
+ // 把二维码与阶段状态一并取回——二维码只在查询里给,通知里不解析它。
+ String realnameState = clean(data.get("real_name_applyment_stat"));
+ if (!realnameState.isBlank()) {
+ jdbc.update("update merchant_onboarding_application set realname_status=? where id=?", realnameStatus(realnameState), application.get("id"));
+ if (onboardingProgressService.supports(application)) refreshRecoveryAfterCommit(((Number) application.get("id")).longValue());
+ }
boolean rejected = List.of("N", "F", "C").contains(auditStatus.toUpperCase()) || "F".equalsIgnoreCase(businessStatus) || !providerSuccess(code);
if (rejected) {
String reason = providerReason(code, message);
@@ -574,6 +592,20 @@
private String responseCode(Map<String, Object> data) { String code = clean(data.get("sub_resp_code")); return code.isBlank() ? clean(data.get("resp_code")) : code; }
private String responseMessage(Map<String, Object> data) { String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); return message.isBlank() ? clean(data.get("resp_desc")) : message; }
+ /**
+ * 通道侧实名申请单状态映射到本地 realname_status。
+ *
+ * <p>这个异步通知只给申请单状态、不给授权状态,所以 {@code APPLYMENT_STATE_PASSED} 只能算
+ * processing——授权是否完成要等查询到 {@code authorize_stat=1} 才能定,不能凭这一步就写 approved。
+ */
+ private static String realnameStatus(String applymentStat) {
+ return switch (applymentStat) {
+ case "APPLYMENT_STATE_REJECTED" -> "failed";
+ case "APPLYMENT_STATE_NOAPPLY", "APPLYMENT_STATE_CANCELED" -> "not_started";
+ default -> "processing";
+ };
+ }
+
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, "当前门店不可操作"); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
index cfcaa90..759462d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -46,6 +46,10 @@
@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/realname")
+ public ApiResponse<Map<String, Object>> submitRealname(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal, @RequestParam(defaultValue = "false") boolean retry) { return ApiResponse.success(merchantSettlementService.submitRealname(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)); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameCommand.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameCommand.java
new file mode 100644
index 0000000..5f9326b
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameCommand.java
@@ -0,0 +1,23 @@
+package com.talentcard.server.business.payment.huifu;
+
+/**
+ * 微信实名认证申请(法人路径)的入参。
+ *
+ * <p>实名认证走法人,所以这里全部是**法人本人**的资料,不是联系人。数据库里对应
+ * {@code subject_name}(法人姓名,企业进件时 {@code legalName} 取的就是它)、
+ * {@code subject_id_no_encrypted}(法人身份证号,加密存储)、{@code legal_mobile}(法人手机号,
+ * V145 新增)、{@code legal_certificate_address}(法人证件居住地址)。
+ *
+ * @param legalName 法人姓名
+ * @param legalMobile 法人本人手机号,不是联系人手机号
+ * @param legalIdNumber 法人身份证号
+ * @param legalAddress 法人证件居住地址,企业必填
+ * @param legalIsBeneficiary 法人是否最终受益人,对应 {@code owner};企业开通微信支付时必填
+ */
+public record HuifuMerchantRealnameCommand(
+ String legalName,
+ String legalMobile,
+ String legalIdNumber,
+ String legalAddress,
+ boolean legalIsBeneficiary
+) {}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java
new file mode 100644
index 0000000..8a6a3c6
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java
@@ -0,0 +1,115 @@
+package com.talentcard.server.business.payment.huifu;
+
+import com.alibaba.fastjson.JSON;
+import com.huifu.bspay.sdk.opps.client.BasePayClient;
+import com.huifu.bspay.sdk.opps.core.BasePay;
+import com.huifu.bspay.sdk.opps.core.config.MerConfig;
+import com.talentcard.server.common.BusinessException;
+import com.talentcard.server.common.ErrorCode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+/**
+ * 微信实名认证申请({@code v2.merchant.busi.realname})。
+ *
+ * <p>实名认证是「微信支付权限」的前置:官方文档写明它的适用对象是「已成功入驻微信的商户,
+ * 需要开通微信支付权限」,也就是说业务开通完成后才做这一步,且不做完实名与授权就收不了款。
+ *
+ * <p>本网关只走法人({@code contact_type=LEGAL})。经办人({@code SUPER})额外要求经办人证件
+ * 正反面与**加盖公章的《微信业务办理授权函》**,盖章件无法自助提交,因此不作为平台路径。
+ *
+ * <p>注意实名是两个阶段:申请单审核通过({@code APPLYMENT_STATE_PASSED})**不等于**授权完成,
+ * 还要由本人扫描通道回传的小程序码并满足 {@code authorize_stat=1}。本网关只负责发起申请。
+ */
+@Service
+public class HuifuMerchantRealnameGateway {
+ private static final Object SDK_LOCK = new Object();
+ private static final Logger log = LoggerFactory.getLogger(HuifuMerchantRealnameGateway.class);
+ /** 微信业务开通类型:线上标准费率。实名查询的 pay_scene 引用的是它,不是业务开通的支付场景 4/5/12。 */
+ static final String PAY_SCENE = "01";
+ private final HuifuPaymentProperties properties;
+
+ public HuifuMerchantRealnameGateway(HuifuPaymentProperties properties) { this.properties = properties; }
+
+ public Result submit(String requestNo, String huifuId, HuifuMerchantRealnameCommand command) {
+ ensureConfigured();
+ Map<String, Object> request = buildRequest(requestNo, huifuId, command);
+ try {
+ synchronized (SDK_LOCK) {
+ initializeSdk();
+ Map<String, Object> response = BasePayClient.request(request, "v2.merchant.busi.realname", false);
+ log.info("Huifu merchant realname response: requestNo={}, huifuId={}, response={}", requestNo, huifuId, JSON.toJSONString(response));
+ return Result.from(response);
+ }
+ } catch (BusinessException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("Huifu merchant realname request error: requestNo={}, huifuId={}", requestNo, huifuId, exception);
+ throw new HuifuPaymentGatewayException("Huifu merchant realname request failed", outcomeUnknown(exception), exception);
+ }
+ }
+
+ /** 组装请求体,与发送解耦,便于直接断言字段。 */
+ static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, HuifuMerchantRealnameCommand command) {
+ return new HuifuMerchantRealnameGateway(properties).buildRequest(requestNo, huifuId, command);
+ }
+
+ private Map<String, Object> buildRequest(String requestNo, String huifuId, HuifuMerchantRealnameCommand command) {
+ Map<String, Object> request = new LinkedHashMap<>();
+ request.put("req_seq_id", requestNo);
+ request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
+ request.put("huifu_id", huifuId);
+ request.put("pay_scene", PAY_SCENE);
+ // 法人本人。文档要求联系人类型为 LEGAL 时 name/mobile/id_card_number 都必须是法人本人的,
+ // 法人手机号与联系人手机号是两个不同的人,调用方必须传法人自己的号码。
+ request.put("name", command.legalName().trim());
+ request.put("mobile", command.legalMobile().trim());
+ request.put("id_card_number", command.legalIdNumber().trim().toUpperCase());
+ request.put("contact_type", "LEGAL");
+ // 企业开通微信支付时必填:经营者/法人是否为最终受益人。
+ request.put("owner", command.legalIsBeneficiary() ? "Y" : "N");
+ putIfPresent(request, "identification_address", command.legalAddress());
+ // 本项目主体是普通企业,不是金融机构。
+ request.put("finance_institution_flag", "N");
+ return request;
+ }
+
+ private void ensureConfigured() {
+ if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
+ if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey()))
+ throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant realname configuration is incomplete");
+ }
+
+ private void initializeSdk() throws Exception {
+ BasePay.debug = false;
+ BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST;
+ MerConfig config = new MerConfig();
+ config.setSysId(properties.getSystemId().trim());
+ config.setProductId(properties.getProductId().trim());
+ config.setRsaPrivateKey(properties.getPrivateKey().trim());
+ config.setRsaPublicKey(properties.getPlatformPublicKey().trim());
+ BasePay.initWithMerConfig(config);
+ }
+
+ private static void putIfPresent(Map<String, Object> request, String key, String value) { if (!blank(value)) request.put(key, value.trim()); }
+ private static boolean blank(String value) { return value == null || value.isBlank(); }
+ private boolean outcomeUnknown(Exception exception) { String message = exception.getMessage() == null ? "" : exception.getMessage().toLowerCase(); return message.contains("timeout") || message.contains("timed out") || message.contains("no response"); }
+
+ @SuppressWarnings("unchecked")
+ public record Result(boolean accepted, String applymentId, String code, String message) {
+ static Result from(Map<String, Object> response) {
+ Object raw = response.get("data");
+ Map<String, Object> data = raw instanceof Map<?, ?> value ? (Map<String, Object>) value : raw instanceof String json && !json.isBlank() ? JSON.parseObject(json, Map.class) : response;
+ String code = value(data, "sub_resp_code", "resp_code", "code");
+ return new Result(success(code), value(data, "applyment_id", "apply_no"), code, value(data, "sub_resp_desc", "resp_desc", "message"));
+ }
+ /** 与业务开通网关保持一致:90000000=审核中属于已受理。 */
+ private static boolean success(String code) { return "00000000".equals(code) || "00000100".equals(code) || "90000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); }
+ private static String value(Map<String, Object> data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; }
+ }
+}
diff --git a/talent-card-server/src/main/resources/db/migration/V145__merchant_onboarding_legal_mobile_and_realname.sql b/talent-card-server/src/main/resources/db/migration/V145__merchant_onboarding_legal_mobile_and_realname.sql
new file mode 100644
index 0000000..c136eb0
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V145__merchant_onboarding_legal_mobile_and_realname.sql
@@ -0,0 +1,20 @@
+-- 进件申请增加「法人手机号」与微信实名认证的状态列。
+--
+-- 法人手机号:微信实名认证走法人(contact_type=LEGAL)时,实名接口的 name/mobile/
+-- id_card_number 三项都必须是**法人本人**的。进件原本只收联系人手机号(contact_mobile),
+-- 而联系人经常不是法人——实测那张商户的联系人是经办人、法人是另一个人,所以必须单独收一列,
+-- 不能用 contact_mobile 顶替,否则会把经办人的号码当作法人的报给微信审核。
+--
+-- realname_* 列:实名申请的关联与结果。规格要求「未决申请不重复提交」,判断依据就是这几列
+-- (已有 apply_no 就查询原申请、不重新提交),与 business_open_* 同一套模式。
+--
+-- 注意:不要写 `add column if not exists`——那是 MariaDB 的扩展,MySQL 8.0.36 会报 ERROR 1064
+-- (本仓 V143 的注释里记过这个坑)。迁移只执行一次,本来也不需要它。
+-- 开发库 spring.flyway.enabled=false,这些列由 MerchantOnboardingSchemaInitializer 补建。
+alter table merchant_onboarding_application
+ add column legal_mobile varchar(20) default null,
+ add column realname_status varchar(16) not null default 'not_started',
+ add column realname_request_no varchar(32) default null,
+ add column realname_apply_no varchar(32) default null,
+ add column realname_response_code varchar(32) default null,
+ add column realname_response_message varchar(512) default null;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantRealnameServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantRealnameServiceTests.java
new file mode 100644
index 0000000..15daf90
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantRealnameServiceTests.java
@@ -0,0 +1,200 @@
+package com.talentcard.server.business.merchant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import java.util.Map;
+import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway;
+import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
+import com.talentcard.server.common.BusinessException;
+import com.talentcard.server.common.SensitiveDataCrypto;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+/**
+ * 微信实名认证的发起、材料校验与状态闭环(法人路径)。
+ *
+ * <p>实名是微信支付权限的前置,也是「未完成实名不允许收款」这条决策的落点。这里钉住四件事:
+ * 材料缺项要一次说清、微信业务未确认前不发起、未决申请不重复提交、授权是否完成只看
+ * {@code authorize_stat}({@code FREEZED} 也可能已经授权成功)。
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+class MerchantRealnameServiceTests {
+ @Autowired MerchantOnboardingProgressService progressBean;
+ @Autowired JdbcTemplate jdbc;
+ @Autowired MerchantRealnameService service;
+ @Autowired SensitiveDataCrypto crypto;
+ @MockitoBean HuifuMerchantRealnameGateway realnameGateway;
+ @MockitoBean HuifuOnboardingProgressGateway progressGateway;
+
+ @Test
+ void missingLegalMobileIsReportedBeforeAnyProviderCall() {
+ long applicationId = fixture(null);
+ assertThatThrownBy(() -> service.submit(applicationId, false))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("法人手机号");
+ verifyNoInteractions(realnameGateway);
+ }
+
+ /**
+ * 材料校验里点明了「不能用联系人手机号顶替」:那张真实商户的联系人是经办人、法人是另一个人,
+ * 混淆会把经办人的号码报给微信审核,而实名被驳回要换业务申请编号重来。
+ */
+ @Test
+ void theLegalMobileIsNotTakenFromTheContactMobile() {
+ long applicationId = fixture(null, "13700000000");
+ assertThatThrownBy(() -> service.submit(applicationId, false))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("不能用联系人手机号顶替");
+ verifyNoInteractions(realnameGateway);
+ }
+
+ @Test
+ void realnameIsNotSubmittedBeforeTheWechatBusinessIsConfirmed() {
+ long applicationId = fixture("13900000000", "13700000000", null);
+ assertThatThrownBy(() -> service.submit(applicationId, false))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("微信支付业务开通尚未确认生效");
+ verifyNoInteractions(realnameGateway);
+ }
+
+ @Test
+ void anAcceptedApplicationIsStoredAsProcessingWithItsApplymentId() {
+ long applicationId = fixture("13900000000");
+ when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000",
+ "beneficiary_info_list","[{\"bo_name\":\"张维\"}]"));
+ when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
+
+ service.submit(applicationId, false);
+
+ Map<String,Object> saved = jdbc.queryForMap("select realname_status,realname_request_no,realname_apply_no from merchant_onboarding_application where id=?", applicationId);
+ assertThat(saved.get("realname_status")).isEqualTo("processing");
+ assertThat(String.valueOf(saved.get("realname_apply_no"))).isEqualTo("2000002327252449");
+ // 业务申请编号按申请单号派生并带随机后缀:驳回后重发起必须能换成新的,所以不能只按申请单号派生。
+ String applicationNo = jdbc.queryForObject("select application_no from merchant_onboarding_application where id=?", String.class, applicationId);
+ assertThat(String.valueOf(saved.get("realname_request_no"))).startsWith(applicationNo + "RN").hasSizeLessThanOrEqualTo(32);
+ // 法人是受益人(受益人列表里有法人本人),owner 应取 Y。
+ var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class);
+ verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture());
+ assertThat(command.getValue().legalIsBeneficiary()).isTrue();
+ assertThat(command.getValue().legalName()).isEqualTo("张维");
+ assertThat(command.getValue().legalMobile()).isEqualTo("13900000000");
+ }
+
+ /** 未决申请不重复提交:重复点击只会去查询通道,不会再报一份实名申请。 */
+ @Test
+ void aPendingApplicationIsNeverSubmittedTwice() {
+ long applicationId = fixture("13900000000");
+ when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
+ when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
+ when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
+
+ service.submit(applicationId, false);
+ service.submit(applicationId, false);
+ service.submit(applicationId, true);
+
+ verify(realnameGateway,times(1)).submit(anyString(),anyString(),any());
+ }
+
+ /** 通道明确拒绝才允许换新申请单号重来,且新号必须与旧号不同。 */
+ @Test
+ void aRejectedApplicationCanBeResubmittedWithANewRequestNumber() {
+ long applicationId = fixture("13900000000");
+ when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
+ when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
+ when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(false,"","00000001","基础参数校验失败"));
+
+ service.submit(applicationId, false);
+ assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("failed");
+ assertThatThrownBy(() -> service.submit(applicationId, false)).hasMessageContaining("上次实名申请未通过");
+
+ when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252450","00000000","成功"));
+ service.submit(applicationId, true);
+
+ var requests = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(realnameGateway,times(2)).submit(requests.capture(),anyString(),any());
+ assertThat(requests.getAllValues()).doesNotHaveDuplicates();
+ }
+
+ /** 结果未知时保持未决,不能回退成可重提交,否则会给同一商户报两份实名申请。 */
+ @Test
+ void anUnknownOutcomeStaysPendingAndBlocksResubmission() {
+ long applicationId = fixture("13900000000");
+ when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
+ when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
+ when(realnameGateway.submit(anyString(),anyString(),any())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null));
+
+ service.submit(applicationId, false);
+ assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("processing");
+
+ service.submit(applicationId, true);
+ verify(realnameGateway,times(1)).submit(anyString(),anyString(),any());
+ }
+
+ /**
+ * 授权是否完成只看 {@code authorize_stat}:{@code PASSED} 单独不算完成,而 {@code FREEZED}
+ * (「该主体已完成过入驻」)也可能已经授权成功——真实样本就是 FREEZED + authorize_stat=1,
+ * 按申请单状态判会把它显示成待办理。
+ */
+ @Test
+ void authorizationIsDecidedByAuthorizeStatNotByTheApplicationState() {
+ MerchantOnboardingProgressService progress = progressBean;
+ assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","1")).isEqualTo("confirmed");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","0")).isEqualTo("action_required");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","1")).isEqualTo("confirmed");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","0")).isEqualTo("action_required");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_NOAPPLY","0")).isEqualTo("missing");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_REJECTED","0")).isEqualTo("failed");
+ assertThat(stageOf(progress,"APPLYMENT_STATE_WAITTING_FOR_AUDIT","0")).isEqualTo("pending");
+ }
+
+ private String stageOf(MerchantOnboardingProgressService progress, String applymentStat, String authorizeStat) {
+ long applicationId = fixture("13900000000");
+ when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
+ when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
+ when(progressGateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat",applymentStat,"authorize_stat",authorizeStat));
+ Map<String,Object> view = progress.refresh(applicationId, false);
+ return stageStatus(view,"realname");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String stageStatus(Map<String,Object> view, String key) {
+ for (Object item : (java.util.List<Object>) view.get("stages")) {
+ Map<String,Object> stage = (Map<String,Object>) item;
+ if (key.equals(stage.get("key"))) return String.valueOf(stage.get("status"));
+ }
+ throw new IllegalStateException("stage not found: " + key);
+ }
+
+ private long fixture(String legalMobile) { return fixture(legalMobile, "13700000000", WECHAT_CONFIRMED); }
+
+ private long fixture(String legalMobile, String contactMobile) { return fixture(legalMobile, contactMobile, WECHAT_CONFIRMED); }
+
+ /** 进度里「微信支付」已确认;传 null 表示还没有进度记录,微信业务开通未确认。 */
+ private static final String WECHAT_CONFIRMED = "{\"wechat\":\"confirmed\"}";
+
+ /**
+ * 建一张走分阶段模型的进件申请:结算卡已关联、法人资料按实测那张商户的形状填
+ * ——法人与联系人**不是同一个人**(联系人阳野、法人张维)。
+ */
+ private long fixture(String legalMobile, String contactMobile, String stagesJson) {
+ String key = "RN-" + 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,subject_id_no_masked,subject_id_no_encrypted,legal_certificate_address,legal_mobile,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'enterprise','张维','513********4667',?,'成都市成华区东丽街138号9栋2单元16楼5号',?,'阳野',?,?,'pending','6666000241335656','original-apply',?)",
+ merchant, store, key, crypto.encrypt("513021198109304667"), legalMobile, contactMobile, key, card);
+ Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key);
+ jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, stagesJson == null ? "{}" : stagesJson);
+ return applicationId;
+ }
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java
new file mode 100644
index 0000000..e4df3e3
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java
@@ -0,0 +1,75 @@
+package com.talentcard.server.business.payment.huifu;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * 微信实名认证申请的请求组装与回执判定。
+ *
+ * <p>实名走法人:{@code name}/{@code mobile}/{@code id_card_number} 三项都必须是**法人本人**的,
+ * 不能用联系人资料顶替。实测那张商户的联系人是经办人、法人是另一个人,混淆会把经办人的号码
+ * 报给微信审核,而这一关是驳回要换申请单重来的。
+ */
+class HuifuMerchantRealnameTests {
+
+ @Test
+ void anEnterpriseRealnameApplicationUsesTheLegalPersonOnly() {
+ Map<String,Object> request = request(command("成都市成华区东丽街138号9栋2单元16楼5号",true));
+
+ assertThat(request).containsEntry("contact_type","LEGAL");
+ // pay_scene 引用的是「微信业务开通类型」,线上是 01,不是业务开通的支付场景 4/5/12。
+ assertThat(request).containsEntry("pay_scene","01");
+ assertThat(request).containsEntry("name","张维");
+ assertThat(request).containsEntry("mobile","13900000000");
+ assertThat(request).containsEntry("id_card_number","513021198109304667");
+ assertThat(request).containsEntry("owner","Y");
+ assertThat(request).containsEntry("identification_address","成都市成华区东丽街138号9栋2单元16楼5号");
+ assertThat(request).containsEntry("finance_institution_flag","N");
+ }
+
+ @Test
+ void aLegalPersonWhoIsNotTheBeneficiaryIsDeclaredAsSuch() {
+ assertThat(request(command("成都市成华区东丽街138号",false))).containsEntry("owner","N");
+ }
+
+ /** 法人姓名与身份证号按大写提交;手机号必须原样是法人本人的。 */
+ @Test
+ void theLegalIdNumberIsNormalisedToUpperCase() {
+ Map<String,Object> request = request(new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667x","成都市",true));
+ assertThat(request).containsEntry("id_card_number","513021198109304667X");
+ }
+
+ @Test
+ void aSubmittedRealnameApplicationCountsAsAccepted() {
+ HuifuMerchantRealnameGateway.Result accepted = HuifuMerchantRealnameGateway.Result.from(
+ Map.of("resp_code","00000000","resp_desc","成功","applyment_id","2000002327252449"));
+ assertThat(accepted.accepted()).isTrue();
+ assertThat(accepted.applymentId()).isEqualTo("2000002327252449");
+ assertThat(accepted.code()).isEqualTo("00000000");
+ }
+
+ @Test
+ void aRejectedRealnameApplicationIsNotAccepted() {
+ HuifuMerchantRealnameGateway.Result rejected = HuifuMerchantRealnameGateway.Result.from(
+ Map.of("resp_code","00000001","resp_desc","基础参数校验失败"));
+ assertThat(rejected.accepted()).isFalse();
+ assertThat(rejected.applymentId()).isEmpty();
+ }
+
+ private Map<String,Object> request(HuifuMerchantRealnameCommand command) {
+ HuifuPaymentProperties properties = new HuifuPaymentProperties();
+ properties.setEnabled(true);
+ properties.setSystemId("6666000220591054");
+ properties.setProductId("KAZX");
+ properties.setPrivateKey("x");
+ properties.setPlatformPublicKey("y");
+ return HuifuMerchantRealnameGateway.buildRequestForTest(properties,"RN-TEST-1","6666000241335656",command);
+ }
+
+ private static HuifuMerchantRealnameCommand command(String legalAddress, boolean legalIsBeneficiary) {
+ return new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667",legalAddress,legalIsBeneficiary);
+ }
+}
--
Gitblit v1.7.1