小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

View File

@@ -0,0 +1,385 @@
package logic
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
"tool-api/internal/model/entity"
)
// ============================================================================
// 管理端「订单管理 + 退款」逻辑
//
// 数据源:member_orders(order_type 1=会员套餐 2=次数包,同一张表)。
//
// 退款语义(本次核心):
// - 仅允许 status=1(已发货) → 2(已退款);其它状态给可读业务错误(4015)。
// - 幂等:status=2 再调 → 成功返回,但**不重复回收权益**(幂等由行锁 + status 判定保证)。
// - revokeService=true → 回收所购服务:
// 会员套餐:回退 users.level_expire_at(减去本单购买的天数),回收后若已到期则同时回落默认等级;
// level_expire_at IS NULL(永久)**同样真取消**:置为当前时刻(已过期,用户裁定 2026-09-23),
// level_key 保留不清空,失效判定交给 EffectiveLevelKey。
// 次数包: 扣回 user_tool_quota 的付费次数(扣到 0 为止,不出负数)。
// revokeService=false → 仅改状态,权益保留。
// - 释放券:退款时释放该订单占用的免单券 / 优惠码(仅当状态确实 1→2 时执行)。
// - 审计:记录操作人(审计上下文)、订单号、是否回收服务。
//
// 幂等/一致性设计说明:
// 回收权益与状态变更在**同一事务**内完成(按 out_trade_no 行锁),任一失败整体回滚 →
// 「已退款必然已按 revokeService 处理完毕」。新增列 service_revoked 记录**实际回收结果**
// (勾选「取消服务」且确有生效中的授权被解除才为 1;勾了但无可回收授权为 0,避免「列表显示已回收
// 但实际什么都没回收」的不诚实记录),用于列表展示与审计留痕(即便将来放开「退款后再回收」也据此判重)。
// ============================================================================
// AdminOrderList 订单列表(分页 + 关键字 + 状态筛选,默认创建时间倒序)。
func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableMemberOrders)
if req.Status != nil {
m = m.Where("status", *req.Status)
}
if kw := strings.TrimSpace(req.Keyword); kw != "" {
like := "%" + kw + "%"
// 关键字:订单号 直接匹配;用户名/昵称 通过 users 子查询匹配归属用户
m = m.Where(
"out_trade_no LIKE ? OR user_id IN (SELECT id FROM "+consts.TableUsers+" WHERE nickname LIKE ? OR username LIKE ?)",
like, like, like,
)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("created_at").OrderDesc("id").All()
if err != nil {
return nil, err
}
userInfo := orderUsersMap(ctx, records)
planNames, _ := planNameMap(ctx)
packNames, _ := quotaPackNameMap(ctx)
toolNames, _ := toolNameMap(ctx)
list := make([]v1.OrderItem, 0, len(records))
for _, r := range records {
uid := r["user_id"].Int64()
orderType := r["order_type"].Int()
if orderType == 0 {
orderType = consts.OrderTypeMember // 兼容加列之前的历史订单
}
item := v1.OrderItem{
Id: r["id"].Int64(),
OutTradeNo: r["out_trade_no"].String(),
UserId: uid,
OrderType: orderType,
PlanKey: r["plan_key"].String(),
PackKey: r["pack_key"].String(),
ToolKey: r["tool_key"].String(),
Times: r["times"].Int(),
OriginPriceCents: r["origin_price_cents"].Int64(),
DiscountCents: r["discount_cents"].Int64(),
PaidPriceCents: r["paid_price_cents"].Int64(),
PromoKind: r["promo_kind"].Int(),
Status: r["status"].Int(),
StatusText: orderStatusText(r["status"].Int()),
ServiceRevoked: r["service_revoked"].Int(),
CreatedAt: timeStr(r["created_at"].GTime()),
PaidAt: timeStr(r["paid_at"].GTime()),
RefundedAt: timeStr(r["refunded_at"].GTime()),
}
if u, ok := userInfo[uid]; ok {
item.Nickname = u["nickname"].String()
item.Username = u["username"].String()
item.AvatarUrl = u["avatar_url"].String()
}
item.Title = orderTitle(orderType, r["plan_key"].String(), r["pack_key"].String(), r["tool_key"].String(),
planNames, packNames, toolNames)
list = append(list, item)
}
return &v1.OrderListRes{List: list, Total: total}, nil
}
// orderUsersMap 批量取订单归属用户信息(一次查询,避免 N+1)。
func orderUsersMap(ctx context.Context, records gdb.Result) map[int64]gdb.Record {
out := map[int64]gdb.Record{}
idSet := map[int64]struct{}{}
for _, r := range records {
if uid := r["user_id"].Int64(); uid > 0 {
idSet[uid] = struct{}{}
}
}
if len(idSet) == 0 {
return out
}
ids := make([]int64, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
rows, err := g.Model(consts.TableUsers).WhereIn("id", ids).All()
if err != nil {
return out
}
for _, u := range rows {
out[u["id"].Int64()] = u
}
return out
}
// orderTitle 计算订单商品展示名。
//
// 会员套餐 → 套餐名;次数包 → 工具名 · 档位名(工具名缺失时仅档位名)。
func orderTitle(
orderType int, planKey, packKey, toolKey string,
planNames, packNames, toolNames map[string]string,
) string {
if orderType == consts.OrderTypeQuota {
title := packNames[packKey]
if tn := toolNames[toolKey]; tn != "" && title != "" {
title = tn + " · " + title
}
return title
}
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
}
// revokeOrderServiceTx 回收订单所购服务(仅已知订单行快照,事务内、行锁已持有)。
//
// 返回 revoked = 是否确有「生效中的授权」被解除,供 service_revoked 按**实际结果**落库。
func revokeOrderServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) {
if o.OrderType == consts.OrderTypeQuota {
return revokeQuotaServiceTx(ctx, tx, o)
}
return revokeMemberServiceTx(ctx, tx, o)
}
// revokeQuotaServiceTx 次数包回收:扣回对应工具付费次数(扣到 0 为止,不出负数)。
//
// 不变量:total_bought >= times_left(扣减 total_bought 时不低于扣减后的 times_left),
// 使看板「付费额度累计消耗 = Σ(total_bought - times_left)」保持自洽。
//
// 返回 revoked = 实际扣回了「生效中的付费额度」(原 times_left > 0):
// 无额度记录 / 额度已耗尽(=0) 时不产生实际回收,返回 false。
func revokeQuotaServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) {
if o.ToolKey == "" || o.Times <= 0 {
// 数据异常(正常发货要求 toolKey 非空且 times>0):无可回收项,跳过并告警
g.Log().Warningf(ctx, "[refund] 次数包订单缺少工具/次数,跳过回收 outTradeNo=%s toolKey=%q times=%d",
o.OutTradeNo, o.ToolKey, o.Times)
return false, nil
}
row, err := tx.Model(consts.TableUserToolQuota).
Where("user_id", o.UserId).Where("tool_key", o.ToolKey).LockUpdate().One()
if err != nil {
return false, err
}
if row.IsEmpty() {
// 该用户在该工具上已无付费额度记录(可能已过期清理):无可回收项
return false, nil
}
id := row["id"].Int64()
left := row["times_left"].Int()
bought := row["total_bought"].Int()
newLeft := left - o.Times
if newLeft < 0 {
newLeft = 0 // 扣到 0 为止,绝不为负
}
newBought := bought - o.Times
if newBought < newLeft {
newBought = newLeft
}
if _, err = tx.Model(consts.TableUserToolQuota).Where("id", id).Data(g.Map{
"times_left": newLeft,
"total_bought": newBought,
// ⚠️ 库内 NOW():避免 Go 侧传参 datetime 的 TZ-01 8h 偏差(新代码不得引入)。
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return false, err
}
g.Log().Infof(ctx, "[refund] 次数包回收 userId=%d tool=%s -%d 次(剩余 %d→%d)outTradeNo=%s",
o.UserId, o.ToolKey, o.Times, left, newLeft, o.OutTradeNo)
// 原本就有可用额度才构成「生效中的授权被解除」;额度已为 0 时扣减不产生实际回收。
return left > 0, nil
}
// revokeMemberServiceTx 会员套餐回收:回退 users.level_expire_at。
//
// 回收模型:发货时按「购买天数」顺延有效期(extendMembership),回收即**反向减去本单天数**。
// - level_expire_at 为 NULL(永久)→ 按用户裁定(2026-09-23)**同样真取消、置为已过期**:
// 写入 level_expire_at = NOW();level_key **保留不清空**(失效判定交给 EffectiveLevelKey)。
// - 有限期:减去本单天数;减完后若已到期(<= now)→ 夹到当前时刻(保持非 NULL),
// 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 下用户可见行为完全一致。
//
// 返回 revoked = 回收**前**是否为「生效中的会员」(唯一判定走 EffectiveLevelKey),
// 供 service_revoked 按实际结果落库(已过期 / 免费用户退款时不记「已回收」)。
func revokeMemberServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) {
row, err := tx.Model(consts.TableUsers).Where("id", o.UserId).LockUpdate().One()
if err != nil {
return false, err
}
if row.IsEmpty() {
return false, nil
}
expire := row["level_expire_at"].GTime()
// 回收前是否为「生效中的会员」:唯一判定走 EffectiveLevelKey(永久 NULL 与未过期同为有效,已过期回落默认)。
wasEffective := EffectiveLevelKey(&entity.Users{
LevelKey: row["level_key"].String(),
LevelExpireAt: expire,
}) != consts.DefaultLevelKey
// 永久会员:真取消,置为已过期(用户裁定 2026-09-23)。
// NOW() 在库内取,规避 Go 侧传参的时区偏差(TZ-01);WHERE 限定 IS NULL,绝不误动有限期。
if expire == nil || expire.IsZero() {
if _, err = tx.Exec(
"UPDATE "+consts.TableUsers+" SET level_expire_at = NOW(), updated_at = NOW() "+
"WHERE id = ? AND level_expire_at IS NULL",
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
}
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, ?), "+
"updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL",
days, now, o.UserId)
if err != nil {
return false, err
}
if n, _ := result.RowsAffected(); n == 0 {
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)
return wasEffective, nil
}