feat(settlement): 完善商户结算功能并新增申请条件页面
- 移除银行卡片和提现审核的PATCH接口
- 更新优惠券可用性检查逻辑,增加人才等级限制和重复使用检测
- 重构优惠券选择页面,优化人才ID获取方式
- 新增人才卡申请条件详情页面,展示各类人才申请标准
- 添加汇付商户业务开通相关命令和网关实现
- 实现提现状态查询功能,用于处理延迟回调场景
- 配置汇付商户业务通知URL参数
| | |
| | | } |
| | | }, |
| | | { |
| | | "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" |
| | | } |
| | | } |
| | | ], |
| New file |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | 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 = () => { |
| | |
| | | <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) |
| | |
| | | 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() |
| | | }) |
| | |
| | | } |
| | | 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 |
| | | }, () => { |
| | |
| | | }) |
| | | 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()) |
| | |
| | | <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"> |
| | |
| | | const merchantId = ref('') |
| | | const productId = ref('') |
| | | const orderId = ref('') |
| | | const talentId = ref('') |
| | | const couponText = ref('无') |
| | | const couponAmount = ref(0.0) |
| | | const productName = ref('【川味盛宴】川菜12人餐') |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | <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> |
| | |
| | | <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) |
| | |
| | | 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 : '' |
| | |
| | | refreshCountdown() |
| | | if (isExpired.value) { |
| | | stopCountdown() |
| | | loadOrder() |
| | | } |
| | | }, 1000) |
| | | } |
| | |
| | | }, (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) => { |
| | |
| | | .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> |
| | |
| | | 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) |
| | |
| | | const isSolidNav = ref(false) |
| | | const merchantId = ref('') |
| | | const productId = ref('') |
| | | const talentId = ref('') |
| | | const productName = ref('【川味盛宴】川菜12人餐') |
| | | const productImage = ref('') |
| | | const fallbackImage = '' |
| | |
| | | // #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) |
| | |
| | | }) |
| | | 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 |
| | |
| | | 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) |
| | | } |
| | |
| | | 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', |
| | |
| | | |
| | | <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> |
| | | |
| | |
| | | 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() |
| | |
| | | }) |
| | | } |
| | | |
| | | 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) => { |
| | |
| | | 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) |
| | | } |
| | | |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | .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; } |
| | |
| | | router.push({ name: 'merchant-onboarding-detail', params: { id: row.id } }) |
| | | return |
| | | } |
| | | if (kind.value === 'bank-cards') return |
| | | reviewing.value = row |
| | | reviewVisible.value = true |
| | | } |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | @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)); } |
| | |
| | | @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, "开发测试人才账号未配置微信标识"); |
| | |
| | | |
| | | 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, |
| | |
| | | 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; |
| | |
| | | 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); } |
| | |
| | | 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) |
| | |
| | | |
| | | 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); |
| | |
| | | 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) |
| | |
| | | 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"))); } |
| | |
| | | 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"); |
| | |
| | | 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); } } |
| | | } |
| New file |
| | |
| | | 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()); } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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) |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | private String notifyUrl = ""; |
| | | private String withdrawalProductId = ""; |
| | | private String withdrawalNotifyUrl = ""; |
| | | private String merchantBusinessNotifyUrl = ""; |
| | | private String withdrawalIntoAccountDateType = "D0"; |
| | | private boolean delayAccount; |
| | | |
| | |
| | | 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; } |
| New file |
| | |
| | | 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) {} |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.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 ""; } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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"); |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | public record HuifuWithdrawalQueryCommand(String withdrawalNo, String huifuId, String requestDate) {} |
| | |
| | | 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()); |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.portal; |
| | | |
| | | import jakarta.validation.constraints.NotNull; |
| | | |
| | | public record PendingOrderCancelRequest(@NotNull Long talentId) { |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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, "支付接口已升级,请使用支付发起接口"); |
| | |
| | | |
| | | 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 |
| | |
| | | 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(""" |
| | |
| | | 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, |
| | |
| | | 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 |
| | |
| | | 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); } |
| | |
| | | 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 |
| | |
| | | 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(); |
| | | } |
| | |
| | | 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); |
| | |
| | | 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, "该优惠券当前不可用,请重新选择"); |
| | | } |
| | |
| | | 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)); |
| | | } |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| | |
| | | CALL add_columns_merchant_onboarding(); |
| | | |
| | | -- 清理存储过程 |
| | | DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding; |
| | | DROP PROCEDURE IF EXISTS add_columns_merchant_onboarding; |