shinnosuke
2026-08-05 971e637c2eebe93011a485ddfa99d4f19289811d
feat(admin): 添加优惠券人才等级数量配置功能并完善商户入驻流程

- 在优惠券功能中新增talentLevelQuantities字段用于配置各人才等级发放数量
- 更新前端优惠券编辑界面,支持人才等级开关和数量输入功能
- 优化优惠券金额验证范围为0.1-9999.9元
- 新增企业商户入驻汇付接口和服务类
- 完善支付服务中的汇付ID获取逻辑
- 优化路由配置和数据模型结构
- 移除已废弃的入驻审核接口
- 添加企业入驻时的表单验证逻辑
3个文件已添加
14个文件已修改
320 ■■■■■ 已修改文件
talent-card-admin/src/router/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/CouponCreateRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/CouponUpdateRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/auth/TalentAuthService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingCommand.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentCommand.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/router/index.ts
@@ -224,7 +224,7 @@
          meta: { title: '人才政策管理-发布', businessKind: 'talentPolicy' },
        },
        {
          path: 'talent-policy-categories',
          path: 'talent-policy-categories/:id',
          name: 'talent-policy-categories',
          component: () => import('../views/TalentPolicyEditView.vue'),
          meta: { title: '人才政策管理-修改', businessKind: 'talentPolicyCategory' },
talent-card-admin/src/types/admin.ts
@@ -298,6 +298,7 @@
  conditionAmount?: number
  couponAmount?: number
  talentLevels?: string
  talentLevelQuantities?: Record<string, number> | string
  validStart?: string
  validEnd?: string
  productScope?: string
@@ -320,6 +321,7 @@
  conditionAmount?: number
  couponAmount?: number
  talentLevels: string[]
  talentLevelQuantities: Record<string, number>
  validStart?: string
  validEnd?: string
  productScope: string
talent-card-admin/src/views/MerchantCreateView.vue
@@ -278,6 +278,8 @@
const merchantCoupons = ref<Coupon[]>([])
const couponProducts = ref<Product[]>([])
const selectedCouponProductIds = ref<Array<number | string>>([])
const couponLevelQuantities = reactive<Record<string, number>>({})
const couponTalentOptions = ['卓越人才卡', '杰出人才卡', '菁英人才卡']
const businessHours = reactive<BusinessHour[]>([{ id: 1, value: [] }])
@@ -869,6 +871,8 @@
  couponEditForm.governmentSubsidy = true
  couponEditForm.status = 1
  selectedCouponProductIds.value = []
  Object.keys(couponLevelQuantities).forEach((key) => delete couponLevelQuantities[key])
  couponEditForm.talentLevels.forEach((level) => { couponLevelQuantities[level] = 1 })
}
function parseTalentLevels(value?: string) {
@@ -885,6 +889,10 @@
  couponEditForm.couponAmount =
    detail.couponAmount === undefined || detail.couponAmount === null ? '' : String(detail.couponAmount)
  couponEditForm.talentLevels = parseTalentLevels(detail.talentLevels)
  Object.keys(couponLevelQuantities).forEach((key) => delete couponLevelQuantities[key])
  let savedQuantities: Record<string, number> = {}
  try { savedQuantities = typeof detail.talentLevelQuantities === 'string' ? JSON.parse(detail.talentLevelQuantities) : (detail.talentLevelQuantities || {}) } catch { savedQuantities = {} }
  couponEditForm.talentLevels.forEach((level) => { couponLevelQuantities[level] = Number(savedQuantities[level] || 1) })
  couponEditForm.validTime = detail.validStart && detail.validEnd ? [detail.validStart, detail.validEnd] : []
  couponEditForm.productScope = detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.storeConsume = detail.storeConsume === true || detail.storeConsume === 1
@@ -933,6 +941,15 @@
  selectedCouponProductIds.value = selectedCouponProductIds.value.filter((id) => String(id) !== String(row.id))
}
function toggleCouponTalentLevel(level: string, enabled: boolean) {
  if (enabled) {
    if (!couponEditForm.talentLevels.includes(level)) couponEditForm.talentLevels.push(level)
    if (!couponLevelQuantities[level]) couponLevelQuantities[level] = 1
    return
  }
  couponEditForm.talentLevels = couponEditForm.talentLevels.filter((item) => item !== level)
}
function couponPayload(): CouponPayload {
  return {
    merchantId: currentMerchantId(),
@@ -944,6 +961,7 @@
        : Number(couponEditForm.fullAmount),
    couponAmount: couponEditForm.couponAmount === '' ? undefined : Number(couponEditForm.couponAmount),
    talentLevels: couponEditForm.talentLevels,
    talentLevelQuantities: Object.fromEntries(couponEditForm.talentLevels.map((level) => [level, Number(couponLevelQuantities[level] || 1)])),
    validStart: couponEditForm.validTime?.[0],
    validEnd: couponEditForm.validTime?.[1],
    productScope: couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
@@ -960,8 +978,8 @@
    return
  }
  const couponAmount = Number(couponEditForm.couponAmount)
  if (couponEditForm.couponAmount === '' || !Number.isFinite(couponAmount) || couponAmount <= 0) {
    ElMessage.warning('请输入大于0的优惠券金额')
  if (couponEditForm.couponAmount === '' || !Number.isFinite(couponAmount) || couponAmount < 0.1 || couponAmount > 9999.9) {
    ElMessage.warning('优惠券金额必须在0.1-9999.9元之间')
    return
  }
  if (couponEditForm.useRequirement === 'full' && couponEditForm.fullAmount === '') {
@@ -1795,11 +1813,24 @@
            <div class="coupon-edit-row">
              <label>可用人才</label>
              <el-checkbox-group v-model="couponEditForm.talentLevels" class="coupon-talent-checkboxes">
              <el-checkbox-group v-model="couponEditForm.talentLevels" class="coupon-talent-checkboxes" style="display: none">
                <el-checkbox label="卓越人才卡">卓越人才卡</el-checkbox>
                <el-checkbox label="杰出人才卡">杰出人才卡</el-checkbox>
                <el-checkbox label="菁英人才卡">菁英人才卡</el-checkbox>
              </el-checkbox-group>
              <div class="coupon-talent-rows" style="display: grid; gap: 22px; padding: 4px 0">
                <div v-for="level in couponTalentOptions" :key="level" class="coupon-talent-row" style="display: flex; align-items: center; gap: 18px; min-height: 32px">
                  <strong style="width: 90px; font-weight: 500">{{ level }}</strong>
                  <el-switch :model-value="couponEditForm.talentLevels.includes(level)" @change="(enabled: boolean) => toggleCouponTalentLevel(level, enabled)" />
                  <el-input v-model="couponLevelQuantities[level]" :disabled="!couponEditForm.talentLevels.includes(level)" placeholder="请输入" style="width: 108px" />
                  <span>张</span>
                </div>
              </div>
              <div v-for="level in couponEditForm.talentLevels" :key="level" class="coupon-level-quantity" style="display: none">
                <span>{{ level }}</span>
                <el-input-number v-model="couponLevelQuantities[level]" :min="1" :max="99" :precision="0" :step="1" controls-position="right" />
                <span>张</span>
              </div>
            </div>
            <div class="coupon-edit-row">
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -6,7 +6,7 @@
type Kind = 'onboardings' | 'bank-cards' | 'withdrawals'
const kind = ref<Kind>('withdrawals')
const kind = ref<Kind>('onboardings')
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const total = ref(0)
@@ -14,6 +14,8 @@
const pageSize = ref(20)
const reviewVisible = ref(false)
const reviewing = ref<Record<string, any> | null>(null)
const onboardingDetailVisible = ref(false)
const onboardingDetail = ref<Record<string, any> | null>(null)
const filters = reactive({
  merchantName: '',
@@ -104,6 +106,11 @@
}
function openReview(row: Record<string, any>) {
  if (kind.value === 'onboardings') {
    onboardingDetail.value = row
    onboardingDetailVisible.value = true
    return
  }
  reviewing.value = row
  reviewVisible.value = true
}
@@ -270,6 +277,20 @@
      </template>
    </el-dialog>
  </div>
    <el-dialog v-model="onboardingDetailVisible" title="商户进件详情" width="560px" :close-on-click-modal="false">
      <div v-if="onboardingDetail" class="detail-grid">
        <div><span>进件编号</span><strong>{{ onboardingDetail.applicationNo || '-' }}</strong></div>
        <div><span>进件状态</span><el-tag :type="statusType(onboardingDetail.status)" effect="light">{{ statusLabel(onboardingDetail.status) }}</el-tag></div>
        <div><span>商家名称</span><strong>{{ onboardingDetail.merchantName || '-' }}</strong></div>
        <div><span>商户类型</span><strong>{{ onboardingDetail.merchantType || '-' }}</strong></div>
        <div><span>进件主体</span><strong>{{ onboardingDetail.subjectName || '-' }}</strong></div>
        <div><span>主体类型</span><strong>{{ onboardingDetail.subjectType === 'enterprise' ? '企业' : '个人' }}</strong></div>
        <div><span>统一社会信用代码</span><strong>{{ onboardingDetail.creditCode || '-' }}</strong></div>
        <div><span>联系人电话</span><strong>{{ onboardingDetail.contactMobile || '-' }}</strong></div>
        <div class="detail-full"><span>汇付反馈</span><strong>{{ onboardingDetail.rejectReason || '等待汇付审核结果' }}</strong></div>
      </div>
      <template #footer><el-button type="primary" @click="onboardingDetailVisible = false">关闭</el-button></template>
    </el-dialog>
</template>
<style scoped>
@@ -295,5 +316,10 @@
.review-summary div { display: flex; justify-content: space-between; gap: 16px; }
.review-summary span { color: #909399; }
.review-summary strong { color: #303133; font-weight: 500; text-align: right; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
.detail-grid > div { display: grid; gap: 5px; }
.detail-grid span { color: #909399; font-size: 13px; }
.detail-grid strong { color: #303133; font-weight: 500; word-break: break-all; }
.detail-full { grid-column: 1 / -1; }
@media (max-width: 1280px) { .query-form { grid-template-columns: repeat(2, minmax(280px, 1fr)); } }
</style>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -10,6 +10,7 @@
import java.util.Map;
import com.talentcard.server.business.BusinessIdGenerator;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher;
@@ -714,7 +715,7 @@
    ) {
        StringBuilder sql = new StringBuilder("""
                select c.id, c.merchant_id, m.merchant_name, c.coupon_name, c.condition_type,
                       c.condition_amount, c.coupon_amount, c.talent_levels, c.valid_start, c.valid_end,
                       c.condition_amount, c.coupon_amount, c.talent_levels, c.talent_level_quantities, c.valid_start, c.valid_end,
                       c.product_scope, c.store_consume, c.government_subsidy, c.use_count, c.status,
                       c.created_at, c.updated_at,
                       (select count(*) from merchant_coupon_product cp where cp.coupon_id = c.id) as product_count,
@@ -766,7 +767,7 @@
    public Map<String, Object> coupon(Long id) {
        Map<String, Object> result = one("""
                select c.id, c.merchant_id, m.merchant_name, c.coupon_name, c.condition_type,
                       c.condition_amount, c.coupon_amount, c.talent_levels, c.valid_start, c.valid_end,
                       c.condition_amount, c.coupon_amount, c.talent_levels, c.talent_level_quantities, c.valid_start, c.valid_end,
                       c.product_scope, c.store_consume, c.government_subsidy, c.use_count, c.status,
                       c.created_at, c.updated_at
                from merchant_coupon c
@@ -782,11 +783,13 @@
    public Map<String, Object> createCoupon(CouponCreateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
        validateCouponAmount(request.couponAmount());
        validateTalentLevelQuantities(request.talentLevelQuantities());
        jdbcTemplate.update("""
                insert into merchant_coupon(merchant_id, coupon_name, condition_type, condition_amount,
                                            coupon_amount, talent_levels, valid_start, valid_end,
                                            coupon_amount, talent_levels, talent_level_quantities, valid_start, valid_end,
                                            product_scope, store_consume, government_subsidy, status)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
                """,
                request.merchantId(),
                request.couponName(),
@@ -794,6 +797,7 @@
                request.conditionAmount(),
                valueOrZero(request.couponAmount()),
                joinTalentLevels(request.talentLevels()),
                levelQuantitiesJson(request.talentLevelQuantities()),
                blankToNull(request.validStart()),
                blankToNull(request.validEnd()),
                normalizeProductScope(request.productScope()),
@@ -808,10 +812,12 @@
    public Map<String, Object> updateCoupon(Long id, CouponUpdateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
        validateCouponAmount(request.couponAmount());
        validateTalentLevelQuantities(request.talentLevelQuantities());
        int updated = jdbcTemplate.update("""
                update merchant_coupon
                set merchant_id = ?, coupon_name = ?, condition_type = ?, condition_amount = ?,
                    coupon_amount = ?, talent_levels = ?, valid_start = ?, valid_end = ?,
                    coupon_amount = ?, talent_levels = ?, talent_level_quantities = ?, valid_start = ?, valid_end = ?,
                    product_scope = ?, store_consume = ?, government_subsidy = ?, status = ?
                where deleted = 0 and id = ?
                """,
@@ -821,6 +827,7 @@
                request.conditionAmount(),
                valueOrZero(request.couponAmount()),
                joinTalentLevels(request.talentLevels()),
                levelQuantitiesJson(request.talentLevelQuantities()),
                blankToNull(request.validStart()),
                blankToNull(request.validEnd()),
                normalizeProductScope(request.productScope()),
@@ -893,6 +900,17 @@
        return value == null ? BigDecimal.ZERO : value;
    }
    private void validateCouponAmount(BigDecimal value) {
        if (value == null || value.compareTo(new BigDecimal("0.1")) < 0 || value.compareTo(new BigDecimal("9999.9")) > 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券金额必须在0.1-9999.9元之间");
        }
    }
    private void validateTalentLevelQuantities(Map<String, Integer> values) {
        if (values == null) return;
        values.values().forEach(value -> { if (value == null || value < 1 || value > 99) throw new BusinessException(ErrorCode.BAD_REQUEST, "人才卡发放数量必须为1-99"); });
    }
    private String levelQuantitiesJson(Map<String, Integer> values) { return values == null || values.isEmpty() ? null : JSON.toJSONString(values); }
    private String toJson(Object value) {
        if (value == null) {
            return null;
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -26,7 +26,6 @@
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.onboardings(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/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)); }
  @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@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.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @PatchMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> reviewOnboarding(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewOnboarding(id, body.get("status"), body.get("reason"), body.get("huifuId"))); }
  @PatchMapping("/merchant-settlement/bank-cards/{id}") public ApiResponse<Map<String,Object>> reviewBankCard(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewBankCard(id, body.get("status"), body.get("reason"), body.get("tokenNo"))); }
  @PatchMapping("/merchant-settlement/withdrawals/{id}") public ApiResponse<Map<String,Object>> reviewWithdrawal(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewWithdrawal(id, body.get("status"), body.get("reason"))); }
  @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/CouponCreateRequest.java
@@ -2,9 +2,11 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -18,9 +20,10 @@
        String conditionType,
        BigDecimal conditionAmount,
        @NotNull
        @DecimalMin(value = "0.01")
        @DecimalMin(value = "0.1") @DecimalMax(value = "9999.9")
        BigDecimal couponAmount,
        List<String> talentLevels,
        Map<String, Integer> talentLevelQuantities,
        String validStart,
        String validEnd,
        @NotBlank
talent-card-server/src/main/java/com/talentcard/server/business/admin/CouponUpdateRequest.java
@@ -2,9 +2,11 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -18,9 +20,10 @@
        String conditionType,
        BigDecimal conditionAmount,
        @NotNull
        @DecimalMin(value = "0.01")
        @DecimalMin(value = "0.1") @DecimalMax(value = "9999.9")
        BigDecimal couponAmount,
        List<String> talentLevels,
        Map<String, Integer> talentLevelQuantities,
        String validStart,
        String validEnd,
        @NotBlank
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -36,11 +36,9 @@
        return page(select, count, "w", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
    }
    @Transactional public Map<String, Object> reviewOnboarding(Long id, String status, String reason, String huifuId) { return reviewOnboardingInternal(id, status, reason, huifuId); }
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); }
    public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { if ("paid".equals(normalizeWithdrawalStatus(status))) return withdrawalService.dispatch(id); return update("merchant_withdrawal", id, "failed", reason, "withdrawal"); }
    private Map<String,Object> reviewOnboardingInternal(Long id,String status,String reason,String huifuId) { String value=reviewStatus(status); if("approved".equals(value) && (huifuId==null || !huifuId.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu merchant ID is required when approving onboarding"); int affected=jdbc.update("update merchant_onboarding_application set status=?, reject_reason=?, provider_huifu_id=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?huifuId.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Onboarding is already handled or does not exist");return Map.of("id",id,"status",value); }
    private Map<String,Object> reviewBankCardInternal(Long id,String status,String reason,String tokenNo) { String value=reviewStatus(status); if("approved".equals(value) && (tokenNo==null || !tokenNo.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu withdrawal card token is required when approving a bank card"); int affected=jdbc.update("update merchant_bank_card set status=?, reject_reason=?, provider_token_no=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?tokenNo.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Bank card is already handled or does not exist");return Map.of("id",id,"status",value); }
    private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; }
    private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; }
talent-card-server/src/main/java/com/talentcard/server/business/auth/TalentAuthService.java
@@ -108,17 +108,47 @@
                where mobile = ? and deleted = 0 and status = 1
                limit 1 for update
                """, mobile);
        if (talents.isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前手机号未录入人才服务名单,请联系管理员");
        if (talents.isEmpty()) return issueSession(registerVisitor(openId, mobile));
        Map<String, Object> talent = talents.get(0);
        long talentId = number(talent.get("id"));
        List<Map<String, Object>> binding = rows("select id from talent_user where openid = ? and deleted = 0 limit 1 for update", openId);
        List<Map<String, Object>> binding = rows("""
                select t.id,
                       exists(select 1 from talent_card c where c.talent_id = t.id and c.deleted = 0 and c.card_type = 'PERSONAL') has_talent_card
                from talent_user t
                where t.openid = ? and t.deleted = 0
                limit 1 for update
                """, openId);
        if (!binding.isEmpty() && number(binding.get(0).get("id")) != talentId) {
            throw new BusinessException(ErrorCode.CONFLICT, "当前微信已绑定其他人才账号,请联系管理员");
            if (number(binding.get(0).get("hasTalentCard")) != 0) {
                throw new BusinessException(ErrorCode.CONFLICT, "当前微信已绑定其他人才账号,请联系管理员");
            }
            jdbc.update("update talent_user set openid = null where id = ?", number(binding.get(0).get("id")));
        }
        jdbc.update("update talent_user set openid = ? where id = ?", openId, talentId);
        return issueSession(talentId);
    }
    private long registerVisitor(String openId, String mobile) {
        List<Map<String, Object>> bindings = rows("""
                select id
                from talent_user
                where openid = ? and deleted = 0 and status = 1
                limit 1 for update
                """, openId);
        if (!bindings.isEmpty()) {
            long talentId = number(bindings.get(0).get("id"));
            jdbc.update("update talent_user set mobile = ? where id = ?", mobile, talentId);
            return talentId;
        }
        jdbc.update("""
                insert into talent_user(openid, name, mobile, talent_level, source_area_type, status)
                values (?, '普通游客', ?, '普通游客', 'LOCAL', 1)
                """, openId, mobile);
        Long talentId = jdbc.queryForObject("select last_insert_id()", Long.class);
        if (talentId == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "普通游客注册失败");
        return talentId;
    }
    private Map<String, Object> issueSession(long talentId) {
        jdbc.update("update talent_session set revoked_at = now() where talent_id = ? and revoked_at is null", talentId);
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -13,6 +13,8 @@
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingCommand;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingCommand;
import com.talentcard.server.business.payment.huifu.HuifuEnterpriseOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.common.SensitiveDataCrypto;
import com.talentcard.server.common.BusinessException;
@@ -26,11 +28,13 @@
    private final JdbcTemplate jdbc;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final HuifuIndividualOnboardingGateway individualOnboardingGateway;
    private final HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway;
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway) {
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway) {
        this.jdbc = jdbc;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
        this.individualOnboardingGateway = individualOnboardingGateway;
        this.enterpriseOnboardingGateway = enterpriseOnboardingGateway;
    }
    @Transactional(readOnly = true)
@@ -49,13 +53,32 @@
        if (!active.isEmpty()) return onboarding(active.get(0));
        boolean enterprise = "enterprise".equals(request.subjectType());
        if (enterprise && (request.creditCode() == null || !request.creditCode().trim().matches("[0-9A-Z]{18}"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入18位统一社会信用代码");
        if (enterprise && (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入法人正确的身份证号");
        if (enterprise && (request.subjectCertificateBeginDate() == null || request.subjectCertificateBeginDate().isBlank())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入营业执照成立日期");
        if (!enterprise && (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的身份证号");
        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, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, registered_region, registered_address, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, clean(request.registeredRegion()), clean(request.registeredAddress()), request.contactName().trim(), request.contactMobile().trim(), key.trim());
        if ("individual".equals(request.subjectType())) submitIndividualOnboarding(no, request);
        else submitEnterpriseOnboarding(no, request);
        return onboarding(rows("select * from merchant_onboarding_application where application_no = ?", no).get(0));
    }
    private void submitEnterpriseOnboarding(String applicationNo, MerchantOnboardingRequest request) {
        try {
            HuifuEnterpriseOnboardingGateway.Result result = enterpriseOnboardingGateway.create(new HuifuEnterpriseOnboardingCommand(
                    applicationNo, request.subjectName().trim(), request.creditCode().trim(),
                    certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()),
                    clean(request.registeredRegion()), clean(request.registeredAddress()), request.contactName().trim(),
                    clean(request.subjectIdNumber()).toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()),
                    certificateBeginDate(request.subjectCertificateBeginDate()), request.contactName().trim(), request.contactMobile().trim()));
            if (result.accepted() && !result.huifuId().isBlank()) jdbc.update("update merchant_onboarding_application set status='approved', provider_huifu_id=?, reviewed_at=now(), reject_reason=null where application_no=? and status='pending'", result.huifuId(), applicationNo);
            else if (!result.accepted()) jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where application_no=? and status='pending'", providerReason(result.code(), result.message()), applicationNo);
            else jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu accepted the request but did not return huifu_id", applicationNo);
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
        }
    }
    private void submitIndividualOnboarding(String applicationNo, MerchantOnboardingRequest request) {
@@ -142,7 +165,8 @@
    private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    private String certificateValidityType(String value) { return "long_term".equals(value) ? "1" : "0"; }
    private String certificateBeginDate(String value) { return clean(value).replace("-", ""); }
    private String providerReason(HuifuIndividualOnboardingGateway.Result result) { String value = clean(result.message()); return value.isEmpty() ? "Huifu onboarding rejected: " + clean(result.code()) : value; }
    private String providerReason(HuifuIndividualOnboardingGateway.Result result) { return providerReason(result.code(), result.message()); }
    private String providerReason(String code, String message) { String value = clean(message); return value.isEmpty() ? "Huifu onboarding rejected: " + clean(code) : value; }
    private String maskId(String id) { String value = clean(id); return value.length() < 8 ? "" : value.substring(0, 3) + "********" + value.substring(value.length() - 4); }
    private String sha256(String value) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder result = new StringBuilder(); for (byte valueByte : digest) result.append(String.format("%02x", valueByte)); return result.toString(); } catch (Exception exception) { throw new IllegalStateException("Unable to hash bank card", exception); } }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -47,7 +47,7 @@
        Map<String, Object> response;
        try {
            response = gateway.createMiniProgramPayment(new HuifuMiniProgramPaymentCommand(
                    prepared.paymentNo(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt()));
                    prepared.paymentNo(), prepared.huifuId(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt()));
        } catch (HuifuPaymentGatewayException exception) {
            if (exception.isOutcomeUnknown()) {
                transactions.executeWithoutResult(status -> markCreateUnknown(prepared.paymentNo(), "GATEWAY_UNKNOWN", exception.getMessage()));
@@ -77,9 +77,16 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少有效支付幂等键");
        }
        List<Map<String, Object>> orders = rows("""
                select o.id, o.order_no, o.talent_id, o.merchant_id, o.pay_amount, o.order_status, o.price_version,
                select o.id, o.order_no, o.talent_id, o.merchant_id, o.store_id, o.pay_amount, o.order_status, o.price_version,
                       o.expires_at, coalesce(o.product_name_snapshot, o.order_type, '人才权益服务') goods_description,
                       t.openid
                       t.openid,
                       coalesce((select a.provider_huifu_id
                                 from merchant_onboarding_application a
                                 where a.merchant_id = o.merchant_id
                                   and a.status = 'approved'
                                   and a.provider_huifu_id is not null
                                   and trim(a.provider_huifu_id) <> ''
                                 order by a.id desc limit 1), '') provider_huifu_id
                from benefit_order o
                join talent_user t on t.id = o.talent_id and t.deleted = 0
                where o.deleted = 0 and o.order_no = ? and o.talent_id = ?
@@ -88,7 +95,7 @@
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在或无权支付");
        Map<String, Object> order = orders.get(0);
        long payAmount = number(order.get("payAmount"));
        if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", 0, "", LocalDateTime.now(), false, true, "", false);
        if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false);
        if (number(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可支付");
        if (number(order.get("priceVersion")) != priceVersion) throw new BusinessException(ErrorCode.CONFLICT, "订单金额已更新,请重新确认");
        LocalDateTime expiresAt = localDateTime(order.get("expiresAt"));
@@ -100,10 +107,12 @@
                        verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end
                    where id = ? and order_status = 0
                    """, number(order.get("id")));
            return new PreparedPayment("", "", 0, "", LocalDateTime.now(), false, true, "", false);
            return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false);
        }
        String openId = string(order.get("openid"));
        if (openId.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "当前用户未完成微信支付授权");
        String huifuId = string(order.get("providerHuifuId"));
        if (huifuId.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户汇付商户号未配置或尚未审核通过");
        List<Map<String, Object>> existingRows = rows("""
                select payment_no, order_no, talent_id, status, attempt_no, client_payment_payload
@@ -119,22 +128,22 @@
            }
            String status = string(existing.get("status"));
            String paymentNo = string(existing.get("paymentNo"));
            if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, "", 0, "", LocalDateTime.now(), false, true, "", false);
            if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, "", "", 0, "", LocalDateTime.now(), false, true, "", false);
            if ("PREPAY_CREATED".equals(status)) {
                return new PreparedPayment(paymentNo, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false);
                return new PreparedPayment(paymentNo, "", "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false);
            }
            if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) {
                return new PreparedPayment(paymentNo, "", 0, "", LocalDateTime.now(), false, false, "", true);
                return new PreparedPayment(paymentNo, "", "", 0, "", LocalDateTime.now(), false, false, "", true);
            }
            int nextAttempt = (int) number(existing.get("attemptNo")) + 1;
            String retryPaymentNo = BusinessIdGenerator.next("P");
            insertPaymentTransaction(retryPaymentNo, order, orderNo, talentId, payAmount, idempotencyKey, nextAttempt);
            return new PreparedPayment(retryPaymentNo, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false);
            return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false);
        }
        String paymentNo = BusinessIdGenerator.next("P");
        insertPaymentTransaction(paymentNo, order, orderNo, talentId, payAmount, idempotencyKey, 1);
        return new PreparedPayment(paymentNo, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false);
        return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false);
    }
    private void insertPaymentTransaction(
@@ -327,6 +336,7 @@
    private record PreparedPayment(
            String paymentNo,
            String huifuId,
            String openId,
            long amountInCent,
            String goodsDescription,
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingCommand.java
New file
@@ -0,0 +1,17 @@
package com.talentcard.server.business.payment.huifu;
public record HuifuEnterpriseOnboardingCommand(
        String applicationNo,
        String companyName,
        String creditCode,
        String licenseValidityType,
        String licenseBeginDate,
        String registeredRegion,
        String registeredAddress,
        String legalName,
        String legalIdNumber,
        String legalValidityType,
        String legalBeginDate,
        String contactName,
        String contactMobile
) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java
New file
@@ -0,0 +1,94 @@
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.huifu.bspay.sdk.opps.core.request.V2UserBasicdataEntRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class HuifuEnterpriseOnboardingGateway {
    private static final Object SDK_LOCK = new Object();
    private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private final HuifuPaymentProperties properties;
    public HuifuEnterpriseOnboardingGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public Result create(HuifuEnterpriseOnboardingCommand command) {
        ensureConfigured();
        V2UserBasicdataEntRequest request = new V2UserBasicdataEntRequest();
        request.setReqSeqId(command.applicationNo());
        request.setReqDate(DATE.format(LocalDate.now()));
        request.setRegName(command.companyName());
        request.setLicenseCode(command.creditCode());
        request.setLicenseValidityType(command.licenseValidityType());
        request.setLicenseBeginDate(command.licenseBeginDate());
        request.setRegDetail(joinAddress(command.registeredRegion(), command.registeredAddress()));
        request.setLegalName(command.legalName());
        request.setLegalCertType("00");
        request.setLegalCertNo(command.legalIdNumber());
        request.setLegalCertValidityType(command.legalValidityType());
        request.setLegalCertBeginDate(command.legalBeginDate());
        request.setLegalCertNationality("CHN");
        request.setContactName(command.contactName());
        request.setContactMobile(command.contactMobile());
        try {
            Map<String, Object> response;
            synchronized (SDK_LOCK) {
                initializeSdk();
                response = BasePayClient.request(request, false);
            }
            return Result.from(response);
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new HuifuPaymentGatewayException("Huifu enterprise onboarding request failed", outcomeUnknown(exception), exception);
        }
    }
    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 onboarding 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 String joinAddress(String region, String address) {
        String left = region == null ? "" : region.trim();
        String right = address == null ? "" : address.trim();
        return left.isEmpty() ? right : right.isEmpty() ? left : left + right;
    }
    private 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 huifuId, 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");
            String message = value(data, "sub_resp_desc", "resp_desc", "message");
            String huifuId = value(data, "huifu_id", "huifuId");
            return new Result("00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code), huifuId, code, message);
        }
        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 ""; }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentCommand.java
@@ -4,6 +4,7 @@
public record HuifuMiniProgramPaymentCommand(
        String paymentNo,
        String huifuId,
        String openId,
        long amountInCent,
        String goodsDescription,
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java
@@ -38,7 +38,7 @@
        V3TradePaymentJspayRequest request = new V3TradePaymentJspayRequest();
        request.setReqDate(REQUEST_DATE.format(LocalDateTime.now()));
        request.setReqSeqId(command.paymentNo());
        request.setHuifuId(properties.getMerchantId().trim());
        request.setHuifuId(command.huifuId().trim());
        request.setGoodsDesc(command.goodsDescription());
        request.setTransAmt(BigDecimal.valueOf(command.amountInCent(), 2).toPlainString());
        request.setTradeType("T_MINIAPP");
talent-card-server/src/main/resources/db/migration/V98__merchant_coupon_talent_level_quantities.sql
New file
@@ -0,0 +1,2 @@
alter table merchant_coupon
    add column talent_level_quantities varchar(512) default null comment 'talent card level quantities JSON';