技 术 评 审 版

商品 · 订单 · 退款交易中心优化改造

前端 lianer-backend-ui (Vue 2 / Element UI) · 后端 lianer-back (Spring Boot / Dubbo / Shenyu) · 分支 商品和订单交易中心优化改造V0510
技术范围概述本次改造以退款域为核心:重构主动退款抽屉、退款单审批流(跳过财务)、订单列表与详情抽屉群;新增退款自动审批工作流(无理由窗口按商品类型区分:会员卡下单48小时内·私教7天内,且权益未用)、权益申请即冻结(apply 阶段冻结,驳回/撤销自动解冻)与权益扣减时机迁移(apply → pay 退款完成时才扣);新增团购平台原路退款渠道(refundType=4);自动续费签约扣款单独识别、仅原路退款、可一键解约;订单支付来源厘清;并重构会员卡/私教 ↔ 团购商品 一对多绑定(门店配卡页,含性能待优化项)。前端改动已提交至 V0510 分支并推送;后端改动遵循「先改不提交」,仅在 lianer-back 工作区修改未 commit。
4退款渠道(含新增 refundType=4)
48h/7天无理由自动审批(会员卡48h·私教7天)+ 权益申请即冻结、退完才扣
1→N商品↔团购 一对多绑定(解耦主数据)
5退款单状态机节点 · 后端先改不提交
演示模式(dev-only)标「演示数据」的截图为人工注入示意(线上此类订单少)。在 9528 页面 F12 控制台执行 localStorage.setItem('demoFake','1');location.reload() 即可在订单列表/退款单据/退款抽屉实地看到自动续费扣款、系统自动审批等示意数据;localStorage.removeItem('demoFake') 关闭。仅 [DEV-ONLY] localStorage 开关控制,不进正常流程。
① 退款渠道模型与状态机 ② 退款单审批流改造(跳过财务) ③ 退款自动审批工作流 ④ RefundDrawer 主动退款抽屉 ⑤ 可退金额计算与两个关键实现点 ⑥ refundType=4 团购平台原路退款 ⑦ 商品 ↔ 团购 一对多映射重构 ⑧ 订单列表 / 详情抽屉重构 ⑨ 后端改动清单(未提交) ⑩ 风险点 / 待接入 / 评审关注

1退款渠道模型与状态机

refundType 渠道枚举

refundType渠道资金动作审核后流向退款上限
1原路退回泰融支付通道原路退审核 → 自动完成(status=3)实收金额
2撤销核销团购通道撤销、恢复券审核 → 自动完成(status=3)整笔 paidAmount
3财务打款线下银行转账审核 → 待打款(status=2)→ 财务回填OA → 完成最终实收 = (实收−已退)×(1−费率)
4团购平台原路退款无资金动作,仅标记+扣权益审核 → 自动完成(status=3)团购实付金额

refundType 初值在 apply() 阶段由原订单 payType 推导,非用户随意选;自动审批后团购/线下单财务可在抽屉显式改选渠道(撤销核销 / 团购平台原路退款 / 手动打款)。权益扣减已迁移到 pay() 退款完成时(详见「退款自动审批工作流」一节);在此之上新增「申请即冻结」中间态——用户一提交退款(apply)即冻结对应权益(暂时停用会员卡/私教课,审核打款期间不可约课/核销/进场),驳回(status=-1)或用户撤销(status=-2)自动解冻恢复使用,退款打款完成(status=3)才正式扣减退出。

status 状态机

status=1待审核 type 1/2/4 → status=3 type 3 → status=2 驳回 → status=-1 待打款 pay()财务回填OA status=3 打款完成终态 status=-2 用户取消
1 待审核2 审核待打款3 打款完成(终态)-1 运营驳回-2 用户取消

2退款单审批流改造(跳过财务)前端后端

src/views/finance/refundList.vue(约 1448 行,合并自 origin/Release260309 后重构)+ 后端 RefundBusiness.java

后端 RefundBusiness.java(未提交)

audit(): autoPayout = refundType==1 || refundType==2 || refundType==4
        → 命中则 audit 内串接 pay(),status 直推 3
        → refundType==4 成功文案「审核通过,已标记团购平台原路退款」
