shinnosuke
2026-08-06 79ae06e6b4172d90df77b0e49ad3ab12ba63cd45
feat(settlement): 完善商户结算功能并新增申请条件页面

- 移除银行卡片和提现审核的PATCH接口
- 更新优惠券可用性检查逻辑,增加人才等级限制和重复使用检测
- 重构优惠券选择页面,优化人才ID获取方式
- 新增人才卡申请条件详情页面,展示各类人才申请标准
- 添加汇付商户业务开通相关命令和网关实现
- 实现提现状态查询功能,用于处理延迟回调场景
- 配置汇付商户业务通知URL参数
2个文件已删除
9个文件已添加
24个文件已修改
738 ■■■■■ 已修改文件
rencaika/pages.json 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/card-application-conditions/card-application-conditions.uvue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/card/card.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-select/coupon-select.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/payment-confirm/payment-confirm.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-payment/pending-payment.uvue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/product-detail/product-detail.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/UpgradeApplicationDetailView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/auth/TalentAuthService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/HuifuMerchantWithdrawalService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWithdrawalReconciliationScheduler.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentCallbackController.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuUserBusinessOpenCommand.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuUserBusinessOpenGateway.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalQueryCommand.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderCancelRequest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderExpiryScheduler.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V100__bank_branch_directory.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V102__talent_service_staff_assignment.sql 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V103__merchant_bank_card_huifu_business_open.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V97__huifu_user_basicdata_onboarding_fields.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V99__talent_service_staff_assignment.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages.json
@@ -463,17 +463,10 @@
            }
        },
        {
            "path": "pages/ticket-package/ticket-package",
            "path": "pages/card-application-conditions/card-application-conditions",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F5F7FA"
            }
        },
        {
            "path": "pages/display-policy/display-policy",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F5F7FA"
                "backgroundColor": "#FFFFFF"
            }
        }
    ],
rencaika/pages/card-application-conditions/card-application-conditions.uvue
New file
@@ -0,0 +1,107 @@
<template>
    <view class="page">
        <view class="custom-nav"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg"></image></view><text class="nav-title">申请条件</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view>
        <scroll-view class="content" scroll-y="true" show-scrollbar="false">
            <view class="hero"><text class="hero-title">{{cardType}}</text><text class="hero-subtitle">东区人才卡申请条件</text></view>
            <view class="summary-card"><text class="summary-title">发放对象</text><text class="summary-text">{{recipient}}</text></view>
            <view class="conditions-card"><text class="conditions-title">具体申请条件</text><text class="conditions-text">{{conditions}}</text></view>
            <view class="content-bottom"></view>
        </scroll-view>
    </view>
