Files
quyun-v2/backend/app/services/order.go
2026-01-08 09:57:04 +08:00

422 lines
12 KiB
Go

package services
import (
"context"
"errors"
"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/types"
"gorm.io/gorm"
)
// @provider
type order struct{}
func (s *order) ListUserOrders(ctx context.Context, userID int64, status string) ([]user_dto.Order, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
tbl, q := models.OrderQuery.QueryContext(ctx)
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)
}
var data []user_dto.Order
for _, v := range list {
dto, _ := s.composeOrderDTO(ctx, v)
data = append(data, dto)
}
return data, nil
}
func (s *order) GetUserOrder(ctx context.Context, userID int64, id int64) (*user_dto.Order, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
tbl, q := models.OrderQuery.QueryContext(ctx)
item, err := q.Where(tbl.ID.Eq(id), tbl.UserID.Eq(uid)).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,
userID int64,
form *transaction_dto.OrderCreateForm,
) (*transaction_dto.OrderCreateResponse, error) {
if userID == 0 {
return nil, errorx.ErrUnauthorized
}
uid := userID
cid := form.ContentID
// 1. Fetch Content & Price
content, err := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).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, 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: uuid.NewString(),
Snapshot: types.NewJSONType(fields.OrdersSnapshot{}),
}
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, 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,
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 o.Status != consts.OrderStatusCreated {
return nil, errorx.ErrStatusConflict.WithMsg("订单状态不可支付")
}
if form.Method == "balance" {
return s.payWithBalance(ctx, o)
}
// External payment (mock) - normally returns URL/params
return &transaction_dto.OrderPayResponse{
PayParams: "mock_pay_params",
}, nil
}
// ProcessExternalPayment handles callback from payment gateway
func (s *order) ProcessExternalPayment(ctx context.Context, 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 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 := 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()
// snapshot := o.Snapshot // Preserve existing snapshot or update it with external ID
// TODO: Update snapshot with payment info
_, 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)
_, 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: 0, // TODO
BalanceAfter: 0, // TODO
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.UserID, "order", "支付成功", "订单已支付,您可以查看已购内容。")
if tenantOwnerID > 0 {
_ = Notification.Send(ctx, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。")
}
}
return nil
}
func (s *order) Status(ctx context.Context, 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)
}
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) 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),
}
}