pay() : 新增 else-if payType==4 分支 —— 纯标记:
        setRefundType(4)/setStatus(3)/setStatusName("打款完成")/setPayTime
        不走通道、不动 tuangou_order

前端 refundList.vue

3退款自动审批工作流前端后端

用户提交 / 后台发起的退款,命中条件时系统自动审批;并把权益扣减时机从 apply 阶段迁移到「退款最终完成(pay)」——钱未确认退之前一律不实际扣减。同时在 apply 阶段新增「申请即冻结」:用户一提交退款即冻结对应权益(暂时停用,审核打款期间不可约课/核销/进场),被运营驳回(status=-1)或用户撤销(status=-2)则自动解冻、恢复正常使用,退款打款完成(status=3)才正式扣减退出。

触发条件(两者同时满足,所有退款来源 applyType 1/2 均适用)

条件判定
下单窗口内(按商品类型区分)
会员卡(时长卡/次数卡):48 小时内
私教(次数课/时长课):7 天内
会员卡:order.orderTime >= now - 48h
私教:order.orderTime >= now - 7d
本订单商品权益一次都未使用会员卡:lian_face_record本卡权益有效期窗口(userRights.startTime~endTime)内签到数 = 0(新增 selectNumByPhoneInRange,替换原全局 selectNumByUserId);私教时长课:checkRightsIdScheduleCount(rightsId)=0;次数/体验课:times==remain
apply() 退款申请窗口内(卡48h/私教7d) & 权益未用? refundType=1 原路退回自动 audit→pay,全自动·无财务 自动审批通过 → 待打款(status=2)财务在抽屉选资金渠道并确认 refundType=2 撤销核销 refundType=4 团购平台原路退款 refundType=3 手动打款 pay() 退款完成status=3·此刻才扣权益
▲ 自动审批后:原路退回全自动;其余由财务在抽屉显式选 撤销核销 / 团购平台原路退款 / 手动打款,确认即 pay 完成扣权益(非真机界面)

后端改动 RefundBusiness.java + LianerFaceRecordMapper(未提交)

前端 finance/refundList.vue

列表与处理抽屉对 auditUser==='系统自动审批' 的单据展示绿色「系统自动审批」标识;自动审批后的团购/线下单(含团购平台原路退款)天然落入既有 status=2 财务「选渠道 + 确认」流,财务在抽屉选 撤销核销 / 团购平台原路退款 / 手动打款 之一确认,无需改流程。

【演示数据·字段人工注入仅示意 UI】退款单据列表 · 系统自动审批标识(行1 原路退回已自动完成;行2 团购待财务确认渠道)
财务管理 → 退款单据
权益冻结口径(已采用申请即冻结)用户一提交退款(apply)即冻结对应权益——暂时停用会员卡/私教课,审核与打款期间不可约课/核销/进场;被运营驳回(status=-1)或用户撤销(userCancle, status=-2)→自动解冻、恢复正常使用;退款打款完成(status=3)→ 权益正式扣减退出。实现上:apply() 新增冻结、userCancle()/驳回解冻、pay() 退款完成时的实际扣减(cancelMemberRightsByOrder/cancelRightsByOrder)保留不变。

4RefundDrawer 主动退款抽屉前端

src/views/order/components/RefundDrawer.vue(约 800 行,新建/重构)— 从 order/member.vue & course.vue 的「退款」按钮唤起,渠道优先流。

组件协作RefundDrawer(发起)/ refundList.vue 内嵌处理抽屉(审批+打款,真实链路)/ RefundDetailDrawer(只读详情,订单列表「详情」入口)三者共用 refund-drawer.scss 视觉语言与渠道 pill(含 ch-4 绿色)。

自动续费签约扣款(isAuto==1)退款如何处理

抽屉对该来源做统一处理:

后端缺口(待接入)当前 RefundBusiness 退款链路未调用任何解约逻辑(解约在 RenewalBusiness),即退款不会终止签约 → 不解约就「退一笔又扣一笔」。需后端在退款完成时按前端传入的 terminateAutoRenew 调 RenewalBusiness 终止签约(后端先改不提交)。

