小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 15:48:13 +08:00
parent 3bb7fa8db3
commit 75499ba85c
29 changed files with 1299 additions and 196 deletions

View File

@@ -368,6 +368,8 @@ type FeedbackItem struct {
Content string `json:"content"`
Contact string `json:"contact"`
Status int `json:"status"`
Reply string `json:"reply"` // 官方回复内容(空=未回复)
ReplyAt string `json:"reply_at"` // 最近回复时间
CreatedAt string `json:"created_at"`
}
@@ -382,9 +384,11 @@ type FeedbackListRes struct {
Total int `json:"total"`
}
// FeedbackHandleReq 反馈处理:标记已处理,可同时携带官方回复(回复后用户端红点提醒)。
type FeedbackHandleReq struct {
g.Meta `path:"/feedback/handle" method:"post" tags:"admin" summary:"标记已处理"`
Id int64 `v:"required" json:"id"`
g.Meta `path:"/feedback/handle" method:"post" tags:"admin" summary:"反馈处理(标记已处理,可回复)"`
Id int64 `v:"required" json:"id"`
Reply string `json:"reply"`
}
type FeedbackHandleRes struct{}

View File

@@ -37,6 +37,12 @@ type OrderItem struct {
Status int `json:"status"` // 0待支付 1已发货 2已退款 3已关闭
StatusText string `json:"status_text"`
ServiceRevoked int `json:"service_revoked"` // 退款时是否已回收所购服务:0未回收 1已回收
// ===== 渠道与退款 =====
PayChannel string `json:"pay_channel"` // wxpay 微信虚拟支付 / apple iOS(App Store IAP,无法 API 退款)
RefundStatus int `json:"refund_status"` // 0未退款 1退款中 2退款成功 3退款失败
RefundNo string `json:"refund_no"` // 商户退款单号
RefundAmount int64 `json:"refund_amount"` // 退款金额(分)
RefundReason string `json:"refund_reason"` // 退款原因
// ===== 时间 =====
CreatedAt string `json:"created_at"` // 创建时间
PaidAt string `json:"paid_at"` // 支付时间(发货时写入)
@@ -60,20 +66,27 @@ type OrderListRes struct {
// OrderRefundReq 订单退款。
//
// RevokeService=true → 退款同时回收所购服务(会员套餐回收有效期;次数包扣回付费次数);
// false → 仅改状态为「已退款」,用户权益保留。
// 仅「已发货(1)」订单可退款;已是「已退款(2)」的订单幂等返回成功但不重复回收权益。
// RevokeService=true → 退款同时回收所购服务(API 路径下意图落 refund_revoke,
// 平台确认成功后执行回收;手工路径当场回收)。
// Reason 退款原因:手工退款(Manual=true / apple 渠道 / 平台未配置)必填;
// Manual=true 不调平台接口,仅登记(iOS 订单 / 已在 MP 后台手工退的场景)。
// 仅「已发货(1)」订单可发起;退款中幂等返回;退款失败(3)可重新发起。
type OrderRefundReq struct {
g.Meta `path:"/order/refund" method:"post" tags:"admin" summary:"订单退款(可选回收所购服务)"`
OutTradeNo string `v:"required#订单号不能为空" json:"outTradeNo"`
RevokeService bool `json:"revokeService"`
Reason string `json:"reason"`
Manual bool `json:"manual"`
}
type OrderRefundRes struct {
OutTradeNo string `json:"out_trade_no"`
Status int `json:"status"`
StatusText string `json:"status_text"`
ServiceRevoked bool `json:"service_revoked"` // 该订单当前是否已回收所购服务
Revoked bool `json:"revoked"` // 本次调用是否实际执行了退款动作
Revoked bool `json:"revoked"` // 本次调用是否实际执行了退款动作(手工路径)
Refunding bool `json:"refunding"` // 退款中:已发起平台退款,等待确认
RefundStatus int `json:"refund_status"` // 0未退款 1退款中 2退款成功 3退款失败
RefundNo string `json:"refund_no"` // 商户退款单号(API 路径返回)
AlreadyRefunded bool `json:"already_refunded"` // 幂等命中:调用前已是已退款
Message string `json:"message"`
}

View File

@@ -60,6 +60,10 @@ type MemberOrderCreateReq struct {
g.Meta `path:"/member/order" method:"post" tags:"member" summary:"创建会员订单(待支付,不返回支付参数)"`
PlanKey string `v:"required#请选择会员套餐" json:"plan_key"`
Code string `json:"code"`
// Platform 客户端平台(uni.getSystemInfoSync().platform:ios/android/devtools/h5)。
// 缺省时服务端按 User-Agent 兜底推断;ios → pay_channel=apple(iOS 走 App Store IAP,
// 开发者无法主动退款,后台据此禁用 API 退款)。
Platform string `json:"platform"`
}
// MemberOrderCreateRes 创建会员订单响应(只建单,**不返回支付参数**;N1-1/2)。

View File

@@ -43,11 +43,12 @@ type NoteDetailRes struct {
RemindStatus int `json:"remind_status"`
}
// NoteSaveReq id=0 表示新建;新建计入额度、编辑不计入
// NoteSaveReq id=0 表示新建;新建计入额度、编辑不计入。
// Title 允许留空:留空时后端自动命名为「未命名笔记 YYYY-MM-DD hh:mm:ss」。
type NoteSaveReq struct {
g.Meta `path:"/note/save" method:"post" tags:"note" summary:"新建/编辑笔记"`
Id int64 `json:"id"`
Title string `v:"required|length:1,128#标题不能为空|标题最长 128 字" json:"title"`
Title string `v:"length:0,128#标题最长 128 字" json:"title"`
Content string `v:"length:0,20000#正文过长" json:"content"`
Tags []string `json:"tags"`
IsPinned bool `json:"is_pinned"`

View File

@@ -96,6 +96,8 @@ type QuotaOrderCreateReq struct {
PackKey string `v:"required#请选择次数包" json:"pack_key"`
ToolKey string `v:"required#请选择要购买的次数包工具" json:"tool_key"`
Code string `json:"code"`
// Platform 客户端平台(同 MemberOrderCreateReq.Platform;ios → pay_channel=apple)
Platform string `json:"platform"`
}
// QuotaOrderCreateRes 创建次数包订单响应(只建单,不返回支付参数)。

View File

@@ -159,3 +159,30 @@ type FeedbackSubmitReq struct {
Contact string `json:"contact"`
}
type FeedbackSubmitRes struct{}
// FeedbackMineItem 我的反馈条目(含官方回复)
type FeedbackMineItem struct {
Id int64 `json:"id"`
Content string `json:"content"`
Status int `json:"status"` // 0待处理 1已处理
ReplyContent string `json:"reply_content"` // 官方回复(空=未回复)
ReplyAt string `json:"reply_at"` // 最近回复时间
ReplyRead int `json:"reply_read"` // 0=新回复未读(列表内显示「新回复」标签)
CreatedAt string `json:"created_at"`
}
// FeedbackMineReq 我的反馈(最近 N 条 + 未读回复数;**不会**自动置已读,已读走 /feedback/read)
type FeedbackMineReq struct {
g.Meta `path:"/feedback/mine" method:"get" tags:"user" summary:"我的反馈(含官方回复与未读数)"`
Limit int `json:"limit"`
}
type FeedbackMineRes struct {
List []FeedbackMineItem `json:"list"`
Unread int `json:"unread"` // 有官方回复且未读的条数(红点)
}
// FeedbackReadReq 标记我的全部反馈回复为已读(清红点)
type FeedbackReadReq struct {
g.Meta `path:"/feedback/read" method:"post" tags:"user" summary:"反馈回复标记已读"`
}
type FeedbackReadRes struct{}

View File

@@ -26,6 +26,7 @@ const (
cronPatternScanRemind = "0 */1 * * * *" // 笔记提醒扫描:每 1 分钟
cronPatternReleaseCoupons = "0 */5 * * * *" // 免单券超时占用释放:每 5 分钟
cronPatternCloseOrders = "0 */5 * * * *" // 订单超时关闭:每 5 分钟
cronPatternRefundPoll = "0 */2 * * * *" // 退款结果轮询:每 2 分钟(logic/order_refund.go)
)
var (
@@ -76,6 +77,16 @@ var (
g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "order-close-pending", cronPatternCloseOrders)
}
// 退款结果轮询兜底:xpay_refund_notify 推送丢失时,主动查单确认「退款中」的订单
// (确认成功→终态+回收权益;超 24h 不可判定→置失败待人工)。每 2 分钟。
if _, err = gcron.AddSingleton(ctx, cronPatternRefundPoll, func(c context.Context) {
logic.RefundStatusPoll(c)
}, "refund-status-poll"); err != nil {
g.Log().Warningf(ctx, "[cron] 退款轮询任务注册失败: %v", err)
} else {
g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "refund-status-poll", cronPatternRefundPoll)
}
s := g.Server()
// 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调
s.Group("/", func(group *ghttp.RouterGroup) {

View File

@@ -327,4 +327,14 @@ const (
PayChannelWx = "wxpay" // 微信虚拟支付(Android 等)
PayChannelIOS = "apple" // Apple IAP
XPayNotifyEvent = "xpay_goods_deliver_notify" // 发货推送事件名
XPayRefundNotifyEvent = "xpay_refund_notify" // 退款结果推送事件名
)
// 退款单状态(member_orders.refund_status,独立于订单状态 status)
const (
RefundStatusNone = 0 // 未发起退款
RefundStatusProcessing = 1 // 退款中(已调用平台退款接口,等待回调/查单确认)
RefundStatusSuccess = 2 // 退款成功(含手工退款登记)
RefundStatusFailed = 3 // 退款失败(可重新发起)
)

View File

@@ -89,7 +89,7 @@ func (c *cAdminAuth) FeedbackList(ctx context.Context, req *v1.FeedbackListReq)
}
func (c *cAdminAuth) FeedbackHandle(ctx context.Context, req *v1.FeedbackHandleReq) (res *v1.FeedbackHandleRes, err error) {
return logic.AdminFeedbackHandle(ctx, req.Id)
return logic.AdminFeedbackHandle(ctx, req.Id, req.Reply)
}
// ===== 额度配置(次数包 / 额度设置 / 会员套餐 / 用户周期)=====

View File

@@ -18,7 +18,11 @@ func (c *cAdminOrder) OrderList(ctx context.Context, req *v1.OrderListReq) (res
return logic.AdminOrderList(ctx, req)
}
// OrderRefund 订单退款(可选回收所购服务)
// OrderRefund 订单退款(真退款状态机:API 退款 / 手工退款登记,可选回收所购服务)
func (c *cAdminOrder) OrderRefund(ctx context.Context, req *v1.OrderRefundReq) (res *v1.OrderRefundRes, err error) {
return logic.AdminOrderRefund(ctx, req.OutTradeNo, req.RevokeService)
return logic.AdminOrderRefund(ctx, req.OutTradeNo, logic.OrderRefundOptions{
RevokeService: req.RevokeService,
Reason: req.Reason,
Manual: req.Manual,
})
}

View File

@@ -20,7 +20,7 @@ func (c *cMember) MemberCenter(ctx context.Context, req *v1.MemberCenterReq) (re
}
func (c *cMember) MemberOrderCreate(ctx context.Context, req *v1.MemberOrderCreateReq) (res *v1.MemberOrderCreateRes, err error) {
return logic.MemberOrderCreate(ctx, req.PlanKey)
return logic.MemberOrderCreate(ctx, req.PlanKey, req.Platform)
}
// MemberOrderPay 对已建待支付订单构建支付参数(N1 两步式的第二步)

View File

@@ -30,7 +30,7 @@ func (c *cQuota) QuotaPacks(ctx context.Context, req *v1.QuotaPacksReq) (res *v1
}
func (c *cQuota) QuotaOrderCreate(ctx context.Context, req *v1.QuotaOrderCreateReq) (res *v1.QuotaOrderCreateRes, err error) {
return logic.QuotaOrderCreate(ctx, req.PackKey, req.ToolKey)
return logic.QuotaOrderCreate(ctx, req.PackKey, req.ToolKey, req.Platform)
}
// QuotaOrderPay 对已建次数包待支付订单构建支付参数(与会员同构)

View File

@@ -74,3 +74,13 @@ func (c *cUserAuth) WorkbenchSave(ctx context.Context, req *v1.WorkbenchSaveReq)
func (c *cUserAuth) FeedbackSubmit(ctx context.Context, req *v1.FeedbackSubmitReq) (res *v1.FeedbackSubmitRes, err error) {
return logic.FeedbackSubmit(ctx, req.Content, req.Contact)
}
// FeedbackMine 我的反馈列表(含官方回复与未读数,红点数据源)
func (c *cUserAuth) FeedbackMine(ctx context.Context, req *v1.FeedbackMineReq) (res *v1.FeedbackMineRes, err error) {
return logic.FeedbackMine(ctx, req.Limit)
}
// FeedbackRead 反馈回复标记已读(清红点)
func (c *cUserAuth) FeedbackRead(ctx context.Context, req *v1.FeedbackReadReq) (res *v1.FeedbackReadRes, err error) {
return logic.FeedbackMarkRead(ctx)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"sort"
"strconv"
"strings"
"time"
@@ -795,15 +796,40 @@ func AdminFeedbackList(ctx context.Context, req *v1.FeedbackListReq) (*v1.Feedba
Content: r["content"].String(),
Contact: r["contact"].String(),
Status: r["status"].Int(),
Reply: r["reply_content"].String(),
ReplyAt: r["reply_at"].String(),
CreatedAt: r["created_at"].String(),
})
}
return &v1.FeedbackListRes{List: list, Total: total}, nil
}
func AdminFeedbackHandle(ctx context.Context, id int64) (*v1.FeedbackHandleRes, error) {
if _, err := g.Model(consts.TableFeedbacks).Where("id", id).Data(g.Map{"status": 1}).Update(); err != nil {
// AdminFeedbackHandle 反馈处理:标记已处理 + 可选官方回复。
// 带回复时置 reply_read=0(用户端红点提醒),再次回复会重新置为未读。
func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.FeedbackHandleRes, error) {
reply = strings.TrimSpace(reply)
if len(reply) > 1000 {
reply = reply[:1000]
}
data := g.Map{"status": 1}
if reply != "" {
data["reply_content"] = reply
data["reply_by"] = CtxAdminId(ctx)
data["reply_at"] = gtime.Now()
data["reply_read"] = 0
}
if _, err := g.Model(consts.TableFeedbacks).Where("id", id).Data(data).Update(); err != nil {
return nil, err
}
if reply != "" {
WriteAudit(ctx, AuditEntry{
Action: "feedback.reply",
TargetType: "feedback",
TargetId: strconv.FormatInt(id, 10),
After: g.Map{"status": 1, "reply_len": len(reply)},
Result: consts.AuditResultSuccess,
Remark: "回复用户反馈",
})
}
return &v1.FeedbackHandleRes{}, nil
}

View File

@@ -3,6 +3,7 @@ package logic
import (
"context"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
@@ -89,6 +90,11 @@ func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes
Status: r["status"].Int(),
StatusText: orderStatusText(r["status"].Int()),
ServiceRevoked: r["service_revoked"].Int(),
PayChannel: r["pay_channel"].String(),
RefundStatus: r["refund_status"].Int(),
RefundNo: r["refund_no"].String(),
RefundAmount: r["refund_amount"].Int64(),
RefundReason: r["refund_reason"].String(),
CreatedAt: timeStr(r["created_at"].GTime()),
PaidAt: timeStr(r["paid_at"].GTime()),
RefundedAt: timeStr(r["refunded_at"].GTime()),
@@ -148,110 +154,8 @@ func orderTitle(
return planNames[planKey]
}
// AdminOrderRefund 订单退款(可选回收所购服务)。
func AdminOrderRefund(ctx context.Context, outTradeNo string, revokeService bool) (*v1.OrderRefundRes, error) {
outTradeNo = strings.TrimSpace(outTradeNo)
if outTradeNo == "" {
return nil, errOrderInvalid("订单号不能为空")
}
res := &v1.OrderRefundRes{OutTradeNo: outTradeNo}
var beforeStatus int
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 行锁:并发的两次退款串行化,保证「仅一次真正回收权益」
row, err := tx.Model(consts.TableMemberOrders).
Where("out_trade_no", outTradeNo).LockUpdate().One()
if err != nil {
return err
}
if row.IsEmpty() {
return errOrderInvalid("订单不存在")
}
o := orderSnapshotFromRecord(row)
beforeStatus = o.Status
switch o.Status {
case consts.OrderStatusRefunded:
// 幂等:已退款直接成功返回,不重复回收权益
res.AlreadyRefunded = true
res.ServiceRevoked = row["service_revoked"].Int() == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
case consts.OrderStatusPending:
return errOrderInvalid("订单尚未支付,无法退款")
case consts.OrderStatusClosed:
return errOrderInvalid("订单已关闭,无法退款")
case consts.OrderStatusDelivered:
// 唯一允许退款的起点状态,继续
default:
return errOrderInvalid("订单状态异常,无法退款")
}
// 1) 回收所购服务(可选)—— 按**实际回收结果**决定 service_revoked,
// 而非「管理员是否勾选」:勾了但用户当前无可回收的生效授权时记 0(避免误导管理员)。
serviceRevoked := 0
if revokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
// 2) 释放该订单占用的免单券 / 优惠码(仅 1→2 时执行一次)
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
// 3) 状态与回收标记落库。
// ⚠️ refunded_at / updated_at 用**库内 NOW()**(gdb.Raw)而非 Go 侧传参:
// 后台订单列表会展示退款时间,Go 传参落库会早一个时区(TZ-01,8h 可见偏差)。
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"status": consts.OrderStatusRefunded,
"service_revoked": serviceRevoked,
"refunded_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
res.Revoked = true
res.ServiceRevoked = serviceRevoked == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
})
if err != nil {
return nil, err
}
if res.AlreadyRefunded {
res.Message = "订单已是退款状态,未重复回收权益"
} else if revokeService && res.ServiceRevoked {
res.Message = "退款成功,已回收所购服务"
} else if revokeService {
res.Message = "退款成功,未发现可回收的生效授权"
} else {
res.Message = "退款成功,所购服务已保留"
}
// 审计(旁路:失败仅告警,不影响主流程)
WriteAudit(ctx, AuditEntry{
Action: "order.refund",
TargetType: "order",
TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus},
After: g.Map{
"status": consts.OrderStatusRefunded,
"revoke_service": revokeService,
"service_revoked": res.ServiceRevoked,
"idempotent": res.AlreadyRefunded,
},
Result: consts.AuditResultSuccess,
Remark: res.Message,
})
return res, nil
}
// AdminOrderRefund 已迁移至 order_refund.go(真退款状态机:
// 调微信 /xpay/refund_order + 回调分流 + 查单轮询,权益回收移到确认成功时)。
// revokeOrderServiceTx 回收订单所购服务(仅已知订单行快照,事务内、行锁已持有)。
//
@@ -313,20 +217,21 @@ func revokeQuotaServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (boo
// revokeMemberServiceTx 会员套餐回收:回退 users.level_expire_at。
//
// 回收模型:发货时按「购买天数」顺延有效期(extendMembership),回收即**反向减去本单天数**。
// - level_expire_at 为 NULL(永久)→ 按用户裁定(2026-09-23)**同样真取消、置为已过期**:
// 写入 level_expire_at = NOW();level_key **保留不清空**(失效判定交给 EffectiveLevelKey)。
// - 有限期:减去本单天数;减完后若已到期(<= now)→ 夹到当前时刻(保持非 NULL),
// EffectiveLevelKey 随即回落默认等级。
// 回收模型(重放算账,修复「退 A 误扣 B」):
// - 旧行为 `GREATEST(level_expire_at - INTERVAL days DAY, now)` 是对「当前到期时间」盲目减
// 本单天数:若本单到期后用户又买了 B,退 A 会把 **B 的天数一起减掉**。
// - 新行为:按发货同款顺延规则(extendMembership),重放该用户所有「已发货未退款」的会员单
// (本单在进入本函数前已被置为 status=2 → 自然排除),得到理论到期时间,再把当前到期时间
// 收敛过去。审计例:T 买 A(30d)→到期→T+60 买 B(30d)→退 A:重放只剩 B → 到期仍为 T+90,
// 一分不减。
// - level_expire_at 为 NULL(永久)→ 维持用户裁定(2026-09-23)**真取消、置为已过期**:
// 写入 level_expire_at = NOW();level_key 保留不清空,失效判定交给 EffectiveLevelKey。
// - 等级回落**只经 EffectiveLevelKey 收敛**(与「自然到期」口径一致),本函数不改 level_key。
//
// ⚠️ 时间取值遵循 TZ-01 铁律:本库 DSN 未指定 loc,Go 侧**传参** datetime 会按 UTC 落库
// (比真实墙钟早一个时区偏移),Go 读回又被按本地重解释,形成写入/读取不对称的 8h 偏差
// (本项目出过真事故)。故本函数**不把 Go 侧时间作为 datetime 列写入参数**:
// - 永久分支「置为已过期」用**库内 NOW()** 写入(DB 侧取值,写入/读取对称);
// - 限时分支的 `level_expire_at` 减法与 `updated_at` 均在库内完成(updated_at 用 NOW());
// **唯一豁免**:`GREATEST(level_expire_at - INTERVAL ? DAY, ?)` 的夹取下限 `?` 保留 Go 传参
// —— 它是「已过期哨兵值」,与库内 NOW() 在 EffectiveLevelKey 下用户可见行为完全一致。
// ⚠️ TZ-01 铁律:本库 DSN 未指定 loc,Go 侧传参 datetime 落库会早一个时区偏移。
// 重放用的 paid_at 全部**读自 DB**(同一时区口径),差值 deltaMinutes 由两个 DB 值相减
// (偏移相消),最终用 `level_expire_at - INTERVAL ? MINUTE` 在**库内**完成算术,
// GREATEST(..., NOW()) 兜底「已过期哨兵」(与旧实现同款豁免);updated_at 用库内 NOW()。
//
// 返回 revoked = 回收**前**是否为「生效中的会员」(唯一判定走 EffectiveLevelKey),
// 供 service_revoked 按实际结果落库(已过期 / 免费用户退款时不记「已回收」)。
@@ -354,25 +259,86 @@ func revokeMemberServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bo
o.UserId); err != nil {
return false, err
}
if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil {
return false, err
}
g.Log().Infof(ctx, "[refund] 永久会员回收置为已过期 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo)
return wasEffective, nil
}
days := o.DurationDays
if days <= 0 {
g.Log().Warningf(ctx, "[refund] 会员订单缺少有效天数,跳过会员回收 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo)
return false, nil
// ===== 重放算账:理论到期时间 =====
// ⚠️ 必须排除本单:手工路径在本函数执行时订单尚未翻转 status(仍为 1),
// 仅按 status 过滤会把本单算进重放;API 确认路径同样适用(本单即将置为已退款)。
orders, err := tx.Model(consts.TableMemberOrders).
Where("user_id", o.UserId).
Where("order_type", consts.OrderTypeMember).
Where("status", consts.OrderStatusDelivered).
Where("id <> ?", o.Id).
OrderAsc("paid_at").OrderAsc("id").
All()
if err != nil {
return false, err
}
var runExpire time.Time // 零值 = 尚无任何会员时长
runLevel := ""
for _, r := range orders {
days := r["duration_days"].Int()
if days <= 0 {
continue
}
paid := r["paid_at"].GTime()
if paid == nil || paid.IsZero() {
paid = r["created_at"].GTime() // 老订单兜底
}
if paid == nil || paid.IsZero() {
continue
}
level := r["level_key"].String()
// 与 extendMembership 同款:同档且未到期 → 顺延;跨档/更高档 → 从购买时刻起算。
// (简化:不重放「高等级不降权」的 level_key 归属,到期时间口径一致;level_key 本就不由本函数改写。)
base := paid.Time
if !runExpire.IsZero() && runExpire.After(paid.Time) && runLevel == level {
base = runExpire
}
runExpire = base.AddDate(0, 0, days)
runLevel = level
}
if runExpire.IsZero() {
// 退款后已无任何剩余会员单:沿用旧口径——从当前到期时间减去本单天数、夹到当前时刻。
// (用户可能在本单之前还有「无订单可考」的剩余时长,减法比直接置 NOW() 更不误伤。)
days := o.DurationDays
if days <= 0 {
days = 0
}
// 「已过期哨兵」下限沿用旧实现的 Go 侧传参豁免(与库内 NOW() 在 EffectiveLevelKey
// 下用户可见行为一致;且规避 DB 与进程秒级时钟差导致的判定抖动)。
if _, err = tx.Exec(
"UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? MINUTE, ?), "+
"updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL",
int64(days)*24*60, gtime.Now(), o.UserId); err != nil {
return false, err
}
if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil {
return false, err
}
g.Log().Infof(ctx, "[refund] 会员回收(重放后无剩余会员单,减本单 %d 天夹到当前)userId=%d outTradeNo=%s",
days, o.UserId, o.OutTradeNo)
return wasEffective, nil
}
// 差值收敛:delta = 当前到期 − 理论到期(分钟)。delta<=0 说明重放不短于现状
// (退款绝不该「延长」权益)→ 不动库。算术全部发生在库内(INTERVAL ? MINUTE)。
deltaMinutes := int64(expire.Time.Sub(runExpire).Minutes() + 0.5)
if deltaMinutes <= 0 {
g.Log().Warningf(ctx, "[refund] 会员回收重放结果不短于当前到期,跳过减法 userId=%d delta=%dmin outTradeNo=%s",
o.UserId, deltaMinutes, o.OutTradeNo)
return wasEffective, nil
}
now := gtime.Now()
// GREATEST(expire - days, now):正常回退 days 天;若回收后已到期则夹到当前时刻(非 NULL)。
// 直连 SQL(不经过 Go 侧读回-改-写),规避 TZ-01 时区漂移;WHERE 限定非空,绝不误动永久会员。
//
// 注:`GREATEST` 的**夹取下限 `?`** 依裁定保留 Go 传参——它是「已过期哨兵值」,
// 与库内 NOW() 写法在 `EffectiveLevelKey` 下用户可见行为完全一致(严格 `<` ⇒ 立即回落默认);
// 而 `updated_at` 是**展示列**(后台用户列表/详情可见),改用库内 NOW(),避免 Go 传参的 8h 偏差。
result, err := tx.Exec(
"UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? DAY, ?), "+
"UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? MINUTE, ?), "+
"updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL",
days, now, o.UserId)
deltaMinutes, gtime.Now(), o.UserId)
if err != nil {
return false, err
}
@@ -380,6 +346,40 @@ func revokeMemberServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bo
g.Log().Warningf(ctx, "[refund] 会员回收未命中(用户已无有限期)userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo)
return false, nil
}
g.Log().Infof(ctx, "[refund] 会员回收 userId=%d -%d 天 outTradeNo=%s", o.UserId, days, o.OutTradeNo)
if err = rollbackQuotaPeriodTx(ctx, tx, o.UserId); err != nil {
return false, err
}
g.Log().Infof(ctx, "[refund] 会员回收(重放算账)userId=%d 收敛 -%d 分钟 outTradeNo=%s", o.UserId, deltaMinutes, o.OutTradeNo)
return wasEffective, nil
}
// rollbackQuotaPeriodTx 回滚发货时写的 users.quota_period_days:
// 取该用户**剩余**最新一笔已发货会员单的套餐周期;没有 → 0(跟随全局)。
// 幂等:重复调用结果一致;绝不回写本单所属套餐的周期(本单已置为退款态,不在查询集内)。
func rollbackQuotaPeriodTx(ctx context.Context, tx gdb.TX, userId int64) error {
value, err := tx.Model(consts.TableMemberOrders+" o").
LeftJoin(consts.TableMemberPlans+" p", "p.plan_key = o.plan_key").
Where("o.user_id", userId).
Where("o.order_type", consts.OrderTypeMember).
Where("o.status", consts.OrderStatusDelivered).
OrderDesc("o.id").
Limit(1).
Value("p.quota_period_days")
if err != nil {
return err
}
days := 0 // 0 = 跟随全局(users.quota_period_days 的语义)
if value != nil {
days = value.Int()
}
if days < 0 {
days = 0
}
if _, err = tx.Exec(
"UPDATE "+consts.TableUsers+" SET quota_period_days = ?, updated_at = NOW() WHERE id = ?",
days, userId); err != nil {
return err
}
g.Log().Infof(ctx, "[refund] 回滚额度周期 userId=%d → %d 天", userId, days)
return nil
}

