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/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(tbl.UserID.Eq(uid), tbl.TenantID.Eq(tenantID)). Or(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(tbl.ID.Eq(id), tbl.UserID.Eq(uid), tbl.TenantID.Eq(tenantID)). Or(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, 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, 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("订单状态不可支付") } 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, 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() // 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.TenantID, o.UserID, "order", "支付成功", "订单已支付,您可以查看已购内容。") if tenantOwnerID > 0 { _ = Notification.Send(ctx, o.TenantID, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。") } } return nil } func (s *order) Status(ctx context.Context, tenantID, 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 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), } }