小程序一些功能更新
This commit is contained in:
@@ -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{}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 创建次数包订单响应(只建单,不返回支付参数)。
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 // 退款失败(可重新发起)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ===== 额度配置(次数包 / 额度设置 / 会员套餐 / 用户周期)=====
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 两步式的第二步)
|
||||
|
||||
@@ -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 对已建次数包待支付订单构建支付参数(与会员同构)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("无退款信息字段时不得判定成功(防字段缺失误判)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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其他'"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)+
|
||||
|
||||
409
internal/logic/order_refund.go
Normal file
409
internal/logic/order_refund.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
50
sql/feedback_reply.sql
Normal 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;
|
||||
61
sql/member_orders_refund2.sql
Normal file
61
sql/member_orders_refund2.sql
Normal 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;
|
||||
Reference in New Issue
Block a user