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;