417 lines
11 KiB
Go
417 lines
11 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"
|
|
"github.com/spf13/cast"
|
|
"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 string) (*user_dto.Order, error) {
|
|
if userID == 0 {
|
|
return nil, errorx.ErrUnauthorized
|
|
}
|
|
uid := userID
|
|
oid := cast.ToInt64(id)
|
|
|
|
tbl, q := models.OrderQuery.QueryContext(ctx)
|
|
item, err := q.Where(tbl.ID.Eq(oid), 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 := cast.ToInt64(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 != "" {
|
|
ucid := cast.ToInt64(form.UserCouponID)
|
|
discount, err := Coupon.Validate(ctx, uid, ucid, amountOriginal)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
amountDiscount = discount
|
|
|
|
uc, err := models.UserCouponQuery.WithContext(ctx).Where(models.UserCouponQuery.ID.Eq(ucid)).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 != "" {
|
|
if err := Coupon.MarkUsed(ctx, tx, cast.ToInt64(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: cast.ToString(order.ID),
|
|
}, nil
|
|
}
|
|
|
|
func (s *order) Pay(
|
|
ctx context.Context,
|
|
userID int64,
|
|
id string,
|
|
form *transaction_dto.OrderPayForm,
|
|
) (*transaction_dto.OrderPayResponse, error) {
|
|
if userID == 0 {
|
|
return nil, errorx.ErrUnauthorized
|
|
}
|
|
uid := userID
|
|
oid := cast.ToInt64(id)
|
|
|
|
// Fetch Order
|
|
o, err := models.OrderQuery.WithContext(ctx).
|
|
Where(models.OrderQuery.ID.Eq(oid), 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, externalID string) error {
|
|
oid := cast.ToInt64(orderID)
|
|
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).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 string) (*transaction_dto.OrderStatusResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *order) composeOrderDTO(ctx context.Context, o *models.Order) (user_dto.Order, error) {
|
|
dto := user_dto.Order{
|
|
ID: cast.ToString(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: cast.ToString(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: cast.ToString(c.ID),
|
|
Title: c.Title,
|
|
Genre: c.Genre,
|
|
AuthorID: cast.ToString(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: cast.ToString(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),
|
|
}
|
|
}
|