</template>
<script setup lang="uts">
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    const cardType = ref('东区人才卡')
    const recipient = ref('')
    const conditions = ref('')
    const excellentConditions = `一类人才(国内外顶尖人才)
1. 国家最高科学技术奖获得者。
2. 中国科学院院士,中国工程院院士。
3. 中国社会科学院学部委员、荣誉学部委员。
4. “国家海外高层次人才引进计划”“顶尖人才与创新团队项目”入选者、“国家高层次人才特殊支持计划”杰出人才人选。
5. 国家自然科学奖、国家技术发明奖、国家科学技术进步奖一等奖及以上获得者(第1完成人)。
6. “天府峨眉计划”顶尖人才项目、“天府青城计划”杰出科学家项目入选者。
7. 国家实验室主任。
8. 美国、英国、德国、法国、加拿大、澳大利亚、瑞典、日本等主要科技发达国家科学院、工程院院士。
9. 其他相当于上述层次的人才。
二类人才(国家级领军人才)
1. 科技创新类。
(1)“国家海外高层次人才引进计划”中除“顶尖人才与创新团队项目”和“青年项目”以外的入选者,“国家高层次人才特殊支持计划”杰出人才、青年拔尖人才以外的入选者。
(2)中国青年科技奖获得者。(3)国家杰出青年科学基金获得者。(4)百千万人才工程国家级人选。(5)国防科技卓越青年科学基金获得者。
(6)国家自然科学奖、国家技术发明奖、国家科学技术进步奖一等奖获得者(第2至5完成人)、二等奖获得者(第1完成人)。
(7)中国专利金奖、中国外观设计金奖前2位完成人(须为专利发明人或设计人)。(8)四川省科学技术杰出贡献奖获得者。
(9)国家实验室副主任、学术委员会主任,全国(国家)重点实验室、天府实验室主任,国家新兴产业创新中心、国家制造业创新中心、国家技术创新中心、国家产业技术工程化中心主任(董事长、总经理),三大国际标准化技术组织的各技术委员会主席、副主席,全国专业标准化技术委员会主任委员、副主任委员。
(10)全国知识产权领军人才、全国创新争先奖(团队带头人或个人)获得者。(11)“国家工程师奖”获得者。(12)“全国工程勘察设计大师”“梁思成建筑奖”获得者。
2. 重点产业类。
(1)近5年,总部在东区全国500强企业(以中国企业联合会、中国企业家协会最新发布为准)主要负责人(每个企业限2人)。
(2)上一年度,东区重点产业链营业收入50亿元(含)以上企业(不含房地产开发企业)主要负责人(每个企业限2人)、首席技术官或相当职务人才,且上一年度工资性收入在100万元(含)以上。
(3)上一年度,在东区纳税2000万元及以上的重点产业链企业主要负责人(每个企业限2人)、首席技术官或相当职务人才,且上一年度工资性收入在100万元(含)以上。
(4)上一年度,在东区固定资产投资15亿元(含)以上的重点产业链企业主要负责人、首席技术官或相当职务人才。
3. 技术技能类。(1)中华技能大奖获得者、世界技能大赛金牌获得者、近5年培养出世界技能大赛金牌获得者的主教练。(2)享受国务院政府特殊津贴专家。(3)全国杰出专业技术人才。
4. 教育卫生类。(1)“长江学者奖励计划”特聘教授和讲座教授。(2)国家级教学名师、名校长,全国名师、名校长工作室领衔人,全国教书育人楷模、全国模范教师,全国优秀教师,国家级教学成果一等奖第1完成人。(3)白求恩奖章获得者、南丁格尔奖获得者、中国医师奖获得者。(4)国医大师、国家岐黄学者、全国名中医、国务院政府特殊津贴专家。(5)中华医学会各专业委员会主任委员、副主任委员。(6)创建国家临床重点专科、国家临床医学研究中心、四川省临床医学重点实验室、四川省临床医学研究中心的科室主要负责人。
5. 社会科学及文化体育类。(1)近2届奥运会金牌获得者,近2届列入奥运会项目的世界杯、世锦赛金牌获得者,近8年培养出奥运会或列入奥运会项目的世界杯、世锦赛金牌获得者的教练员。(2)中国政府“友谊奖”获得者。(3)国家社会科学基金重大项目首席专家。(4)茅盾文学奖获得者,鲁迅文学奖获得者。(5)全国文化名家暨“四个一批”人才,“全国中青年德艺双馨文艺工作者”获得者;中宣部精神文明建设“五个一”工程获奖作品主创人员;全国性最高文化艺术、广播电视、新闻出版奖项获得者(作品奖奖项须为第1创作人员)。(6)中国文化艺术政府奖群星奖获得者(第1创作人员)。
6. 乡村振兴类。(1)世界农业奖获得者。(2)国家现代农业产业技术体系首席科学家。(3)全国农牧渔业丰收奖“农业技术推广贡献奖”获得者、“农业技术推广成果奖”一等奖主要完成人(前3位完成人)、“农业技术推广合作奖”主要完成人(第1完成人)。(4)神农中华农业科技奖科学研究类成果一等奖主要完成人(前3位完成人)、技术发明类成果一等奖第1完成人、科学普及奖一等奖第1完成人、优秀创新团队奖第1完成人。
7. 其他相当于上述层次的人才。
三类人才(省部级领军人才)
1. 科技创新类。(1)“国家海外高层次人才引进计划”中“青年项目”入选者、“国家高层次人才特殊支持计划”青年拔尖人才。(2)国家优秀青年科学基金获得者,中国科学院“百人计划”入选者。(3)“长江学者奖励计划”青年学者。(4)国家博士后创新人才支持计划入选者,省级博士后创新人才支持项目入选者。(5)国家自然科学奖、国家技术发明奖、国家科学技术进步奖二等奖获得者(第2至5完成人)。(6)全国(国家)重点实验室、天府实验室副主任(前2名),国家产业创新中心、国家制造业创新中心、国家技术创新中心、国家工程研究中心、国家企业技术中心副主任或副董事长、副总经理(前2名)。(7)三大国际标准化技术组织的各分技术委员会主席、副主席,全国标准化分技术委员会主任委员、副主任委员,担任主导国际标准制定项目的召集人,中国标准创新贡献奖一等奖前3完成人、二等奖第1完成人、中国专利银奖获得者(须为第一承担单位排名前2专利发明人)、中国外观设计金奖获得者(须为第一承担单位排名前2专利设计人)。(8)四川杰出人才入选者,“天府峨眉计划”中“创业领军人才项目”“创新领军人才项目”入选者、“天府青城计划”中“领军人才”入选者。(9)省级自然科学奖、技术发明奖、科学技术进步奖一等奖获得者(第1完成人);省级青年科技奖获得者。(10)“国家优质工程奖”“中国建设工程鲁班奖”“中国土木工程詹天佑奖”等权威奖项项目负责人。
2. 重点产业类。(1)近5年,总部在东区的四川100强企业(以四川省企业联合会最新发布为准)主要负责人(每个企业限2人)。(2)上一年度,东区重点产业链营业收入10亿元(含)以上企业(不含房地产开发企业)主要负责人(每个企业限2人)、首席技术官或相当职务人才,且上一年度工资性收入在80万元(含)以上。(3)上一年度,在东区纳税1000万元至2000万元的重点产业链企业主要负责人(每个企业限2人)、首席技术官或相当职务人才,且上一年度工资性收入在80万元(含)以上。(4)上一年度,在东区固定资产投资10亿元(含)以上的重点产业链企业主要负责人、首席技术官或相当职务人才。
3. 技术技能类。(1)世界技能大赛银、铜牌获得者,全国技能大赛金牌获得者,近5年培养出世界技能大赛银、铜牌获得者、全国技能大赛金牌获得者的主教练。(2)全国技术能手。(3)国家级技能大师工作室领衔人。(4)全国专业社会工作领军人才。(5)大国工匠。(6)省级学术和技术带头人。(7)首席技师。
4. 教育卫生类。(1)获得教育部表彰的中小学(含中等职业学校)名师、名校长、名班主任等优秀人才。(2)国家级教学成果一等奖核心成员(第2至5名),国家级教学成果二等奖、省级教学成果特等奖第1完成人。(3)省级特级教师。(4)国家临床医学研究中心或国家医学中心骨干(排名前三)。(5)四川省医学会各专业委员会主任委员、副主任委员。(6)国家岐黄青年学者,四川省卫生健康英才计划首席专家、卫生健康领军人才,四川省公共卫生高层次人才。(7)创建国家中医优势重点专科、国家中西医协同“旗舰”科室的科室主要负责人。(8)创建四川省区域医疗中心的科室主要负责人。
5. 社会科学及文化体育类。(1)近2届奥运会银牌、铜牌获得者,近2届列入奥运会项目的世界杯、世锦赛银牌、铜牌获得者,近2届亚运会、全运会金牌获得者。近8年培养出奥运会或列入奥运会项目的世界杯、世锦赛银牌、铜牌获得者,亚运会、全运会金牌获得者的教练员。(2)中宣部宣传思想文化青年英才、文化和旅游部优秀专家。(3)国家级金牌导游(中国好导游)、全国性重要节庆文艺奖项单项奖获得者(作品类奖项须为主创人员)、省级精神文明建设“五个一”工程获奖作品主创人员。(4)教育部人文社会科学重大、重点项目负责人;国家社会科学基金项目重点项目负责人;省(部)级哲学社会科学重点研究基地负责人;省级哲学社会科学成果奖特等奖及一等奖前2完成人。(5)中国工艺美术大师。(6)国家级非物质文化遗产代表性传承人。
6. 乡村振兴类。(1)国家现代农业产业技术体系岗位科学家。(2)中华农业英才奖获得者、“神农英才”计划神农领军英才入选者、神农青年英才入选者。(3)全国十佳农民。(4)全国农牧渔业丰收奖“农业技术推广成果奖”二等奖主要完成人(前3位完成人)、“农业技术推广合作奖”主要完成人(第2完成人)。(5)神农中华农业科技奖科学研究类成果二等奖主要完成人(前3位完成人)、技术发明类二等奖第1完成人、科学普及奖二等奖第1完成人、优秀创新团队奖第2完成人。(6)“大国农匠”全国农民技能大赛一等奖获得者。
7. 其他相当于上述层次的人才。`
    const outstandingConditions = `四类人才(市级领军人才)
1. 科技创新类。(1)“天府峨眉计划”中“青年人才项目”入选者、“天府青城计划”中“青年拔尖人才”入选者。(2)“攀枝花市优秀人才选拔培养计划”中除“青鹏人才”和“青技人才”以外的入选者。(3)省级自然科学奖、技术发明奖、科学技术进步奖一等奖获得者(第2至5完成人)、二等奖获得者(第1至3完成人)、三等奖获得者(第1完成人)。(4)省级技术创新中心、省级工程技术研究中心、省级重点实验室主任,省级工程研究中心(工程实验室)主任。(5)中国外观设计银奖、中国外观设计优秀奖获得者(须为第一承担单位排名前2专利设计人),中国专利优秀奖获得者(须为第一承担单位排名前2专利发明人)。(6)四川省专利奖特等奖获得者(须为第一承担单位排名前2专利发明人),四川省知识产权骨干人才,四川省标准化技术委员会的主任委员、副主任委员,四川省标准创新贡献奖一等奖前3完成人、二等奖第1完成人。
2. 重点产业类。(1)上一年度,东区重点产业链营业收入1亿元(含)以上企业(不含房地产开发企业)主要负责人(每个企业限2人)、首席技术官,且上一年度工资性收入在50万元(含)以上。(2)上一年度,在东区纳税500万元至1000万元的重点产业链企业主要负责人(每个企业限2人)、首席技术官,且上一年度工资性收入在50万元(含)以上。(3)上一年度,在东区固定资产投资5亿元(含)以上的重点产业链企业主要负责人、首席技术官。(4)在东区国家级科技创新平台、国家级专精特新“小巨人”企业、上一年度纳税20强企业每年可推荐2名同层次人才。
3. 技术技能类。(1)具有正高级职称任职资格的专业技术人才。(2)省级学术和技术带头人后备人选、市级学术和技术带头人。(3)省级技能大师工作室领衔人。(4)四川工匠。(5)特级技师。(6)省级技能大师、省级技术能手,副省级城市或省会城市的市级技术能手、技能大师工作室领衔人、工匠。(7)全国技能大赛银、铜牌获得者,省级一类技能大赛一等奖获得者,近5年培养出全国技能大赛银、铜牌获得者、省级一类技能大赛一等奖获得者的主教练。
4. 教育卫生类。(1)国家级教学成果二等奖、省级教学成果特等奖核心成员(第2至5名),省级教学成果奖一、二等奖成果第1完成人。(2)副省级城市或省会城市的特级教师、省级名师、名校长工作室领衔人,省级中小学名师、名校长、名班主任的优秀人才,省级优秀教师、“四有”好老师、最美教师。(3)全国中医临床优秀人才、国家级中医师承指导老师。(4)创建四川省临床重点专科、四川省卫生健康委医学重点实验室、四川省中医药管理局中医药重点实验室、四川省中医优势专科、四川省中西医协同“旗舰”科室、四川省妇幼保健特色专科的科室主要负责人。(5)四川省卫生健康英才计划之四川省卫生健康中青年骨干人才、四川省临床技能名师、四川省名中医。
5. 社会科学及文化体育类。(1)近2届亚运会、全运会银牌、铜牌获得者或亚锦赛金牌获得者,近8年输送培养出亚运会、全运会银牌、铜牌获得者或亚锦赛金牌获得者的教练员。(2)国家级文艺家协会会员。(3)全省性最高文化艺术、广播电视、新闻出版奖单项最高奖项获得者,受省(部)级部门表彰的宣传文化行业带头人。(4)省级工艺美术大师。(5)省级非物质文化遗产代表性传承人。(6)省级哲学社会科学成果奖二等奖第1完成人。
6. 乡村振兴类。(1)全国农牧渔业丰收奖“农业技术推广成果奖”三等奖主要完成人(前3完成人)、“农业技术推广合作奖”主要完成人(第3完成人)。(2)神农中华农业科技奖科学研究类成果三等奖主要完成人(前3完成人)、技术发明类三等奖第1完成人、科学普及奖三等奖第1完成人、优秀创新团队奖第3完成人。(3)“大国农匠”全国农民技能大赛二等奖获得者。(4)四川省农村乡土人才创新创业大赛金奖获得者。
7. 其他相当于上述层次的人才。
五类人才(高级人才)
1. 市级学术和技术带头人后备人选,市级技能大师工作室领办人,市级技术能手,市“工匠杯”“技能精英”“攀西工匠杯”技能大赛一等奖获得者。
2. 取得国内外高校全日制博士研究生学历且获得相应学位的人才(国外高校取得的学历及学位必须经国家教育主管部门认证通过)。
3. 市级名师、名校长工作室领衔人,近8年培养出省运会金牌获得者的教练员。
4. 创建市级临床重点专科、市级中医优势专科、市级医学重点实验室的科室主要负责人、四川省岐黄菁英、四川省基层卫生拔尖人才、四川省民族地区基层卫生优秀人才、攀枝花卫生健康英才计划之攀枝花市名中医、攀枝花市卫生健康中青年骨干人才、攀枝花市临床技能名师、攀枝花市公共卫生高层次人才。
5. 其他相当于上述层次的人才。`
    const eliteConditions = `六类人才(优秀人才)
具有副高级职称任职资格的专业技术人才,“攀枝花市优秀人才选拔培养计划”中“青鹏人才”“青技人才”入选者,攀枝花卫生健康英才计划之基层卫生拔尖人才,取得国内外高校全日制硕士研究生学历且获得相应学位的人才(国外高校取得的学历及学位必须经国家教育主管部门认证通过),具有高级技师职业技能等级人才。
七类人才(基础人才)
“双一流”建设高校(建设学科)全日制本科毕业生,教育部直属师范类院校教育类专业全日制本科毕业生,传染病专科医院、精神病专科医院、妇女儿童专科医院引进的传染病、精神、儿科等专业的普通高校全日制本科毕业生,国家级或省级重点艺术院校毕业的艺术专业全日制本科毕业生,具有技师职业技能等级人才。`
    onLoad(() => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect()
        const systemInfo = uni.getSystemInfoSync()
        navTop.value = capsule.top
        navHeight.value = capsule.height
        navWidth.value = capsule.width
        navRight.value = systemInfo.windowWidth - capsule.right
        // #endif
        recipient.value = '经区人才服务专窗认定的高层次人才,根据人才层次发放卓越人才卡、杰出人才卡或菁英人才卡。'
        conditions.value = '卓越人才卡\n发放对象:经区人才服务专窗认定的一至三类高层次人才。\n\n' + excellentConditions + '\n\n杰出人才卡\n发放对象:经区人才服务专窗认定的四至五类高层次人才。\n\n' + outstandingConditions + '\n\n菁英人才卡\n发放对象:经区人才服务专窗认定的六至七类高层次人才。\n\n' + eliteConditions
    })
    const goBack = () => safeBack('/pages/card/card')
