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, status string) ([]user_dto.Order, error) { userID := ctx.Value(consts.CtxKeyUser) if userID == nil { return nil, errorx.ErrUnauthorized } uid := cast.ToInt64(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, id string) (*user_dto.Order, error) { userID := ctx.Value(consts.CtxKeyUser) if userID == nil { return nil, errorx.ErrUnauthorized } uid := cast.ToInt64(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, form *transaction_dto.OrderCreateForm) (*transaction_dto.OrderCreateResponse, error) { userID := ctx.Value(consts.CtxKeyUser) if userID == nil { return nil, errorx.ErrUnauthorized } uid := cast.ToInt64(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 { // If price missing, treat as error? Or maybe 0? // Better to require price record. return nil, errorx.ErrDataCorrupted.WithCause(err).WithMsg("价格信息缺失") } // 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: price.PriceAmount, AmountDiscount: 0, AmountPaid: price.PriceAmount, IdempotencyKey: uuid.NewString(), Snapshot: types.NewJSONType(fields.OrdersSnapshot{}), } if err := models.OrderQuery.WithContext(ctx).Create(order); err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } // 3. Create Order Item item := &models.OrderItem{ TenantID: content.TenantID, UserID: uid, OrderID: order.ID, ContentID: cid, ContentUserID: content.UserID, AmountPaid: order.AmountPaid, } if err := models.OrderItemQuery.WithContext(ctx).Create(item); err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } return &transaction_dto.OrderCreateResponse{ OrderID: cast.ToString(order.ID), }, nil } func (s *order) Pay(ctx context.Context, id string, form *transaction_dto.OrderPayForm) (*transaction_dto.OrderPayResponse, error) { userID := ctx.Value(consts.CtxKeyUser) if userID == nil { return nil, errorx.ErrUnauthorized } uid := cast.ToInt64(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. Deduct User Balance (Only for balance method) if method == "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) 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), Status: string(o.Status), 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), } }