按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款: - 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款), 提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。 - 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试 记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。 新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。 - 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个 入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。 - 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠 失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。 订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。 - 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、 支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。 渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多 提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。 - 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、 微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动 重提以避免重复退款。 - 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。 迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供, 未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
72 lines
3.0 KiB
Go
72 lines
3.0 KiB
Go
package refundchannel
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 渠道退款请求号生成规则参数。
|
||
const (
|
||
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
|
||
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
|
||
channelRefundRequestNoRandomLen = 18
|
||
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
|
||
channelRefundRequestNoLength = 30
|
||
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0,超过取前 4 位。
|
||
channelRefundRequestNoPrefixLen = 4
|
||
)
|
||
|
||
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
|
||
var shanghaiLocation = time.FixedZone("CST", 8*3600)
|
||
|
||
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
|
||
//
|
||
// 规则与富友流水号完全一致(本包不引入渠道 SDK,因此在此独立实现同一规则):
|
||
// 前缀规整为 4 位(不足左侧补 0,超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
|
||
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
|
||
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
|
||
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
|
||
var builder strings.Builder
|
||
builder.Grow(channelRefundRequestNoLength)
|
||
builder.WriteString(normalizeChannelRefundPrefix(prefix))
|
||
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
|
||
|
||
buffer := make([]byte, channelRefundRequestNoRandomLen)
|
||
if _, err := rand.Read(buffer); err != nil {
|
||
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
|
||
builder.WriteString(fallbackRandomSegment(now))
|
||
return builder.String()
|
||
}
|
||
for _, value := range buffer {
|
||
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0,超过取前 4 位。
|
||
func normalizeChannelRefundPrefix(prefix string) string {
|
||
normalized := strings.TrimSpace(prefix)
|
||
if len(normalized) >= channelRefundRequestNoPrefixLen {
|
||
return normalized[:channelRefundRequestNoPrefixLen]
|
||
}
|
||
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
|
||
}
|
||
|
||
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
|
||
func fallbackRandomSegment(now time.Time) string {
|
||
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
|
||
segment = strings.Map(func(char rune) rune {
|
||
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
|
||
return char
|
||
}
|
||
return 'X'
|
||
}, segment)
|
||
if len(segment) >= channelRefundRequestNoRandomLen {
|
||
return segment[:channelRefundRequestNoRandomLen]
|
||
}
|
||
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
|
||
}
|