独立特性:退款时可同时取消签约(解约开关)

【演示数据·isAuto 等人工注入仅示意 UI】自动续费扣款单退款抽屉(整屏全局)· 原路退回·自动续费代扣卡 + 自动续约优惠补差 + 同时终止自动续费签约开关
订单管理 → 会员卡订单(isAuto==1 行)→ 退款
【演示数据·人工注入仅示意 UI】提交前二次确认(整屏全局)· 含优惠补差 + 解约文案
底部「提交退款」→ $confirm
退款抽屉打开(整屏全局)· 订单概览 + ① 4 渠道卡(tuangou/original/offline/tgPlatform)
订单管理 → 会员卡订单 → 行内「退款」
选 offline 财务打款(整屏全局)· channelMax=finalReceived + ③ 权益残值 + ④ 费用拆解 computed 链路
RefundDrawer 内 pickMode('offline')
提交前 $confirm 二次确认(整屏全局)· 渠道相关文案
底部「提交退款」→ this.$confirm 退款确认

5可退金额计算 + 两个关键实现点前端

计算链路

// 残值:课时卡按比例;无单位数据兜底为剩余可退(要点②)
residualValue   = !totalUnits ? remainingPayable
                              : round2(paidAmount * remainUnits / totalUnits)
recommendedRefundable = max(0, residualValue - penaltyFee - discountSupplement)
maxRefund       = mode==='tuangou' ? paidAmount : min(channelMax, remainingPayable)
cappedRecommended     = min(recommendedRefundable, maxRefund)
// 财务打款上限 = 最终实收(要点①)
finalReceived   = round2(max(0, paidAmount - alreadyRefunded) * (1 - feeRate))
                  feeRate = hasTuangou ? 0.08 : 0.006

残值的具体计算

前端口径(RefundDrawer.vue computed,统一展示用)

// 单位:会员时长卡=天 / 会员次数卡=次 / 私教=节
paidAmount(实收)   = orderPrice ?? (totalPrice - discountPrice)
remainingPayable   = max(0, 实收 - alreadyRefunded(已退))
totalUnits(总单位) = orderDetail.times
usedUnits(已用)    = usedTimes ?? (totalUnits - surplusTimes) ?? 0
remainUnits(剩余)  = max(0, totalUnits - usedUnits)
unitPrice(单价)    = round2(实收 / totalUnits)
// 残值
residualValue = totalUnits==0 ? remainingPayable                 // 无单位数据兜底
                              : round2(实收 × remainUnits / totalUnits)  // ≡ 单价 × 剩余
defaultPenaltyFee = round2(residualValue × 0.2)                    // 默认违约金20%,可手改

后端口径(RefundBusiness.java,落库权威,按 orderType + cardType/courseCoachType 分支)

类型窗口内 且 未使用(会员卡 48 小时 / 私教 7 天)其他情况(残值 + 违约金)
会员卡 · 时长卡 cardType=1下单 48 小时内且未使用:全额,refundAmt=订单实收,服务费0,退款=总天数残值 = 实收 × 剩余天数 / 总天数
剩余天数 = endTime − max(startTime, now)
服务费 = 残值 × 20%;refundAmt = 残值 − 服务费
会员卡 · 次数卡 cardType=2下单 48 小时内且未使用:全额,refundAmt=订单实收,服务费0,退款=总次数单价 = 实收 / 总次数
残值 = 单价 × 剩余次数;服务费 = 残值 × 20%;refundAmt = 残值 − 服务费
私教 · 次数课 courseCoachType=1下单 7 天内且未使用:全额,refundAmt=订单实收,服务费0单价 = 实收 / 总次数;7 天内:refundAmt = 单价 × 剩余次(不扣违约金);超 7 天:再扣 20% 违约金
私教 · 时长课 courseCoachType=2下单 7 天内全额(且无任一已签到排课)按剩余时长比例折算 + 20% 违约金;当前订单有未签到排课则禁止退款(HAS_COURSE_NOT_ALLOW_REFUND)
口径差异前端按 实收 × 剩余/总 精确折算用于展示与推荐;后端时长卡按整天比例落库(days/totalday),二者在跨天/边界可能有取整差,最终以后端 audit/pay 落库金额为准;违约金默认 20%、自动续费订单含「优惠补差」扣项,运营可在抽屉手动覆盖可退金额。

