feat: 移除“租户管理员为用户充值 / 每租户一套余额”能力:余额统一为全局用户余额

This commit is contained in:
2025-12-23 10:59:59 +08:00
parent dd7bcdfb98
commit a80c9b759b
39 changed files with 566 additions and 1869 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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过滤可选

View File

@@ -29,7 +29,7 @@ type AdminOrderListFilter struct {
// ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.titlelike
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。

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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 表的全局余额,用户可在已加入租户间共享消费;按租户充值会导致账务复杂且易出错。

View File

@@ -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(

View File

@@ -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(&current).Error; err != nil {
var current models.User
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(&current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("user not found")
}
return err
}
out.Ledger = &existing
out.TenantUser = &current
out.User = &current
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(&current).Error; err != nil {
var current models.User
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(&current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("user not found")
}
return err
}
out.Ledger = &existing
out.TenantUser = &current
out.User = &current
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

View File

@@ -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,
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() {

View File

@@ -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:<orderID>,保证同一订单不会重复入账。
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,

View File

@@ -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() {
@@ -1158,9 +918,9 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
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,9 +931,9 @@ 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).
@@ -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)

View File

@@ -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
}

View File

@@ -269,8 +269,6 @@ func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, invit
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
}
@@ -434,8 +432,6 @@ func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operator
UserID: req.UserID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
}

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,10 +66,10 @@ type orderItemQuery struct {
Snapshot field.JSONB // 内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计
CreatedAt field.Time // 创建时间:默认 now()
UpdatedAt field.Time // 更新时间:默认 now()
Content orderItemQueryBelongsToContent
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 {

View File

@@ -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"` // 原价金额:分;未折扣前金额(用于展示与对账)

View File

@@ -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 // 原价金额:分;未折扣前金额(用于展示与对账)

View File

@@ -21,7 +21,7 @@ type TenantLedger struct {
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不同类型决定余额/冻结余额的变更方向
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"` // 变更后可用余额:用于审计与对账回放
@@ -32,7 +32,7 @@ type TenantLedger struct {
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 组成可选的结构化幂等/追溯键
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"`
}

View File

@@ -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

View File

@@ -22,11 +22,9 @@ type TenantUser struct {
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
}
// Quick operations without importing query package

View File

@@ -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()
@@ -48,11 +46,9 @@ type tenantUserQuery struct {
TenantID field.Int64
UserID field.Int64
Role field.Array
Balance field.Int64
Status field.Field
CreatedAt field.Time
UpdatedAt field.Time
BalanceFrozen field.Int64 // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0
fieldMap map[string]field.Expr
}
@@ -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 {

View File

@@ -28,6 +28,8 @@ type User struct {
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"`

View File

@@ -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{}),
@@ -65,6 +67,8 @@ type userQuery struct {
Roles field.Array
Status field.Field
Metas field.JSONB
Balance field.Int64 // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
BalanceFrozen field.Int64 // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
VerifiedAt field.Time
OwnedTenant userQueryBelongsToOwnedTenant
@@ -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
}

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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:

View File

@@ -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,

View File

@@ -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:

View File

@@ -149,27 +149,19 @@
- refunding 期间不得重复扣款/重复回收权益;
- 失败可重试(明确重试幂等键策略)。
## Epic E审计字段结构化当前充值操作者更多在 snapshot/remark
## Epic E审计字段结构化操作者/业务引用结构化
### E1P1, DB/APItenant_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_idadmin/buyer/system
- 购买/退款/调账等敏感 ledger 必须写入 operator_user_idadmin/buyer/system
- 后台可按 operator_user_id 检索敏感操作流水。
### E2P1, DB/Ordertopup 结构化操作者字段(可选)
- **DB 变更**(二选一):
1) 在 `orders` 增加 `operator_user_id`(对 topup 更直观)
2) 保持在 snapshot但保证 ledger/operator 字段可追溯
- **验收用例**
- 导出订单时能明确区分“充值发起人”和“充值受益人”。
## 1. 建议交付顺序(最小闭环)
1) A1 → A2先把公开读能力与语义定死

View File

@@ -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

View File

@@ -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`

View File

@@ -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