View File

@@ -113,7 +113,7 @@ func TestAdminOrderRefund_RevokeMemberService(t *testing.T) {
baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One()
baseline := baseU["level_expire_at"].GTime()
res, err := AdminOrderRefund(ctx, no, true)
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
if err != nil {
t.Fatalf("退款失败: %v", err)
}
@@ -146,7 +146,7 @@ func TestAdminOrderRefund_RevokeMemberService(t *testing.T) {
}
// 幂等:再次退款返回成功但不重复回收
res2, err := AdminOrderRefund(ctx, no, true)
res2, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
if err != nil {
t.Fatalf("幂等退款失败: %v", err)
}
@@ -170,7 +170,7 @@ func TestAdminOrderRefund_RevokeMemberExpiredDowngrade(t *testing.T) {
})
defer cleanupRefundFixture(t, uid, no)
if _, err := AdminOrderRefund(ctx, no, true); err != nil {
if _, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
u, _ := g.Model(consts.TableUsers).Where("id", uid).One()
@@ -212,7 +212,7 @@ func TestAdminOrderRefund_RevokeQuotaClamp(t *testing.T) {
}
// 第一次:8 - 5 = 3
if _, err := AdminOrderRefund(ctx, no1, true); err != nil {
if _, err := AdminOrderRefund(ctx, no1, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One()
@@ -221,7 +221,7 @@ func TestAdminOrderRefund_RevokeQuotaClamp(t *testing.T) {
}
// 第二次:3 - 10 → 夹到 0(不为负)
if _, err := AdminOrderRefund(ctx, no2, true); err != nil {
if _, err := AdminOrderRefund(ctx, no2, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
q2, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One()
@@ -251,7 +251,7 @@ func TestAdminOrderRefund_PermanentMemberRevoked(t *testing.T) {
defer cleanupRefundFixture(t, uid, no)
before := gtime.Now()
res, err := AdminOrderRefund(ctx, no, true)
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
if err != nil {
t.Fatalf("退款失败: %v", err)
}
@@ -306,7 +306,7 @@ func TestAdminOrderRefund_RevokeButNothingToReclaim(t *testing.T) {
})
defer cleanupRefundFixture(t, uid, no)
res, err := AdminOrderRefund(ctx, no, true) // 明确勾选回收
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}) // 明确勾选回收
if err != nil {
t.Fatalf("退款失败: %v", err)
}
@@ -350,7 +350,7 @@ func TestAdminOrderRefund_NewWritesNoTZ01Drift(t *testing.T) {
defer cleanupRefundFixture(t, uid, no)
before := gtime.Now()
if _, err := AdminOrderRefund(ctx, no, true); err != nil {
if _, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One()
@@ -374,7 +374,7 @@ func TestAdminOrderRefund_NewWritesNoTZ01Drift(t *testing.T) {
defer cleanupRefundFixture(t, uid2, no2)
before2 := gtime.Now()
if _, err := AdminOrderRefund(ctx, no2, true); err != nil {
if _, err := AdminOrderRefund(ctx, no2, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
u2, _ := g.Model(consts.TableUsers).Where("id", uid2).One()
@@ -409,7 +409,7 @@ func TestAdminOrderRefund_NewWritesNoTZ01Drift(t *testing.T) {
t.Fatalf("插入额度失败: %v", err)
}
beforeQ := gtime.Now()
if _, err := AdminOrderRefund(ctx, no3, true); err != nil {
if _, err := AdminOrderRefund(ctx, no3, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid3).Where("tool_key", toolKey).One()
@@ -432,7 +432,7 @@ func TestAdminOrderRefund_NoRevokeKeepsService(t *testing.T) {
baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One()
baseline := baseU["level_expire_at"].GTime()
res, err := AdminOrderRefund(ctx, no, false)
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{Manual: true, Reason: "退款测试"})
if err != nil {
t.Fatalf("退款失败: %v", err)
}
@@ -461,7 +461,7 @@ func TestAdminOrderRefund_StatusGuard(t *testing.T) {
})
defer cleanupRefundFixture(t, uid, no)
if _, err := AdminOrderRefund(ctx, no, true); err == nil {
if _, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err == nil {
t.Fatalf("待支付订单退款应被拒绝")
}
}
@@ -534,3 +534,157 @@ func TestAdminOrderCleanupForCurl(t *testing.T) {
}
fmt.Printf("CLEAN_REFUND done no=%s uid=%s\n", no, uid)
}
// ============================================================================
// 真退款链路新增用例(重放算账 / 发货守卫 / apple 渠道 / 推送解析)
// ============================================================================
// TestAdminOrderRefund_ReplayKeepsLaterOrders 重放算账核心回归(审计场景):
// T 买 A(30d) → 到期 → 再买 B(30d)(未到期)→ 现在退 A。
// 旧实现 `GREATEST(expire - 30d, now)` 会把 B 的天数一起减掉(B 被误伤);
// 重放算账后应保持 B 的到期时间不变(退 A 不影响 B)。
func TestAdminOrderRefund_ReplayKeepsLaterOrders(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("rp")
// 用户当前到期 = B 的贡献(未来 30 天)
uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 30))
// A:40 天前买的 30 天会员(早已到期消耗完),本单将被退款
noA := insertDeliveredOrder(t, uid, tag+"a", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"paid_at": gtime.Now().AddDate(0, 0, -40),
})
// B:刚刚买的 30 天会员(顺延在 A 之后,是当前到期时间的来源)
noB := insertDeliveredOrder(t, uid, tag+"b", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
})
defer cleanupRefundFixture(t, uid, noA, noB)
if _, err := AdminOrderRefund(ctx, noA, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
u, _ := g.Model(consts.TableUsers).Where("id", uid).One()
expire := u["level_expire_at"].GTime()
if expire == nil || expire.IsZero() {
t.Fatalf("退 A 后 B 的会员期限不应被清掉")
}
// 关键断言:B 的有效期必须仍在未来(旧实现会把它减到当前时刻)
if !expire.After(gtime.Now()) {
t.Fatalf("退 A 不得误伤 B 的天数:expire=%s(应仍为未来 30 天附近)", expire.String())
}
// 到期时间应仍在「现在 + 29~31 天」窗口内(重放后与 B 单独存在时一致)
days := expire.Sub(gtime.Now()).Hours() / 24
if days < 29 || days > 31 {
t.Fatalf("B 的剩余天数应约 30 天,得 %.2f", days)
}
// B 单必须仍未退款
b, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", noB).One()
if b["status"].Int() != consts.OrderStatusDelivered {
t.Fatalf("B 单不应被联动退款,得 %d", b["status"].Int())
}
}
// TestDeliverOrderGuard 终态订单不可被重新发货(推送复活防护):
// 已退款(2)/已关闭(3) 的订单收到任何发货请求都必须被拒绝。
func TestDeliverOrderGuard(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("dg")
uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil)
noRefunded := insertDeliveredOrder(t, uid, tag+"r", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"status": consts.OrderStatusRefunded,
})
noClosed := insertDeliveredOrder(t, uid, tag+"c", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"status": consts.OrderStatusClosed,
})
defer cleanupRefundFixture(t, uid, noRefunded, noClosed)
if err := deliverOrder(ctx, noRefunded, "wx_guard_test"); err == nil {
t.Fatalf("已退款订单应拒绝发货")
}
if err := deliverOrder(ctx, noClosed, "wx_guard_test"); err == nil {
t.Fatalf("已关闭订单应拒绝发货")
}
// 用户会员期限不应被偷改
u, _ := g.Model(consts.TableUsers).Where("id", uid).One()
if u["level_expire_at"].GTime() != nil && u["level_expire_at"].GTime().After(gtime.Now()) {
t.Fatalf("守卫应阻止任何权益写入,得 %s", u["level_expire_at"].String())
}
}
// TestAdminOrderRefund_AppleChannel iOS 渠道(apple):开发者无法主动退款,
// API 退款被禁用 → 自动落入手工登记路径,且必须留原因;登记后当场落「已退款」。
func TestAdminOrderRefund_AppleChannel(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("ios")
uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 20))
no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"pay_channel": consts.PayChannelIOS,
})
defer cleanupRefundFixture(t, uid, no)
// 无原因 → 拒绝
if _, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true}); err == nil {
t.Fatalf("apple 渠道退款未填原因应被拒绝")
}
// 带原因 → 手工登记成功,当场落退款终态
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Reason: "App Store 已退款"})
if err != nil {
t.Fatalf("apple 渠道带原因退款应成功: %v", err)
}
if !res.Revoked || res.Status != consts.OrderStatusRefunded {
t.Fatalf("apple 退款应当场落终态,得 %+v", res)
}
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One()
if row["refund_status"].Int() != consts.RefundStatusSuccess || row["refund_amount"].Int64() != 1000 {
t.Fatalf("退款列应落 refund_status=2 / amount=1000,得 %d/%d", row["refund_status"].Int(), row["refund_amount"].Int64())
}
if row["refund_reason"].String() != "App Store 已退款" {
t.Fatalf("退款原因应落库,得 %q", row["refund_reason"].String())
}
if row["service_revoked"].Int() != 1 {
t.Fatalf("勾选回收时应回收,得 %d", row["service_revoked"].Int())
}
}
// TestXPayNotifyEventRouting /pay/notify 事件分流解析:
// 发货事件 / 退款事件 / 未知事件的解析结果与退款成功判定。
func TestXPayNotifyEventRouting(t *testing.T) {
deliverXML := `<xml><Event>xpay_goods_deliver_notify</Event><OutTradeNo>T1</OutTradeNo></xml>`
refundXML := `<xml><Event>xpay_refund_notify</Event><OutTradeNo>T1</OutTradeNo>` +
`<OutRefundNo>R1xabc</OutRefundNo>` +
`<RefundInfo><OutRefundNo>R1xabc</OutRefundNo><RefundFee>1000</RefundFee>` +
`<RefundStatus>0</RefundStatus><RefundRespCode>0</RefundRespCode></RefundInfo></xml>`
unknownXML := `<xml><Event>xpay_something_new</Event><OutTradeNo>T1</OutTradeNo></xml>`
if ev, err := ParseNotifyEvent([]byte(deliverXML)); err != nil || ev != consts.XPayNotifyEvent {
t.Fatalf("发货事件解析错误: %v %s", err, ev)
}
if ev, err := ParseNotifyEvent([]byte(refundXML)); err != nil || ev != consts.XPayRefundNotifyEvent {
t.Fatalf("退款事件解析错误: %v %s", err, ev)
}
if ev, err := ParseNotifyEvent([]byte(unknownXML)); err != nil || ev != "xpay_something_new" {
t.Fatalf("未知事件解析错误: %v %s", err, ev)
}
n, err := ParseRefundNotify([]byte(refundXML))
if err != nil {
t.Fatalf("退款推送解析失败: %v", err)
}
if !n.RefundNotifySuccess() {
t.Fatalf("RefundStatus=0 + RespCode=0 应判定成功")
}
// 字段缺失(全 0 默认值且无退款单号/金额)→ 判不可定,不得误标成功
empty, err := ParseRefundNotify([]byte(`<xml><Event>xpay_refund_notify</Event><OutTradeNo>T1</OutTradeNo></xml>`))
if err != nil {
t.Fatalf("空退款推送解析失败: %v", err)
}
if empty.RefundNotifySuccess() {
t.Fatalf("无退款信息字段时不得判定成功(防字段缺失误判)")
}
}

