From 1da84f2af379b5ee98757158e840fc5decac2a82 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 13:12:26 +0800 Subject: [PATCH] feat: add TenantLedger model and query generation - Introduced TenantLedger model with fields for managing tenant transactions, including ID, TenantID, UserID, OrderID, transaction Type, Amount, and balance details. - Implemented CRUD operations for TenantLedger with methods for Create, Update, Delete, and Reload. - Generated query methods for TenantLedger to facilitate database interactions, including filtering, pagination, and aggregation functions. - Established relationships with Order model for foreign key references. --- backend/app/http/super/routes.gen.go | 2 +- backend/app/http/tenant/dto/order.go | 22 + backend/app/http/tenant/dto/order_admin.go | 36 + backend/app/http/tenant/dto/order_me.go | 14 + backend/app/http/tenant/order.go | 60 ++ backend/app/http/tenant/order_admin.go | 113 +++ backend/app/http/tenant/order_me.go | 63 ++ backend/app/http/tenant/provider.gen.go | 27 + backend/app/http/tenant/routes.gen.go | 52 +- backend/app/services/ledger.go | 217 +++++ backend/app/services/order.go | 612 ++++++++++++++ backend/app/services/provider.gen.go | 28 + backend/app/services/services.gen.go | 6 + backend/database/.transform.yaml | 33 + .../20251218120000_orders_ledgers.sql | 150 ++++ backend/database/models/content_access.gen.go | 18 +- .../models/content_access.query.gen.go | 18 +- backend/database/models/content_assets.gen.go | 18 +- .../models/content_assets.query.gen.go | 18 +- backend/database/models/content_prices.gen.go | 24 +- .../models/content_prices.query.gen.go | 24 +- backend/database/models/contents.gen.go | 26 +- backend/database/models/contents.query.gen.go | 26 +- backend/database/models/media_assets.gen.go | 24 +- .../database/models/media_assets.query.gen.go | 24 +- backend/database/models/order_items.gen.go | 63 ++ .../database/models/order_items.query.gen.go | 680 ++++++++++++++++ backend/database/models/orders.gen.go | 72 ++ backend/database/models/orders.query.gen.go | 618 ++++++++++++++ backend/database/models/query.gen.go | 24 + backend/database/models/tenant_ledgers.gen.go | 71 ++ .../models/tenant_ledgers.query.gen.go | 604 ++++++++++++++ backend/database/models/tenant_users.gen.go | 17 +- .../database/models/tenant_users.query.gen.go | 24 +- backend/database/models/users.gen.go | 2 +- backend/database/models/users.query.gen.go | 206 ++--- backend/docs/docs.go | 752 +++++++++++++++++- backend/docs/swagger.json | 752 +++++++++++++++++- backend/docs/swagger.yaml | 525 +++++++++++- backend/pkg/consts/consts.gen.go | 520 ++++++++++++ backend/pkg/consts/consts.go | 108 ++- backend/tests/tenant.http | 40 + 42 files changed, 6468 insertions(+), 265 deletions(-) create mode 100644 backend/app/http/tenant/dto/order.go create mode 100644 backend/app/http/tenant/dto/order_admin.go create mode 100644 backend/app/http/tenant/dto/order_me.go create mode 100644 backend/app/http/tenant/order.go create mode 100644 backend/app/http/tenant/order_admin.go create mode 100644 backend/app/http/tenant/order_me.go create mode 100644 backend/app/services/ledger.go create mode 100644 backend/app/services/order.go create mode 100644 backend/database/migrations/20251218120000_orders_ledgers.sql create mode 100644 backend/database/models/order_items.gen.go create mode 100644 backend/database/models/order_items.query.gen.go create mode 100644 backend/database/models/orders.gen.go create mode 100644 backend/database/models/orders.query.gen.go create mode 100644 backend/database/models/tenant_ledgers.gen.go create mode 100644 backend/database/models/tenant_ledgers.query.gen.go diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index ba692a9..a42ed14 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -18,7 +18,7 @@ import ( // Routes implements the HttpRoute contract and provides route registration // for all controllers in the super module. // -// @provider contracts.HttpRoute atom.GroupRoutes +// @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares diff --git a/backend/app/http/tenant/dto/order.go b/backend/app/http/tenant/dto/order.go new file mode 100644 index 0000000..a9115a3 --- /dev/null +++ b/backend/app/http/tenant/dto/order.go @@ -0,0 +1,22 @@ +package dto + +import "quyun/v2/database/models" + +// PurchaseContentForm defines the request body for purchasing a content using tenant balance. +type PurchaseContentForm struct { + // IdempotencyKey is used to ensure the purchase request is processed at most once. + // 建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。 + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// PurchaseContentResponse returns the order and granted access after a purchase. +type PurchaseContentResponse struct { + // Order is the created or existing order record (may be nil for owner/free-path without order). + Order *models.Order `json:"order,omitempty"` + // Item is the single order item of this purchase (current implementation is 1 order -> 1 content). + Item *models.OrderItem `json:"item,omitempty"` + // Access is the content access record after purchase grant. + Access *models.ContentAccess `json:"access,omitempty"` + // AmountPaid is the final paid amount in cents (CNY 分). + AmountPaid int64 `json:"amount_paid,omitempty"` +} diff --git a/backend/app/http/tenant/dto/order_admin.go b/backend/app/http/tenant/dto/order_admin.go new file mode 100644 index 0000000..96fb898 --- /dev/null +++ b/backend/app/http/tenant/dto/order_admin.go @@ -0,0 +1,36 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// AdminOrderListFilter defines query filters for tenant-admin order listing. +type AdminOrderListFilter struct { + // Pagination controls paging parameters (page/limit). + requests.Pagination `json:",inline" query:",inline"` + // UserID filters orders by buyer user id. + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + // Status filters orders by order status. + Status *consts.OrderStatus `json:"status,omitempty" query:"status"` +} + +// AdminOrderRefundForm defines payload for tenant-admin to refund an order. +type AdminOrderRefundForm struct { + // Force indicates bypassing the default refund window check (paid_at + 24h). + // 强制退款:true 表示绕过默认退款时间窗限制(需审计)。 + Force bool `json:"force,omitempty"` + // Reason is the human-readable refund reason used for audit. + // 退款原因:建议必填(由业务侧校验);用于审计与追责。 + Reason string `json:"reason,omitempty"` + // IdempotencyKey ensures refund request is processed at most once. + // 幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。 + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// AdminOrderDetail returns a tenant-admin order detail payload. +type AdminOrderDetail struct { + // Order is the order with items preloaded. + Order *models.Order `json:"order,omitempty"` +} diff --git a/backend/app/http/tenant/dto/order_me.go b/backend/app/http/tenant/dto/order_me.go new file mode 100644 index 0000000..e5528cc --- /dev/null +++ b/backend/app/http/tenant/dto/order_me.go @@ -0,0 +1,14 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// MyOrderListFilter defines query filters for listing current user's orders within a tenant. +type MyOrderListFilter struct { + // Pagination controls paging parameters (page/limit). + requests.Pagination `json:",inline" query:",inline"` + // Status filters orders by order status. + Status *consts.OrderStatus `json:"status,omitempty" query:"status"` +} diff --git a/backend/app/http/tenant/order.go b/backend/app/http/tenant/order.go new file mode 100644 index 0000000..f915b21 --- /dev/null +++ b/backend/app/http/tenant/order.go @@ -0,0 +1,60 @@ +package tenant + +import ( + "time" + + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// order provides tenant-side order endpoints for members (purchase and my orders). +// +// @provider +type order struct{} + +// purchaseContent +// +// @Summary 购买内容(余额支付) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param contentID path int64 true "ContentID" +// @Param form body dto.PurchaseContentForm true "Form" +// @Success 200 {object} dto.PurchaseContentResponse +// +// @Router /t/:tenantCode/v1/contents/:contentID/purchase [post] +// @Bind tenant local key(tenant) +// @Bind user local key(user) +// @Bind contentID path +// @Bind form body +func (*order) purchaseContent(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64, form *dto.PurchaseContentForm) (*dto.PurchaseContentResponse, error) { + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": user.ID, + "content_id": contentID, + "idempotency_key": form.IdempotencyKey, + }).Info("tenant.order.purchase_content") + + res, err := services.Order.PurchaseContent(ctx, &services.PurchaseContentParams{ + TenantID: tenant.ID, + UserID: user.ID, + ContentID: contentID, + IdempotencyKey: form.IdempotencyKey, + Now: time.Now(), + }) + if err != nil { + return nil, err + } + + return &dto.PurchaseContentResponse{ + Order: res.Order, + Item: res.OrderItem, + Access: res.Access, + AmountPaid: res.AmountPaid, + }, nil +} diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go new file mode 100644 index 0000000..678aeb8 --- /dev/null +++ b/backend/app/http/tenant/order_admin.go @@ -0,0 +1,113 @@ +package tenant + +import ( + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// orderAdmin provides tenant-admin order management endpoints. +// +// @provider +type orderAdmin struct{} + +// adminOrderList +// +// @Summary 订单列表(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminOrderListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=models.Order} +// +// @Router /t/:tenantCode/v1/admin/orders [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*orderAdmin) adminOrderList(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminOrderListFilter) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + }).Info("tenant.admin.orders.list") + + return services.Order.AdminOrderPage(ctx, tenant.ID, filter) +} + +// adminOrderDetail +// +// @Summary 订单详情(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param orderID path int64 true "OrderID" +// @Success 200 {object} dto.AdminOrderDetail +// +// @Router /t/:tenantCode/v1/admin/orders/:orderID [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind orderID path +func (*orderAdmin) adminOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, orderID int64) (*dto.AdminOrderDetail, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "order_id": orderID, + }).Info("tenant.admin.orders.detail") + + m, err := services.Order.AdminOrderDetail(ctx, tenant.ID, orderID) + if err != nil { + return nil, err + } + return &dto.AdminOrderDetail{Order: m}, nil +} + +// adminRefund +// +// @Summary 订单退款(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param orderID path int64 true "OrderID" +// @Param form body dto.AdminOrderRefundForm true "Form" +// @Success 200 {object} models.Order +// +// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind orderID path +// @Bind form body +func (*orderAdmin) adminRefund(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, orderID int64, form *dto.AdminOrderRefundForm) (*models.Order, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "order_id": orderID, + "force": form.Force, + "idempotency_key": form.IdempotencyKey, + }).Info("tenant.admin.orders.refund") + + return services.Order.AdminRefundOrder(ctx, tenant.ID, tenantUser.UserID, orderID, form.Force, form.Reason, form.IdempotencyKey, time.Now()) +} diff --git a/backend/app/http/tenant/order_me.go b/backend/app/http/tenant/order_me.go new file mode 100644 index 0000000..1716a1b --- /dev/null +++ b/backend/app/http/tenant/order_me.go @@ -0,0 +1,63 @@ +package tenant + +import ( + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// orderMe provides member order endpoints (my orders within a tenant). +// +// @provider +type orderMe struct{} + +// myOrders +// +// @Summary 我的订单列表(当前租户) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.MyOrderListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=models.Order} +// +// @Router /t/:tenantCode/v1/orders [get] +// @Bind tenant local key(tenant) +// @Bind user local key(user) +// @Bind filter query +func (*orderMe) myOrders(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.MyOrderListFilter) (*requests.Pager, error) { + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": user.ID, + }).Info("tenant.orders.me.list") + + return services.Order.MyOrderPage(ctx, tenant.ID, user.ID, filter) +} + +// myOrderDetail +// +// @Summary 我的订单详情(当前租户) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param orderID path int64 true "OrderID" +// @Success 200 {object} models.Order +// +// @Router /t/:tenantCode/v1/orders/:orderID [get] +// @Bind tenant local key(tenant) +// @Bind user local key(user) +// @Bind orderID path +func (*orderMe) myOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, orderID int64) (*models.Order, error) { + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": user.ID, + "order_id": orderID, + }).Info("tenant.orders.me.detail") + + return services.Order.MyOrderDetail(ctx, tenant.ID, user.ID, orderID) +} diff --git a/backend/app/http/tenant/provider.gen.go b/backend/app/http/tenant/provider.gen.go index 9673404..e4dcc2e 100755 --- a/backend/app/http/tenant/provider.gen.go +++ b/backend/app/http/tenant/provider.gen.go @@ -31,17 +31,44 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*order, error) { + obj := &order{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func() (*orderAdmin, error) { + obj := &orderAdmin{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func() (*orderMe, error) { + obj := &orderMe{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( content *content, contentAdmin *contentAdmin, me *me, middlewares *middlewares.Middlewares, + order *order, + orderAdmin *orderAdmin, + orderMe *orderMe, ) (contracts.HttpRoute, error) { obj := &Routes{ content: content, contentAdmin: contentAdmin, me: me, middlewares: middlewares, + order: order, + orderAdmin: orderAdmin, + orderMe: orderMe, } if err := obj.Prepare(); err != nil { return nil, err diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index f8b161e..255afde 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -19,7 +19,7 @@ import ( // Routes implements the HttpRoute contract and provides route registration // for all controllers in the tenant module. // -// @provider contracts.HttpRoute atom.GroupRoutes +// @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares @@ -27,6 +27,9 @@ type Routes struct { content *content contentAdmin *contentAdmin me *me + order *order + orderAdmin *orderAdmin + orderMe *orderMe } // Prepare initializes the routes provider with logging configuration. @@ -113,6 +116,53 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("user"), Local[*models.TenantUser]("tenant_user"), )) + // Register routes for controller: order + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/contents/:contentID/purchase -> order.purchaseContent") + router.Post("/t/:tenantCode/v1/contents/:contentID/purchase"[len(r.Path()):], DataFunc4( + r.order.purchaseContent, + Local[*models.Tenant]("tenant"), + Local[*models.User]("user"), + PathParam[int64]("contentID"), + Body[dto.PurchaseContentForm]("form"), + )) + // Register routes for controller: orderAdmin + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders -> orderAdmin.adminOrderList") + router.Get("/t/:tenantCode/v1/admin/orders"[len(r.Path()):], DataFunc3( + r.orderAdmin.adminOrderList, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminOrderListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/orders/:orderID -> orderAdmin.adminOrderDetail") + router.Get("/t/:tenantCode/v1/admin/orders/:orderID"[len(r.Path()):], DataFunc3( + r.orderAdmin.adminOrderDetail, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("orderID"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/orders/:orderID/refund -> orderAdmin.adminRefund") + router.Post("/t/:tenantCode/v1/admin/orders/:orderID/refund"[len(r.Path()):], DataFunc4( + r.orderAdmin.adminRefund, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("orderID"), + Body[dto.AdminOrderRefundForm]("form"), + )) + // Register routes for controller: orderMe + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders -> orderMe.myOrders") + router.Get("/t/:tenantCode/v1/orders"[len(r.Path()):], DataFunc3( + r.orderMe.myOrders, + Local[*models.Tenant]("tenant"), + Local[*models.User]("user"), + Query[dto.MyOrderListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders/:orderID -> orderMe.myOrderDetail") + router.Get("/t/:tenantCode/v1/orders/:orderID"[len(r.Path()):], DataFunc3( + r.orderMe.myOrderDetail, + Local[*models.Tenant]("tenant"), + Local[*models.User]("user"), + PathParam[int64]("orderID"), + )) r.log.Info("Successfully registered all routes") } diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go new file mode 100644 index 0000000..676c521 --- /dev/null +++ b/backend/app/services/ledger.go @@ -0,0 +1,217 @@ +package services + +import ( + "context" + "errors" + "time" + + "quyun/v2/app/errorx" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// LedgerApplyResult is the result of a single ledger application, including the created ledger record +// and the updated tenant user balance snapshot. +type LedgerApplyResult struct { + // Ledger is the created ledger record (or existing one if idempotent hit). + Ledger *models.TenantLedger + // TenantUser is the updated tenant user record reflecting the post-apply balances. + TenantUser *models.TenantUser +} + +// ledger provides tenant balance ledger operations (freeze/unfreeze/etc.) with idempotency and row-locking. +// +// @provider +type ledger struct { + db *gorm.DB +} + +// Freeze moves funds from available balance to frozen balance and records a tenant ledger entry. +func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now) +} + +// Unfreeze moves funds from frozen balance back to available balance and records a tenant ledger entry. +func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now) +} + +// FreezeTx is the transaction-scoped variant of Freeze. +func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now) +} + +// UnfreezeTx is the transaction-scoped variant of Unfreeze. +func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now) +} + +// DebitPurchaseTx turns frozen funds into a finalized debit (reduces frozen balance) and records a ledger entry. +func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now) +} + +// CreditRefundTx credits funds back to available balance and records a ledger entry. +func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { + return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now) +} + +func (s *ledger) apply( + ctx context.Context, + tx *gorm.DB, + tenantID, userID, orderID int64, + ledgerType consts.TenantLedgerType, + amount, deltaBalance, deltaFrozen int64, + idempotencyKey, remark string, + now time.Time, +) (*LedgerApplyResult, error) { + if amount <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0") + } + if now.IsZero() { + now = time.Now() + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "order_id": orderID, + "type": ledgerType, + "amount": amount, + "idempotency_key": idempotencyKey, + "delta_balance": deltaBalance, + "delta_frozen": deltaFrozen, + "remark_non_empty": remark != "", + }).Info("services.ledger.apply") + + var out LedgerApplyResult + + err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if idempotencyKey != "" { + var existing models.TenantLedger + if err := tx. + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). + First(&existing).Error; err == nil { + var current models.TenantUser + if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil { + return err + } + out.Ledger = &existing + out.TenantUser = ¤t + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + var tu models.TenantUser + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND user_id = ?", tenantID, userID). + First(&tu).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("tenant user not found") + } + return err + } + + if idempotencyKey != "" { + var existing models.TenantLedger + if err := tx. + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). + First(&existing).Error; err == nil { + out.Ledger = &existing + out.TenantUser = &tu + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + balanceBefore := tu.Balance + frozenBefore := tu.BalanceFrozen + balanceAfter := balanceBefore + deltaBalance + frozenAfter := frozenBefore + deltaFrozen + + if balanceAfter < 0 { + return errorx.ErrPreconditionFailed.WithMsg("余额不足") + } + if frozenAfter < 0 { + return errorx.ErrPreconditionFailed.WithMsg("冻结余额不足") + } + + if err := tx.Model(&models.TenantUser{}). + Where("id = ?", tu.ID). + Updates(map[string]any{ + "balance": balanceAfter, + "balance_frozen": frozenAfter, + "updated_at": now, + }).Error; err != nil { + return err + } + + ledger := &models.TenantLedger{ + TenantID: tenantID, + UserID: userID, + OrderID: orderID, + Type: ledgerType, + Amount: amount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceAfter, + FrozenBefore: frozenBefore, + FrozenAfter: frozenAfter, + IdempotencyKey: idempotencyKey, + Remark: remark, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(ledger).Error; err != nil { + if idempotencyKey != "" { + var existing models.TenantLedger + if e2 := tx. + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). + First(&existing).Error; e2 == nil { + out.Ledger = &existing + out.TenantUser = &tu + return nil + } + } + return err + } + + tu.Balance = balanceAfter + tu.BalanceFrozen = frozenAfter + tu.UpdatedAt = now + + out.Ledger = ledger + out.TenantUser = &tu + return nil + }) + if err != nil { + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "order_id": orderID, + "type": ledgerType, + "idempotency_key": idempotencyKey, + }).WithError(err).Warn("services.ledger.apply.failed") + return nil, err + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "order_id": orderID, + "type": ledgerType, + "ledger_id": out.Ledger.ID, + "idempotency_key": idempotencyKey, + "balance_after": out.TenantUser.Balance, + "frozen_after": out.TenantUser.BalanceFrozen, + }).Info("services.ledger.apply.ok") + + return &out, nil +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go new file mode 100644 index 0000000..ebbbc50 --- /dev/null +++ b/backend/app/services/order.go @@ -0,0 +1,612 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + pkgerrors "github.com/pkg/errors" + "github.com/samber/lo" + "github.com/sirupsen/logrus" + "go.ipao.vip/gen" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "go.ipao.vip/gen/types" +) + +// PurchaseContentParams defines parameters for purchasing a content within a tenant using tenant balance. +type PurchaseContentParams struct { + // TenantID is the tenant scope. + TenantID int64 + // UserID is the buyer user id. + UserID int64 + // ContentID is the target content id. + ContentID int64 + // IdempotencyKey is used to ensure a purchase request is processed at most once. + IdempotencyKey string + // Now is the logical time used for created_at/paid_at and ledger snapshots (optional). + Now time.Time +} + +// PurchaseContentResult is returned after purchase attempt (idempotent hit returns existing order/access state). +type PurchaseContentResult struct { + // Order is the created or existing order record (may be nil when already purchased without order context). + Order *models.Order + // OrderItem is the related order item record (single-item purchase). + OrderItem *models.OrderItem + // Access is the content access record after purchase grant. + Access *models.ContentAccess + // AmountPaid is the final paid amount in cents (CNY 分). + AmountPaid int64 +} + +// order provides order domain operations. +// +// @provider +type order struct { + db *gorm.DB + ledger *ledger +} + +// MyOrderPage lists orders for current user within a tenant. +func (s *order) MyOrderPage(ctx context.Context, tenantID, userID int64, filter *dto.MyOrderListFilter) (*requests.Pager, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") + } + if filter == nil { + filter = &dto.MyOrderListFilter{} + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "status": lo.FromPtr(filter.Status), + }).Info("services.order.me.page") + + filter.Pagination.Format() + + tbl, query := models.OrderQuery.QueryContext(ctx) + query = query.Preload(tbl.Items) + + conds := []gen.Condition{ + tbl.TenantID.Eq(tenantID), + tbl.UserID.Eq(userID), + } + if filter.Status != nil { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +// MyOrderDetail returns order detail for current user within a tenant. +func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int64) (*models.Order, error) { + if tenantID <= 0 || userID <= 0 || orderID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/order_id must be > 0") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "order_id": orderID, + }).Info("services.order.me.detail") + + tbl, query := models.OrderQuery.QueryContext(ctx) + m, err := query.Preload(tbl.Items).Where( + tbl.TenantID.Eq(tenantID), + tbl.UserID.Eq(userID), + tbl.ID.Eq(orderID), + ).First() + if err != nil { + return nil, err + } + return m, nil +} + +// AdminOrderPage lists orders within a tenant for tenant-admin. +func (s *order) AdminOrderPage(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + } + if filter == nil { + filter = &dto.AdminOrderListFilter{} + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": lo.FromPtr(filter.UserID), + "status": lo.FromPtr(filter.Status), + }).Info("services.order.admin.page") + + filter.Pagination.Format() + + tbl, query := models.OrderQuery.QueryContext(ctx) + query = query.Preload(tbl.Items) + + conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} + if filter.UserID != nil { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.Status != nil { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +// AdminOrderDetail returns an order detail within a tenant for tenant-admin. +func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (*models.Order, error) { + if tenantID <= 0 || orderID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "order_id": orderID, + }).Info("services.order.admin.detail") + + tbl, query := models.OrderQuery.QueryContext(ctx) + m, err := query.Preload(tbl.Items).Where(tbl.TenantID.Eq(tenantID), tbl.ID.Eq(orderID)).First() + if err != nil { + return nil, err + } + return m, nil +} + +// AdminRefundOrder refunds a paid order (supports forced refund) and revokes granted content access. +func (s *order) AdminRefundOrder(ctx context.Context, tenantID, operatorUserID, orderID int64, force bool, reason, idempotencyKey string, now time.Time) (*models.Order, error) { + if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0") + } + if now.IsZero() { + now = time.Now() + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "order_id": orderID, + "force": force, + "idempotency_key": idempotencyKey, + }).Info("services.order.admin.refund") + + var out *models.Order + + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var orderModel models.Order + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("Items"). + Where("tenant_id = ? AND id = ?", tenantID, orderID). + First(&orderModel).Error; err != nil { + return err + } + + if orderModel.Status == consts.OrderStatusRefunded { + out = &orderModel + return nil + } + if orderModel.Status != consts.OrderStatusPaid { + return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款") + } + if orderModel.PaidAt.IsZero() { + return errorx.ErrPreconditionFailed.WithMsg("订单缺少 paid_at,无法退款") + } + + if !force { + deadline := orderModel.PaidAt.Add(consts.DefaultOrderRefundWindow) + if now.After(deadline) { + return errorx.ErrPreconditionFailed.WithMsg("已超过默认退款时间窗") + } + } + + amount := orderModel.AmountPaid + refundKey := fmt.Sprintf("refund:%d", orderModel.ID) + + if amount > 0 { + if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil { + return err + } + } + + // revoke content access immediately + for _, item := range orderModel.Items { + if item == nil { + continue + } + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, orderModel.UserID, item.ContentID). + Updates(map[string]any{ + "status": consts.ContentAccessStatusRevoked, + "revoked_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + } + + if err := tx.Table(models.TableNameOrder). + Where("id = ?", orderModel.ID). + Updates(map[string]any{ + "status": consts.OrderStatusRefunded, + "refunded_at": now, + "refund_forced": force, + "refund_operator_user_id": operatorUserID, + "refund_reason": reason, + "updated_at": now, + }).Error; err != nil { + return err + } + + orderModel.Status = consts.OrderStatusRefunded + orderModel.RefundedAt = now + orderModel.RefundForced = force + orderModel.RefundOperatorUserID = operatorUserID + orderModel.RefundReason = reason + orderModel.UpdatedAt = now + + out = &orderModel + return nil + }) + if err != nil { + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "order_id": orderID, + "force": force, + "idempotency_key": idempotencyKey, + }).WithError(err).Warn("services.order.admin.refund.failed") + return nil, err + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "order_id": orderID, + "status": out.Status, + "refund_forced": out.RefundForced, + }).Info("services.order.admin.refund.ok") + + return out, nil +} + +func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) { + if params == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("params is required") + } + if params.TenantID <= 0 || params.UserID <= 0 || params.ContentID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/content_id must be > 0") + } + + now := params.Now + if now.IsZero() { + now = time.Now() + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": params.TenantID, + "user_id": params.UserID, + "content_id": params.ContentID, + "idempotency_key": params.IdempotencyKey, + }).Info("services.order.purchase_content") + + var out PurchaseContentResult + + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if params.IdempotencyKey != "" { + var existing models.Order + if err := tx. + Preload("Items"). + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", params.TenantID, params.UserID, params.IdempotencyKey). + First(&existing).Error; err == nil { + out.Order = &existing + if len(existing.Items) > 0 { + out.OrderItem = existing.Items[0] + } + if out.OrderItem != nil { + var access models.ContentAccess + if err := tx. + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, out.OrderItem.ContentID). + First(&access).Error; err == nil { + out.Access = &access + } + } + out.AmountPaid = existing.AmountPaid + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + var content models.Content + if err := tx. + Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", params.TenantID, params.ContentID). + First(&content).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("content not found") + } + return err + } + if content.Status != consts.ContentStatusPublished { + return errorx.ErrPreconditionFailed.WithMsg("content not published") + } + + if content.UserID == params.UserID { + out.AmountPaid = 0 + if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, 0, now); err != nil { + return err + } + var access models.ContentAccess + if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + return err + } + out.Access = &access + return nil + } + + var accessExisting models.ContentAccess + if err := tx. + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&accessExisting).Error; err == nil { + if accessExisting.Status == consts.ContentAccessStatusActive { + out.Access = &accessExisting + return nil + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var price models.ContentPrice + priceAmount := int64(0) + if err := tx.Where("tenant_id = ? AND content_id = ?", params.TenantID, params.ContentID).First(&price).Error; err == nil { + priceAmount = price.PriceAmount + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + amountPaid := s.computeFinalPrice(priceAmount, &price, now) + out.AmountPaid = amountPaid + + if amountPaid == 0 { + orderModel := &models.Order{ + TenantID: params.TenantID, + UserID: params.UserID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + Currency: consts.CurrencyCNY, + AmountOriginal: priceAmount, + AmountDiscount: priceAmount - amountPaid, + AmountPaid: amountPaid, + Snapshot: types.JSON([]byte("{}")), + IdempotencyKey: params.IdempotencyKey, + PaidAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(orderModel).Error; err != nil { + return err + } + item := &models.OrderItem{ + TenantID: params.TenantID, + UserID: params.UserID, + OrderID: orderModel.ID, + ContentID: params.ContentID, + ContentUserID: content.UserID, + AmountPaid: amountPaid, + Snapshot: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(item).Error; err != nil { + return err + } + if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { + return err + } + var access models.ContentAccess + if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + return err + } + out.Order = orderModel + out.OrderItem = item + out.Access = &access + return nil + } + + orderModel := &models.Order{ + TenantID: params.TenantID, + UserID: params.UserID, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusCreated, + Currency: consts.CurrencyCNY, + AmountOriginal: priceAmount, + AmountDiscount: priceAmount - amountPaid, + AmountPaid: amountPaid, + Snapshot: types.JSON([]byte("{}")), + IdempotencyKey: params.IdempotencyKey, + CreatedAt: now, + UpdatedAt: now, + } + + freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey) + if params.IdempotencyKey == "" { + freezeKey = "" + } + if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now); err != nil { + return err + } + + if err := tx.Create(orderModel).Error; err != nil { + return err + } + + item := &models.OrderItem{ + TenantID: params.TenantID, + UserID: params.UserID, + OrderID: orderModel.ID, + ContentID: params.ContentID, + ContentUserID: content.UserID, + AmountPaid: amountPaid, + Snapshot: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(item).Error; err != nil { + return err + } + + debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey) + if params.IdempotencyKey == "" { + debitKey = "" + } + if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil { + return err + } + + if err := tx.Model(&models.Order{}). + Where("id = ?", orderModel.ID). + Updates(map[string]any{ + "status": consts.OrderStatusPaid, + "paid_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + + if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { + return err + } + + var access models.ContentAccess + if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + return err + } + + out.Order = orderModel + out.OrderItem = item + out.Access = &access + return nil + }) + if err != nil { + logrus.WithFields(logrus.Fields{ + "tenant_id": params.TenantID, + "user_id": params.UserID, + "content_id": params.ContentID, + "idempotency_key": params.IdempotencyKey, + }).WithError(err).Warn("services.order.purchase_content.failed") + return nil, pkgerrors.Wrap(err, "purchase content failed") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": params.TenantID, + "user_id": params.UserID, + "content_id": params.ContentID, + "order_id": loID(out.Order), + "amount_paid": out.AmountPaid, + "idempotency_key": params.IdempotencyKey, + }).Info("services.order.purchase_content.ok") + + return &out, nil +} + +func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice, now time.Time) int64 { + if priceAmount <= 0 || price == nil { + return 0 + } + + discountType := price.DiscountType + if discountType == "" { + discountType = consts.DiscountTypeNone + } + + if !price.DiscountStartAt.IsZero() && now.Before(price.DiscountStartAt) { + return priceAmount + } + if !price.DiscountEndAt.IsZero() && now.After(price.DiscountEndAt) { + return priceAmount + } + + switch discountType { + case consts.DiscountTypePercent: + percent := price.DiscountValue + if percent <= 0 { + return priceAmount + } + if percent >= 100 { + return 0 + } + return priceAmount * (100 - percent) / 100 + case consts.DiscountTypeAmount: + amount := price.DiscountValue + if amount <= 0 { + return priceAmount + } + if amount >= priceAmount { + return 0 + } + return priceAmount - amount + default: + return priceAmount + } +} + +func (s *order) grantAccess(ctx context.Context, tx *gorm.DB, tenantID, userID, contentID, orderID int64, now time.Time) error { + insert := map[string]any{ + "tenant_id": tenantID, + "user_id": userID, + "content_id": contentID, + "order_id": orderID, + "status": consts.ContentAccessStatusActive, + "revoked_at": nil, + "created_at": now, + "updated_at": now, + } + + if err := tx.Table(models.TableNameContentAccess). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "content_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "order_id": orderID, + "status": consts.ContentAccessStatusActive, + "revoked_at": nil, + "updated_at": now, + }), + }). + Create(insert).Error; err != nil { + return err + } + return nil +} + +func loID(m *models.Order) int64 { + if m == nil { + return 0 + } + return m.ID +} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index f30e682..4f3ba2e 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -16,9 +16,35 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func( + db *gorm.DB, + ) (*ledger, error) { + obj := &ledger{ + db: db, + } + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func( + db *gorm.DB, + ledger *ledger, + ) (*order, error) { + obj := &order{ + db: db, + ledger: ledger, + } + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( content *content, db *gorm.DB, + ledger *ledger, + order *order, tenant *tenant, test *test, user *user, @@ -26,6 +52,8 @@ func Provide(opts ...opt.Option) error { obj := &services{ content: content, db: db, + ledger: ledger, + order: order, tenant: tenant, test: test, user: user, diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go index ca17eaf..3cfef1b 100644 --- a/backend/app/services/services.gen.go +++ b/backend/app/services/services.gen.go @@ -9,6 +9,8 @@ var _db *gorm.DB // exported CamelCase Services var ( Content *content + Ledger *ledger + Order *order Tenant *tenant Test *test User *user @@ -19,6 +21,8 @@ type services struct { db *gorm.DB // define Services content *content + ledger *ledger + order *order tenant *tenant test *test user *user @@ -29,6 +33,8 @@ func (svc *services) Prepare() error { // set exported Services here Content = svc.content + Ledger = svc.ledger + Order = svc.order Tenant = svc.tenant Test = svc.test User = svc.user diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index f62392f..78cc00c 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -32,6 +32,12 @@ field_type: discount_type: consts.DiscountType content_access: status: consts.ContentAccessStatus + orders: + type: consts.OrderType + status: consts.OrderStatus + currency: consts.Currency + tenant_ledgers: + type: consts.TenantLedgerType field_relate: users: OwnedTenant: @@ -55,3 +61,30 @@ field_relate: json: users join_foreign_key: tenant_id join_references: user_id + orders: + Items: + relation: has_many + table: order_items + json: items + foreign_key: order_id + references: id + order_items: + Order: + relation: belongs_to + table: orders + json: order + foreign_key: order_id + references: id + Content: + relation: belongs_to + table: contents + json: content + foreign_key: content_id + references: id + tenant_ledgers: + Order: + relation: belongs_to + table: orders + json: order + foreign_key: order_id + references: id diff --git a/backend/database/migrations/20251218120000_orders_ledgers.sql b/backend/database/migrations/20251218120000_orders_ledgers.sql new file mode 100644 index 0000000..17acb99 --- /dev/null +++ b/backend/database/migrations/20251218120000_orders_ledgers.sql @@ -0,0 +1,150 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE tenant_users + ADD COLUMN IF NOT EXISTS balance_frozen bigint NOT NULL DEFAULT 0; + +-- tenant_users.balance_frozen:冻结余额(用于下单冻结、争议期等) +COMMENT ON COLUMN tenant_users.balance_frozen IS '冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0'; + +CREATE INDEX IF NOT EXISTS ix_tenant_users_tenant_balance_frozen ON tenant_users(tenant_id, balance_frozen); + +CREATE TABLE IF NOT EXISTS orders( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + type varchar(32) NOT NULL DEFAULT 'content_purchase', + status varchar(32) NOT NULL DEFAULT 'created', + currency varchar(16) NOT NULL DEFAULT 'CNY', + amount_original bigint NOT NULL DEFAULT 0, + amount_discount bigint NOT NULL DEFAULT 0, + amount_paid bigint NOT NULL DEFAULT 0, + snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + idempotency_key varchar(128) NOT NULL DEFAULT '', + paid_at timestamptz, + refunded_at timestamptz, + refund_forced boolean NOT NULL DEFAULT false, + refund_operator_user_id bigint, + refund_reason varchar(255) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +-- orders:订单主表(租户内购买/充值等业务单据) +COMMENT ON TABLE orders IS '订单:租户内的业务交易单据;记录成交金额快照、状态流转与退款信息;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN orders.id IS '主键ID:自增;用于关联订单明细、账本流水、权益等'; +COMMENT ON COLUMN orders.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN orders.user_id IS '用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准'; +COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase'; +COMMENT ON COLUMN orders.status IS '订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致'; +COMMENT ON COLUMN orders.currency IS '币种:当前固定 CNY;金额单位为分'; +COMMENT ON COLUMN orders.amount_original IS '原价金额:分;未折扣前金额(用于展示与对账)'; +COMMENT ON COLUMN orders.amount_discount IS '优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)'; +COMMENT ON COLUMN orders.amount_paid IS '实付金额:分;从租户内余额扣款的金额(下单时快照)'; +COMMENT ON COLUMN orders.snapshot IS '订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示'; +COMMENT ON COLUMN orders.idempotency_key IS '幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)'; +COMMENT ON COLUMN orders.paid_at IS '支付/扣款完成时间:余额支付在 debit_purchase 成功后写入'; +COMMENT ON COLUMN orders.refunded_at IS '退款完成时间:退款落账成功后写入'; +COMMENT ON COLUMN orders.refund_forced IS '是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)'; +COMMENT ON COLUMN orders.refund_operator_user_id IS '退款操作人用户ID:租户管理员/系统;用于审计与追责'; +COMMENT ON COLUMN orders.refund_reason IS '退款原因:后台/用户发起退款的原因说明;用于审计'; +COMMENT ON COLUMN orders.created_at IS '创建时间:默认 now();用于审计与排序'; +COMMENT ON COLUMN orders.updated_at IS '更新时间:默认 now();状态变更/退款写入时更新'; + +CREATE INDEX IF NOT EXISTS ix_orders_tenant_user ON orders(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS ix_orders_tenant_status ON orders(tenant_id, status); +CREATE INDEX IF NOT EXISTS ix_orders_tenant_paid_at ON orders(tenant_id, paid_at); +CREATE UNIQUE INDEX IF NOT EXISTS ux_orders_tenant_idempotency_key ON orders(tenant_id, user_id, idempotency_key) WHERE idempotency_key <> ''; + +CREATE TABLE IF NOT EXISTS order_items( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + order_id bigint NOT NULL, + content_id bigint NOT NULL, + content_user_id bigint NOT NULL DEFAULT 0, + amount_paid bigint NOT NULL DEFAULT 0, + snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, order_id, content_id) +); + +-- order_items:订单明细(购买内容通常一单一内容,但保留扩展能力) +COMMENT ON TABLE order_items IS '订单明细:记录订单购买的具体内容及金额/快照;支持后续扩展为一单多内容'; +COMMENT ON COLUMN order_items.id IS '主键ID:自增'; +COMMENT ON COLUMN order_items.tenant_id IS '租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致'; +COMMENT ON COLUMN order_items.user_id IS '用户ID:下单用户(buyer);冗余字段用于查询加速与审计'; +COMMENT ON COLUMN order_items.order_id IS '订单ID:关联 orders.id;用于聚合订单明细'; +COMMENT ON COLUMN order_items.content_id IS '内容ID:关联 contents.id;用于生成/撤销 content_access'; +COMMENT ON COLUMN order_items.content_user_id IS '内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者'; +COMMENT ON COLUMN order_items.amount_paid IS '该行实付金额:分;通常等于订单 amount_paid(单内容场景)'; +COMMENT ON COLUMN order_items.snapshot IS '内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计'; +COMMENT ON COLUMN order_items.created_at IS '创建时间:默认 now()'; +COMMENT ON COLUMN order_items.updated_at IS '更新时间:默认 now()'; + +CREATE INDEX IF NOT EXISTS ix_order_items_tenant_order ON order_items(tenant_id, order_id); +CREATE INDEX IF NOT EXISTS ix_order_items_tenant_content ON order_items(tenant_id, content_id); + +CREATE TABLE IF NOT EXISTS tenant_ledgers( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + order_id bigint, + type varchar(32) NOT NULL, + amount bigint NOT NULL DEFAULT 0, + balance_before bigint NOT NULL DEFAULT 0, + balance_after bigint NOT NULL DEFAULT 0, + frozen_before bigint NOT NULL DEFAULT 0, + frozen_after bigint NOT NULL DEFAULT 0, + idempotency_key varchar(128) NOT NULL DEFAULT '', + remark varchar(255) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +-- tenant_ledgers:租户内余额账本流水(必须可审计、可幂等) +COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(充值/冻结/扣款/退款等);用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.id IS '主键ID:自增'; +COMMENT ON COLUMN tenant_ledgers.tenant_id IS '租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致'; +COMMENT ON COLUMN tenant_ledgers.user_id IS '用户ID:余额账户归属用户;对应 tenant_users.user_id'; +COMMENT ON COLUMN tenant_ledgers.order_id IS '关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空'; +COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; +COMMENT ON COLUMN tenant_ledgers.amount IS '流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)'; +COMMENT ON COLUMN tenant_ledgers.balance_before IS '变更前可用余额:用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.balance_after IS '变更后可用余额:用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.frozen_before IS '变更前冻结余额:用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.frozen_after IS '变更后冻结余额:用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.idempotency_key IS '幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)'; +COMMENT ON COLUMN tenant_ledgers.remark IS '备注:业务说明/后台操作原因等;用于审计'; +COMMENT ON COLUMN tenant_ledgers.created_at IS '创建时间:默认 now()'; +COMMENT ON COLUMN tenant_ledgers.updated_at IS '更新时间:默认 now()'; + +CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_user ON tenant_ledgers(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_order ON tenant_ledgers(tenant_id, order_id); +CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_type ON tenant_ledgers(tenant_id, type); +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_idempotency_key ON tenant_ledgers(tenant_id, user_id, idempotency_key) WHERE idempotency_key <> ''; + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_idempotency_key; +DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_type; +DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_order; +DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_user; +DROP TABLE IF EXISTS tenant_ledgers; + +DROP INDEX IF EXISTS ix_order_items_tenant_content; +DROP INDEX IF EXISTS ix_order_items_tenant_order; +DROP TABLE IF EXISTS order_items; + +DROP INDEX IF EXISTS ux_orders_tenant_idempotency_key; +DROP INDEX IF EXISTS ix_orders_tenant_paid_at; +DROP INDEX IF EXISTS ix_orders_tenant_status; +DROP INDEX IF EXISTS ix_orders_tenant_user; +DROP TABLE IF EXISTS orders; + +DROP INDEX IF EXISTS ix_tenant_users_tenant_balance_frozen; +ALTER TABLE tenant_users DROP COLUMN IF EXISTS balance_frozen; + +-- +goose StatementEnd + diff --git a/backend/database/models/content_access.gen.go b/backend/database/models/content_access.gen.go index 1bdf710..fc5339b 100644 --- a/backend/database/models/content_access.gen.go +++ b/backend/database/models/content_access.gen.go @@ -17,15 +17,15 @@ const TableNameContentAccess = "content_access" // ContentAccess mapped from table type ContentAccess struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - ContentID int64 `gorm:"column:content_id;type:bigint;not null" json:"content_id"` - OrderID int64 `gorm:"column:order_id;type:bigint" json:"order_id"` - Status consts.ContentAccessStatus `gorm:"column:status;type:character varying(16);not null;default:active" json:"status"` - RevokedAt time.Time `gorm:"column:revoked_at;type:timestamp with time zone" json:"revoked_at"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离;与内容、用户归属一致" json:"tenant_id"` // 租户ID:多租户隔离;与内容、用户归属一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:权益所属用户;用于访问校验" json:"user_id"` // 用户ID:权益所属用户;用于访问校验 + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id)" json:"content_id"` // 内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id) + OrderID int64 `gorm:"column:order_id;type:bigint;comment:订单ID:产生该权益的订单;可为空(例如后台补发/迁移)" json:"order_id"` // 订单ID:产生该权益的订单;可为空(例如后台补发/迁移) + Status consts.ContentAccessStatus `gorm:"column:status;type:character varying(16);not null;default:active;comment:权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规)" json:"status"` // 权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规) + RevokedAt time.Time `gorm:"column:revoked_at;type:timestamp with time zone;comment:撤销时间:当 status=revoked 时写入;用于审计与追责" json:"revoked_at"` // 撤销时间:当 status=revoked 时写入;用于审计与追责 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计" json:"created_at"` // 创建时间:默认 now();用于审计 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新 status 时写入" json:"updated_at"` // 更新时间:默认 now();更新 status 时写入 } // Quick operations without importing query package diff --git a/backend/database/models/content_access.query.gen.go b/backend/database/models/content_access.query.gen.go index dd66640..e2a958a 100644 --- a/backend/database/models/content_access.query.gen.go +++ b/backend/database/models/content_access.query.gen.go @@ -44,15 +44,15 @@ type contentAccessQuery struct { contentAccessQueryDo contentAccessQueryDo ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - ContentID field.Int64 - OrderID field.Int64 - Status field.Field - RevokedAt field.Time - CreatedAt field.Time - UpdatedAt field.Time + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离;与内容、用户归属一致 + UserID field.Int64 // 用户ID:权益所属用户;用于访问校验 + ContentID field.Int64 // 内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id) + OrderID field.Int64 // 订单ID:产生该权益的订单;可为空(例如后台补发/迁移) + Status field.Field // 权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规) + RevokedAt field.Time // 撤销时间:当 status=revoked 时写入;用于审计与追责 + CreatedAt field.Time // 创建时间:默认 now();用于审计 + UpdatedAt field.Time // 更新时间:默认 now();更新 status 时写入 fieldMap map[string]field.Expr } diff --git a/backend/database/models/content_assets.gen.go b/backend/database/models/content_assets.gen.go index f2308db..d0a9b9c 100644 --- a/backend/database/models/content_assets.gen.go +++ b/backend/database/models/content_assets.gen.go @@ -17,15 +17,15 @@ const TableNameContentAsset = "content_assets" // ContentAsset mapped from table type ContentAsset struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - ContentID int64 `gorm:"column:content_id;type:bigint;not null" json:"content_id"` - AssetID int64 `gorm:"column:asset_id;type:bigint;not null" json:"asset_id"` - Role consts.ContentAssetRole `gorm:"column:role;type:character varying(32);not null;default:main" json:"role"` - Sort int32 `gorm:"column:sort;type:integer;not null" json:"sort"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致" json:"tenant_id"` // 租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者)" json:"user_id"` // 用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者) + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:关联 contents.id;用于查询内容下资源列表" json:"content_id"` // 内容ID:关联 contents.id;用于查询内容下资源列表 + AssetID int64 `gorm:"column:asset_id;type:bigint;not null;comment:资源ID:关联 media_assets.id;用于查询资源归属内容" json:"asset_id"` // 资源ID:关联 media_assets.id;用于查询资源归属内容 + Role consts.ContentAssetRole `gorm:"column:role;type:character varying(32);not null;default:main;comment:资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过" json:"role"` // 资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过 + Sort int32 `gorm:"column:sort;type:integer;not null;comment:排序:同一 role 下的展示顺序,数值越小越靠前" json:"sort"` // 排序:同一 role 下的展示顺序,数值越小越靠前 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计" json:"created_at"` // 创建时间:默认 now();用于审计 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新 sort/role 时写入" json:"updated_at"` // 更新时间:默认 now();更新 sort/role 时写入 } // Quick operations without importing query package diff --git a/backend/database/models/content_assets.query.gen.go b/backend/database/models/content_assets.query.gen.go index 24e4712..27bc95a 100644 --- a/backend/database/models/content_assets.query.gen.go +++ b/backend/database/models/content_assets.query.gen.go @@ -44,15 +44,15 @@ type contentAssetQuery struct { contentAssetQueryDo contentAssetQueryDo ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - ContentID field.Int64 - AssetID field.Int64 - Role field.Field - Sort field.Int32 - CreatedAt field.Time - UpdatedAt field.Time + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致 + UserID field.Int64 // 用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者) + ContentID field.Int64 // 内容ID:关联 contents.id;用于查询内容下资源列表 + AssetID field.Int64 // 资源ID:关联 media_assets.id;用于查询资源归属内容 + Role field.Field // 资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过 + Sort field.Int32 // 排序:同一 role 下的展示顺序,数值越小越靠前 + CreatedAt field.Time // 创建时间:默认 now();用于审计 + UpdatedAt field.Time // 更新时间:默认 now();更新 sort/role 时写入 fieldMap map[string]field.Expr } diff --git a/backend/database/models/content_prices.gen.go b/backend/database/models/content_prices.gen.go index b2f3c57..144986a 100644 --- a/backend/database/models/content_prices.gen.go +++ b/backend/database/models/content_prices.gen.go @@ -17,18 +17,18 @@ const TableNameContentPrice = "content_prices" // ContentPrice mapped from table type ContentPrice struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - ContentID int64 `gorm:"column:content_id;type:bigint;not null" json:"content_id"` - Currency consts.Currency `gorm:"column:currency;type:character varying(16);not null;default:CNY" json:"currency"` - PriceAmount int64 `gorm:"column:price_amount;type:bigint;not null" json:"price_amount"` - DiscountType consts.DiscountType `gorm:"column:discount_type;type:character varying(16);not null;default:none" json:"discount_type"` - DiscountValue int64 `gorm:"column:discount_value;type:bigint;not null" json:"discount_value"` - DiscountStartAt time.Time `gorm:"column:discount_start_at;type:timestamp with time zone" json:"discount_start_at"` - DiscountEndAt time.Time `gorm:"column:discount_end_at;type:timestamp with time zone" json:"discount_end_at"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离;与内容归属一致" json:"tenant_id"` // 租户ID:多租户隔离;与内容归属一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计" json:"user_id"` // 用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计 + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价" json:"content_id"` // 内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价 + Currency consts.Currency `gorm:"column:currency;type:character varying(16);not null;default:CNY;comment:币种:当前固定 CNY;金额单位为分" json:"currency"` // 币种:当前固定 CNY;金额单位为分 + PriceAmount int64 `gorm:"column:price_amount;type:bigint;not null;comment:基础价格:分;0 表示免费(可直接访问正片资源)" json:"price_amount"` // 基础价格:分;0 表示免费(可直接访问正片资源) + DiscountType consts.DiscountType `gorm:"column:discount_type;type:character varying(16);not null;default:none;comment:折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照" json:"discount_type"` // 折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照 + DiscountValue int64 `gorm:"column:discount_value;type:bigint;not null;comment:折扣值:percent=0-100(按业务校验);amount=分;none 时忽略" json:"discount_value"` // 折扣值:percent=0-100(按业务校验);amount=分;none 时忽略 + DiscountStartAt time.Time `gorm:"column:discount_start_at;type:timestamp with time zone;comment:折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释)" json:"discount_start_at"` // 折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释) + DiscountEndAt time.Time `gorm:"column:discount_end_at;type:timestamp with time zone;comment:折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释)" json:"discount_end_at"` // 折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释) + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计" json:"created_at"` // 创建时间:默认 now();用于审计 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新价格/折扣时写入" json:"updated_at"` // 更新时间:默认 now();更新价格/折扣时写入 } // Quick operations without importing query package diff --git a/backend/database/models/content_prices.query.gen.go b/backend/database/models/content_prices.query.gen.go index 627714d..c3c8529 100644 --- a/backend/database/models/content_prices.query.gen.go +++ b/backend/database/models/content_prices.query.gen.go @@ -47,18 +47,18 @@ type contentPriceQuery struct { contentPriceQueryDo contentPriceQueryDo ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - ContentID field.Int64 - Currency field.Field - PriceAmount field.Int64 - DiscountType field.Field - DiscountValue field.Int64 - DiscountStartAt field.Time - DiscountEndAt field.Time - CreatedAt field.Time - UpdatedAt field.Time + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离;与内容归属一致 + UserID field.Int64 // 用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计 + ContentID field.Int64 // 内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价 + Currency field.Field // 币种:当前固定 CNY;金额单位为分 + PriceAmount field.Int64 // 基础价格:分;0 表示免费(可直接访问正片资源) + DiscountType field.Field // 折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照 + DiscountValue field.Int64 // 折扣值:percent=0-100(按业务校验);amount=分;none 时忽略 + DiscountStartAt field.Time // 折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释) + DiscountEndAt field.Time // 折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释) + CreatedAt field.Time // 创建时间:默认 now();用于审计 + UpdatedAt field.Time // 更新时间:默认 now();更新价格/折扣时写入 fieldMap map[string]field.Expr } diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index 8599422..892b4b7 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -18,19 +18,19 @@ const TableNameContent = "contents" // Content mapped from table type Content struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Title string `gorm:"column:title;type:character varying(255);not null" json:"title"` - Description string `gorm:"column:description;type:text;not null" json:"description"` - Status consts.ContentStatus `gorm:"column:status;type:character varying(32);not null;default:draft" json:"status"` - Visibility consts.ContentVisibility `gorm:"column:visibility;type:character varying(32);not null;default:tenant_only" json:"visibility"` - PreviewSeconds int32 `gorm:"column:preview_seconds;type:integer;not null;default:60" json:"preview_seconds"` - PreviewDownloadable bool `gorm:"column:preview_downloadable;type:boolean;not null" json:"preview_downloadable"` - PublishedAt time.Time `gorm:"column:published_at;type:timestamp with time zone" json:"published_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;用于内容引用" json:"id"` // 主键ID:自增;用于内容引用 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见)" json:"user_id"` // 用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见) + Title string `gorm:"column:title;type:character varying(255);not null;comment:标题:用于列表展示与搜索;建议限制长度(由业务校验)" json:"title"` // 标题:用于列表展示与搜索;建议限制长度(由业务校验) + Description string `gorm:"column:description;type:text;not null;comment:描述:用于详情页展示;可为空字符串" json:"description"` // 描述:用于详情页展示;可为空字符串 + Status consts.ContentStatus `gorm:"column:status;type:character varying(32);not null;default:draft;comment:状态:draft/reviewing/published/unpublished/blocked;published 才对外展示" json:"status"` // 状态:draft/reviewing/published/unpublished/blocked;published 才对外展示 + Visibility consts.ContentVisibility `gorm:"column:visibility;type:character varying(32);not null;default:tenant_only;comment:可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验" json:"visibility"` // 可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验 + PreviewSeconds int32 `gorm:"column:preview_seconds;type:integer;not null;default:60;comment:试看秒数:默认 60;只对 preview 资源生效;必须为正整数" json:"preview_seconds"` // 试看秒数:默认 60;只对 preview 资源生效;必须为正整数 + PreviewDownloadable bool `gorm:"column:preview_downloadable;type:boolean;not null;comment:试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming)" json:"preview_downloadable"` // 试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming) + PublishedAt time.Time `gorm:"column:published_at;type:timestamp with time zone;comment:发布时间:首次发布时写入;用于时间窗与排序" json:"published_at"` // 发布时间:首次发布时写入;用于时间窗与排序 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();编辑内容时写入" json:"updated_at"` // 更新时间:默认 now();编辑内容时写入 } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index fd1eab2..c967c65 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -48,19 +48,19 @@ type contentQuery struct { contentQueryDo contentQueryDo ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - Title field.String - Description field.String - Status field.Field - Visibility field.Field - PreviewSeconds field.Int32 - PreviewDownloadable field.Bool - PublishedAt field.Time - DeletedAt field.Field - CreatedAt field.Time - UpdatedAt field.Time + ID field.Int64 // 主键ID:自增;用于内容引用 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见) + Title field.String // 标题:用于列表展示与搜索;建议限制长度(由业务校验) + Description field.String // 描述:用于详情页展示;可为空字符串 + Status field.Field // 状态:draft/reviewing/published/unpublished/blocked;published 才对外展示 + Visibility field.Field // 可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验 + PreviewSeconds field.Int32 // 试看秒数:默认 60;只对 preview 资源生效;必须为正整数 + PreviewDownloadable field.Bool // 试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming) + PublishedAt field.Time // 发布时间:首次发布时写入;用于时间窗与排序 + DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 + UpdatedAt field.Time // 更新时间:默认 now();编辑内容时写入 fieldMap map[string]field.Expr } diff --git a/backend/database/models/media_assets.gen.go b/backend/database/models/media_assets.gen.go index fbb4906..497c892 100644 --- a/backend/database/models/media_assets.gen.go +++ b/backend/database/models/media_assets.gen.go @@ -19,18 +19,18 @@ const TableNameMediaAsset = "media_assets" // MediaAsset mapped from table type MediaAsset struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Type consts.MediaAssetType `gorm:"column:type;type:character varying(32);not null;default:video" json:"type"` - Status consts.MediaAssetStatus `gorm:"column:status;type:character varying(32);not null;default:uploaded" json:"status"` - Provider string `gorm:"column:provider;type:character varying(64);not null" json:"provider"` - Bucket string `gorm:"column:bucket;type:character varying(128);not null" json:"bucket"` - ObjectKey string `gorm:"column:object_key;type:character varying(512);not null" json:"object_key"` - Meta types.JSON `gorm:"column:meta;type:jsonb;not null;default:{}" json:"meta"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;仅用于内部关联" json:"id"` // 主键ID:自增;仅用于内部关联 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:资源上传者;用于审计与权限控制" json:"user_id"` // 用户ID:资源上传者;用于审计与权限控制 + Type consts.MediaAssetType `gorm:"column:type;type:character varying(32);not null;default:video;comment:资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)" json:"type"` // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) + Status consts.MediaAssetStatus `gorm:"column:status;type:character varying(32);not null;default:uploaded;comment:处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供" json:"status"` // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 + Provider string `gorm:"column:provider;type:character varying(64);not null;comment:存储提供方:例如 s3/minio/oss;便于多存储扩展" json:"provider"` // 存储提供方:例如 s3/minio/oss;便于多存储扩展 + Bucket string `gorm:"column:bucket;type:character varying(128);not null;comment:存储桶:对象所在 bucket;与 provider 组合确定存储定位" json:"bucket"` // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 + ObjectKey string `gorm:"column:object_key;type:character varying(512);not null;comment:对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)" json:"object_key"` // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) + Meta types.JSON `gorm:"column:meta;type:jsonb;not null;default:{};comment:元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控" json:"meta"` // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone;comment:软删除时间:非空表示已删除;对外接口需过滤" json:"deleted_at"` // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();更新状态/元数据时写入" json:"updated_at"` // 更新时间:默认 now();更新状态/元数据时写入 } // Quick operations without importing query package diff --git a/backend/database/models/media_assets.query.gen.go b/backend/database/models/media_assets.query.gen.go index 3fa7a47..6ad11ea 100644 --- a/backend/database/models/media_assets.query.gen.go +++ b/backend/database/models/media_assets.query.gen.go @@ -47,18 +47,18 @@ type mediaAssetQuery struct { mediaAssetQueryDo mediaAssetQueryDo ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - Type field.Field - Status field.Field - Provider field.String - Bucket field.String - ObjectKey field.String - Meta field.JSONB - DeletedAt field.Field - CreatedAt field.Time - UpdatedAt field.Time + ID field.Int64 // 主键ID:自增;仅用于内部关联 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 用户ID:资源上传者;用于审计与权限控制 + Type field.Field // 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) + Status field.Field // 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 + Provider field.String // 存储提供方:例如 s3/minio/oss;便于多存储扩展 + Bucket field.String // 存储桶:对象所在 bucket;与 provider 组合确定存储定位 + ObjectKey field.String // 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) + Meta field.JSONB // 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 + DeletedAt field.Field // 软删除时间:非空表示已删除;对外接口需过滤 + CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 + UpdatedAt field.Time // 更新时间:默认 now();更新状态/元数据时写入 fieldMap map[string]field.Expr } diff --git a/backend/database/models/order_items.gen.go b/backend/database/models/order_items.gen.go new file mode 100644 index 0000000..4ac2774 --- /dev/null +++ b/backend/database/models/order_items.gen.go @@ -0,0 +1,63 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/types" +) + +const TableNameOrderItem = "order_items" + +// OrderItem mapped from table +type OrderItem struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致" json:"tenant_id"` // 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);冗余字段用于查询加速与审计" json:"user_id"` // 用户ID:下单用户(buyer);冗余字段用于查询加速与审计 + OrderID int64 `gorm:"column:order_id;type:bigint;not null;comment:订单ID:关联 orders.id;用于聚合订单明细" json:"order_id"` // 订单ID:关联 orders.id;用于聚合订单明细 + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:关联 contents.id;用于生成/撤销 content_access" json:"content_id"` // 内容ID:关联 contents.id;用于生成/撤销 content_access + ContentUserID int64 `gorm:"column:content_user_id;type:bigint;not null;comment:内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者" json:"content_user_id"` // 内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者 + AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:该行实付金额:分;通常等于订单 amount_paid(单内容场景)" json:"amount_paid"` // 该行实付金额:分;通常等于订单 amount_paid(单内容场景) + Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() + Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` + Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"` +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *OrderItem) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.OrderItem.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *OrderItem) Save(ctx context.Context) error { return Q.OrderItem.WithContext(ctx).Save(m) } + +// Create inserts the model using the default DB. +func (m *OrderItem) Create(ctx context.Context) error { return Q.OrderItem.WithContext(ctx).Create(m) } + +// Delete removes the row represented by the model using the default DB. +func (m *OrderItem) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.OrderItem.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *OrderItem) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.OrderItem.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *OrderItem) Reload(ctx context.Context) error { + fresh, err := Q.OrderItem.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/order_items.query.gen.go b/backend/database/models/order_items.query.gen.go new file mode 100644 index 0000000..91ad511 --- /dev/null +++ b/backend/database/models/order_items.query.gen.go @@ -0,0 +1,680 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newOrderItem(db *gorm.DB, opts ...gen.DOOption) orderItemQuery { + _orderItemQuery := orderItemQuery{} + + _orderItemQuery.orderItemQueryDo.UseDB(db, opts...) + _orderItemQuery.orderItemQueryDo.UseModel(&OrderItem{}) + + tableName := _orderItemQuery.orderItemQueryDo.TableName() + _orderItemQuery.ALL = field.NewAsterisk(tableName) + _orderItemQuery.ID = field.NewInt64(tableName, "id") + _orderItemQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _orderItemQuery.UserID = field.NewInt64(tableName, "user_id") + _orderItemQuery.OrderID = field.NewInt64(tableName, "order_id") + _orderItemQuery.ContentID = field.NewInt64(tableName, "content_id") + _orderItemQuery.ContentUserID = field.NewInt64(tableName, "content_user_id") + _orderItemQuery.AmountPaid = field.NewInt64(tableName, "amount_paid") + _orderItemQuery.Snapshot = field.NewJSONB(tableName, "snapshot") + _orderItemQuery.CreatedAt = field.NewTime(tableName, "created_at") + _orderItemQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _orderItemQuery.Order = orderItemQueryBelongsToOrder{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Order", "Order"), + } + + _orderItemQuery.Content = orderItemQueryBelongsToContent{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Content", "Content"), + } + + _orderItemQuery.fillFieldMap() + + return _orderItemQuery +} + +type orderItemQuery struct { + orderItemQueryDo orderItemQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 + UserID field.Int64 // 用户ID:下单用户(buyer);冗余字段用于查询加速与审计 + OrderID field.Int64 // 订单ID:关联 orders.id;用于聚合订单明细 + ContentID field.Int64 // 内容ID:关联 contents.id;用于生成/撤销 content_access + ContentUserID field.Int64 // 内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者 + AmountPaid field.Int64 // 该行实付金额:分;通常等于订单 amount_paid(单内容场景) + Snapshot field.JSONB // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 + CreatedAt field.Time // 创建时间:默认 now() + UpdatedAt field.Time // 更新时间:默认 now() + Order orderItemQueryBelongsToOrder + + Content orderItemQueryBelongsToContent + + fieldMap map[string]field.Expr +} + +func (o orderItemQuery) Table(newTableName string) *orderItemQuery { + o.orderItemQueryDo.UseTable(newTableName) + return o.updateTableName(newTableName) +} + +func (o orderItemQuery) As(alias string) *orderItemQuery { + o.orderItemQueryDo.DO = *(o.orderItemQueryDo.As(alias).(*gen.DO)) + return o.updateTableName(alias) +} + +func (o *orderItemQuery) updateTableName(table string) *orderItemQuery { + o.ALL = field.NewAsterisk(table) + o.ID = field.NewInt64(table, "id") + o.TenantID = field.NewInt64(table, "tenant_id") + o.UserID = field.NewInt64(table, "user_id") + o.OrderID = field.NewInt64(table, "order_id") + o.ContentID = field.NewInt64(table, "content_id") + o.ContentUserID = field.NewInt64(table, "content_user_id") + o.AmountPaid = field.NewInt64(table, "amount_paid") + o.Snapshot = field.NewJSONB(table, "snapshot") + o.CreatedAt = field.NewTime(table, "created_at") + o.UpdatedAt = field.NewTime(table, "updated_at") + + o.fillFieldMap() + + return o +} + +func (o *orderItemQuery) QueryContext(ctx context.Context) (*orderItemQuery, *orderItemQueryDo) { + return o, o.orderItemQueryDo.WithContext(ctx) +} + +func (o *orderItemQuery) WithContext(ctx context.Context) *orderItemQueryDo { + return o.orderItemQueryDo.WithContext(ctx) +} + +func (o orderItemQuery) TableName() string { return o.orderItemQueryDo.TableName() } + +func (o orderItemQuery) Alias() string { return o.orderItemQueryDo.Alias() } + +func (o orderItemQuery) Columns(cols ...field.Expr) gen.Columns { + return o.orderItemQueryDo.Columns(cols...) +} + +func (o *orderItemQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := o.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (o *orderItemQuery) fillFieldMap() { + o.fieldMap = make(map[string]field.Expr, 12) + o.fieldMap["id"] = o.ID + o.fieldMap["tenant_id"] = o.TenantID + o.fieldMap["user_id"] = o.UserID + o.fieldMap["order_id"] = o.OrderID + o.fieldMap["content_id"] = o.ContentID + o.fieldMap["content_user_id"] = o.ContentUserID + o.fieldMap["amount_paid"] = o.AmountPaid + o.fieldMap["snapshot"] = o.Snapshot + o.fieldMap["created_at"] = o.CreatedAt + o.fieldMap["updated_at"] = o.UpdatedAt + +} + +func (o orderItemQuery) clone(db *gorm.DB) orderItemQuery { + o.orderItemQueryDo.ReplaceConnPool(db.Statement.ConnPool) + o.Order.db = db.Session(&gorm.Session{Initialized: true}) + o.Order.db.Statement.ConnPool = db.Statement.ConnPool + o.Content.db = db.Session(&gorm.Session{Initialized: true}) + o.Content.db.Statement.ConnPool = db.Statement.ConnPool + return o +} + +func (o orderItemQuery) replaceDB(db *gorm.DB) orderItemQuery { + o.orderItemQueryDo.ReplaceDB(db) + o.Order.db = db.Session(&gorm.Session{}) + o.Content.db = db.Session(&gorm.Session{}) + return o +} + +type orderItemQueryBelongsToOrder struct { + db *gorm.DB + + field.RelationField +} + +func (a orderItemQueryBelongsToOrder) Where(conds ...field.Expr) *orderItemQueryBelongsToOrder { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a orderItemQueryBelongsToOrder) WithContext(ctx context.Context) *orderItemQueryBelongsToOrder { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a orderItemQueryBelongsToOrder) Session(session *gorm.Session) *orderItemQueryBelongsToOrder { + a.db = a.db.Session(session) + return &a +} + +func (a orderItemQueryBelongsToOrder) Model(m *OrderItem) *orderItemQueryBelongsToOrderTx { + return &orderItemQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())} +} + +func (a orderItemQueryBelongsToOrder) Unscoped() *orderItemQueryBelongsToOrder { + a.db = a.db.Unscoped() + return &a +} + +type orderItemQueryBelongsToOrderTx struct{ tx *gorm.Association } + +func (a orderItemQueryBelongsToOrderTx) Find() (result *Order, err error) { + return result, a.tx.Find(&result) +} + +func (a orderItemQueryBelongsToOrderTx) Append(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Replace(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Delete(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a orderItemQueryBelongsToOrderTx) Clear() error { + return a.tx.Clear() +} + +func (a orderItemQueryBelongsToOrderTx) Count() int64 { + return a.tx.Count() +} + +func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrderTx { + a.tx = a.tx.Unscoped() + return &a +} + +type orderItemQueryBelongsToContent struct { + db *gorm.DB + + field.RelationField +} + +func (a orderItemQueryBelongsToContent) Where(conds ...field.Expr) *orderItemQueryBelongsToContent { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a orderItemQueryBelongsToContent) WithContext(ctx context.Context) *orderItemQueryBelongsToContent { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a orderItemQueryBelongsToContent) Session(session *gorm.Session) *orderItemQueryBelongsToContent { + a.db = a.db.Session(session) + return &a +} + +func (a orderItemQueryBelongsToContent) Model(m *OrderItem) *orderItemQueryBelongsToContentTx { + return &orderItemQueryBelongsToContentTx{a.db.Model(m).Association(a.Name())} +} + +func (a orderItemQueryBelongsToContent) Unscoped() *orderItemQueryBelongsToContent { + a.db = a.db.Unscoped() + return &a +} + +type orderItemQueryBelongsToContentTx struct{ tx *gorm.Association } + +func (a orderItemQueryBelongsToContentTx) Find() (result *Content, err error) { + return result, a.tx.Find(&result) +} + +func (a orderItemQueryBelongsToContentTx) Append(values ...*Content) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a orderItemQueryBelongsToContentTx) Replace(values ...*Content) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a orderItemQueryBelongsToContentTx) Delete(values ...*Content) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a orderItemQueryBelongsToContentTx) Clear() error { + return a.tx.Clear() +} + +func (a orderItemQueryBelongsToContentTx) Count() int64 { + return a.tx.Count() +} + +func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToContentTx { + a.tx = a.tx.Unscoped() + return &a +} + +type orderItemQueryDo struct{ gen.DO } + +func (o orderItemQueryDo) Debug() *orderItemQueryDo { + return o.withDO(o.DO.Debug()) +} + +func (o orderItemQueryDo) WithContext(ctx context.Context) *orderItemQueryDo { + return o.withDO(o.DO.WithContext(ctx)) +} + +func (o orderItemQueryDo) ReadDB() *orderItemQueryDo { + return o.Clauses(dbresolver.Read) +} + +func (o orderItemQueryDo) WriteDB() *orderItemQueryDo { + return o.Clauses(dbresolver.Write) +} + +func (o orderItemQueryDo) Session(config *gorm.Session) *orderItemQueryDo { + return o.withDO(o.DO.Session(config)) +} + +func (o orderItemQueryDo) Clauses(conds ...clause.Expression) *orderItemQueryDo { + return o.withDO(o.DO.Clauses(conds...)) +} + +func (o orderItemQueryDo) Returning(value interface{}, columns ...string) *orderItemQueryDo { + return o.withDO(o.DO.Returning(value, columns...)) +} + +func (o orderItemQueryDo) Not(conds ...gen.Condition) *orderItemQueryDo { + return o.withDO(o.DO.Not(conds...)) +} + +func (o orderItemQueryDo) Or(conds ...gen.Condition) *orderItemQueryDo { + return o.withDO(o.DO.Or(conds...)) +} + +func (o orderItemQueryDo) Select(conds ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Select(conds...)) +} + +func (o orderItemQueryDo) Where(conds ...gen.Condition) *orderItemQueryDo { + return o.withDO(o.DO.Where(conds...)) +} + +func (o orderItemQueryDo) Order(conds ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Order(conds...)) +} + +func (o orderItemQueryDo) Distinct(cols ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Distinct(cols...)) +} + +func (o orderItemQueryDo) Omit(cols ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Omit(cols...)) +} + +func (o orderItemQueryDo) Join(table schema.Tabler, on ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Join(table, on...)) +} + +func (o orderItemQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.LeftJoin(table, on...)) +} + +func (o orderItemQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.RightJoin(table, on...)) +} + +func (o orderItemQueryDo) Group(cols ...field.Expr) *orderItemQueryDo { + return o.withDO(o.DO.Group(cols...)) +} + +func (o orderItemQueryDo) Having(conds ...gen.Condition) *orderItemQueryDo { + return o.withDO(o.DO.Having(conds...)) +} + +func (o orderItemQueryDo) Limit(limit int) *orderItemQueryDo { + return o.withDO(o.DO.Limit(limit)) +} + +func (o orderItemQueryDo) Offset(offset int) *orderItemQueryDo { + return o.withDO(o.DO.Offset(offset)) +} + +func (o orderItemQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *orderItemQueryDo { + return o.withDO(o.DO.Scopes(funcs...)) +} + +func (o orderItemQueryDo) Unscoped() *orderItemQueryDo { + return o.withDO(o.DO.Unscoped()) +} + +func (o orderItemQueryDo) Create(values ...*OrderItem) error { + if len(values) == 0 { + return nil + } + return o.DO.Create(values) +} + +func (o orderItemQueryDo) CreateInBatches(values []*OrderItem, batchSize int) error { + return o.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (o orderItemQueryDo) Save(values ...*OrderItem) error { + if len(values) == 0 { + return nil + } + return o.DO.Save(values) +} + +func (o orderItemQueryDo) First() (*OrderItem, error) { + if result, err := o.DO.First(); err != nil { + return nil, err + } else { + return result.(*OrderItem), nil + } +} + +func (o orderItemQueryDo) Take() (*OrderItem, error) { + if result, err := o.DO.Take(); err != nil { + return nil, err + } else { + return result.(*OrderItem), nil + } +} + +func (o orderItemQueryDo) Last() (*OrderItem, error) { + if result, err := o.DO.Last(); err != nil { + return nil, err + } else { + return result.(*OrderItem), nil + } +} + +func (o orderItemQueryDo) Find() ([]*OrderItem, error) { + result, err := o.DO.Find() + return result.([]*OrderItem), err +} + +func (o orderItemQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*OrderItem, err error) { + buf := make([]*OrderItem, 0, batchSize) + err = o.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (o orderItemQueryDo) FindInBatches(result *[]*OrderItem, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return o.DO.FindInBatches(result, batchSize, fc) +} + +func (o orderItemQueryDo) Attrs(attrs ...field.AssignExpr) *orderItemQueryDo { + return o.withDO(o.DO.Attrs(attrs...)) +} + +func (o orderItemQueryDo) Assign(attrs ...field.AssignExpr) *orderItemQueryDo { + return o.withDO(o.DO.Assign(attrs...)) +} + +func (o orderItemQueryDo) Joins(fields ...field.RelationField) *orderItemQueryDo { + for _, _f := range fields { + o = *o.withDO(o.DO.Joins(_f)) + } + return &o +} + +func (o orderItemQueryDo) Preload(fields ...field.RelationField) *orderItemQueryDo { + for _, _f := range fields { + o = *o.withDO(o.DO.Preload(_f)) + } + return &o +} + +func (o orderItemQueryDo) FirstOrInit() (*OrderItem, error) { + if result, err := o.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*OrderItem), nil + } +} + +func (o orderItemQueryDo) FirstOrCreate() (*OrderItem, error) { + if result, err := o.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*OrderItem), nil + } +} + +func (o orderItemQueryDo) FindByPage(offset int, limit int) (result []*OrderItem, count int64, err error) { + result, err = o.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = o.Offset(-1).Limit(-1).Count() + return +} + +func (o orderItemQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = o.Count() + if err != nil { + return + } + + err = o.Offset(offset).Limit(limit).Scan(result) + return +} + +func (o orderItemQueryDo) Scan(result interface{}) (err error) { + return o.DO.Scan(result) +} + +func (o orderItemQueryDo) Delete(models ...*OrderItem) (result gen.ResultInfo, err error) { + return o.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (o orderItemQueryDo) ForceDelete() (gen.ResultInfo, error) { + return o.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (o orderItemQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return o.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (o orderItemQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return o.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (o orderItemQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (o orderItemQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (o orderItemQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (o orderItemQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (o orderItemQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := o.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (o orderItemQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := o.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (o orderItemQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(o.TableName(), "id") + if err := o.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (o orderItemQueryDo) GetByID(id int64) (*OrderItem, error) { + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (o orderItemQueryDo) GetByIDs(ids ...int64) ([]*OrderItem, error) { + if len(ids) == 0 { + return []*OrderItem{}, nil + } + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (o orderItemQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (o orderItemQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.In(ids...)).Delete() +} + +func (o *orderItemQueryDo) withDO(do gen.Dao) *orderItemQueryDo { + o.DO = *do.(*gen.DO) + return o +} diff --git a/backend/database/models/orders.gen.go b/backend/database/models/orders.gen.go new file mode 100644 index 0000000..0426559 --- /dev/null +++ b/backend/database/models/orders.gen.go @@ -0,0 +1,72 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/types" +) + +const TableNameOrder = "orders" + +// Order mapped from table +type Order struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;用于关联订单明细、账本流水、权益等" json:"id"` // 主键ID:自增;用于关联订单明细、账本流水、权益等 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准" json:"user_id"` // 用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准 + Type consts.OrderType `gorm:"column:type;type:character varying(32);not null;default:content_purchase;comment:订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase" json:"type"` // 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase + Status consts.OrderStatus `gorm:"column:status;type:character varying(32);not null;default:created;comment:订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致" json:"status"` // 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 + Currency consts.Currency `gorm:"column:currency;type:character varying(16);not null;default:CNY;comment:币种:当前固定 CNY;金额单位为分" json:"currency"` // 币种:当前固定 CNY;金额单位为分 + AmountOriginal int64 `gorm:"column:amount_original;type:bigint;not null;comment:原价金额:分;未折扣前金额(用于展示与对账)" json:"amount_original"` // 原价金额:分;未折扣前金额(用于展示与对账) + AmountDiscount int64 `gorm:"column:amount_discount;type:bigint;not null;comment:优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)" json:"amount_discount"` // 优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照) + AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:实付金额:分;从租户内余额扣款的金额(下单时快照)" json:"amount_paid"` // 实付金额:分;从租户内余额扣款的金额(下单时快照) + Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示" json:"snapshot"` // 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 + IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成) + PaidAt time.Time `gorm:"column:paid_at;type:timestamp with time zone;comment:支付/扣款完成时间:余额支付在 debit_purchase 成功后写入" json:"paid_at"` // 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入 + RefundedAt time.Time `gorm:"column:refunded_at;type:timestamp with time zone;comment:退款完成时间:退款落账成功后写入" json:"refunded_at"` // 退款完成时间:退款落账成功后写入 + RefundForced bool `gorm:"column:refund_forced;type:boolean;not null;comment:是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)" json:"refund_forced"` // 是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计) + RefundOperatorUserID int64 `gorm:"column:refund_operator_user_id;type:bigint;comment:退款操作人用户ID:租户管理员/系统;用于审计与追责" json:"refund_operator_user_id"` // 退款操作人用户ID:租户管理员/系统;用于审计与追责 + RefundReason string `gorm:"column:refund_reason;type:character varying(255);not null;comment:退款原因:后台/用户发起退款的原因说明;用于审计" json:"refund_reason"` // 退款原因:后台/用户发起退款的原因说明;用于审计 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();状态变更/退款写入时更新" json:"updated_at"` // 更新时间:默认 now();状态变更/退款写入时更新 + Items []*OrderItem `gorm:"foreignKey:OrderID;references:ID" json:"items,omitempty"` +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *Order) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.Order.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *Order) Save(ctx context.Context) error { return Q.Order.WithContext(ctx).Save(m) } + +// Create inserts the model using the default DB. +func (m *Order) Create(ctx context.Context) error { return Q.Order.WithContext(ctx).Create(m) } + +// Delete removes the row represented by the model using the default DB. +func (m *Order) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.Order.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *Order) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.Order.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *Order) Reload(ctx context.Context) error { + fresh, err := Q.Order.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/orders.query.gen.go b/backend/database/models/orders.query.gen.go new file mode 100644 index 0000000..799d2d8 --- /dev/null +++ b/backend/database/models/orders.query.gen.go @@ -0,0 +1,618 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newOrder(db *gorm.DB, opts ...gen.DOOption) orderQuery { + _orderQuery := orderQuery{} + + _orderQuery.orderQueryDo.UseDB(db, opts...) + _orderQuery.orderQueryDo.UseModel(&Order{}) + + tableName := _orderQuery.orderQueryDo.TableName() + _orderQuery.ALL = field.NewAsterisk(tableName) + _orderQuery.ID = field.NewInt64(tableName, "id") + _orderQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _orderQuery.UserID = field.NewInt64(tableName, "user_id") + _orderQuery.Type = field.NewField(tableName, "type") + _orderQuery.Status = field.NewField(tableName, "status") + _orderQuery.Currency = field.NewField(tableName, "currency") + _orderQuery.AmountOriginal = field.NewInt64(tableName, "amount_original") + _orderQuery.AmountDiscount = field.NewInt64(tableName, "amount_discount") + _orderQuery.AmountPaid = field.NewInt64(tableName, "amount_paid") + _orderQuery.Snapshot = field.NewJSONB(tableName, "snapshot") + _orderQuery.IdempotencyKey = field.NewString(tableName, "idempotency_key") + _orderQuery.PaidAt = field.NewTime(tableName, "paid_at") + _orderQuery.RefundedAt = field.NewTime(tableName, "refunded_at") + _orderQuery.RefundForced = field.NewBool(tableName, "refund_forced") + _orderQuery.RefundOperatorUserID = field.NewInt64(tableName, "refund_operator_user_id") + _orderQuery.RefundReason = field.NewString(tableName, "refund_reason") + _orderQuery.CreatedAt = field.NewTime(tableName, "created_at") + _orderQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _orderQuery.Items = orderQueryHasManyItems{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Items", "OrderItem"), + } + + _orderQuery.fillFieldMap() + + return _orderQuery +} + +type orderQuery struct { + orderQueryDo orderQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID:自增;用于关联订单明细、账本流水、权益等 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准 + Type field.Field // 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase + Status field.Field // 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 + Currency field.Field // 币种:当前固定 CNY;金额单位为分 + AmountOriginal field.Int64 // 原价金额:分;未折扣前金额(用于展示与对账) + AmountDiscount field.Int64 // 优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照) + AmountPaid field.Int64 // 实付金额:分;从租户内余额扣款的金额(下单时快照) + Snapshot field.JSONB // 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 + IdempotencyKey field.String // 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成) + PaidAt field.Time // 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入 + RefundedAt field.Time // 退款完成时间:退款落账成功后写入 + RefundForced field.Bool // 是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计) + RefundOperatorUserID field.Int64 // 退款操作人用户ID:租户管理员/系统;用于审计与追责 + RefundReason field.String // 退款原因:后台/用户发起退款的原因说明;用于审计 + CreatedAt field.Time // 创建时间:默认 now();用于审计与排序 + UpdatedAt field.Time // 更新时间:默认 now();状态变更/退款写入时更新 + Items orderQueryHasManyItems + + fieldMap map[string]field.Expr +} + +func (o orderQuery) Table(newTableName string) *orderQuery { + o.orderQueryDo.UseTable(newTableName) + return o.updateTableName(newTableName) +} + +func (o orderQuery) As(alias string) *orderQuery { + o.orderQueryDo.DO = *(o.orderQueryDo.As(alias).(*gen.DO)) + return o.updateTableName(alias) +} + +func (o *orderQuery) updateTableName(table string) *orderQuery { + o.ALL = field.NewAsterisk(table) + o.ID = field.NewInt64(table, "id") + o.TenantID = field.NewInt64(table, "tenant_id") + o.UserID = field.NewInt64(table, "user_id") + o.Type = field.NewField(table, "type") + o.Status = field.NewField(table, "status") + o.Currency = field.NewField(table, "currency") + o.AmountOriginal = field.NewInt64(table, "amount_original") + o.AmountDiscount = field.NewInt64(table, "amount_discount") + o.AmountPaid = field.NewInt64(table, "amount_paid") + o.Snapshot = field.NewJSONB(table, "snapshot") + o.IdempotencyKey = field.NewString(table, "idempotency_key") + o.PaidAt = field.NewTime(table, "paid_at") + o.RefundedAt = field.NewTime(table, "refunded_at") + o.RefundForced = field.NewBool(table, "refund_forced") + o.RefundOperatorUserID = field.NewInt64(table, "refund_operator_user_id") + o.RefundReason = field.NewString(table, "refund_reason") + o.CreatedAt = field.NewTime(table, "created_at") + o.UpdatedAt = field.NewTime(table, "updated_at") + + o.fillFieldMap() + + return o +} + +func (o *orderQuery) QueryContext(ctx context.Context) (*orderQuery, *orderQueryDo) { + return o, o.orderQueryDo.WithContext(ctx) +} + +func (o *orderQuery) WithContext(ctx context.Context) *orderQueryDo { + return o.orderQueryDo.WithContext(ctx) +} + +func (o orderQuery) TableName() string { return o.orderQueryDo.TableName() } + +func (o orderQuery) Alias() string { return o.orderQueryDo.Alias() } + +func (o orderQuery) Columns(cols ...field.Expr) gen.Columns { return o.orderQueryDo.Columns(cols...) } + +func (o *orderQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := o.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (o *orderQuery) fillFieldMap() { + o.fieldMap = make(map[string]field.Expr, 19) + o.fieldMap["id"] = o.ID + o.fieldMap["tenant_id"] = o.TenantID + o.fieldMap["user_id"] = o.UserID + o.fieldMap["type"] = o.Type + o.fieldMap["status"] = o.Status + o.fieldMap["currency"] = o.Currency + o.fieldMap["amount_original"] = o.AmountOriginal + o.fieldMap["amount_discount"] = o.AmountDiscount + o.fieldMap["amount_paid"] = o.AmountPaid + o.fieldMap["snapshot"] = o.Snapshot + o.fieldMap["idempotency_key"] = o.IdempotencyKey + o.fieldMap["paid_at"] = o.PaidAt + o.fieldMap["refunded_at"] = o.RefundedAt + o.fieldMap["refund_forced"] = o.RefundForced + o.fieldMap["refund_operator_user_id"] = o.RefundOperatorUserID + o.fieldMap["refund_reason"] = o.RefundReason + o.fieldMap["created_at"] = o.CreatedAt + o.fieldMap["updated_at"] = o.UpdatedAt + +} + +func (o orderQuery) clone(db *gorm.DB) orderQuery { + o.orderQueryDo.ReplaceConnPool(db.Statement.ConnPool) + o.Items.db = db.Session(&gorm.Session{Initialized: true}) + o.Items.db.Statement.ConnPool = db.Statement.ConnPool + return o +} + +func (o orderQuery) replaceDB(db *gorm.DB) orderQuery { + o.orderQueryDo.ReplaceDB(db) + o.Items.db = db.Session(&gorm.Session{}) + return o +} + +type orderQueryHasManyItems struct { + db *gorm.DB + + field.RelationField +} + +func (a orderQueryHasManyItems) Where(conds ...field.Expr) *orderQueryHasManyItems { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a orderQueryHasManyItems) WithContext(ctx context.Context) *orderQueryHasManyItems { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a orderQueryHasManyItems) Session(session *gorm.Session) *orderQueryHasManyItems { + a.db = a.db.Session(session) + return &a +} + +func (a orderQueryHasManyItems) Model(m *Order) *orderQueryHasManyItemsTx { + return &orderQueryHasManyItemsTx{a.db.Model(m).Association(a.Name())} +} + +func (a orderQueryHasManyItems) Unscoped() *orderQueryHasManyItems { + a.db = a.db.Unscoped() + return &a +} + +type orderQueryHasManyItemsTx struct{ tx *gorm.Association } + +func (a orderQueryHasManyItemsTx) Find() (result []*OrderItem, err error) { + return result, a.tx.Find(&result) +} + +func (a orderQueryHasManyItemsTx) Append(values ...*OrderItem) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a orderQueryHasManyItemsTx) Replace(values ...*OrderItem) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a orderQueryHasManyItemsTx) Delete(values ...*OrderItem) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a orderQueryHasManyItemsTx) Clear() error { + return a.tx.Clear() +} + +func (a orderQueryHasManyItemsTx) Count() int64 { + return a.tx.Count() +} + +func (a orderQueryHasManyItemsTx) Unscoped() *orderQueryHasManyItemsTx { + a.tx = a.tx.Unscoped() + return &a +} + +type orderQueryDo struct{ gen.DO } + +func (o orderQueryDo) Debug() *orderQueryDo { + return o.withDO(o.DO.Debug()) +} + +func (o orderQueryDo) WithContext(ctx context.Context) *orderQueryDo { + return o.withDO(o.DO.WithContext(ctx)) +} + +func (o orderQueryDo) ReadDB() *orderQueryDo { + return o.Clauses(dbresolver.Read) +} + +func (o orderQueryDo) WriteDB() *orderQueryDo { + return o.Clauses(dbresolver.Write) +} + +func (o orderQueryDo) Session(config *gorm.Session) *orderQueryDo { + return o.withDO(o.DO.Session(config)) +} + +func (o orderQueryDo) Clauses(conds ...clause.Expression) *orderQueryDo { + return o.withDO(o.DO.Clauses(conds...)) +} + +func (o orderQueryDo) Returning(value interface{}, columns ...string) *orderQueryDo { + return o.withDO(o.DO.Returning(value, columns...)) +} + +func (o orderQueryDo) Not(conds ...gen.Condition) *orderQueryDo { + return o.withDO(o.DO.Not(conds...)) +} + +func (o orderQueryDo) Or(conds ...gen.Condition) *orderQueryDo { + return o.withDO(o.DO.Or(conds...)) +} + +func (o orderQueryDo) Select(conds ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Select(conds...)) +} + +func (o orderQueryDo) Where(conds ...gen.Condition) *orderQueryDo { + return o.withDO(o.DO.Where(conds...)) +} + +func (o orderQueryDo) Order(conds ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Order(conds...)) +} + +func (o orderQueryDo) Distinct(cols ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Distinct(cols...)) +} + +func (o orderQueryDo) Omit(cols ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Omit(cols...)) +} + +func (o orderQueryDo) Join(table schema.Tabler, on ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Join(table, on...)) +} + +func (o orderQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.LeftJoin(table, on...)) +} + +func (o orderQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.RightJoin(table, on...)) +} + +func (o orderQueryDo) Group(cols ...field.Expr) *orderQueryDo { + return o.withDO(o.DO.Group(cols...)) +} + +func (o orderQueryDo) Having(conds ...gen.Condition) *orderQueryDo { + return o.withDO(o.DO.Having(conds...)) +} + +func (o orderQueryDo) Limit(limit int) *orderQueryDo { + return o.withDO(o.DO.Limit(limit)) +} + +func (o orderQueryDo) Offset(offset int) *orderQueryDo { + return o.withDO(o.DO.Offset(offset)) +} + +func (o orderQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *orderQueryDo { + return o.withDO(o.DO.Scopes(funcs...)) +} + +func (o orderQueryDo) Unscoped() *orderQueryDo { + return o.withDO(o.DO.Unscoped()) +} + +func (o orderQueryDo) Create(values ...*Order) error { + if len(values) == 0 { + return nil + } + return o.DO.Create(values) +} + +func (o orderQueryDo) CreateInBatches(values []*Order, batchSize int) error { + return o.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (o orderQueryDo) Save(values ...*Order) error { + if len(values) == 0 { + return nil + } + return o.DO.Save(values) +} + +func (o orderQueryDo) First() (*Order, error) { + if result, err := o.DO.First(); err != nil { + return nil, err + } else { + return result.(*Order), nil + } +} + +func (o orderQueryDo) Take() (*Order, error) { + if result, err := o.DO.Take(); err != nil { + return nil, err + } else { + return result.(*Order), nil + } +} + +func (o orderQueryDo) Last() (*Order, error) { + if result, err := o.DO.Last(); err != nil { + return nil, err + } else { + return result.(*Order), nil + } +} + +func (o orderQueryDo) Find() ([]*Order, error) { + result, err := o.DO.Find() + return result.([]*Order), err +} + +func (o orderQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*Order, err error) { + buf := make([]*Order, 0, batchSize) + err = o.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (o orderQueryDo) FindInBatches(result *[]*Order, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return o.DO.FindInBatches(result, batchSize, fc) +} + +func (o orderQueryDo) Attrs(attrs ...field.AssignExpr) *orderQueryDo { + return o.withDO(o.DO.Attrs(attrs...)) +} + +func (o orderQueryDo) Assign(attrs ...field.AssignExpr) *orderQueryDo { + return o.withDO(o.DO.Assign(attrs...)) +} + +func (o orderQueryDo) Joins(fields ...field.RelationField) *orderQueryDo { + for _, _f := range fields { + o = *o.withDO(o.DO.Joins(_f)) + } + return &o +} + +func (o orderQueryDo) Preload(fields ...field.RelationField) *orderQueryDo { + for _, _f := range fields { + o = *o.withDO(o.DO.Preload(_f)) + } + return &o +} + +func (o orderQueryDo) FirstOrInit() (*Order, error) { + if result, err := o.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*Order), nil + } +} + +func (o orderQueryDo) FirstOrCreate() (*Order, error) { + if result, err := o.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*Order), nil + } +} + +func (o orderQueryDo) FindByPage(offset int, limit int) (result []*Order, count int64, err error) { + result, err = o.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = o.Offset(-1).Limit(-1).Count() + return +} + +func (o orderQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = o.Count() + if err != nil { + return + } + + err = o.Offset(offset).Limit(limit).Scan(result) + return +} + +func (o orderQueryDo) Scan(result interface{}) (err error) { + return o.DO.Scan(result) +} + +func (o orderQueryDo) Delete(models ...*Order) (result gen.ResultInfo, err error) { + return o.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (o orderQueryDo) ForceDelete() (gen.ResultInfo, error) { + return o.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (o orderQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return o.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (o orderQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return o.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (o orderQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (o orderQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (o orderQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (o orderQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := o.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (o orderQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := o.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (o orderQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := o.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (o orderQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(o.TableName(), "id") + if err := o.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (o orderQueryDo) GetByID(id int64) (*Order, error) { + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (o orderQueryDo) GetByIDs(ids ...int64) ([]*Order, error) { + if len(ids) == 0 { + return []*Order{}, nil + } + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (o orderQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (o orderQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(o.TableName(), "id") + return o.Where(pk.In(ids...)).Delete() +} + +func (o *orderQueryDo) withDO(do gen.Dao) *orderQueryDo { + o.DO = *do.(*gen.DO) + return o +} diff --git a/backend/database/models/query.gen.go b/backend/database/models/query.gen.go index 4e9de78..bce92a0 100644 --- a/backend/database/models/query.gen.go +++ b/backend/database/models/query.gen.go @@ -22,7 +22,10 @@ var ( ContentAssetQuery *contentAssetQuery ContentPriceQuery *contentPriceQuery MediaAssetQuery *mediaAssetQuery + OrderQuery *orderQuery + OrderItemQuery *orderItemQuery TenantQuery *tenantQuery + TenantLedgerQuery *tenantLedgerQuery TenantUserQuery *tenantUserQuery UserQuery *userQuery ) @@ -34,7 +37,10 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { ContentAssetQuery = &Q.ContentAsset ContentPriceQuery = &Q.ContentPrice MediaAssetQuery = &Q.MediaAsset + OrderQuery = &Q.Order + OrderItemQuery = &Q.OrderItem TenantQuery = &Q.Tenant + TenantLedgerQuery = &Q.TenantLedger TenantUserQuery = &Q.TenantUser UserQuery = &Q.User } @@ -47,7 +53,10 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { ContentAsset: newContentAsset(db, opts...), ContentPrice: newContentPrice(db, opts...), MediaAsset: newMediaAsset(db, opts...), + Order: newOrder(db, opts...), + OrderItem: newOrderItem(db, opts...), Tenant: newTenant(db, opts...), + TenantLedger: newTenantLedger(db, opts...), TenantUser: newTenantUser(db, opts...), User: newUser(db, opts...), } @@ -61,7 +70,10 @@ type Query struct { ContentAsset contentAssetQuery ContentPrice contentPriceQuery MediaAsset mediaAssetQuery + Order orderQuery + OrderItem orderItemQuery Tenant tenantQuery + TenantLedger tenantLedgerQuery TenantUser tenantUserQuery User userQuery } @@ -76,7 +88,10 @@ func (q *Query) clone(db *gorm.DB) *Query { ContentAsset: q.ContentAsset.clone(db), ContentPrice: q.ContentPrice.clone(db), MediaAsset: q.MediaAsset.clone(db), + Order: q.Order.clone(db), + OrderItem: q.OrderItem.clone(db), Tenant: q.Tenant.clone(db), + TenantLedger: q.TenantLedger.clone(db), TenantUser: q.TenantUser.clone(db), User: q.User.clone(db), } @@ -98,7 +113,10 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { ContentAsset: q.ContentAsset.replaceDB(db), ContentPrice: q.ContentPrice.replaceDB(db), MediaAsset: q.MediaAsset.replaceDB(db), + Order: q.Order.replaceDB(db), + OrderItem: q.OrderItem.replaceDB(db), Tenant: q.Tenant.replaceDB(db), + TenantLedger: q.TenantLedger.replaceDB(db), TenantUser: q.TenantUser.replaceDB(db), User: q.User.replaceDB(db), } @@ -110,7 +128,10 @@ type queryCtx struct { ContentAsset *contentAssetQueryDo ContentPrice *contentPriceQueryDo MediaAsset *mediaAssetQueryDo + Order *orderQueryDo + OrderItem *orderItemQueryDo Tenant *tenantQueryDo + TenantLedger *tenantLedgerQueryDo TenantUser *tenantUserQueryDo User *userQueryDo } @@ -122,7 +143,10 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { ContentAsset: q.ContentAsset.WithContext(ctx), ContentPrice: q.ContentPrice.WithContext(ctx), MediaAsset: q.MediaAsset.WithContext(ctx), + Order: q.Order.WithContext(ctx), + OrderItem: q.OrderItem.WithContext(ctx), Tenant: q.Tenant.WithContext(ctx), + TenantLedger: q.TenantLedger.WithContext(ctx), TenantUser: q.TenantUser.WithContext(ctx), User: q.User.WithContext(ctx), } diff --git a/backend/database/models/tenant_ledgers.gen.go b/backend/database/models/tenant_ledgers.gen.go new file mode 100644 index 0000000..8440c3a --- /dev/null +++ b/backend/database/models/tenant_ledgers.gen.go @@ -0,0 +1,71 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen" +) + +const TableNameTenantLedger = "tenant_ledgers" + +// TenantLedger mapped from table +type TenantLedger struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致" json:"tenant_id"` // 租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:余额账户归属用户;对应 tenant_users.user_id" json:"user_id"` // 用户ID:余额账户归属用户;对应 tenant_users.user_id + OrderID int64 `gorm:"column:order_id;type:bigint;comment:关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空" json:"order_id"` // 关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空 + Type consts.TenantLedgerType `gorm:"column:type;type:character varying(32);not null;comment:流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)" json:"amount"` // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束) + BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变更前可用余额:用于审计与对账回放" json:"balance_before"` // 变更前可用余额:用于审计与对账回放 + BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变更后可用余额:用于审计与对账回放" json:"balance_after"` // 变更后可用余额:用于审计与对账回放 + FrozenBefore int64 `gorm:"column:frozen_before;type:bigint;not null;comment:变更前冻结余额:用于审计与对账回放" json:"frozen_before"` // 变更前冻结余额:用于审计与对账回放 + FrozenAfter int64 `gorm:"column:frozen_after;type:bigint;not null;comment:变更后冻结余额:用于审计与对账回放" json:"frozen_after"` // 变更后冻结余额:用于审计与对账回放 + IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成) + Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:业务说明/后台操作原因等;用于审计" json:"remark"` // 备注:业务说明/后台操作原因等;用于审计 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() + Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *TenantLedger) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantLedger.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *TenantLedger) Save(ctx context.Context) error { + return Q.TenantLedger.WithContext(ctx).Save(m) +} + +// Create inserts the model using the default DB. +func (m *TenantLedger) Create(ctx context.Context) error { + return Q.TenantLedger.WithContext(ctx).Create(m) +} + +// Delete removes the row represented by the model using the default DB. +func (m *TenantLedger) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantLedger.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *TenantLedger) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantLedger.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *TenantLedger) Reload(ctx context.Context) error { + fresh, err := Q.TenantLedger.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/tenant_ledgers.query.gen.go b/backend/database/models/tenant_ledgers.query.gen.go new file mode 100644 index 0000000..490040e --- /dev/null +++ b/backend/database/models/tenant_ledgers.query.gen.go @@ -0,0 +1,604 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newTenantLedger(db *gorm.DB, opts ...gen.DOOption) tenantLedgerQuery { + _tenantLedgerQuery := tenantLedgerQuery{} + + _tenantLedgerQuery.tenantLedgerQueryDo.UseDB(db, opts...) + _tenantLedgerQuery.tenantLedgerQueryDo.UseModel(&TenantLedger{}) + + tableName := _tenantLedgerQuery.tenantLedgerQueryDo.TableName() + _tenantLedgerQuery.ALL = field.NewAsterisk(tableName) + _tenantLedgerQuery.ID = field.NewInt64(tableName, "id") + _tenantLedgerQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _tenantLedgerQuery.UserID = field.NewInt64(tableName, "user_id") + _tenantLedgerQuery.OrderID = field.NewInt64(tableName, "order_id") + _tenantLedgerQuery.Type = field.NewField(tableName, "type") + _tenantLedgerQuery.Amount = field.NewInt64(tableName, "amount") + _tenantLedgerQuery.BalanceBefore = field.NewInt64(tableName, "balance_before") + _tenantLedgerQuery.BalanceAfter = field.NewInt64(tableName, "balance_after") + _tenantLedgerQuery.FrozenBefore = field.NewInt64(tableName, "frozen_before") + _tenantLedgerQuery.FrozenAfter = field.NewInt64(tableName, "frozen_after") + _tenantLedgerQuery.IdempotencyKey = field.NewString(tableName, "idempotency_key") + _tenantLedgerQuery.Remark = field.NewString(tableName, "remark") + _tenantLedgerQuery.CreatedAt = field.NewTime(tableName, "created_at") + _tenantLedgerQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _tenantLedgerQuery.Order = tenantLedgerQueryBelongsToOrder{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Order", "Order"), + } + + _tenantLedgerQuery.fillFieldMap() + + return _tenantLedgerQuery +} + +type tenantLedgerQuery struct { + tenantLedgerQueryDo tenantLedgerQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致 + UserID field.Int64 // 用户ID:余额账户归属用户;对应 tenant_users.user_id + OrderID field.Int64 // 关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空 + Type field.Field // 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 + Amount field.Int64 // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束) + BalanceBefore field.Int64 // 变更前可用余额:用于审计与对账回放 + BalanceAfter field.Int64 // 变更后可用余额:用于审计与对账回放 + FrozenBefore field.Int64 // 变更前冻结余额:用于审计与对账回放 + FrozenAfter field.Int64 // 变更后冻结余额:用于审计与对账回放 + IdempotencyKey field.String // 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成) + Remark field.String // 备注:业务说明/后台操作原因等;用于审计 + CreatedAt field.Time // 创建时间:默认 now() + UpdatedAt field.Time // 更新时间:默认 now() + Order tenantLedgerQueryBelongsToOrder + + fieldMap map[string]field.Expr +} + +func (t tenantLedgerQuery) Table(newTableName string) *tenantLedgerQuery { + t.tenantLedgerQueryDo.UseTable(newTableName) + return t.updateTableName(newTableName) +} + +func (t tenantLedgerQuery) As(alias string) *tenantLedgerQuery { + t.tenantLedgerQueryDo.DO = *(t.tenantLedgerQueryDo.As(alias).(*gen.DO)) + return t.updateTableName(alias) +} + +func (t *tenantLedgerQuery) updateTableName(table string) *tenantLedgerQuery { + t.ALL = field.NewAsterisk(table) + t.ID = field.NewInt64(table, "id") + t.TenantID = field.NewInt64(table, "tenant_id") + t.UserID = field.NewInt64(table, "user_id") + t.OrderID = field.NewInt64(table, "order_id") + t.Type = field.NewField(table, "type") + t.Amount = field.NewInt64(table, "amount") + t.BalanceBefore = field.NewInt64(table, "balance_before") + t.BalanceAfter = field.NewInt64(table, "balance_after") + t.FrozenBefore = field.NewInt64(table, "frozen_before") + t.FrozenAfter = field.NewInt64(table, "frozen_after") + t.IdempotencyKey = field.NewString(table, "idempotency_key") + t.Remark = field.NewString(table, "remark") + t.CreatedAt = field.NewTime(table, "created_at") + t.UpdatedAt = field.NewTime(table, "updated_at") + + t.fillFieldMap() + + return t +} + +func (t *tenantLedgerQuery) QueryContext(ctx context.Context) (*tenantLedgerQuery, *tenantLedgerQueryDo) { + return t, t.tenantLedgerQueryDo.WithContext(ctx) +} + +func (t *tenantLedgerQuery) WithContext(ctx context.Context) *tenantLedgerQueryDo { + return t.tenantLedgerQueryDo.WithContext(ctx) +} + +func (t tenantLedgerQuery) TableName() string { return t.tenantLedgerQueryDo.TableName() } + +func (t tenantLedgerQuery) Alias() string { return t.tenantLedgerQueryDo.Alias() } + +func (t tenantLedgerQuery) Columns(cols ...field.Expr) gen.Columns { + return t.tenantLedgerQueryDo.Columns(cols...) +} + +func (t *tenantLedgerQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := t.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (t *tenantLedgerQuery) fillFieldMap() { + t.fieldMap = make(map[string]field.Expr, 15) + t.fieldMap["id"] = t.ID + t.fieldMap["tenant_id"] = t.TenantID + t.fieldMap["user_id"] = t.UserID + t.fieldMap["order_id"] = t.OrderID + t.fieldMap["type"] = t.Type + t.fieldMap["amount"] = t.Amount + t.fieldMap["balance_before"] = t.BalanceBefore + t.fieldMap["balance_after"] = t.BalanceAfter + t.fieldMap["frozen_before"] = t.FrozenBefore + t.fieldMap["frozen_after"] = t.FrozenAfter + t.fieldMap["idempotency_key"] = t.IdempotencyKey + t.fieldMap["remark"] = t.Remark + t.fieldMap["created_at"] = t.CreatedAt + t.fieldMap["updated_at"] = t.UpdatedAt + +} + +func (t tenantLedgerQuery) clone(db *gorm.DB) tenantLedgerQuery { + t.tenantLedgerQueryDo.ReplaceConnPool(db.Statement.ConnPool) + t.Order.db = db.Session(&gorm.Session{Initialized: true}) + t.Order.db.Statement.ConnPool = db.Statement.ConnPool + return t +} + +func (t tenantLedgerQuery) replaceDB(db *gorm.DB) tenantLedgerQuery { + t.tenantLedgerQueryDo.ReplaceDB(db) + t.Order.db = db.Session(&gorm.Session{}) + return t +} + +type tenantLedgerQueryBelongsToOrder struct { + db *gorm.DB + + field.RelationField +} + +func (a tenantLedgerQueryBelongsToOrder) Where(conds ...field.Expr) *tenantLedgerQueryBelongsToOrder { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a tenantLedgerQueryBelongsToOrder) WithContext(ctx context.Context) *tenantLedgerQueryBelongsToOrder { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a tenantLedgerQueryBelongsToOrder) Session(session *gorm.Session) *tenantLedgerQueryBelongsToOrder { + a.db = a.db.Session(session) + return &a +} + +func (a tenantLedgerQueryBelongsToOrder) Model(m *TenantLedger) *tenantLedgerQueryBelongsToOrderTx { + return &tenantLedgerQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())} +} + +func (a tenantLedgerQueryBelongsToOrder) Unscoped() *tenantLedgerQueryBelongsToOrder { + a.db = a.db.Unscoped() + return &a +} + +type tenantLedgerQueryBelongsToOrderTx struct{ tx *gorm.Association } + +func (a tenantLedgerQueryBelongsToOrderTx) Find() (result *Order, err error) { + return result, a.tx.Find(&result) +} + +func (a tenantLedgerQueryBelongsToOrderTx) Append(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a tenantLedgerQueryBelongsToOrderTx) Replace(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a tenantLedgerQueryBelongsToOrderTx) Delete(values ...*Order) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a tenantLedgerQueryBelongsToOrderTx) Clear() error { + return a.tx.Clear() +} + +func (a tenantLedgerQueryBelongsToOrderTx) Count() int64 { + return a.tx.Count() +} + +func (a tenantLedgerQueryBelongsToOrderTx) Unscoped() *tenantLedgerQueryBelongsToOrderTx { + a.tx = a.tx.Unscoped() + return &a +} + +type tenantLedgerQueryDo struct{ gen.DO } + +func (t tenantLedgerQueryDo) Debug() *tenantLedgerQueryDo { + return t.withDO(t.DO.Debug()) +} + +func (t tenantLedgerQueryDo) WithContext(ctx context.Context) *tenantLedgerQueryDo { + return t.withDO(t.DO.WithContext(ctx)) +} + +func (t tenantLedgerQueryDo) ReadDB() *tenantLedgerQueryDo { + return t.Clauses(dbresolver.Read) +} + +func (t tenantLedgerQueryDo) WriteDB() *tenantLedgerQueryDo { + return t.Clauses(dbresolver.Write) +} + +func (t tenantLedgerQueryDo) Session(config *gorm.Session) *tenantLedgerQueryDo { + return t.withDO(t.DO.Session(config)) +} + +func (t tenantLedgerQueryDo) Clauses(conds ...clause.Expression) *tenantLedgerQueryDo { + return t.withDO(t.DO.Clauses(conds...)) +} + +func (t tenantLedgerQueryDo) Returning(value interface{}, columns ...string) *tenantLedgerQueryDo { + return t.withDO(t.DO.Returning(value, columns...)) +} + +func (t tenantLedgerQueryDo) Not(conds ...gen.Condition) *tenantLedgerQueryDo { + return t.withDO(t.DO.Not(conds...)) +} + +func (t tenantLedgerQueryDo) Or(conds ...gen.Condition) *tenantLedgerQueryDo { + return t.withDO(t.DO.Or(conds...)) +} + +func (t tenantLedgerQueryDo) Select(conds ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Select(conds...)) +} + +func (t tenantLedgerQueryDo) Where(conds ...gen.Condition) *tenantLedgerQueryDo { + return t.withDO(t.DO.Where(conds...)) +} + +func (t tenantLedgerQueryDo) Order(conds ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Order(conds...)) +} + +func (t tenantLedgerQueryDo) Distinct(cols ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Distinct(cols...)) +} + +func (t tenantLedgerQueryDo) Omit(cols ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Omit(cols...)) +} + +func (t tenantLedgerQueryDo) Join(table schema.Tabler, on ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Join(table, on...)) +} + +func (t tenantLedgerQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.LeftJoin(table, on...)) +} + +func (t tenantLedgerQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.RightJoin(table, on...)) +} + +func (t tenantLedgerQueryDo) Group(cols ...field.Expr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Group(cols...)) +} + +func (t tenantLedgerQueryDo) Having(conds ...gen.Condition) *tenantLedgerQueryDo { + return t.withDO(t.DO.Having(conds...)) +} + +func (t tenantLedgerQueryDo) Limit(limit int) *tenantLedgerQueryDo { + return t.withDO(t.DO.Limit(limit)) +} + +func (t tenantLedgerQueryDo) Offset(offset int) *tenantLedgerQueryDo { + return t.withDO(t.DO.Offset(offset)) +} + +func (t tenantLedgerQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *tenantLedgerQueryDo { + return t.withDO(t.DO.Scopes(funcs...)) +} + +func (t tenantLedgerQueryDo) Unscoped() *tenantLedgerQueryDo { + return t.withDO(t.DO.Unscoped()) +} + +func (t tenantLedgerQueryDo) Create(values ...*TenantLedger) error { + if len(values) == 0 { + return nil + } + return t.DO.Create(values) +} + +func (t tenantLedgerQueryDo) CreateInBatches(values []*TenantLedger, batchSize int) error { + return t.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (t tenantLedgerQueryDo) Save(values ...*TenantLedger) error { + if len(values) == 0 { + return nil + } + return t.DO.Save(values) +} + +func (t tenantLedgerQueryDo) First() (*TenantLedger, error) { + if result, err := t.DO.First(); err != nil { + return nil, err + } else { + return result.(*TenantLedger), nil + } +} + +func (t tenantLedgerQueryDo) Take() (*TenantLedger, error) { + if result, err := t.DO.Take(); err != nil { + return nil, err + } else { + return result.(*TenantLedger), nil + } +} + +func (t tenantLedgerQueryDo) Last() (*TenantLedger, error) { + if result, err := t.DO.Last(); err != nil { + return nil, err + } else { + return result.(*TenantLedger), nil + } +} + +func (t tenantLedgerQueryDo) Find() ([]*TenantLedger, error) { + result, err := t.DO.Find() + return result.([]*TenantLedger), err +} + +func (t tenantLedgerQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*TenantLedger, err error) { + buf := make([]*TenantLedger, 0, batchSize) + err = t.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (t tenantLedgerQueryDo) FindInBatches(result *[]*TenantLedger, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return t.DO.FindInBatches(result, batchSize, fc) +} + +func (t tenantLedgerQueryDo) Attrs(attrs ...field.AssignExpr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Attrs(attrs...)) +} + +func (t tenantLedgerQueryDo) Assign(attrs ...field.AssignExpr) *tenantLedgerQueryDo { + return t.withDO(t.DO.Assign(attrs...)) +} + +func (t tenantLedgerQueryDo) Joins(fields ...field.RelationField) *tenantLedgerQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Joins(_f)) + } + return &t +} + +func (t tenantLedgerQueryDo) Preload(fields ...field.RelationField) *tenantLedgerQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Preload(_f)) + } + return &t +} + +func (t tenantLedgerQueryDo) FirstOrInit() (*TenantLedger, error) { + if result, err := t.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*TenantLedger), nil + } +} + +func (t tenantLedgerQueryDo) FirstOrCreate() (*TenantLedger, error) { + if result, err := t.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*TenantLedger), nil + } +} + +func (t tenantLedgerQueryDo) FindByPage(offset int, limit int) (result []*TenantLedger, count int64, err error) { + result, err = t.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = t.Offset(-1).Limit(-1).Count() + return +} + +func (t tenantLedgerQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = t.Count() + if err != nil { + return + } + + err = t.Offset(offset).Limit(limit).Scan(result) + return +} + +func (t tenantLedgerQueryDo) Scan(result interface{}) (err error) { + return t.DO.Scan(result) +} + +func (t tenantLedgerQueryDo) Delete(models ...*TenantLedger) (result gen.ResultInfo, err error) { + return t.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (t tenantLedgerQueryDo) ForceDelete() (gen.ResultInfo, error) { + return t.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (t tenantLedgerQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (t tenantLedgerQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (t tenantLedgerQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (t tenantLedgerQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (t tenantLedgerQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (t tenantLedgerQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (t tenantLedgerQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := t.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (t tenantLedgerQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := t.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (t tenantLedgerQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(t.TableName(), "id") + if err := t.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (t tenantLedgerQueryDo) GetByID(id int64) (*TenantLedger, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (t tenantLedgerQueryDo) GetByIDs(ids ...int64) ([]*TenantLedger, error) { + if len(ids) == 0 { + return []*TenantLedger{}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (t tenantLedgerQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (t tenantLedgerQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Delete() +} + +func (t *tenantLedgerQueryDo) withDO(do gen.Dao) *tenantLedgerQueryDo { + t.DO = *do.(*gen.DO) + return t +} diff --git a/backend/database/models/tenant_users.gen.go b/backend/database/models/tenant_users.gen.go index 44beba6..f06398d 100644 --- a/backend/database/models/tenant_users.gen.go +++ b/backend/database/models/tenant_users.gen.go @@ -18,14 +18,15 @@ const TableNameTenantUser = "tenant_users" // TenantUser mapped from table type TenantUser struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"` - Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"` - Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` + UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` + Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"` + Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"` + Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` + BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0" json:"balance_frozen"` // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 } // Quick operations without importing query package diff --git a/backend/database/models/tenant_users.query.gen.go b/backend/database/models/tenant_users.query.gen.go index fe083f9..280d18c 100644 --- a/backend/database/models/tenant_users.query.gen.go +++ b/backend/database/models/tenant_users.query.gen.go @@ -33,6 +33,7 @@ func newTenantUser(db *gorm.DB, opts ...gen.DOOption) tenantUserQuery { _tenantUserQuery.Status = field.NewField(tableName, "status") _tenantUserQuery.CreatedAt = field.NewTime(tableName, "created_at") _tenantUserQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _tenantUserQuery.BalanceFrozen = field.NewInt64(tableName, "balance_frozen") _tenantUserQuery.fillFieldMap() @@ -42,15 +43,16 @@ func newTenantUser(db *gorm.DB, opts ...gen.DOOption) tenantUserQuery { type tenantUserQuery struct { tenantUserQueryDo tenantUserQueryDo - ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - Role field.Array - Balance field.Int64 - Status field.Field - CreatedAt field.Time - UpdatedAt field.Time + ALL field.Asterisk + ID field.Int64 + TenantID field.Int64 + UserID field.Int64 + Role field.Array + Balance field.Int64 + Status field.Field + CreatedAt field.Time + UpdatedAt field.Time + BalanceFrozen field.Int64 // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 fieldMap map[string]field.Expr } @@ -75,6 +77,7 @@ func (t *tenantUserQuery) updateTableName(table string) *tenantUserQuery { t.Status = field.NewField(table, "status") t.CreatedAt = field.NewTime(table, "created_at") t.UpdatedAt = field.NewTime(table, "updated_at") + t.BalanceFrozen = field.NewInt64(table, "balance_frozen") t.fillFieldMap() @@ -107,7 +110,7 @@ func (t *tenantUserQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo } func (t *tenantUserQuery) fillFieldMap() { - t.fieldMap = make(map[string]field.Expr, 8) + t.fieldMap = make(map[string]field.Expr, 9) t.fieldMap["id"] = t.ID t.fieldMap["tenant_id"] = t.TenantID t.fieldMap["user_id"] = t.UserID @@ -116,6 +119,7 @@ func (t *tenantUserQuery) fillFieldMap() { t.fieldMap["status"] = t.Status t.fieldMap["created_at"] = t.CreatedAt t.fieldMap["updated_at"] = t.UpdatedAt + t.fieldMap["balance_frozen"] = t.BalanceFrozen } func (t tenantUserQuery) clone(db *gorm.DB) tenantUserQuery { diff --git a/backend/database/models/users.gen.go b/backend/database/models/users.gen.go index 72844a7..a21ab32 100644 --- a/backend/database/models/users.gen.go +++ b/backend/database/models/users.gen.go @@ -29,8 +29,8 @@ type User struct { Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"` Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp with time zone" json:"verified_at"` - OwnedTenant *Tenant `json:"owned,omitempty"` Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"` + OwnedTenant *Tenant `json:"owned,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/users.query.gen.go b/backend/database/models/users.query.gen.go index 5a1f7f5..c84903f 100644 --- a/backend/database/models/users.query.gen.go +++ b/backend/database/models/users.query.gen.go @@ -35,18 +35,18 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { _userQuery.Status = field.NewField(tableName, "status") _userQuery.Metas = field.NewJSONB(tableName, "metas") _userQuery.VerifiedAt = field.NewTime(tableName, "verified_at") - _userQuery.OwnedTenant = userQueryBelongsToOwnedTenant{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("OwnedTenant", "Tenant"), - } - _userQuery.Tenants = userQueryManyToManyTenants{ db: db.Session(&gorm.Session{}), RelationField: field.NewRelation("Tenants", "Tenant"), } + _userQuery.OwnedTenant = userQueryBelongsToOwnedTenant{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("OwnedTenant", "Tenant"), + } + _userQuery.fillFieldMap() return _userQuery @@ -55,20 +55,20 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { type userQuery struct { userQueryDo userQueryDo - ALL field.Asterisk - ID field.Int64 - CreatedAt field.Time - UpdatedAt field.Time - DeletedAt field.Field - Username field.String - Password field.String - Roles field.Array - Status field.Field - Metas field.JSONB - VerifiedAt field.Time - OwnedTenant userQueryBelongsToOwnedTenant + ALL field.Asterisk + ID field.Int64 + CreatedAt field.Time + UpdatedAt field.Time + DeletedAt field.Field + Username field.String + Password field.String + Roles field.Array + Status field.Field + Metas field.JSONB + VerifiedAt field.Time + Tenants userQueryManyToManyTenants - Tenants userQueryManyToManyTenants + OwnedTenant userQueryBelongsToOwnedTenant fieldMap map[string]field.Expr } @@ -141,101 +141,20 @@ func (u *userQuery) fillFieldMap() { func (u userQuery) clone(db *gorm.DB) userQuery { u.userQueryDo.ReplaceConnPool(db.Statement.ConnPool) - u.OwnedTenant.db = db.Session(&gorm.Session{Initialized: true}) - u.OwnedTenant.db.Statement.ConnPool = db.Statement.ConnPool u.Tenants.db = db.Session(&gorm.Session{Initialized: true}) u.Tenants.db.Statement.ConnPool = db.Statement.ConnPool + u.OwnedTenant.db = db.Session(&gorm.Session{Initialized: true}) + u.OwnedTenant.db.Statement.ConnPool = db.Statement.ConnPool return u } func (u userQuery) replaceDB(db *gorm.DB) userQuery { u.userQueryDo.ReplaceDB(db) - u.OwnedTenant.db = db.Session(&gorm.Session{}) u.Tenants.db = db.Session(&gorm.Session{}) + u.OwnedTenant.db = db.Session(&gorm.Session{}) return u } -type userQueryBelongsToOwnedTenant struct { - db *gorm.DB - - field.RelationField -} - -func (a userQueryBelongsToOwnedTenant) Where(conds ...field.Expr) *userQueryBelongsToOwnedTenant { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a userQueryBelongsToOwnedTenant) WithContext(ctx context.Context) *userQueryBelongsToOwnedTenant { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a userQueryBelongsToOwnedTenant) Session(session *gorm.Session) *userQueryBelongsToOwnedTenant { - a.db = a.db.Session(session) - return &a -} - -func (a userQueryBelongsToOwnedTenant) Model(m *User) *userQueryBelongsToOwnedTenantTx { - return &userQueryBelongsToOwnedTenantTx{a.db.Model(m).Association(a.Name())} -} - -func (a userQueryBelongsToOwnedTenant) Unscoped() *userQueryBelongsToOwnedTenant { - a.db = a.db.Unscoped() - return &a -} - -type userQueryBelongsToOwnedTenantTx struct{ tx *gorm.Association } - -func (a userQueryBelongsToOwnedTenantTx) Find() (result *Tenant, err error) { - return result, a.tx.Find(&result) -} - -func (a userQueryBelongsToOwnedTenantTx) Append(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a userQueryBelongsToOwnedTenantTx) Replace(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a userQueryBelongsToOwnedTenantTx) Delete(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a userQueryBelongsToOwnedTenantTx) Clear() error { - return a.tx.Clear() -} - -func (a userQueryBelongsToOwnedTenantTx) Count() int64 { - return a.tx.Count() -} - -func (a userQueryBelongsToOwnedTenantTx) Unscoped() *userQueryBelongsToOwnedTenantTx { - a.tx = a.tx.Unscoped() - return &a -} - type userQueryManyToManyTenants struct { db *gorm.DB @@ -317,6 +236,87 @@ func (a userQueryManyToManyTenantsTx) Unscoped() *userQueryManyToManyTenantsTx { return &a } +type userQueryBelongsToOwnedTenant struct { + db *gorm.DB + + field.RelationField +} + +func (a userQueryBelongsToOwnedTenant) Where(conds ...field.Expr) *userQueryBelongsToOwnedTenant { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a userQueryBelongsToOwnedTenant) WithContext(ctx context.Context) *userQueryBelongsToOwnedTenant { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a userQueryBelongsToOwnedTenant) Session(session *gorm.Session) *userQueryBelongsToOwnedTenant { + a.db = a.db.Session(session) + return &a +} + +func (a userQueryBelongsToOwnedTenant) Model(m *User) *userQueryBelongsToOwnedTenantTx { + return &userQueryBelongsToOwnedTenantTx{a.db.Model(m).Association(a.Name())} +} + +func (a userQueryBelongsToOwnedTenant) Unscoped() *userQueryBelongsToOwnedTenant { + a.db = a.db.Unscoped() + return &a +} + +type userQueryBelongsToOwnedTenantTx struct{ tx *gorm.Association } + +func (a userQueryBelongsToOwnedTenantTx) Find() (result *Tenant, err error) { + return result, a.tx.Find(&result) +} + +func (a userQueryBelongsToOwnedTenantTx) Append(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a userQueryBelongsToOwnedTenantTx) Replace(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a userQueryBelongsToOwnedTenantTx) Delete(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a userQueryBelongsToOwnedTenantTx) Clear() error { + return a.tx.Clear() +} + +func (a userQueryBelongsToOwnedTenantTx) Count() int64 { + return a.tx.Count() +} + +func (a userQueryBelongsToOwnedTenantTx) Unscoped() *userQueryBelongsToOwnedTenantTx { + a.tx = a.tx.Unscoped() + return &a +} + type userQueryDo struct{ gen.DO } func (u userQueryDo) Debug() *userQueryDo { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e3f541c..3c221fa 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -603,6 +603,176 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/orders": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ], + "description": "Status filters orders by order status.", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "UserID filters orders by buyer user id.", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.Order" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/orders/{orderID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单详情(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminOrderDetail" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/orders/{orderID}/refund": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单退款(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminOrderRefundForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } + }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -781,6 +951,54 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/contents/{contentID}/purchase": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "购买内容(余额支付)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PurchaseContentForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.PurchaseContentResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/me": { "get": { "consumes": [ @@ -811,9 +1029,138 @@ const docTemplate = `{ } } } + }, + "/t/{tenantCode}/v1/orders": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "我的订单列表(当前租户)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ], + "description": "Status filters orders by order status.", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.Order" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/orders/{orderID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "我的订单详情(当前租户)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } } }, "definitions": { + "consts.ContentAccessStatus": { + "type": "string", + "enum": [ + "active", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "ContentAccessStatusActive", + "ContentAccessStatusRevoked", + "ContentAccessStatusExpired" + ] + }, "consts.ContentAssetRole": { "type": "string", "enum": [ @@ -909,6 +1256,36 @@ const docTemplate = `{ "MediaAssetTypeImage" ] }, + "consts.OrderStatus": { + "type": "string", + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ] + }, + "consts.OrderType": { + "type": "string", + "enum": [ + "content_purchase", + "topup" + ], + "x-enum-varnames": [ + "OrderTypeContentPurchase", + "OrderTypeTopup" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -957,6 +1334,36 @@ const docTemplate = `{ "UserStatusBanned" ] }, + "dto.AdminOrderDetail": { + "type": "object", + "properties": { + "order": { + "description": "Order is the order with items preloaded.", + "allOf": [ + { + "$ref": "#/definitions/models.Order" + } + ] + } + } + }, + "dto.AdminOrderRefundForm": { + "type": "object", + "properties": { + "force": { + "description": "Force indicates bypassing the default refund window check (paid_at + 24h).\n强制退款:true 表示绕过默认退款时间窗限制(需审计)。", + "type": "boolean" + }, + "idempotency_key": { + "description": "IdempotencyKey ensures refund request is processed at most once.\n幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。", + "type": "string" + }, + "reason": { + "description": "Reason is the human-readable refund reason used for audit.\n退款原因:建议必填(由业务侧校验);用于审计与追责。", + "type": "string" + } + } + }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { @@ -1195,6 +1602,48 @@ const docTemplate = `{ } } }, + "dto.PurchaseContentForm": { + "type": "object", + "properties": { + "idempotency_key": { + "description": "IdempotencyKey is used to ensure the purchase request is processed at most once.\n建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。", + "type": "string" + } + } + }, + "dto.PurchaseContentResponse": { + "type": "object", + "properties": { + "access": { + "description": "Access is the content access record after purchase grant.", + "allOf": [ + { + "$ref": "#/definitions/models.ContentAccess" + } + ] + }, + "amount_paid": { + "description": "AmountPaid is the final paid amount in cents (CNY 分).", + "type": "integer" + }, + "item": { + "description": "Item is the single order item of this purchase (current implementation is 1 order -\u003e 1 content).", + "allOf": [ + { + "$ref": "#/definitions/models.OrderItem" + } + ] + }, + "order": { + "description": "Order is the created or existing order record (may be nil for owner/free-path without order).", + "allOf": [ + { + "$ref": "#/definitions/models.Order" + } + ] + } + } + }, "dto.TenantExpireUpdateForm": { "type": "object", "required": [ @@ -1387,43 +1836,113 @@ const docTemplate = `{ "type": "object", "properties": { "created_at": { + "description": "创建时间:默认 now();用于审计与排序", "type": "string" }, "deleted_at": { - "$ref": "#/definitions/gorm.DeletedAt" + "description": "软删除时间:非空表示已删除;对外接口需过滤", + "allOf": [ + { + "$ref": "#/definitions/gorm.DeletedAt" + } + ] }, "description": { + "description": "描述:用于详情页展示;可为空字符串", "type": "string" }, "id": { + "description": "主键ID:自增;用于内容引用", "type": "integer" }, "preview_downloadable": { + "description": "试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming)", "type": "boolean" }, "preview_seconds": { + "description": "试看秒数:默认 60;只对 preview 资源生效;必须为正整数", "type": "integer" }, "published_at": { + "description": "发布时间:首次发布时写入;用于时间窗与排序", "type": "string" }, "status": { - "$ref": "#/definitions/consts.ContentStatus" + "description": "状态:draft/reviewing/published/unpublished/blocked;published 才对外展示", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] }, "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" }, "title": { + "description": "标题:用于列表展示与搜索;建议限制长度(由业务校验)", "type": "string" }, "updated_at": { + "description": "更新时间:默认 now();编辑内容时写入", "type": "string" }, "user_id": { + "description": "用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见)", "type": "integer" }, "visibility": { - "$ref": "#/definitions/consts.ContentVisibility" + "description": "可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "models.ContentAccess": { + "type": "object", + "properties": { + "content_id": { + "description": "内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id)", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now();用于审计", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "order_id": { + "description": "订单ID:产生该权益的订单;可为空(例如后台补发/迁移)", + "type": "integer" + }, + "revoked_at": { + "description": "撤销时间:当 status=revoked 时写入;用于审计与追责", + "type": "string" + }, + "status": { + "description": "权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规)", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAccessStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离;与内容、用户归属一致", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now();更新 status 时写入", + "type": "string" + }, + "user_id": { + "description": "用户ID:权益所属用户;用于访问校验", + "type": "integer" } } }, @@ -1431,30 +1950,43 @@ const docTemplate = `{ "type": "object", "properties": { "asset_id": { + "description": "资源ID:关联 media_assets.id;用于查询资源归属内容", "type": "integer" }, "content_id": { + "description": "内容ID:关联 contents.id;用于查询内容下资源列表", "type": "integer" }, "created_at": { + "description": "创建时间:默认 now();用于审计", "type": "string" }, "id": { + "description": "主键ID:自增", "type": "integer" }, "role": { - "$ref": "#/definitions/consts.ContentAssetRole" + "description": "资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAssetRole" + } + ] }, "sort": { + "description": "排序:同一 role 下的展示顺序,数值越小越靠前", "type": "integer" }, "tenant_id": { + "description": "租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致", "type": "integer" }, "updated_at": { + "description": "更新时间:默认 now();更新 sort/role 时写入", "type": "string" }, "user_id": { + "description": "用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者)", "type": "integer" } } @@ -1463,39 +1995,59 @@ const docTemplate = `{ "type": "object", "properties": { "content_id": { + "description": "内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价", "type": "integer" }, "created_at": { + "description": "创建时间:默认 now();用于审计", "type": "string" }, "currency": { - "$ref": "#/definitions/consts.Currency" + "description": "币种:当前固定 CNY;金额单位为分", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] }, "discount_end_at": { + "description": "折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释)", "type": "string" }, "discount_start_at": { + "description": "折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释)", "type": "string" }, "discount_type": { - "$ref": "#/definitions/consts.DiscountType" + "description": "折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照", + "allOf": [ + { + "$ref": "#/definitions/consts.DiscountType" + } + ] }, "discount_value": { + "description": "折扣值:percent=0-100(按业务校验);amount=分;none 时忽略", "type": "integer" }, "id": { + "description": "主键ID:自增", "type": "integer" }, "price_amount": { + "description": "基础价格:分;0 表示免费(可直接访问正片资源)", "type": "integer" }, "tenant_id": { + "description": "租户ID:多租户隔离;与内容归属一致", "type": "integer" }, "updated_at": { + "description": "更新时间:默认 now();更新价格/折扣时写入", "type": "string" }, "user_id": { + "description": "用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计", "type": "integer" } } @@ -1504,42 +2056,218 @@ const docTemplate = `{ "type": "object", "properties": { "bucket": { + "description": "存储桶:对象所在 bucket;与 provider 组合确定存储定位", "type": "string" }, "created_at": { + "description": "创建时间:默认 now();用于审计与排序", "type": "string" }, "deleted_at": { - "$ref": "#/definitions/gorm.DeletedAt" + "description": "软删除时间:非空表示已删除;对外接口需过滤", + "allOf": [ + { + "$ref": "#/definitions/gorm.DeletedAt" + } + ] }, "id": { + "description": "主键ID:自增;仅用于内部关联", "type": "integer" }, "meta": { + "description": "元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控", "type": "array", "items": { "type": "integer" } }, "object_key": { + "description": "对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)", "type": "string" }, "provider": { + "description": "存储提供方:例如 s3/minio/oss;便于多存储扩展", "type": "string" }, "status": { - "$ref": "#/definitions/consts.MediaAssetStatus" + "description": "处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetStatus" + } + ] }, "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" }, "type": { - "$ref": "#/definitions/consts.MediaAssetType" + "description": "资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] }, "updated_at": { + "description": "更新时间:默认 now();更新状态/元数据时写入", "type": "string" }, "user_id": { + "description": "用户ID:资源上传者;用于审计与权限控制", + "type": "integer" + } + } + }, + "models.Order": { + "type": "object", + "properties": { + "amount_discount": { + "description": "优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)", + "type": "integer" + }, + "amount_original": { + "description": "原价金额:分;未折扣前金额(用于展示与对账)", + "type": "integer" + }, + "amount_paid": { + "description": "实付金额:分;从租户内余额扣款的金额(下单时快照)", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now();用于审计与排序", + "type": "string" + }, + "currency": { + "description": "币种:当前固定 CNY;金额单位为分", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "id": { + "description": "主键ID:自增;用于关联订单明细、账本流水、权益等", + "type": "integer" + }, + "idempotency_key": { + "description": "幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.OrderItem" + } + }, + "paid_at": { + "description": "支付/扣款完成时间:余额支付在 debit_purchase 成功后写入", + "type": "string" + }, + "refund_forced": { + "description": "是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)", + "type": "boolean" + }, + "refund_operator_user_id": { + "description": "退款操作人用户ID:租户管理员/系统;用于审计与追责", + "type": "integer" + }, + "refund_reason": { + "description": "退款原因:后台/用户发起退款的原因说明;用于审计", + "type": "string" + }, + "refunded_at": { + "description": "退款完成时间:退款落账成功后写入", + "type": "string" + }, + "snapshot": { + "description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "type": { + "description": "订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "updated_at": { + "description": "更新时间:默认 now();状态变更/退款写入时更新", + "type": "string" + }, + "user_id": { + "description": "用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准", + "type": "integer" + } + } + }, + "models.OrderItem": { + "type": "object", + "properties": { + "amount_paid": { + "description": "该行实付金额:分;通常等于订单 amount_paid(单内容场景)", + "type": "integer" + }, + "content": { + "$ref": "#/definitions/models.Content" + }, + "content_id": { + "description": "内容ID:关联 contents.id;用于生成/撤销 content_access", + "type": "integer" + }, + "content_user_id": { + "description": "内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "order": { + "$ref": "#/definitions/models.Order" + }, + "order_id": { + "description": "订单ID:关联 orders.id;用于聚合订单明细", + "type": "integer" + }, + "snapshot": { + "description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计", + "type": "array", + "items": { + "type": "integer" + } + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "用户ID:下单用户(buyer);冗余字段用于查询加速与审计", "type": "integer" } } @@ -1594,6 +2322,10 @@ const docTemplate = `{ "balance": { "type": "integer" }, + "balance_frozen": { + "description": "冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0", + "type": "integer" + }, "created_at": { "type": "string" }, @@ -1718,7 +2450,7 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "localhost:8080", - BasePath: "/t/{tenantCode}/v1", + BasePath: "/", Schemes: []string{}, Title: "ApiDoc", Description: "Multi-tenant media platform backend API.", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 916d800..c59fdde 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -16,7 +16,7 @@ "version": "1.0" }, "host": "localhost:8080", - "basePath": "/t/{tenantCode}/v1", + "basePath": "/", "paths": { "/super/v1/auth/login": { "post": { @@ -597,6 +597,176 @@ } } }, + "/t/{tenantCode}/v1/admin/orders": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ], + "description": "Status filters orders by order status.", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "UserID filters orders by buyer user id.", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.Order" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/orders/{orderID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单详情(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AdminOrderDetail" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/orders/{orderID}/refund": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "订单退款(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminOrderRefundForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } + }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -775,6 +945,54 @@ } } }, + "/t/{tenantCode}/v1/contents/{contentID}/purchase": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "购买内容(余额支付)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PurchaseContentForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.PurchaseContentResponse" + } + } + } + } + }, "/t/{tenantCode}/v1/me": { "get": { "consumes": [ @@ -805,9 +1023,138 @@ } } } + }, + "/t/{tenantCode}/v1/orders": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "我的订单列表(当前租户)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ], + "description": "Status filters orders by order status.", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.Order" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/orders/{orderID}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "我的订单详情(当前租户)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "OrderID", + "name": "orderID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Order" + } + } + } + } } }, "definitions": { + "consts.ContentAccessStatus": { + "type": "string", + "enum": [ + "active", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "ContentAccessStatusActive", + "ContentAccessStatusRevoked", + "ContentAccessStatusExpired" + ] + }, "consts.ContentAssetRole": { "type": "string", "enum": [ @@ -903,6 +1250,36 @@ "MediaAssetTypeImage" ] }, + "consts.OrderStatus": { + "type": "string", + "enum": [ + "created", + "paid", + "refunding", + "refunded", + "canceled", + "failed" + ], + "x-enum-varnames": [ + "OrderStatusCreated", + "OrderStatusPaid", + "OrderStatusRefunding", + "OrderStatusRefunded", + "OrderStatusCanceled", + "OrderStatusFailed" + ] + }, + "consts.OrderType": { + "type": "string", + "enum": [ + "content_purchase", + "topup" + ], + "x-enum-varnames": [ + "OrderTypeContentPurchase", + "OrderTypeTopup" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -951,6 +1328,36 @@ "UserStatusBanned" ] }, + "dto.AdminOrderDetail": { + "type": "object", + "properties": { + "order": { + "description": "Order is the order with items preloaded.", + "allOf": [ + { + "$ref": "#/definitions/models.Order" + } + ] + } + } + }, + "dto.AdminOrderRefundForm": { + "type": "object", + "properties": { + "force": { + "description": "Force indicates bypassing the default refund window check (paid_at + 24h).\n强制退款:true 表示绕过默认退款时间窗限制(需审计)。", + "type": "boolean" + }, + "idempotency_key": { + "description": "IdempotencyKey ensures refund request is processed at most once.\n幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。", + "type": "string" + }, + "reason": { + "description": "Reason is the human-readable refund reason used for audit.\n退款原因:建议必填(由业务侧校验);用于审计与追责。", + "type": "string" + } + } + }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { @@ -1189,6 +1596,48 @@ } } }, + "dto.PurchaseContentForm": { + "type": "object", + "properties": { + "idempotency_key": { + "description": "IdempotencyKey is used to ensure the purchase request is processed at most once.\n建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。", + "type": "string" + } + } + }, + "dto.PurchaseContentResponse": { + "type": "object", + "properties": { + "access": { + "description": "Access is the content access record after purchase grant.", + "allOf": [ + { + "$ref": "#/definitions/models.ContentAccess" + } + ] + }, + "amount_paid": { + "description": "AmountPaid is the final paid amount in cents (CNY 分).", + "type": "integer" + }, + "item": { + "description": "Item is the single order item of this purchase (current implementation is 1 order -\u003e 1 content).", + "allOf": [ + { + "$ref": "#/definitions/models.OrderItem" + } + ] + }, + "order": { + "description": "Order is the created or existing order record (may be nil for owner/free-path without order).", + "allOf": [ + { + "$ref": "#/definitions/models.Order" + } + ] + } + } + }, "dto.TenantExpireUpdateForm": { "type": "object", "required": [ @@ -1381,43 +1830,113 @@ "type": "object", "properties": { "created_at": { + "description": "创建时间:默认 now();用于审计与排序", "type": "string" }, "deleted_at": { - "$ref": "#/definitions/gorm.DeletedAt" + "description": "软删除时间:非空表示已删除;对外接口需过滤", + "allOf": [ + { + "$ref": "#/definitions/gorm.DeletedAt" + } + ] }, "description": { + "description": "描述:用于详情页展示;可为空字符串", "type": "string" }, "id": { + "description": "主键ID:自增;用于内容引用", "type": "integer" }, "preview_downloadable": { + "description": "试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming)", "type": "boolean" }, "preview_seconds": { + "description": "试看秒数:默认 60;只对 preview 资源生效;必须为正整数", "type": "integer" }, "published_at": { + "description": "发布时间:首次发布时写入;用于时间窗与排序", "type": "string" }, "status": { - "$ref": "#/definitions/consts.ContentStatus" + "description": "状态:draft/reviewing/published/unpublished/blocked;published 才对外展示", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] }, "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" }, "title": { + "description": "标题:用于列表展示与搜索;建议限制长度(由业务校验)", "type": "string" }, "updated_at": { + "description": "更新时间:默认 now();编辑内容时写入", "type": "string" }, "user_id": { + "description": "用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见)", "type": "integer" }, "visibility": { - "$ref": "#/definitions/consts.ContentVisibility" + "description": "可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentVisibility" + } + ] + } + } + }, + "models.ContentAccess": { + "type": "object", + "properties": { + "content_id": { + "description": "内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id)", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now();用于审计", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "order_id": { + "description": "订单ID:产生该权益的订单;可为空(例如后台补发/迁移)", + "type": "integer" + }, + "revoked_at": { + "description": "撤销时间:当 status=revoked 时写入;用于审计与追责", + "type": "string" + }, + "status": { + "description": "权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规)", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAccessStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离;与内容、用户归属一致", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now();更新 status 时写入", + "type": "string" + }, + "user_id": { + "description": "用户ID:权益所属用户;用于访问校验", + "type": "integer" } } }, @@ -1425,30 +1944,43 @@ "type": "object", "properties": { "asset_id": { + "description": "资源ID:关联 media_assets.id;用于查询资源归属内容", "type": "integer" }, "content_id": { + "description": "内容ID:关联 contents.id;用于查询内容下资源列表", "type": "integer" }, "created_at": { + "description": "创建时间:默认 now();用于审计", "type": "string" }, "id": { + "description": "主键ID:自增", "type": "integer" }, "role": { - "$ref": "#/definitions/consts.ContentAssetRole" + "description": "资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAssetRole" + } + ] }, "sort": { + "description": "排序:同一 role 下的展示顺序,数值越小越靠前", "type": "integer" }, "tenant_id": { + "description": "租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致", "type": "integer" }, "updated_at": { + "description": "更新时间:默认 now();更新 sort/role 时写入", "type": "string" }, "user_id": { + "description": "用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者)", "type": "integer" } } @@ -1457,39 +1989,59 @@ "type": "object", "properties": { "content_id": { + "description": "内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价", "type": "integer" }, "created_at": { + "description": "创建时间:默认 now();用于审计", "type": "string" }, "currency": { - "$ref": "#/definitions/consts.Currency" + "description": "币种:当前固定 CNY;金额单位为分", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] }, "discount_end_at": { + "description": "折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释)", "type": "string" }, "discount_start_at": { + "description": "折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释)", "type": "string" }, "discount_type": { - "$ref": "#/definitions/consts.DiscountType" + "description": "折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照", + "allOf": [ + { + "$ref": "#/definitions/consts.DiscountType" + } + ] }, "discount_value": { + "description": "折扣值:percent=0-100(按业务校验);amount=分;none 时忽略", "type": "integer" }, "id": { + "description": "主键ID:自增", "type": "integer" }, "price_amount": { + "description": "基础价格:分;0 表示免费(可直接访问正片资源)", "type": "integer" }, "tenant_id": { + "description": "租户ID:多租户隔离;与内容归属一致", "type": "integer" }, "updated_at": { + "description": "更新时间:默认 now();更新价格/折扣时写入", "type": "string" }, "user_id": { + "description": "用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计", "type": "integer" } } @@ -1498,42 +2050,218 @@ "type": "object", "properties": { "bucket": { + "description": "存储桶:对象所在 bucket;与 provider 组合确定存储定位", "type": "string" }, "created_at": { + "description": "创建时间:默认 now();用于审计与排序", "type": "string" }, "deleted_at": { - "$ref": "#/definitions/gorm.DeletedAt" + "description": "软删除时间:非空表示已删除;对外接口需过滤", + "allOf": [ + { + "$ref": "#/definitions/gorm.DeletedAt" + } + ] }, "id": { + "description": "主键ID:自增;仅用于内部关联", "type": "integer" }, "meta": { + "description": "元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控", "type": "array", "items": { "type": "integer" } }, "object_key": { + "description": "对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)", "type": "string" }, "provider": { + "description": "存储提供方:例如 s3/minio/oss;便于多存储扩展", "type": "string" }, "status": { - "$ref": "#/definitions/consts.MediaAssetStatus" + "description": "处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetStatus" + } + ] }, "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", "type": "integer" }, "type": { - "$ref": "#/definitions/consts.MediaAssetType" + "description": "资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] }, "updated_at": { + "description": "更新时间:默认 now();更新状态/元数据时写入", "type": "string" }, "user_id": { + "description": "用户ID:资源上传者;用于审计与权限控制", + "type": "integer" + } + } + }, + "models.Order": { + "type": "object", + "properties": { + "amount_discount": { + "description": "优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)", + "type": "integer" + }, + "amount_original": { + "description": "原价金额:分;未折扣前金额(用于展示与对账)", + "type": "integer" + }, + "amount_paid": { + "description": "实付金额:分;从租户内余额扣款的金额(下单时快照)", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now();用于审计与排序", + "type": "string" + }, + "currency": { + "description": "币种:当前固定 CNY;金额单位为分", + "allOf": [ + { + "$ref": "#/definitions/consts.Currency" + } + ] + }, + "id": { + "description": "主键ID:自增;用于关联订单明细、账本流水、权益等", + "type": "integer" + }, + "idempotency_key": { + "description": "幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.OrderItem" + } + }, + "paid_at": { + "description": "支付/扣款完成时间:余额支付在 debit_purchase 成功后写入", + "type": "string" + }, + "refund_forced": { + "description": "是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)", + "type": "boolean" + }, + "refund_operator_user_id": { + "description": "退款操作人用户ID:租户管理员/系统;用于审计与追责", + "type": "integer" + }, + "refund_reason": { + "description": "退款原因:后台/用户发起退款的原因说明;用于审计", + "type": "string" + }, + "refunded_at": { + "description": "退款完成时间:退款落账成功后写入", + "type": "string" + }, + "snapshot": { + "description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "type": { + "description": "订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "updated_at": { + "description": "更新时间:默认 now();状态变更/退款写入时更新", + "type": "string" + }, + "user_id": { + "description": "用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准", + "type": "integer" + } + } + }, + "models.OrderItem": { + "type": "object", + "properties": { + "amount_paid": { + "description": "该行实付金额:分;通常等于订单 amount_paid(单内容场景)", + "type": "integer" + }, + "content": { + "$ref": "#/definitions/models.Content" + }, + "content_id": { + "description": "内容ID:关联 contents.id;用于生成/撤销 content_access", + "type": "integer" + }, + "content_user_id": { + "description": "内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者", + "type": "integer" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "order": { + "$ref": "#/definitions/models.Order" + }, + "order_id": { + "description": "订单ID:关联 orders.id;用于聚合订单明细", + "type": "integer" + }, + "snapshot": { + "description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计", + "type": "array", + "items": { + "type": "integer" + } + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "用户ID:下单用户(buyer);冗余字段用于查询加速与审计", "type": "integer" } } @@ -1588,6 +2316,10 @@ "balance": { "type": "integer" }, + "balance_frozen": { + "description": "冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0", + "type": "integer" + }, "created_at": { "type": "string" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 7cbbbdd..790ce13 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,15 @@ -basePath: /t/{tenantCode}/v1 +basePath: / definitions: + consts.ContentAccessStatus: + enum: + - active + - revoked + - expired + type: string + x-enum-varnames: + - ContentAccessStatusActive + - ContentAccessStatusRevoked + - ContentAccessStatusExpired consts.ContentAssetRole: enum: - main @@ -74,6 +84,30 @@ definitions: - MediaAssetTypeVideo - MediaAssetTypeAudio - MediaAssetTypeImage + consts.OrderStatus: + enum: + - created + - paid + - refunding + - refunded + - canceled + - failed + type: string + x-enum-varnames: + - OrderStatusCreated + - OrderStatusPaid + - OrderStatusRefunding + - OrderStatusRefunded + - OrderStatusCanceled + - OrderStatusFailed + consts.OrderType: + enum: + - content_purchase + - topup + type: string + x-enum-varnames: + - OrderTypeContentPurchase + - OrderTypeTopup consts.Role: enum: - user @@ -110,6 +144,31 @@ definitions: - UserStatusPendingVerify - UserStatusVerified - UserStatusBanned + dto.AdminOrderDetail: + properties: + order: + allOf: + - $ref: '#/definitions/models.Order' + description: Order is the order with items preloaded. + type: object + dto.AdminOrderRefundForm: + properties: + force: + description: |- + Force indicates bypassing the default refund window check (paid_at + 24h). + 强制退款:true 表示绕过默认退款时间窗限制(需审计)。 + type: boolean + idempotency_key: + description: |- + IdempotencyKey ensures refund request is processed at most once. + 幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。 + type: string + reason: + description: |- + Reason is the human-readable refund reason used for audit. + 退款原因:建议必填(由业务侧校验);用于审计与追责。 + type: string + type: object dto.ContentAssetAttachForm: properties: asset_id: @@ -261,6 +320,34 @@ definitions: - $ref: '#/definitions/models.User' description: User is the authenticated user derived from JWT `user_id`. type: object + dto.PurchaseContentForm: + properties: + idempotency_key: + description: |- + IdempotencyKey is used to ensure the purchase request is processed at most once. + 建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。 + type: string + type: object + dto.PurchaseContentResponse: + properties: + access: + allOf: + - $ref: '#/definitions/models.ContentAccess' + description: Access is the content access record after purchase grant. + amount_paid: + description: AmountPaid is the final paid amount in cents (CNY 分). + type: integer + item: + allOf: + - $ref: '#/definitions/models.OrderItem' + description: Item is the single order item of this purchase (current implementation + is 1 order -> 1 content). + order: + allOf: + - $ref: '#/definitions/models.Order' + description: Order is the created or existing order record (may be nil for + owner/free-path without order). + type: object dto.TenantExpireUpdateForm: properties: duration: @@ -386,107 +473,298 @@ definitions: models.Content: properties: created_at: + description: 创建时间:默认 now();用于审计与排序 type: string deleted_at: - $ref: '#/definitions/gorm.DeletedAt' + allOf: + - $ref: '#/definitions/gorm.DeletedAt' + description: 软删除时间:非空表示已删除;对外接口需过滤 description: + description: 描述:用于详情页展示;可为空字符串 type: string id: + description: 主键ID:自增;用于内容引用 type: integer preview_downloadable: + description: 试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming) type: boolean preview_seconds: + description: 试看秒数:默认 60;只对 preview 资源生效;必须为正整数 type: integer published_at: + description: 发布时间:首次发布时写入;用于时间窗与排序 type: string status: - $ref: '#/definitions/consts.ContentStatus' + allOf: + - $ref: '#/definitions/consts.ContentStatus' + description: 状态:draft/reviewing/published/unpublished/blocked;published 才对外展示 tenant_id: + description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id type: integer title: + description: 标题:用于列表展示与搜索;建议限制长度(由业务校验) type: string updated_at: + description: 更新时间:默认 now();编辑内容时写入 type: string user_id: + description: 用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见) type: integer visibility: - $ref: '#/definitions/consts.ContentVisibility' + allOf: + - $ref: '#/definitions/consts.ContentVisibility' + description: 可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验 + type: object + models.ContentAccess: + properties: + content_id: + description: 内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id) + type: integer + created_at: + description: 创建时间:默认 now();用于审计 + type: string + id: + description: 主键ID:自增 + type: integer + order_id: + description: 订单ID:产生该权益的订单;可为空(例如后台补发/迁移) + type: integer + revoked_at: + description: 撤销时间:当 status=revoked 时写入;用于审计与追责 + type: string + status: + allOf: + - $ref: '#/definitions/consts.ContentAccessStatus' + description: 权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规) + tenant_id: + description: 租户ID:多租户隔离;与内容、用户归属一致 + type: integer + updated_at: + description: 更新时间:默认 now();更新 status 时写入 + type: string + user_id: + description: 用户ID:权益所属用户;用于访问校验 + type: integer type: object models.ContentAsset: properties: asset_id: + description: 资源ID:关联 media_assets.id;用于查询资源归属内容 type: integer content_id: + description: 内容ID:关联 contents.id;用于查询内容下资源列表 type: integer created_at: + description: 创建时间:默认 now();用于审计 type: string id: + description: 主键ID:自增 type: integer role: - $ref: '#/definitions/consts.ContentAssetRole' + allOf: + - $ref: '#/definitions/consts.ContentAssetRole' + description: 资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过 sort: + description: 排序:同一 role 下的展示顺序,数值越小越靠前 type: integer tenant_id: + description: 租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致 type: integer updated_at: + description: 更新时间:默认 now();更新 sort/role 时写入 type: string user_id: + description: 用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者) type: integer type: object models.ContentPrice: properties: content_id: + description: 内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价 type: integer created_at: + description: 创建时间:默认 now();用于审计 type: string currency: - $ref: '#/definitions/consts.Currency' + allOf: + - $ref: '#/definitions/consts.Currency' + description: 币种:当前固定 CNY;金额单位为分 discount_end_at: + description: 折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释) type: string discount_start_at: + description: 折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释) type: string discount_type: - $ref: '#/definitions/consts.DiscountType' + allOf: + - $ref: '#/definitions/consts.DiscountType' + description: 折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照 discount_value: + description: 折扣值:percent=0-100(按业务校验);amount=分;none 时忽略 type: integer id: + description: 主键ID:自增 type: integer price_amount: + description: 基础价格:分;0 表示免费(可直接访问正片资源) type: integer tenant_id: + description: 租户ID:多租户隔离;与内容归属一致 type: integer updated_at: + description: 更新时间:默认 now();更新价格/折扣时写入 type: string user_id: + description: 用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计 type: integer type: object models.MediaAsset: properties: bucket: + description: 存储桶:对象所在 bucket;与 provider 组合确定存储定位 type: string created_at: + description: 创建时间:默认 now();用于审计与排序 type: string deleted_at: - $ref: '#/definitions/gorm.DeletedAt' + allOf: + - $ref: '#/definitions/gorm.DeletedAt' + description: 软删除时间:非空表示已删除;对外接口需过滤 id: + description: 主键ID:自增;仅用于内部关联 type: integer meta: + description: 元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控 items: type: integer type: array object_key: + description: 对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发) type: string provider: + description: 存储提供方:例如 s3/minio/oss;便于多存储扩展 type: string status: - $ref: '#/definitions/consts.MediaAssetStatus' + allOf: + - $ref: '#/definitions/consts.MediaAssetStatus' + description: 处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供 tenant_id: + description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id type: integer type: - $ref: '#/definitions/consts.MediaAssetType' + allOf: + - $ref: '#/definitions/consts.MediaAssetType' + description: 资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等) updated_at: + description: 更新时间:默认 now();更新状态/元数据时写入 type: string user_id: + description: 用户ID:资源上传者;用于审计与权限控制 + type: integer + type: object + models.Order: + properties: + amount_discount: + description: 优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照) + type: integer + amount_original: + description: 原价金额:分;未折扣前金额(用于展示与对账) + type: integer + amount_paid: + description: 实付金额:分;从租户内余额扣款的金额(下单时快照) + type: integer + created_at: + description: 创建时间:默认 now();用于审计与排序 + type: string + currency: + allOf: + - $ref: '#/definitions/consts.Currency' + description: 币种:当前固定 CNY;金额单位为分 + id: + description: 主键ID:自增;用于关联订单明细、账本流水、权益等 + type: integer + idempotency_key: + description: 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成) + type: string + items: + items: + $ref: '#/definitions/models.OrderItem' + type: array + paid_at: + description: 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入 + type: string + refund_forced: + description: 是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计) + type: boolean + refund_operator_user_id: + description: 退款操作人用户ID:租户管理员/系统;用于审计与追责 + type: integer + refund_reason: + description: 退款原因:后台/用户发起退款的原因说明;用于审计 + type: string + refunded_at: + description: 退款完成时间:退款落账成功后写入 + type: string + snapshot: + description: 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 + items: + type: integer + type: array + status: + allOf: + - $ref: '#/definitions/consts.OrderStatus' + description: 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 + tenant_id: + description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + type: integer + type: + allOf: + - $ref: '#/definitions/consts.OrderType' + description: 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase + updated_at: + description: 更新时间:默认 now();状态变更/退款写入时更新 + type: string + user_id: + description: 用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准 + type: integer + type: object + models.OrderItem: + properties: + amount_paid: + description: 该行实付金额:分;通常等于订单 amount_paid(单内容场景) + type: integer + content: + $ref: '#/definitions/models.Content' + content_id: + description: 内容ID:关联 contents.id;用于生成/撤销 content_access + type: integer + content_user_id: + description: 内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者 + type: integer + created_at: + description: 创建时间:默认 now() + type: string + id: + description: 主键ID:自增 + type: integer + order: + $ref: '#/definitions/models.Order' + order_id: + description: 订单ID:关联 orders.id;用于聚合订单明细 + type: integer + snapshot: + description: 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 + items: + type: integer + type: array + tenant_id: + description: 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 + type: integer + updated_at: + description: 更新时间:默认 now() + type: string + user_id: + description: 用户ID:下单用户(buyer);冗余字段用于查询加速与审计 type: integer type: object models.Tenant: @@ -522,6 +800,9 @@ definitions: properties: balance: type: integer + balance_frozen: + description: 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 + type: integer created_at: type: string id: @@ -992,6 +1273,120 @@ paths: summary: 设置内容价格与折扣 tags: - Tenant + /t/{tenantCode}/v1/admin/orders: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Status filters orders by order status. + enum: + - created + - paid + - refunding + - refunded + - canceled + - failed + in: query + name: status + type: string + x-enum-varnames: + - OrderStatusCreated + - OrderStatusPaid + - OrderStatusRefunding + - OrderStatusRefunded + - OrderStatusCanceled + - OrderStatusFailed + - description: UserID filters orders by buyer user id. + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/models.Order' + type: object + summary: 订单列表(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/orders/{orderID}: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: OrderID + format: int64 + in: path + name: orderID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.AdminOrderDetail' + summary: 订单详情(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/orders/{orderID}/refund: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: OrderID + format: int64 + in: path + name: orderID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminOrderRefundForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Order' + summary: 订单退款(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/contents: get: consumes: @@ -1108,6 +1503,38 @@ paths: summary: 获取试看资源(preview role) tags: - Tenant + /t/{tenantCode}/v1/contents/{contentID}/purchase: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.PurchaseContentForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.PurchaseContentResponse' + summary: 购买内容(余额支付) + tags: + - Tenant /t/{tenantCode}/v1/me: get: consumes: @@ -1128,6 +1555,84 @@ paths: summary: 当前租户上下文信息 tags: - Tenant + /t/{tenantCode}/v1/orders: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Status filters orders by order status. + enum: + - created + - paid + - refunding + - refunded + - canceled + - failed + in: query + name: status + type: string + x-enum-varnames: + - OrderStatusCreated + - OrderStatusPaid + - OrderStatusRefunding + - OrderStatusRefunded + - OrderStatusCanceled + - OrderStatusFailed + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/models.Order' + type: object + summary: 我的订单列表(当前租户) + tags: + - Tenant + /t/{tenantCode}/v1/orders/{orderID}: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: OrderID + format: int64 + in: path + name: orderID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Order' + summary: 我的订单详情(当前租户) + tags: + - Tenant securityDefinitions: BasicAuth: type: basic diff --git a/backend/pkg/consts/consts.gen.go b/backend/pkg/consts/consts.gen.go index 899ee08..7e93c95 100644 --- a/backend/pkg/consts/consts.gen.go +++ b/backend/pkg/consts/consts.gen.go @@ -1343,6 +1343,346 @@ func (x NullMediaAssetTypeStr) Value() (driver.Value, error) { return x.MediaAssetType.String(), nil } +const ( + // OrderStatusCreated is a OrderStatus of type created. + OrderStatusCreated OrderStatus = "created" + // OrderStatusPaid is a OrderStatus of type paid. + OrderStatusPaid OrderStatus = "paid" + // OrderStatusRefunding is a OrderStatus of type refunding. + OrderStatusRefunding OrderStatus = "refunding" + // OrderStatusRefunded is a OrderStatus of type refunded. + OrderStatusRefunded OrderStatus = "refunded" + // OrderStatusCanceled is a OrderStatus of type canceled. + OrderStatusCanceled OrderStatus = "canceled" + // OrderStatusFailed is a OrderStatus of type failed. + OrderStatusFailed OrderStatus = "failed" +) + +var ErrInvalidOrderStatus = fmt.Errorf("not a valid OrderStatus, try [%s]", strings.Join(_OrderStatusNames, ", ")) + +var _OrderStatusNames = []string{ + string(OrderStatusCreated), + string(OrderStatusPaid), + string(OrderStatusRefunding), + string(OrderStatusRefunded), + string(OrderStatusCanceled), + string(OrderStatusFailed), +} + +// OrderStatusNames returns a list of possible string values of OrderStatus. +func OrderStatusNames() []string { + tmp := make([]string, len(_OrderStatusNames)) + copy(tmp, _OrderStatusNames) + return tmp +} + +// OrderStatusValues returns a list of the values for OrderStatus +func OrderStatusValues() []OrderStatus { + return []OrderStatus{ + OrderStatusCreated, + OrderStatusPaid, + OrderStatusRefunding, + OrderStatusRefunded, + OrderStatusCanceled, + OrderStatusFailed, + } +} + +// String implements the Stringer interface. +func (x OrderStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x OrderStatus) IsValid() bool { + _, err := ParseOrderStatus(string(x)) + return err == nil +} + +var _OrderStatusValue = map[string]OrderStatus{ + "created": OrderStatusCreated, + "paid": OrderStatusPaid, + "refunding": OrderStatusRefunding, + "refunded": OrderStatusRefunded, + "canceled": OrderStatusCanceled, + "failed": OrderStatusFailed, +} + +// ParseOrderStatus attempts to convert a string to a OrderStatus. +func ParseOrderStatus(name string) (OrderStatus, error) { + if x, ok := _OrderStatusValue[name]; ok { + return x, nil + } + return OrderStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidOrderStatus) +} + +var errOrderStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *OrderStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = OrderStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseOrderStatus(v) + case []byte: + *x, err = ParseOrderStatus(string(v)) + case OrderStatus: + *x = v + case *OrderStatus: + if v == nil { + return errOrderStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errOrderStatusNilPtr + } + *x, err = ParseOrderStatus(*v) + default: + return errors.New("invalid type for OrderStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x OrderStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *OrderStatus) Set(val string) error { + v, err := ParseOrderStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *OrderStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *OrderStatus) Type() string { + return "OrderStatus" +} + +type NullOrderStatus struct { + OrderStatus OrderStatus + Valid bool +} + +func NewNullOrderStatus(val interface{}) (x NullOrderStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullOrderStatus) Scan(value interface{}) (err error) { + if value == nil { + x.OrderStatus, x.Valid = OrderStatus(""), false + return + } + + err = x.OrderStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.OrderStatus), nil +} + +type NullOrderStatusStr struct { + NullOrderStatus +} + +func NewNullOrderStatusStr(val interface{}) (x NullOrderStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.OrderStatus.String(), nil +} + +const ( + // OrderTypeContentPurchase is a OrderType of type content_purchase. + OrderTypeContentPurchase OrderType = "content_purchase" + // OrderTypeTopup is a OrderType of type topup. + OrderTypeTopup OrderType = "topup" +) + +var ErrInvalidOrderType = fmt.Errorf("not a valid OrderType, try [%s]", strings.Join(_OrderTypeNames, ", ")) + +var _OrderTypeNames = []string{ + string(OrderTypeContentPurchase), + string(OrderTypeTopup), +} + +// OrderTypeNames returns a list of possible string values of OrderType. +func OrderTypeNames() []string { + tmp := make([]string, len(_OrderTypeNames)) + copy(tmp, _OrderTypeNames) + return tmp +} + +// OrderTypeValues returns a list of the values for OrderType +func OrderTypeValues() []OrderType { + return []OrderType{ + OrderTypeContentPurchase, + OrderTypeTopup, + } +} + +// String implements the Stringer interface. +func (x OrderType) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x OrderType) IsValid() bool { + _, err := ParseOrderType(string(x)) + return err == nil +} + +var _OrderTypeValue = map[string]OrderType{ + "content_purchase": OrderTypeContentPurchase, + "topup": OrderTypeTopup, +} + +// ParseOrderType attempts to convert a string to a OrderType. +func ParseOrderType(name string) (OrderType, error) { + if x, ok := _OrderTypeValue[name]; ok { + return x, nil + } + return OrderType(""), fmt.Errorf("%s is %w", name, ErrInvalidOrderType) +} + +var errOrderTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *OrderType) Scan(value interface{}) (err error) { + if value == nil { + *x = OrderType("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseOrderType(v) + case []byte: + *x, err = ParseOrderType(string(v)) + case OrderType: + *x = v + case *OrderType: + if v == nil { + return errOrderTypeNilPtr + } + *x = *v + case *string: + if v == nil { + return errOrderTypeNilPtr + } + *x, err = ParseOrderType(*v) + default: + return errors.New("invalid type for OrderType") + } + + return +} + +// Value implements the driver Valuer interface. +func (x OrderType) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *OrderType) Set(val string) error { + v, err := ParseOrderType(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *OrderType) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *OrderType) Type() string { + return "OrderType" +} + +type NullOrderType struct { + OrderType OrderType + Valid bool +} + +func NewNullOrderType(val interface{}) (x NullOrderType) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullOrderType) Scan(value interface{}) (err error) { + if value == nil { + x.OrderType, x.Valid = OrderType(""), false + return + } + + err = x.OrderType.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderType) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.OrderType), nil +} + +type NullOrderTypeStr struct { + NullOrderType +} + +func NewNullOrderTypeStr(val interface{}) (x NullOrderTypeStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderTypeStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.OrderType.String(), nil +} + const ( // RoleUser is a Role of type user. RoleUser Role = "user" @@ -1503,6 +1843,186 @@ func (x NullRoleStr) Value() (driver.Value, error) { return x.Role.String(), nil } +const ( + // TenantLedgerTypeCreditTopup is a TenantLedgerType of type credit_topup. + TenantLedgerTypeCreditTopup TenantLedgerType = "credit_topup" + // TenantLedgerTypeDebitPurchase is a TenantLedgerType of type debit_purchase. + TenantLedgerTypeDebitPurchase TenantLedgerType = "debit_purchase" + // TenantLedgerTypeCreditRefund is a TenantLedgerType of type credit_refund. + TenantLedgerTypeCreditRefund TenantLedgerType = "credit_refund" + // TenantLedgerTypeFreeze is a TenantLedgerType of type freeze. + TenantLedgerTypeFreeze TenantLedgerType = "freeze" + // TenantLedgerTypeUnfreeze is a TenantLedgerType of type unfreeze. + TenantLedgerTypeUnfreeze TenantLedgerType = "unfreeze" + // TenantLedgerTypeAdjustment is a TenantLedgerType of type adjustment. + TenantLedgerTypeAdjustment TenantLedgerType = "adjustment" +) + +var ErrInvalidTenantLedgerType = fmt.Errorf("not a valid TenantLedgerType, try [%s]", strings.Join(_TenantLedgerTypeNames, ", ")) + +var _TenantLedgerTypeNames = []string{ + string(TenantLedgerTypeCreditTopup), + string(TenantLedgerTypeDebitPurchase), + string(TenantLedgerTypeCreditRefund), + string(TenantLedgerTypeFreeze), + string(TenantLedgerTypeUnfreeze), + string(TenantLedgerTypeAdjustment), +} + +// TenantLedgerTypeNames returns a list of possible string values of TenantLedgerType. +func TenantLedgerTypeNames() []string { + tmp := make([]string, len(_TenantLedgerTypeNames)) + copy(tmp, _TenantLedgerTypeNames) + return tmp +} + +// TenantLedgerTypeValues returns a list of the values for TenantLedgerType +func TenantLedgerTypeValues() []TenantLedgerType { + return []TenantLedgerType{ + TenantLedgerTypeCreditTopup, + TenantLedgerTypeDebitPurchase, + TenantLedgerTypeCreditRefund, + TenantLedgerTypeFreeze, + TenantLedgerTypeUnfreeze, + TenantLedgerTypeAdjustment, + } +} + +// String implements the Stringer interface. +func (x TenantLedgerType) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x TenantLedgerType) IsValid() bool { + _, err := ParseTenantLedgerType(string(x)) + return err == nil +} + +var _TenantLedgerTypeValue = map[string]TenantLedgerType{ + "credit_topup": TenantLedgerTypeCreditTopup, + "debit_purchase": TenantLedgerTypeDebitPurchase, + "credit_refund": TenantLedgerTypeCreditRefund, + "freeze": TenantLedgerTypeFreeze, + "unfreeze": TenantLedgerTypeUnfreeze, + "adjustment": TenantLedgerTypeAdjustment, +} + +// ParseTenantLedgerType attempts to convert a string to a TenantLedgerType. +func ParseTenantLedgerType(name string) (TenantLedgerType, error) { + if x, ok := _TenantLedgerTypeValue[name]; ok { + return x, nil + } + return TenantLedgerType(""), fmt.Errorf("%s is %w", name, ErrInvalidTenantLedgerType) +} + +var errTenantLedgerTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *TenantLedgerType) Scan(value interface{}) (err error) { + if value == nil { + *x = TenantLedgerType("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseTenantLedgerType(v) + case []byte: + *x, err = ParseTenantLedgerType(string(v)) + case TenantLedgerType: + *x = v + case *TenantLedgerType: + if v == nil { + return errTenantLedgerTypeNilPtr + } + *x = *v + case *string: + if v == nil { + return errTenantLedgerTypeNilPtr + } + *x, err = ParseTenantLedgerType(*v) + default: + return errors.New("invalid type for TenantLedgerType") + } + + return +} + +// Value implements the driver Valuer interface. +func (x TenantLedgerType) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *TenantLedgerType) Set(val string) error { + v, err := ParseTenantLedgerType(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *TenantLedgerType) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *TenantLedgerType) Type() string { + return "TenantLedgerType" +} + +type NullTenantLedgerType struct { + TenantLedgerType TenantLedgerType + Valid bool +} + +func NewNullTenantLedgerType(val interface{}) (x NullTenantLedgerType) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullTenantLedgerType) Scan(value interface{}) (err error) { + if value == nil { + x.TenantLedgerType, x.Valid = TenantLedgerType(""), false + return + } + + err = x.TenantLedgerType.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantLedgerType) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.TenantLedgerType), nil +} + +type NullTenantLedgerTypeStr struct { + NullTenantLedgerType +} + +func NewNullTenantLedgerTypeStr(val interface{}) (x NullTenantLedgerTypeStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantLedgerTypeStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.TenantLedgerType.String(), nil +} + const ( // TenantStatusPendingVerify is a TenantStatus of type pending_verify. TenantStatusPendingVerify TenantStatus = "pending_verify" diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index ffe6398..6bb8e5e 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -1,6 +1,10 @@ package consts -import "quyun/v2/app/requests" +import ( + "time" + + "quyun/v2/app/requests" +) // Format // @@ -275,6 +279,10 @@ const ( // DefaultContentPreviewSeconds is the default preview duration in seconds when content.preview_seconds is unset/invalid. // 默认试看时长(秒):当未配置或传入非法值时使用。 DefaultContentPreviewSeconds int32 = 60 + + // DefaultOrderRefundWindow is the default refundable time window starting from paid_at. + // 默认退款时间窗:paid_at + 24h;租户管理侧可以通过强制退款绕过该限制(需审计)。 + DefaultOrderRefundWindow = 24 * time.Hour ) // content_prices @@ -360,3 +368,101 @@ func ContentAccessStatusItems() []requests.KV { } return items } + +// orders + +// swagger:enum OrderType +// ENUM( content_purchase, topup ) +type OrderType string + +// Description returns the Chinese label for the specific enum value. +func (t OrderType) Description() string { + switch t { + case OrderTypeContentPurchase: + return "购买内容" + case OrderTypeTopup: + return "充值" + default: + return "未知类型" + } +} + +// OrderTypeItems returns the KV list for FE dropdowns. +func OrderTypeItems() []requests.KV { + values := OrderTypeValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} + +// swagger:enum OrderStatus +// ENUM( created, paid, refunding, refunded, canceled, failed ) +type OrderStatus string + +// Description returns the Chinese label for the specific enum value. +func (t OrderStatus) Description() string { + switch t { + case OrderStatusCreated: + return "已创建" + case OrderStatusPaid: + return "已支付" + case OrderStatusRefunding: + return "退款中" + case OrderStatusRefunded: + return "已退款" + case OrderStatusCanceled: + return "已取消" + case OrderStatusFailed: + return "失败" + default: + return "未知状态" + } +} + +// OrderStatusItems returns the KV list for FE dropdowns. +func OrderStatusItems() []requests.KV { + values := OrderStatusValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} + +// tenant_ledgers + +// swagger:enum TenantLedgerType +// ENUM( credit_topup, debit_purchase, credit_refund, freeze, unfreeze, adjustment ) +type TenantLedgerType string + +// Description returns the Chinese label for the specific enum value. +func (t TenantLedgerType) Description() string { + switch t { + case TenantLedgerTypeCreditTopup: + return "充值入账" + case TenantLedgerTypeDebitPurchase: + return "购买扣款" + case TenantLedgerTypeCreditRefund: + return "退款回滚" + case TenantLedgerTypeFreeze: + return "冻结" + case TenantLedgerTypeUnfreeze: + return "解冻" + case TenantLedgerTypeAdjustment: + return "人工调账" + default: + return "未知类型" + } +} + +// TenantLedgerTypeItems returns the KV list for FE dropdowns. +func TenantLedgerTypeItems() []requests.KV { + values := TenantLedgerTypeValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 32d5513..3026e00 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -33,6 +33,25 @@ GET {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/assets Content-Type: application/json Authorization: Bearer {{ token }} +### Tenant - Purchase content (tenant balance) +POST {{ host }}/t/{{ tenantCode }}/v1/contents/{{ contentID }}/purchase +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "idempotency_key": "purchase-{{ contentID }}-001" +} + +### Tenant - My orders list +GET {{ host }}/t/{{ tenantCode }}/v1/orders?page=1&limit=10 +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant - My order detail +GET {{ host }}/t/{{ tenantCode }}/v1/orders/{{ orderID }} +Content-Type: application/json +Authorization: Bearer {{ token }} + ### Tenant Admin - Create content draft POST {{ host }}/t/{{ tenantCode }}/v1/admin/contents Content-Type: application/json @@ -79,3 +98,24 @@ Authorization: Bearer {{ token }} "sort": 0 } +### Tenant Admin - Orders list +GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10 +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - Order detail +@orderID = 1 +GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders/{{ orderID }} +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - Refund order (default window paid_at + 24h) +POST {{ host }}/t/{{ tenantCode }}/v1/admin/orders/{{ orderID }}/refund +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "force": false, + "reason": "联调退款", + "idempotency_key": "refund-{{ orderID }}-001" +}