diff --git a/api/admin/v1/admin.go b/api/admin/v1/admin.go index 1a3e95c..cf590e8 100644 --- a/api/admin/v1/admin.go +++ b/api/admin/v1/admin.go @@ -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{} diff --git a/api/admin/v1/order.go b/api/admin/v1/order.go index e1a5b14..093fc40 100644 --- a/api/admin/v1/order.go +++ b/api/admin/v1/order.go @@ -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"` } diff --git a/api/user/v1/member.go b/api/user/v1/member.go index c8f6db4..e3899cf 100644 --- a/api/user/v1/member.go +++ b/api/user/v1/member.go @@ -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)。 diff --git a/api/user/v1/note.go b/api/user/v1/note.go index dce5762..8ab9006 100644 --- a/api/user/v1/note.go +++ b/api/user/v1/note.go @@ -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"` diff --git a/api/user/v1/quota.go b/api/user/v1/quota.go index 1470f82..5db34de 100644 --- a/api/user/v1/quota.go +++ b/api/user/v1/quota.go @@ -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 创建次数包订单响应(只建单,不返回支付参数)。 diff --git a/api/user/v1/user.go b/api/user/v1/user.go index aabe5d1..a04eff3 100644 --- a/api/user/v1/user.go +++ b/api/user/v1/user.go @@ -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{} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index c001590..ef175c1 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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) { diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 3e0cd80..d09c8b5 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -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 // 退款失败(可重新发起) ) diff --git a/internal/controller/admin.go b/internal/controller/admin.go index 0ee4700..33b35ff 100644 --- a/internal/controller/admin.go +++ b/internal/controller/admin.go @@ -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) } // ===== 额度配置(次数包 / 额度设置 / 会员套餐 / 用户周期)===== diff --git a/internal/controller/admin_order.go b/internal/controller/admin_order.go index 3f544d6..923c7d7 100644 --- a/internal/controller/admin_order.go +++ b/internal/controller/admin_order.go @@ -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, + }) } diff --git a/internal/controller/member.go b/internal/controller/member.go index d31d2c0..cce73db 100644 --- a/internal/controller/member.go +++ b/internal/controller/member.go @@ -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 两步式的第二步) diff --git a/internal/controller/quota.go b/internal/controller/quota.go index 7a9ddad..9ac16b6 100644 --- a/internal/controller/quota.go +++ b/internal/controller/quota.go @@ -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 对已建次数包待支付订单构建支付参数(与会员同构) diff --git a/internal/controller/user.go b/internal/controller/user.go index 4271c5d..605060c 100644 --- a/internal/controller/user.go +++ b/internal/controller/user.go @@ -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) +} diff --git a/internal/logic/admin.go b/internal/logic/admin.go index 5d1bb46..631a8c1 100644 --- a/internal/logic/admin.go +++ b/internal/logic/admin.go @@ -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 } diff --git a/internal/logic/admin_order.go b/internal/logic/admin_order.go index 65d85f8..6a5c190 100644 --- a/internal/logic/admin_order.go +++ b/internal/logic/admin_order.go @@ -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 +} diff --git a/internal/logic/admin_order_test.go b/internal/logic/admin_order_test.go index e19a65e..cfef74b 100644 --- a/internal/logic/admin_order_test.go +++ b/internal/logic/admin_order_test.go @@ -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 := `xpay_goods_deliver_notifyT1` + refundXML := `xpay_refund_notifyT1` + + `R1xabc` + + `R1xabc1000` + + `00` + unknownXML := `xpay_something_newT1` + + 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(`xpay_refund_notifyT1`)) + if err != nil { + t.Fatalf("空退款推送解析失败: %v", err) + } + if empty.RefundNotifySuccess() { + t.Fatalf("无退款信息字段时不得判定成功(防字段缺失误判)") + } +} diff --git a/internal/logic/member.go b/internal/logic/member.go index 751eba0..5d7111f 100644 --- a/internal/logic/member.go +++ b/internal/logic/member.go @@ -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() diff --git a/internal/logic/migrate.go b/internal/logic/migrate.go index 359f731..c4a16be 100644 --- a/internal/logic/migrate.go +++ b/internal/logic/migrate.go @@ -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其他'"}, diff --git a/internal/logic/note.go b/internal/logic/note.go index 249173e..5bdee5e 100644 --- a/internal/logic/note.go +++ b/internal/logic/note.go @@ -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, diff --git a/internal/logic/order_pay.go b/internal/logic/order_pay.go index 3016dc9..7f89ff4 100644 --- a/internal/logic/order_pay.go +++ b/internal/logic/order_pay.go @@ -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)+ diff --git a/internal/logic/order_refund.go b/internal/logic/order_refund.go new file mode 100644 index 0000000..8b0083d --- /dev/null +++ b/internal/logic/order_refund.go @@ -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) + } + } +} diff --git a/internal/logic/quota_api.go b/internal/logic/quota_api.go index 3f8f338..a1e8769 100644 --- a/internal/logic/quota_api.go +++ b/internal/logic/quota_api.go @@ -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 diff --git a/internal/logic/tools.go b/internal/logic/tools.go index 6083def..9280a99 100644 --- a/internal/logic/tools.go +++ b/internal/logic/tools.go @@ -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 +} diff --git a/internal/logic/xpay.go b/internal/logic/xpay.go index ba789ff..ddbd257 100644 --- a/internal/logic/xpay.go +++ b/internal/logic/xpay.go @@ -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 { diff --git a/internal/model/entity/entity_member_orders.go b/internal/model/entity/entity_member_orders.go index bea6a85..236a248 100644 --- a/internal/model/entity/entity_member_orders.go +++ b/internal/model/entity/entity_member_orders.go @@ -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"` } diff --git a/manifest/static/avatar/202609/12767de8af53f4dc237e0b565e70b76c.jpeg b/manifest/static/avatar/202609/12767de8af53f4dc237e0b565e70b76c.jpeg new file mode 100644 index 0000000..8f7d051 Binary files /dev/null and b/manifest/static/avatar/202609/12767de8af53f4dc237e0b565e70b76c.jpeg differ diff --git a/manifest/static/avatar/202609/b9225851688fc6c06a5e1399734f368f.jpeg b/manifest/static/avatar/202609/b9225851688fc6c06a5e1399734f368f.jpeg new file mode 100644 index 0000000..2a0dba9 Binary files /dev/null and b/manifest/static/avatar/202609/b9225851688fc6c06a5e1399734f368f.jpeg differ diff --git a/sql/feedback_reply.sql b/sql/feedback_reply.sql new file mode 100644 index 0000000..0b24500 --- /dev/null +++ b/sql/feedback_reply.sql @@ -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; diff --git a/sql/member_orders_refund2.sql b/sql/member_orders_refund2.sql new file mode 100644 index 0000000..a30b8de --- /dev/null +++ b/sql/member_orders_refund2.sql @@ -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;