Files
quyun-v2/backend/app/services/order.go
2025-12-19 09:11:28 +08:00

1430 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/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"
)
// 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
}
// PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。
type PurchaseOrderSnapshot struct {
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID用于审计与后续分成扩展
ContentUserID int64 `json:"content_user_id"`
// ContentVisibility 下单时的可见性快照。
ContentVisibility consts.ContentVisibility `json:"content_visibility"`
// PreviewSeconds 下单时的试看秒数快照。
PreviewSeconds int32 `json:"preview_seconds"`
// PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false
PreviewDownloadable bool `json:"preview_downloadable"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// PriceAmount 基础价格(分)。
PriceAmount int64 `json:"price_amount"`
// DiscountType 折扣类型none/percent/amount
DiscountType consts.DiscountType `json:"discount_type"`
// DiscountValue 折扣值percent=0-100amount=分)。
DiscountValue int64 `json:"discount_value"`
// DiscountStartAt 折扣开始时间(可选)。
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
// DiscountEndAt 折扣结束时间(可选)。
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
// AmountOriginal 原价金额(分)。
AmountOriginal int64 `json:"amount_original"`
// AmountDiscount 优惠金额(分)。
AmountDiscount int64 `json:"amount_discount"`
// AmountPaid 实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
// PurchaseAt 下单时间(逻辑时间)。
PurchaseAt time.Time `json:"purchase_at"`
// PurchaseIdempotency 幂等键(可选)。
PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"`
// PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。
PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"`
}
// OrderItemSnapshot 为“订单明细”的内容快照。
type OrderItemSnapshot struct {
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题快照。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID。
ContentUserID int64 `json:"content_user_id"`
// AmountPaid 该行实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
}
// TopupOrderSnapshot 为“后台充值订单”的快照(用于审计与追责)。
type TopupOrderSnapshot struct {
// OperatorUserID 充值操作人用户ID租户管理员
OperatorUserID int64 `json:"operator_user_id"`
// TargetUserID 充值目标用户ID租户成员
TargetUserID int64 `json:"target_user_id"`
// Amount 充值金额(分)。
Amount int64 `json:"amount"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// Reason 充值原因(可选,强烈建议填写用于审计)。
Reason string `json:"reason,omitempty"`
// IdempotencyKey 幂等键(可选)。
IdempotencyKey string `json:"idempotency_key,omitempty"`
// TopupAt 充值时间(逻辑时间)。
TopupAt time.Time `json:"topup_at"`
}
// 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
}
func marshalSnapshot(v any) types.JSON {
b, err := json.Marshal(v)
if err != nil {
return types.JSON([]byte("{}"))
}
if len(b) == 0 {
return types.JSON([]byte("{}"))
}
return types.JSON(b)
}
// 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 := marshalSnapshot(&TopupOrderSnapshot{
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 := marshalSnapshot(&PurchaseOrderSnapshot{
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 := marshalSnapshot(&OrderItemSnapshot{
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 := marshalSnapshot(&PurchaseOrderSnapshot{
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 := marshalSnapshot(&OrderItemSnapshot{
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
}