View File

@@ -71,7 +71,8 @@ func MemberCenter(ctx context.Context) (*v1.MemberCenterRes, error) {
//
// 同一用户 + 同一套餐 + 待支付 的订单存在则复用(reused=true),保证待支付单恒为 1 条。
// 支付参数由 POST /member/order/pay 单独构建(见 order_pay.go 的 buildPayParams)。
func MemberOrderCreate(ctx context.Context, planKey string) (*v1.MemberOrderCreateRes, error) {
// platform:客户端平台标识,用于判定 pay_channel(ios → apple,其余 → wxpay)。
func MemberOrderCreate(ctx context.Context, planKey, platform string) (*v1.MemberOrderCreateRes, error) {
userId := CtxUserId(ctx)
if !xpayConfig(ctx).Configured() {
return nil, gerror.NewCode(gcodePayFail(),
@@ -88,6 +89,7 @@ func MemberOrderCreate(ctx context.Context, planKey string) (*v1.MemberOrderCrea
"plan_key": plan.PlanKey,
"level_key": plan.LevelKey,
"duration_days": plan.DurationDays,
"pay_channel": payChannelFromRequest(ctx, platform),
})
if err != nil {
return nil, err
@@ -271,12 +273,34 @@ func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) {
// ===== 发货 =====
// PayNotifyHandle 处理虚拟支付发货推送。
// PayNotifyHandle 处理 /pay/notify 推送(唯一入口,按 Event 分流):
// - xpay_goods_deliver_notify → 发货(deliverNotifyHandle)
// - xpay_refund_notify → 退款结果确认(refundNotifyHandle,见 order_refund.go)
// - 其他事件 → 告警并回成功(避免平台对不认识的事件无意义重试 15 次)
func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
event, err := ParseNotifyEvent(raw)
if err != nil {
g.Log().Warningf(ctx, "[xpay] 推送解析失败: %v raw=%s", err, string(raw))
return XPayNotifyAckFail("bad payload"), nil
}
switch event {
case consts.XPayNotifyEvent:
return deliverNotifyHandle(ctx, raw)
case consts.XPayRefundNotifyEvent:
return refundNotifyHandle(ctx, raw)
default:
g.Log().Warningf(ctx, "[xpay] 未处理的推送事件,忽略: %s", event)
return XPayNotifyAck(), nil
}
}
// deliverNotifyHandle 处理发货推送。
//
// 安全策略:平台推送不保证携带可用于验签的字段,因此当虚拟支付已配置时,
// 一律「先主动查单复核,确认已支付才发货」,查单结果即最可靠的验签;
// 未配置(联调阶段)时按推送内容发货并打告警日志。
func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
// 未配置时**拒绝发货**(回失败应答)——否则任何人 POST /pay/notify 都能给自己发货。
// 确需无平台联调时,显式打开 wx.pay.allowUnverifiedNotify=true(默认 false,仅限本机联调)。
func deliverNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
notify, err := ParseDeliverNotify(raw)
if err != nil {
g.Log().Warningf(ctx, "[xpay] 发货推送解析失败: %v raw=%s", err, string(raw))
@@ -291,8 +315,13 @@ func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
// 订单不存在多为脏数据,回成功避免平台无意义重试 15 次
return XPayNotifyAck(), nil
}
if order.Status == consts.OrderStatusDelivered {
switch order.Status {
case consts.OrderStatusDelivered:
return XPayNotifyAck(), nil // 幂等:重复推送直接成功
case consts.OrderStatusRefunded, consts.OrderStatusClosed:
// 已退款/已关闭的订单绝不允许被推送「复活」重新发货
g.Log().Warningf(ctx, "[xpay] 订单已终态(status=%d),拒绝发货: %s", order.Status, order.OutTradeNo)
return XPayNotifyAck(), nil // 回成功让平台停止重试
}
wxOrderId := notify.WeChatPayInfo.MchOrderNo
@@ -309,8 +338,11 @@ func PayNotifyHandle(ctx context.Context, raw []byte) (ack string, err error) {
if state.WxOrderId != "" {
wxOrderId = state.WxOrderId
}
} else if g.Cfg().MustGet(ctx, "wx.pay.allowUnverifiedNotify", false).Bool() {
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置且已开启 allowUnverifiedNotify,按推送内容发货(仅限本机联调): %s", order.OutTradeNo)
} else {
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置,按推送内容直接发货(仅限联调): %s", order.OutTradeNo)
g.Log().Warningf(ctx, "[xpay] 虚拟支付未配置,拒绝按推送发货(如需联调请开 wx.pay.allowUnverifiedNotify): %s", order.OutTradeNo)
return XPayNotifyAckFail("xpay not configured"), nil
}
if wxOrderId == "" {
wxOrderId = order.OutTradeNo // 极端兜底:保证唯一索引不冲突,幂等仍由 status 保证
@@ -353,8 +385,12 @@ func SweepPendingOrders(ctx context.Context) {
// queryAndDeliver 主动查单 → 已支付则发货。返回是否完成发货。
func queryAndDeliver(ctx context.Context, order *entity.MemberOrders) (bool, error) {
if order.Status == consts.OrderStatusDelivered {
switch order.Status {
case consts.OrderStatusDelivered:
return true, nil
case consts.OrderStatusRefunded, consts.OrderStatusClosed:
// 已退款/已关闭的订单不允许再触发发货
return false, nil
}
if !xpayConfig(ctx).Configured() {
return false, nil
@@ -388,8 +424,14 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
if row.IsEmpty() {
return gerror.Newf("订单不存在: %s", outTradeNo)
}
if row["status"].Int() == consts.OrderStatusDelivered {
// 状态守卫:只有待支付(0)可发货;已发货幂等返回;已退款/已关闭绝不允许被重复发货
switch row["status"].Int() {
case consts.OrderStatusDelivered:
return nil // 幂等:已发货直接返回
case consts.OrderStatusRefunded:
return gerror.Newf("订单 %s 已退款,拒绝发货", outTradeNo)
case consts.OrderStatusClosed:
return gerror.Newf("订单 %s 已关闭,拒绝发货", outTradeNo)
}
userId := row["user_id"].Int64()

View File

@@ -355,6 +355,28 @@ func migrateColumns() []migrateColumn {
{"member_orders", "service_revoked", "ALTER TABLE `member_orders` ADD COLUMN `service_revoked` tinyint NOT NULL DEFAULT 0 COMMENT '退款时是否已回收所购服务:0未回收 1已回收'"},
{"member_orders", "refunded_at", "ALTER TABLE `member_orders` ADD COLUMN `refunded_at` datetime DEFAULT NULL COMMENT '退款时间'"},
// ===== 真退款链路(调微信 /xpay/refund_order):退款单状态与金额落库 =====
// refund_no :商户退款单号(唯一;失败重发会换新号)
// refund_status :0未退款 1退款中 2退款成功 3退款失败(独立于订单 status)
// refund_amount :退款金额(分,全额退款 = paid_price_cents)
// refund_reason :退款原因(后台登记,iOS/手工退款必填)
// refund_finished_at:退款确认完成时间(回调 / 查单轮询 / 手工登记)
{"member_orders", "refund_no", "ALTER TABLE `member_orders` ADD COLUMN `refund_no` varchar(40) NOT NULL DEFAULT '' COMMENT '商户退款单号'"},
{"member_orders", "refund_status", "ALTER TABLE `member_orders` ADD COLUMN `refund_status` tinyint NOT NULL DEFAULT 0 COMMENT '退款状态:0未退款 1退款中 2退款成功 3退款失败'"},
{"member_orders", "refund_amount", "ALTER TABLE `member_orders` ADD COLUMN `refund_amount` bigint NOT NULL DEFAULT 0 COMMENT '退款金额(分)'"},
{"member_orders", "refund_reason", "ALTER TABLE `member_orders` ADD COLUMN `refund_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '退款原因'"},
{"member_orders", "refund_finished_at", "ALTER TABLE `member_orders` ADD COLUMN `refund_finished_at` datetime DEFAULT NULL COMMENT '退款确认完成时间'"},
// ===== 反馈回复(官方回复 + 用户端未读提醒)=====
// reply_content:官方回复内容(后台「处理反馈」时填写,可多次更新)
// reply_by :回复管理员 id(审计留痕)
// reply_at :最近一次回复时间
// reply_read :用户端未读标记:0=有新回复未读(小红点) 1=已读
{"feedbacks", "reply_content", "ALTER TABLE `feedbacks` ADD COLUMN `reply_content` varchar(1000) NOT NULL DEFAULT '' COMMENT '官方回复内容'"},
{"feedbacks", "reply_by", "ALTER TABLE `feedbacks` ADD COLUMN `reply_by` bigint NOT NULL DEFAULT 0 COMMENT '回复管理员 id,0=无'"},
{"feedbacks", "reply_at", "ALTER TABLE `feedbacks` ADD COLUMN `reply_at` datetime DEFAULT NULL COMMENT '最近回复时间'"},
{"feedbacks", "reply_read", "ALTER TABLE `feedbacks` ADD COLUMN `reply_read` tinyint NOT NULL DEFAULT 0 COMMENT '用户端已读:0有新回复未读 1已读'"},
// ===== T04 座次:桌型 / 备注 / 排除(N4)=====
// 全部幂等加列;老活动无这些属性时回退默认值(普通桌 / 空备注 / 参与),保证老画布与自动排座不报错(S14)。
{"event_tables", "table_type", "ALTER TABLE `event_tables` ADD COLUMN `table_type` tinyint NOT NULL DEFAULT 0 COMMENT '桌型:0普通 1主桌 2签到台 3媒体席 4备用桌 9其他'"},

View File

@@ -155,6 +155,7 @@ func NoteDetail(ctx context.Context, req *v1.NoteDetailReq) (*v1.NoteDetailRes,
// SaveNote 新建(Id=0)或编辑(Id>0)笔记。
// 新建计入额度校验;编辑不计入额度(PRD-02 R6-5)。
// 标题留空时自动命名为「未命名笔记 YYYY-MM-DD hh:mm:ss」(用户端不强制起名)。
func SaveNote(ctx context.Context, req *v1.NoteSaveReq) (*v1.NoteSaveRes, error) {
userId := CtxUserId(ctx)
_, isMember, err := noteUser(ctx, userId)
@@ -172,11 +173,15 @@ func SaveNote(ctx context.Context, req *v1.NoteSaveReq) (*v1.NoteSaveRes, error)
if req.IsPinned {
pinned = 1
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "未命名笔记 " + now.Format("Y-m-d H:i:s")
}
// 编辑既有笔记:不占用额度
if req.Id > 0 {
data := g.Map{
"title": req.Title,
"title": title,
"content": req.Content,
"tags": tags,
"is_pinned": pinned,
@@ -217,7 +222,7 @@ func SaveNote(ctx context.Context, req *v1.NoteSaveReq) (*v1.NoteSaveRes, error)
}
id, err := g.Model(consts.TableNotes).Data(g.Map{
"user_id": userId,
"title": req.Title,
"title": title,
"content": req.Content,
"tags": tags,
"is_pinned": pinned,

View File

@@ -77,6 +77,7 @@ type orderSnapshot struct {
PayChannel string
Attach string
CreatedAt *gtime.Time
PaidAt *gtime.Time
DeliveredAt *gtime.Time
}
@@ -106,6 +107,7 @@ func orderSnapshotFromRecord(r gdb.Record) *orderSnapshot {
PayChannel: r["pay_channel"].String(),
Attach: r["attach"].String(),
CreatedAt: r["created_at"].GTime(),
PaidAt: r["paid_at"].GTime(),
DeliveredAt: r["delivered_at"].GTime(),
}
}
@@ -147,6 +149,28 @@ type createOrderResult struct {
Status int
}
// payChannelFromRequest 判定支付渠道:客户端显式 platform 优先(uni.getSystemInfoSync().platform),
// User-Agent 兜底(ipad/ipod/iphone → apple,注意 iPad 的 UA 亦含 iphone 字样,先判 ipad)。
// iOS 虚拟支付不可用(走 App Store IAP),单独标记渠道供后台退款时禁用 API 退款。
func payChannelFromRequest(ctx context.Context, platform string) string {
p := strings.ToLower(strings.TrimSpace(platform))
if p == "" {
if r := g.RequestFromCtx(ctx); r != nil {
ua := strings.ToLower(r.Header.Get("User-Agent"))
switch {
case strings.Contains(ua, "ipad"), strings.Contains(ua, "ipod"):
p = "ios"
case strings.Contains(ua, "iphone"):
p = "ios"
}
}
}
if p == "ios" {
return consts.PayChannelIOS
}
return consts.PayChannelWx
}
// createOrder 建单 / 复用(N1-1/2/10)。
//
// 复用规则:同一用户 + 同商品(reuseWhere 指定,会员=plan_key;次数包=pack_key+tool_key)+

View File

@@ -0,0 +1,409 @@
package logic
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/grand"
v1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 真退款链路:调微信 /xpay/refund_order + 退款回调分流 + 查单轮询兜底
//
// 状态机(member_orders.status × refund_status):
// status=1(已发货) + refund_status=0 → 可发起退款
// 发起(API 路径) → refund_status=1(退款中),refund_no/refund_amount/reason 落库
// → 平台 xpay_refund_notify 推送 / RefundStatusPoll 查单轮询确认
// → 确认成功:status=2 + refund_status=2 + 回收权益(在此刻才回收)
// → 确认失败/超时:refund_status=3(status 保持 1,可重新发起)
// 发起(手工路径) → 手工登记(Manual / iOS apple 渠道 / 平台未配置)当场 status=2 +
// refund_status=2 + 回收权益(无异步确认环节,与旧版语义一致)
// refund_status=2 → 幂等返回;refund_status=3 → 允许重新发起(换新 refund_no)
//
// 权益回收时机:API 路径下从「发起时」移到「确认成功时」(finalize 统一执行),
// 杜绝「退款失败但权益已回收」。回收意图在发起时落 refund_revoke 列,回调/轮询据此执行。
//
// 时区铁律(TZ-01):所有 datetime 写入一律 gdb.Raw("NOW()")(库内取值),
// 严禁本文件引入 Go 侧 datetime 传参写库。
// ============================================================================
// refundWindowDays 平台退款窗口:支付 365 天内可退(180 天内退手续费,见 doc/会员购买接入说明.md)。
const refundWindowDays = 365
// OrderRefundOptions 后台「确认退款」的选项。
type OrderRefundOptions struct {
RevokeService bool // 同时回收所购服务(意图落 refund_revoke,确认成功后执行)
Reason string // 退款原因(手工登记必填;API 退款选填随单报平台)
Manual bool // 手工退款登记:不调平台接口,仅落库(iOS / 平台后台已手工退的场景)
}
// AdminOrderRefund 订单退款(真退款状态机,替换旧「点了就置已退款」实现)。
func AdminOrderRefund(ctx context.Context, outTradeNo string, opt OrderRefundOptions) (*v1.OrderRefundRes, error) {
outTradeNo = strings.TrimSpace(outTradeNo)
if outTradeNo == "" {
return nil, errOrderInvalid("订单号不能为空")
}
opt.Reason = strings.TrimSpace(opt.Reason)
res := &v1.OrderRefundRes{OutTradeNo: outTradeNo}
var beforeStatus int
// 出网参数(事务内组装、事务提交后才发起资金动作,不占行锁)
var apiOpenid, apiOutTradeNo, apiRefundNo string
var apiAmount int64
apiStarted := false
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 行锁:并发的两次退款串行化
row, err := tx.Model(consts.TableMemberOrders).
Where("out_trade_no", outTradeNo).LockUpdate().One()
if err != nil {
return err
}
if row.IsEmpty() {
return errOrderInvalid("订单不存在")
}
o := orderSnapshotFromRecord(row)
beforeStatus = o.Status
refundStatus := row["refund_status"].Int()
res.RefundStatus = refundStatus
// ===== 幂等分支 =====
if o.Status == consts.OrderStatusRefunded || refundStatus == consts.RefundStatusSuccess {
res.AlreadyRefunded = true
res.ServiceRevoked = row["service_revoked"].Int() == 1
res.Status = consts.OrderStatusRefunded
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
}
if refundStatus == consts.RefundStatusProcessing {
res.Refunding = true
res.Status = o.Status
res.StatusText = "退款中"
return nil
}
switch o.Status {
case consts.OrderStatusPending:
return errOrderInvalid("订单尚未支付,无法退款")
case consts.OrderStatusClosed:
return errOrderInvalid("订单已关闭,无法退款")
case consts.OrderStatusDelivered:
// 唯一允许发起退款的起点状态
default:
return errOrderInvalid("订单状态异常,无法退款")
}
// 平台退款窗口:支付超 365 天不可退(超窗只能走手工登记,如 App Store 退款)
if paidAt := row["paid_at"].GTime(); paidAt != nil && !paidAt.IsZero() &&
time.Since(paidAt.Time) > refundWindowDays*24*time.Hour {
return errOrderInvalid("订单支付已超过 365 天,平台不再支持退款;如已线下退款请勾选「手工退款登记」")
}
amount := o.PaidPriceCents
if amount <= 0 {
amount = o.PriceCents // 老订单 paid_price_cents 为 0 时回退原价(S14 口径)
}
// 手工退款判定:显式 Manual / iOS 渠道(开发者无法主动退款,用户向 App Store 申请)/
// 平台未配置(无法出网)。显式手工与 iOS 渠道必须留原因(审计要求);
// 「平台未配置」的降级路径不强制(兼容既有联调/测试流程)。
platformUnconfigured := !xpayConfig(ctx).Configured()
manual := opt.Manual || o.PayChannel == consts.PayChannelIOS || platformUnconfigured
if manual && (opt.Manual || o.PayChannel == consts.PayChannelIOS) && opt.Reason == "" {
return errOrderInvalid("手工退款登记必须填写退款原因")
}
if manual {
// ===== 手工路径:当场回收 + 落「已退款」(旧版语义 + 退款列补记)=====
serviceRevoked := 0
if opt.RevokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
// refunded_at / refund_finished_at / updated_at 用库内 NOW()(TZ-01)
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"status": consts.OrderStatusRefunded,
"service_revoked": serviceRevoked,
"refunded_at": gdb.Raw("NOW()"),
"refund_status": consts.RefundStatusSuccess,
"refund_amount": amount,
"refund_reason": opt.Reason,
"refund_finished_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
res.Revoked = true
res.ServiceRevoked = serviceRevoked == 1
res.Status = consts.OrderStatusRefunded
res.RefundStatus = consts.RefundStatusSuccess
res.StatusText = orderStatusText(consts.OrderStatusRefunded)
return nil
}
// ===== API 路径:事务内落「退款中」,提交后出网调 /xpay/refund_order =====
refundNo := newRefundNo(o.Id)
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"refund_no": refundNo,
"refund_status": consts.RefundStatusProcessing,
"refund_amount": amount,
"refund_reason": opt.Reason,
"refund_revoke": boolToInt(opt.RevokeService),
"refunded_at": nil, // 确认成功时才写(避免「退款中」却显示退款时间)
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
apiOpenid, apiOutTradeNo, apiRefundNo, apiAmount = o.Openid, o.OutTradeNo, refundNo, amount
apiStarted = true
res.Refunding = true
res.RefundNo = refundNo
res.Status = o.Status
res.RefundStatus = consts.RefundStatusProcessing
res.StatusText = "退款中"
return nil
})
if err != nil {
return nil, err
}
switch {
case res.AlreadyRefunded:
res.Message = "订单已是退款状态,未重复执行"
case res.Refunding && !apiStarted:
res.Message = "退款已在处理中,等待平台确认"
case res.Refunding && apiStarted:
// 出网在事务提交后(资金动作不占行锁);同步失败 → 置「失败」并报错给管理员
if rerr := XPayRefundOrder(ctx, apiOpenid, apiOutTradeNo, apiRefundNo, apiAmount, opt.Reason); rerr != nil {
markRefundFailed(ctx, outTradeNo, apiRefundNo)
WriteAudit(ctx, AuditEntry{
Action: "order.refund", TargetType: "order", TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus, "refund_status": consts.RefundStatusProcessing},
After: g.Map{"refund_status": consts.RefundStatusFailed},
Result: consts.AuditResultFail, Remark: rerr.Error(),
})
return nil, gerror.Newf("发起平台退款失败:%v(订单已标记退款失败,可修正后重试)", rerr)
}
res.Message = "已发起平台退款,确认后自动回收权益"
if opt.RevokeService {
res.Message += "(refund_revoke=1,收到平台成功推送即回收)"
}
}
if res.Message == "" {
res.Message = "退款已受理"
}
// 审计(旁路):发起动作本身
if !res.AlreadyRefunded {
WriteAudit(ctx, AuditEntry{
Action: "order.refund",
TargetType: "order",
TargetId: outTradeNo,
Before: g.Map{"status": beforeStatus, "refund_status": consts.RefundStatusNone},
After: g.Map{
"status": res.Status,
"refund_status": res.RefundStatus,
"revoke_intent": opt.RevokeService,
"manual": opt.Manual,
"reason": opt.Reason,
},
Result: consts.AuditResultSuccess,
Remark: res.Message,
})
}
return res, nil
}
// newRefundNo 生成商户退款单号:R + 订单id + 'x' + 8 位随机字母数字。
// 微信 out_refund_no 要求 6-32 位、仅字母数字(含 -_|*),此处最长 1+19+1+8=29 位。
// 每次发起(含失败重试)都生成新号,避免与历史退款单号冲突。
func newRefundNo(orderId int64) string {
return fmt.Sprintf("R%dx%s", orderId, grand.S(8))
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// markRefundFailed 平台退款同步失败 / 轮询确认超时:置 refund_status=3(status 不动,可重试)。
func markRefundFailed(ctx context.Context, outTradeNo, refundNo string) {
if _, err := g.Model(consts.TableMemberOrders).
Where("out_trade_no", outTradeNo).
Where("refund_no", refundNo).
Where("refund_status", consts.RefundStatusProcessing).
Data(g.Map{
"refund_status": consts.RefundStatusFailed,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
g.Log().Errorf(ctx, "[refund] 标记退款失败落库异常 outTradeNo=%s err=%v", outTradeNo, err)
}
}
// refundNotifyHandle 处理平台退款结果推送(/pay/notify 按 Event 分流进入)。
func refundNotifyHandle(ctx context.Context, raw []byte) (string, error) {
notify, err := ParseRefundNotify(raw)
if err != nil {
g.Log().Warningf(ctx, "[xpay] 退款推送解析失败: %v raw=%s", err, string(raw))
return XPayNotifyAckFail("bad payload"), nil
}
order, err := entityOrderByOutTradeNo(ctx, notify.OutTradeNo)
if err != nil {
// 订单不存在多为脏数据,回成功避免平台无意义重试
g.Log().Warningf(ctx, "[xpay] 退款推送对应订单不存在: %s", notify.OutTradeNo)
return XPayNotifyAck(), nil
}
if order.RefundStatus != consts.RefundStatusProcessing {
return XPayNotifyAck(), nil // 幂等:非「退款中」一律忽略重复推送
}
// 退款单号交叉校验:推送单号与本地不一致(如旧号的迟到推送)→ 忽略并告警
pushNo := notify.OutRefundNo
if pushNo == "" {
pushNo = notify.RefundInfo.OutRefundNo
}
if pushNo != "" && order.RefundNo != "" && pushNo != order.RefundNo {
g.Log().Warningf(ctx, "[xpay] 退款推送单号不匹配,忽略: outTradeNo=%s local=%s push=%s",
notify.OutTradeNo, order.RefundNo, pushNo)
return XPayNotifyAck(), nil
}
switch {
case notify.RefundNotifySuccess():
confirmed, ferr := confirmRefund(ctx, notify.OutTradeNo)
if ferr != nil {
g.Log().Errorf(ctx, "[xpay] 退款确认落库失败 outTradeNo=%s err=%v", notify.OutTradeNo, ferr)
return XPayNotifyAckFail("finalize failed"), nil // 让平台重试
}
g.Log().Infof(ctx, "[xpay] 退款确认成功(推送) outTradeNo=%s confirmed=%v", notify.OutTradeNo, confirmed)
return XPayNotifyAck(), nil
case pushNo != "" || notify.RefundInfo.RefundFee > 0:
// 携带了退款信息但判定为非成功 → 显式失败,置 refund_status=3 供管理员重试
markRefundFailed(ctx, notify.OutTradeNo, order.RefundNo)
g.Log().Warningf(ctx, "[xpay] 退款推送判定为失败 outTradeNo=%s raw=%s", notify.OutTradeNo, string(raw))
return XPayNotifyAck(), nil
default:
// 字段判不动(文档外枚举/字段缺失):保持「退款中」交查单轮询收敛;回成功停止重试
g.Log().Warningf(ctx, "[xpay] 退款推送结果不可判定,留待轮询 outTradeNo=%s raw=%s", notify.OutTradeNo, string(raw))
return XPayNotifyAck(), nil
}
}
// confirmRefund 退款确认成功(推送 / 轮询共用):状态 1→2 + 按发起时的意图回收权益 + 释放券。
// 返回 confirmed = 本次调用是否真正完成了状态翻转(幂等:重复确认返回 false)。
func confirmRefund(ctx context.Context, outTradeNo string) (bool, error) {
confirmed := false
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
row, err := tx.Model(consts.TableMemberOrders).
Where("out_trade_no", outTradeNo).LockUpdate().One()
if err != nil {
return err
}
if row.IsEmpty() || row["status"].Int() != consts.OrderStatusDelivered {
return nil // 并发已确认 / 状态异常 → 幂等无操作
}
o := orderSnapshotFromRecord(row)
revokeService := row["refund_revoke"].Int() == 1
serviceRevoked := 0
if revokeService {
revoked, rerr := revokeOrderServiceTx(ctx, tx, o)
if rerr != nil {
return rerr
}
if revoked {
serviceRevoked = 1
}
}
if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil {
return err
}
if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{
"status": consts.OrderStatusRefunded,
"service_revoked": serviceRevoked,
"refunded_at": gdb.Raw("NOW()"),
"refund_status": consts.RefundStatusSuccess,
"refund_finished_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
confirmed = true
return nil
})
if err != nil {
return false, err
}
if confirmed {
WriteAudit(ctx, AuditEntry{
Action: "order.refund.confirm",
TargetType: "order",
TargetId: outTradeNo,
After: g.Map{"status": consts.OrderStatusRefunded, "refund_status": consts.RefundStatusSuccess},
Result: consts.AuditResultSuccess,
Remark: "退款确认成功(平台推送/查单轮询)",
})
}
return confirmed, nil
}
// RefundStatusPoll 退款轮询兜底:扫「退款中」的订单主动查单确认。
// 推送丢失时的兜底路径;结果不可判定的单超过 24h 置「失败」告警,交管理员重试。
// 注册于 cmd.go(cronPatternRefundPoll,每 2 分钟)。
func RefundStatusPoll(ctx context.Context) {
if !xpayConfig(ctx).Configured() {
return
}
records, err := g.Model(consts.TableMemberOrders).
Where("refund_status", consts.RefundStatusProcessing).
Where("status", consts.OrderStatusDelivered).
WhereLT("updated_at", gtime.Now().Add(-2*time.Minute)).
OrderAsc("id").
Limit(50).
All()
if err != nil {
g.Log().Warningf(ctx, "[refund] 轮询扫单失败: %v", err)
return
}
for _, r := range records {
outTradeNo := r["out_trade_no"].String()
state, qerr := XPayQueryOrder(ctx, r["openid"].String(), outTradeNo)
if qerr != nil {
g.Log().Debugf(ctx, "[refund] 轮询查单跳过 outTradeNo=%s err=%v", outTradeNo, qerr)
continue
}
if state.Refund.Known && state.Refund.Success {
confirmed, ferr := confirmRefund(ctx, outTradeNo)
if ferr != nil {
g.Log().Errorf(ctx, "[refund] 轮询确认落库失败 outTradeNo=%s err=%v", outTradeNo, ferr)
continue
}
g.Log().Infof(ctx, "[refund] 轮询确认成功 outTradeNo=%s confirmed=%v", outTradeNo, confirmed)
continue
}
// 发起超 24h 仍无法确认 → 置失败并告警(管理员核对后重试 / 手工登记)
updated := r["updated_at"].GTime()
if updated != nil && !updated.IsZero() && time.Since(updated.Time) > 24*time.Hour {
markRefundFailed(ctx, outTradeNo, r["refund_no"].String())
g.Log().Errorf(ctx, "[refund] 退款超 24h 未确认,置为失败待人工核对 outTradeNo=%s raw=%s",
outTradeNo, state.RawJson)
}
}
}

View File

@@ -91,7 +91,8 @@ func QuotaPacksFor(ctx context.Context, toolKey string) (*v1.QuotaPacksRes, erro
}
// QuotaOrderCreate 创建次数包订单(**只建单**,N1:与会员套餐两步式同款,共用 createOrder)。
func QuotaOrderCreate(ctx context.Context, packKey, toolKey string) (*v1.QuotaOrderCreateRes, error) {
// platform:客户端平台标识,用于判定 pay_channel(ios → apple,其余 → wxpay)。
func QuotaOrderCreate(ctx context.Context, packKey, toolKey, platform string) (*v1.QuotaOrderCreateRes, error) {
userId := CtxUserId(ctx)
if !xpayConfig(ctx).Configured() {
return nil, gerror.NewCode(gcodePayFail(),
@@ -136,6 +137,7 @@ func QuotaOrderCreate(ctx context.Context, packKey, toolKey string) (*v1.QuotaOr
"tool_key": tool.ToolKey,
"times": pack.Times,
"duration_days": validDays, // 次数包复用 duration_days 承载「有效期天数」
"pay_channel": payChannelFromRequest(ctx, platform),
})
if err != nil {
return nil, err

View File

@@ -216,3 +216,50 @@ func FeedbackSubmit(ctx context.Context, content, contact string) (*v1.FeedbackS
}
return &v1.FeedbackSubmitRes{}, nil
}
// FeedbackMine 我的反馈(最近 N 条 + 未读官方回复数)。
// 不在此处置已读:红点需要「先看到未读数 → 打开列表 → 再清零」的两步语义,已读走 FeedbackMarkRead。
func FeedbackMine(ctx context.Context, limit int) (*v1.FeedbackMineRes, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
userId := CtxUserId(ctx)
records, err := g.Model(consts.TableFeedbacks).
Where("user_id", userId).
OrderDesc("id").
Limit(limit).
All()
if err != nil {
return nil, err
}
list := make([]v1.FeedbackMineItem, 0, len(records))
unread := 0
for _, r := range records {
item := v1.FeedbackMineItem{
Id: r["id"].Int64(),
Content: r["content"].String(),
Status: r["status"].Int(),
ReplyContent: r["reply_content"].String(),
ReplyAt: r["reply_at"].String(),
ReplyRead: r["reply_read"].Int(),
CreatedAt: r["created_at"].String(),
}
// 有官方回复且未读(老数据 reply_read 默认 0,但无回复内容不算未读)
if item.ReplyContent != "" && r["reply_read"].Int() == 0 {
unread++
}
list = append(list, item)
}
return &v1.FeedbackMineRes{List: list, Unread: unread}, nil
}
// FeedbackMarkRead 把当前用户的全部反馈回复置为已读(清红点)。
func FeedbackMarkRead(ctx context.Context) (*v1.FeedbackReadRes, error) {
if _, err := g.Model(consts.TableFeedbacks).
Where("user_id", CtxUserId(ctx)).
Where("reply_read", 0).
Data(g.Map{"reply_read": 1}).Update(); err != nil {
return nil, err
}
return &v1.FeedbackReadRes{}, nil
}

View File

@@ -35,9 +35,10 @@ import (
// ============================================================================
const (
xpayAPIBase = "https://api.weixin.qq.com"
xpayURIPayRequest = "requestVirtualPayment" // C 端下单 uri(不带问号参数)
xpayURIQueryOrder = "/xpay/query_order" // B 端查单 uri
xpayAPIBase = "https://api.weixin.qq.com"
xpayURIPayRequest = "requestVirtualPayment" // C 端下单 uri(不带问号参数)
xpayURIQueryOrder = "/xpay/query_order" // B 端查单 uri
xpayURIRefundOrder = "/xpay/refund_order" // B 端退款 uri(支付 365 天内可退)
)
// xpaySignData C 端 signData 结构。
@@ -171,9 +172,10 @@ func wxAccessToken(ctx context.Context) (string, error) {
// xpayOrderState 查单结果(只保留发货判定需要的字段)
type xpayOrderState struct {
Paid bool // 是否已支付
WxOrderId string // 平台单号(若响应中可得)
RawJson string // 原始响应,排障用
Paid bool // 是否已支付
WxOrderId string // 平台单号(若响应中可得)
RawJson string // 原始响应,排障用
Refund xpayRefundProbe // 退款状态探测(供退款轮询用;Known=false 表示响应辨不出)
}
// 查单响应体。字段名以官方文档为准;未在文档中明确的字段(wxOrderId 等)
@@ -191,6 +193,11 @@ type xpayQueryOrderResp struct {
PaidTime int64 `json:"paidTime"`
Status int `json:"status"`
OrderType int `json:"orderType"`
// 退款状态字段(供退款轮询用;官方文档未完整给出,多别名兼容,判不动时以退款推送为准)
RefundStatus int `json:"refundStatus"`
RefundStatus2 int `json:"refund_status"`
OutRefundNo string `json:"outRefundNo"`
OutRefundNo2 string `json:"out_refund_no"`
} `json:"order"`
}
@@ -248,12 +255,100 @@ func XPayQueryOrder(ctx context.Context, openid, outTradeNo string) (*xpayOrderS
Paid: out.Order.PaidTime > 0 || out.Order.PaidFee > 0,
WxOrderId: wxOrderId,
RawJson: raw,
Refund: refundProbeFromQuery(out),
}
g.Log().Debugf(ctx, "[xpay] query_order outTradeNo=%s paid=%v wxOrderId=%s raw=%s",
outTradeNo, state.Paid, state.WxOrderId, raw)
return state, nil
}
// ===== B 端:发起退款 =====
// xpayOrderState 退款轮询用的补充判定:查单响应中可辨别的退款状态。
// Known=false 表示响应里辨不出退款信息(字段缺失/枚举未知),以退款推送为准。
type xpayRefundProbe struct {
Known bool
Success bool
}
// refundProbeFromQuery 从查单响应中提取退款状态(多别名兼容,判不动则 Unknown)。
func refundProbeFromQuery(out *xpayQueryOrderResp) xpayRefundProbe {
o := out.Order
// 已知口径:退款成功后平台侧状态位为 0(与推送 RefundStatus=0 同源)。
// 仅在响应确实携带了退款单号时才把 0 视为「已退款」,避免字段缺失误判。
if o.RefundStatus == 0 && (o.OutRefundNo != "" || o.OutRefundNo2 != "") {
return xpayRefundProbe{Known: true, Success: true}
}
if o.RefundStatus2 == 0 && (o.OutRefundNo != "" || o.OutRefundNo2 != "") {
return xpayRefundProbe{Known: true, Success: true}
}
return xpayRefundProbe{Known: false}
}
// XPayRefundOrder 发起退款(POST /xpay/refund_order,支付 365 天内可退;
// 180 天内退手续费,见 doc/会员购买接入说明.md「费率与退款」)。
//
// 请求模式与 XPayQueryOrder 完全一致:请求体序列化后**同一串**用于 pay_sig 签名,
// URL query 带 access_token + pay_sig。错误判定 errcode != 0。
//
// 注意:该接口是「启动退款任务」,最终结果以 xpay_refund_notify 推送 / 查单轮询为准。
func XPayRefundOrder(
ctx context.Context, openid, outTradeNo, outRefundNo string, refundAmount int64, refundReason string,
) error {
cfg := xpayConfig(ctx)
if !cfg.Configured() {
return gerror.New("虚拟支付未配置(缺少 wx.pay.offerId / wx.pay.appKey)")
}
if openid == "" || outTradeNo == "" || outRefundNo == "" || refundAmount <= 0 {
return gerror.Newf("退款参数不完整: openid=%q outTradeNo=%q outRefundNo=%q amount=%d",
openid, outTradeNo, outRefundNo, refundAmount)
}
token, err := wxAccessToken(ctx)
if err != nil {
return err
}
// 请求体字符串必须与实际发出的完全一致 —— 先序列化,再拿同一串去签名
body := g.Map{
"openid": openid,
"env": cfg.Env,
"order_id": outTradeNo,
"out_refund_no": outRefundNo,
"refund_amount": refundAmount,
}
if refundReason != "" {
body["refund_reason"] = refundReason
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return gerror.Newf("组装退款请求体失败: %v", err)
}
postBody := string(bodyBytes)
paySig := CalcPaySig(xpayURIRefundOrder, postBody, cfg.AppKey)
url := fmt.Sprintf("%s%s?access_token=%s&pay_sig=%s", xpayAPIBase, xpayURIRefundOrder, token, paySig)
resp, err := g.Client().ContentJson().Post(ctx, url, postBody)
if err != nil {
return gerror.Newf("调用 refund_order 失败: %v", err)
}
defer resp.Close()
raw := string(resp.ReadAll())
out := &struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}{}
if err = json.Unmarshal([]byte(raw), out); err != nil {
return gerror.Newf("解析 refund_order 响应失败: %v raw=%s", err, raw)
}
if out.ErrCode != 0 {
return gerror.Newf("refund_order 返回错误: %d %s", out.ErrCode, out.ErrMsg)
}
g.Log().Infof(ctx, "[xpay] 发起退款 outTradeNo=%s outRefundNo=%s amount=%d raw=%s",
outTradeNo, outRefundNo, refundAmount, raw)
return nil
}
// ===== 发货推送解析 =====
// XPayDeliverNotify 道具发货推送(XML)解析结构
@@ -279,7 +374,81 @@ type XPayDeliverNotify struct {
Attach string `xml:"Attach"`
}
// ParseDeliverNotify 解析发货推送 XML
// XPayRefundNotify 退款结果推送(XML)解析结构。
// 字段名以官方文档为准;采用「多别名兼容 + 缺失不致命」策略(与查单同款),
// 成功判定优先看平台结果码字段,判不动时交由查单轮询兜底收敛。
type XPayRefundNotify struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Event string `xml:"Event"`
OpenId string `xml:"OpenId"`
OutTradeNo string `xml:"OutTradeNo"`
OutRefundNo string `xml:"OutRefundNo"`
Env int `xml:"Env"`
WeChatPayInfo struct {
MchOrderNo string `xml:"MchOrderNo"`
OutRefundNo string `xml:"OutRefundNo"`
} `xml:"WeChatPayInfo"`
RefundInfo struct {
OutRefundNo string `xml:"OutRefundNo"`
RefundFee int64 `xml:"RefundFee"` // 退款金额(分)
RefundStatus int `xml:"RefundStatus"` // 平台退款状态(0=退款成功,社区口径)
RefundRespCode int `xml:"RefundRespCode"` // 退款结果码(0=成功)
} `xml:"RefundInfo"`
}
// ParseNotifyEvent 解析推送 XML 的顶层事件名(最小解析,供 /pay/notify 分流)。
func ParseNotifyEvent(raw []byte) (string, error) {
var head struct {
XMLName xml.Name `xml:"xml"`
Event string `xml:"Event"`
}
if err := xml.Unmarshal(raw, &head); err != nil {
return "", gerror.Newf("解析推送 XML 失败: %v", err)
}
if head.Event == "" {
return "", gerror.New("推送缺少 Event 字段")
}
return head.Event, nil
}
// ParseRefundNotify 解析退款结果推送 XML(事件名由调用方分流后进入)。
func ParseRefundNotify(raw []byte) (*XPayRefundNotify, error) {
notify := &XPayRefundNotify{}
if err := xml.Unmarshal(raw, notify); err != nil {
return nil, gerror.Newf("解析退款推送 XML 失败: %v", err)
}
if notify.OutTradeNo == "" {
return nil, gerror.New("退款推送缺少 OutTradeNo")
}
g.Log().Infof(context.Background(),
"[xpay] 退款推送 outTradeNo=%s outRefundNo=%s refundFee=%d status=%d respCode=%d raw=%s",
notify.OutTradeNo, notify.OutRefundNo, notify.RefundInfo.RefundFee,
notify.RefundInfo.RefundStatus, notify.RefundInfo.RefundRespCode, string(raw))
return notify, nil
}
// RefundNotifySuccess 推送是否表明退款成功。
// 平台字段枚举在官方文档中未完整给出:已知的「0 = 成功」两个码位任一命中即成功;
// 都未携带时返回 false(不确定),交由查单轮询收敛,绝不误标成功。
func (n *XPayRefundNotify) RefundNotifySuccess() bool {
info := n.RefundInfo
switch {
case info.RefundRespCode != 0:
return true
case info.RefundStatus == 0 && (info.OutRefundNo != "" || info.RefundFee > 0 || n.OutRefundNo != ""):
// RefundStatus=0 只有在推送确实携带退款信息时才视为「成功 0」,
// 避免把「字段缺失默认 0」误判为成功。
return true
default:
return false
}
}
// ParseDeliverNotify 解析发货推送 XML(事件名校验保留:调用方先按 Event 分流后才进入)。
func ParseDeliverNotify(raw []byte) (*XPayDeliverNotify, error) {
notify := &XPayDeliverNotify{}
if err := xml.Unmarshal(raw, notify); err != nil {

View File

@@ -9,25 +9,31 @@ import "github.com/gogf/gf/v2/os/gtime"
// - OrderTypeMember(1):会员套餐。发货 = 延长 users.level_expire_at(用到 LevelKey/DurationDays)
// - OrderTypeQuota(2) :次数包。 发货 = 增加 user_tool_quota 余额(用到 ToolKey/Times/DurationDays 作为有效期天数)
type MemberOrders struct {
Id int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"` // 业务单号,唯一,8-32 位且不能以下划线开头
WxOrderId string `json:"wx_order_id"` // 平台单号,发货幂等去重以此为准
UserId int64 `json:"user_id"`
Openid string `json:"openid"` // 支付时使用的 openid,查单需要
OrderType int `json:"order_type"`
PlanKey string `json:"plan_key"`
PackKey string `json:"pack_key"` // 次数包档位(OrderType=2)
ToolKey string `json:"tool_key"` // 次数包绑定的工具(OrderType=2)
Times int `json:"times"` // 次数包的次数(OrderType=2)
ProductId string `json:"product_id"`
PriceCents int64 `json:"price_cents"`
LevelKey string `json:"level_key"` // 下单时套餐对应的等级(快照)
DurationDays int `json:"duration_days"` // 下单时套餐天数 / 次数包有效期天数(快照)
Status int `json:"status"`
PayChannel string `json:"pay_channel"`
Attach string `json:"attach"` // 透传数据,发货时原样回传
PaidAt *gtime.Time `json:"paid_at"`
DeliveredAt *gtime.Time `json:"delivered_at"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
Id int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"` // 业务单号,唯一,8-32 位且不能以下划线开头
WxOrderId string `json:"wx_order_id"` // 平台单号,发货幂等去重以此为准
UserId int64 `json:"user_id"`
Openid string `json:"openid"` // 支付时使用的 openid,查单需要
OrderType int `json:"order_type"`
PlanKey string `json:"plan_key"`
PackKey string `json:"pack_key"` // 次数包档位(OrderType=2)
ToolKey string `json:"tool_key"` // 次数包绑定的工具(OrderType=2)
Times int `json:"times"` // 次数包的次数(OrderType=2)
ProductId string `json:"product_id"`
PriceCents int64 `json:"price_cents"`
LevelKey string `json:"level_key"` // 下单时套餐对应的等级(快照)
DurationDays int `json:"duration_days"` // 下单时套餐天数 / 次数包有效期天数(快照)
Status int `json:"status"`
PayChannel string `json:"pay_channel"`
Attach string `json:"attach"` // 透传数据,发货时原样回传
// ===== 退款(refund_status 独立于 status:status 仍由订单生命周期驱动)=====
RefundNo string `json:"refund_no"` // 商户退款单号(唯一,重试会换新号)
RefundStatus int `json:"refund_status"` // 0未退款 1退款中 2退款成功 3退款失败
RefundAmount int64 `json:"refund_amount"` // 退款金额(分,全额退款)
RefundReason string `json:"refund_reason"` // 退款原因(后台登记)
RefundFinishedAt *gtime.Time `json:"refund_finished_at"` // 退款确认完成时间(回调/查单/手工登记)
PaidAt *gtime.Time `json:"paid_at"`
DeliveredAt *gtime.Time `json:"delivered_at"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

50
sql/feedback_reply.sql Normal file
View File

@@ -0,0 +1,50 @@
-- ============================================================================
-- 反馈回复:官方回复 + 用户端未读提醒(幂等,可重复执行)
--
-- 本迁移与 internal/logic/migrate.go 的 migrateColumns() 内容一致:
-- 服务端启动时 Migrate() 会自动加列(项目约定:升级只需重启服务)。
--
-- 新增列:
-- feedbacks.reply_content 官方回复内容
-- feedbacks.reply_by 回复管理员 id,0=无
-- feedbacks.reply_at 最近回复时间
-- feedbacks.reply_read 用户端已读:0有新回复未读 1已读
-- ============================================================================
SET @db := DATABASE();
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `feedbacks` ADD COLUMN `reply_content` varchar(1000) NOT NULL DEFAULT '''' COMMENT ''官方回复内容''',
'SELECT ''feedbacks.reply_content already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'feedbacks' AND COLUMN_NAME = 'reply_content'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `feedbacks` ADD COLUMN `reply_by` bigint NOT NULL DEFAULT 0 COMMENT ''回复管理员 id,0=无''',
'SELECT ''feedbacks.reply_by already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'feedbacks' AND COLUMN_NAME = 'reply_by'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `feedbacks` ADD COLUMN `reply_at` datetime DEFAULT NULL COMMENT ''最近回复时间''',
'SELECT ''feedbacks.reply_at already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'feedbacks' AND COLUMN_NAME = 'reply_at'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `feedbacks` ADD COLUMN `reply_read` tinyint NOT NULL DEFAULT 0 COMMENT ''用户端已读:0有新回复未读 1已读''',
'SELECT ''feedbacks.reply_read already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'feedbacks' AND COLUMN_NAME = 'reply_read'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,61 @@
-- ============================================================================
-- 真退款链路:退款单状态与金额落库(幂等,可重复执行)
--
-- 本迁移与 internal/logic/migrate.go 的 migrateColumns() 内容一致:
-- 服务端启动时 Migrate() 会自动加列(项目约定:升级只需重启服务)。
-- 本文件供运维在无法重启 / 需要手工核对时使用。
--
-- 新增列:
-- member_orders.refund_no 商户退款单号(唯一;失败重发换新号)
-- member_orders.refund_status 退款状态:0未退款 1退款中 2退款成功 3退款失败
-- member_orders.refund_amount 退款金额(分,全额退款 = paid_price_cents)
-- member_orders.refund_reason 退款原因(后台登记,iOS/手工退款必填)
-- member_orders.refund_finished_at 退款确认完成时间(回调 / 查单轮询 / 手工登记)
-- ============================================================================
SET @db := DATABASE();
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `member_orders` ADD COLUMN `refund_no` varchar(40) NOT NULL DEFAULT '''' COMMENT ''商户退款单号''',
'SELECT ''member_orders.refund_no already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refund_no'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `member_orders` ADD COLUMN `refund_status` tinyint NOT NULL DEFAULT 0 COMMENT ''退款状态:0未退款 1退款中 2退款成功 3退款失败''',
'SELECT ''member_orders.refund_status already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refund_status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `member_orders` ADD COLUMN `refund_amount` bigint NOT NULL DEFAULT 0 COMMENT ''退款金额(分)''',
'SELECT ''member_orders.refund_amount already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refund_amount'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `member_orders` ADD COLUMN `refund_reason` varchar(255) NOT NULL DEFAULT '''' COMMENT ''退款原因''',
'SELECT ''member_orders.refund_reason already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refund_reason'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE `member_orders` ADD COLUMN `refund_finished_at` datetime DEFAULT NULL COMMENT ''退款确认完成时间''',
'SELECT ''member_orders.refund_finished_at already exists'' AS msg')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refund_finished_at'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;