</script>
<style>
    .page { display: flex; width: 100%; height: 100vh; background-color: #FFF9F3; flex-direction: column; }
    .custom-nav { flex-shrink: 0; padding-bottom: 24rpx; background-image: linear-gradient(180deg, #FFE5AB 0%, #FFF4DC 100%); }
    .safe-space { height: 28px; } .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 26rpx; padding-right: 10px; position: relative; align-items: center; justify-content: space-between; }
    .back-button { display: flex; width: 60rpx; height: 60rpx; margin-left: -8rpx; justify-content: center; align-items: center; } .back-button image { width: 38rpx; height: 38rpx; } .back-button-active { opacity: .55; }
    .nav-title { position: absolute; left: 50%; top: 50%; color: #222326; font-size: 36rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; } .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .content { min-height: 0; flex: 1; } .hero { padding: 58rpx 58rpx 42rpx; background-image: linear-gradient(135deg, #F26B38 0%, #FA9534 100%); } .hero-title { display: block; color: #FFFFFF; font-size: 43rpx; font-weight: 700; } .hero-subtitle { display: block; margin-top: 15rpx; color: rgba(255,255,255,.87); font-size: 26rpx; }
    .summary-card, .conditions-card { margin: 30rpx 30rpx 0; padding: 34rpx; border-radius: 22rpx; background-color: #FFFFFF; box-shadow: 0 9rpx 28rpx rgba(116,76,46,.08); } .summary-title, .conditions-title { display: block; color: #24262A; font-size: 31rpx; font-weight: 700; } .summary-text { display: block; margin-top: 20rpx; color: #6F7782; font-size: 28rpx; line-height: 46rpx; }
    .conditions-text { display: block; margin-top: 24rpx; color: #535B66; font-size: 27rpx; line-height: 48rpx; white-space: pre-wrap; } .content-bottom { height: 52rpx; height: calc(52rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/pages/card/card.uvue
@@ -14,7 +14,7 @@
                <view class="guest-card-item" v-for="item in guestCardTypes" :key="item.title">
                    <view class="guest-card-title"><view></view><text>{{item.title}}</text><view></view></view>
                    <view class="guest-card-preview"><image class="guest-card-image" :src="item.image" mode="aspectFit"></image></view>
                    <view class="condition-link" hover-class="condition-link-active" @tap="openApplyNotice"><text>点击查看申请条件</text></view>
                    <view class="condition-link" hover-class="condition-link-active" @tap="openApplyConditions"><text>点击查看申请条件</text></view>
                </view>
                <view class="guest-bottom"></view>
            </view>
@@ -202,6 +202,7 @@
    const handleCodeImageError = () => handleCodeFailure('动态人才码已失效')
    const refreshCode = () => { if (canShowCode.value) requestDynamicCode() }
    const openApplyNotice = () => { showApplyNotice.value = true }
    const openApplyConditions = () => uni.navigateTo({ url: '/pages/card-application-conditions/card-application-conditions' })
    const closeApplyNotice = () => { showApplyNotice.value = false }
    const preventClose = () => {}
    const onlineConsult = () => {
rencaika/pages/coupon-select/coupon-select.uvue
@@ -48,6 +48,7 @@
<script setup lang="uts">
    import { safeBack } from '../../utils/navigation.uts'
    import { getOrderCouponQuote, getOrderCoupons, getPendingOrderCoupons, selectPendingOrderCoupon } from '../../services/portal.uts'
    import { getSession } from '../../services/session.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -79,6 +80,7 @@
        if (options != null && options['productId'] != null) productId.value = options['productId'] as string
        if (options != null && options['orderId'] != null) orderId.value = options['orderId'] as string
        if (options != null && options['talentId'] != null) talentId.value = options['talentId'] as string
        if (talentId.value == '') { const session = getSession(); if (session != null && session.talentId != null) talentId.value = '' + session.talentId }
        if (options != null && options['priceVersion'] != null) priceVersion.value = Number(options['priceVersion'] as string)
        loadCoupons()
    })
@@ -106,7 +108,7 @@
        }
        loading.value = true
        loadFailed.value = false
        getOrderCoupons(merchantId.value, productId.value, orderId.value, (data: any) => {
        getOrderCoupons(merchantId.value, productId.value, orderId.value, talentId.value, (data: any) => {
            coupons.value = data instanceof Array ? data : []
            loading.value = false
        }, () => {
@@ -132,7 +134,7 @@
            })
            return
        }
        getOrderCouponQuote(merchantId.value, productId.value, orderId.value, '' + coupon.id, () => {
        getOrderCouponQuote(merchantId.value, productId.value, orderId.value, '' + coupon.id, talentId.value, () => {
            selecting.value = false
            uni.setStorageSync(couponStorageKey(), '' + coupon.id)
            safeBack(couponFallback())
rencaika/pages/payment-confirm/payment-confirm.uvue
@@ -32,7 +32,7 @@
                    <view class="price-item"><text class="price-label">原价</text><text class="price-value">¥ {{originalAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">总优惠金额</text><text class="price-value">-¥ {{discountAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">用户实付</text><text class="price-value strong">¥ {{actualPayAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">补贴金额</text><text class="price-value">¥ 0.00</text></view>
                    <view class="price-item"><text class="price-label">补贴金额</text><text class="price-value">¥ {{discountAmountText}}</text></view>
                </view>
                <view class="actual-payment">
@@ -63,6 +63,7 @@
    const merchantId = ref('')
    const productId = ref('')
    const orderId = ref('')
    const talentId = ref('')
    const couponText = ref('无')
    const couponAmount = ref(0.0)
    const productName = ref('【川味盛宴】川菜12人餐')
@@ -99,6 +100,8 @@
        if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
        if (options != null && options['productId'] != null) productId.value = options['productId'] as string
        if (options != null && options['orderId'] != null) orderId.value = options['orderId'] as string
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = '' + session.talentId
        applyProductById(productId.value)
        if (options != null && options['name'] != null) productName.value = decodeURIComponent(options['name'] as string)
        if (options != null && options['image'] != null) productImage.value = decodeURIComponent(options['image'] as string)
@@ -132,7 +135,7 @@
        if (!validOrderContext()) return
        const sequence = quoteSequence + 1
        quoteSequence = sequence
        getOrderCouponQuote(merchantId.value, productId.value, orderId.value, couponId, (data: any) => {
        getOrderCouponQuote(merchantId.value, productId.value, orderId.value, couponId, talentId.value, (data: any) => {
            if (sequence != quoteSequence || data == null) return
            productName.value = data.productName != null ? data.productName as string : productName.value
            if (data.coverUrl != null && data.coverUrl != '') productImage.value = resolveApiUrl(data.coverUrl as string)
rencaika/pages/pending-payment/pending-payment.uvue
@@ -45,6 +45,7 @@
            <text class="actual-amount">¥ {{actualPayAmountText}}</text>
            <text v-if="paymentChecking" class="payment-checking">支付结果确认中</text>
            <view class="pay-button" :class="isExpired || isSubmitting ? 'disabled' : ''" hover-class="pay-button-active" @tap="confirmPayment"><text>{{isExpired ? '订单已超时' : (isSubmitting ? '处理中' : '确认支付')}}</text></view>
            <view v-if="!isExpired && !isSubmitting" class="cancel-button" hover-class="cancel-button-active" @tap="cancelOrder"><text>取消订单</text></view>
        </view>
    </view>
</template>
@@ -52,7 +53,7 @@
<script setup lang="uts">
    import { apiEnvironment } from '../../config/api.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { createPendingOrderPayment, getPendingOrder, getPendingOrderPaymentStatus } from '../../services/portal.uts'
    import { cancelPendingOrder, createPendingOrderPayment, getPendingOrder, getPendingOrderPaymentStatus } from '../../services/portal.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -132,6 +133,10 @@
        loadFailed.value = false
        getPendingOrder(orderId.value, talentId.value, (data: any) => {
            loading.value = false
            if (data != null && Number(data.orderStatus) == 2) {
                uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
                return
            }
            merchantName.value = data.merchantNameSnapshot != null ? data.merchantNameSnapshot as string : '人才权益服务'
            merchantAddress.value = data.merchantAddressSnapshot != null ? data.merchantAddressSnapshot as string : ''
            merchantMobile.value = data.merchantMobileSnapshot != null ? data.merchantMobileSnapshot as string : ''
@@ -164,6 +169,7 @@
            refreshCountdown()
            if (isExpired.value) {
                stopCountdown()
                loadOrder()
            }
        }, 1000)
    }
@@ -215,6 +221,25 @@
        }, (error) => {
            isSubmitting.value = false
            uni.showToast({ title: error.message, icon: 'none' })
        })
    }
    const cancelOrder = () => {
        if (isSubmitting.value || isExpired.value) return
        uni.showModal({
            title: '取消订单',
            content: '确定取消当前订单吗?',
            success: (result: any) => {
                if (result.confirm !== true) return
                isSubmitting.value = true
                cancelPendingOrder(orderId.value, talentId.value, () => {
                    isSubmitting.value = false
                    stopCountdown()
                    uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
                }, (error: any) => {
                    isSubmitting.value = false
                    uni.showToast({ title: error.message, icon: 'none' })
                })
            }
        })
    }
    const requestWechatPayment = (params: any) => {
@@ -320,4 +345,6 @@
    .pay-button text { color: #FFFFFF; font-size: 28rpx; font-weight: 700; }
    .pay-button-active { opacity: .78; }
    .pay-button.disabled { background-image: none; background-color: #C7CBD1; }
    .cancel-button { display: flex; min-width: 160rpx; height: 58rpx; margin-top: 18rpx; border-width: 1rpx; border-style: solid; border-color: #FF6D32; border-radius: 30rpx; justify-content: center; align-items: center; }
    .cancel-button text { color: #FF6730; font-size: 25rpx; font-weight: 600; } .cancel-button-active { opacity: .55; }
</style>
rencaika/pages/product-detail/product-detail.uvue
@@ -50,6 +50,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getProductDetail } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -59,6 +60,7 @@
    const isSolidNav = ref(false)
    const merchantId = ref('')
    const productId = ref('')
    const talentId = ref('')
    const productName = ref('【川味盛宴】川菜12人餐')
    const productImage = ref('')
    const fallbackImage = ''
@@ -103,6 +105,8 @@
        // #endif
        if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
        if (options != null && options['productId'] != null) productId.value = options['productId'] as string
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = '' + session.talentId
        loadProduct(productId.value)
        if (options != null && options['name'] != null) productName.value = decodeURIComponent(options['name'] as string)
        if (options != null && options['image'] != null) productImage.value = decodeURIComponent(options['image'] as string)
@@ -113,7 +117,7 @@
    })
    const loadRemoteProduct = () => {
        if (!isApiConfigured() || !/^\d+$/.test(productId.value)) return
        getProductDetail(productId.value, (data: any) => {
        getProductDetail(productId.value, talentId.value, (data: any) => {
            productName.value = data != null && data.productName != null ? data.productName as string : productName.value
            productImage.value = data != null && data.coverUrl != null && data.coverUrl != '' ? resolveApiUrl(data.coverUrl as string) : productImage.value
            const hasSale = data != null && data.salePrice != null
rencaika/services/portal.uts
@@ -111,21 +111,23 @@
    return request<any>({ path: getApiEndpoint('appProducts'), method: 'GET', skipAuth: true, data: { merchantId: merchantId, page: page, size: size } }, onSuccess, onError)
}
export const getProductDetail = (productId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({ path: getApiEndpoint('appProductDetail') + encodeURIComponent(productId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getProductDetail = (productId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appProductDetail') + encodeURIComponent(productId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getOrderCoupons = (merchantId: string, productId: string, orderId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
export const getOrderCoupons = (merchantId: string, productId: string, orderId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({
        path: getApiEndpoint('appOrderCoupons'),
        method: 'GET',
        skipAuth: true,
        data: { merchantId: merchantId, productId: productId, orderId: orderId }
        data: { merchantId: merchantId, productId: productId, orderId: orderId, talentId: talentId }
    }, onSuccess, onError)
}
export const getOrderCouponQuote = (merchantId: string, productId: string, orderId: string, couponId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { merchantId: merchantId, productId: productId, orderId: orderId }
export const getOrderCouponQuote = (merchantId: string, productId: string, orderId: string, couponId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { merchantId: merchantId, productId: productId, orderId: orderId, talentId: talentId }
    if (couponId != '') data.couponId = couponId
    return request<any>({ path: getApiEndpoint('appOrderCouponQuote'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
@@ -220,6 +222,10 @@
    return request<any>({ path: getApiEndpoint('appPendingOrders') + encodeURIComponent(orderNo) + '/coupon', method: 'POST', skipAuth: true, data: { talentId: Number(talentId), couponId: Number(couponId), priceVersion: priceVersion } }, onSuccess, onError)
}
export const cancelPendingOrder = (orderNo: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({ path: getApiEndpoint('appPendingOrders') + encodeURIComponent(orderNo) + '/cancel', method: 'POST', data: { talentId: Number(talentId) } }, onSuccess, onError)
}
export const createPendingOrderPayment = (orderNo: string, talentId: string, priceVersion: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({
        path: getApiEndpoint('appPendingOrders') + encodeURIComponent(orderNo) + '/payment',
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -42,7 +42,7 @@
                <view class="field-block">
                    <text class="field-label">注册地址(省、市、区)</text>
                    <view class="select-shell" :class="invalidField == 'registeredRegion' ? 'field-invalid' : ''" @tap="chooseRegisteredRegion"><text class="ellipsis-text" :class="registeredRegion.length == 0 ? 'select-placeholder' : ''">{{registeredRegion.length > 0 ? registeredRegion : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view>
                    <picker class="region-picker" mode="region" @change="onRegisteredRegionChange"><view class="select-shell" :class="invalidField == 'registeredRegion' ? 'field-invalid' : ''"><text class="ellipsis-text" :class="registeredRegion.length == 0 ? 'select-placeholder' : ''">{{registeredRegion.length > 0 ? registeredRegion : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker>
                    <text v-if="invalidField == 'registeredRegion'" class="field-error">{{errorMessage}}</text>
                </view>
@@ -141,10 +141,8 @@
    const personalPhone = ref('')
    const invalidField = ref('')
    const errorMessage = ref('')
    const regionCodes = [['510000', '510400', '510402'], ['510000', '510400', '510403'], ['510000', '510400', '510411'], ['510000', '510100', '510104']]
    const merchantTypeOptions = ['企业', '个人']
    const validityOptions = ['固定期限', '长期']
    const regionOptions = ['四川省 攀枝花市 东区', '四川省 攀枝花市 西区', '四川省 攀枝花市 仁和区', '四川省 成都市 锦江区']
    const goBack = () => {
        uni.navigateBack()
@@ -195,17 +193,16 @@
        })
    }
    const chooseRegisteredRegion = () => {
        uni.showActionSheet({
            itemList: regionOptions,
            success: (result: any) => {
                registeredRegion.value = regionOptions[result.tapIndex]
                registeredProvinceCode.value = regionCodes[result.tapIndex][0]
                registeredCityCode.value = regionCodes[result.tapIndex][1]
                registeredDistrictCode.value = regionCodes[result.tapIndex][2]
                clearFieldError('registeredRegion')
            }
        })
    const onRegisteredRegionChange = (event: any) => {
        const values = event.detail.value as Array<string>
        const codes = event.detail.code as Array<string>
        if (values.length != 3 || codes.length != 3) return
        registeredRegion.value = values[0] + ' ' + values[1] + ' ' + values[2]
        registeredProvinceCode.value = codes[0]
        registeredCityCode.value = codes[1]
        registeredDistrictCode.value = codes[2]
        clearFieldError('registeredRegion')
    }
    const onBusinessStartChange = (event: any) => {
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -21,10 +21,15 @@
  return normalized === 'individual' || normalized === 'person' || normalized === '个人' ? '个人' : text(value)
}
function isIndividual(value: unknown) {
  const normalized = String(value || '').toLowerCase()
  return normalized === 'individual' || normalized === 'person' || normalized === '个人'
}
function validityTypeLabel(value: unknown) {
  const normalized = String(value || '').toLowerCase()
  if (normalized === 'long_term' || normalized === 'long-term' || normalized === '长期') return '长期'
  if (normalized === 'fixed' || normalized === '非长期') return '非长期'
  if (normalized === 'long_term' || normalized === 'long-term' || normalized === '长期' || normalized === '1') return '长期'
  if (normalized === 'fixed' || normalized === '非长期' || normalized === '0') return '非长期'
  return text(value)
}
@@ -47,8 +52,10 @@
      <div class="status-value">{{ statusLabel(detail.status) }}</div>
      <h3 class="onboarding-heading">商户进件</h3>
      <div class="detail-grid">
      <div class="detail-grid common-grid">
        <div class="detail-field"><span>商户类型</span><strong>{{ subjectTypeLabel(detail.subjectType) }}</strong></div>
      </div>
      <div v-if="!isIndividual(detail.subjectType)" class="detail-grid">
        <div class="detail-field"><span>统一社会信用代码</span><strong>{{ text(detail.creditCode) }}</strong></div>
        <div class="detail-field"><span>营业执照有效期</span><strong>{{ validityTypeLabel(detail.licenseValidityType) }}</strong></div>
        <div class="detail-field placeholder-field"></div>
@@ -65,6 +72,15 @@
        <div class="detail-field"><span>管理员姓名</span><strong>{{ text(detail.contactName) }}</strong></div>
        <div class="detail-field"><span>管理员手机号</span><strong>{{ text(detail.contactMobile) }}</strong></div>
      </div>
      <div v-else class="detail-grid">
        <div class="detail-field"><span>个人姓名</span><strong>{{ text(detail.subjectName) }}</strong></div>
        <div class="detail-field"><span>个人证件类型</span><strong>身份证</strong></div>
        <div class="detail-field"><span>个人证件号码</span><strong>{{ text(detail.subjectIdNoMasked) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期类型</span><strong>{{ validityTypeLabel(detail.subjectCertValidityType) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期开始日期</span><strong>{{ text(detail.subjectCertBeginDate) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期截止日期</span><strong>{{ text(detail.subjectCertEndDate) }}</strong></div>
        <div class="detail-field"><span>手机号</span><strong>{{ text(detail.contactMobile) }}</strong></div>
      </div>
    </section>
  </div>
</template>
@@ -76,6 +92,7 @@
.status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; }
.onboarding-heading { margin-top: 24px !important; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 860px; }
.common-grid { margin-bottom: -2px; }
.detail-field { display: grid; grid-template-columns: 118px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; }
.detail-field > span { color: #303133; font-size: 13px; font-weight: 500; white-space: nowrap; }
.detail-field > strong { min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; }
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -116,6 +116,7 @@
    router.push({ name: 'merchant-onboarding-detail', params: { id: row.id } })
    return
  }
  if (kind.value === 'bank-cards') return
  reviewing.value = row
  reviewVisible.value = true
}
@@ -244,13 +245,13 @@
          <template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
        </el-table-column>
        <el-table-column label="状态" min-width="100" align="center">
          <template #default="{ row }">
          <template v-if="kind !== 'bank-cards'" #default="{ row }">
            <el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="110" fixed="right" align="center">
          <template #default="{ row }">
            <el-button v-if="kind === 'onboardings'" link type="primary" @click="openReview(row)">查看信息</el-button>
            <el-button v-if="kind === 'onboardings'" link type="primary" @click="openReview(row)">查看</el-button>
            <el-button v-else-if="row.status === 'pending'" link type="primary" @click="openReview(row)">审核</el-button>
            <span v-else class="muted">-</span>
          </template>
talent-card-admin/src/views/UpgradeApplicationDetailView.vue
@@ -103,7 +103,7 @@
    </section>
    <section v-if="detail" class="upgrade-detail-actions">
      <template v-if="pending">
        <el-button type="primary" :loading="processing" @click="approve">已处理</el-button>
        <el-button type="primary" :loading="processing" @click="approve">处理</el-button>
        <el-button type="danger" :loading="processing" @click="reject">审核不通过</el-button>
      </template>
      <span v-else>审核结果:{{ text(detail.status) }}<template v-if="detail.auditRemark">({{ detail.auditRemark }})</template></span>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -30,8 +30,6 @@
  @GetMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> onboarding(@PathVariable Long id) { return ApiResponse.success(settlementService.onboarding(id)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @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/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)); }
  @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); }
  @PostMapping("/payment-reconciliation/run") public ApiResponse<Map<String,Object>> runPaymentReconciliation(@RequestParam(defaultValue = "5") int pendingAfterMinutes) { return ApiResponse.success(reconciliationService.run(pendingAfterMinutes)); }
talent-card-server/src/main/java/com/talentcard/server/business/auth/TalentAuthService.java
@@ -41,12 +41,15 @@
    @Transactional
    public Map<String, Object> createDevelopmentSession(long talentId) {
        List<Map<String, Object>> talents = rows("""
                select id, openid
                select id, openid, status
                from talent_user
                where id = ? and deleted = 0 and status = 1
                where id = ? and deleted = 0
                limit 1 for update
                """, talentId);
        if (talents.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "开发测试人才账号不存在或不可用");
        if (talents.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "开发测试人才账号不存在");
        if (number(talents.get(0).get("status")) != 1) {
            throw new BusinessException(ErrorCode.FORBIDDEN, "持卡人被停用");
        }
        Object openId = talents.get(0).get("openid");
        if (openId == null || String.valueOf(openId).isBlank()) {
            throw new BusinessException(ErrorCode.CONFLICT, "开发测试人才账号未配置微信标识");
@@ -103,13 +106,16 @@
    private Map<String, Object> establishSession(String openId, String mobile) {
        List<Map<String, Object>> talents = rows("""
                select id, openid
                select id, openid, status
                from talent_user
                where mobile = ? and deleted = 0 and status = 1
                where mobile = ? and deleted = 0
                limit 1 for update
                """, mobile);
        if (talents.isEmpty()) return issueSession(registerVisitor(openId, mobile));
        Map<String, Object> talent = talents.get(0);
        if (number(talent.get("status")) != 1) {
            throw new BusinessException(ErrorCode.FORBIDDEN, "持卡人被停用");
        }
        long talentId = number(talent.get("id"));
        List<Map<String, Object>> binding = rows("""
                select t.id,
talent-card-server/src/main/java/com/talentcard/server/business/merchant/HuifuMerchantWithdrawalService.java
@@ -5,6 +5,7 @@
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalQueryCommand;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -59,6 +60,25 @@
        ProviderResult result = ProviderResult.from(data);
        if (result.success()) complete(withdrawalNo, result); else if (result.failed()) fail(withdrawalNo, result.code(), result.message()); else pending(withdrawalNo, result);
    }
    /** Query is the fallback for a delayed or missed provider callback. */
    public void reconcileProcessing(int limit) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select w.withdrawal_no, w.provider_request_date, o.provider_huifu_id
                from merchant_withdrawal w
                join merchant_onboarding_application o on o.merchant_id=w.merchant_id and o.store_id=w.store_id and o.status='approved'
                where w.status='processing' and w.provider_request_date is not null
                order by w.id asc limit ?
                """, Math.max(1, Math.min(limit, 100))));
        for (Map<String, Object> row : rows) {
            String no = text(row.get("withdrawalNo"));
            try {
                ProviderResult result = ProviderResult.from(gateway.query(new HuifuWithdrawalQueryCommand(no, text(row.get("providerHuifuId")), text(row.get("providerRequestDate")))));
                if (result.success()) complete(no, result); else if (result.failed()) fail(no, result.code(), result.message()); else pending(no, result);
            } catch (HuifuPaymentGatewayException ignored) {
                // Keep processing; the following scheduled run will retry the idempotent status query.
            }
        }
    }
    public boolean exists(String withdrawalNo) { Integer count = jdbc.queryForObject("select count(*) from merchant_withdrawal where withdrawal_no=?", Integer.class, withdrawalNo); return count != null && count > 0; }
    private void complete(String no, ProviderResult r) { jdbc.update("update merchant_withdrawal set status='paid', provider_trade_no=coalesce(?,provider_trade_no), provider_response_code=?, provider_response_message=?, paid_at=coalesce(paid_at,now()), failure_reason=null where withdrawal_no=? and status in ('pending','processing')", r.providerNo(), r.code(), r.message(), no); }
    private void fail(String no, String code, String message) { jdbc.update("update merchant_withdrawal set status='failed', provider_response_code=?, provider_response_message=?, failure_reason=? where withdrawal_no=? and status in ('pending','processing')", code, message, message, no); }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -17,27 +17,31 @@
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.business.payment.huifu.HuifuUserBusinessOpenCommand;
import com.talentcard.server.business.payment.huifu.HuifuUserBusinessOpenGateway;
import com.talentcard.server.common.SensitiveDataCrypto;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
public class MerchantSettlementService {
    private final JdbcTemplate jdbc;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    private final HuifuIndividualOnboardingGateway individualOnboardingGateway;
    private final HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final HuifuUserBusinessOpenGateway userBusinessOpenGateway;
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway) {
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway) {
        this.jdbc = jdbc;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
        this.individualOnboardingGateway = individualOnboardingGateway;
        this.enterpriseOnboardingGateway = enterpriseOnboardingGateway;
        this.withdrawalService = withdrawalService;
        this.userBusinessOpenGateway = userBusinessOpenGateway;
    }
    @Transactional(readOnly = true)
@@ -91,9 +95,7 @@
    private void submitIndividualOnboarding(String applicationNo, MerchantOnboardingRequest request) {
        try {
            log.error("Huifu Individual onboarding request: {},{}", request,applicationNo);
            HuifuIndividualOnboardingGateway.Result result = individualOnboardingGateway.create(new HuifuIndividualOnboardingCommand(applicationNo, request.subjectName().trim(), request.subjectIdNumber().trim().toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), request.contactMobile().trim(), clean(request.registeredAddress())));
            log.error("Huifu Individual onboarding result: {}", result);
            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), 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);
@@ -113,13 +115,21 @@
        List<Map<String, Object>> repeated = rows("select * from merchant_bank_card where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return bankCard(repeated.get(0));
        String number = request.cardNumber().trim(); String hash = sha256(number);
        List<Map<String, Object>> same = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ? limit 1", principal.merchantId(), hash);
        if (!same.isEmpty()) return bankCard(same.get(0));
        List<Map<String, Object>> branchRecords = rows("select clearing_code, branch_name, bank_name from bank_branch_directory where clearing_code = ? limit 1", request.branchCode().trim());
        List<Map<String, Object>> branchRecords = rows("select clearing_code, branch_name, bank_name, head_bank_code from bank_branch_directory where clearing_code = ? limit 1", request.branchCode().trim());
        if (branchRecords.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行");
        Map<String, Object> branch = branchRecords.get(0);
        List<Map<String, Object>> same = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ? limit 1", principal.merchantId(), hash);
        if (!same.isEmpty()) {
            Map<String, Object> existing = same.get(0);
            if (!"rejected".equals(clean(existing.get("status")))) return bankCard(existing);
            jdbc.update("update merchant_bank_card set status='pending', reject_reason=null, provider_token_no=null, provider_apply_no=null, provider_request_no=null, provider_response_code=null, provider_response_message=null where id=?", existing.get("id"));
            submitBankCardToHuifu(principal, existing, number, branch);
            return bankCard(rows("select * from merchant_bank_card where id = ?", existing.get("id")).get(0));
        }
        jdbc.update("insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, branch_code, city_code, card_tail, card_masked, card_hash, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), clean(branch.get("bankName")), clean(branch.get("branchName")), clean(branch.get("clearingCode")), request.cityCode().trim(), number.substring(number.length() - 4), "**** **** **** " + number.substring(number.length() - 4), hash, key.trim());
        return bankCard(rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ?", principal.merchantId(), hash).get(0));
        Map<String, Object> card = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ?", principal.merchantId(), hash).get(0);
        submitBankCardToHuifu(principal, card, number, branch);
        return bankCard(rows("select * from merchant_bank_card where id = ?", card.get("id")).get(0));
    }
    @Transactional(readOnly = true)
@@ -191,10 +201,54 @@
        if (request.amountCents() > available) throw new BusinessException(ErrorCode.CONFLICT, "提现金额不能超过当前可提金额");
        String no = BusinessIdGenerator.next("MW");
        jdbc.update("insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, amount_cents, idempotency_key) values (?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, cardId, request.amountCents(), key.trim());
        Long withdrawalId = jdbc.queryForObject("select id from merchant_withdrawal where withdrawal_no = ?", Long.class, no);
        withdrawalService.dispatch(withdrawalId);
        return withdrawal(rows("select w.*, c.bank_name, c.card_masked from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id where w.withdrawal_no = ?", no).get(0));
    }
    private void requireApproved(MerchantPrincipal principal) { if (!"approved".equals(onboardingStatus(principal).get("status"))) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过"); }
    private void submitBankCardToHuifu(MerchantPrincipal principal, Map<String, Object> card, String cardNumber, Map<String, Object> branch) {
        List<Map<String, Object>> onboarding = rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and status='approved' and provider_huifu_id is not null order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (onboarding.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant onboarding is not approved");
        Map<String, Object> subject = onboarding.get(0);
        String bankCode = clean(branch.get("headBankCode"));
        if (bankCode.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "The selected bank branch has no Huifu bank code");
        String requestNo = BusinessIdGenerator.next("BC");
        try {
            HuifuUserBusinessOpenGateway.Result result = userBusinessOpenGateway.create(new HuifuUserBusinessOpenCommand(
                    requestNo, clean(subject.get("providerHuifuId")), clean(subject.get("providerHuifuId")), "enterprise".equalsIgnoreCase(clean(subject.get("subjectType"))) ? "0" : "2", clean(branch.get("bankName")), cardNumber,
                    provinceCode(clean(card.get("cityCode"))), clean(card.get("cityCode")), bankCode, clean(branch.get("clearingCode")), clean(branch.get("branchName")),
                    sensitiveDataCrypto.decrypt(clean(subject.get("subjectIdNoEncrypted"))), certificateValidityType(clean(subject.get("subjectCertValidityType"))), clean(subject.get("subjectCertBeginDate")), clean(subject.get("subjectCertEndDate")), clean(subject.get("contactMobile"))));
            if (result.accepted() && !result.tokenNo().isBlank()) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=?, provider_request_no=?, provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=?", result.tokenNo(), result.applyNo(), requestNo, result.code(), result.message(), card.get("id"));
            else if (result.accepted()) jdbc.update("update merchant_bank_card set status='pending', provider_apply_no=?, provider_request_no=?, provider_response_code=?, provider_response_message=?, reject_reason=null where id=?", result.applyNo(), requestNo, result.code(), result.message(), card.get("id"));
            else jdbc.update("update merchant_bank_card set status='rejected', provider_request_no=?, provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=?", requestNo, result.code(), result.message(), providerReason(result.code(), result.message()), card.get("id"));
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_bank_card set provider_request_no=?, reject_reason=? where id=?", requestNo, "Huifu submission outcome is pending confirmation", card.get("id"));
        }
    }
    @Transactional
    @SuppressWarnings("unchecked")
    public boolean handleBankCardCallback(Map<String, Object> payload) {
        Map<String, Object> data = payload;
        Object nested = payload.get("data");
        if (nested instanceof String value && !value.isBlank()) {
            try { data = com.alibaba.fastjson.JSON.parseObject(value, Map.class); } catch (Exception ignored) { }
        } else if (nested instanceof Map<?, ?> value) data = (Map<String, Object>) value;
        String requestNo = clean(data.get("req_seq_id"));
        if (requestNo.isBlank()) requestNo = clean(payload.get("req_seq_id"));
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> cards = rows("select * from merchant_bank_card where provider_request_no=? limit 1 for update", requestNo);
        if (cards.isEmpty()) return false;
        String tokenNo = clean(data.get("token_no"));
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
        String applyNo = clean(data.get("apply_no"));
        if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cards.get(0).get("id"));
        else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cards.get(0).get("id"));
        else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cards.get(0).get("id"));
        return true;
    }
    private void requireKey(String key) { if (key == null || key.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); }
    private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); }
    private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); }
@@ -209,6 +263,7 @@
    private String certificateValidityType(String value) { return "long_term".equals(value) ? "1" : "0"; }
    private String certificateBeginDate(String value) { return clean(value).replace("-", ""); }
    private String certificateEndDate(String value) { return certificateBeginDate(value); }
    private String provinceCode(String cityCode) { return cityCode != null && cityCode.matches("\\d{6}") ? cityCode.substring(0, 2) + "0000" : ""; }
    private void requireCertificatePeriod(String validityType, String beginDate, String endDate, String label) {
        if (blank(beginDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, label + " begin date is required");
        if (!"long_term".equals(validityType) && blank(endDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, label + " end date is required when it is not long-term");
@@ -217,6 +272,7 @@
    private boolean blank(String value) { return value == null || value.isBlank(); }
    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 boolean providerSuccess(String code) { return "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); }
    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/merchant/MerchantWithdrawalReconciliationScheduler.java
New file
@@ -0,0 +1,22 @@
package com.talentcard.server.business.merchant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class MerchantWithdrawalReconciliationScheduler {
    private static final Logger log = LoggerFactory.getLogger(MerchantWithdrawalReconciliationScheduler.class);
    private final HuifuMerchantWithdrawalService service;
    @Value("${app.payment.huifu.withdrawal-reconciliation.enabled:true}") private boolean enabled;
    public MerchantWithdrawalReconciliationScheduler(HuifuMerchantWithdrawalService service) { this.service = service; }
    @Scheduled(fixedDelayString = "${app.payment.huifu.withdrawal-reconciliation.fixed-delay-ms:300000}")
    public void reconcile() {
        if (!enabled) return;
        try { service.reconcileProcessing(100); }
        catch (RuntimeException exception) { log.warn("Merchant withdrawal reconciliation failed: {}", exception.getClass().getSimpleName()); }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentCallbackController.java
@@ -3,6 +3,7 @@
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.merchant.HuifuMerchantWithdrawalService;
import com.talentcard.server.business.merchant.MerchantSettlementService;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -21,11 +22,13 @@
    private final HuifuPaymentCallbackService callbackService;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final HuifuSignatureService signatureService;
    private final MerchantSettlementService settlementService;
    public PaymentCallbackController(HuifuPaymentCallbackService callbackService, HuifuMerchantWithdrawalService withdrawalService, HuifuSignatureService signatureService) {
    public PaymentCallbackController(HuifuPaymentCallbackService callbackService, HuifuMerchantWithdrawalService withdrawalService, HuifuSignatureService signatureService, MerchantSettlementService settlementService) {
        this.callbackService = callbackService;
        this.withdrawalService = withdrawalService;
        this.signatureService = signatureService;
        this.settlementService = settlementService;
    }
    @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
@@ -43,4 +46,14 @@
        withdrawalService.handleCallback(no, data);
        return ResponseEntity.ok("RECV_ORD_ID_" + no);
    }
    @PostMapping(value = "/merchant-business-notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> merchantBusinessNotify(@RequestParam Map<String, String> form) {
        String payload = form.get("resp_data"), signature = form.get("sign");
        if (!signatureService.verifyCallbackPayload(payload, signature)) throw new BusinessException(ErrorCode.FORBIDDEN, "Invalid Huifu merchant business callback signature");
        @SuppressWarnings("unchecked") Map<String, Object> data = JSON.parseObject(payload, Map.class);
        String no = data.get("req_seq_id") == null ? "" : String.valueOf(data.get("req_seq_id")).trim();
        if (!settlementService.handleBankCardCallback(data)) throw new BusinessException(ErrorCode.NOT_FOUND, "Unknown Huifu merchant business callback");
        return ResponseEntity.ok("RECV_ORD_ID_" + no);
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -16,6 +16,7 @@
    private String notifyUrl = "";
    private String withdrawalProductId = "";
    private String withdrawalNotifyUrl = "";
    private String merchantBusinessNotifyUrl = "";
    private String withdrawalIntoAccountDateType = "D0";
    private boolean delayAccount;
@@ -41,6 +42,11 @@
    public void setWithdrawalProductId(String withdrawalProductId) { this.withdrawalProductId = withdrawalProductId; }
    public String getWithdrawalNotifyUrl() { return withdrawalNotifyUrl; }
    public void setWithdrawalNotifyUrl(String withdrawalNotifyUrl) { this.withdrawalNotifyUrl = withdrawalNotifyUrl; }
    public String getMerchantBusinessNotifyUrl() {
        if (merchantBusinessNotifyUrl != null && !merchantBusinessNotifyUrl.isBlank()) return merchantBusinessNotifyUrl;
        return withdrawalNotifyUrl == null ? "" : withdrawalNotifyUrl.replace("/withdrawal-notify", "/merchant-business-notify");
    }
    public void setMerchantBusinessNotifyUrl(String merchantBusinessNotifyUrl) { this.merchantBusinessNotifyUrl = merchantBusinessNotifyUrl; }
    public String getWithdrawalIntoAccountDateType() { return withdrawalIntoAccountDateType; }
    public void setWithdrawalIntoAccountDateType(String withdrawalIntoAccountDateType) { this.withdrawalIntoAccountDateType = withdrawalIntoAccountDateType; }
    public boolean isDelayAccount() { return delayAccount; }
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuUserBusinessOpenCommand.java
New file
@@ -0,0 +1,7 @@
package com.talentcard.server.business.payment.huifu;
public record HuifuUserBusinessOpenCommand(
        String requestNo, String huifuId, String upperHuifuId, String cardType, String cardName, String cardNumber,
        String provinceCode, String cityCode, String bankCode, String branchCode, String branchName,
        String certificateNo, String certificateValidityType, String certificateBeginDate,
        String certificateEndDate, String mobileNo) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuUserBusinessOpenGateway.java
New file
@@ -0,0 +1,62 @@
package com.talentcard.server.business.payment.huifu;
import com.alibaba.fastjson.JSON;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class HuifuUserBusinessOpenGateway {
    private static final Object SDK_LOCK = new Object();
    private final HuifuPaymentProperties properties;
    public HuifuUserBusinessOpenGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public Result create(HuifuUserBusinessOpenCommand command) {
        ensureConfigured();
        Map<String, Object> card = new LinkedHashMap<>();
        card.put("card_type", command.cardType());
        card.put("card_name", command.cardName());
        card.put("card_no", command.cardNumber());
        card.put("prov_id", command.provinceCode());
        card.put("area_id", command.cityCode());
        card.put("bank_code", command.bankCode());
        card.put("branch_code", command.branchCode());
        card.put("branch_name", command.branchName());
        card.put("cert_type", "00");
        card.put("cert_no", command.certificateNo());
        card.put("cert_validity_type", command.certificateValidityType());
        card.put("cert_begin_date", command.certificateBeginDate());
        if (!blank(command.certificateEndDate())) card.put("cert_end_date", command.certificateEndDate());
        card.put("mp", command.mobileNo());
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("req_seq_id", command.requestNo());
        request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
        request.put("huifu_id", command.huifuId());
        request.put("upper_huifu_id", command.upperHuifuId());
        request.put("card_info", JSON.toJSONString(card));
        if (!blank(properties.getMerchantBusinessNotifyUrl())) request.put("async_return_url", properties.getMerchantBusinessNotifyUrl().trim());
        try {
            synchronized (SDK_LOCK) { initializeSdk(); return Result.from(BasePayClient.request(request, "v2.user.busi.open", false)); }
        } catch (BusinessException exception) { throw exception; }
        catch (Exception exception) { throw new HuifuPaymentGatewayException("Huifu user business opening request failed", unknown(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.getMerchantId()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu user business opening 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 boolean blank(String value) { return value == null || value.isBlank(); }
    private boolean unknown(Exception exception) { String value = exception.getMessage() == null ? "" : exception.getMessage().toLowerCase(); return value.contains("timeout") || value.contains("no response"); }
    @SuppressWarnings("unchecked") public record Result(boolean accepted, String tokenNo, String applyNo, String code, String message) {
        static Result from(Map<String, Object> response) { Object raw = response.get("data"); Map<String, Object> data = raw instanceof Map<?, ?> value ? (Map<String, Object>) value : raw instanceof String json && !json.isBlank() ? JSON.parseObject(json, Map.class) : response; String code = value(data, "sub_resp_code", "resp_code", "code"); return new Result(success(code), value(data, "token_no", "tokenNo"), value(data, "apply_no", "applyNo"), code, value(data, "sub_resp_desc", "resp_desc", "message")); }
        private static boolean success(String code) { return "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); }
        private static String value(Map<String, Object> data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java
@@ -4,6 +4,7 @@
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.V2TradeSettlementEncashmentRequest;
import com.huifu.bspay.sdk.opps.core.request.V2TradeSettlementQueryRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.slf4j.Logger;
@@ -45,6 +46,23 @@
            throw new HuifuPaymentGatewayException("Huifu withdrawal request failed", unknown(exception), exception);
        }
    }
    public Map<String, Object> query(HuifuWithdrawalQueryCommand command) {
        ensureConfigured();
        V2TradeSettlementQueryRequest request = new V2TradeSettlementQueryRequest();
        request.setHuifuId(command.huifuId());
        request.setOrgReqDate(command.requestDate());
        request.setOrgReqSeqId(command.withdrawalNo());
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                return BasePayClient.request(request, false);
            }
        } catch (BusinessException exception) { throw exception; }
        catch (Exception exception) {
            log.warn("Huifu withdrawal query failed: type={}, message={}", exception.getClass().getSimpleName(), safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException("Huifu withdrawal query failed", unknown(exception), exception);
        }
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
        if (blank(properties.getSystemId()) || blank(properties.getWithdrawalProductId()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu withdrawal configuration is incomplete");
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalQueryCommand.java
New file
@@ -0,0 +1,3 @@
package com.talentcard.server.business.payment.huifu;
public record HuifuWithdrawalQueryCommand(String withdrawalNo, String huifuId, String requestDate) {}
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java
@@ -33,9 +33,9 @@
        Map<String, Object> product = purchasableProduct(request.merchantId(), request.productId());
        int usedFreeCount = usedFreeCount(talentId, request.productId(), product.get("rightsConfig"), talentCard.get("cardLevel"));
        ProductPriceCalculator.ProductPrice personalizedPrice = ProductPriceCalculator.calculate(product.get("originalPrice"), product.get("salePrice"), product.get("rightsConfig"), talentCard.get("cardLevel"), usedFreeCount);
        Map<String, Object> coupon = request.couponId() == null ? null : usableCoupon(request.merchantId(), request.productId(), personalizedPrice.salePrice(), request.couponId());
        Map<String, Object> existing = findReusableOrder(talentId, request.merchantId(), request.productId(), request.couponId());
        if (existing != null) return existing;
        Map<String, Object> coupon = request.couponId() == null ? null : usableCoupon(talentId, request.merchantId(), request.productId(), personalizedPrice.salePrice(), request.couponId(), String.valueOf(talentCard.get("cardLevel")));
        long originalAmount = MoneyUtils.yuanToCent(personalizedPrice.originalPrice());
        long basePayAmount = MoneyUtils.yuanToCent(personalizedPrice.salePrice());
@@ -144,20 +144,26 @@
        return today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
    }
    private Map<String, Object> usableCoupon(Long merchantId, Long productId, Object salePrice, Long couponId) {
    private Map<String, Object> usableCoupon(Long talentId, Long merchantId, Long productId, Object salePrice, Long couponId, String cardLevel) {
        List<Map<String, Object>> rows = query("""
                select c.id, c.coupon_amount
                from merchant_coupon c
                where c.id = ? and c.deleted = 0 and c.status = 1 and c.merchant_id = ?
                  and (c.valid_start is null or c.valid_start <= current_date)
                  and (c.valid_end is null or c.valid_end >= current_date)
                  and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
                  and (c.product_scope = 'ALL' or exists (
                    select 1 from merchant_coupon_product cp
                    where cp.coupon_id = c.id and cp.product_id = ?
                  ))
                limit 1
                """, couponId, merchantId, decimal(salePrice), productId);
              and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
              and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
              and (c.product_scope = 'ALL' or exists (
                select 1 from merchant_coupon_product cp
                where cp.coupon_id = c.id and cp.product_id = ?
              ))
              and not exists (
                select 1 from benefit_order o
                where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                  and (o.expires_at is null or o.expires_at > current_timestamp)
              )
            limit 1
            """, couponId, merchantId, decimal(salePrice), cardLevel, productId, talentId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该优惠券当前不可用,请重新选择");
        return rows.get(0);
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderCancelRequest.java
New file
@@ -0,0 +1,6 @@
package com.talentcard.server.business.portal;
import jakarta.validation.constraints.NotNull;
public record PendingOrderCancelRequest(@NotNull Long talentId) {
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderExpiryScheduler.java
New file
@@ -0,0 +1,27 @@
package com.talentcard.server.business.portal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** Ensures expired unpaid orders are cancelled even when the user has left the mini program. */
@Component
public class PendingOrderExpiryScheduler {
    private static final Logger log = LoggerFactory.getLogger(PendingOrderExpiryScheduler.class);
    private final PendingOrderPortalService pendingOrderService;
    public PendingOrderExpiryScheduler(PendingOrderPortalService pendingOrderService) {
        this.pendingOrderService = pendingOrderService;
    }
    @Scheduled(fixedDelayString = "${app.order.pending-expiry.fixed-delay-ms:60000}")
    public void cancelExpiredOrders() {
        try {
            int count = pendingOrderService.expireDueOrders();
            if (count > 0) log.info("Cancelled {} expired pending order(s)", count);
        } catch (RuntimeException exception) {
            log.warn("Expired pending order cleanup failed: {}", exception.getClass().getSimpleName());
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalController.java
@@ -48,6 +48,16 @@
        return ApiResponse.success(pendingOrderPortalService.chooseCoupon(orderNo, request));
    }
    @PostMapping("/{orderNo}/cancel")
    public ApiResponse<Map<String, Object>> cancel(
            @PathVariable String orderNo,
            @Valid @RequestBody PendingOrderCancelRequest request,
            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
    ) {
        ensureSessionTalent(authorization, request.talentId());
        return ApiResponse.success(pendingOrderPortalService.cancel(orderNo, request.talentId()));
    }
    @PostMapping("/{orderNo}/pay")
    public ApiResponse<Map<String, Object>> pay(@PathVariable String orderNo, @Valid @RequestBody PendingOrderPaymentRequest request) {
        throw new BusinessException(ErrorCode.CONFLICT, "支付接口已升级,请使用支付发起接口");
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
@@ -20,10 +20,22 @@
    public PendingOrderPortalService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }
    @Transactional(readOnly = true)
    @Transactional
    public Map<String, Object> detail(String orderNo, Long talentId) {
        expireOrder(orderNo, talentId);
        Map<String, Object> order = find(orderNo, talentId);
        return view(order);
    }
    /** Cancels every unpaid order that has reached its payment deadline. */
    @Transactional
    public int expireDueOrders() {
        return jdbcTemplate.update("""
            update benefit_order
            set order_status = 2
            where deleted = 0 and order_status = 0
              and expires_at is not null and expires_at <= current_timestamp
            """);
    }
    @Transactional
@@ -41,14 +53,21 @@
        Long productId = longValue(order.get("productId"));
        if (merchantId == null || productId == null) throw new BusinessException(ErrorCode.CONFLICT, "该订单暂不支持选择优惠券");
        BigDecimal basePayAmount = yuan(order.get("basePayAmount"));
        String cardLevel = activeCardLevel(request.talentId());
        List<Map<String, Object>> coupons = query("""
            select id, coupon_amount from merchant_coupon c
            where c.id = ? and c.deleted = 0 and c.status = 1 and c.merchant_id = ?
              and (c.valid_start is null or c.valid_start <= current_date)
              and (c.valid_end is null or c.valid_end >= current_date)
              and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
              and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
              and (c.product_scope = 'ALL' or exists (select 1 from merchant_coupon_product cp where cp.coupon_id = c.id and cp.product_id = ?))
            """, request.couponId(), merchantId, basePayAmount, productId);
              and not exists (
                select 1 from benefit_order o
                where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                  and o.order_no <> ? and (o.expires_at is null or o.expires_at > current_timestamp)
              )
            """, request.couponId(), merchantId, basePayAmount, cardLevel, productId, request.talentId(), orderNo);
        if (coupons.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该优惠券当前不可用,请重新选择");
        long couponAmount = Math.min(MoneyUtils.yuanToCent((BigDecimal) coupons.get(0).get("couponAmount")), longNumber(order.get("basePayAmount")));
        jdbcTemplate.update("""
@@ -65,6 +84,7 @@
        Long productId = longValue(order.get("productId"));
        if (merchantId == null || productId == null) return List.of();
        BigDecimal basePayAmount = yuan(order.get("basePayAmount"));
        String cardLevel = activeCardLevel(talentId);
        return query("""
            select c.id, c.coupon_name as name, c.coupon_amount as amount,
                   case when c.condition_type = 'NONE' or c.condition_amount is null then '无门槛使用' else concat('满', c.condition_amount, '元可用') end as scope,
@@ -74,9 +94,15 @@
              and (c.valid_start is null or c.valid_start <= current_date)
              and (c.valid_end is null or c.valid_end >= current_date)
              and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
              and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
              and (c.product_scope = 'ALL' or exists (select 1 from merchant_coupon_product cp where cp.coupon_id = c.id and cp.product_id = ?))
              and not exists (
                select 1 from benefit_order o
                where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                  and o.order_no <> ? and (o.expires_at is null or o.expires_at > current_timestamp)
              )
            order by c.coupon_amount desc, c.id desc
            """, merchantId, basePayAmount, productId);
            """, merchantId, basePayAmount, cardLevel, productId, talentId, orderNo);
    }
    @Transactional
@@ -125,13 +151,50 @@
        result.put("couponSelectable", longValue(order.get("productId")) != null && orderStatus == 0 && !expired);
        result.put("couponLabel", order.get("couponName") == null ? "无" : "减" + yuan(order.get("couponAmount")).stripTrailingZeros().toPlainString() + "元");
        result.put("originalAmount", yuan(order.get("orderAmount")));
        result.put("discountAmount", yuan(Math.max(0, longNumber(order.get("orderAmount")) - longNumber(order.get("payAmount")))));
        // “优惠金额”在待支付页仅表示优惠券抵扣,不应把人才折扣或补贴混入其中。
        result.put("discountAmount", yuan(order.get("couponAmount")));
        result.put("payAmount", yuan(order.get("payAmount")));
        result.put("subsidyAmount", yuan(order.get("subsidyAmount")));
        // 待支付页的补贴金额展示人才折扣与优惠券抵扣合计,即原价减去用户实付。
        result.put("subsidyAmount", yuan(Math.max(0, longNumber(order.get("orderAmount")) - longNumber(order.get("payAmount")))));
        return result;
    }
    private List<Map<String, Object>> query(String sql, Object... args) { return jdbcTemplate.queryForList(sql, args).stream().map(DatabaseRows::normalize).toList(); }
    private String activeCardLevel(Long talentId) {
        List<Map<String, Object>> cards = query("select card_level from talent_card where talent_id=? and deleted=0 and status=1 and card_type='PERSONAL' and (valid_end is null or valid_end >= current_date) order by id desc limit 1", talentId);
        if (cards.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前暂无有效人才卡");
        return String.valueOf(cards.get(0).get("cardLevel"));
    }
    @Transactional
    public Map<String, Object> cancel(String orderNo, Long talentId) {
        expireOrder(orderNo, talentId);
        Map<String, Object> order = find(orderNo, talentId);
        if (longNumber(order.get("orderStatus")) == 2) return view(order);
        if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消");
        Integer paymentCount = jdbcTemplate.queryForObject("""
            select count(*) from payment_transaction
            where order_no = ? and talent_id = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'SUCCEEDED')
            """, Integer.class, orderNo, talentId);
        if (paymentCount != null && paymentCount > 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "支付已发起,暂不能取消,请稍后确认支付结果");
        }
        int updated = jdbcTemplate.update("""
            update benefit_order set order_status = 2
            where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0
            """, orderNo, talentId);
        if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消");
        return view(find(orderNo, talentId));
    }
    private void expireOrder(String orderNo, Long talentId) {
        jdbcTemplate.update("""
            update benefit_order
            set order_status = 2
            where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0
              and expires_at is not null and expires_at <= current_timestamp
            """, orderNo, talentId);
    }
    private long longNumber(Object value) { return value instanceof Number number ? number.longValue() : 0; }
    private Long longValue(Object value) { return value instanceof Number number ? number.longValue() : null; }
    private BigDecimal yuan(Object cent) { return BigDecimal.valueOf(longNumber(cent), 2); }
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -662,6 +662,7 @@
    public List<Map<String, Object>> availableOrderCoupons(Long merchantId, Long productId, String orderId, Long talentId) {
        Map<String, Object> product = orderCouponProduct(merchantId, productId, orderId);
        BigDecimal productAmount = personalizedProductPrice(product, talentId);
        String cardLevel = talentId == null ? null : activeCouponCardLevel(talentId);
        return query("""
                select c.id, c.coupon_name, c.condition_type, c.condition_amount, c.coupon_amount,
                       c.valid_start, c.valid_end
@@ -669,13 +670,19 @@
                where c.deleted = 0 and c.status = 1 and c.merchant_id = ?
                  and (c.valid_start is null or c.valid_start <= current_date)
                  and (c.valid_end is null or c.valid_end >= current_date)
                  and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
                  and (c.product_scope = 'ALL' or exists (
                    select 1 from merchant_coupon_product cp
                    where cp.coupon_id = c.id and cp.product_id = ?
                  ))
                order by c.coupon_amount desc, c.id desc
                """, merchantId, productAmount, productId).stream()
              and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
              and (? is null or coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
              and (c.product_scope = 'ALL' or exists (
                select 1 from merchant_coupon_product cp
                where cp.coupon_id = c.id and cp.product_id = ?
              ))
              and (? is null or not exists (
                select 1 from benefit_order o
                where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                  and (o.expires_at is null or o.expires_at > current_timestamp)
              ))
            order by c.coupon_amount desc, c.id desc
            """, merchantId, productAmount, cardLevel, cardLevel, productId, talentId, talentId).stream()
                .map(this::couponListItem)
                .toList();
    }
@@ -691,6 +698,7 @@
        BigDecimal originalAmount = decimal(product.get("originalPrice"));
        if (originalAmount.signum() <= 0) originalAmount = decimal(product.get("salePrice"));
        BigDecimal productAmount = personalizedProductPrice(product, talentId);
        String cardLevel = talentId == null ? null : activeCouponCardLevel(talentId);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("orderId", orderId);
        result.put("merchantId", merchantId);
@@ -718,12 +726,18 @@
                where c.id = ? and c.deleted = 0 and c.status = 1 and c.merchant_id = ?
                  and (c.valid_start is null or c.valid_start <= current_date)
                  and (c.valid_end is null or c.valid_end >= current_date)
                  and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
                  and (c.product_scope = 'ALL' or exists (
                    select 1 from merchant_coupon_product cp
                    where cp.coupon_id = c.id and cp.product_id = ?
                  ))
                """, couponId, merchantId, productAmount, productId);
              and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?)
              and (? is null or coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0)
              and (c.product_scope = 'ALL' or exists (
                select 1 from merchant_coupon_product cp
                where cp.coupon_id = c.id and cp.product_id = ?
              ))
              and (? is null or not exists (
                select 1 from benefit_order o
                where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
                  and (o.expires_at is null or o.expires_at > current_timestamp)
              ))
            """, couponId, merchantId, productAmount, cardLevel, cardLevel, productId, talentId, talentId);
        if (coupons.isEmpty()) {
            throw new BusinessException(ErrorCode.CONFLICT, "该优惠券当前不可用,请重新选择");
        }
@@ -845,6 +859,12 @@
        return value.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
    }
    private String activeCouponCardLevel(Long talentId) {
        List<Map<String, Object>> cards = query("select card_level from talent_card where talent_id=? and deleted=0 and status=1 and card_type='PERSONAL' and (valid_end is null or valid_end >= current_date) order by id desc limit 1", talentId);
        if (cards.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前暂无有效人才卡");
        return String.valueOf(cards.get(0).get("cardLevel"));
    }
    private List<Map<String, Object>> query(String sql, Object... args) {
        return DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args));
    }
talent-card-server/src/main/resources/db/migration/V100__bank_branch_directory.sql
@@ -11,4 +11,22 @@
    key idx_bank_branch_directory_branch (branch_name(64))
) engine=InnoDB default charset=utf8mb4 comment='Bank branch clearing code directory';
alter table merchant_bank_card add column branch_code varchar(12) default null after branch_name;
DELIMITER $$
DROP PROCEDURE IF EXISTS add_merchant_bank_card_branch_code$$
CREATE PROCEDURE add_merchant_bank_card_branch_code()
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = database()
          AND table_name = 'merchant_bank_card'
          AND column_name = 'branch_code'
    ) THEN
        ALTER TABLE merchant_bank_card ADD COLUMN branch_code VARCHAR(12) DEFAULT NULL AFTER branch_name;
    END IF;
END$$
DELIMITER ;
CALL add_merchant_bank_card_branch_code();
DROP PROCEDURE IF EXISTS add_merchant_bank_card_branch_code;
talent-card-server/src/main/resources/db/migration/V102__talent_service_staff_assignment.sql
New file
@@ -0,0 +1,28 @@
DELIMITER $$
DROP PROCEDURE IF EXISTS add_talent_user_service_staff_assignment$$
CREATE PROCEDURE add_talent_user_service_staff_assignment()
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_schema = database()
          AND table_name = 'talent_user'
          AND column_name = 'service_staff_id'
    ) THEN
        ALTER TABLE talent_user ADD COLUMN service_staff_id BIGINT DEFAULT NULL AFTER advisor_id;
    END IF;
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.statistics
        WHERE table_schema = database()
          AND table_name = 'talent_user'
          AND index_name = 'idx_talent_user_service_staff_id'
    ) THEN
        CREATE INDEX idx_talent_user_service_staff_id ON talent_user (service_staff_id);
    END IF;
END$$
DELIMITER ;
CALL add_talent_user_service_staff_assignment();
DROP PROCEDURE IF EXISTS add_talent_user_service_staff_assignment;
talent-card-server/src/main/resources/db/migration/V103__merchant_bank_card_huifu_business_open.sql
New file
@@ -0,0 +1,5 @@
alter table merchant_bank_card
    add column provider_apply_no varchar(32) default null after provider_token_no,
    add column provider_request_no varchar(32) default null after provider_apply_no,
    add column provider_response_code varchar(32) default null after provider_request_no,
    add column provider_response_message varchar(512) default null after provider_response_code;
talent-card-server/src/main/resources/db/migration/V97__huifu_user_basicdata_onboarding_fields.sql
File was deleted
talent-card-server/src/main/resources/db/migration/V99__merchant_onboarding_huifu_kyc.sql
@@ -54,4 +54,4 @@
CALL add_columns_merchant_onboarding();
-- 清理存储过程
DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding;
DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding;
talent-card-server/src/main/resources/db/migration/V99__talent_service_staff_assignment.sql
File was deleted