Files
quyun-v2/backend/app/services/order.go

614 lines
17 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 (
"context"
"errors"
"strings"
"time"
"quyun/v2/app/errorx"
transaction_dto "quyun/v2/app/http/v1/dto"
user_dto "quyun/v2/app/http/v1/dto"
"quyun/v2/database/fields"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/google/uuid"
"go.ipao.vip/gen/field"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type order struct{}
func (s *order) ListUserOrders(ctx context.Context, tenantID, userID int64, status string) ([]user_dto.Order, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
tbl, q := models.OrderQuery.QueryContext(ctx)
if tenantID > 0 {
q = q.Where(field.Or(
field.And(tbl.UserID.Eq(uid), tbl.TenantID.Eq(tenantID)),
field.And(tbl.UserID.Eq(uid), tbl.Type.Eq(consts.OrderTypeRecharge)),
))
} else {
q = q.Where(tbl.UserID.Eq(uid))
}
if status != "" && status != "all" {
q = q.Where(tbl.Status.Eq(consts.OrderStatus(status)))
}
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
data, err := s.composeOrderListDTO(ctx, list)
if err != nil {
return nil, err
}
return data, nil
}
func (s *order) GetUserOrder(ctx context.Context, tenantID, userID, id int64) (*user_dto.Order, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
tbl, q := models.OrderQuery.QueryContext(ctx)
itemQuery := q
if tenantID > 0 {
itemQuery = itemQuery.Where(field.Or(
field.And(tbl.ID.Eq(id), tbl.UserID.Eq(uid), tbl.TenantID.Eq(tenantID)),
field.And(tbl.ID.Eq(id), tbl.UserID.Eq(uid), tbl.Type.Eq(consts.OrderTypeRecharge)),
))
} else {
itemQuery = itemQuery.Where(tbl.ID.Eq(id), tbl.UserID.Eq(uid))
}
item, err := itemQuery.First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
dto, err := s.composeOrderDTO(ctx, item)
if err != nil {
return nil, err
}
return &dto, nil
}
func (s *order) Create(
ctx context.Context,
tenantID int64,
userID int64,
form *transaction_dto.OrderCreateForm,
) (*transaction_dto.OrderCreateResponse, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
cid := form.ContentID
// 幂等控制:相同幂等键直接返回已创建的订单。
idempotencyKey := ""
if form.IdempotencyKey != nil {
idempotencyKey = strings.TrimSpace(*form.IdempotencyKey)
}
if idempotencyKey != "" {
tbl, q := models.OrderQuery.QueryContext(ctx)
q = q.Where(tbl.UserID.Eq(uid), tbl.IdempotencyKey.Eq(idempotencyKey))
if tenantID > 0 {
q = q.Where(tbl.TenantID.Eq(tenantID))
}
existing, err := q.First()
if err == nil {
return &transaction_dto.OrderCreateResponse{OrderID: existing.ID}, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
}
// 1. Fetch Content & Price
contentQuery := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid))
if tenantID > 0 {
contentQuery = contentQuery.Where(models.ContentQuery.TenantID.Eq(tenantID))
}
content, err := contentQuery.First()
if err != nil {
return nil, errorx.ErrRecordNotFound.WithMsg("内容不存在")
}
if content.Status != consts.ContentStatusPublished {
return nil, errorx.ErrBusinessLogic.WithMsg("内容未发布")
}
price, err := models.ContentPriceQuery.WithContext(ctx).Where(models.ContentPriceQuery.ContentID.Eq(cid)).First()
if err != nil {
return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失")
}
amountOriginal := price.PriceAmount
var amountDiscount int64 = 0
var couponID int64 = 0
// Validate Coupon
if form.UserCouponID > 0 {
discount, err := Coupon.Validate(ctx, tenantID, uid, form.UserCouponID, amountOriginal)
if err != nil {
return nil, err
}
amountDiscount = discount
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(form.UserCouponID)).First()
if err == nil {
couponID = uc.CouponID
}
}
amountPaid := amountOriginal - amountDiscount
// 2. Create Order (Status: Created)
order := &models.Order{
TenantID: content.TenantID,
UserID: uid,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusCreated,
Currency: consts.Currency(price.Currency),
AmountOriginal: amountOriginal,
AmountDiscount: amountDiscount,
AmountPaid: amountPaid,
CouponID: couponID,
IdempotencyKey: idempotencyKey,
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}),
}
if order.IdempotencyKey == "" {
order.IdempotencyKey = uuid.NewString()
}
err = models.Q.Transaction(func(tx *models.Query) error {
if err := tx.Order.WithContext(ctx).Create(order); err != nil {
return err
}
// 3. Create Order Item
item := &models.OrderItem{
TenantID: content.TenantID,
UserID: uid,
OrderID: order.ID,
ContentID: cid,
ContentUserID: content.UserID,
AmountPaid: amountPaid,
}
if err := tx.OrderItem.WithContext(ctx).Create(item); err != nil {
return err
}
// Mark Coupon Used
if form.UserCouponID > 0 {
if err := Coupon.MarkUsed(ctx, tx, tenantID, form.UserCouponID, order.ID); err != nil {
return err
}
}
return nil
})
if err != nil {
if _, ok := err.(*errorx.AppError); ok {
return nil, err
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return &transaction_dto.OrderCreateResponse{
OrderID: order.ID,
}, nil
}
func (s *order) Pay(
ctx context.Context,
tenantID int64,
userID int64,
id int64,
form *transaction_dto.OrderPayForm,
) (*transaction_dto.OrderPayResponse, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
// Fetch Order
o, err := models.OrderQuery.WithContext(ctx).
Where(models.OrderQuery.ID.Eq(id), models.OrderQuery.UserID.Eq(uid)).
First()
if err != nil {
return nil, errorx.ErrRecordNotFound
}
if tenantID > 0 && o.TenantID > 0 && o.TenantID != tenantID {
return nil, errorx.ErrForbidden.WithMsg("租户不匹配")
}
if o.Status != consts.OrderStatusCreated {
return nil, errorx.ErrStatusConflict.WithMsg("订单状态不可支付")
}
switch form.Method {
case "balance":
return s.payWithBalance(ctx, o)
case "alipay", "external":
// mock external: 标记已支付,避免前端卡住
if err := s.settleOrder(ctx, o, "external", ""); err != nil {
if _, ok := err.(*errorx.AppError); ok {
return nil, err
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return &transaction_dto.OrderPayResponse{PayParams: "mock_pay_params"}, nil
default:
return nil, errorx.ErrBadRequest.WithMsg("unsupported payment method")
}
}
// ProcessExternalPayment handles callback from payment gateway
func (s *order) ProcessExternalPayment(ctx context.Context, tenantID, orderID int64, externalID string) error {
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(orderID)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
if tenantID > 0 && o.TenantID > 0 && o.TenantID != tenantID {
return errorx.ErrForbidden.WithMsg("租户不匹配")
}
if o.Status != consts.OrderStatusCreated {
return nil // Already processed idempotency
}
return s.settleOrder(ctx, o, "external", externalID)
}
func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transaction_dto.OrderPayResponse, error) {
err := s.settleOrder(ctx, o, "balance", "")
if err != nil {
if _, ok := err.(*errorx.AppError); ok {
return nil, err
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
return &transaction_dto.OrderPayResponse{
PayParams: "balance_paid",
}, nil
}
func (s *order) settleOrder(ctx context.Context, o *models.Order, method, externalID string) error {
var tenantOwnerID int64
// 关键结算事务:遇到数据库冲突/死锁时短暂退避重试,避免支付状态卡死。
err := retryCriticalWrite(ctx, func() error {
tenantOwnerID = 0
return models.Q.Transaction(func(tx *models.Query) error {
// 1. Handle Balance Updates
if o.Type == consts.OrderTypeRecharge {
// Income: Recharge (Credit User Balance)
_, err := tx.User.WithContext(ctx).
Where(tx.User.ID.Eq(o.UserID)).
Update(tx.User.Balance, gorm.Expr("balance + ?", o.AmountPaid))
if err != nil {
return err
}
} else if method == "balance" {
// Expense: Purchase with Balance (Deduct User Balance)
info, err := tx.User.WithContext(ctx).
Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)).
Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid))
if err != nil {
return err
}
if info.RowsAffected == 0 {
return errorx.ErrQuotaExceeded.WithMsg("余额不足")
}
}
// 2. Update Order Status
now := time.Now()
_, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(o.ID)).Updates(&models.Order{
Status: consts.OrderStatusPaid,
PaidAt: now,
UpdatedAt: now,
})
if err != nil {
return err
}
// 3. Grant Content Access
items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(o.ID)).Find()
for _, item := range items {
// Check if access already exists (idempotency)
exists, _ := tx.ContentAccess.WithContext(ctx).
Where(tx.ContentAccess.UserID.Eq(o.UserID), tx.ContentAccess.ContentID.Eq(item.ContentID)).
Exists()
if exists {
continue
}
access := &models.ContentAccess{
TenantID: item.TenantID,
UserID: o.UserID,
ContentID: item.ContentID,
OrderID: o.ID,
Status: consts.ContentAccessStatusActive,
}
if err := tx.ContentAccess.WithContext(ctx).Save(access); err != nil {
return err
}
}
// 4. Create Tenant Ledger (Revenue) - Only for Content Purchase
if o.Type == consts.OrderTypeContentPurchase {
t, err := tx.Tenant.WithContext(ctx).Where(tx.Tenant.ID.Eq(o.TenantID)).First()
if err != nil {
return err
}
tenantOwnerID = t.UserID
// Calculate Commission
amount := o.AmountPaid
fee := int64(float64(amount) * 0.10)
creatorIncome := amount - fee
// Credit Tenant Owner Balance (Net Income) 并记录余额快照
owner, err := tx.User.WithContext(ctx).
Where(tx.User.ID.Eq(tenantOwnerID)).
First()
if err != nil {
return err
}
balanceBefore := owner.Balance
balanceAfter := balanceBefore + creatorIncome
_, err = tx.User.WithContext(ctx).
Where(tx.User.ID.Eq(tenantOwnerID)).
Update(tx.User.Balance, gorm.Expr("balance + ?", creatorIncome))
if err != nil {
return err
}
ledger := &models.TenantLedger{
TenantID: o.TenantID,
UserID: t.UserID, // Owner
OrderID: o.ID,
Type: consts.TenantLedgerTypeDebitPurchase, // Income from purchase
Amount: creatorIncome,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
FrozenBefore: 0,
FrozenAfter: 0,
IdempotencyKey: uuid.NewString(),
Remark: "内容销售收入 (扣除平台费)",
OperatorUserID: o.UserID,
}
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
return err
}
}
return nil
})
})
if err != nil {
return err
}
if Notification != nil {
_ = Notification.Send(ctx, o.TenantID, o.UserID, "order", "支付成功", "订单已支付,您可以查看已购内容。")
if tenantOwnerID > 0 {
_ = Notification.Send(ctx, o.TenantID, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。")
}
}
return nil
}
func (s *order) Status(ctx context.Context, tenantID, userID, id int64) (*transaction_dto.OrderStatusResponse, error) {
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if userID > 0 && o.UserID != userID {
return nil, errorx.ErrForbidden.WithMsg("无权访问该订单")
}
if tenantID > 0 && o.TenantID > 0 && o.TenantID != tenantID {
return nil, errorx.ErrForbidden.WithMsg("租户不匹配")
}
return &transaction_dto.OrderStatusResponse{
Status: string(o.Status),
}, nil
}
func (s *order) composeOrderDTO(ctx context.Context, o *models.Order) (user_dto.Order, error) {
dto := user_dto.Order{
ID: o.ID,
Type: string(o.Type),
TypeDescription: o.Type.Description(),
Status: string(o.Status),
StatusDescription: o.Status.Description(),
Amount: float64(o.AmountPaid) / 100.0,
CreateTime: o.CreatedAt.Format(time.RFC3339),
TenantID: o.TenantID,
}
// Fetch Tenant Name
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(o.TenantID)).First()
if err == nil {
dto.TenantName = t.Name
}
// Fetch Items
items, err := models.OrderItemQuery.WithContext(ctx).Where(models.OrderItemQuery.OrderID.Eq(o.ID)).Find()
if err == nil {
dto.Quantity = len(items)
for _, item := range items {
// Fetch Content
var c models.Content
err := models.ContentQuery.WithContext(ctx).
Where(models.ContentQuery.ID.Eq(item.ContentID)).
UnderlyingDB().
Preload("ContentAssets").
Preload("ContentAssets.Asset").
First(&c).Error
if err == nil {
ci := transaction_dto.ContentItem{
ID: c.ID,
Title: c.Title,
Genre: c.Genre,
AuthorID: c.UserID,
Price: float64(item.AmountPaid) / 100.0, // Use actual paid amount
}
// Cover logic (simplified from content service)
for _, asset := range c.ContentAssets {
if asset.Role == consts.ContentAssetRoleCover && asset.Asset != nil {
ci.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
break
}
}
dto.Items = append(dto.Items, ci)
}
}
}
return dto, nil
}
func (s *order) composeOrderListDTO(ctx context.Context, orders []*models.Order) ([]user_dto.Order, error) {
if len(orders) == 0 {
return []user_dto.Order{}, nil
}
// 批量收集订单、租户、内容 ID避免逐条查询。
orderIDs := make([]int64, 0, len(orders))
tenantIDSet := make(map[int64]struct{}, len(orders))
for _, o := range orders {
orderIDs = append(orderIDs, o.ID)
if o.TenantID > 0 {
tenantIDSet[o.TenantID] = struct{}{}
}
}
tenantIDs := make([]int64, 0, len(tenantIDSet))
for id := range tenantIDSet {
tenantIDs = append(tenantIDs, id)
}
tenantMap := make(map[int64]*models.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
tbl, q := models.TenantQuery.QueryContext(ctx)
list, err := q.Where(tbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, t := range list {
tenantMap[t.ID] = t
}
}
itemsByOrder := make(map[int64][]*models.OrderItem, len(orderIDs))
contentIDSet := make(map[int64]struct{}, len(orderIDs))
if len(orderIDs) > 0 {
tbl, q := models.OrderItemQuery.QueryContext(ctx)
items, err := q.Where(tbl.OrderID.In(orderIDs...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, item := range items {
itemsByOrder[item.OrderID] = append(itemsByOrder[item.OrderID], item)
if item.ContentID > 0 {
contentIDSet[item.ContentID] = struct{}{}
}
}
}
contentIDs := make([]int64, 0, len(contentIDSet))
for id := range contentIDSet {
contentIDs = append(contentIDs, id)
}
contentMap := make(map[int64]*models.Content, len(contentIDs))
if len(contentIDs) > 0 {
var contents []*models.Content
tbl, q := models.ContentQuery.QueryContext(ctx)
err := q.Where(tbl.ID.In(contentIDs...)).
UnderlyingDB().
Preload("ContentAssets").
Preload("ContentAssets.Asset").
Find(&contents).Error
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, c := range contents {
contentMap[c.ID] = c
}
}
data := make([]user_dto.Order, 0, len(orders))
for _, o := range orders {
dto := user_dto.Order{
ID: o.ID,
Type: string(o.Type),
TypeDescription: o.Type.Description(),
Status: string(o.Status),
StatusDescription: o.Status.Description(),
Amount: float64(o.AmountPaid) / 100.0,
CreateTime: o.CreatedAt.Format(time.RFC3339),
TenantID: o.TenantID,
}
if t, ok := tenantMap[o.TenantID]; ok {
dto.TenantName = t.Name
}
items := itemsByOrder[o.ID]
dto.Quantity = len(items)
for _, item := range items {
c := contentMap[item.ContentID]
if c == nil {
continue
}
ci := transaction_dto.ContentItem{
ID: c.ID,
Title: c.Title,
Genre: c.Genre,
AuthorID: c.UserID,
Price: float64(item.AmountPaid) / 100.0,
}
for _, asset := range c.ContentAssets {
if asset.Role == consts.ContentAssetRoleCover && asset.Asset != nil {
ci.Cover = Common.GetAssetURL(asset.Asset.ObjectKey)
break
}
}
dto.Items = append(dto.Items, ci)
}
data = append(data, dto)
}
return data, nil
}
func (s *order) toUserOrderDTO(o *models.Order) user_dto.Order {
return user_dto.Order{
ID: o.ID,
Status: string(o.Status), // Need cast for DTO string field if DTO field is string
Amount: float64(o.AmountPaid) / 100.0,
CreateTime: o.CreatedAt.Format(time.RFC3339),
}
}