diff --git a/backend/app/commands/migrate/20140202000000_river_queue.go b/backend/app/commands/migrate/20140202000000_river_queue.go deleted file mode 100644 index 40e2255..0000000 --- a/backend/app/commands/migrate/20140202000000_river_queue.go +++ /dev/null @@ -1,35 +0,0 @@ -package migrate - -import ( - "context" - "database/sql" - - "github.com/pkg/errors" - "github.com/pressly/goose/v3" - "github.com/riverqueue/river/riverdriver/riverdatabasesql" - "github.com/riverqueue/river/rivermigrate" -) - -func init() { - goose.AddMigrationNoTxContext(RiverQueueUp, RiverQueueDown) -} - -func RiverQueueUp(ctx context.Context, db *sql.DB) error { - migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) - if err != nil { - return errors.Wrap(err, "river migrate up failed") - } - - _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: -1}) - return err -} - -func RiverQueueDown(ctx context.Context, db *sql.DB) error { - migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) - if err != nil { - return errors.Wrap(err, "river migrate down failed") - } - - _, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{TargetVersion: -1}) - return err -} diff --git a/backend/app/commands/migrate/migrate.go b/backend/app/commands/migrate/migrate.go index 9dd44e3..f669779 100644 --- a/backend/app/commands/migrate/migrate.go +++ b/backend/app/commands/migrate/migrate.go @@ -57,33 +57,27 @@ func Serve(cmd *cobra.Command, args []string) error { goose.SetBaseFS(database.MigrationFS) goose.SetTableName("migrations") - goose.AddNamedMigrationNoTxContext("0001_river_job.go", RiverUp, RiverDown) + goose.AddNamedMigrationNoTxContext( + "10000000000001_river_job.go", + func(ctx context.Context, db *sql.DB) error { + migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) + if err != nil { + return err + } + + _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: -1}) + return err + }, + func(ctx context.Context, db *sql.DB) error { + migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) + if err != nil { + return err + } + + _, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{TargetVersion: -1}) + return err + }) return goose.RunContext(context.Background(), action, svc.DB, "migrations", args...) }) } - -func RiverUp(ctx context.Context, db *sql.DB) error { - migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) - if err != nil { - return err - } - - // Migrate up. An empty MigrateOpts will migrate all the way up, but - // best practice is to specify a specific target version. - _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{}) - return err -} - -func RiverDown(ctx context.Context, db *sql.DB) error { - migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil) - if err != nil { - return err - } - - // TargetVersion -1 removes River's schema completely. - _, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{ - TargetVersion: -1, - }) - return err -} diff --git a/backend/app/http/tenant/dto/ledger_admin.go b/backend/app/http/tenant/dto/ledger_admin.go index b999671..fa7328f 100644 --- a/backend/app/http/tenant/dto/ledger_admin.go +++ b/backend/app/http/tenant/dto/ledger_admin.go @@ -18,7 +18,7 @@ type AdminLedgerListFilter struct { requests.Pagination `json:",inline" query:",inline"` // OperatorUserID 按操作者用户ID过滤(可选)。 - // 典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。 + // 典型场景:后台检索“某个管理员发起的退款/调账”等敏感操作流水。 OperatorUserID *int64 `json:"operator_user_id,omitempty" query:"operator_user_id"` // UserID 按余额账户归属用户ID过滤(可选)。 @@ -32,7 +32,7 @@ type AdminLedgerListFilter struct { OrderID *int64 `json:"order_id,omitempty" query:"order_id"` // BizRefType 按业务引用类型过滤(可选)。 - // 约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。 + // 约定:当前业务写入为 "order";未来可扩展为 refund 等。 BizRefType *string `json:"biz_ref_type,omitempty" query:"biz_ref_type"` // BizRefID 按业务引用ID过滤(可选)。 diff --git a/backend/app/http/tenant/dto/order_admin.go b/backend/app/http/tenant/dto/order_admin.go index 7a03399..9d857df 100644 --- a/backend/app/http/tenant/dto/order_admin.go +++ b/backend/app/http/tenant/dto/order_admin.go @@ -29,7 +29,7 @@ type AdminOrderListFilter struct { // ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。 ContentTitle *string `json:"content_title,omitempty" query:"content_title"` - // Type 订单类型(可选):content_purchase/topup 等。 + // Type 订单类型(可选):content_purchase 等。 Type *consts.OrderType `json:"type,omitempty" query:"type"` // Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。 diff --git a/backend/app/http/tenant/dto/order_admin_batch_topup.go b/backend/app/http/tenant/dto/order_admin_batch_topup.go deleted file mode 100644 index 73fe6ce..0000000 --- a/backend/app/http/tenant/dto/order_admin_batch_topup.go +++ /dev/null @@ -1,65 +0,0 @@ -package dto - -// AdminBatchTopupItem 批量充值的单条明细。 -type AdminBatchTopupItem struct { - // UserID 目标用户ID:必须属于当前租户,否则该条充值失败。 - UserID int64 `json:"user_id"` - - // Amount 充值金额:单位分;必须 > 0。 - Amount int64 `json:"amount"` - - // Reason 充值原因(可选):用于审计与追溯。 - Reason string `json:"reason,omitempty"` - - // IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成; - // 建议前端/调用方提供稳定值,便于重试时保持结果一致。 - IdempotencyKey string `json:"idempotency_key,omitempty"` -} - -// AdminBatchTopupForm 租户管理员批量充值请求参数。 -type AdminBatchTopupForm struct { - // BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。 - BatchIdempotencyKey string `json:"batch_idempotency_key"` - - // Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。 - Items []*AdminBatchTopupItem `json:"items"` -} - -// AdminBatchTopupResultItem 批量充值的单条处理结果。 -type AdminBatchTopupResultItem struct { - // UserID 目标用户ID。 - UserID int64 `json:"user_id"` - - // Amount 充值金额(单位分)。 - Amount int64 `json:"amount"` - - // IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。 - IdempotencyKey string `json:"idempotency_key"` - - // OrderID 生成的订单ID:成功时返回;失败时为 0。 - OrderID int64 `json:"order_id,omitempty"` - - // OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。 - OK bool `json:"ok"` - - // ErrorCode 错误码:失败时返回;成功时为 0。 - ErrorCode int `json:"error_code,omitempty"` - - // ErrorMessage 错误信息:失败时返回;成功时为空。 - ErrorMessage string `json:"error_message,omitempty"` -} - -// AdminBatchTopupResponse 批量充值的汇总结果。 -type AdminBatchTopupResponse struct { - // Total 总条数:等于 items 长度。 - Total int `json:"total"` - - // Success 成功条数。 - Success int `json:"success"` - - // Failed 失败条数。 - Failed int `json:"failed"` - - // Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。 - Items []*AdminBatchTopupResultItem `json:"items"` -} diff --git a/backend/app/http/tenant/dto/topup_admin.go b/backend/app/http/tenant/dto/topup_admin.go deleted file mode 100644 index fbe25e1..0000000 --- a/backend/app/http/tenant/dto/topup_admin.go +++ /dev/null @@ -1,11 +0,0 @@ -package dto - -// AdminTopupForm defines payload for tenant-admin to topup a tenant member balance. -type AdminTopupForm struct { - // Amount is the topup amount in cents (CNY 分); must be > 0. - Amount int64 `json:"amount,omitempty"` - // Reason is the human-readable topup reason used for audit. - Reason string `json:"reason,omitempty"` - // IdempotencyKey ensures the topup request is processed at most once. - IdempotencyKey string `json:"idempotency_key,omitempty"` -} diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go index 0de8782..fa0bd6c 100644 --- a/backend/app/http/tenant/order_admin.go +++ b/backend/app/http/tenant/order_admin.go @@ -135,25 +135,25 @@ func (*orderAdmin) adminOrderDetail( return &dto.AdminOrderDetail{Order: m}, nil } - // adminRefund - // - // @Summary 订单退款(租户管理) - // @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 - // @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。 - // @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( +// adminRefund +// +// @Summary 订单退款(租户管理) +// @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 +// @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。 +// @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, @@ -187,88 +187,5 @@ func (*orderAdmin) adminOrderDetail( ) } -// adminTopupUser -// -// @Summary 为租户成员充值(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param userID path int64 true "UserID" -// @Param form body dto.AdminTopupForm true "Form" -// @Success 200 {object} models.Order -// -// @Router /t/:tenantCode/v1/admin/users/:userID/topup [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind userID path -// @Bind form body -func (*orderAdmin) adminTopupUser( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - userID int64, - form *dto.AdminTopupForm, -) (*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, - "operator_user": tenantUser.UserID, - "target_user": userID, - "amount": form.Amount, - "idempotency_key": form.IdempotencyKey, - }).Info("tenant.admin.users.topup") - - return services.Order.AdminTopupUser( - ctx, - tenant.ID, - tenantUser.UserID, - userID, - form.Amount, - form.IdempotencyKey, - form.Reason, - time.Now(), - ) -} - -// adminBatchTopupUsers -// -// @Summary 批量为租户成员充值(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.AdminBatchTopupForm true "Form" -// @Success 200 {object} dto.AdminBatchTopupResponse -// -// @Router /t/:tenantCode/v1/admin/users/topup/batch [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind form body -func (*orderAdmin) adminBatchTopupUsers( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - form *dto.AdminBatchTopupForm, -) (*dto.AdminBatchTopupResponse, 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, - "total": len(form.Items), - }).Info("tenant.admin.users.topup.batch") - - return services.Order.AdminBatchTopupUsers(ctx, tenant.ID, tenantUser.UserID, form, time.Now()) -} +// 注意:已移除“租户管理员为用户充值”能力。 +// 余额已改为 users 表的全局余额,用户可在已加入租户间共享消费;按租户充值会导致账务复杂且易出错。 diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index cd2e1c8..57e53c3 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -218,21 +218,6 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("orderID"), Body[dto.AdminOrderRefundForm]("form"), )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/:userID/topup -> orderAdmin.adminTopupUser") - router.Post("/t/:tenantCode/v1/admin/users/:userID/topup"[len(r.Path()):], DataFunc4( - r.orderAdmin.adminTopupUser, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("userID"), - Body[dto.AdminTopupForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/topup/batch -> orderAdmin.adminBatchTopupUsers") - router.Post("/t/:tenantCode/v1/admin/users/topup/batch"[len(r.Path()):], DataFunc3( - r.orderAdmin.adminBatchTopupUsers, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Body[dto.AdminBatchTopupForm]("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( diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go index c8f007a..0fbbe57 100644 --- a/backend/app/services/ledger.go +++ b/backend/app/services/ledger.go @@ -18,23 +18,25 @@ import ( "gorm.io/gorm/clause" ) -// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与租户用户余额快照。 +// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与用户余额快照。 type LedgerApplyResult struct { // Ledger 为本次创建的账本记录(若幂等命中则返回已有记录)。 Ledger *models.TenantLedger - // TenantUser 为写入后余额状态(若幂等命中则返回当前快照)。 - TenantUser *models.TenantUser + // User 为写入后余额状态(若幂等命中则返回当前快照)。 + User *models.User } -// ledger 提供租户余额账本能力(冻结/解冻/扣减/退款/充值),支持幂等与行锁保证一致性。 +// ledger 提供租户账本能力(冻结/解冻/扣减/退款等),支持幂等与行锁保证一致性。 +// 注意:余额为 users 表的全局余额,用户可在已加入租户间共享消费。 // // @provider type ledger struct { db *gorm.DB } -// MyBalance 查询当前用户在指定租户下的余额信息(可用/冻结)。 -func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) { +// MyBalance 查询当前用户的全局余额信息(可用/冻结)。 +// 语义:必须先是该租户成员(否则返回 not found),但余额数据来源为 users。 +func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.User, error) { if tenantID <= 0 || userID <= 0 { return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") } @@ -44,14 +46,23 @@ func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models "user_id": userID, }).Info("services.ledger.me.balance") - tbl, query := models.TenantUserQuery.QueryContext(ctx) - m, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err != nil { + // 必须先是租户成员。 + tblTU, queryTU := models.TenantUserQuery.QueryContext(ctx) + if _, err := queryTU.Where(tblTU.TenantID.Eq(tenantID), tblTU.UserID.Eq(userID)).First(); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound.WithMsg("tenant user not found") } return nil, err } + + tblU, queryU := models.UserQuery.QueryContext(ctx) + m, err := queryU.Where(tblU.ID.Eq(userID), tblU.DeletedAt.IsNull()).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("user not found") + } + return nil, err + } return m, nil } @@ -232,11 +243,6 @@ func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, oper return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now) } -// CreditTopupTx 将充值金额记入可用余额,并写入账本记录。 -func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now) -} - func (s *ledger) apply( ctx context.Context, tx *gorm.DB, @@ -273,18 +279,32 @@ func (s *ledger) apply( var out LedgerApplyResult err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 必须先是租户成员(账本维度仍按 tenant_id 记录)。 + var tu models.TenantUser + if err := tx. + 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 { - var current models.TenantUser - if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil { + var current models.User + if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("user not found") + } return err } out.Ledger = &existing - out.TenantUser = ¤t + out.User = ¤t return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err @@ -297,26 +317,29 @@ func (s *ledger) apply( if err := tx. Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). 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 { + var current models.User + if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("user not found") + } return err } out.Ledger = &existing - out.TenantUser = ¤t + out.User = ¤t return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } } - // 使用行锁锁住 tenant_users,确保同一租户下同一用户余额更新的串行一致性。 - var tu models.TenantUser + // 使用行锁锁住 users,确保同一用户在“跨租户消费”场景下余额更新的串行一致性。 + var u models.User if err := tx. Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND user_id = ?", tenantID, userID). - First(&tu).Error; err != nil { + Where("id = ? AND deleted_at IS NULL", userID). + First(&u).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("tenant user not found") + return errorx.ErrRecordNotFound.WithMsg("user not found") } return err } @@ -328,7 +351,7 @@ func (s *ledger) apply( Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). First(&existing).Error; err == nil { out.Ledger = &existing - out.TenantUser = &tu + out.User = &u return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err @@ -342,15 +365,15 @@ func (s *ledger) apply( Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). First(&existing).Error; err == nil { out.Ledger = &existing - out.TenantUser = &tu + out.User = &u return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } } - balanceBefore := tu.Balance - frozenBefore := tu.BalanceFrozen + balanceBefore := u.Balance + frozenBefore := u.BalanceFrozen balanceAfter := balanceBefore + deltaBalance frozenAfter := frozenBefore + deltaFrozen @@ -363,8 +386,8 @@ func (s *ledger) apply( } // 先更新余额,再写账本:任何一步失败都回滚,保证“余额变更”和“账本记录”一致。 - if err := tx.Model(&models.TenantUser{}). - Where("id = ?", tu.ID). + if err := tx.Model(&models.User{}). + Where("id = ?", u.ID). Updates(map[string]any{ "balance": balanceAfter, "balance_frozen": frozenAfter, @@ -400,7 +423,7 @@ func (s *ledger) apply( Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). First(&existing).Error; e2 == nil { out.Ledger = &existing - out.TenantUser = &tu + out.User = &u return nil } } @@ -411,19 +434,19 @@ func (s *ledger) apply( Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). First(&existing).Error; e2 == nil { out.Ledger = &existing - out.TenantUser = &tu + out.User = &u return nil } } return err } - tu.Balance = balanceAfter - tu.BalanceFrozen = frozenAfter - tu.UpdatedAt = now + u.Balance = balanceAfter + u.BalanceFrozen = frozenAfter + u.UpdatedAt = now out.Ledger = ledger - out.TenantUser = &tu + out.User = &u return nil }) if err != nil { @@ -450,8 +473,8 @@ func (s *ledger) apply( "type": ledgerType, "ledger_id": out.Ledger.ID, "idempotency_key": idempotencyKey, - "balance_after": out.TenantUser.Balance, - "frozen_after": out.TenantUser.BalanceFrozen, + "balance_after": out.User.Balance, + "frozen_after": out.User.BalanceFrozen, }).Info("services.ledger.apply.ok") return &out, nil diff --git a/backend/app/services/ledger_test.go b/backend/app/services/ledger_test.go index 2dd8717..9a48900 100644 --- a/backend/app/services/ledger_test.go +++ b/backend/app/services/ledger_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "testing" "time" @@ -46,15 +47,24 @@ func Test_Ledger(t *testing.T) { } func (s *LedgerTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) { - database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser) + database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser, models.TableNameUser) + + now := time.Now().UTC() + _, err := s.DB.ExecContext(ctx, ` +INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen) +VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6) +ON CONFLICT (id) DO UPDATE +SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at +`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen) + So(err, ShouldBeNil) tu := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Balance: balance, - BalanceFrozen: frozen, - Status: consts.UserStatusVerified, + TenantID: tenantID, + UserID: userID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + CreatedAt: now, + UpdatedAt: now, } So(tu.Create(ctx), ShouldBeNil) } @@ -82,7 +92,7 @@ func (s *LedgerTestSuite) Test_Freeze() { So(err, ShouldBeNil) So(res, ShouldNotBeNil) So(res.Ledger, ShouldNotBeNil) - So(res.TenantUser, ShouldNotBeNil) + So(res.User, ShouldNotBeNil) So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeFreeze) So(res.Ledger.Amount, ShouldEqual, 300) So(res.Ledger.BalanceBefore, ShouldEqual, 1000) @@ -92,8 +102,8 @@ func (s *LedgerTestSuite) Test_Freeze() { So(res.Ledger.OperatorUserID, ShouldEqual, userID) So(res.Ledger.BizRefType, ShouldEqual, "") So(res.Ledger.BizRefID, ShouldEqual, int64(0)) - So(res.TenantUser.Balance, ShouldEqual, 700) - So(res.TenantUser.BalanceFrozen, ShouldEqual, 300) + So(res.User.Balance, ShouldEqual, 700) + So(res.User.BalanceFrozen, ShouldEqual, 300) }) Convey("幂等键重复调用不应重复扣减", func() { @@ -106,10 +116,10 @@ func (s *LedgerTestSuite) Test_Freeze() { So(res2.Ledger, ShouldNotBeNil) So(res2.Ledger.IdempotencyKey, ShouldEqual, "k_freeze_idem") - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 700) - So(tu2.BalanceFrozen, ShouldEqual, 300) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 700) + So(u2.BalanceFrozen, ShouldEqual, 300) }) Convey("余额不足应返回前置条件失败", func() { @@ -157,8 +167,8 @@ func (s *LedgerTestSuite) Test_Unfreeze() { So(res.Ledger.OperatorUserID, ShouldEqual, userID) So(res.Ledger.BizRefType, ShouldEqual, "") So(res.Ledger.BizRefID, ShouldEqual, int64(0)) - So(res.TenantUser.Balance, ShouldEqual, 1000) - So(res.TenantUser.BalanceFrozen, ShouldEqual, 0) + So(res.User.Balance, ShouldEqual, 1000) + So(res.User.BalanceFrozen, ShouldEqual, 0) }) Convey("幂等键重复调用不应重复入账", func() { @@ -172,10 +182,10 @@ func (s *LedgerTestSuite) Test_Unfreeze() { So(err, ShouldBeNil) So(res2, ShouldNotBeNil) - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 1000) - So(tu2.BalanceFrozen, ShouldEqual, 0) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 1000) + So(u2.BalanceFrozen, ShouldEqual, 0) }) }) } @@ -210,8 +220,8 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() { So(res.Ledger.OperatorUserID, ShouldEqual, userID) So(res.Ledger.BizRefType, ShouldEqual, "order") So(res.Ledger.BizRefID, ShouldEqual, int64(123)) - So(res.TenantUser.Balance, ShouldEqual, 700) - So(res.TenantUser.BalanceFrozen, ShouldEqual, 0) + So(res.User.Balance, ShouldEqual, 700) + So(res.User.BalanceFrozen, ShouldEqual, 0) }) Convey("幂等键重复调用不应重复扣减冻结余额", func() { @@ -224,10 +234,10 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() { _, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second)) So(err, ShouldBeNil) - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 700) - So(tu2.BalanceFrozen, ShouldEqual, 0) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 700) + So(u2.BalanceFrozen, ShouldEqual, 0) }) }) } @@ -259,8 +269,8 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() { So(res.Ledger.OperatorUserID, ShouldEqual, userID) So(res.Ledger.BizRefType, ShouldEqual, "order") So(res.Ledger.BizRefID, ShouldEqual, int64(123)) - So(res.TenantUser.Balance, ShouldEqual, 1000) - So(res.TenantUser.BalanceFrozen, ShouldEqual, 0) + So(res.User.Balance, ShouldEqual, 1000) + So(res.User.BalanceFrozen, ShouldEqual, 0) }) Convey("幂等键重复调用不应重复退款入账", func() { @@ -274,48 +284,9 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() { _, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second)) So(err, ShouldBeNil) - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 1000) - }) - }) -} - -func (s *LedgerTestSuite) Test_CreditTopupTx() { - Convey("Ledger.CreditTopupTx", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - Convey("金额非法应返回参数错误", func() { - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 0, "k_topup_invalid_amount", "topup", now) - So(err, ShouldNotBeNil) - }) - - Convey("成功充值应增加可用余额并写入账本", func() { - res, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_1", "topup", now) - So(err, ShouldBeNil) - So(res, ShouldNotBeNil) - So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditTopup) - So(res.Ledger.OperatorUserID, ShouldEqual, int64(999)) - So(res.Ledger.BizRefType, ShouldEqual, "order") - So(res.Ledger.BizRefID, ShouldEqual, int64(456)) - So(res.TenantUser.Balance, ShouldEqual, 1200) - So(res.TenantUser.BalanceFrozen, ShouldEqual, 0) - }) - - Convey("幂等键重复调用不应重复充值入账", func() { - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now) - So(err, ShouldBeNil) - _, err = Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second)) - So(err, ShouldBeNil) - - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 1200) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 1000) }) }) } @@ -328,7 +299,7 @@ func (s *LedgerTestSuite) Test_MyBalance() { s.seedTenantUser(ctx, tenantID, userID, 1000, 200) - Convey("成功返回租户内余额", func() { + Convey("成功返回全局余额", func() { m, err := Ledger.MyBalance(ctx, tenantID, userID) So(err, ShouldBeNil) So(m, ShouldNotBeNil) @@ -352,9 +323,9 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, userID, 1, 200, "k_topup_for_page", "topup", now) + _, err := Ledger.Freeze(ctx, tenantID, userID, 1, 200, "k_freeze_for_page_1", "freeze", now) So(err, ShouldBeNil) - _, err = Ledger.Freeze(ctx, tenantID, userID, 2, 100, "k_freeze_for_page", "freeze", now.Add(time.Second)) + _, err = Ledger.Unfreeze(ctx, tenantID, userID, 1, 100, "k_unfreeze_for_page_1", "unfreeze", now.Add(time.Second)) So(err, ShouldBeNil) Convey("分页返回流水列表", func() { @@ -365,7 +336,7 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() { }) Convey("按 type 过滤", func() { - typ := consts.TenantLedgerTypeCreditTopup + typ := consts.TenantLedgerTypeFreeze pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{Type: &typ}) So(err, ShouldBeNil) So(pager.Total, ShouldEqual, 1) @@ -382,8 +353,8 @@ func (s *LedgerTestSuite) Test_AdminLedgerPage() { s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - // 模拟后台管理员为用户充值:operator_user_id 与 user_id 不同。 - _, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_topup_for_page", "topup", now) + // 模拟后台管理员为用户冻结资金:operator_user_id 与 user_id 不同。 + _, err := Ledger.FreezeTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_freeze_for_page", "freeze", now) So(err, ShouldBeNil) Convey("按 operator_user_id 过滤", func() { diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 5dab87f..a7d0d7b 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -198,131 +198,6 @@ func (s *order) AdminOrderExportCSV( }, nil } -// AdminBatchTopupUsers 租户管理员批量为成员充值(逐条幂等,允许部分失败)。 -func (s *order) AdminBatchTopupUsers( - ctx context.Context, - tenantID, operatorUserID int64, - form *dto.AdminBatchTopupForm, - now time.Time, -) (*dto.AdminBatchTopupResponse, error) { - if tenantID <= 0 || operatorUserID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("form is required") - } - if strings.TrimSpace(form.BatchIdempotencyKey) == "" { - return nil, errorx.ErrInvalidParameter.WithMsg("batch_idempotency_key is required") - } - if len(form.Items) == 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("items is required") - } - if now.IsZero() { - now = time.Now() - } - - // 批量充值属于高敏感操作:限制单次条数,避免拖垮系统。 - const maxItems = 200 - if len(form.Items) > maxItems { - return nil, errorx.ErrInvalidParameter.WithMsg("items too many") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "batch_idempotency_key": form.BatchIdempotencyKey, - "total": len(form.Items), - }).Info("services.order.admin.batch_topup") - - out := &dto.AdminBatchTopupResponse{ - Total: len(form.Items), - Items: make([]*dto.AdminBatchTopupResultItem, 0, len(form.Items)), - } - - for idx, item := range form.Items { - if item == nil { - out.Failed++ - out.Items = append(out.Items, &dto.AdminBatchTopupResultItem{ - OK: false, - ErrorCode: int(errorx.CodeInvalidParameter), - ErrorMessage: "item is nil", - }) - continue - } - - idemKey := strings.TrimSpace(item.IdempotencyKey) - if idemKey == "" { - // 关键语义:为空时用批次幂等键派生,确保批次重试不会重复入账。 - idemKey = fmt.Sprintf("batch_topup:%s:%d:%d", strings.TrimSpace(form.BatchIdempotencyKey), item.UserID, idx) - } - - resultItem := &dto.AdminBatchTopupResultItem{ - UserID: item.UserID, - Amount: item.Amount, - IdempotencyKey: idemKey, - OK: false, - } - - // 单条参数校验:失败只影响该条,不影响整批(便于运营侧修正后重试)。 - if item.UserID <= 0 { - resultItem.ErrorCode = int(errorx.CodeInvalidParameter) - resultItem.ErrorMessage = "user_id must be > 0" - out.Failed++ - out.Items = append(out.Items, resultItem) - continue - } - if item.Amount <= 0 { - resultItem.ErrorCode = int(errorx.CodeInvalidParameter) - resultItem.ErrorMessage = "amount must be > 0" - out.Failed++ - out.Items = append(out.Items, resultItem) - continue - } - - // 逐条调用单用户充值逻辑:保持“订单 + 账本 + 余额”一致性与幂等语义一致。 - orderModel, err := s.AdminTopupUser( - ctx, - tenantID, - operatorUserID, - item.UserID, - item.Amount, - idemKey, - item.Reason, - now, - ) - if err != nil { - // 错误收敛为可展示结构:便于前端逐条展示与导出审计。 - var appErr *errorx.AppError - if errors.As(err, &appErr) { - resultItem.ErrorCode = int(appErr.Code) - resultItem.ErrorMessage = appErr.Message - } else { - resultItem.ErrorCode = int(errorx.CodeInternalError) - resultItem.ErrorMessage = err.Error() - } - out.Failed++ - out.Items = append(out.Items, resultItem) - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "user_id": item.UserID, - "amount": item.Amount, - "idempotency_key": idemKey, - }).WithError(err).Warn("services.order.admin.batch_topup.item_failed") - continue - } - - resultItem.OK = true - if orderModel != nil { - resultItem.OrderID = orderModel.ID - } - out.Success++ - out.Items = append(out.Items, resultItem) - } - - return out, nil -} - // PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 type PurchaseContentParams struct { // TenantID 租户 ID(多租户隔离范围)。 @@ -349,7 +224,7 @@ type PurchaseContentResult struct { AmountPaid int64 } -// order 提供订单域能力(购买、充值、退款、查询等)。 +// order 提供订单域能力(购买、退款、查询等)。 // // @provider type order struct { @@ -394,123 +269,6 @@ func (s *order) enqueueOrderRefundJob(args jobs_args.OrderRefundJob) error { return s.job.Add(args) } -// AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。 -func (s *order) AdminTopupUser( - ctx context.Context, - tenantID, operatorUserID, targetUserID, amount int64, - idempotencyKey, reason string, - now time.Time, -) (*models.Order, error) { - if tenantID <= 0 || operatorUserID <= 0 || targetUserID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/target_user_id must be > 0") - } - 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, - "operator_user": operatorUserID, - "target_user": targetUserID, - "amount": amount, - "idempotency_key": idempotencyKey, - }).Info("services.order.admin.topup_user") - - var out models.Order - - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 关键前置条件:目标用户必须属于该租户(同时加行锁,避免并发余额写入冲突)。 - var tu models.TenantUser - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID). - First(&tu).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrPreconditionFailed.WithMsg("目标用户不属于该租户") - } - return err - } - - // 充值幂等:按 orders(tenant_id,user_id,idempotency_key) 去重,避免重复入账。 - if idempotencyKey != "" { - var existing models.Order - if err := tx.Where( - "tenant_id = ? AND user_id = ? AND idempotency_key = ?", - tenantID, targetUserID, idempotencyKey, - ).First(&existing).Error; err == nil { - out = existing - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - - // 先落订单(paid),再写入账本(credit_topup),确保“订单可追溯 + 账本可对账”。 - snapshot := newOrderSnapshot(consts.OrderTypeTopup, &fields.OrdersTopupSnapshot{ - OperatorUserID: operatorUserID, - TargetUserID: targetUserID, - Amount: amount, - Currency: consts.CurrencyCNY, - Reason: reason, - IdempotencyKey: idempotencyKey, - TopupAt: now, - }) - orderModel := models.Order{ - TenantID: tenantID, - UserID: targetUserID, - Type: consts.OrderTypeTopup, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: amount, - AmountDiscount: 0, - AmountPaid: amount, - Snapshot: snapshot, - IdempotencyKey: idempotencyKey, - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(&orderModel).Error; err != nil { - return err - } - - // 账本幂等键固定使用 topup:,保证同一订单不会重复入账。 - ledgerKey := fmt.Sprintf("topup:%d", orderModel.ID) - remark := reason - if remark == "" { - remark = fmt.Sprintf("topup by tenant_admin:%d", operatorUserID) - } - if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, operatorUserID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil { - return err - } - - out = orderModel - return nil - }) - if err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user": operatorUserID, - "target_user": targetUserID, - "amount": amount, - "idempotency_key": idempotencyKey, - }).WithError(err).Warn("services.order.admin.topup_user.failed") - return nil, err - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "target_user": targetUserID, - "order_id": out.ID, - "amount": amount, - }).Info("services.order.admin.topup_user.ok") - - return &out, nil -} - // MyOrderPage 分页查询当前用户在租户内的订单。 func (s *order) MyOrderPage( ctx context.Context, diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index a4e0e83..8c8ec74 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -60,15 +60,22 @@ func (s *OrderTestSuite) truncate(ctx context.Context, tableNames ...string) { } func (s *OrderTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) { - tu := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Balance: balance, - BalanceFrozen: frozen, - Status: consts.UserStatusVerified, - } - So(tu.Create(ctx), ShouldBeNil) + now := time.Now().UTC() + _, err := s.DB.ExecContext(ctx, ` +INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen) +VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6) +ON CONFLICT (id) DO UPDATE +SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at +`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen) + So(err, ShouldBeNil) + + _, err = s.DB.ExecContext(ctx, ` +INSERT INTO tenant_users (tenant_id, user_id, role, status, created_at, updated_at) +VALUES ($1, $2, ARRAY['member'], $3, $4, $4) +ON CONFLICT (tenant_id, user_id) DO UPDATE +SET role = EXCLUDED.role, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at +`, tenantID, userID, consts.UserStatusVerified, now) + So(err, ShouldBeNil) } func (s *OrderTestSuite) seedPublishedContent(ctx context.Context, tenantID, ownerUserID int64) *models.Content { @@ -102,98 +109,6 @@ func (s *OrderTestSuite) seedContentPrice(ctx context.Context, tenantID, content So(p.Create(ctx), ShouldBeNil) } -func (s *OrderTestSuite) Test_AdminTopupUser() { - Convey("Order.AdminTopupUser", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - operatorUserID := int64(10) - targetUserID := int64(20) - - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameTenantUser, - ) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminTopupUser(ctx, 0, operatorUserID, targetUserID, 100, "", "", now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminTopupUser(ctx, tenantID, 0, targetUserID, 100, "", "", now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminTopupUser(ctx, tenantID, operatorUserID, 0, 100, "", "", now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 0, "", "", now) - So(err, ShouldNotBeNil) - }) - - Convey("目标用户不属于该租户应返回前置条件失败", func() { - _, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 100, "idem_not_member", "", now) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) - }) - - Convey("成功充值并写入账本", func() { - s.seedTenantUser(ctx, tenantID, targetUserID, 0, 0) - - orderModel, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 300, "idem_topup_1", "充值原因", now) - So(err, ShouldBeNil) - So(orderModel, ShouldNotBeNil) - So(orderModel.ID, ShouldBeGreaterThan, 0) - So(orderModel.Type, ShouldEqual, consts.OrderTypeTopup) - So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid) - So(orderModel.AmountPaid, ShouldEqual, 300) - - snap := orderModel.Snapshot.Data() - So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup)) - - var snapData fields.OrdersTopupSnapshot - So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) - So(snapData.OperatorUserID, ShouldEqual, operatorUserID) - So(snapData.TargetUserID, ShouldEqual, targetUserID) - So(snapData.Amount, ShouldEqual, int64(300)) - - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil) - So(tu.Balance, ShouldEqual, 300) - - var ledgers []*models.TenantLedger - So(_db.WithContext(ctx). - Where("tenant_id = ? AND user_id = ? AND type = ?", tenantID, targetUserID, consts.TenantLedgerTypeCreditTopup). - Order("id ASC"). - Find(&ledgers).Error, ShouldBeNil) - So(len(ledgers), ShouldEqual, 1) - So(ledgers[0].OrderID, ShouldEqual, orderModel.ID) - So(ledgers[0].Amount, ShouldEqual, 300) - }) - - Convey("幂等键重复调用不应重复入账", func() { - s.seedTenantUser(ctx, tenantID, targetUserID, 0, 0) - - o1, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 300, "idem_topup_2", "充值原因", now) - So(err, ShouldBeNil) - So(o1, ShouldNotBeNil) - - o2, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 999, "idem_topup_2", "不同金额也不应重复处理", now.Add(time.Second)) - So(err, ShouldBeNil) - So(o2, ShouldNotBeNil) - So(o2.ID, ShouldEqual, o1.ID) - - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil) - So(tu.Balance, ShouldEqual, 300) - }) - }) -} - func (s *OrderTestSuite) Test_MyOrderPage() { Convey("Order.MyOrderPage", s.T(), func() { ctx := s.T().Context() @@ -654,45 +569,6 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { So(itemsDesc[0].CreatedAt.After(itemsDesc[1].CreatedAt), ShouldBeTrue) }) - Convey("按 type 过滤", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeTopup, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o1.Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: 3, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 200, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - - typ := consts.OrderTypeTopup - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - Type: &typ, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - Convey("组合筛选:user_id + status + amount_paid 区间 + content_id", func() { s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) @@ -888,123 +764,6 @@ func (s *OrderTestSuite) Test_AdminOrderExportCSV() { }) } -func (s *OrderTestSuite) Test_AdminBatchTopupUsers() { - Convey("Order.AdminBatchTopupUsers", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - operatorUserID := int64(10) - - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameTenantUser, - ) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminBatchTopupUsers(ctx, 0, operatorUserID, &dto.AdminBatchTopupForm{}, now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminBatchTopupUsers(ctx, tenantID, 0, &dto.AdminBatchTopupForm{}, now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, nil, now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: ""}, now) - So(err, ShouldNotBeNil) - - _, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: "b1", Items: nil}, now) - So(err, ShouldNotBeNil) - }) - - Convey("超过单批次最大条数应返回错误", func() { - items := make([]*dto.AdminBatchTopupItem, 0, 201) - for i := 0; i < 201; i++ { - items = append(items, &dto.AdminBatchTopupItem{UserID: int64(1000 + i), Amount: 1}) - } - _, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{ - BatchIdempotencyKey: "too_many", - Items: items, - }, now) - So(err, ShouldNotBeNil) - }) - - Convey("单条参数不合法应只影响该条并返回错误明细", func() { - s.seedTenantUser(ctx, tenantID, 20, 0, 0) - - form := &dto.AdminBatchTopupForm{ - BatchIdempotencyKey: "batch_invalid_item", - Items: []*dto.AdminBatchTopupItem{ - nil, - {UserID: 0, Amount: 100, Reason: "bad_user_id"}, - {UserID: 20, Amount: 0, Reason: "bad_amount"}, - {UserID: 20, Amount: 100, Reason: "ok"}, - }, - } - resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.Total, ShouldEqual, 4) - So(resp.Success, ShouldEqual, 1) - So(resp.Failed, ShouldEqual, 3) - So(len(resp.Items), ShouldEqual, 4) - So(resp.Items[0].OK, ShouldBeFalse) - So(resp.Items[1].OK, ShouldBeFalse) - So(resp.Items[2].OK, ShouldBeFalse) - So(resp.Items[3].OK, ShouldBeTrue) - }) - - Convey("部分成功应返回明细结果且成功入账", func() { - // seed 2 个成员,1 个非成员 - s.seedTenantUser(ctx, tenantID, 20, 0, 0) - s.seedTenantUser(ctx, tenantID, 21, 0, 0) - - form := &dto.AdminBatchTopupForm{ - BatchIdempotencyKey: "batch_001", - Items: []*dto.AdminBatchTopupItem{ - {UserID: 20, Amount: 100, Reason: "a"}, - {UserID: 999, Amount: 100, Reason: "not_member"}, - {UserID: 21, Amount: 200, Reason: "b"}, - }, - } - resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.Total, ShouldEqual, 3) - So(resp.Success, ShouldEqual, 2) - So(resp.Failed, ShouldEqual, 1) - So(len(resp.Items), ShouldEqual, 3) - So(resp.Items[0].OK, ShouldBeTrue) - So(resp.Items[0].OrderID, ShouldBeGreaterThan, 0) - So(resp.Items[1].OK, ShouldBeFalse) - So(resp.Items[2].OK, ShouldBeTrue) - - var tu20 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20).Error, ShouldBeNil) - So(tu20.Balance, ShouldEqual, 100) - var tu21 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21).Error, ShouldBeNil) - So(tu21.Balance, ShouldEqual, 200) - - Convey("同一批次重复调用应幂等,不重复入账", func() { - resp2, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now.Add(time.Second)) - So(err, ShouldBeNil) - So(resp2.Success, ShouldEqual, 2) - - var tu20b models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20b).Error, ShouldBeNil) - So(tu20b.Balance, ShouldEqual, 100) - var tu21b models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21b).Error, ShouldBeNil) - So(tu21b.Balance, ShouldEqual, 200) - }) - }) - }) -} - func (s *OrderTestSuite) Test_AdminRefundOrder() { Convey("Order.AdminRefundOrder", s.T(), func() { ctx := s.T().Context() @@ -1020,6 +779,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { models.TableNameOrderItem, models.TableNameOrder, models.TableNameTenantUser, + models.TableNameUser, ) Convey("参数非法应返回错误", func() { @@ -1077,8 +837,8 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { So(err, ShouldNotBeNil) }) - Convey("成功退款应回收权益并入账", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) + Convey("成功退款应回收权益并入账", func() { + s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) contentID := int64(123) orderModel := &models.Order{ @@ -1122,45 +882,45 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { } So(access.Create(ctx), ShouldBeNil) - refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) - So(err, ShouldBeNil) - So(refunding, ShouldNotBeNil) - So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) + refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) + So(err, ShouldBeNil) + So(refunding, ShouldNotBeNil) + So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) - // refunding 期间重复请求应幂等返回 refunding(并允许重复触发入队,不影响最终结果)。 - refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second)) - So(err, ShouldBeNil) - So(refunding2, ShouldNotBeNil) - So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding) + // refunding 期间重复请求应幂等返回 refunding(并允许重复触发入队,不影响最终结果)。 + refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second)) + So(err, ShouldBeNil) + So(refunding2, ShouldNotBeNil) + So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding) - refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ - TenantID: tenantID, - OrderID: orderModel.ID, - OperatorUserID: operatorUserID, - Force: false, - Reason: "原因", - Now: now.Add(2 * time.Minute), - }) - So(err, ShouldBeNil) - So(refunded, ShouldNotBeNil) - So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded) + refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ + TenantID: tenantID, + OrderID: orderModel.ID, + OperatorUserID: operatorUserID, + Force: false, + Reason: "原因", + Now: now.Add(2 * time.Minute), + }) + So(err, ShouldBeNil) + So(refunded, ShouldNotBeNil) + So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded) - // worker 重试/重复执行应幂等:不重复入账、不重复回收权益。 - refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ - TenantID: tenantID, - OrderID: orderModel.ID, - OperatorUserID: operatorUserID, - Force: false, - Reason: "原因", - Now: now.Add(5 * time.Minute), - }) - So(err, ShouldBeNil) - So(refundedRetry, ShouldNotBeNil) - So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded) + // worker 重试/重复执行应幂等:不重复入账、不重复回收权益。 + refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ + TenantID: tenantID, + OrderID: orderModel.ID, + OperatorUserID: operatorUserID, + Force: false, + Reason: "原因", + Now: now.Add(5 * time.Minute), + }) + So(err, ShouldBeNil) + So(refundedRetry, ShouldNotBeNil) + So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded) - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) - So(tu.Balance, ShouldEqual, 300) + var u models.User + So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil) + So(u.Balance, ShouldEqual, 300) var access2 models.ContentAccess So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, buyerUserID, contentID).First(&access2).Error, ShouldBeNil) @@ -1171,31 +931,31 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { So(err, ShouldBeNil) So(refunded2.Status, ShouldEqual, consts.OrderStatusRefunded) - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 300) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 300) var ledgers []*models.TenantLedger - So(_db.WithContext(ctx). - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)). - Find(&ledgers).Error, ShouldBeNil) - So(len(ledgers), ShouldEqual, 1) - }) - - Convey("不可重试错误分类应稳定", func() { - So(IsRefundJobNonRetryableError(nil), ShouldBeFalse) - So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse) - - So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue) - - So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse) - }) + So(_db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)). + Find(&ledgers).Error, ShouldBeNil) + So(len(ledgers), ShouldEqual, 1) }) - } + + Convey("不可重试错误分类应稳定", func() { + So(IsRefundJobNonRetryableError(nil), ShouldBeFalse) + So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse) + + So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue) + So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue) + + So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse) + }) + }) +} func (s *OrderTestSuite) Test_PurchaseContent() { Convey("Order.PurchaseContent", s.T(), func() { @@ -1302,6 +1062,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() { models.TableNameContentPrice, models.TableNameContent, models.TableNameTenantUser, + models.TableNameUser, ) s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) content := s.seedPublishedContent(ctx, tenantID, ownerUserID) @@ -1335,10 +1096,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(itemSnap.ContentID, ShouldEqual, content.ID) So(itemSnap.AmountPaid, ShouldEqual, int64(300)) - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) - So(tu.Balance, ShouldEqual, 700) - So(tu.BalanceFrozen, ShouldEqual, 0) + var u models.User + So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil) + So(u.Balance, ShouldEqual, 700) + So(u.BalanceFrozen, ShouldEqual, 0) res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ TenantID: tenantID, @@ -1350,10 +1111,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(err, ShouldBeNil) So(res2.Order.ID, ShouldEqual, res1.Order.ID) - var tu2 models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil) - So(tu2.Balance, ShouldEqual, 700) - So(tu2.BalanceFrozen, ShouldEqual, 0) + var u2 models.User + So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil) + So(u2.Balance, ShouldEqual, 700) + So(u2.BalanceFrozen, ShouldEqual, 0) }) Convey("存在回滚标记时应稳定返回“失败+已回滚”", func() { @@ -1366,6 +1127,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() { models.TableNameContentPrice, models.TableNameContent, models.TableNameTenantUser, + models.TableNameUser, ) s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) content := s.seedPublishedContent(ctx, tenantID, ownerUserID) diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 8824f3c..60d1c90 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -278,20 +278,23 @@ func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64 return result, nil } - tbl, query := models.TenantUserQuery.QueryContext(ctx) - var items []struct { TenantID int64 Balance int64 } - err := query. - Select( - tbl.TenantID, - tbl.Balance.Sum().As("balance"), - ). - Where(tbl.TenantID.In(tenantIds...)). - Group(tbl.TenantID). - Scan(&items) + + // 全局余额:按租户维度统计“该租户成员的 users.balance 之和”。 + // 注意:用户可能加入多个租户,因此不同租户的统计会出现重复计入(这符合“按租户视角”统计的直觉)。 + err := models.Q.TenantUser. + WithContext(ctx). + UnderlyingDB(). + Table(models.TableNameTenantUser+" tu"). + Select("tu.tenant_id, COALESCE(SUM(u.balance), 0) AS balance"). + Joins("JOIN "+models.TableNameUser+" u ON u.id = tu.user_id AND u.deleted_at IS NULL"). + Where("tu.tenant_id IN ?", tenantIds). + Group("tu.tenant_id"). + Scan(&items). + Error if err != nil { return nil, err } diff --git a/backend/app/services/tenant_join.go b/backend/app/services/tenant_join.go index 8f5d709..6ed2b45 100644 --- a/backend/app/services/tenant_join.go +++ b/backend/app/services/tenant_join.go @@ -265,14 +265,12 @@ func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, invit // 加入租户:默认 member + verified;与 tenant.AddUser 保持一致。 tu := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - Balance: 0, - BalanceFrozen: 0, - CreatedAt: now, - UpdatedAt: now, + TenantID: tenantID, + UserID: userID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + CreatedAt: now, + UpdatedAt: now, } if err := tx.Create(tu).Error; err != nil { if isUniqueViolation(err) { @@ -430,14 +428,12 @@ func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operator // 先落成员关系,再更新申请状态,保证“通过后一定能成为成员”(至少幂等)。 tu := &models.TenantUser{ - TenantID: tenantID, - UserID: req.UserID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - Balance: 0, - BalanceFrozen: 0, - CreatedAt: now, - UpdatedAt: now, + TenantID: tenantID, + UserID: req.UserID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + CreatedAt: now, + UpdatedAt: now, } if err := tx.Create(tu).Error; err != nil && !isUniqueViolation(err) { return err diff --git a/backend/database/fields/orders.go b/backend/database/fields/orders.go index 0aa6ebe..59a7ab1 100644 --- a/backend/database/fields/orders.go +++ b/backend/database/fields/orders.go @@ -14,7 +14,7 @@ import ( // - 查询/展示时可以先看 kind,再按需解析 data。 // - 兼容历史数据:如果旧数据没有 kind/data,则按 legacy 处理(data = 原始 JSON)。 type OrdersSnapshot struct { - // Kind 快照类型:建议与订单类型对齐(例如 content_purchase / topup)。 + // Kind 快照类型:建议与订单类型对齐(例如 content_purchase)。 Kind string `json:"kind"` // Data 具体快照数据(按 Kind 对应不同结构)。 Data json.RawMessage `json:"data"` @@ -78,21 +78,3 @@ type OrdersContentPurchaseSnapshot struct { // PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。 PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"` } - -// OrdersTopupSnapshot 为“后台充值订单”的快照(用于审计与追责)。 -type OrdersTopupSnapshot struct { - // OperatorUserID 充值操作人用户ID(租户管理员)。 - OperatorUserID int64 `json:"operator_user_id"` - // TargetUserID 充值目标用户ID(租户成员)。 - TargetUserID int64 `json:"target_user_id"` - // Amount 充值金额(分)。 - Amount int64 `json:"amount"` - // Currency 币种:当前固定 CNY(金额单位为分)。 - Currency consts.Currency `json:"currency"` - // Reason 充值原因(可选,强烈建议填写用于审计)。 - Reason string `json:"reason,omitempty"` - // IdempotencyKey 幂等键(可选)。 - IdempotencyKey string `json:"idempotency_key,omitempty"` - // TopupAt 充值时间(逻辑时间)。 - TopupAt time.Time `json:"topup_at"` -} diff --git a/backend/database/migrations/20251215084449_users.sql b/backend/database/migrations/20251215084449_users.sql index 9acfc22..0922b41 100644 --- a/backend/database/migrations/20251215084449_users.sql +++ b/backend/database/migrations/20251215084449_users.sql @@ -10,9 +10,17 @@ CREATE TABLE IF NOT EXISTS users( roles text[] NOT NULL DEFAULT ARRAY['user'], status varchar(50) NOT NULL DEFAULT 'active', metas jsonb NOT NULL DEFAULT '{}', + balance bigint NOT NULL DEFAULT 0, + balance_frozen bigint NOT NULL DEFAULT 0, verified_at timestamptz ); +COMMENT ON COLUMN users.balance IS '全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0'; +COMMENT ON COLUMN users.balance_frozen IS '全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0'; + +CREATE INDEX IF NOT EXISTS ix_users_balance ON users(balance); +CREATE INDEX IF NOT EXISTS ix_users_balance_frozen ON users(balance_frozen); + -- +goose StatementEnd -- +goose Down -- +goose StatementBegin diff --git a/backend/database/migrations/20251216011456_tenant_users.sql b/backend/database/migrations/20251216011456_tenant_users.sql index e6f722f..d2dd11d 100644 --- a/backend/database/migrations/20251216011456_tenant_users.sql +++ b/backend/database/migrations/20251216011456_tenant_users.sql @@ -5,7 +5,6 @@ CREATE TABLE IF NOT EXISTS tenant_users( tenant_id bigint NOT NULL, user_id bigint NOT NULL, role TEXT[] NOT NULL DEFAULT ARRAY['member'], - balance bigint NOT NULL DEFAULT 0, status varchar(50) NOT NULL DEFAULT 'active', created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW(), diff --git a/backend/database/migrations/20251218120000_orders_ledgers.sql b/backend/database/migrations/20251218120000_orders_ledgers.sql index 17acb99..067b876 100644 --- a/backend/database/migrations/20251218120000_orders_ledgers.sql +++ b/backend/database/migrations/20251218120000_orders_ledgers.sql @@ -1,13 +1,5 @@ -- +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, @@ -29,12 +21,12 @@ CREATE TABLE IF NOT EXISTS orders( updated_at timestamptz NOT NULL DEFAULT NOW() ); --- orders:订单主表(租户内购买/充值等业务单据) +-- 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.type IS '订单类型:content_purchase(购买内容)等;当前默认 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 '原价金额:分;未折扣前金额(用于展示与对账)'; @@ -103,12 +95,12 @@ CREATE TABLE IF NOT EXISTS tenant_ledgers( ); -- tenant_ledgers:租户内余额账本流水(必须可审计、可幂等) -COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(充值/冻结/扣款/退款等);用于审计与对账回放'; +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.type IS '流水类型: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 '变更后可用余额:用于审计与对账回放'; @@ -143,8 +135,4 @@ 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/migrations/20251222211500_tenant_ledgers_audit_fields.sql b/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql index 3309a9f..074bbf8 100644 --- a/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql +++ b/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql @@ -6,12 +6,12 @@ ALTER TABLE tenant_ledgers ADD COLUMN IF NOT EXISTS biz_ref_id bigint; -- tenant_ledgers.operator_user_id:操作者(谁触发该流水) --- 用途:用于审计与风控追溯(例如后台代充值/代退款/调账等)。 +-- 用途:用于审计与风控追溯(例如后台代退款/调账等)。 COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; -- tenant_ledgers.biz_ref_type/biz_ref_id:业务引用(幂等与追溯) --- 用途:在 idempotency_key 之外提供结构化引用(例如 order/refund/topup 等),便于报表与按业务对象追溯。 -COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; +-- 用途:在 idempotency_key 之外提供结构化引用(例如 order/refund 等),便于报表与按业务对象追溯。 +COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; COMMENT ON COLUMN tenant_ledgers.biz_ref_id IS '业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计'; -- 索引:按操作者检索敏感操作流水(后台审计用)。 @@ -38,4 +38,3 @@ ALTER TABLE tenant_ledgers DROP COLUMN IF EXISTS biz_ref_type, DROP COLUMN IF EXISTS operator_user_id; -- +goose StatementEnd - diff --git a/backend/database/migrations/20251223124000_update_order_ledger_comments.sql b/backend/database/migrations/20251223124000_update_order_ledger_comments.sql new file mode 100644 index 0000000..e1df9ba --- /dev/null +++ b/backend/database/migrations/20251223124000_update_order_ledger_comments.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +-- 清理“充值”遗留描述:当前项目已移除租户充值与 per-tenant 余额。 + +COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)等;当前默认 content_purchase'; + +COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(冻结/扣款/退款/调账等);用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; + +COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; +COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- 新项目不需要依赖 Down 做历史回滚;保持与 Up 一致,避免引入已移除特性的遗留描述。 +COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)等;当前默认 content_purchase'; + +COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(冻结/扣款/退款/调账等);用于审计与对账回放'; +COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; + +COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; +COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; +-- +goose StatementEnd diff --git a/backend/database/models/order_items.gen.go b/backend/database/models/order_items.gen.go index 3c7507b..43930e2 100644 --- a/backend/database/models/order_items.gen.go +++ b/backend/database/models/order_items.gen.go @@ -28,8 +28,8 @@ type OrderItem struct { Snapshot types.JSONType[fields.OrderItemsSnapshot] `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() - Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"` 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 diff --git a/backend/database/models/order_items.query.gen.go b/backend/database/models/order_items.query.gen.go index 26afd59..91ad511 100644 --- a/backend/database/models/order_items.query.gen.go +++ b/backend/database/models/order_items.query.gen.go @@ -35,18 +35,18 @@ func newOrderItem(db *gorm.DB, opts ...gen.DOOption) orderItemQuery { _orderItemQuery.Snapshot = field.NewJSONB(tableName, "snapshot") _orderItemQuery.CreatedAt = field.NewTime(tableName, "created_at") _orderItemQuery.UpdatedAt = field.NewTime(tableName, "updated_at") - _orderItemQuery.Content = orderItemQueryBelongsToContent{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Content", "Content"), - } - _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 @@ -66,9 +66,9 @@ type orderItemQuery struct { Snapshot field.JSONB // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 CreatedAt field.Time // 创建时间:默认 now() UpdatedAt field.Time // 更新时间:默认 now() - Content orderItemQueryBelongsToContent + Order orderItemQueryBelongsToOrder - Order orderItemQueryBelongsToOrder + Content orderItemQueryBelongsToContent fieldMap map[string]field.Expr } @@ -143,101 +143,20 @@ func (o *orderItemQuery) fillFieldMap() { func (o orderItemQuery) clone(db *gorm.DB) orderItemQuery { o.orderItemQueryDo.ReplaceConnPool(db.Statement.ConnPool) - o.Content.db = db.Session(&gorm.Session{Initialized: true}) - o.Content.db.Statement.ConnPool = 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.Content.db = db.Session(&gorm.Session{}) o.Order.db = db.Session(&gorm.Session{}) + o.Content.db = db.Session(&gorm.Session{}) return o } -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 orderItemQueryBelongsToOrder struct { db *gorm.DB @@ -319,6 +238,87 @@ func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrder 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 { diff --git a/backend/database/models/orders.gen.go b/backend/database/models/orders.gen.go index 235ff29..2dff0d1 100644 --- a/backend/database/models/orders.gen.go +++ b/backend/database/models/orders.gen.go @@ -22,7 +22,7 @@ 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 + Type consts.OrderType `gorm:"column:type;type:character varying(32);not null;default:content_purchase;comment:订单类型:content_purchase(购买内容)等;当前默认 content_purchase" json:"type"` // 订单类型:content_purchase(购买内容)等;当前默认 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"` // 原价金额:分;未折扣前金额(用于展示与对账) diff --git a/backend/database/models/orders.query.gen.go b/backend/database/models/orders.query.gen.go index 799d2d8..65eb6b2 100644 --- a/backend/database/models/orders.query.gen.go +++ b/backend/database/models/orders.query.gen.go @@ -61,7 +61,7 @@ type orderQuery struct { 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 + Type field.Field // 订单类型:content_purchase(购买内容)等;当前默认 content_purchase Status field.Field // 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 Currency field.Field // 币种:当前固定 CNY;金额单位为分 AmountOriginal field.Int64 // 原价金额:分;未折扣前金额(用于展示与对账) diff --git a/backend/database/models/tenant_ledgers.gen.go b/backend/database/models/tenant_ledgers.gen.go index 22cc924..1a1fceb 100644 --- a/backend/database/models/tenant_ledgers.gen.go +++ b/backend/database/models/tenant_ledgers.gen.go @@ -17,23 +17,23 @@ 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() - OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)" json:"operator_user_id"` // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) - BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 - BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计" json:"biz_ref_id"` // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 + 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:流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型: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() + OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)" json:"operator_user_id"` // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) + BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计" json:"biz_ref_id"` // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` } diff --git a/backend/database/models/tenant_ledgers.query.gen.go b/backend/database/models/tenant_ledgers.query.gen.go index 79dabbb..9a90810 100644 --- a/backend/database/models/tenant_ledgers.query.gen.go +++ b/backend/database/models/tenant_ledgers.query.gen.go @@ -61,7 +61,7 @@ type tenantLedgerQuery struct { 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;不同类型决定余额/冻结余额的变更方向 + Type field.Field // 流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 Amount field.Int64 // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束) BalanceBefore field.Int64 // 变更前可用余额:用于审计与对账回放 BalanceAfter field.Int64 // 变更后可用余额:用于审计与对账回放 @@ -72,7 +72,7 @@ type tenantLedgerQuery struct { CreatedAt field.Time // 创建时间:默认 now() UpdatedAt field.Time // 更新时间:默认 now() OperatorUserID field.Int64 // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时) - BizRefType field.String // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + BizRefType field.String // 业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 BizRefID field.Int64 // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 Order tenantLedgerQueryBelongsToOrder diff --git a/backend/database/models/tenant_users.gen.go b/backend/database/models/tenant_users.gen.go index 27713ca..a20a838 100644 --- a/backend/database/models/tenant_users.gen.go +++ b/backend/database/models/tenant_users.gen.go @@ -18,15 +18,13 @@ 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:verified" 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 + 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"` + Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:verified" 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"` } // 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 280d18c..7b3cf42 100644 --- a/backend/database/models/tenant_users.query.gen.go +++ b/backend/database/models/tenant_users.query.gen.go @@ -29,11 +29,9 @@ func newTenantUser(db *gorm.DB, opts ...gen.DOOption) tenantUserQuery { _tenantUserQuery.TenantID = field.NewInt64(tableName, "tenant_id") _tenantUserQuery.UserID = field.NewInt64(tableName, "user_id") _tenantUserQuery.Role = field.NewArray(tableName, "role") - _tenantUserQuery.Balance = field.NewInt64(tableName, "balance") _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() @@ -43,16 +41,14 @@ 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 - BalanceFrozen field.Int64 // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 + ALL field.Asterisk + ID field.Int64 + TenantID field.Int64 + UserID field.Int64 + Role field.Array + Status field.Field + CreatedAt field.Time + UpdatedAt field.Time fieldMap map[string]field.Expr } @@ -73,11 +69,9 @@ func (t *tenantUserQuery) updateTableName(table string) *tenantUserQuery { t.TenantID = field.NewInt64(table, "tenant_id") t.UserID = field.NewInt64(table, "user_id") t.Role = field.NewArray(table, "role") - t.Balance = field.NewInt64(table, "balance") 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() @@ -110,16 +104,14 @@ func (t *tenantUserQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo } func (t *tenantUserQuery) fillFieldMap() { - t.fieldMap = make(map[string]field.Expr, 9) + t.fieldMap = make(map[string]field.Expr, 7) t.fieldMap["id"] = t.ID t.fieldMap["tenant_id"] = t.TenantID t.fieldMap["user_id"] = t.UserID t.fieldMap["role"] = t.Role - t.fieldMap["balance"] = t.Balance 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..a70b227 100644 --- a/backend/database/models/users.gen.go +++ b/backend/database/models/users.gen.go @@ -19,18 +19,20 @@ const TableNameUser = "users" // User mapped from table type User struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - 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"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` - Username string `gorm:"column:username;type:character varying(255);not null" json:"username"` - Password string `gorm:"column:password;type:character varying(255);not null" json:"password"` - Roles types.Array[consts.Role] `gorm:"column:roles;type:text[];not null;default:ARRAY['user" json:"roles"` - 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"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + 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"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` + Username string `gorm:"column:username;type:character varying(255);not null" json:"username"` + Password string `gorm:"column:password;type:character varying(255);not null" json:"password"` + Roles types.Array[consts.Role] `gorm:"column:roles;type:text[];not null;default:ARRAY['user" json:"roles"` + 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"` + Balance int64 `gorm:"column:balance;type:bigint;not null;comment:全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0" json:"balance"` // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0 + BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0" json:"balance_frozen"` // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0 + 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"` } // 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..78100cd 100644 --- a/backend/database/models/users.query.gen.go +++ b/backend/database/models/users.query.gen.go @@ -34,6 +34,8 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { _userQuery.Roles = field.NewArray(tableName, "roles") _userQuery.Status = field.NewField(tableName, "status") _userQuery.Metas = field.NewJSONB(tableName, "metas") + _userQuery.Balance = field.NewInt64(tableName, "balance") + _userQuery.BalanceFrozen = field.NewInt64(tableName, "balance_frozen") _userQuery.VerifiedAt = field.NewTime(tableName, "verified_at") _userQuery.OwnedTenant = userQueryBelongsToOwnedTenant{ db: db.Session(&gorm.Session{}), @@ -55,18 +57,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 + Balance field.Int64 // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0 + BalanceFrozen field.Int64 // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0 + VerifiedAt field.Time + OwnedTenant userQueryBelongsToOwnedTenant Tenants userQueryManyToManyTenants @@ -94,6 +98,8 @@ func (u *userQuery) updateTableName(table string) *userQuery { u.Roles = field.NewArray(table, "roles") u.Status = field.NewField(table, "status") u.Metas = field.NewJSONB(table, "metas") + u.Balance = field.NewInt64(table, "balance") + u.BalanceFrozen = field.NewInt64(table, "balance_frozen") u.VerifiedAt = field.NewTime(table, "verified_at") u.fillFieldMap() @@ -125,7 +131,7 @@ func (u *userQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (u *userQuery) fillFieldMap() { - u.fieldMap = make(map[string]field.Expr, 12) + u.fieldMap = make(map[string]field.Expr, 14) u.fieldMap["id"] = u.ID u.fieldMap["created_at"] = u.CreatedAt u.fieldMap["updated_at"] = u.UpdatedAt @@ -135,6 +141,8 @@ func (u *userQuery) fillFieldMap() { u.fieldMap["roles"] = u.Roles u.fieldMap["status"] = u.Status u.fieldMap["metas"] = u.Metas + u.fieldMap["balance"] = u.Balance + u.fieldMap["balance_frozen"] = u.BalanceFrozen u.fieldMap["verified_at"] = u.VerifiedAt } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index add13fa..9e5bcd1 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -967,7 +967,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。", + "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund 等。", "name": "biz_ref_type", "in": "query" }, @@ -991,7 +991,7 @@ const docTemplate = `{ }, { "type": "integer", - "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。", + "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的退款/调账”等敏感操作流水。", "name": "operator_user_id", "in": "query" }, @@ -1009,7 +1009,6 @@ const docTemplate = `{ }, { "enum": [ - "credit_topup", "debit_purchase", "credit_refund", "freeze", @@ -1018,7 +1017,6 @@ const docTemplate = `{ ], "type": "string", "x-enum-varnames": [ - "TenantLedgerTypeCreditTopup", "TenantLedgerTypeDebitPurchase", "TenantLedgerTypeCreditRefund", "TenantLedgerTypeFreeze", @@ -1452,15 +1450,13 @@ const docTemplate = `{ }, { "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "type": "string", "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ], - "description": "Type 订单类型(可选):content_purchase/topup 等。", + "description": "Type 订单类型(可选):content_purchase 等。", "name": "type", "in": "query" }, @@ -1615,15 +1611,13 @@ const docTemplate = `{ }, { "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "type": "string", "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ], - "description": "Type 订单类型(可选):content_purchase/topup 等。", + "description": "Type 订单类型(可选):content_purchase 等。", "name": "type", "in": "query" }, @@ -1691,6 +1685,7 @@ const docTemplate = `{ }, "/t/{tenantCode}/v1/admin/orders/{orderID}/refund": { "post": { + "description": "该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。\n重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。", "consumes": [ "application/json" ], @@ -1834,46 +1829,6 @@ const docTemplate = `{ } } }, - "/t/{tenantCode}/v1/admin/users/topup/batch": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tenant" - ], - "summary": "批量为租户成员充值(租户管理)", - "parameters": [ - { - "type": "string", - "description": "Tenant Code", - "name": "tenantCode", - "in": "path", - "required": true - }, - { - "description": "Form", - "name": "form", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/dto.AdminBatchTopupForm" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/dto.AdminBatchTopupResponse" - } - } - } - } - }, "/t/{tenantCode}/v1/admin/users/{userID}": { "delete": { "consumes": [ @@ -2000,54 +1955,6 @@ const docTemplate = `{ } } }, - "/t/{tenantCode}/v1/admin/users/{userID}/topup": { - "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": "UserID", - "name": "userID", - "in": "path", - "required": true - }, - { - "description": "Form", - "name": "form", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/dto.AdminTopupForm" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.Order" - } - } - } - } - }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -2945,12 +2852,10 @@ const docTemplate = `{ "consts.OrderType": { "type": "string", "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ] }, "consts.Role": { @@ -2993,7 +2898,6 @@ const docTemplate = `{ "consts.TenantLedgerType": { "type": "string", "enum": [ - "credit_topup", "debit_purchase", "credit_refund", "freeze", @@ -3001,7 +2905,6 @@ const docTemplate = `{ "adjustment" ], "x-enum-varnames": [ - "TenantLedgerTypeCreditTopup", "TenantLedgerTypeDebitPurchase", "TenantLedgerTypeCreditRefund", "TenantLedgerTypeFreeze", @@ -3046,100 +2949,6 @@ const docTemplate = `{ "UserStatusBanned" ] }, - "dto.AdminBatchTopupForm": { - "type": "object", - "properties": { - "batch_idempotency_key": { - "description": "BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。", - "type": "string" - }, - "items": { - "description": "Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。", - "type": "array", - "items": { - "$ref": "#/definitions/dto.AdminBatchTopupItem" - } - } - } - }, - "dto.AdminBatchTopupItem": { - "type": "object", - "properties": { - "amount": { - "description": "Amount 充值金额:单位分;必须 \u003e 0。", - "type": "integer" - }, - "idempotency_key": { - "description": "IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成;\n建议前端/调用方提供稳定值,便于重试时保持结果一致。", - "type": "string" - }, - "reason": { - "description": "Reason 充值原因(可选):用于审计与追溯。", - "type": "string" - }, - "user_id": { - "description": "UserID 目标用户ID:必须属于当前租户,否则该条充值失败。", - "type": "integer" - } - } - }, - "dto.AdminBatchTopupResponse": { - "type": "object", - "properties": { - "failed": { - "description": "Failed 失败条数。", - "type": "integer" - }, - "items": { - "description": "Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。", - "type": "array", - "items": { - "$ref": "#/definitions/dto.AdminBatchTopupResultItem" - } - }, - "success": { - "description": "Success 成功条数。", - "type": "integer" - }, - "total": { - "description": "Total 总条数:等于 items 长度。", - "type": "integer" - } - } - }, - "dto.AdminBatchTopupResultItem": { - "type": "object", - "properties": { - "amount": { - "description": "Amount 充值金额(单位分)。", - "type": "integer" - }, - "error_code": { - "description": "ErrorCode 错误码:失败时返回;成功时为 0。", - "type": "integer" - }, - "error_message": { - "description": "ErrorMessage 错误信息:失败时返回;成功时为空。", - "type": "string" - }, - "idempotency_key": { - "description": "IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。", - "type": "string" - }, - "ok": { - "description": "OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。", - "type": "boolean" - }, - "order_id": { - "description": "OrderID 生成的订单ID:成功时返回;失败时为 0。", - "type": "integer" - }, - "user_id": { - "description": "UserID 目标用户ID。", - "type": "integer" - } - } - }, "dto.AdminLedgerItem": { "type": "object", "properties": { @@ -3383,23 +3192,6 @@ const docTemplate = `{ } } }, - "dto.AdminTopupForm": { - "type": "object", - "properties": { - "amount": { - "description": "Amount is the topup amount in cents (CNY 分); must be \u003e 0.", - "type": "integer" - }, - "idempotency_key": { - "description": "IdempotencyKey ensures the topup request is processed at most once.", - "type": "string" - }, - "reason": { - "description": "Reason is the human-readable topup reason used for audit.", - "type": "string" - } - } - }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { @@ -3859,6 +3651,14 @@ const docTemplate = `{ "dto.UserItem": { "type": "object", "properties": { + "balance": { + "description": "全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0", + "type": "integer" + }, + "balance_frozen": { + "description": "全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0", + "type": "integer" + }, "created_at": { "type": "string" }, @@ -4336,7 +4136,7 @@ const docTemplate = `{ "type": "integer" }, "type": { - "description": "订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase", + "description": "订单类型:content_purchase(购买内容)等;当前默认 content_purchase", "allOf": [ { "$ref": "#/definitions/consts.OrderType" @@ -4582,7 +4382,7 @@ const docTemplate = `{ "type": "integer" }, "biz_ref_type": { - "description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", + "description": "业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", "type": "string" }, "created_at": { @@ -4625,7 +4425,7 @@ const docTemplate = `{ "type": "integer" }, "type": { - "description": "流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", + "description": "流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", "allOf": [ { "$ref": "#/definitions/consts.TenantLedgerType" @@ -4645,13 +4445,6 @@ const docTemplate = `{ "models.TenantUser": { "type": "object", "properties": { - "balance": { - "type": "integer" - }, - "balance_frozen": { - "description": "冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0", - "type": "integer" - }, "created_at": { "type": "string" }, @@ -4681,6 +4474,14 @@ const docTemplate = `{ "models.User": { "type": "object", "properties": { + "balance": { + "description": "全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0", + "type": "integer" + }, + "balance_frozen": { + "description": "全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0", + "type": "integer" + }, "created_at": { "type": "string" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 9d042d3..2d51a29 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -961,7 +961,7 @@ }, { "type": "string", - "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。", + "description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund 等。", "name": "biz_ref_type", "in": "query" }, @@ -985,7 +985,7 @@ }, { "type": "integer", - "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。", + "description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的退款/调账”等敏感操作流水。", "name": "operator_user_id", "in": "query" }, @@ -1003,7 +1003,6 @@ }, { "enum": [ - "credit_topup", "debit_purchase", "credit_refund", "freeze", @@ -1012,7 +1011,6 @@ ], "type": "string", "x-enum-varnames": [ - "TenantLedgerTypeCreditTopup", "TenantLedgerTypeDebitPurchase", "TenantLedgerTypeCreditRefund", "TenantLedgerTypeFreeze", @@ -1446,15 +1444,13 @@ }, { "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "type": "string", "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ], - "description": "Type 订单类型(可选):content_purchase/topup 等。", + "description": "Type 订单类型(可选):content_purchase 等。", "name": "type", "in": "query" }, @@ -1609,15 +1605,13 @@ }, { "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "type": "string", "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ], - "description": "Type 订单类型(可选):content_purchase/topup 等。", + "description": "Type 订单类型(可选):content_purchase 等。", "name": "type", "in": "query" }, @@ -1685,6 +1679,7 @@ }, "/t/{tenantCode}/v1/admin/orders/{orderID}/refund": { "post": { + "description": "该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。\n重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。", "consumes": [ "application/json" ], @@ -1828,46 +1823,6 @@ } } }, - "/t/{tenantCode}/v1/admin/users/topup/batch": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tenant" - ], - "summary": "批量为租户成员充值(租户管理)", - "parameters": [ - { - "type": "string", - "description": "Tenant Code", - "name": "tenantCode", - "in": "path", - "required": true - }, - { - "description": "Form", - "name": "form", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/dto.AdminBatchTopupForm" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/dto.AdminBatchTopupResponse" - } - } - } - } - }, "/t/{tenantCode}/v1/admin/users/{userID}": { "delete": { "consumes": [ @@ -1994,54 +1949,6 @@ } } }, - "/t/{tenantCode}/v1/admin/users/{userID}/topup": { - "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": "UserID", - "name": "userID", - "in": "path", - "required": true - }, - { - "description": "Form", - "name": "form", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/dto.AdminTopupForm" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.Order" - } - } - } - } - }, "/t/{tenantCode}/v1/contents": { "get": { "consumes": [ @@ -2939,12 +2846,10 @@ "consts.OrderType": { "type": "string", "enum": [ - "content_purchase", - "topup" + "content_purchase" ], "x-enum-varnames": [ - "OrderTypeContentPurchase", - "OrderTypeTopup" + "OrderTypeContentPurchase" ] }, "consts.Role": { @@ -2987,7 +2892,6 @@ "consts.TenantLedgerType": { "type": "string", "enum": [ - "credit_topup", "debit_purchase", "credit_refund", "freeze", @@ -2995,7 +2899,6 @@ "adjustment" ], "x-enum-varnames": [ - "TenantLedgerTypeCreditTopup", "TenantLedgerTypeDebitPurchase", "TenantLedgerTypeCreditRefund", "TenantLedgerTypeFreeze", @@ -3040,100 +2943,6 @@ "UserStatusBanned" ] }, - "dto.AdminBatchTopupForm": { - "type": "object", - "properties": { - "batch_idempotency_key": { - "description": "BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。", - "type": "string" - }, - "items": { - "description": "Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。", - "type": "array", - "items": { - "$ref": "#/definitions/dto.AdminBatchTopupItem" - } - } - } - }, - "dto.AdminBatchTopupItem": { - "type": "object", - "properties": { - "amount": { - "description": "Amount 充值金额:单位分;必须 \u003e 0。", - "type": "integer" - }, - "idempotency_key": { - "description": "IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成;\n建议前端/调用方提供稳定值,便于重试时保持结果一致。", - "type": "string" - }, - "reason": { - "description": "Reason 充值原因(可选):用于审计与追溯。", - "type": "string" - }, - "user_id": { - "description": "UserID 目标用户ID:必须属于当前租户,否则该条充值失败。", - "type": "integer" - } - } - }, - "dto.AdminBatchTopupResponse": { - "type": "object", - "properties": { - "failed": { - "description": "Failed 失败条数。", - "type": "integer" - }, - "items": { - "description": "Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。", - "type": "array", - "items": { - "$ref": "#/definitions/dto.AdminBatchTopupResultItem" - } - }, - "success": { - "description": "Success 成功条数。", - "type": "integer" - }, - "total": { - "description": "Total 总条数:等于 items 长度。", - "type": "integer" - } - } - }, - "dto.AdminBatchTopupResultItem": { - "type": "object", - "properties": { - "amount": { - "description": "Amount 充值金额(单位分)。", - "type": "integer" - }, - "error_code": { - "description": "ErrorCode 错误码:失败时返回;成功时为 0。", - "type": "integer" - }, - "error_message": { - "description": "ErrorMessage 错误信息:失败时返回;成功时为空。", - "type": "string" - }, - "idempotency_key": { - "description": "IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。", - "type": "string" - }, - "ok": { - "description": "OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。", - "type": "boolean" - }, - "order_id": { - "description": "OrderID 生成的订单ID:成功时返回;失败时为 0。", - "type": "integer" - }, - "user_id": { - "description": "UserID 目标用户ID。", - "type": "integer" - } - } - }, "dto.AdminLedgerItem": { "type": "object", "properties": { @@ -3377,23 +3186,6 @@ } } }, - "dto.AdminTopupForm": { - "type": "object", - "properties": { - "amount": { - "description": "Amount is the topup amount in cents (CNY 分); must be \u003e 0.", - "type": "integer" - }, - "idempotency_key": { - "description": "IdempotencyKey ensures the topup request is processed at most once.", - "type": "string" - }, - "reason": { - "description": "Reason is the human-readable topup reason used for audit.", - "type": "string" - } - } - }, "dto.ContentAssetAttachForm": { "type": "object", "properties": { @@ -3853,6 +3645,14 @@ "dto.UserItem": { "type": "object", "properties": { + "balance": { + "description": "全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0", + "type": "integer" + }, + "balance_frozen": { + "description": "全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0", + "type": "integer" + }, "created_at": { "type": "string" }, @@ -4330,7 +4130,7 @@ "type": "integer" }, "type": { - "description": "订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase", + "description": "订单类型:content_purchase(购买内容)等;当前默认 content_purchase", "allOf": [ { "$ref": "#/definitions/consts.OrderType" @@ -4576,7 +4376,7 @@ "type": "integer" }, "biz_ref_type": { - "description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", + "description": "业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键", "type": "string" }, "created_at": { @@ -4619,7 +4419,7 @@ "type": "integer" }, "type": { - "description": "流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", + "description": "流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向", "allOf": [ { "$ref": "#/definitions/consts.TenantLedgerType" @@ -4639,13 +4439,6 @@ "models.TenantUser": { "type": "object", "properties": { - "balance": { - "type": "integer" - }, - "balance_frozen": { - "description": "冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0", - "type": "integer" - }, "created_at": { "type": "string" }, @@ -4675,6 +4468,14 @@ "models.User": { "type": "object", "properties": { + "balance": { + "description": "全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0", + "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 bd4c723..33984c0 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -111,11 +111,9 @@ definitions: consts.OrderType: enum: - content_purchase - - topup type: string x-enum-varnames: - OrderTypeContentPurchase - - OrderTypeTopup consts.Role: enum: - user @@ -146,7 +144,6 @@ definitions: - TenantJoinRequestStatusRejected consts.TenantLedgerType: enum: - - credit_topup - debit_purchase - credit_refund - freeze @@ -154,7 +151,6 @@ definitions: - adjustment type: string x-enum-varnames: - - TenantLedgerTypeCreditTopup - TenantLedgerTypeDebitPurchase - TenantLedgerTypeCreditRefund - TenantLedgerTypeFreeze @@ -188,75 +184,6 @@ definitions: - UserStatusPendingVerify - UserStatusVerified - UserStatusBanned - dto.AdminBatchTopupForm: - properties: - batch_idempotency_key: - description: BatchIdempotencyKey 批次幂等键:必须填写;用于重试同一批次时保证不会重复入账。 - type: string - items: - description: Items 充值明细列表:至少 1 条;单批次条数在业务侧限制(避免拖垮系统)。 - items: - $ref: '#/definitions/dto.AdminBatchTopupItem' - type: array - type: object - dto.AdminBatchTopupItem: - properties: - amount: - description: Amount 充值金额:单位分;必须 > 0。 - type: integer - idempotency_key: - description: |- - IdempotencyKey 幂等键(可选):为空时后端会用 batch_idempotency_key 派生生成; - 建议前端/调用方提供稳定值,便于重试时保持结果一致。 - type: string - reason: - description: Reason 充值原因(可选):用于审计与追溯。 - type: string - user_id: - description: UserID 目标用户ID:必须属于当前租户,否则该条充值失败。 - type: integer - type: object - dto.AdminBatchTopupResponse: - properties: - failed: - description: Failed 失败条数。 - type: integer - items: - description: Items 明细结果列表:与请求 items 顺序一致,便于前端逐条展示。 - items: - $ref: '#/definitions/dto.AdminBatchTopupResultItem' - type: array - success: - description: Success 成功条数。 - type: integer - total: - description: Total 总条数:等于 items 长度。 - type: integer - type: object - dto.AdminBatchTopupResultItem: - properties: - amount: - description: Amount 充值金额(单位分)。 - type: integer - error_code: - description: ErrorCode 错误码:失败时返回;成功时为 0。 - type: integer - error_message: - description: ErrorMessage 错误信息:失败时返回;成功时为空。 - type: string - idempotency_key: - description: IdempotencyKey 实际使用的幂等键:可能为客户端传入,也可能为后端派生生成。 - type: string - ok: - description: OK 是否成功:true 表示该条充值已成功入账或命中幂等成功结果。 - type: boolean - order_id: - description: OrderID 生成的订单ID:成功时返回;失败时为 0。 - type: integer - user_id: - description: UserID 目标用户ID。 - type: integer - type: object dto.AdminLedgerItem: properties: ledger: @@ -448,19 +375,6 @@ definitions: description: Role 角色:member/tenant_admin。 type: string type: object - dto.AdminTopupForm: - properties: - amount: - description: Amount is the topup amount in cents (CNY 分); must be > 0. - type: integer - idempotency_key: - description: IdempotencyKey ensures the topup request is processed at most - once. - type: string - reason: - description: Reason is the human-readable topup reason used for audit. - type: string - type: object dto.ContentAssetAttachForm: properties: asset_id: @@ -760,6 +674,12 @@ definitions: type: object dto.UserItem: properties: + balance: + description: 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0 + type: integer + balance_frozen: + description: 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0 + type: integer created_at: type: string deleted_at: @@ -1077,7 +997,7 @@ definitions: type: allOf: - $ref: '#/definitions/consts.OrderType' - description: 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase + description: 订单类型:content_purchase(购买内容)等;当前默认 content_purchase updated_at: description: 更新时间:默认 now();状态变更/退款写入时更新 type: string @@ -1244,7 +1164,7 @@ definitions: description: 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计 type: integer biz_ref_type: - description: 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 + description: 业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键 type: string created_at: description: 创建时间:默认 now() @@ -1278,7 +1198,7 @@ definitions: type: allOf: - $ref: '#/definitions/consts.TenantLedgerType' - description: 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 + description: 流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向 updated_at: description: 更新时间:默认 now() type: string @@ -1288,11 +1208,6 @@ definitions: type: object models.TenantUser: properties: - balance: - type: integer - balance_frozen: - description: 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 - type: integer created_at: type: string id: @@ -1312,6 +1227,12 @@ definitions: type: object models.User: properties: + balance: + description: 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0 + type: integer + balance_frozen: + description: 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0 + type: integer created_at: type: string deleted_at: @@ -2004,7 +1925,7 @@ paths: type: integer - description: |- BizRefType 按业务引用类型过滤(可选)。 - 约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。 + 约定:当前业务写入为 "order";未来可扩展为 refund 等。 in: query name: biz_ref_type type: string @@ -2023,7 +1944,7 @@ paths: type: integer - description: |- OperatorUserID 按操作者用户ID过滤(可选)。 - 典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。 + 典型场景:后台检索“某个管理员发起的退款/调账”等敏感操作流水。 in: query name: operator_user_id type: integer @@ -2037,7 +1958,6 @@ paths: type: integer - description: Type 按流水类型过滤(可选)。 enum: - - credit_topup - debit_purchase - credit_refund - freeze @@ -2047,7 +1967,6 @@ paths: name: type type: string x-enum-varnames: - - TenantLedgerTypeCreditTopup - TenantLedgerTypeDebitPurchase - TenantLedgerTypeCreditRefund - TenantLedgerTypeFreeze @@ -2340,16 +2259,14 @@ paths: - OrderStatusRefunded - OrderStatusCanceled - OrderStatusFailed - - description: Type 订单类型(可选):content_purchase/topup 等。 + - description: Type 订单类型(可选):content_purchase 等。 enum: - content_purchase - - topup in: query name: type type: string x-enum-varnames: - OrderTypeContentPurchase - - OrderTypeTopup - description: UserID 下单用户ID(可选):按买家用户ID精确过滤。 in: query name: user_id @@ -2403,6 +2320,9 @@ paths: post: consumes: - application/json + description: |- + 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 + 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。 parameters: - description: Tenant Code in: path @@ -2509,16 +2429,14 @@ paths: - OrderStatusRefunded - OrderStatusCanceled - OrderStatusFailed - - description: Type 订单类型(可选):content_purchase/topup 等。 + - description: Type 订单类型(可选):content_purchase 等。 enum: - content_purchase - - topup in: query name: type type: string x-enum-varnames: - OrderTypeContentPurchase - - OrderTypeTopup - description: UserID 下单用户ID(可选):按买家用户ID精确过滤。 in: query name: user_id @@ -2685,64 +2603,6 @@ paths: summary: 设置成员角色(租户管理) tags: - Tenant - /t/{tenantCode}/v1/admin/users/{userID}/topup: - post: - consumes: - - application/json - parameters: - - description: Tenant Code - in: path - name: tenantCode - required: true - type: string - - description: UserID - format: int64 - in: path - name: userID - required: true - type: integer - - description: Form - in: body - name: form - required: true - schema: - $ref: '#/definitions/dto.AdminTopupForm' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/models.Order' - summary: 为租户成员充值(租户管理) - tags: - - Tenant - /t/{tenantCode}/v1/admin/users/topup/batch: - post: - consumes: - - application/json - parameters: - - description: Tenant Code - in: path - name: tenantCode - required: true - type: string - - description: Form - in: body - name: form - required: true - schema: - $ref: '#/definitions/dto.AdminBatchTopupForm' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/dto.AdminBatchTopupResponse' - summary: 批量为租户成员充值(租户管理) - tags: - - Tenant /t/{tenantCode}/v1/contents: get: consumes: diff --git a/backend/pkg/consts/consts.gen.go b/backend/pkg/consts/consts.gen.go index f32bb10..29ad0cf 100644 --- a/backend/pkg/consts/consts.gen.go +++ b/backend/pkg/consts/consts.gen.go @@ -1686,15 +1686,12 @@ func (x NullOrderStatusStr) Value() (driver.Value, error) { 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. @@ -1708,7 +1705,6 @@ func OrderTypeNames() []string { func OrderTypeValues() []OrderType { return []OrderType{ OrderTypeContentPurchase, - OrderTypeTopup, } } @@ -1726,7 +1722,6 @@ func (x OrderType) IsValid() bool { var _OrderTypeValue = map[string]OrderType{ "content_purchase": OrderTypeContentPurchase, - "topup": OrderTypeTopup, } // ParseOrderType attempts to convert a string to a OrderType. @@ -2004,8 +1999,6 @@ func (x NullRoleStr) Value() (driver.Value, error) { } 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. @@ -2021,7 +2014,6 @@ const ( var ErrInvalidTenantLedgerType = fmt.Errorf("not a valid TenantLedgerType, try [%s]", strings.Join(_TenantLedgerTypeNames, ", ")) var _TenantLedgerTypeNames = []string{ - string(TenantLedgerTypeCreditTopup), string(TenantLedgerTypeDebitPurchase), string(TenantLedgerTypeCreditRefund), string(TenantLedgerTypeFreeze), @@ -2039,7 +2031,6 @@ func TenantLedgerTypeNames() []string { // TenantLedgerTypeValues returns a list of the values for TenantLedgerType func TenantLedgerTypeValues() []TenantLedgerType { return []TenantLedgerType{ - TenantLedgerTypeCreditTopup, TenantLedgerTypeDebitPurchase, TenantLedgerTypeCreditRefund, TenantLedgerTypeFreeze, @@ -2061,7 +2052,6 @@ func (x TenantLedgerType) IsValid() bool { } var _TenantLedgerTypeValue = map[string]TenantLedgerType{ - "credit_topup": TenantLedgerTypeCreditTopup, "debit_purchase": TenantLedgerTypeDebitPurchase, "credit_refund": TenantLedgerTypeCreditRefund, "freeze": TenantLedgerTypeFreeze, diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index 5dd626d..de483bc 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -398,7 +398,7 @@ func ContentAccessStatusItems() []requests.KV { // orders // swagger:enum OrderType -// ENUM( content_purchase, topup ) +// ENUM( content_purchase ) type OrderType string // Description returns the Chinese label for the specific enum value. @@ -406,8 +406,6 @@ func (t OrderType) Description() string { switch t { case OrderTypeContentPurchase: return "购买内容" - case OrderTypeTopup: - return "充值" default: return "未知类型" } @@ -460,14 +458,12 @@ func OrderStatusItems() []requests.KV { // tenant_ledgers // swagger:enum TenantLedgerType -// ENUM( credit_topup, debit_purchase, credit_refund, freeze, unfreeze, adjustment ) +// ENUM( 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: diff --git a/backend/specs/spec01-backlog.md b/backend/specs/spec01-backlog.md index 8a611b7..f983e5d 100644 --- a/backend/specs/spec01-backlog.md +++ b/backend/specs/spec01-backlog.md @@ -149,27 +149,19 @@ - refunding 期间不得重复扣款/重复回收权益; - 失败可重试(明确重试幂等键策略)。 -## Epic E:审计字段结构化(当前充值操作者更多在 snapshot/remark) +## Epic E:审计字段结构化(操作者/业务引用结构化) ### E1(P1, DB/API)tenant_ledgers 增加操作者字段与业务引用字段 - **DB 变更**(建议): - `tenant_ledgers.operator_user_id bigint NULL` - - `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/topup/etc) + - `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/etc) - `tenant_ledgers.biz_ref_id bigint NULL` - 对 `(tenant_id, biz_ref_type, biz_ref_id, type)` 做唯一约束(或与 idempotency_key 二选一作为主幂等源)。 - **验收用例**: - - 充值/退款/购买相关 ledger 必须写入 operator_user_id(admin/buyer/system); + - 购买/退款/调账等敏感 ledger 必须写入 operator_user_id(admin/buyer/system); - 后台可按 operator_user_id 检索敏感操作流水。 -### E2(P1, DB/Order)topup 结构化操作者字段(可选) - -- **DB 变更**(二选一): - 1) 在 `orders` 增加 `operator_user_id`(对 topup 更直观) - 2) 保持在 snapshot,但保证 ledger/operator 字段可追溯 -- **验收用例**: - - 导出订单时能明确区分“充值发起人”和“充值受益人”。 - ## 1. 建议交付顺序(最小闭环) 1) A1 → A2(先把公开读能力与语义定死) diff --git a/backend/specs/spec01-gap-analysis.md b/backend/specs/spec01-gap-analysis.md index 0c0372a..4559d32 100644 --- a/backend/specs/spec01-gap-analysis.md +++ b/backend/specs/spec01-gap-analysis.md @@ -13,9 +13,9 @@ - **加入租户**:支持邀请码加入与申请加入(tenantjoin 模块)。 ### 1.2 余额体系(可用 + 冻结)与账本流水 -- **账户维度**:`tenant_users(tenant_id,user_id)`;字段包含 `balance`、`balance_frozen`。 +- **账户维度**:`users(id)`;字段包含 `balance`、`balance_frozen`(全局余额,可在已加入租户间共享消费)。 - **账本流水**:`tenant_ledgers` 记录每次余额变更,含: - - `type`(credit_topup / freeze / unfreeze / debit_purchase / credit_refund 等); + - `type`(freeze / unfreeze / debit_purchase / credit_refund 等); - `balance_before/after`、`frozen_before/after` 快照; - `idempotency_key` 唯一约束(tenant+user 维度)用于幂等落账。 - **一致性**:账本落地实现包含行锁与“余额/冻结余额不得为负”的不变量校验。 @@ -28,10 +28,10 @@ - **试看**:区分 preview/main 资源角色;`/preview` 不要求购买,`/assets` 要求已购/免费/作者。 ### 1.4 订单、购买、充值与退款 -- **订单与明细**:`orders` + `order_items`;支持 type=content_purchase/topup 与状态流转。 +- **订单与明细**:`orders` + `order_items`;支持 type=content_purchase 与状态流转。 - **购买(余额支付)**:支持冻结→扣款(消耗冻结)→授予权益;并发靠行锁+冻结方案防止透支。 - **购买幂等**:`idempotency_key` 支持“至多一次”购买语义;失败会写回滚标记并稳定返回“失败+已回滚”。 -- **充值**:租户管理员可为租户成员单笔充值 + 批量充值;写 topup 订单 + credit_topup 账本。 +- **充值**:已移除(不提供按租户充值能力)。 - **退款**:租户管理员可对已支付订单退款;默认时间窗(paid_at + 24h),可 force 绕过;退款入账 + 回收权益。 - **后台订单查询**:支持管理员按条件分页查询与导出(CSV)。 diff --git a/backend/specs/spec01.md b/backend/specs/spec01.md index 3a62f39..62ab391 100644 --- a/backend/specs/spec01.md +++ b/backend/specs/spec01.md @@ -172,8 +172,7 @@ ### 7.1 订单类型(建议) 为后续扩展预留 `order_type`: - `content_purchase`:购买内容(本 spec 核心) -- `topup`:充值订单(租户发放/用户支付) -> 你已确认本期只做 1.A + 2.A,因此 `content_purchase` + `topup(tenant_grant)` 为主,`user_pay`/`service_fee` 仅作为未来扩展位保留。 +> 已移除“租户为用户充值 / topup”特性:用户余额为全局属性(`users.balance`),可在加入的任意租户内消费。 ### 7.2 订单状态(建议) 以余额支付为例(不接三方): @@ -218,7 +217,6 @@ - 所有入账/出账/退款都强制关联“业务单据”(订单/退款单)。 ### 8.2 流水类型(建议) -- `credit_topup`:充值入账 - `debit_purchase`:购买扣款 - `credit_refund`:退款回滚 - `freeze` / `unfreeze`:冻结/解冻(可选) @@ -235,12 +233,8 @@ 2) 创建 `TenantUser(tenant_id,user_id,role=member,balance=0)` 3) tenant_admin 可提升为 `tenant_admin` -### 9.2 租户为用户充值 -1) tenant_admin 在后台选择 tenant_user、输入金额、填写备注/原因 -2) 创建 `topup` 订单(或 topup 记录) -3) 写入 ledger:`credit_topup` -4) 增加 tenant_user.balance -5) 返回充值结果与可用余额 +### 9.2 (已移除)租户为用户充值 +本项目不支持“租户管理员为用户充值”。余额为 users 全局余额,用户可在已加入租户内共享消费。 ### 9.3 用户购买内容(余额支付) 1) buyer 选择 tenant 下某 content @@ -259,7 +253,7 @@ 1) tenant_admin 选中订单,校验可退款(状态/风控/时间窗) 2) 创建退款记录(可选),订单状态→refunding 3) 写入 ledger:`credit_refund`(金额=退款金额) -4) 增加 tenant_user.balance(可用余额) +4) 增加用户全局余额(可用余额) 5) 订单状态→refunded,记录 refunded_at 与操作者 6) 收回/标记权益(若需要) @@ -370,7 +364,7 @@ ### 13.7 balance_ledgers(强烈建议新增) - `id`, `tenant_id`, `user_id`(或 `tenant_user_id`) - `direction`(credit/debit) -- `type`(credit_topup/debit_purchase/credit_refund/...) +- `type`(debit_purchase/credit_refund/...) - `amount`(正数) - `balance_before`, `balance_after`(可选但强审计) - `biz_ref_type`, `biz_ref_id`(唯一约束,幂等) @@ -385,8 +379,6 @@ ## 14. API 草案(只描述意图,不锁死路径) ### 14.1 租户侧(Tenant Admin) -- 充值: - - `POST /tenants/:tenant_id/users/:user_id/topup`(amount, note, idempotency_key) - 订单查询: - `GET /tenants/:tenant_id/orders`(分页+筛选) - `GET /tenants/:tenant_id/orders/:id` diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index cc700d5..9374d74 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -233,39 +233,6 @@ Authorization: Bearer {{ token }} "idempotency_key": "refund-{{ orderID }}-001" } -### Tenant Admin - Topup a tenant member -@topupUserID = 2 -POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/{{ topupUserID }}/topup -Content-Type: application/json -Authorization: Bearer {{ token }} - -{ - "amount": 1000, - "reason": "联调充值", - "idempotency_key": "topup-{{ topupUserID }}-001" -} - -### Tenant Admin - Batch topup users -POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/topup/batch -Content-Type: application/json -Authorization: Bearer {{ token }} - -{ - "batch_idempotency_key": "batch-topup-001", - "items": [ - { - "user_id": 2, - "amount": 1000, - "reason": "批量充值-1" - }, - { - "user_id": 3, - "amount": 2000, - "reason": "批量充值-2" - } - ] -} - ### Tenant Admin - Join a user to tenant (add member) @joinUserID = 3 POST {{ host }}/t/{{ tenantCode }}/v1/admin/users/{{ joinUserID }}/join