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

@@ -135,25 +135,25 @@ func (*orderAdmin) adminOrderDetail(
return &dto.AdminOrderDetail{Order: m}, nil
}
// adminRefund
//
// @Summary 订单退款(租户管理)
// @Description 该接口只负责将订单从 paid 推进到 refunding并提交异步退款任务退款入账与权益回收由 worker 异步完成。
// @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param orderID path int64 true "OrderID"
// @Param form body dto.AdminOrderRefundForm true "Form"
// @Success 200 {object} models.Order
//
// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind orderID path
// @Bind form body
func (*orderAdmin) adminRefund(
// adminRefund
//
// @Summary 订单退款(租户管理)
// @Description 该接口只负责将订单从 paid 推进到 refunding并提交异步退款任务退款入账与权益回收由 worker 异步完成。
// @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param orderID path int64 true "OrderID"
// @Param form body dto.AdminOrderRefundForm true "Form"
// @Success 200 {object} models.Order
//
// @Router /t/:tenantCode/v1/admin/orders/:orderID/refund [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind orderID path
// @Bind form body
func (*orderAdmin) adminRefund(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
@@ -187,88 +187,5 @@ func (*orderAdmin) adminOrderDetail(
)
}
// adminTopupUser
//
// @Summary 为租户成员充值(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param userID path int64 true "UserID"
// @Param form body dto.AdminTopupForm true "Form"
// @Success 200 {object} models.Order
//
// @Router /t/:tenantCode/v1/admin/users/:userID/topup [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind userID path
// @Bind form body
func (*orderAdmin) adminTopupUser(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
userID int64,
form *dto.AdminTopupForm,
) (*models.Order, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if form == nil {
return nil, errorx.ErrInvalidParameter
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"operator_user": tenantUser.UserID,
"target_user": userID,
"amount": form.Amount,
"idempotency_key": form.IdempotencyKey,
}).Info("tenant.admin.users.topup")
return services.Order.AdminTopupUser(
ctx,
tenant.ID,
tenantUser.UserID,
userID,
form.Amount,
form.IdempotencyKey,
form.Reason,
time.Now(),
)
}
// adminBatchTopupUsers
//
// @Summary 批量为租户成员充值(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param form body dto.AdminBatchTopupForm true "Form"
// @Success 200 {object} dto.AdminBatchTopupResponse
//
// @Router /t/:tenantCode/v1/admin/users/topup/batch [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind form body
func (*orderAdmin) adminBatchTopupUsers(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
form *dto.AdminBatchTopupForm,
) (*dto.AdminBatchTopupResponse, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if form == nil {
return nil, errorx.ErrInvalidParameter
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"total": len(form.Items),
}).Info("tenant.admin.users.topup.batch")
return services.Order.AdminBatchTopupUsers(ctx, tenant.ID, tenantUser.UserID, form, time.Now())
}
// 注意:已移除“租户管理员为用户充值”能力。
// 余额已改为 users 表的全局余额,用户可在已加入租户间共享消费;按租户充值会导致账务复杂且易出错。

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,
TenantID: tenantID,
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
CreatedAt: now,
UpdatedAt: now,
}
So(tu.Create(ctx), ShouldBeNil)
}
@@ -82,7 +92,7 @@ func (s *LedgerTestSuite) Test_Freeze() {
So(err, ShouldBeNil)
So(res, ShouldNotBeNil)
So(res.Ledger, ShouldNotBeNil)
So(res.TenantUser, ShouldNotBeNil)
So(res.User, ShouldNotBeNil)
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeFreeze)
So(res.Ledger.Amount, ShouldEqual, 300)
So(res.Ledger.BalanceBefore, ShouldEqual, 1000)
@@ -92,8 +102,8 @@ func (s *LedgerTestSuite) Test_Freeze() {
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "")
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
So(res.TenantUser.Balance, ShouldEqual, 700)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 300)
So(res.User.Balance, ShouldEqual, 700)
So(res.User.BalanceFrozen, ShouldEqual, 300)
})
Convey("幂等键重复调用不应重复扣减", func() {
@@ -106,10 +116,10 @@ func (s *LedgerTestSuite) Test_Freeze() {
So(res2.Ledger, ShouldNotBeNil)
So(res2.Ledger.IdempotencyKey, ShouldEqual, "k_freeze_idem")
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 700)
So(tu2.BalanceFrozen, ShouldEqual, 300)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 700)
So(u2.BalanceFrozen, ShouldEqual, 300)
})
Convey("余额不足应返回前置条件失败", func() {
@@ -157,8 +167,8 @@ func (s *LedgerTestSuite) Test_Unfreeze() {
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "")
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
So(res.TenantUser.Balance, ShouldEqual, 1000)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
So(res.User.Balance, ShouldEqual, 1000)
So(res.User.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复入账", func() {
@@ -172,10 +182,10 @@ func (s *LedgerTestSuite) Test_Unfreeze() {
So(err, ShouldBeNil)
So(res2, ShouldNotBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1000)
So(tu2.BalanceFrozen, ShouldEqual, 0)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 1000)
So(u2.BalanceFrozen, ShouldEqual, 0)
})
})
}
@@ -210,8 +220,8 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
So(res.TenantUser.Balance, ShouldEqual, 700)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
So(res.User.Balance, ShouldEqual, 700)
So(res.User.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复扣减冻结余额", func() {
@@ -224,10 +234,10 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second))
So(err, ShouldBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 700)
So(tu2.BalanceFrozen, ShouldEqual, 0)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 700)
So(u2.BalanceFrozen, ShouldEqual, 0)
})
})
}
@@ -259,8 +269,8 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
So(res.TenantUser.Balance, ShouldEqual, 1000)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
So(res.User.Balance, ShouldEqual, 1000)
So(res.User.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复退款入账", func() {
@@ -274,48 +284,9 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second))
So(err, ShouldBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1000)
})
})
}
func (s *LedgerTestSuite) Test_CreditTopupTx() {
Convey("Ledger.CreditTopupTx", s.T(), func() {
ctx := s.T().Context()
tenantID := int64(1)
userID := int64(2)
now := time.Now().UTC()
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
Convey("金额非法应返回参数错误", func() {
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 0, "k_topup_invalid_amount", "topup", now)
So(err, ShouldNotBeNil)
})
Convey("成功充值应增加可用余额并写入账本", func() {
res, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_1", "topup", now)
So(err, ShouldBeNil)
So(res, ShouldNotBeNil)
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditTopup)
So(res.Ledger.OperatorUserID, ShouldEqual, int64(999))
So(res.Ledger.BizRefType, ShouldEqual, "order")
So(res.Ledger.BizRefID, ShouldEqual, int64(456))
So(res.TenantUser.Balance, ShouldEqual, 1200)
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
})
Convey("幂等键重复调用不应重复充值入账", func() {
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now)
So(err, ShouldBeNil)
_, err = Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second))
So(err, ShouldBeNil)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 1200)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 1000)
})
})
}
@@ -328,7 +299,7 @@ func (s *LedgerTestSuite) Test_MyBalance() {
s.seedTenantUser(ctx, tenantID, userID, 1000, 200)
Convey("成功返回租户内余额", func() {
Convey("成功返回全局余额", func() {
m, err := Ledger.MyBalance(ctx, tenantID, userID)
So(err, ShouldBeNil)
So(m, ShouldNotBeNil)
@@ -352,9 +323,9 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, userID, 1, 200, "k_topup_for_page", "topup", now)
_, err := Ledger.Freeze(ctx, tenantID, userID, 1, 200, "k_freeze_for_page_1", "freeze", now)
So(err, ShouldBeNil)
_, err = Ledger.Freeze(ctx, tenantID, userID, 2, 100, "k_freeze_for_page", "freeze", now.Add(time.Second))
_, err = Ledger.Unfreeze(ctx, tenantID, userID, 1, 100, "k_unfreeze_for_page_1", "unfreeze", now.Add(time.Second))
So(err, ShouldBeNil)
Convey("分页返回流水列表", func() {
@@ -365,7 +336,7 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
})
Convey("按 type 过滤", func() {
typ := consts.TenantLedgerTypeCreditTopup
typ := consts.TenantLedgerTypeFreeze
pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{Type: &typ})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
@@ -382,8 +353,8 @@ func (s *LedgerTestSuite) Test_AdminLedgerPage() {
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
// 模拟后台管理员为用户充值operator_user_id 与 user_id 不同。
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_topup_for_page", "topup", now)
// 模拟后台管理员为用户冻结资金operator_user_id 与 user_id 不同。
_, err := Ledger.FreezeTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_freeze_for_page", "freeze", now)
So(err, ShouldBeNil)
Convey("按 operator_user_id 过滤", func() {

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() {
@@ -1077,8 +837,8 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
So(err, ShouldNotBeNil)
})
Convey("成功退款应回收权益并入账", func() {
s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0)
Convey("成功退款应回收权益并入账", func() {
s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0)
contentID := int64(123)
orderModel := &models.Order{
@@ -1122,45 +882,45 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
}
So(access.Create(ctx), ShouldBeNil)
refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute))
So(err, ShouldBeNil)
So(refunding, ShouldNotBeNil)
So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding)
refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute))
So(err, ShouldBeNil)
So(refunding, ShouldNotBeNil)
So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding)
// refunding 期间重复请求应幂等返回 refunding并允许重复触发入队不影响最终结果
refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second))
So(err, ShouldBeNil)
So(refunding2, ShouldNotBeNil)
So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding)
// refunding 期间重复请求应幂等返回 refunding并允许重复触发入队不影响最终结果
refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second))
So(err, ShouldBeNil)
So(refunding2, ShouldNotBeNil)
So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding)
refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{
TenantID: tenantID,
OrderID: orderModel.ID,
OperatorUserID: operatorUserID,
Force: false,
Reason: "原因",
Now: now.Add(2 * time.Minute),
})
So(err, ShouldBeNil)
So(refunded, ShouldNotBeNil)
So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded)
refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{
TenantID: tenantID,
OrderID: orderModel.ID,
OperatorUserID: operatorUserID,
Force: false,
Reason: "原因",
Now: now.Add(2 * time.Minute),
})
So(err, ShouldBeNil)
So(refunded, ShouldNotBeNil)
So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded)
// worker 重试/重复执行应幂等:不重复入账、不重复回收权益。
refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{
TenantID: tenantID,
OrderID: orderModel.ID,
OperatorUserID: operatorUserID,
Force: false,
Reason: "原因",
Now: now.Add(5 * time.Minute),
})
So(err, ShouldBeNil)
So(refundedRetry, ShouldNotBeNil)
So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded)
// worker 重试/重复执行应幂等:不重复入账、不重复回收权益。
refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{
TenantID: tenantID,
OrderID: orderModel.ID,
OperatorUserID: operatorUserID,
Force: false,
Reason: "原因",
Now: now.Add(5 * time.Minute),
})
So(err, ShouldBeNil)
So(refundedRetry, ShouldNotBeNil)
So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded)
var tu models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil)
So(tu.Balance, ShouldEqual, 300)
var u models.User
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil)
So(u.Balance, ShouldEqual, 300)
var access2 models.ContentAccess
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, buyerUserID, contentID).First(&access2).Error, ShouldBeNil)
@@ -1171,31 +931,31 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
So(err, ShouldBeNil)
So(refunded2.Status, ShouldEqual, consts.OrderStatusRefunded)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 300)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 300)
var ledgers []*models.TenantLedger
So(_db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)).
Find(&ledgers).Error, ShouldBeNil)
So(len(ledgers), ShouldEqual, 1)
})
Convey("不可重试错误分类应稳定", func() {
So(IsRefundJobNonRetryableError(nil), ShouldBeFalse)
So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse)
So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse)
})
So(_db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)).
Find(&ledgers).Error, ShouldBeNil)
So(len(ledgers), ShouldEqual, 1)
})
}
Convey("不可重试错误分类应稳定", func() {
So(IsRefundJobNonRetryableError(nil), ShouldBeFalse)
So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse)
So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue)
So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse)
})
})
}
func (s *OrderTestSuite) Test_PurchaseContent() {
Convey("Order.PurchaseContent", s.T(), func() {
@@ -1302,6 +1062,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
models.TableNameContentPrice,
models.TableNameContent,
models.TableNameTenantUser,
models.TableNameUser,
)
s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0)
content := s.seedPublishedContent(ctx, tenantID, ownerUserID)
@@ -1335,10 +1096,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
So(itemSnap.ContentID, ShouldEqual, content.ID)
So(itemSnap.AmountPaid, ShouldEqual, int64(300))
var tu models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil)
So(tu.Balance, ShouldEqual, 700)
So(tu.BalanceFrozen, ShouldEqual, 0)
var u models.User
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil)
So(u.Balance, ShouldEqual, 700)
So(u.BalanceFrozen, ShouldEqual, 0)
res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{
TenantID: tenantID,
@@ -1350,10 +1111,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
So(err, ShouldBeNil)
So(res2.Order.ID, ShouldEqual, res1.Order.ID)
var tu2 models.TenantUser
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil)
So(tu2.Balance, ShouldEqual, 700)
So(tu2.BalanceFrozen, ShouldEqual, 0)
var u2 models.User
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil)
So(u2.Balance, ShouldEqual, 700)
So(u2.BalanceFrozen, ShouldEqual, 0)
})
Convey("存在回滚标记时应稳定返回“失败+已回滚”", func() {
@@ -1366,6 +1127,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
models.TableNameContentPrice,
models.TableNameContent,
models.TableNameTenantUser,
models.TableNameUser,
)
s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0)
content := s.seedPublishedContent(ctx, tenantID, ownerUserID)

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

