Files
quyun-v2/backend/app/services/order.go
Rogee 683965ae39 feat: Refactor order snapshot handling and introduce structured snapshot types
- Added new structured snapshot types for orders and order items to improve data integrity and clarity.
- Updated the Order and OrderItem models to use the new JSONType for snapshots.
- Refactored tests to accommodate the new snapshot structure, ensuring compatibility with legacy data.
- Enhanced the OrdersSnapshot struct to support multiple snapshot types and maintain backward compatibility.
- Introduced new fields for order items and orders to capture detailed snapshot information for auditing and historical display.
2025-12-22 21:11:33 +08:00

1486 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
pkgerrors "github.com/pkg/errors"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/field"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"go.ipao.vip/gen/types"
)
func newOrderSnapshot(kind consts.OrderType, payload any) types.JSONType[fields.OrdersSnapshot] {
b, err := json.Marshal(payload)
if err != nil || len(b) == 0 {
b = []byte("{}")
}
return types.NewJSONType(fields.OrdersSnapshot{
Kind: string(kind),
Data: b,
})
}
// AdminOrderExportCSV 租户管理员导出订单列表CSV 文本)。
func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*dto.AdminOrderExportResponse, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminOrderListFilter{}
}
// 导出属于高消耗操作:限制最大行数,避免拖垮数据库。
const maxRows = 5000
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"max_rows": maxRows,
"user_id": lo.FromPtr(filter.UserID),
"username": filter.UsernameTrimmed(),
"content_id": lo.FromPtr(filter.ContentID),
"content_title": filter.ContentTitleTrimmed(),
"type": lo.FromPtr(filter.Type),
"status": lo.FromPtr(filter.Status),
}).Info("services.order.admin.export_csv")
tbl, query := models.OrderQuery.QueryContext(ctx)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.UserID != nil {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil {
conds = append(conds, tbl.Type.Eq(*filter.Type))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.AmountPaidMin != nil {
conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
if username := filter.UsernameTrimmed(); username != "" {
uTbl, _ := models.UserQuery.QueryContext(ctx)
query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID))
conds = append(conds, uTbl.Username.Like(database.WrapLike(username)))
}
needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != ""
if needItemJoin {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
if filter.ContentID != nil && *filter.ContentID > 0 {
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
}
if title := filter.ContentTitleTrimmed(); title != "" {
cTbl, _ := models.ContentQuery.QueryContext(ctx)
query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID))
conds = append(conds, cTbl.Title.Like(database.WrapLike(title)))
}
query = query.Group(tbl.ID)
}
// 排序:复用 AdminOrderPage 的白名单,避免任意字段导致注入/慢查询。
orderBys := make([]field.Expr, 0, 4)
allowedAsc := map[string]field.Expr{
"id": tbl.ID.Asc(),
"created_at": tbl.CreatedAt.Asc(),
"paid_at": tbl.PaidAt.Asc(),
"amount_paid": tbl.AmountPaid.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": tbl.ID.Desc(),
"created_at": tbl.CreatedAt.Desc(),
"paid_at": tbl.PaidAt.Desc(),
"amount_paid": tbl.AmountPaid.Desc(),
}
for _, f := range filter.AscFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedAsc[f]; ok {
orderBys = append(orderBys, ob)
}
}
for _, f := range filter.DescFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedDesc[f]; ok {
orderBys = append(orderBys, ob)
}
}
if len(orderBys) == 0 {
orderBys = append(orderBys, tbl.ID.Desc())
} else {
orderBys = append(orderBys, tbl.ID.Desc())
}
items, err := query.Where(conds...).Order(orderBys...).Limit(maxRows).Find()
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
w := csv.NewWriter(buf)
_ = w.Write([]string{"id", "tenant_id", "user_id", "type", "status", "amount_paid", "paid_at", "created_at"})
for _, it := range items {
if it == nil {
continue
}
paidAt := ""
if !it.PaidAt.IsZero() {
paidAt = it.PaidAt.UTC().Format(time.RFC3339)
}
_ = w.Write([]string{
fmt.Sprintf("%d", it.ID),
fmt.Sprintf("%d", it.TenantID),
fmt.Sprintf("%d", it.UserID),
string(it.Type),
string(it.Status),
fmt.Sprintf("%d", it.AmountPaid),
paidAt,
it.CreatedAt.UTC().Format(time.RFC3339),
})
}
w.Flush()
if err := w.Error(); err != nil {
return nil, err
}
filename := fmt.Sprintf("tenant_%d_orders_%s.csv", tenantID, time.Now().UTC().Format("20060102_150405"))
return &dto.AdminOrderExportResponse{
Filename: filename,
ContentType: "text/csv",
CSV: buf.String(),
}, nil
}
// AdminBatchTopupUsers 租户管理员批量为成员充值(逐条幂等,允许部分失败)。
func (s *order) AdminBatchTopupUsers(
ctx context.Context,
tenantID, operatorUserID int64,
form *dto.AdminBatchTopupForm,
now time.Time,
) (*dto.AdminBatchTopupResponse, error) {
if tenantID <= 0 || operatorUserID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0")
}
if form == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("form is required")
}
if strings.TrimSpace(form.BatchIdempotencyKey) == "" {
return nil, errorx.ErrInvalidParameter.WithMsg("batch_idempotency_key is required")
}
if len(form.Items) == 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("items is required")
}
if now.IsZero() {
now = time.Now()
}
// 批量充值属于高敏感操作:限制单次条数,避免拖垮系统。
const maxItems = 200
if len(form.Items) > maxItems {
return nil, errorx.ErrInvalidParameter.WithMsg("items too many")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"batch_idempotency_key": form.BatchIdempotencyKey,
"total": len(form.Items),
}).Info("services.order.admin.batch_topup")
out := &dto.AdminBatchTopupResponse{
Total: len(form.Items),
Items: make([]*dto.AdminBatchTopupResultItem, 0, len(form.Items)),
}
for idx, item := range form.Items {
if item == nil {
out.Failed++
out.Items = append(out.Items, &dto.AdminBatchTopupResultItem{
OK: false,
ErrorCode: int(errorx.CodeInvalidParameter),
ErrorMessage: "item is nil",
})
continue
}
idemKey := strings.TrimSpace(item.IdempotencyKey)
if idemKey == "" {
// 关键语义:为空时用批次幂等键派生,确保批次重试不会重复入账。
idemKey = fmt.Sprintf("batch_topup:%s:%d:%d", strings.TrimSpace(form.BatchIdempotencyKey), item.UserID, idx)
}
resultItem := &dto.AdminBatchTopupResultItem{
UserID: item.UserID,
Amount: item.Amount,
IdempotencyKey: idemKey,
OK: false,
}
// 单条参数校验:失败只影响该条,不影响整批(便于运营侧修正后重试)。
if item.UserID <= 0 {
resultItem.ErrorCode = int(errorx.CodeInvalidParameter)
resultItem.ErrorMessage = "user_id must be > 0"
out.Failed++
out.Items = append(out.Items, resultItem)
continue
}
if item.Amount <= 0 {
resultItem.ErrorCode = int(errorx.CodeInvalidParameter)
resultItem.ErrorMessage = "amount must be > 0"
out.Failed++
out.Items = append(out.Items, resultItem)
continue
}
// 逐条调用单用户充值逻辑:保持“订单 + 账本 + 余额”一致性与幂等语义一致。
orderModel, err := s.AdminTopupUser(
ctx,
tenantID,
operatorUserID,
item.UserID,
item.Amount,
idemKey,
item.Reason,
now,
)
if err != nil {
// 错误收敛为可展示结构:便于前端逐条展示与导出审计。
var appErr *errorx.AppError
if errors.As(err, &appErr) {
resultItem.ErrorCode = int(appErr.Code)
resultItem.ErrorMessage = appErr.Message
} else {
resultItem.ErrorCode = int(errorx.CodeInternalError)
resultItem.ErrorMessage = err.Error()
}
out.Failed++
out.Items = append(out.Items, resultItem)
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"user_id": item.UserID,
"amount": item.Amount,
"idempotency_key": idemKey,
}).WithError(err).Warn("services.order.admin.batch_topup.item_failed")
continue
}
resultItem.OK = true
if orderModel != nil {
resultItem.OrderID = orderModel.ID
}
out.Success++
out.Items = append(out.Items, resultItem)
}
return out, nil
}
// PurchaseContentParams 定义“租户内使用余额购买内容”的入参。
type PurchaseContentParams struct {
// TenantID 租户 ID多租户隔离范围
TenantID int64
// UserID 购买者用户 ID。
UserID int64
// ContentID 内容 ID。
ContentID int64
// IdempotencyKey 幂等键:用于确保同一购买请求“至多处理一次”。
IdempotencyKey string
// Now 逻辑时间:用于 created_at/paid_at 与账本快照(可选,便于测试/一致性)。
Now time.Time
}
// PurchaseContentResult 为购买结果(幂等命中时返回已存在的订单/权益状态)。
type PurchaseContentResult struct {
// Order 订单记录(可能为 nil例如“已购买且无订单上下文”的快捷路径
Order *models.Order
// OrderItem 订单明细(本业务为单内容购买,通常只有 1 条)。
OrderItem *models.OrderItem
// Access 内容权益(购买完成后应为 active
Access *models.ContentAccess
// AmountPaid 实付金额单位CNY
AmountPaid int64
}
// order 提供订单域能力(购买、充值、退款、查询等)。
//
// @provider
type order struct {
db *gorm.DB
ledger *ledger
}
// AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。
func (s *order) AdminTopupUser(
ctx context.Context,
tenantID, operatorUserID, targetUserID, amount int64,
idempotencyKey, reason string,
now time.Time,
) (*models.Order, error) {
if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0")
}
if amount <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0")
}
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user": operatorUserID,
"target_user": targetUserID,
"amount": amount,
"idempotency_key": idempotencyKey,
}).Info("services.order.admin.topup_user")
var out models.Order
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 关键前置条件:目标用户必须属于该租户(同时加行锁,避免并发余额写入冲突)。
var tu models.TenantUser
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).
First(&tu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrPreconditionFailed.WithMsg("目标用户不属于该租户")
}
return err
}
// 充值幂等:按 orders(tenant_id,user_id,idempotency_key) 去重,避免重复入账。
if idempotencyKey != "" {
var existing models.Order
if err := tx.Where(
"tenant_id = ? AND user_id = ? AND idempotency_key = ?",
tenantID, targetUserID, idempotencyKey,
).First(&existing).Error; err == nil {
out = existing
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
// 先落订单paid再写入账本credit_topup确保“订单可追溯 + 账本可对账”。
snapshot := newOrderSnapshot(consts.OrderTypeTopup, &fields.OrdersTopupSnapshot{
OperatorUserID: operatorUserID,
TargetUserID: targetUserID,
Amount: amount,
Currency: consts.CurrencyCNY,
Reason: reason,
IdempotencyKey: idempotencyKey,
TopupAt: now,
})
orderModel := models.Order{
TenantID: tenantID,
UserID: targetUserID,
Type: consts.OrderTypeTopup,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: amount,
AmountDiscount: 0,
AmountPaid: amount,
Snapshot: snapshot,
IdempotencyKey: idempotencyKey,
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&orderModel).Error; err != nil {
return err
}
// 账本幂等键固定使用 topup:<orderID>,保证同一订单不会重复入账。
ledgerKey := fmt.Sprintf("topup:%d", orderModel.ID)
remark := reason
if remark == "" {
remark = fmt.Sprintf("topup by tenant_admin:%d", operatorUserID)
}
if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil {
return err
}
out = orderModel
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user": operatorUserID,
"target_user": targetUserID,
"amount": amount,
"idempotency_key": idempotencyKey,
}).WithError(err).Warn("services.order.admin.topup_user.failed")
return nil, err
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"target_user": targetUserID,
"order_id": out.ID,
"amount": amount,
}).Info("services.order.admin.topup_user.ok")
return &out, nil
}
// MyOrderPage 分页查询当前用户在租户内的订单。
func (s *order) MyOrderPage(
ctx context.Context,
tenantID, userID int64,
filter *dto.MyOrderListFilter,
) (*requests.Pager, error) {
if tenantID <= 0 || userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
}
if filter == nil {
filter = &dto.MyOrderListFilter{}
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"status": lo.FromPtr(filter.Status),
"content_id": lo.FromPtr(filter.ContentID),
}).Info("services.order.me.page")
filter.Pagination.Format()
tbl, query := models.OrderQuery.QueryContext(ctx)
query = query.Preload(tbl.Items)
conds := []gen.Condition{
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.ContentID != nil && *filter.ContentID > 0 {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
query = query.Group(tbl.ID)
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// MyOrderDetail 查询当前用户在租户内的订单详情。
func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int64) (*models.Order, error) {
if tenantID <= 0 || userID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/order_id must be > 0")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"order_id": orderID,
}).Info("services.order.me.detail")
tbl, query := models.OrderQuery.QueryContext(ctx)
m, err := query.Preload(tbl.Items).Where(
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
tbl.ID.Eq(orderID),
).First()
if err != nil {
return nil, err
}
return m, nil
}
// AdminOrderPage 租户管理员分页查询租户内订单。
func (s *order) AdminOrderPage(
ctx context.Context,
tenantID int64,
filter *dto.AdminOrderListFilter,
) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminOrderListFilter{}
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": lo.FromPtr(filter.UserID),
"username": filter.UsernameTrimmed(),
"content_id": lo.FromPtr(filter.ContentID),
"content_title": filter.ContentTitleTrimmed(),
"type": lo.FromPtr(filter.Type),
"status": lo.FromPtr(filter.Status),
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,
"paid_at_from": filter.PaidAtFrom,
"paid_at_to": filter.PaidAtTo,
"amount_paid_min": filter.AmountPaidMin,
"amount_paid_max": filter.AmountPaidMax,
}).Info("services.order.admin.page")
filter.Pagination.Format()
tbl, query := models.OrderQuery.QueryContext(ctx)
query = query.Preload(tbl.Items)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.UserID != nil {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Type != nil {
conds = append(conds, tbl.Type.Eq(*filter.Type))
}
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.AmountPaidMin != nil {
conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
// 用户关键字:按 users.username 模糊匹配。
// 关键点orders.user_id 与 users.id 一对一,不会导致重复行,无需 group by。
if username := filter.UsernameTrimmed(); username != "" {
uTbl, _ := models.UserQuery.QueryContext(ctx)
query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID))
conds = append(conds, uTbl.Username.Like(database.WrapLike(username)))
}
// 内容过滤:通过 order_items以及 contents关联查询。
// 关键点orders 与 order_items 一对多join 后必须 group by orders.id 以避免同一订单重复返回。
needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != ""
if needItemJoin {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
if filter.ContentID != nil && *filter.ContentID > 0 {
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
}
if title := filter.ContentTitleTrimmed(); title != "" {
cTbl, _ := models.ContentQuery.QueryContext(ctx)
query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID))
conds = append(conds, cTbl.Title.Like(database.WrapLike(title)))
}
query = query.Group(tbl.ID)
}
// 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。
// 约定:只允许按以下字段排序;未指定时默认按 id desc。
orderBys := make([]field.Expr, 0, 4)
allowedAsc := map[string]field.Expr{
"id": tbl.ID.Asc(),
"created_at": tbl.CreatedAt.Asc(),
"paid_at": tbl.PaidAt.Asc(),
"amount_paid": tbl.AmountPaid.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": tbl.ID.Desc(),
"created_at": tbl.CreatedAt.Desc(),
"paid_at": tbl.PaidAt.Desc(),
"amount_paid": tbl.AmountPaid.Desc(),
}
for _, f := range filter.AscFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedAsc[f]; ok {
orderBys = append(orderBys, ob)
}
}
for _, f := range filter.DescFields() {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if ob, ok := allowedDesc[f]; ok {
orderBys = append(orderBys, ob)
}
}
// 默认加上 id desc 作为稳定排序(尤其是 join + group 的场景)。
if len(orderBys) == 0 {
orderBys = append(orderBys, tbl.ID.Desc())
} else {
orderBys = append(orderBys, tbl.ID.Desc())
}
items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// AdminOrderDetail 租户管理员查询租户内订单详情。
func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (*models.Order, error) {
if tenantID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"order_id": orderID,
}).Info("services.order.admin.detail")
tbl, query := models.OrderQuery.QueryContext(ctx)
m, err := query.Preload(tbl.Items).Where(tbl.TenantID.Eq(tenantID), tbl.ID.Eq(orderID)).First()
if err != nil {
return nil, err
}
return m, nil
}
// AdminRefundOrder 退款已支付订单(支持强制退款),并立即回收已授予的内容权益。
func (s *order) AdminRefundOrder(
ctx context.Context,
tenantID, operatorUserID, orderID int64,
force bool,
reason, idempotencyKey string,
now time.Time,
) (*models.Order, error) {
if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0")
}
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"force": force,
"idempotency_key": idempotencyKey,
}).Info("services.order.admin.refund")
var out *models.Order
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 行锁锁住订单,避免并发退款/重复退款导致状态错乱。
var orderModel models.Order
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Items").
Where("tenant_id = ? AND id = ?", tenantID, orderID).
First(&orderModel).Error; err != nil {
return err
}
// 状态机:已退款直接幂等返回;仅允许已支付订单退款。
if orderModel.Status == consts.OrderStatusRefunded {
out = &orderModel
return nil
}
if orderModel.Status != consts.OrderStatusPaid {
return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款")
}
if orderModel.PaidAt.IsZero() {
return errorx.ErrPreconditionFailed.WithMsg("订单缺少 paid_at无法退款")
}
// 时间窗:默认 paid_at + 24hforce=true 可绕过。
if !force {
deadline := orderModel.PaidAt.Add(consts.DefaultOrderRefundWindow)
if now.After(deadline) {
return errorx.ErrPreconditionFailed.WithMsg("已超过默认退款时间窗")
}
}
amount := orderModel.AmountPaid
refundKey := fmt.Sprintf("refund:%d", orderModel.ID)
// 先退余额(账本入账),后更新订单状态与权益,确保退款可对账且可追溯。
if amount > 0 {
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
return err
}
}
// 退款对权益:立即回收 content_accessrevoked
for _, item := range orderModel.Items {
if item == nil {
continue
}
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, orderModel.UserID, item.ContentID).
Updates(map[string]any{
"status": consts.ContentAccessStatusRevoked,
"revoked_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
}
// 最后更新订单退款字段,保证退款后的最终状态一致。
if err := tx.Table(models.TableNameOrder).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusRefunded,
"refunded_at": now,
"refund_forced": force,
"refund_operator_user_id": operatorUserID,
"refund_reason": reason,
"updated_at": now,
}).Error; err != nil {
return err
}
orderModel.Status = consts.OrderStatusRefunded
orderModel.RefundedAt = now
orderModel.RefundForced = force
orderModel.RefundOperatorUserID = operatorUserID
orderModel.RefundReason = reason
orderModel.UpdatedAt = now
out = &orderModel
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"force": force,
"idempotency_key": idempotencyKey,
}).WithError(err).Warn("services.order.admin.refund.failed")
return nil, err
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"order_id": orderID,
"status": out.Status,
"refund_forced": out.RefundForced,
}).Info("services.order.admin.refund.ok")
return out, nil
}
func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) {
if params == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("params is required")
}
if params.TenantID <= 0 || params.UserID <= 0 || params.ContentID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/content_id must be > 0")
}
now := params.Now
if now.IsZero() {
now = time.Now()
}
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"idempotency_key": params.IdempotencyKey,
}).Info("services.order.purchase_content")
var out PurchaseContentResult
// 幂等购买采用“三段式”流程,保证一致性:
// 1) 先独立事务冻结余额(预留资金);
// 2) 再用单事务写订单+扣款+授予权益;
// 3) 若第 2 步失败,则解冻并写入回滚标记,保证重试稳定返回“失败+已回滚”。
if params.IdempotencyKey != "" {
freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey)
debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey)
rollbackKey := fmt.Sprintf("%s:rollback", params.IdempotencyKey)
// 1) 若该幂等键已生成订单,则直接返回订单与权益(幂等命中)。
{
tbl, query := models.OrderQuery.QueryContext(ctx)
existing, err := query.Preload(tbl.Items).Where(
tbl.TenantID.Eq(params.TenantID),
tbl.UserID.Eq(params.UserID),
tbl.IdempotencyKey.Eq(params.IdempotencyKey),
).First()
if err == nil {
out.Order = existing
if len(existing.Items) > 0 {
out.OrderItem = existing.Items[0]
}
out.AmountPaid = existing.AmountPaid
if out.OrderItem != nil {
aTbl, aQuery := models.ContentAccessQuery.QueryContext(ctx)
access, err := aQuery.Where(
aTbl.TenantID.Eq(params.TenantID),
aTbl.UserID.Eq(params.UserID),
aTbl.ContentID.Eq(out.OrderItem.ContentID),
).First()
if err == nil {
out.Access = access
}
}
return &out, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
// 2) 若历史已回滚过该幂等请求,则稳定返回“失败+已回滚”(避免重复冻结/重复扣款)。
{
tbl, query := models.TenantLedgerQuery.QueryContext(ctx)
_, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.UserID.Eq(params.UserID),
tbl.IdempotencyKey.Eq(rollbackKey),
).First()
if err == nil {
return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
// 查询内容与价格:放在事务外简化逻辑;后续以订单事务为准。
var content models.Content
{
tbl, query := models.ContentQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.ID.Eq(params.ContentID),
tbl.DeletedAt.IsNull(),
).First()
if err != nil {
return nil, err
}
content = *m
}
if content.Status != consts.ContentStatusPublished {
return nil, errorx.ErrPreconditionFailed.WithMsg("content not published")
}
// 作者自购:直接授予权益(不走余额冻结/扣款)。
if content.UserID == params.UserID {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, 0, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&access).Error; err != nil {
return err
}
out.AmountPaid = 0
out.Access = &access
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
priceAmount := int64(0)
var price models.ContentPrice
{
tbl, query := models.ContentPriceQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(params.TenantID),
tbl.ContentID.Eq(params.ContentID),
).First()
if err == nil {
price = *m
priceAmount = m.PriceAmount
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
amountPaid := s.computeFinalPrice(priceAmount, &price, now)
out.AmountPaid = amountPaid
discountType := price.DiscountType
if discountType == "" {
discountType = consts.DiscountTypeNone
}
var discountStartAt *time.Time
if !price.DiscountStartAt.IsZero() {
t := price.DiscountStartAt
discountStartAt = &t
}
var discountEndAt *time.Time
if !price.DiscountEndAt.IsZero() {
t := price.DiscountEndAt
discountEndAt = &t
}
purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
ContentVisibility: content.Visibility,
PreviewSeconds: content.PreviewSeconds,
PreviewDownloadable: content.PreviewDownloadable,
Currency: consts.CurrencyCNY,
PriceAmount: priceAmount,
DiscountType: discountType,
DiscountValue: price.DiscountValue,
DiscountStartAt: discountStartAt,
DiscountEndAt: discountEndAt,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
PurchaseAt: now,
PurchaseIdempotency: params.IdempotencyKey,
})
itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
})
// 免费内容:无需冻结,保持单事务写订单+权益。
if amountPaid == 0 {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: purchaseSnapshot,
IdempotencyKey: params.IdempotencyKey,
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
})
if err != nil {
return nil, pkgerrors.Wrap(err, "purchase content failed")
}
return &out, nil
}
// 3) 独立事务冻结余额:便于后续在订单事务失败时做补偿解冻。
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now)
return err
}); err != nil {
return nil, pkgerrors.Wrap(err, "purchase freeze failed")
}
// 4) 单事务完成:落订单 → 账本扣款(消耗冻结)→ 更新订单 paid → 授予权益。
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusCreated,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: purchaseSnapshot,
IdempotencyKey: params.IdempotencyKey,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil {
return err
}
if err := tx.Model(&models.Order{}).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusPaid,
"paid_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
// 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。
orderModel.Status = consts.OrderStatusPaid
orderModel.PaidAt = now
orderModel.UpdatedAt = now
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
}); err != nil {
// 5) 补偿:订单事务失败时,必须解冻,并写入回滚标记,保证后续幂等重试稳定返回失败。
_ = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, e1 := s.ledger.UnfreezeTx(
ctx,
tx,
params.TenantID,
params.UserID,
0,
amountPaid,
rollbackKey,
"purchase rollback",
now,
)
return e1
})
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"idempotency_key": params.IdempotencyKey,
}).WithError(err).Warn("services.order.purchase_content.rollback")
return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚")
}
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"order_id": loID(out.Order),
"amount_paid": out.AmountPaid,
"idempotency_key": params.IdempotencyKey,
}).Info("services.order.purchase_content.ok")
return &out, nil
}
// 非幂等请求走“单事务”旧流程:冻结 + 落单 + 扣款 + 授权全部在一个事务内完成(失败整体回滚)。
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var content models.Content
if err := tx.
Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", params.TenantID, params.ContentID).
First(&content).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("content not found")
}
return err
}
if content.Status != consts.ContentStatusPublished {
return errorx.ErrPreconditionFailed.WithMsg("content not published")
}
var accessExisting models.ContentAccess
if err := tx.
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&accessExisting).Error; err == nil {
if accessExisting.Status == consts.ContentAccessStatusActive {
out.Access = &accessExisting
return nil
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var price models.ContentPrice
priceAmount := int64(0)
if err := tx.Where("tenant_id = ? AND content_id = ?", params.TenantID, params.ContentID).First(&price).Error; err == nil {
priceAmount = price.PriceAmount
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
amountPaid := s.computeFinalPrice(priceAmount, &price, now)
out.AmountPaid = amountPaid
discountType := price.DiscountType
if discountType == "" {
discountType = consts.DiscountTypeNone
}
var discountStartAt *time.Time
if !price.DiscountStartAt.IsZero() {
t := price.DiscountStartAt
discountStartAt = &t
}
var discountEndAt *time.Time
if !price.DiscountEndAt.IsZero() {
t := price.DiscountEndAt
discountEndAt = &t
}
purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
ContentVisibility: content.Visibility,
PreviewSeconds: content.PreviewSeconds,
PreviewDownloadable: content.PreviewDownloadable,
Currency: consts.CurrencyCNY,
PriceAmount: priceAmount,
DiscountType: discountType,
DiscountValue: price.DiscountValue,
DiscountStartAt: discountStartAt,
DiscountEndAt: discountEndAt,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
PurchaseAt: now,
})
itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{
ContentID: content.ID,
ContentTitle: content.Title,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
})
if amountPaid == 0 {
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: purchaseSnapshot,
IdempotencyKey: "",
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
}
orderModel := &models.Order{
TenantID: params.TenantID,
UserID: params.UserID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusCreated,
Currency: consts.CurrencyCNY,
AmountOriginal: priceAmount,
AmountDiscount: priceAmount - amountPaid,
AmountPaid: amountPaid,
Snapshot: purchaseSnapshot,
IdempotencyKey: "",
CreatedAt: now,
UpdatedAt: now,
}
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil {
return err
}
if err := tx.Create(orderModel).Error; err != nil {
return err
}
item := &models.OrderItem{
TenantID: params.TenantID,
UserID: params.UserID,
OrderID: orderModel.ID,
ContentID: params.ContentID,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
Snapshot: itemSnapshot,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(item).Error; err != nil {
return err
}
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil {
return err
}
if err := tx.Model(&models.Order{}).
Where("id = ?", orderModel.ID).
Updates(map[string]any{
"status": consts.OrderStatusPaid,
"paid_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
// 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。
orderModel.Status = consts.OrderStatusPaid
orderModel.PaidAt = now
orderModel.UpdatedAt = now
if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil {
return err
}
var access models.ContentAccess
if err := tx.Table(models.TableNameContentAccess).
Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).
First(&access).Error; err != nil {
return err
}
out.Order = orderModel
out.OrderItem = item
out.Access = &access
return nil
})
if err != nil {
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"idempotency_key": params.IdempotencyKey,
}).WithError(err).Warn("services.order.purchase_content.failed")
return nil, pkgerrors.Wrap(err, "purchase content failed")
}
logrus.WithFields(logrus.Fields{
"tenant_id": params.TenantID,
"user_id": params.UserID,
"content_id": params.ContentID,
"order_id": loID(out.Order),
"amount_paid": out.AmountPaid,
"idempotency_key": params.IdempotencyKey,
}).Info("services.order.purchase_content.ok")
return &out, nil
}
func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice, now time.Time) int64 {
// 价格计算:按折扣策略与生效时间窗口计算最终实付金额(单位:分)。
if priceAmount <= 0 || price == nil {
return 0
}
discountType := price.DiscountType
if discountType == "" {
discountType = consts.DiscountTypeNone
}
if !price.DiscountStartAt.IsZero() && now.Before(price.DiscountStartAt) {
return priceAmount
}
if !price.DiscountEndAt.IsZero() && now.After(price.DiscountEndAt) {
return priceAmount
}
switch discountType {
case consts.DiscountTypePercent:
percent := price.DiscountValue
if percent <= 0 {
return priceAmount
}
if percent >= 100 {
return 0
}
return priceAmount * (100 - percent) / 100
case consts.DiscountTypeAmount:
amount := price.DiscountValue
if amount <= 0 {
return priceAmount
}
if amount >= priceAmount {
return 0
}
return priceAmount - amount
default:
return priceAmount
}
}
func (s *order) grantAccess(
ctx context.Context,
tx *gorm.DB,
tenantID, userID, contentID, orderID int64,
now time.Time,
) error {
// 权益写入策略:按 (tenant_id,user_id,content_id) upsert确保重复购买/重试时权益最终为 active。
insert := map[string]any{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
"order_id": orderID,
"status": consts.ContentAccessStatusActive,
"revoked_at": nil,
"created_at": now,
"updated_at": now,
}
if err := tx.Table(models.TableNameContentAccess).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "content_id"}},
DoUpdates: clause.Assignments(map[string]any{
"order_id": orderID,
"status": consts.ContentAccessStatusActive,
"revoked_at": nil,
"updated_at": now,
}),
}).
Create(insert).Error; err != nil {
return err
}
return nil
}
func loID(m *models.Order) int64 {
if m == nil {
return 0
}
return m.ID
}