537 lines
14 KiB
Go
537 lines
14 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)
|
||
}
|
||
|
||
data, err := s.composeOrderListDTO(ctx, list)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
func (s *order) GetUserOrder(ctx context.Context, userID, 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) 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),
|
||
}
|
||
}
|