要点 ① 财务打款上限 = 最终实收

channelMax() 的 offline 分支取 finalReceived(最终实收),label 为「最终实收(通道/平台扣费后)」,渠道卡副标题与上限提示按费率口径说明。与订单列表 computeFinalReceived 口径一致(团购 8% / 通道 0.6%)。

要点 ② 可退金额自动联动(computed 驱动)

要满足的联动

切换渠道 / 改违约金后可退金额需实时联动;订单无单位数据时也要给出合理默认值(非 0)。

为何用 computed 而非 watch

① watch + manualOverride 标志 + $nextTick 的多重异步联动时序难控,故用 computed 取代。
queryMemberOrderDetail 返回的 MemberOrderInfoResusedTimes/surplusTimes,时长卡/年卡 times 可空 → totalUnits=0,若残值硬返回 0 推荐值会塌成 0,故无单位数据时残值兜底为剩余可退。

// 重构:computed 驱动取代 watch/nextTick
isManualRefundable() { return this.manualRefundable !== null }
effectiveRefundable: {
  get() { return manualRefundable != null ? manualRefundable : cappedRecommended },
  set(v){ const n=num(v); manualRefundable = |n-cappedRecommended|<0.001 ? null : n }
}
// 兜底:无单位数据 → 残值 = 剩余可退(实付−已退),而非 0
residualValue(){ if(!totalUnits) return this.remainingPayable; ... }

同步删除 syncRefundable/onRefundableChange/resetRefundable 方法、form.penaltyFee/discountSupplement 两个 watch、未用的 hasSettleAmountapplyModeDefaults/reset 改为只置 manualRefundable=null

注意 Element UI 2.15.14 坑el-input-numbervalue watcher 是 immediate:true 且在 handler 内 $emit('input')。computed setter 已用「与 cappedRecommended 近似即视为回到自动态」消化该回灌,避免误标手动覆盖。

6refundType=4 团购平台原路退款

业务语义:用户已在美团/抖音 App 自助退款且到账,本系统仅做登记标记 + 权益扣减,不发起任何实际退款。仅对团购订单(hasTuangou)开放。

