feat(admin): 添加优惠券人才等级数量配置功能并完善商户入驻流程
- 在优惠券功能中新增talentLevelQuantities字段用于配置各人才等级发放数量
- 更新前端优惠券编辑界面,支持人才等级开关和数量输入功能
- 优化优惠券金额验证范围为0.1-9999.9元
- 新增企业商户入驻汇付接口和服务类
- 完善支付服务中的汇付ID获取逻辑
- 优化路由配置和数据模型结构
- 移除已废弃的入驻审核接口
- 添加企业入驻时的表单验证逻辑
| | |
| | | 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' }, |
| | |
| | | conditionAmount?: number |
| | | couponAmount?: number |
| | | talentLevels?: string |
| | | talentLevelQuantities?: Record<string, number> | string |
| | | validStart?: string |
| | | validEnd?: string |
| | | productScope?: string |
| | |
| | | conditionAmount?: number |
| | | couponAmount?: number |
| | | talentLevels: string[] |
| | | talentLevelQuantities: Record<string, number> |
| | | validStart?: string |
| | | validEnd?: string |
| | | productScope: string |
| | |
| | | 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: [] }]) |
| | | |
| | |
| | | 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) { |
| | |
| | | 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 |
| | |
| | | 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(), |
| | |
| | | : 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', |
| | |
| | | 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 === '') { |
| | |
| | | |
| | | <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"> |
| | |
| | | |
| | | 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) |
| | |
| | | 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: '', |
| | |
| | | } |
| | | |
| | | function openReview(row: Record<string, any>) { |
| | | if (kind.value === 'onboardings') { |
| | | onboardingDetail.value = row |
| | | onboardingDetailVisible.value = true |
| | | return |
| | | } |
| | | reviewing.value = row |
| | | reviewVisible.value = true |
| | | } |
| | |
| | | </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> |
| | |
| | | .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> |
| | |
| | | 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; |
| | |
| | | ) { |
| | | 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, |
| | |
| | | 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 |
| | |
| | | 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(), |
| | |
| | | request.conditionAmount(), |
| | | valueOrZero(request.couponAmount()), |
| | | joinTalentLevels(request.talentLevels()), |
| | | levelQuantitiesJson(request.talentLevelQuantities()), |
| | | blankToNull(request.validStart()), |
| | | blankToNull(request.validEnd()), |
| | | normalizeProductScope(request.productScope()), |
| | |
| | | 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 = ? |
| | | """, |
| | |
| | | request.conditionAmount(), |
| | | valueOrZero(request.couponAmount()), |
| | | joinTalentLevels(request.talentLevels()), |
| | | levelQuantitiesJson(request.talentLevelQuantities()), |
| | | blankToNull(request.validStart()), |
| | | blankToNull(request.validEnd()), |
| | | normalizeProductScope(request.productScope()), |
| | |
| | | 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; |
| | |
| | | @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)); } |
| | |
| | | |
| | | 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; |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | 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; |
| | | |
| | |
| | | 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 |
| | |
| | | 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; } |
| | |
| | | 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); |
| | | |
| | |
| | | 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; |
| | |
| | | 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) |
| | |
| | | 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) { |
| | |
| | | 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); } } |
| | | } |
| | |
| | | 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())); |
| | |
| | | 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 = ? |
| | |
| | | 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")); |
| | |
| | | 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 |
| | |
| | | } |
| | | 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( |
| | |
| | | |
| | | private record PreparedPayment( |
| | | String paymentNo, |
| | | String huifuId, |
| | | String openId, |
| | | long amountInCent, |
| | | String goodsDescription, |
| New file |
| | |
| | | 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 |
| | | ) {} |
| New file |
| | |
| | | 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 ""; } |
| | | } |
| | | } |
| | |
| | | |
| | | public record HuifuMiniProgramPaymentCommand( |
| | | String paymentNo, |
| | | String huifuId, |
| | | String openId, |
| | | long amountInCent, |
| | | String goodsDescription, |
| | |
| | | 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"); |
| New file |
| | |
| | | alter table merchant_coupon |
| | | add column talent_level_quantities varchar(512) default null comment 'talent card level quantities JSON'; |