feat: 移除“租户管理员为用户充值 / 每租户一套余额”能力:余额统一为全局用户余额
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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过滤(可选)。
|
||||
|
||||
@@ -29,7 +29,7 @@ type AdminOrderListFilter struct {
|
||||
// ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。
|
||||
ContentTitle *string `json:"content_title,omitempty" query:"content_title"`
|
||||
|
||||
// Type 订单类型(可选):content_purchase/topup 等。
|
||||
// Type 订单类型(可选):content_purchase 等。
|
||||
Type *consts.OrderType `json:"type,omitempty" query:"type"`
|
||||
|
||||
// Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 表的全局余额,用户可在已加入租户间共享消费;按租户充值会导致账务复杂且易出错。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -18,23 +18,25 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与租户用户余额快照。
|
||||
// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与用户余额快照。
|
||||
type LedgerApplyResult struct {
|
||||
// Ledger 为本次创建的账本记录(若幂等命中则返回已有记录)。
|
||||
Ledger *models.TenantLedger
|
||||
// TenantUser 为写入后余额状态(若幂等命中则返回当前快照)。
|
||||
TenantUser *models.TenantUser
|
||||
// User 为写入后余额状态(若幂等命中则返回当前快照)。
|
||||
User *models.User
|
||||
}
|
||||
|
||||
// ledger 提供租户余额账本能力(冻结/解冻/扣减/退款/充值),支持幂等与行锁保证一致性。
|
||||
// ledger 提供租户账本能力(冻结/解冻/扣减/退款等),支持幂等与行锁保证一致性。
|
||||
// 注意:余额为 users 表的全局余额,用户可在已加入租户间共享消费。
|
||||
//
|
||||
// @provider
|
||||
type ledger struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// MyBalance 查询当前用户在指定租户下的余额信息(可用/冻结)。
|
||||
func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) {
|
||||
// MyBalance 查询当前用户的全局余额信息(可用/冻结)。
|
||||
// 语义:必须先是该租户成员(否则返回 not found),但余额数据来源为 users。
|
||||
func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.User, error) {
|
||||
if tenantID <= 0 || userID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
|
||||
}
|
||||
@@ -44,14 +46,23 @@ func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models
|
||||
"user_id": userID,
|
||||
}).Info("services.ledger.me.balance")
|
||||
|
||||
tbl, query := models.TenantUserQuery.QueryContext(ctx)
|
||||
m, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First()
|
||||
if err != nil {
|
||||
// 必须先是租户成员。
|
||||
tblTU, queryTU := models.TenantUserQuery.QueryContext(ctx)
|
||||
if _, err := queryTU.Where(tblTU.TenantID.Eq(tenantID), tblTU.UserID.Eq(userID)).First(); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("tenant user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tblU, queryU := models.UserQuery.QueryContext(ctx)
|
||||
m, err := queryU.Where(tblU.ID.Eq(userID), tblU.DeletedAt.IsNull()).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errorx.ErrRecordNotFound.WithMsg("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -232,11 +243,6 @@ func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, oper
|
||||
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// CreditTopupTx 将充值金额记入可用余额,并写入账本记录。
|
||||
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
func (s *ledger) apply(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
@@ -273,18 +279,32 @@ func (s *ledger) apply(
|
||||
var out LedgerApplyResult
|
||||
|
||||
err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 必须先是租户成员(账本维度仍按 tenant_id 记录)。
|
||||
var tu models.TenantUser
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
|
||||
First(&tu).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 幂等快速路径:在进入行锁之前先查一次,减少锁竞争(命中则直接返回)。
|
||||
if idempotencyKey != "" {
|
||||
var existing models.TenantLedger
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; err == nil {
|
||||
var current models.TenantUser
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil {
|
||||
var current models.User
|
||||
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = ¤t
|
||||
out.User = ¤t
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
@@ -297,26 +317,29 @@ func (s *ledger) apply(
|
||||
if err := tx.
|
||||
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
|
||||
First(&existing).Error; err == nil {
|
||||
var current models.TenantUser
|
||||
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil {
|
||||
var current models.User
|
||||
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = ¤t
|
||||
out.User = ¤t
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 使用行锁锁住 tenant_users,确保同一租户下同一用户余额更新的串行一致性。
|
||||
var tu models.TenantUser
|
||||
// 使用行锁锁住 users,确保同一用户在“跨租户消费”场景下余额更新的串行一致性。
|
||||
var u models.User
|
||||
if err := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
|
||||
First(&tu).Error; err != nil {
|
||||
Where("id = ? AND deleted_at IS NULL", userID).
|
||||
First(&u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
|
||||
return errorx.ErrRecordNotFound.WithMsg("user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -328,7 +351,7 @@ func (s *ledger) apply(
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; err == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
out.User = &u
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
@@ -342,15 +365,15 @@ func (s *ledger) apply(
|
||||
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
|
||||
First(&existing).Error; err == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
out.User = &u
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
balanceBefore := tu.Balance
|
||||
frozenBefore := tu.BalanceFrozen
|
||||
balanceBefore := u.Balance
|
||||
frozenBefore := u.BalanceFrozen
|
||||
balanceAfter := balanceBefore + deltaBalance
|
||||
frozenAfter := frozenBefore + deltaFrozen
|
||||
|
||||
@@ -363,8 +386,8 @@ func (s *ledger) apply(
|
||||
}
|
||||
|
||||
// 先更新余额,再写账本:任何一步失败都回滚,保证“余额变更”和“账本记录”一致。
|
||||
if err := tx.Model(&models.TenantUser{}).
|
||||
Where("id = ?", tu.ID).
|
||||
if err := tx.Model(&models.User{}).
|
||||
Where("id = ?", u.ID).
|
||||
Updates(map[string]any{
|
||||
"balance": balanceAfter,
|
||||
"balance_frozen": frozenAfter,
|
||||
@@ -400,7 +423,7 @@ func (s *ledger) apply(
|
||||
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
||||
First(&existing).Error; e2 == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
out.User = &u
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -411,19 +434,19 @@ func (s *ledger) apply(
|
||||
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
|
||||
First(&existing).Error; e2 == nil {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
out.User = &u
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
tu.Balance = balanceAfter
|
||||
tu.BalanceFrozen = frozenAfter
|
||||
tu.UpdatedAt = now
|
||||
u.Balance = balanceAfter
|
||||
u.BalanceFrozen = frozenAfter
|
||||
u.UpdatedAt = now
|
||||
|
||||
out.Ledger = ledger
|
||||
out.TenantUser = &tu
|
||||
out.User = &u
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -450,8 +473,8 @@ func (s *ledger) apply(
|
||||
"type": ledgerType,
|
||||
"ledger_id": out.Ledger.ID,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"balance_after": out.TenantUser.Balance,
|
||||
"frozen_after": out.TenantUser.BalanceFrozen,
|
||||
"balance_after": out.User.Balance,
|
||||
"frozen_after": out.User.BalanceFrozen,
|
||||
}).Info("services.ledger.apply.ok")
|
||||
|
||||
return &out, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -46,15 +47,24 @@ func Test_Ledger(t *testing.T) {
|
||||
}
|
||||
|
||||
func (s *LedgerTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) {
|
||||
database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser)
|
||||
database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser, models.TableNameUser)
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen)
|
||||
VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at
|
||||
`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
tu := &models.TenantUser{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
|
||||
Balance: balance,
|
||||
BalanceFrozen: frozen,
|
||||
Status: consts.UserStatusVerified,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
So(tu.Create(ctx), ShouldBeNil)
|
||||
}
|
||||
@@ -82,7 +92,7 @@ func (s *LedgerTestSuite) Test_Freeze() {
|
||||
So(err, ShouldBeNil)
|
||||
So(res, ShouldNotBeNil)
|
||||
So(res.Ledger, ShouldNotBeNil)
|
||||
So(res.TenantUser, ShouldNotBeNil)
|
||||
So(res.User, ShouldNotBeNil)
|
||||
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeFreeze)
|
||||
So(res.Ledger.Amount, ShouldEqual, 300)
|
||||
So(res.Ledger.BalanceBefore, ShouldEqual, 1000)
|
||||
@@ -92,8 +102,8 @@ func (s *LedgerTestSuite) Test_Freeze() {
|
||||
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
|
||||
So(res.Ledger.BizRefType, ShouldEqual, "")
|
||||
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
|
||||
So(res.TenantUser.Balance, ShouldEqual, 700)
|
||||
So(res.TenantUser.BalanceFrozen, ShouldEqual, 300)
|
||||
So(res.User.Balance, ShouldEqual, 700)
|
||||
So(res.User.BalanceFrozen, ShouldEqual, 300)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复扣减", func() {
|
||||
@@ -106,10 +116,10 @@ func (s *LedgerTestSuite) Test_Freeze() {
|
||||
So(res2.Ledger, ShouldNotBeNil)
|
||||
So(res2.Ledger.IdempotencyKey, ShouldEqual, "k_freeze_idem")
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 700)
|
||||
So(tu2.BalanceFrozen, ShouldEqual, 300)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 700)
|
||||
So(u2.BalanceFrozen, ShouldEqual, 300)
|
||||
})
|
||||
|
||||
Convey("余额不足应返回前置条件失败", func() {
|
||||
@@ -157,8 +167,8 @@ func (s *LedgerTestSuite) Test_Unfreeze() {
|
||||
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
|
||||
So(res.Ledger.BizRefType, ShouldEqual, "")
|
||||
So(res.Ledger.BizRefID, ShouldEqual, int64(0))
|
||||
So(res.TenantUser.Balance, ShouldEqual, 1000)
|
||||
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||
So(res.User.Balance, ShouldEqual, 1000)
|
||||
So(res.User.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复入账", func() {
|
||||
@@ -172,10 +182,10 @@ func (s *LedgerTestSuite) Test_Unfreeze() {
|
||||
So(err, ShouldBeNil)
|
||||
So(res2, ShouldNotBeNil)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 1000)
|
||||
So(tu2.BalanceFrozen, ShouldEqual, 0)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 1000)
|
||||
So(u2.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -210,8 +220,8 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
|
||||
So(res.Ledger.BizRefType, ShouldEqual, "order")
|
||||
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
|
||||
So(res.TenantUser.Balance, ShouldEqual, 700)
|
||||
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||
So(res.User.Balance, ShouldEqual, 700)
|
||||
So(res.User.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复扣减冻结余额", func() {
|
||||
@@ -224,10 +234,10 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 700)
|
||||
So(tu2.BalanceFrozen, ShouldEqual, 0)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 700)
|
||||
So(u2.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -259,8 +269,8 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
|
||||
So(res.Ledger.OperatorUserID, ShouldEqual, userID)
|
||||
So(res.Ledger.BizRefType, ShouldEqual, "order")
|
||||
So(res.Ledger.BizRefID, ShouldEqual, int64(123))
|
||||
So(res.TenantUser.Balance, ShouldEqual, 1000)
|
||||
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||
So(res.User.Balance, ShouldEqual, 1000)
|
||||
So(res.User.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复退款入账", func() {
|
||||
@@ -274,48 +284,9 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
|
||||
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LedgerTestSuite) Test_CreditTopupTx() {
|
||||
Convey("Ledger.CreditTopupTx", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
tenantID := int64(1)
|
||||
userID := int64(2)
|
||||
now := time.Now().UTC()
|
||||
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
Convey("金额非法应返回参数错误", func() {
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 0, "k_topup_invalid_amount", "topup", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("成功充值应增加可用余额并写入账本", func() {
|
||||
res, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_1", "topup", now)
|
||||
So(err, ShouldBeNil)
|
||||
So(res, ShouldNotBeNil)
|
||||
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditTopup)
|
||||
So(res.Ledger.OperatorUserID, ShouldEqual, int64(999))
|
||||
So(res.Ledger.BizRefType, ShouldEqual, "order")
|
||||
So(res.Ledger.BizRefID, ShouldEqual, int64(456))
|
||||
So(res.TenantUser.Balance, ShouldEqual, 1200)
|
||||
So(res.TenantUser.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复充值入账", func() {
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 1200)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -328,7 +299,7 @@ func (s *LedgerTestSuite) Test_MyBalance() {
|
||||
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 200)
|
||||
|
||||
Convey("成功返回租户内余额", func() {
|
||||
Convey("成功返回全局余额", func() {
|
||||
m, err := Ledger.MyBalance(ctx, tenantID, userID)
|
||||
So(err, ShouldBeNil)
|
||||
So(m, ShouldNotBeNil)
|
||||
@@ -352,9 +323,9 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
|
||||
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, userID, 1, 200, "k_topup_for_page", "topup", now)
|
||||
_, err := Ledger.Freeze(ctx, tenantID, userID, 1, 200, "k_freeze_for_page_1", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.Freeze(ctx, tenantID, userID, 2, 100, "k_freeze_for_page", "freeze", now.Add(time.Second))
|
||||
_, err = Ledger.Unfreeze(ctx, tenantID, userID, 1, 100, "k_unfreeze_for_page_1", "unfreeze", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("分页返回流水列表", func() {
|
||||
@@ -365,7 +336,7 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
|
||||
})
|
||||
|
||||
Convey("按 type 过滤", func() {
|
||||
typ := consts.TenantLedgerTypeCreditTopup
|
||||
typ := consts.TenantLedgerTypeFreeze
|
||||
pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{Type: &typ})
|
||||
So(err, ShouldBeNil)
|
||||
So(pager.Total, ShouldEqual, 1)
|
||||
@@ -382,8 +353,8 @@ func (s *LedgerTestSuite) Test_AdminLedgerPage() {
|
||||
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
// 模拟后台管理员为用户充值:operator_user_id 与 user_id 不同。
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_topup_for_page", "topup", now)
|
||||
// 模拟后台管理员为用户冻结资金:operator_user_id 与 user_id 不同。
|
||||
_, err := Ledger.FreezeTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_freeze_for_page", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("按 operator_user_id 过滤", func() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,15 +60,22 @@ func (s *OrderTestSuite) truncate(ctx context.Context, tableNames ...string) {
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) {
|
||||
tu := &models.TenantUser{
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
|
||||
Balance: balance,
|
||||
BalanceFrozen: frozen,
|
||||
Status: consts.UserStatusVerified,
|
||||
}
|
||||
So(tu.Create(ctx), ShouldBeNil)
|
||||
now := time.Now().UTC()
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen)
|
||||
VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at
|
||||
`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
_, err = s.DB.ExecContext(ctx, `
|
||||
INSERT INTO tenant_users (tenant_id, user_id, role, status, created_at, updated_at)
|
||||
VALUES ($1, $2, ARRAY['member'], $3, $4, $4)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at
|
||||
`, tenantID, userID, consts.UserStatusVerified, now)
|
||||
So(err, ShouldBeNil)
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) seedPublishedContent(ctx context.Context, tenantID, ownerUserID int64) *models.Content {
|
||||
@@ -102,98 +109,6 @@ func (s *OrderTestSuite) seedContentPrice(ctx context.Context, tenantID, content
|
||||
So(p.Create(ctx), ShouldBeNil)
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_AdminTopupUser() {
|
||||
Convey("Order.AdminTopupUser", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
now := time.Now().UTC()
|
||||
tenantID := int64(1)
|
||||
operatorUserID := int64(10)
|
||||
targetUserID := int64(20)
|
||||
|
||||
s.truncate(
|
||||
ctx,
|
||||
models.TableNameTenantLedger,
|
||||
models.TableNameOrderItem,
|
||||
models.TableNameOrder,
|
||||
models.TableNameTenantUser,
|
||||
)
|
||||
|
||||
Convey("参数非法应返回错误", func() {
|
||||
_, err := Order.AdminTopupUser(ctx, 0, operatorUserID, targetUserID, 100, "", "", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminTopupUser(ctx, tenantID, 0, targetUserID, 100, "", "", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminTopupUser(ctx, tenantID, operatorUserID, 0, 100, "", "", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 0, "", "", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("目标用户不属于该租户应返回前置条件失败", func() {
|
||||
_, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 100, "idem_not_member", "", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
var appErr *errorx.AppError
|
||||
So(errors.As(err, &appErr), ShouldBeTrue)
|
||||
So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed)
|
||||
})
|
||||
|
||||
Convey("成功充值并写入账本", func() {
|
||||
s.seedTenantUser(ctx, tenantID, targetUserID, 0, 0)
|
||||
|
||||
orderModel, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 300, "idem_topup_1", "充值原因", now)
|
||||
So(err, ShouldBeNil)
|
||||
So(orderModel, ShouldNotBeNil)
|
||||
So(orderModel.ID, ShouldBeGreaterThan, 0)
|
||||
So(orderModel.Type, ShouldEqual, consts.OrderTypeTopup)
|
||||
So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid)
|
||||
So(orderModel.AmountPaid, ShouldEqual, 300)
|
||||
|
||||
snap := orderModel.Snapshot.Data()
|
||||
So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup))
|
||||
|
||||
var snapData fields.OrdersTopupSnapshot
|
||||
So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil)
|
||||
So(snapData.OperatorUserID, ShouldEqual, operatorUserID)
|
||||
So(snapData.TargetUserID, ShouldEqual, targetUserID)
|
||||
So(snapData.Amount, ShouldEqual, int64(300))
|
||||
|
||||
var tu models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil)
|
||||
So(tu.Balance, ShouldEqual, 300)
|
||||
|
||||
var ledgers []*models.TenantLedger
|
||||
So(_db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND user_id = ? AND type = ?", tenantID, targetUserID, consts.TenantLedgerTypeCreditTopup).
|
||||
Order("id ASC").
|
||||
Find(&ledgers).Error, ShouldBeNil)
|
||||
So(len(ledgers), ShouldEqual, 1)
|
||||
So(ledgers[0].OrderID, ShouldEqual, orderModel.ID)
|
||||
So(ledgers[0].Amount, ShouldEqual, 300)
|
||||
})
|
||||
|
||||
Convey("幂等键重复调用不应重复入账", func() {
|
||||
s.seedTenantUser(ctx, tenantID, targetUserID, 0, 0)
|
||||
|
||||
o1, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 300, "idem_topup_2", "充值原因", now)
|
||||
So(err, ShouldBeNil)
|
||||
So(o1, ShouldNotBeNil)
|
||||
|
||||
o2, err := Order.AdminTopupUser(ctx, tenantID, operatorUserID, targetUserID, 999, "idem_topup_2", "不同金额也不应重复处理", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
So(o2, ShouldNotBeNil)
|
||||
So(o2.ID, ShouldEqual, o1.ID)
|
||||
|
||||
var tu models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil)
|
||||
So(tu.Balance, ShouldEqual, 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_MyOrderPage() {
|
||||
Convey("Order.MyOrderPage", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
@@ -654,45 +569,6 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
|
||||
So(itemsDesc[0].CreatedAt.After(itemsDesc[1].CreatedAt), ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("按 type 过滤", func() {
|
||||
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
|
||||
|
||||
o1 := &models.Order{
|
||||
TenantID: tenantID,
|
||||
UserID: 2,
|
||||
Type: consts.OrderTypeTopup,
|
||||
Status: consts.OrderStatusPaid,
|
||||
Currency: consts.CurrencyCNY,
|
||||
AmountPaid: 100,
|
||||
Snapshot: newLegacyOrderSnapshot(),
|
||||
PaidAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
So(o1.Create(ctx), ShouldBeNil)
|
||||
|
||||
o2 := &models.Order{
|
||||
TenantID: tenantID,
|
||||
UserID: 3,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusPaid,
|
||||
Currency: consts.CurrencyCNY,
|
||||
AmountPaid: 200,
|
||||
Snapshot: newLegacyOrderSnapshot(),
|
||||
PaidAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
So(o2.Create(ctx), ShouldBeNil)
|
||||
|
||||
typ := consts.OrderTypeTopup
|
||||
pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{
|
||||
Type: &typ,
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(pager.Total, ShouldEqual, 1)
|
||||
})
|
||||
|
||||
Convey("组合筛选:user_id + status + amount_paid 区间 + content_id", func() {
|
||||
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
|
||||
|
||||
@@ -888,123 +764,6 @@ func (s *OrderTestSuite) Test_AdminOrderExportCSV() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_AdminBatchTopupUsers() {
|
||||
Convey("Order.AdminBatchTopupUsers", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
now := time.Now().UTC()
|
||||
tenantID := int64(1)
|
||||
operatorUserID := int64(10)
|
||||
|
||||
s.truncate(
|
||||
ctx,
|
||||
models.TableNameTenantLedger,
|
||||
models.TableNameOrderItem,
|
||||
models.TableNameOrder,
|
||||
models.TableNameTenantUser,
|
||||
)
|
||||
|
||||
Convey("参数非法应返回错误", func() {
|
||||
_, err := Order.AdminBatchTopupUsers(ctx, 0, operatorUserID, &dto.AdminBatchTopupForm{}, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminBatchTopupUsers(ctx, tenantID, 0, &dto.AdminBatchTopupForm{}, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, nil, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: ""}, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
|
||||
_, err = Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{BatchIdempotencyKey: "b1", Items: nil}, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("超过单批次最大条数应返回错误", func() {
|
||||
items := make([]*dto.AdminBatchTopupItem, 0, 201)
|
||||
for i := 0; i < 201; i++ {
|
||||
items = append(items, &dto.AdminBatchTopupItem{UserID: int64(1000 + i), Amount: 1})
|
||||
}
|
||||
_, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, &dto.AdminBatchTopupForm{
|
||||
BatchIdempotencyKey: "too_many",
|
||||
Items: items,
|
||||
}, now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("单条参数不合法应只影响该条并返回错误明细", func() {
|
||||
s.seedTenantUser(ctx, tenantID, 20, 0, 0)
|
||||
|
||||
form := &dto.AdminBatchTopupForm{
|
||||
BatchIdempotencyKey: "batch_invalid_item",
|
||||
Items: []*dto.AdminBatchTopupItem{
|
||||
nil,
|
||||
{UserID: 0, Amount: 100, Reason: "bad_user_id"},
|
||||
{UserID: 20, Amount: 0, Reason: "bad_amount"},
|
||||
{UserID: 20, Amount: 100, Reason: "ok"},
|
||||
},
|
||||
}
|
||||
resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.Total, ShouldEqual, 4)
|
||||
So(resp.Success, ShouldEqual, 1)
|
||||
So(resp.Failed, ShouldEqual, 3)
|
||||
So(len(resp.Items), ShouldEqual, 4)
|
||||
So(resp.Items[0].OK, ShouldBeFalse)
|
||||
So(resp.Items[1].OK, ShouldBeFalse)
|
||||
So(resp.Items[2].OK, ShouldBeFalse)
|
||||
So(resp.Items[3].OK, ShouldBeTrue)
|
||||
})
|
||||
|
||||
Convey("部分成功应返回明细结果且成功入账", func() {
|
||||
// seed 2 个成员,1 个非成员
|
||||
s.seedTenantUser(ctx, tenantID, 20, 0, 0)
|
||||
s.seedTenantUser(ctx, tenantID, 21, 0, 0)
|
||||
|
||||
form := &dto.AdminBatchTopupForm{
|
||||
BatchIdempotencyKey: "batch_001",
|
||||
Items: []*dto.AdminBatchTopupItem{
|
||||
{UserID: 20, Amount: 100, Reason: "a"},
|
||||
{UserID: 999, Amount: 100, Reason: "not_member"},
|
||||
{UserID: 21, Amount: 200, Reason: "b"},
|
||||
},
|
||||
}
|
||||
resp, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp, ShouldNotBeNil)
|
||||
So(resp.Total, ShouldEqual, 3)
|
||||
So(resp.Success, ShouldEqual, 2)
|
||||
So(resp.Failed, ShouldEqual, 1)
|
||||
So(len(resp.Items), ShouldEqual, 3)
|
||||
So(resp.Items[0].OK, ShouldBeTrue)
|
||||
So(resp.Items[0].OrderID, ShouldBeGreaterThan, 0)
|
||||
So(resp.Items[1].OK, ShouldBeFalse)
|
||||
So(resp.Items[2].OK, ShouldBeTrue)
|
||||
|
||||
var tu20 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20).Error, ShouldBeNil)
|
||||
So(tu20.Balance, ShouldEqual, 100)
|
||||
var tu21 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21).Error, ShouldBeNil)
|
||||
So(tu21.Balance, ShouldEqual, 200)
|
||||
|
||||
Convey("同一批次重复调用应幂等,不重复入账", func() {
|
||||
resp2, err := Order.AdminBatchTopupUsers(ctx, tenantID, operatorUserID, form, now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
So(resp2.Success, ShouldEqual, 2)
|
||||
|
||||
var tu20b models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(20)).First(&tu20b).Error, ShouldBeNil)
|
||||
So(tu20b.Balance, ShouldEqual, 100)
|
||||
var tu21b models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, int64(21)).First(&tu21b).Error, ShouldBeNil)
|
||||
So(tu21b.Balance, ShouldEqual, 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OrderTestSuite) Test_AdminRefundOrder() {
|
||||
Convey("Order.AdminRefundOrder", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
@@ -1020,6 +779,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
|
||||
models.TableNameOrderItem,
|
||||
models.TableNameOrder,
|
||||
models.TableNameTenantUser,
|
||||
models.TableNameUser,
|
||||
)
|
||||
|
||||
Convey("参数非法应返回错误", func() {
|
||||
@@ -1158,9 +918,9 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
|
||||
So(refundedRetry, ShouldNotBeNil)
|
||||
So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded)
|
||||
|
||||
var tu models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil)
|
||||
So(tu.Balance, ShouldEqual, 300)
|
||||
var u models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil)
|
||||
So(u.Balance, ShouldEqual, 300)
|
||||
|
||||
var access2 models.ContentAccess
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, buyerUserID, contentID).First(&access2).Error, ShouldBeNil)
|
||||
@@ -1171,9 +931,9 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() {
|
||||
So(err, ShouldBeNil)
|
||||
So(refunded2.Status, ShouldEqual, consts.OrderStatusRefunded)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 300)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 300)
|
||||
|
||||
var ledgers []*models.TenantLedger
|
||||
So(_db.WithContext(ctx).
|
||||
@@ -1302,6 +1062,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
|
||||
models.TableNameContentPrice,
|
||||
models.TableNameContent,
|
||||
models.TableNameTenantUser,
|
||||
models.TableNameUser,
|
||||
)
|
||||
s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0)
|
||||
content := s.seedPublishedContent(ctx, tenantID, ownerUserID)
|
||||
@@ -1335,10 +1096,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
|
||||
So(itemSnap.ContentID, ShouldEqual, content.ID)
|
||||
So(itemSnap.AmountPaid, ShouldEqual, int64(300))
|
||||
|
||||
var tu models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil)
|
||||
So(tu.Balance, ShouldEqual, 700)
|
||||
So(tu.BalanceFrozen, ShouldEqual, 0)
|
||||
var u models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil)
|
||||
So(u.Balance, ShouldEqual, 700)
|
||||
So(u.BalanceFrozen, ShouldEqual, 0)
|
||||
|
||||
res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{
|
||||
TenantID: tenantID,
|
||||
@@ -1350,10 +1111,10 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
|
||||
So(err, ShouldBeNil)
|
||||
So(res2.Order.ID, ShouldEqual, res1.Order.ID)
|
||||
|
||||
var tu2 models.TenantUser
|
||||
So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu2).Error, ShouldBeNil)
|
||||
So(tu2.Balance, ShouldEqual, 700)
|
||||
So(tu2.BalanceFrozen, ShouldEqual, 0)
|
||||
var u2 models.User
|
||||
So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil)
|
||||
So(u2.Balance, ShouldEqual, 700)
|
||||
So(u2.BalanceFrozen, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("存在回滚标记时应稳定返回“失败+已回滚”", func() {
|
||||
@@ -1366,6 +1127,7 @@ func (s *OrderTestSuite) Test_PurchaseContent() {
|
||||
models.TableNameContentPrice,
|
||||
models.TableNameContent,
|
||||
models.TableNameTenantUser,
|
||||
models.TableNameUser,
|
||||
)
|
||||
s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0)
|
||||
content := s.seedPublishedContent(ctx, tenantID, ownerUserID)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -269,8 +269,6 @@ func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, invit
|
||||
UserID: userID,
|
||||
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
|
||||
Status: consts.UserStatusVerified,
|
||||
Balance: 0,
|
||||
BalanceFrozen: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -434,8 +432,6 @@ func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operator
|
||||
UserID: req.UserID,
|
||||
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
|
||||
Status: consts.UserStatusVerified,
|
||||
Balance: 0,
|
||||
BalanceFrozen: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -35,18 +35,18 @@ func newOrderItem(db *gorm.DB, opts ...gen.DOOption) orderItemQuery {
|
||||
_orderItemQuery.Snapshot = field.NewJSONB(tableName, "snapshot")
|
||||
_orderItemQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
||||
_orderItemQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_orderItemQuery.Content = orderItemQueryBelongsToContent{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
|
||||
RelationField: field.NewRelation("Content", "Content"),
|
||||
}
|
||||
|
||||
_orderItemQuery.Order = orderItemQueryBelongsToOrder{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
|
||||
RelationField: field.NewRelation("Order", "Order"),
|
||||
}
|
||||
|
||||
_orderItemQuery.Content = orderItemQueryBelongsToContent{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
|
||||
RelationField: field.NewRelation("Content", "Content"),
|
||||
}
|
||||
|
||||
_orderItemQuery.fillFieldMap()
|
||||
|
||||
return _orderItemQuery
|
||||
@@ -66,10 +66,10 @@ type orderItemQuery struct {
|
||||
Snapshot field.JSONB // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计
|
||||
CreatedAt field.Time // 创建时间:默认 now()
|
||||
UpdatedAt field.Time // 更新时间:默认 now()
|
||||
Content orderItemQueryBelongsToContent
|
||||
|
||||
Order orderItemQueryBelongsToOrder
|
||||
|
||||
Content orderItemQueryBelongsToContent
|
||||
|
||||
fieldMap map[string]field.Expr
|
||||
}
|
||||
|
||||
@@ -143,101 +143,20 @@ func (o *orderItemQuery) fillFieldMap() {
|
||||
|
||||
func (o orderItemQuery) clone(db *gorm.DB) orderItemQuery {
|
||||
o.orderItemQueryDo.ReplaceConnPool(db.Statement.ConnPool)
|
||||
o.Content.db = db.Session(&gorm.Session{Initialized: true})
|
||||
o.Content.db.Statement.ConnPool = db.Statement.ConnPool
|
||||
o.Order.db = db.Session(&gorm.Session{Initialized: true})
|
||||
o.Order.db.Statement.ConnPool = db.Statement.ConnPool
|
||||
o.Content.db = db.Session(&gorm.Session{Initialized: true})
|
||||
o.Content.db.Statement.ConnPool = db.Statement.ConnPool
|
||||
return o
|
||||
}
|
||||
|
||||
func (o orderItemQuery) replaceDB(db *gorm.DB) orderItemQuery {
|
||||
o.orderItemQueryDo.ReplaceDB(db)
|
||||
o.Content.db = db.Session(&gorm.Session{})
|
||||
o.Order.db = db.Session(&gorm.Session{})
|
||||
o.Content.db = db.Session(&gorm.Session{})
|
||||
return o
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToContent struct {
|
||||
db *gorm.DB
|
||||
|
||||
field.RelationField
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Where(conds ...field.Expr) *orderItemQueryBelongsToContent {
|
||||
if len(conds) == 0 {
|
||||
return &a
|
||||
}
|
||||
|
||||
exprs := make([]clause.Expression, 0, len(conds))
|
||||
for _, cond := range conds {
|
||||
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
||||
}
|
||||
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) WithContext(ctx context.Context) *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.WithContext(ctx)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Session(session *gorm.Session) *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.Session(session)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Model(m *OrderItem) *orderItemQueryBelongsToContentTx {
|
||||
return &orderItemQueryBelongsToContentTx{a.db.Model(m).Association(a.Name())}
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Unscoped() *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToContentTx struct{ tx *gorm.Association }
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Find() (result *Content, err error) {
|
||||
return result, a.tx.Find(&result)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Append(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Append(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Replace(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Replace(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Delete(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Delete(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Clear() error {
|
||||
return a.tx.Clear()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Count() int64 {
|
||||
return a.tx.Count()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToContentTx {
|
||||
a.tx = a.tx.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToOrder struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -319,6 +238,87 @@ func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrder
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToContent struct {
|
||||
db *gorm.DB
|
||||
|
||||
field.RelationField
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Where(conds ...field.Expr) *orderItemQueryBelongsToContent {
|
||||
if len(conds) == 0 {
|
||||
return &a
|
||||
}
|
||||
|
||||
exprs := make([]clause.Expression, 0, len(conds))
|
||||
for _, cond := range conds {
|
||||
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
||||
}
|
||||
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) WithContext(ctx context.Context) *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.WithContext(ctx)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Session(session *gorm.Session) *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.Session(session)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Model(m *OrderItem) *orderItemQueryBelongsToContentTx {
|
||||
return &orderItemQueryBelongsToContentTx{a.db.Model(m).Association(a.Name())}
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContent) Unscoped() *orderItemQueryBelongsToContent {
|
||||
a.db = a.db.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToContentTx struct{ tx *gorm.Association }
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Find() (result *Content, err error) {
|
||||
return result, a.tx.Find(&result)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Append(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Append(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Replace(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Replace(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Delete(values ...*Content) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Delete(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Clear() error {
|
||||
return a.tx.Clear()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Count() int64 {
|
||||
return a.tx.Count()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToContentTx {
|
||||
a.tx = a.tx.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryDo struct{ gen.DO }
|
||||
|
||||
func (o orderItemQueryDo) Debug() *orderItemQueryDo {
|
||||
|
||||
@@ -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"` // 原价金额:分;未折扣前金额(用于展示与对账)
|
||||
|
||||
@@ -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 // 原价金额:分;未折扣前金额(用于展示与对账)
|
||||
|
||||
@@ -21,7 +21,7 @@ type TenantLedger struct {
|
||||
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致" json:"tenant_id"` // 租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致
|
||||
UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:余额账户归属用户;对应 tenant_users.user_id" json:"user_id"` // 用户ID:余额账户归属用户;对应 tenant_users.user_id
|
||||
OrderID int64 `gorm:"column:order_id;type:bigint;comment:关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空" json:"order_id"` // 关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空
|
||||
Type consts.TenantLedgerType `gorm:"column:type;type:character varying(32);not null;comment:流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型:credit_topup/debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向
|
||||
Type consts.TenantLedgerType `gorm:"column:type;type:character varying(32);not null;comment:流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向" json:"type"` // 流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向
|
||||
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)" json:"amount"` // 流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)
|
||||
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变更前可用余额:用于审计与对账回放" json:"balance_before"` // 变更前可用余额:用于审计与对账回放
|
||||
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变更后可用余额:用于审计与对账回放" json:"balance_after"` // 变更后可用余额:用于审计与对账回放
|
||||
@@ -32,7 +32,7 @@ type TenantLedger struct {
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
|
||||
OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)" json:"operator_user_id"` // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)
|
||||
BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键
|
||||
BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键
|
||||
BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计" json:"biz_ref_id"` // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计
|
||||
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ type TenantUser struct {
|
||||
TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"`
|
||||
UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"`
|
||||
Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"`
|
||||
Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"`
|
||||
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:verified" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"`
|
||||
BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0" json:"balance_frozen"` // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0
|
||||
}
|
||||
|
||||
// Quick operations without importing query package
|
||||
|
||||
@@ -29,11 +29,9 @@ func newTenantUser(db *gorm.DB, opts ...gen.DOOption) tenantUserQuery {
|
||||
_tenantUserQuery.TenantID = field.NewInt64(tableName, "tenant_id")
|
||||
_tenantUserQuery.UserID = field.NewInt64(tableName, "user_id")
|
||||
_tenantUserQuery.Role = field.NewArray(tableName, "role")
|
||||
_tenantUserQuery.Balance = field.NewInt64(tableName, "balance")
|
||||
_tenantUserQuery.Status = field.NewField(tableName, "status")
|
||||
_tenantUserQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
||||
_tenantUserQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_tenantUserQuery.BalanceFrozen = field.NewInt64(tableName, "balance_frozen")
|
||||
|
||||
_tenantUserQuery.fillFieldMap()
|
||||
|
||||
@@ -48,11 +46,9 @@ type tenantUserQuery struct {
|
||||
TenantID field.Int64
|
||||
UserID field.Int64
|
||||
Role field.Array
|
||||
Balance field.Int64
|
||||
Status field.Field
|
||||
CreatedAt field.Time
|
||||
UpdatedAt field.Time
|
||||
BalanceFrozen field.Int64 // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0
|
||||
|
||||
fieldMap map[string]field.Expr
|
||||
}
|
||||
@@ -73,11 +69,9 @@ func (t *tenantUserQuery) updateTableName(table string) *tenantUserQuery {
|
||||
t.TenantID = field.NewInt64(table, "tenant_id")
|
||||
t.UserID = field.NewInt64(table, "user_id")
|
||||
t.Role = field.NewArray(table, "role")
|
||||
t.Balance = field.NewInt64(table, "balance")
|
||||
t.Status = field.NewField(table, "status")
|
||||
t.CreatedAt = field.NewTime(table, "created_at")
|
||||
t.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
t.BalanceFrozen = field.NewInt64(table, "balance_frozen")
|
||||
|
||||
t.fillFieldMap()
|
||||
|
||||
@@ -110,16 +104,14 @@ func (t *tenantUserQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo
|
||||
}
|
||||
|
||||
func (t *tenantUserQuery) fillFieldMap() {
|
||||
t.fieldMap = make(map[string]field.Expr, 9)
|
||||
t.fieldMap = make(map[string]field.Expr, 7)
|
||||
t.fieldMap["id"] = t.ID
|
||||
t.fieldMap["tenant_id"] = t.TenantID
|
||||
t.fieldMap["user_id"] = t.UserID
|
||||
t.fieldMap["role"] = t.Role
|
||||
t.fieldMap["balance"] = t.Balance
|
||||
t.fieldMap["status"] = t.Status
|
||||
t.fieldMap["created_at"] = t.CreatedAt
|
||||
t.fieldMap["updated_at"] = t.UpdatedAt
|
||||
t.fieldMap["balance_frozen"] = t.BalanceFrozen
|
||||
}
|
||||
|
||||
func (t tenantUserQuery) clone(db *gorm.DB) tenantUserQuery {
|
||||
|
||||
@@ -28,6 +28,8 @@ type User struct {
|
||||
Roles types.Array[consts.Role] `gorm:"column:roles;type:text[];not null;default:ARRAY['user" json:"roles"`
|
||||
Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"`
|
||||
Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"`
|
||||
Balance int64 `gorm:"column:balance;type:bigint;not null;comment:全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0" json:"balance"` // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
|
||||
BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0" json:"balance_frozen"` // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
|
||||
VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp with time zone" json:"verified_at"`
|
||||
OwnedTenant *Tenant `json:"owned,omitempty"`
|
||||
Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"`
|
||||
|
||||
@@ -34,6 +34,8 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery {
|
||||
_userQuery.Roles = field.NewArray(tableName, "roles")
|
||||
_userQuery.Status = field.NewField(tableName, "status")
|
||||
_userQuery.Metas = field.NewJSONB(tableName, "metas")
|
||||
_userQuery.Balance = field.NewInt64(tableName, "balance")
|
||||
_userQuery.BalanceFrozen = field.NewInt64(tableName, "balance_frozen")
|
||||
_userQuery.VerifiedAt = field.NewTime(tableName, "verified_at")
|
||||
_userQuery.OwnedTenant = userQueryBelongsToOwnedTenant{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
@@ -65,6 +67,8 @@ type userQuery struct {
|
||||
Roles field.Array
|
||||
Status field.Field
|
||||
Metas field.JSONB
|
||||
Balance field.Int64 // 全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0
|
||||
BalanceFrozen field.Int64 // 全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0
|
||||
VerifiedAt field.Time
|
||||
OwnedTenant userQueryBelongsToOwnedTenant
|
||||
|
||||
@@ -94,6 +98,8 @@ func (u *userQuery) updateTableName(table string) *userQuery {
|
||||
u.Roles = field.NewArray(table, "roles")
|
||||
u.Status = field.NewField(table, "status")
|
||||
u.Metas = field.NewJSONB(table, "metas")
|
||||
u.Balance = field.NewInt64(table, "balance")
|
||||
u.BalanceFrozen = field.NewInt64(table, "balance_frozen")
|
||||
u.VerifiedAt = field.NewTime(table, "verified_at")
|
||||
|
||||
u.fillFieldMap()
|
||||
@@ -125,7 +131,7 @@ func (u *userQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||
}
|
||||
|
||||
func (u *userQuery) fillFieldMap() {
|
||||
u.fieldMap = make(map[string]field.Expr, 12)
|
||||
u.fieldMap = make(map[string]field.Expr, 14)
|
||||
u.fieldMap["id"] = u.ID
|
||||
u.fieldMap["created_at"] = u.CreatedAt
|
||||
u.fieldMap["updated_at"] = u.UpdatedAt
|
||||
@@ -135,6 +141,8 @@ func (u *userQuery) fillFieldMap() {
|
||||
u.fieldMap["roles"] = u.Roles
|
||||
u.fieldMap["status"] = u.Status
|
||||
u.fieldMap["metas"] = u.Metas
|
||||
u.fieldMap["balance"] = u.Balance
|
||||
u.fieldMap["balance_frozen"] = u.BalanceFrozen
|
||||
u.fieldMap["verified_at"] = u.VerifiedAt
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -149,27 +149,19 @@
|
||||
- refunding 期间不得重复扣款/重复回收权益;
|
||||
- 失败可重试(明确重试幂等键策略)。
|
||||
|
||||
## Epic E:审计字段结构化(当前充值操作者更多在 snapshot/remark)
|
||||
## Epic E:审计字段结构化(操作者/业务引用结构化)
|
||||
|
||||
### E1(P1, DB/API)tenant_ledgers 增加操作者字段与业务引用字段
|
||||
|
||||
- **DB 变更**(建议):
|
||||
- `tenant_ledgers.operator_user_id bigint NULL`
|
||||
- `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/topup/etc)
|
||||
- `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/etc)
|
||||
- `tenant_ledgers.biz_ref_id bigint NULL`
|
||||
- 对 `(tenant_id, biz_ref_type, biz_ref_id, type)` 做唯一约束(或与 idempotency_key 二选一作为主幂等源)。
|
||||
- **验收用例**:
|
||||
- 充值/退款/购买相关 ledger 必须写入 operator_user_id(admin/buyer/system);
|
||||
- 购买/退款/调账等敏感 ledger 必须写入 operator_user_id(admin/buyer/system);
|
||||
- 后台可按 operator_user_id 检索敏感操作流水。
|
||||
|
||||
### E2(P1, DB/Order)topup 结构化操作者字段(可选)
|
||||
|
||||
- **DB 变更**(二选一):
|
||||
1) 在 `orders` 增加 `operator_user_id`(对 topup 更直观)
|
||||
2) 保持在 snapshot,但保证 ledger/operator 字段可追溯
|
||||
- **验收用例**:
|
||||
- 导出订单时能明确区分“充值发起人”和“充值受益人”。
|
||||
|
||||
## 1. 建议交付顺序(最小闭环)
|
||||
|
||||
1) A1 → A2(先把公开读能力与语义定死)
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user