改动
前端 RefundDrawer新增 mode='tgPlatform' 第 4 卡片、limit/提示文案、buildPayload 分支、确认弹窗文案
前端 refundListrefundTypeList 增 {4,团购平台原路退款}、modeKey、ch-4 pill、各处文案分支
前端 RefundDetailDrawer4 渠道卡(ch-4)
样式refund-drawer.scss 增 icon-tgp / pill-tgPlatform(#f0f9eb / #67c23a 绿)
后端RefundBusiness audit/pay 的 type==4 分支(见 §4,未提交)

7商品 ↔ 团购 一对多映射重构前端

内部商品(会员卡 cardId / 私教课 courseId) 1 ── N 团购套餐 deal;团购核销发权益、撤销核销恢复券、refundType=4 扣权益均依赖此绑定。本次将绑定能力从分散的 groupCoupon/bingCoupon.vue 重构进 member/config.vue(门店配卡页),反向唯一:一个 dealId 只能绑一个内部商品。

数据模型(一对多约束)

// 内部商品(会员卡 cardId / 私教课 courseId) 1 ── N 团购绑定
binding = { channel:1美团点评|2抖音, dealId, dealGroupId,
            procuctType:1会员卡|2私教课, procuctId, procuctName }
// 反向唯一:一个 dealId 只能绑一个内部商品 → boundDealInfo(dealId) 命中即提示「已绑定到 XX」
层 / 文件改动
member/config.vue去 el-table 改门店纵向面板;每店左右分列会员卡/私教,商品卡片化;卡片下 getCardBindings/getCourseBindings 渲染绑定 chip(binding-mt/binding-dy);openAddBinding 选商户 key→storeTuangouList 拉 deals→单选→tunagouBindProductdeleteBindingdeleteTuangouProduct(二次确认);boundDealInfo 做反向唯一校验提示;loading 覆盖 stores+cards+courses+bindings 全量
member/components/BatchBindDrawer.vue 新建从 groupCoupon/bingCoupon.vue 移植的批量绑定抽屉,顶部「批量绑定团购」入口
member/components/CardEditDialog.vue 新建从 member/index.vue 抽出会员卡编辑弹窗,配卡页点卡片直接复用
course/components/CourseEditDialog.vue 新建从 course/index.vue 抽出私教课编辑弹窗,同上复用
APItunagouBindList / tunagouBindProduct / deleteTuangouProduct / storeTuangouList(@/api/mt)、tuangouKeyList(@/api/tk)
路由新增 router/modules/goods.js(+54);member.js/course.js 相应迁移
内部商品 cardId/ courseId deal A · 美团点评 deal B · 美团点评 deal C · 抖音 核销时 dealId → 内部商品发对应权益 / 退款扣对应权益
▲ 商品 1 ─ N 团购绑定 + 反向唯一 示意图(非真机界面)

性能问题(按现/旧方法查询会慢到不可用)

一对多绑定铺开后,门店配卡页的加载方式是前端 N×M 扇出 + 后端无批量聚合接口,数据量一上来查询会慢的要死:

实测量级仅按一个品牌(如「练二」)搜索即可命中上千个商品卡、近千条 binding;叠加 门店×商户key 组合,首屏需成百上千次往返 + 多次全表扫描,页面长时间 loading 甚至超时。
优化方向(待后端配合)① 后端提供按 storeId 列表批量返回绑定(已 join 商品名/类型)的聚合接口,前端一次请求拿当前分页全部门店的绑定,替代 N×M 扇出;② 课程/卡的「店→商品」索引改服务端分页 + 聚合,去掉 pageSize:9999 全量与逐课程 N+1;③ 绑定表对 (store_id, deal_id) / procuct_id 建索引。
门店配卡页(绑定主界面)+ 新增绑定弹窗 + 批量绑定抽屉
会员卡管理 → 门店配卡  |  路由 member/config

8订单列表 / 详情抽屉重构前端

order/member.vue(会员卡订单 +1080 行重构)/ order/course.vue(私教订单 +1592 行重构):列表由 8+ 列收敛为 7 列,并配套新建 5 个抽屉/页面组件。

列结构(合并后 7 列)

会员卡订单 member.vue私教订单 course.vue
订单订单号 + 渠道类型 tag(chlType,团购 warning 色);chlType=1 文案「正常下单」→「小程序下单」付款来源单独区分:isAuto==1(自动续费签约扣款)渲染独立 success 标签「自动续费扣款」,不再混入「小程序下单」(其退款渠道由后端 payType==0 || isAuto==1 → refundType=1 原路退回);私教补录审核状态语义聚合进本列(方案2)
门店 / 商品门店 / 会员卡门店 / 课程 / 教练
用户displayUserName 昵称 + 用户 ID + 用户维度 tag(全新 / 复购 / 体验)集中本列;移除重复「体验课」tag;后端 list 已 join 真实昵称,移除 [est] 假角标
金额 / 退款退款金额并入本列 + 退款「详情」链接(viewRefund→RefundDetailDrawer);order_price 统一「应付」语义;「实付」语义修正(方案 A);新增 computeFinalPaid「最终实付」/ computeFinalReceived「最终实收」(团购 8% / 通道 0.6% 前端估算,带 est 角标),orderStatus≥2 已支付订单展示
状态 / 支付原「支付」列改名「订单状态」;原「团购信息」列并入本列;团购单(chlType 4/5)给团购核销详情入口;支付/团购核销详情由 modal 统一重构为抽屉 viewPayment→OrderPaymentDrawer
属性 / 审核属性 / 审核:财务打款(chlType=3)补录审核态(approveStatus 审核通过/待审核/未通过)课时 / 属性
操作退款 / 删除 / 补录批量审核等按钮整理

订单支付来源的厘清与展示优化

旧版来源语义混乱:chlType=1 笼统叫「正常下单」,把用户手动小程序下单自动续费签约代扣混为一类;真实支付方式(payType)又和来源混在一起看不清。本次把「从哪来(来源)」「怎么付的(支付方式)」「订单类型」三个维度拆开、各自明确标识。

维度取值 / 标识说明
来源 chlType(+isAuto
「订单」列 chip
1 小程序下单原「正常下单」语义厘清为「小程序下单」(用户主动在小程序下单)
isAuto==1 自动续费扣款独立绿色 success 标,优先于 chlType=1;签约代扣产生,非用户手动下单
2 外推码下单外推码渠道
3 补录订单线下/非线上补录,配补录审核态聚合同列
4 美团点评 / 5 抖音团购来源,warning 色;并展示团购渠道与核销信息
筛选「渠道类型」下拉与该枚举一致
支付方式 payType隆泰 / 支付宝 / 微信 …真实收款通道,从来源中剥离,收进 OrderPaymentDrawer 支付详情抽屉;也是退款渠道(refundType)推导依据
订单类型 preSale预售卡 / 正式卡独立 orderTypeText 标,与来源分开不再混淆

展示优化:来源/类型/订单号/补录审核态聚合进「订单」列同一处,色彩区分(团购 warning、自动续费 success、其余默认);支付方式与团购核销明细统一收进抽屉,列表只呈现「来源」这一眼判断维度。

新建 / 重构组件

【演示数据·前两行 isAuto 人工注入以展示「自动续费扣款」标识】订单列表新版 + 详情抽屉群
订单管理 → 会员卡订单 / 私教订单  |  member.vue · course.vue

9后端改动清单(lianer-back,未提交)后端

文件改动
RefundBusiness.java · audit/payaudit() autoPayout 含 1/2/4 并串接 pay();pay() 新增 payType==4 纯标记分支;type=4 成功文案
RefundBusiness.java · apply() 本次新增 autoApprovable() 自动审批分支:refundType=1 自动构造 AuditReq 串 audit→pay;2/3/4 置 status=2 待打款 + auditUser「系统自动审批」+ 同步原始订单 approveStatus;无理由窗口·权益未用判定(applyType 1/2 皆适用;窗口按商品类型区分:会员卡 orderTime>=now-48h / 私教 orderTime>=now-7d
RefundBusiness.java · 权益扣减迁移 本次refundMemCardOrder/refundPrivate 的 apply 块移除 cancelMemberRightsByOrder/cancelRightsByOrder,迁至 pay() 退款完成收敛点统一扣减(钱确认退才扣);会员卡 nouse 改按本卡权益期精确判定
RefundBusiness.java · apply 冻结 / userCancle·驳回 解冻 本次新增「申请即冻结权益」apply() 阶段冻结对应会员卡/私教课(暂时停用,审核打款期间不可约课/核销/进场);userCancle()(用户撤销 status=-2)与审核驳回(status=-1)解冻恢复正常使用;实际扣减仍在 pay() 退款完成时(冻结只是新加在申请时的中间态,扣减时机不变)(先改不提交)
LianerFaceRecordMapper(.java/.xml) 本次新增 selectNumByPhoneInRange(phone,startTime,endTime),按本卡权益有效期窗口统计门禁签到(替代全局 selectNumByUserId)
RenewalBusiness 解约 待接入退款完成时须按前端 terminateAutoRenew 调 RenewalBusiness 终止自动续费签约(目前 RefundBusiness 链路未调,缺口见 ④/⑩)
PrivateOrderBackImpl.java / MemberOrderBackImpl.javalist 接口 join 真实会员昵称(userBaseApi.getUsers(userIds)
MemberOrderListBackRes.java新增 private String userName;
提交策略后端按指示「先改不提交」——lianer-back 工作区已改但未 commit;前端每步 commit + push 到 V0510 分支。
退款模块来源完整退款模块(entities/mappers/api/service 共 57 文件)原存在于 dev_gkbn/test2 分支但本地 master 缺失,已选择性 checkout + 补 cross-cutting 依赖(PaymentCodeAndMsg/UserRightsBusiness/OrderBusiness 等)至编译通过;财务退款单 UI 合并自 origin/Release260309 @72f0e60。线上 refund 表实际在用(约 1.9w 行)。

10风险点 / 待接入 / 评审关注