@@ -265,14 +265,12 @@ func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, invit
// 加入租户:默认 member + verified与 tenant.AddUser 保持一致。
tu := &models.TenantUser{
TenantID: tenantID,
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
TenantID: tenantID,
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(tu).Error; err != nil {
if isUniqueViolation(err) {
@@ -430,14 +428,12 @@ func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operator
// 先落成员关系,再更新申请状态,保证“通过后一定能成为成员”(至少幂等)。
tu := &models.TenantUser{
TenantID: tenantID,
UserID: req.UserID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
TenantID: tenantID,
UserID: req.UserID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(tu).Error; err != nil && !isUniqueViolation(err) {
return err

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,9 +66,9 @@ type orderItemQuery struct {
Snapshot field.JSONB // 内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计
CreatedAt field.Time // 创建时间:默认 now()
UpdatedAt field.Time // 更新时间:默认 now()
Content orderItemQueryBelongsToContent
Order orderItemQueryBelongsToOrder
Order orderItemQueryBelongsToOrder
Content orderItemQueryBelongsToContent
fieldMap map[string]field.Expr
}
@@ -143,101 +143,20 @@ func (o *orderItemQuery) fillFieldMap() {
func (o orderItemQuery) clone(db *gorm.DB) orderItemQuery {
o.orderItemQueryDo.ReplaceConnPool(db.Statement.ConnPool)
o.Content.db = db.Session(&gorm.Session{Initialized: true})
o.Content.db.Statement.ConnPool = db.Statement.ConnPool
o.Order.db = db.Session(&gorm.Session{Initialized: true})
o.Order.db.Statement.ConnPool = db.Statement.ConnPool
o.Content.db = db.Session(&gorm.Session{Initialized: true})
o.Content.db.Statement.ConnPool = db.Statement.ConnPool
return o
}
func (o orderItemQuery) replaceDB(db *gorm.DB) orderItemQuery {
o.orderItemQueryDo.ReplaceDB(db)
o.Content.db = db.Session(&gorm.Session{})
o.Order.db = db.Session(&gorm.Session{})
o.Content.db = db.Session(&gorm.Session{})
return o
}
type orderItemQueryBelongsToContent struct {
db *gorm.DB
field.RelationField
}
func (a orderItemQueryBelongsToContent) Where(conds ...field.Expr) *orderItemQueryBelongsToContent {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a orderItemQueryBelongsToContent) WithContext(ctx context.Context) *orderItemQueryBelongsToContent {
a.db = a.db.WithContext(ctx)
return &a
}
func (a orderItemQueryBelongsToContent) Session(session *gorm.Session) *orderItemQueryBelongsToContent {
a.db = a.db.Session(session)
return &a
}
func (a orderItemQueryBelongsToContent) Model(m *OrderItem) *orderItemQueryBelongsToContentTx {
return &orderItemQueryBelongsToContentTx{a.db.Model(m).Association(a.Name())}
}
func (a orderItemQueryBelongsToContent) Unscoped() *orderItemQueryBelongsToContent {
a.db = a.db.Unscoped()
return &a
}
type orderItemQueryBelongsToContentTx struct{ tx *gorm.Association }
func (a orderItemQueryBelongsToContentTx) Find() (result *Content, err error) {
return result, a.tx.Find(&result)
}
func (a orderItemQueryBelongsToContentTx) Append(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Replace(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Delete(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Clear() error {
return a.tx.Clear()
}
func (a orderItemQueryBelongsToContentTx) Count() int64 {
return a.tx.Count()
}
func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToContentTx {
a.tx = a.tx.Unscoped()
return &a
}
type orderItemQueryBelongsToOrder struct {
db *gorm.DB
@@ -319,6 +238,87 @@ func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrder
return &a
}
type orderItemQueryBelongsToContent struct {
db *gorm.DB
field.RelationField
}
func (a orderItemQueryBelongsToContent) Where(conds ...field.Expr) *orderItemQueryBelongsToContent {
if len(conds) == 0 {
return &a
}
exprs := make([]clause.Expression, 0, len(conds))
for _, cond := range conds {
exprs = append(exprs, cond.BeCond().(clause.Expression))
}
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
return &a
}
func (a orderItemQueryBelongsToContent) WithContext(ctx context.Context) *orderItemQueryBelongsToContent {
a.db = a.db.WithContext(ctx)
return &a
}
func (a orderItemQueryBelongsToContent) Session(session *gorm.Session) *orderItemQueryBelongsToContent {
a.db = a.db.Session(session)
return &a
}
func (a orderItemQueryBelongsToContent) Model(m *OrderItem) *orderItemQueryBelongsToContentTx {
return &orderItemQueryBelongsToContentTx{a.db.Model(m).Association(a.Name())}
}
func (a orderItemQueryBelongsToContent) Unscoped() *orderItemQueryBelongsToContent {
a.db = a.db.Unscoped()
return &a
}
type orderItemQueryBelongsToContentTx struct{ tx *gorm.Association }
func (a orderItemQueryBelongsToContentTx) Find() (result *Content, err error) {
return result, a.tx.Find(&result)
}
func (a orderItemQueryBelongsToContentTx) Append(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Append(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Replace(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Replace(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Delete(values ...*Content) (err error) {
targetValues := make([]interface{}, len(values))
for i, v := range values {
targetValues[i] = v
}
return a.tx.Delete(targetValues...)
}
func (a orderItemQueryBelongsToContentTx) Clear() error {
return a.tx.Clear()
}
func (a orderItemQueryBelongsToContentTx) Count() int64 {
return a.tx.Count()
}
func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToContentTx {
a.tx = a.tx.Unscoped()
return &a
}
type orderItemQueryDo struct{ gen.DO }
func (o orderItemQueryDo) Debug() *orderItemQueryDo {

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

@@ -17,23 +17,23 @@ const TableNameTenantLedger = "tenant_ledgers"
// TenantLedger mapped from table <tenant_ledgers>
type TenantLedger struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID自增" json:"id"` // 主键ID自增
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID多租户隔离关键字段必须与 tenant_users.tenant_id 一致" json:"tenant_id"` // 租户ID多租户隔离关键字段必须与 tenant_users.tenant_id 一致
UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID余额账户归属用户对应 tenant_users.user_id" json:"user_id"` // 用户ID余额账户归属用户对应 tenant_users.user_id
OrderID int64 `gorm:"column:order_id;type:bigint;comment:关联订单ID购买/退款类流水应关联 orders.id非订单类可为空" json:"order_id"` // 关联订单ID购买/退款类流水应关联 orders.id非订单类可为空
Type consts.TenantLedgerType `gorm:"column:type;type:character varying(32);not null;comment:流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment不同类型决定余额/冻结余额的变更方向
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)" json:"amount"` // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变更前可用余额:用于审计与对账回放" json:"balance_before"` // 变更前可用余额:用于审计与对账回放
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变更后可用余额:用于审计与对账回放" json:"balance_after"` // 变更后可用余额:用于审计与对账回放
FrozenBefore int64 `gorm:"column:frozen_before;type:bigint;not null;comment:变更前冻结余额:用于审计与对账回放" json:"frozen_before"` // 变更前冻结余额:用于审计与对账回放
FrozenAfter int64 `gorm:"column:frozen_after;type:bigint;not null;comment:变更后冻结余额:用于审计与对账回放" json:"frozen_after"` // 变更后冻结余额:用于审计与对账回放
IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)
Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:业务说明/后台操作原因等;用于审计" json:"remark"` // 备注:业务说明/后台操作原因等;用于审计
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID谁触发该流水admin/buyer/system用于审计与追责可为空历史数据或无法识别时" json:"operator_user_id"` // 操作者用户ID谁触发该流水admin/buyer/system用于审计与追责可为空历史数据或无法识别时
BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型order/refund/topup/etc与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型order/refund/topup/etc与 biz_ref_id 组成可选的结构化幂等/追溯键
BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID与 biz_ref_type 配合使用(例如 orders.id用于对账与审计" json:"biz_ref_id"` // 业务引用ID与 biz_ref_type 配合使用(例如 orders.id用于对账与审计
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID自增" json:"id"` // 主键ID自增
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID多租户隔离关键字段必须与 tenant_users.tenant_id 一致" json:"tenant_id"` // 租户ID多租户隔离关键字段必须与 tenant_users.tenant_id 一致
UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID余额账户归属用户对应 tenant_users.user_id" json:"user_id"` // 用户ID余额账户归属用户对应 tenant_users.user_id
OrderID int64 `gorm:"column:order_id;type:bigint;comment:关联订单ID购买/退款类流水应关联 orders.id非订单类可为空" json:"order_id"` // 关联订单ID购买/退款类流水应关联 orders.id非订单类可为空
Type consts.TenantLedgerType `gorm:"column:type;type:character varying(32);not null;comment:流水类型debit_purchase/credit_refund/freeze/unfreeze/adjustment不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型debit_purchase/credit_refund/freeze/unfreeze/adjustment不同类型决定余额/冻结余额的变更方向
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)" json:"amount"` // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变更前可用余额:用于审计与对账回放" json:"balance_before"` // 变更前可用余额:用于审计与对账回放
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变更后可用余额:用于审计与对账回放" json:"balance_after"` // 变更后可用余额:用于审计与对账回放
FrozenBefore int64 `gorm:"column:frozen_before;type:bigint;not null;comment:变更前冻结余额:用于审计与对账回放" json:"frozen_before"` // 变更前冻结余额:用于审计与对账回放
FrozenAfter int64 `gorm:"column:frozen_after;type:bigint;not null;comment:变更后冻结余额:用于审计与对账回放" json:"frozen_after"` // 变更后冻结余额:用于审计与对账回放
IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)
Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:业务说明/后台操作原因等;用于审计" json:"remark"` // 备注:业务说明/后台操作原因等;用于审计
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID谁触发该流水admin/buyer/system用于审计与追责可为空历史数据或无法识别时" json:"operator_user_id"` // 操作者用户ID谁触发该流水admin/buyer/system用于审计与追责可为空历史数据或无法识别时
BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型order/refund/etc与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型order/refund/etc与 biz_ref_id 组成可选的结构化幂等/追溯键
BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID与 biz_ref_type 配合使用(例如 orders.id用于对账与审计" json:"biz_ref_id"` // 业务引用ID与 biz_ref_type 配合使用(例如 orders.id用于对账与审计
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"`
}

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

@@ -18,15 +18,13 @@ const TableNameTenantUser = "tenant_users"
// TenantUser mapped from table <tenant_users>
type TenantUser struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"`
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"`
Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"`
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:verified" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0" json:"balance_frozen"` // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"`
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"`
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:verified" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
}
// Quick operations without importing query package

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()
@@ -43,16 +41,14 @@ func newTenantUser(db *gorm.DB, opts ...gen.DOOption) tenantUserQuery {
type tenantUserQuery struct {
tenantUserQueryDo tenantUserQueryDo
ALL field.Asterisk
ID field.Int64
TenantID field.Int64
UserID field.Int64
Role field.Array
Balance field.Int64
Status field.Field
CreatedAt field.Time
UpdatedAt field.Time
BalanceFrozen field.Int64 // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0
ALL field.Asterisk
ID field.Int64
TenantID field.Int64
UserID field.Int64
Role field.Array
Status field.Field
CreatedAt field.Time
UpdatedAt field.Time
fieldMap map[string]field.Expr
}
@@ -73,11 +69,9 @@ func (t *tenantUserQuery) updateTableName(table string) *tenantUserQuery {
t.TenantID = field.NewInt64(table, "tenant_id")
t.UserID = field.NewInt64(table, "user_id")
t.Role = field.NewArray(table, "role")
t.Balance = field.NewInt64(table, "balance")
t.Status = field.NewField(table, "status")
t.CreatedAt = field.NewTime(table, "created_at")
t.UpdatedAt = field.NewTime(table, "updated_at")
t.BalanceFrozen = field.NewInt64(table, "balance_frozen")
t.fillFieldMap()
@@ -110,16 +104,14 @@ func (t *tenantUserQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo
}
func (t *tenantUserQuery) fillFieldMap() {
t.fieldMap = make(map[string]field.Expr, 9)
t.fieldMap = make(map[string]field.Expr, 7)
t.fieldMap["id"] = t.ID
t.fieldMap["tenant_id"] = t.TenantID
t.fieldMap["user_id"] = t.UserID
t.fieldMap["role"] = t.Role
t.fieldMap["balance"] = t.Balance
t.fieldMap["status"] = t.Status
t.fieldMap["created_at"] = t.CreatedAt
t.fieldMap["updated_at"] = t.UpdatedAt
t.fieldMap["balance_frozen"] = t.BalanceFrozen
}
func (t tenantUserQuery) clone(db *gorm.DB) tenantUserQuery {

View File

@@ -19,18 +19,20 @@ const TableNameUser = "users"
// User mapped from table <users>
type User struct {
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"`
Username string `gorm:"column:username;type:character varying(255);not null" json:"username"`
Password string `gorm:"column:password;type:character varying(255);not null" json:"password"`
Roles types.Array[consts.Role] `gorm:"column:roles;type:text[];not null;default:ARRAY['user" json:"roles"`
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"`
Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"`
VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp with time zone" json:"verified_at"`
OwnedTenant *Tenant `json:"owned,omitempty"`
Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"`
ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"`
Username string `gorm:"column:username;type:character varying(255);not null" json:"username"`
Password string `gorm:"column:password;type:character varying(255);not null" json:"password"`
Roles types.Array[consts.Role] `gorm:"column:roles;type:text[];not null;default:ARRAY['user" json:"roles"`
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"`
Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"`
Balance int64 `gorm:"column:balance;type:bigint;not null;comment:全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0" json:"balance"` // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0" json:"balance_frozen"` // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp with time zone" json:"verified_at"`
OwnedTenant *Tenant `json:"owned,omitempty"`
Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"`
}
// Quick operations without importing query package

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{}),
@@ -55,18 +57,20 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery {
type userQuery struct {
userQueryDo userQueryDo
ALL field.Asterisk
ID field.Int64
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Username field.String
Password field.String
Roles field.Array
Status field.Field
Metas field.JSONB
VerifiedAt field.Time
OwnedTenant userQueryBelongsToOwnedTenant
ALL field.Asterisk
ID field.Int64
CreatedAt field.Time
UpdatedAt field.Time
DeletedAt field.Field
Username field.String
Password field.String
Roles field.Array
Status field.Field
Metas field.JSONB
Balance field.Int64 // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
BalanceFrozen field.Int64 // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
VerifiedAt field.Time
OwnedTenant userQueryBelongsToOwnedTenant
Tenants userQueryManyToManyTenants
@@ -94,6 +98,8 @@ func (u *userQuery) updateTableName(table string) *userQuery {
u.Roles = field.NewArray(table, "roles")
u.Status = field.NewField(table, "status")
u.Metas = field.NewJSONB(table, "metas")
u.Balance = field.NewInt64(table, "balance")
u.BalanceFrozen = field.NewInt64(table, "balance_frozen")
u.VerifiedAt = field.NewTime(table, "verified_at")
u.fillFieldMap()
@@ -125,7 +131,7 @@ func (u *userQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (u *userQuery) fillFieldMap() {
u.fieldMap = make(map[string]field.Expr, 12)
u.fieldMap = make(map[string]field.Expr, 14)
u.fieldMap["id"] = u.ID
u.fieldMap["created_at"] = u.CreatedAt
u.fieldMap["updated_at"] = u.UpdatedAt
@@ -135,6 +141,8 @@ func (u *userQuery) fillFieldMap() {
u.fieldMap["roles"] = u.Roles
u.fieldMap["status"] = u.Status
u.fieldMap["metas"] = u.Metas
u.fieldMap["balance"] = u.Balance
u.fieldMap["balance_frozen"] = u.BalanceFrozen
u.fieldMap["verified_at"] = u.VerifiedAt
}

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