Compare commits
4 Commits
683965ae39
...
201c55f055
| Author | SHA1 | Date | |
|---|---|---|---|
| 201c55f055 | |||
| a869540d9c | |||
| 5dc0f89ac0 | |||
| 3cb2a6f586 |
53
backend/app/http/tenant/dto/ledger_admin.go
Normal file
53
backend/app/http/tenant/dto/ledger_admin.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
)
|
||||
|
||||
// AdminLedgerListFilter 定义“租户后台余额流水”查询条件。
|
||||
//
|
||||
// 设计目标:
|
||||
// - 用于审计/对账:可以按操作者(operator_user_id)检索敏感操作流水;
|
||||
// - 也可以按用户、订单、类型、业务引用快速定位流水集合。
|
||||
type AdminLedgerListFilter struct {
|
||||
// Pagination 分页参数(page/limit)。
|
||||
requests.Pagination `json:",inline" query:",inline"`
|
||||
|
||||
// OperatorUserID 按操作者用户ID过滤(可选)。
|
||||
// 典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。
|
||||
OperatorUserID *int64 `json:"operator_user_id,omitempty" query:"operator_user_id"`
|
||||
|
||||
// UserID 按余额账户归属用户ID过滤(可选)。
|
||||
// 典型场景:查看某个租户成员的资金变化全链路。
|
||||
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
|
||||
|
||||
// Type 按流水类型过滤(可选)。
|
||||
Type *consts.TenantLedgerType `json:"type,omitempty" query:"type"`
|
||||
|
||||
// OrderID 按关联订单过滤(可选)。
|
||||
OrderID *int64 `json:"order_id,omitempty" query:"order_id"`
|
||||
|
||||
// BizRefType 按业务引用类型过滤(可选)。
|
||||
// 约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。
|
||||
BizRefType *string `json:"biz_ref_type,omitempty" query:"biz_ref_type"`
|
||||
|
||||
// BizRefID 按业务引用ID过滤(可选)。
|
||||
BizRefID *int64 `json:"biz_ref_id,omitempty" query:"biz_ref_id"`
|
||||
|
||||
// CreatedAtFrom 创建时间起(可选)。
|
||||
CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"`
|
||||
// CreatedAtTo 创建时间止(可选)。
|
||||
CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"`
|
||||
}
|
||||
|
||||
// AdminLedgerItem 返回一条余额流水(租户后台视角),并补充展示字段。
|
||||
type AdminLedgerItem struct {
|
||||
// Ledger 流水记录(租户内隔离)。
|
||||
Ledger *models.TenantLedger `json:"ledger"`
|
||||
// TypeDescription 流水类型中文说明(用于前端展示)。
|
||||
TypeDescription string `json:"type_description"`
|
||||
}
|
||||
57
backend/app/http/tenant/ledger_admin.go
Normal file
57
backend/app/http/tenant/ledger_admin.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ledgerAdmin provides tenant-admin ledger audit endpoints.
|
||||
//
|
||||
// @provider
|
||||
type ledgerAdmin struct{}
|
||||
|
||||
// adminLedgers
|
||||
//
|
||||
// @Summary 余额流水列表(租户管理/审计)
|
||||
// @Tags Tenant
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantCode path string true "Tenant Code"
|
||||
// @Param filter query dto.AdminLedgerListFilter true "Filter"
|
||||
// @Success 200 {object} requests.Pager{items=dto.AdminLedgerItem}
|
||||
//
|
||||
// @Router /t/:tenantCode/v1/admin/ledgers [get]
|
||||
// @Bind tenant local key(tenant)
|
||||
// @Bind tenantUser local key(tenant_user)
|
||||
// @Bind filter query
|
||||
func (*ledgerAdmin) adminLedgers(
|
||||
ctx fiber.Ctx,
|
||||
tenant *models.Tenant,
|
||||
tenantUser *models.TenantUser,
|
||||
filter *dto.AdminLedgerListFilter,
|
||||
) (*requests.Pager, error) {
|
||||
if err := requireTenantAdmin(tenantUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &dto.AdminLedgerListFilter{}
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"tenant_id": tenant.ID,
|
||||
"user_id": tenantUser.UserID,
|
||||
"operator_user_id": filter.OperatorUserID,
|
||||
"target_user_id": filter.UserID,
|
||||
"type": filter.Type,
|
||||
"order_id": filter.OrderID,
|
||||
"biz_ref_type": filter.BizRefType,
|
||||
"biz_ref_id": filter.BizRefID,
|
||||
}).Info("tenant.admin.ledgers.list")
|
||||
|
||||
return services.Ledger.AdminLedgerPage(ctx.Context(), tenant.ID, filter)
|
||||
}
|
||||
@@ -24,6 +24,13 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*ledgerAdmin, error) {
|
||||
obj := &ledgerAdmin{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*me, error) {
|
||||
obj := &me{}
|
||||
|
||||
@@ -62,6 +69,7 @@ func Provide(opts ...opt.Option) error {
|
||||
if err := container.Container.Provide(func(
|
||||
content *content,
|
||||
contentAdmin *contentAdmin,
|
||||
ledgerAdmin *ledgerAdmin,
|
||||
me *me,
|
||||
mediaAssetAdmin *mediaAssetAdmin,
|
||||
middlewares *middlewares.Middlewares,
|
||||
@@ -75,6 +83,7 @@ func Provide(opts ...opt.Option) error {
|
||||
obj := &Routes{
|
||||
content: content,
|
||||
contentAdmin: contentAdmin,
|
||||
ledgerAdmin: ledgerAdmin,
|
||||
me: me,
|
||||
mediaAssetAdmin: mediaAssetAdmin,
|
||||
middlewares: middlewares,
|
||||
|
||||
@@ -26,6 +26,7 @@ type Routes struct {
|
||||
// Controller instances
|
||||
content *content
|
||||
contentAdmin *contentAdmin
|
||||
ledgerAdmin *ledgerAdmin
|
||||
me *me
|
||||
mediaAssetAdmin *mediaAssetAdmin
|
||||
order *order
|
||||
@@ -112,6 +113,14 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
PathParam[int64]("contentID"),
|
||||
Body[dto.ContentPriceUpsertForm]("form"),
|
||||
))
|
||||
// Register routes for controller: ledgerAdmin
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/ledgers -> ledgerAdmin.adminLedgers")
|
||||
router.Get("/t/:tenantCode/v1/admin/ledgers"[len(r.Path()):], DataFunc3(
|
||||
r.ledgerAdmin.adminLedgers,
|
||||
Local[*models.Tenant]("tenant"),
|
||||
Local[*models.TenantUser]("tenant_user"),
|
||||
Query[dto.AdminLedgerListFilter]("filter"),
|
||||
))
|
||||
// Register routes for controller: me
|
||||
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me -> me.get")
|
||||
router.Get("/t/:tenantCode/v1/me"[len(r.Path()):], DataFunc3(
|
||||
|
||||
@@ -111,45 +111,137 @@ func (s *ledger) MyLedgerPage(ctx context.Context, tenantID, userID int64, filte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminLedgerPage 分页查询租户内余额流水(租户后台审计用)。
|
||||
func (s *ledger) AdminLedgerPage(ctx context.Context, tenantID int64, filter *dto.AdminLedgerListFilter) (*requests.Pager, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &dto.AdminLedgerListFilter{}
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": lo.FromPtr(filter.OperatorUserID),
|
||||
"user_id": lo.FromPtr(filter.UserID),
|
||||
"type": lo.FromPtr(filter.Type),
|
||||
"order_id": lo.FromPtr(filter.OrderID),
|
||||
"biz_ref_type": lo.FromPtr(filter.BizRefType),
|
||||
"biz_ref_id": lo.FromPtr(filter.BizRefID),
|
||||
"created_at_from": filter.CreatedAtFrom,
|
||||
"created_at_to": filter.CreatedAtTo,
|
||||
"pagination_page": filter.Page,
|
||||
"pagination_limit": filter.Limit,
|
||||
"pagination_offset": filter.Offset(),
|
||||
}).Info("services.ledger.admin.page")
|
||||
|
||||
filter.Pagination.Format()
|
||||
|
||||
tbl, query := models.TenantLedgerQuery.QueryContext(ctx)
|
||||
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
|
||||
|
||||
if filter.OperatorUserID != nil && *filter.OperatorUserID > 0 {
|
||||
conds = append(conds, tbl.OperatorUserID.Eq(*filter.OperatorUserID))
|
||||
}
|
||||
if filter.UserID != nil && *filter.UserID > 0 {
|
||||
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
|
||||
}
|
||||
if filter.Type != nil {
|
||||
conds = append(conds, tbl.Type.Eq(*filter.Type))
|
||||
}
|
||||
if filter.OrderID != nil && *filter.OrderID > 0 {
|
||||
conds = append(conds, tbl.OrderID.Eq(*filter.OrderID))
|
||||
}
|
||||
if filter.BizRefType != nil && *filter.BizRefType != "" {
|
||||
conds = append(conds, tbl.BizRefType.Eq(*filter.BizRefType))
|
||||
}
|
||||
if filter.BizRefID != nil && *filter.BizRefID > 0 {
|
||||
conds = append(conds, tbl.BizRefID.Eq(*filter.BizRefID))
|
||||
}
|
||||
if filter.CreatedAtFrom != nil {
|
||||
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
|
||||
}
|
||||
if filter.CreatedAtTo != nil {
|
||||
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
|
||||
}
|
||||
|
||||
ledgers, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := lo.Map(ledgers, func(m *models.TenantLedger, _ int) *dto.AdminLedgerItem {
|
||||
return &dto.AdminLedgerItem{
|
||||
Ledger: m,
|
||||
TypeDescription: m.Type.Description(),
|
||||
}
|
||||
})
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Freeze 将可用余额转入冻结余额,并写入账本记录。
|
||||
func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
// 冻结通常由用户自己发起(下单冻结);操作者默认等于余额账户归属 user_id。
|
||||
bizRefType := ""
|
||||
if orderID > 0 {
|
||||
bizRefType = "order"
|
||||
}
|
||||
return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// Unfreeze 将冻结余额转回可用余额,并写入账本记录。
|
||||
func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
// 解冻通常由用户自己发起(失败回滚/退款回滚等可能由系统或管理员触发;此处默认等于 user_id)。
|
||||
bizRefType := ""
|
||||
if orderID > 0 {
|
||||
bizRefType = "order"
|
||||
}
|
||||
return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// FreezeTx 为 Freeze 的事务版本(由外层事务控制提交/回滚)。
|
||||
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
bizRefType := ""
|
||||
if orderID > 0 {
|
||||
bizRefType = "order"
|
||||
}
|
||||
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// UnfreezeTx 为 Unfreeze 的事务版本(由外层事务控制提交/回滚)。
|
||||
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
bizRefType := ""
|
||||
if orderID > 0 {
|
||||
bizRefType = "order"
|
||||
}
|
||||
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// DebitPurchaseTx 将冻结资金转为实际扣款(减少冻结余额),并写入账本记录。
|
||||
func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
|
||||
func (s *ledger) DebitPurchaseTx(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.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// CreditRefundTx 将退款金额退回到可用余额,并写入账本记录。
|
||||
func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
|
||||
func (s *ledger) CreditRefundTx(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.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
|
||||
}
|
||||
|
||||
// CreditTopupTx 将充值金额记入可用余额,并写入账本记录。
|
||||
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
||||
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
|
||||
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,
|
||||
tenantID, userID, orderID int64,
|
||||
tenantID, operatorUserID, userID, orderID int64,
|
||||
bizRefType string, bizRefID int64,
|
||||
ledgerType consts.TenantLedgerType,
|
||||
amount, deltaBalance, deltaFrozen int64,
|
||||
idempotencyKey, remark string,
|
||||
@@ -165,8 +257,11 @@ func (s *ledger) apply(
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"biz_ref_type": bizRefType,
|
||||
"biz_ref_id": bizRefID,
|
||||
"type": ledgerType,
|
||||
"amount": amount,
|
||||
"idempotency_key": idempotencyKey,
|
||||
@@ -196,6 +291,24 @@ func (s *ledger) apply(
|
||||
}
|
||||
}
|
||||
|
||||
// 结构化幂等快速路径:当调用方未传 idempotency_key,但提供了 biz_ref 时,按 (tenant,biz_ref,type) 去重。
|
||||
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
|
||||
var existing models.TenantLedger
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = ¤t
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 使用行锁锁住 tenant_users,确保同一租户下同一用户余额更新的串行一致性。
|
||||
var tu models.TenantUser
|
||||
if err := tx.
|
||||
@@ -222,6 +335,20 @@ func (s *ledger) apply(
|
||||
}
|
||||
}
|
||||
|
||||
// 二次结构化幂等校验:与上面的幂等逻辑一致,避免并发下重复写入。
|
||||
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
|
||||
var existing models.TenantLedger
|
||||
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 {
|
||||
out.Ledger = &existing
|
||||
out.TenantUser = &tu
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
balanceBefore := tu.Balance
|
||||
frozenBefore := tu.BalanceFrozen
|
||||
balanceAfter := balanceBefore + deltaBalance
|
||||
@@ -249,8 +376,11 @@ func (s *ledger) apply(
|
||||
// 写入账本:记录变更前后快照,便于对账与审计;幂等键用于去重。
|
||||
ledger := &models.TenantLedger{
|
||||
TenantID: tenantID,
|
||||
OperatorUserID: operatorUserID,
|
||||
UserID: userID,
|
||||
OrderID: orderID,
|
||||
BizRefType: bizRefType,
|
||||
BizRefID: bizRefID,
|
||||
Type: ledgerType,
|
||||
Amount: amount,
|
||||
BalanceBefore: balanceBefore,
|
||||
@@ -274,6 +404,17 @@ func (s *ledger) apply(
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 结构化幂等回读:当未传 idempotency_key 时按 biz_ref 回读。
|
||||
if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 {
|
||||
var existing models.TenantLedger
|
||||
if e2 := tx.
|
||||
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
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -287,24 +428,30 @@ func (s *ledger) apply(
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"type": ledgerType,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"biz_ref_type": bizRefType,
|
||||
"biz_ref_id": bizRefID,
|
||||
"type": ledgerType,
|
||||
"idempotency_key": idempotencyKey,
|
||||
}).WithError(err).Warn("services.ledger.apply.failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"type": ledgerType,
|
||||
"ledger_id": out.Ledger.ID,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"balance_after": out.TenantUser.Balance,
|
||||
"frozen_after": out.TenantUser.BalanceFrozen,
|
||||
"tenant_id": tenantID,
|
||||
"operator_user_id": operatorUserID,
|
||||
"user_id": userID,
|
||||
"order_id": orderID,
|
||||
"biz_ref_type": bizRefType,
|
||||
"biz_ref_id": bizRefID,
|
||||
"type": ledgerType,
|
||||
"ledger_id": out.Ledger.ID,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"balance_after": out.TenantUser.Balance,
|
||||
"frozen_after": out.TenantUser.BalanceFrozen,
|
||||
}).Info("services.ledger.apply.ok")
|
||||
|
||||
return &out, nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"github.com/samber/lo"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
@@ -88,6 +89,9 @@ func (s *LedgerTestSuite) Test_Freeze() {
|
||||
So(res.Ledger.BalanceAfter, ShouldEqual, 700)
|
||||
So(res.Ledger.FrozenBefore, ShouldEqual, 0)
|
||||
So(res.Ledger.FrozenAfter, ShouldEqual, 300)
|
||||
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)
|
||||
})
|
||||
@@ -150,6 +154,9 @@ func (s *LedgerTestSuite) Test_Unfreeze() {
|
||||
So(err, ShouldBeNil)
|
||||
So(res, ShouldNotBeNil)
|
||||
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeUnfreeze)
|
||||
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)
|
||||
})
|
||||
@@ -183,12 +190,12 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
Convey("金额非法应返回参数错误", func() {
|
||||
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 0, "k_debit_invalid_amount", "debit", now)
|
||||
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_debit_invalid_amount", "debit", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("冻结余额不足应返回前置条件失败", func() {
|
||||
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_no_frozen", "debit", now)
|
||||
_, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_no_frozen", "debit", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
@@ -196,10 +203,13 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
res, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_1", "debit", now)
|
||||
res, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_1", "debit", now)
|
||||
So(err, ShouldBeNil)
|
||||
So(res, ShouldNotBeNil)
|
||||
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase)
|
||||
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)
|
||||
})
|
||||
@@ -208,10 +218,10 @@ func (s *LedgerTestSuite) Test_DebitPurchaseTx() {
|
||||
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit_idem", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_idem", "debit", now)
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second))
|
||||
_, 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
|
||||
@@ -232,20 +242,23 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
Convey("金额非法应返回参数错误", func() {
|
||||
_, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 0, "k_refund_invalid_amount", "refund", now)
|
||||
_, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_refund_invalid_amount", "refund", now)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("成功退款应增加可用余额", func() {
|
||||
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_for_refund", "debit", now)
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund", "debit", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
res, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 300, "k_refund_1", "refund", now)
|
||||
res, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_1", "refund", now)
|
||||
So(err, ShouldBeNil)
|
||||
So(res, ShouldNotBeNil)
|
||||
So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditRefund)
|
||||
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)
|
||||
})
|
||||
@@ -253,12 +266,12 @@ func (s *LedgerTestSuite) Test_CreditRefundTx() {
|
||||
Convey("幂等键重复调用不应重复退款入账", func() {
|
||||
_, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund_idem", "freeze", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, 123, 300, "k_debit_for_refund_idem", "debit", now)
|
||||
_, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund_idem", "debit", now)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 300, "k_refund_idem", "refund", now)
|
||||
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second))
|
||||
_, 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
|
||||
@@ -278,23 +291,26 @@ func (s *LedgerTestSuite) Test_CreditTopupTx() {
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
Convey("金额非法应返回参数错误", func() {
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 456, 0, "k_topup_invalid_amount", "topup", now)
|
||||
_, 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, userID, 456, 200, "k_topup_1", "topup", now)
|
||||
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, userID, 456, 200, "k_topup_idem", "topup", now)
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, 999, userID, 456, 200, "k_topup_idem", "topup", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 456, 200, "k_topup_idem", "topup", now.Add(time.Second))
|
||||
_, 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
|
||||
@@ -336,7 +352,7 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
|
||||
|
||||
s.seedTenantUser(ctx, tenantID, userID, 1000, 0)
|
||||
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, 1, 200, "k_topup_for_page", "topup", now)
|
||||
_, err := Ledger.CreditTopupTx(ctx, _db, tenantID, userID, userID, 1, 200, "k_topup_for_page", "topup", now)
|
||||
So(err, ShouldBeNil)
|
||||
_, err = Ledger.Freeze(ctx, tenantID, userID, 2, 100, "k_freeze_for_page", "freeze", now.Add(time.Second))
|
||||
So(err, ShouldBeNil)
|
||||
@@ -356,3 +372,26 @@ func (s *LedgerTestSuite) Test_MyLedgerPage() {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LedgerTestSuite) Test_AdminLedgerPage() {
|
||||
Convey("Ledger.AdminLedgerPage", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
tenantID := int64(1)
|
||||
userID := int64(2)
|
||||
now := time.Now().UTC()
|
||||
|
||||
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)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("按 operator_user_id 过滤", func() {
|
||||
pager, err := Ledger.AdminLedgerPage(ctx, tenantID, &dto.AdminLedgerListFilter{
|
||||
OperatorUserID: lo.ToPtr(int64(999)),
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(pager.Total, ShouldEqual, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ func (s *order) AdminTopupUser(
|
||||
if remark == "" {
|
||||
remark = fmt.Sprintf("topup by tenant_admin:%d", operatorUserID)
|
||||
}
|
||||
if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil {
|
||||
if _, err := s.ledger.CreditTopupTx(ctx, tx, tenantID, operatorUserID, targetUserID, orderModel.ID, amount, ledgerKey, remark, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -772,7 +772,7 @@ func (s *order) AdminRefundOrder(
|
||||
|
||||
// 先退余额(账本入账),后更新订单状态与权益,确保退款可对账且可追溯。
|
||||
if amount > 0 {
|
||||
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
|
||||
if _, err := s.ledger.CreditRefundTx(ctx, tx, tenantID, operatorUserID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1075,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
||||
|
||||
// 3) 独立事务冻结余额:便于后续在订单事务失败时做补偿解冻。
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
_, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now)
|
||||
_, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, pkgerrors.Wrap(err, "purchase freeze failed")
|
||||
@@ -1114,7 +1114,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
||||
if err := tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil {
|
||||
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.Order{}).
|
||||
@@ -1152,6 +1152,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
||||
ctx,
|
||||
tx,
|
||||
params.TenantID,
|
||||
params.UserID, // operator_user_id:购买者本人(下单链路中的补偿动作)
|
||||
params.UserID,
|
||||
0,
|
||||
amountPaid,
|
||||
@@ -1322,7 +1323,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil {
|
||||
if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1345,7 +1346,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil {
|
||||
if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -152,14 +152,14 @@ func (s *OrderTestSuite) Test_AdminTopupUser() {
|
||||
So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid)
|
||||
So(orderModel.AmountPaid, ShouldEqual, 300)
|
||||
|
||||
snap := orderModel.Snapshot.Data()
|
||||
So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup))
|
||||
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 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)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE tenant_ledgers
|
||||
ADD COLUMN IF NOT EXISTS operator_user_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS biz_ref_type varchar(32),
|
||||
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 组成可选的结构化幂等/追溯键';
|
||||
COMMENT ON COLUMN tenant_ledgers.biz_ref_id IS '业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计';
|
||||
|
||||
-- 索引:按操作者检索敏感操作流水(后台审计用)。
|
||||
CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_operator ON tenant_ledgers(tenant_id, operator_user_id);
|
||||
|
||||
-- 索引:按业务引用快速定位同一业务对象的流水集合。
|
||||
CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_biz_ref ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id);
|
||||
|
||||
-- 结构化幂等(可选):同一业务引用在同一流水类型下只能出现一条。
|
||||
-- 说明:biz_ref_* 允许为空;仅当两者都非空时才参与唯一性约束。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type
|
||||
ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type)
|
||||
WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type;
|
||||
DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_biz_ref;
|
||||
DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_operator;
|
||||
|
||||
ALTER TABLE tenant_ledgers
|
||||
DROP COLUMN IF EXISTS biz_ref_id,
|
||||
DROP COLUMN IF EXISTS biz_ref_type,
|
||||
DROP COLUMN IF EXISTS operator_user_id;
|
||||
-- +goose StatementEnd
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- 修正:biz_ref_type/biz_ref_id 在 Go 模型侧为 string/int64(非指针),空值会写入 ''/0,
|
||||
-- 若唯一索引仅判断 NOT NULL,会导致大量流水写入冲突。
|
||||
-- 约束策略:仅当 biz_ref_type 非空 且 biz_ref_id > 0 时才参与唯一性约束。
|
||||
DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type
|
||||
ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type)
|
||||
WHERE biz_ref_type IS NOT NULL AND biz_ref_type <> '' AND biz_ref_id IS NOT NULL AND biz_ref_id <> 0;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type;
|
||||
|
||||
-- Down 回滚为“仅判断 NOT NULL”的版本(不建议在线上使用该版本)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type
|
||||
ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type)
|
||||
WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL;
|
||||
-- +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()
|
||||
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"`
|
||||
Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"`
|
||||
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,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.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.Order = orderItemQueryBelongsToOrder{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
|
||||
RelationField: field.NewRelation("Order", "Order"),
|
||||
}
|
||||
|
||||
_orderItemQuery.fillFieldMap()
|
||||
|
||||
return _orderItemQuery
|
||||
@@ -66,9 +66,9 @@ type orderItemQuery struct {
|
||||
Snapshot field.JSONB // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计
|
||||
CreatedAt field.Time // 创建时间:默认 now()
|
||||
UpdatedAt field.Time // 更新时间:默认 now()
|
||||
Order orderItemQueryBelongsToOrder
|
||||
Content orderItemQueryBelongsToContent
|
||||
|
||||
Content orderItemQueryBelongsToContent
|
||||
Order orderItemQueryBelongsToOrder
|
||||
|
||||
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.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
|
||||
o.Order.db = db.Session(&gorm.Session{Initialized: true})
|
||||
o.Order.db.Statement.ConnPool = db.Statement.ConnPool
|
||||
return o
|
||||
}
|
||||
|
||||
func (o orderItemQuery) replaceDB(db *gorm.DB) orderItemQuery {
|
||||
o.orderItemQueryDo.ReplaceDB(db)
|
||||
o.Order.db = db.Session(&gorm.Session{})
|
||||
o.Content.db = db.Session(&gorm.Session{})
|
||||
o.Order.db = db.Session(&gorm.Session{})
|
||||
return o
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToOrder struct {
|
||||
db *gorm.DB
|
||||
|
||||
field.RelationField
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Where(conds ...field.Expr) *orderItemQueryBelongsToOrder {
|
||||
if len(conds) == 0 {
|
||||
return &a
|
||||
}
|
||||
|
||||
exprs := make([]clause.Expression, 0, len(conds))
|
||||
for _, cond := range conds {
|
||||
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
||||
}
|
||||
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) WithContext(ctx context.Context) *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.WithContext(ctx)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Session(session *gorm.Session) *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.Session(session)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Model(m *OrderItem) *orderItemQueryBelongsToOrderTx {
|
||||
return &orderItemQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())}
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Unscoped() *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToOrderTx struct{ tx *gorm.Association }
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Find() (result *Order, err error) {
|
||||
return result, a.tx.Find(&result)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Append(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Append(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Replace(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Replace(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Delete(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Delete(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Clear() error {
|
||||
return a.tx.Clear()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Count() int64 {
|
||||
return a.tx.Count()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrderTx {
|
||||
a.tx = a.tx.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToContent struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -319,6 +238,87 @@ func (a orderItemQueryBelongsToContentTx) Unscoped() *orderItemQueryBelongsToCon
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToOrder struct {
|
||||
db *gorm.DB
|
||||
|
||||
field.RelationField
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Where(conds ...field.Expr) *orderItemQueryBelongsToOrder {
|
||||
if len(conds) == 0 {
|
||||
return &a
|
||||
}
|
||||
|
||||
exprs := make([]clause.Expression, 0, len(conds))
|
||||
for _, cond := range conds {
|
||||
exprs = append(exprs, cond.BeCond().(clause.Expression))
|
||||
}
|
||||
a.db = a.db.Clauses(clause.Where{Exprs: exprs})
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) WithContext(ctx context.Context) *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.WithContext(ctx)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Session(session *gorm.Session) *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.Session(session)
|
||||
return &a
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Model(m *OrderItem) *orderItemQueryBelongsToOrderTx {
|
||||
return &orderItemQueryBelongsToOrderTx{a.db.Model(m).Association(a.Name())}
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrder) Unscoped() *orderItemQueryBelongsToOrder {
|
||||
a.db = a.db.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryBelongsToOrderTx struct{ tx *gorm.Association }
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Find() (result *Order, err error) {
|
||||
return result, a.tx.Find(&result)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Append(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Append(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Replace(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Replace(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Delete(values ...*Order) (err error) {
|
||||
targetValues := make([]interface{}, len(values))
|
||||
for i, v := range values {
|
||||
targetValues[i] = v
|
||||
}
|
||||
return a.tx.Delete(targetValues...)
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Clear() error {
|
||||
return a.tx.Clear()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Count() int64 {
|
||||
return a.tx.Count()
|
||||
}
|
||||
|
||||
func (a orderItemQueryBelongsToOrderTx) Unscoped() *orderItemQueryBelongsToOrderTx {
|
||||
a.tx = a.tx.Unscoped()
|
||||
return &a
|
||||
}
|
||||
|
||||
type orderItemQueryDo struct{ gen.DO }
|
||||
|
||||
func (o orderItemQueryDo) Debug() *orderItemQueryDo {
|
||||
|
||||
@@ -31,6 +31,9 @@ type TenantLedger struct {
|
||||
Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:业务说明/后台操作原因等;用于审计" json:"remark"` // 备注:业务说明/后台操作原因等;用于审计
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
|
||||
OperatorUserID int64 `gorm:"column:operator_user_id;type:bigint;comment:操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)" json:"operator_user_id"` // 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)
|
||||
BizRefType string `gorm:"column:biz_ref_type;type:character varying(32);comment:业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键" json:"biz_ref_type"` // 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键
|
||||
BizRefID int64 `gorm:"column:biz_ref_id;type:bigint;comment:业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计" json:"biz_ref_id"` // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计
|
||||
Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ func newTenantLedger(db *gorm.DB, opts ...gen.DOOption) tenantLedgerQuery {
|
||||
_tenantLedgerQuery.Remark = field.NewString(tableName, "remark")
|
||||
_tenantLedgerQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
||||
_tenantLedgerQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_tenantLedgerQuery.OperatorUserID = field.NewInt64(tableName, "operator_user_id")
|
||||
_tenantLedgerQuery.BizRefType = field.NewString(tableName, "biz_ref_type")
|
||||
_tenantLedgerQuery.BizRefID = field.NewInt64(tableName, "biz_ref_id")
|
||||
_tenantLedgerQuery.Order = tenantLedgerQueryBelongsToOrder{
|
||||
db: db.Session(&gorm.Session{}),
|
||||
|
||||
@@ -68,6 +71,9 @@ type tenantLedgerQuery struct {
|
||||
Remark field.String // 备注:业务说明/后台操作原因等;用于审计
|
||||
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 组成可选的结构化幂等/追溯键
|
||||
BizRefID field.Int64 // 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计
|
||||
Order tenantLedgerQueryBelongsToOrder
|
||||
|
||||
fieldMap map[string]field.Expr
|
||||
@@ -99,6 +105,9 @@ func (t *tenantLedgerQuery) updateTableName(table string) *tenantLedgerQuery {
|
||||
t.Remark = field.NewString(table, "remark")
|
||||
t.CreatedAt = field.NewTime(table, "created_at")
|
||||
t.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
t.OperatorUserID = field.NewInt64(table, "operator_user_id")
|
||||
t.BizRefType = field.NewString(table, "biz_ref_type")
|
||||
t.BizRefID = field.NewInt64(table, "biz_ref_id")
|
||||
|
||||
t.fillFieldMap()
|
||||
|
||||
@@ -131,7 +140,7 @@ func (t *tenantLedgerQuery) GetFieldByName(fieldName string) (field.OrderExpr, b
|
||||
}
|
||||
|
||||
func (t *tenantLedgerQuery) fillFieldMap() {
|
||||
t.fieldMap = make(map[string]field.Expr, 15)
|
||||
t.fieldMap = make(map[string]field.Expr, 18)
|
||||
t.fieldMap["id"] = t.ID
|
||||
t.fieldMap["tenant_id"] = t.TenantID
|
||||
t.fieldMap["user_id"] = t.UserID
|
||||
@@ -146,6 +155,9 @@ func (t *tenantLedgerQuery) fillFieldMap() {
|
||||
t.fieldMap["remark"] = t.Remark
|
||||
t.fieldMap["created_at"] = t.CreatedAt
|
||||
t.fieldMap["updated_at"] = t.UpdatedAt
|
||||
t.fieldMap["operator_user_id"] = t.OperatorUserID
|
||||
t.fieldMap["biz_ref_type"] = t.BizRefType
|
||||
t.fieldMap["biz_ref_id"] = t.BizRefID
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -939,6 +939,125 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/t/{tenantCode}/v1/admin/ledgers": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tenant"
|
||||
],
|
||||
"summary": "余额流水列表(租户管理/审计)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Tenant Code",
|
||||
"name": "tenantCode",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "BizRefID 按业务引用ID过滤(可选)。",
|
||||
"name": "biz_ref_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。",
|
||||
"name": "biz_ref_type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "CreatedAtFrom 创建时间起(可选)。",
|
||||
"name": "created_at_from",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "CreatedAtTo 创建时间止(可选)。",
|
||||
"name": "created_at_to",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。",
|
||||
"name": "operator_user_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "OrderID 按关联订单过滤(可选)。",
|
||||
"name": "order_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"credit_topup",
|
||||
"debit_purchase",
|
||||
"credit_refund",
|
||||
"freeze",
|
||||
"unfreeze",
|
||||
"adjustment"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"TenantLedgerTypeCreditTopup",
|
||||
"TenantLedgerTypeDebitPurchase",
|
||||
"TenantLedgerTypeCreditRefund",
|
||||
"TenantLedgerTypeFreeze",
|
||||
"TenantLedgerTypeUnfreeze",
|
||||
"TenantLedgerTypeAdjustment"
|
||||
],
|
||||
"description": "Type 按流水类型过滤(可选)。",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "UserID 按余额账户归属用户ID过滤(可选)。\n典型场景:查看某个租户成员的资金变化全链路。",
|
||||
"name": "user_id",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/requests.Pager"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/dto.AdminLedgerItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/t/{tenantCode}/v1/admin/media_assets": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
@@ -3021,6 +3140,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.AdminLedgerItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ledger": {
|
||||
"description": "Ledger 流水记录(租户内隔离)。",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.TenantLedger"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type_description": {
|
||||
"description": "TypeDescription 流水类型中文说明(用于前端展示)。",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.AdminMediaAssetUploadCompleteForm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4181,10 +4317,11 @@ const docTemplate = `{
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/types.JSONType-fields_OrdersSnapshot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致",
|
||||
@@ -4251,10 +4388,11 @@ const docTemplate = `{
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/types.JSONType-fields_OrderItemsSnapshot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tenant_id": {
|
||||
"description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致",
|
||||
@@ -4439,6 +4577,14 @@ const docTemplate = `{
|
||||
"description": "变更前可用余额:用于审计与对账回放",
|
||||
"type": "integer"
|
||||
},
|
||||
"biz_ref_id": {
|
||||
"description": "业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计",
|
||||
"type": "integer"
|
||||
},
|
||||
"biz_ref_type": {
|
||||
"description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"description": "创建时间:默认 now()",
|
||||
"type": "string"
|
||||
@@ -4459,6 +4605,10 @@ const docTemplate = `{
|
||||
"description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)",
|
||||
"type": "string"
|
||||
},
|
||||
"operator_user_id": {
|
||||
"description": "操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)",
|
||||
"type": "integer"
|
||||
},
|
||||
"order": {
|
||||
"$ref": "#/definitions/models.Order"
|
||||
},
|
||||
@@ -4609,6 +4759,12 @@ const docTemplate = `{
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.JSONType-fields_OrderItemsSnapshot": {
|
||||
"type": "object"
|
||||
},
|
||||
"types.JSONType-fields_OrdersSnapshot": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
@@ -933,6 +933,125 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/t/{tenantCode}/v1/admin/ledgers": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tenant"
|
||||
],
|
||||
"summary": "余额流水列表(租户管理/审计)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Tenant Code",
|
||||
"name": "tenantCode",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "BizRefID 按业务引用ID过滤(可选)。",
|
||||
"name": "biz_ref_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "BizRefType 按业务引用类型过滤(可选)。\n约定:当前业务写入为 \"order\";未来可扩展为 refund/topup 等。",
|
||||
"name": "biz_ref_type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "CreatedAtFrom 创建时间起(可选)。",
|
||||
"name": "created_at_from",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "CreatedAtTo 创建时间止(可选)。",
|
||||
"name": "created_at_to",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "OperatorUserID 按操作者用户ID过滤(可选)。\n典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。",
|
||||
"name": "operator_user_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "OrderID 按关联订单过滤(可选)。",
|
||||
"name": "order_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"credit_topup",
|
||||
"debit_purchase",
|
||||
"credit_refund",
|
||||
"freeze",
|
||||
"unfreeze",
|
||||
"adjustment"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-varnames": [
|
||||
"TenantLedgerTypeCreditTopup",
|
||||
"TenantLedgerTypeDebitPurchase",
|
||||
"TenantLedgerTypeCreditRefund",
|
||||
"TenantLedgerTypeFreeze",
|
||||
"TenantLedgerTypeUnfreeze",
|
||||
"TenantLedgerTypeAdjustment"
|
||||
],
|
||||
"description": "Type 按流水类型过滤(可选)。",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "UserID 按余额账户归属用户ID过滤(可选)。\n典型场景:查看某个租户成员的资金变化全链路。",
|
||||
"name": "user_id",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/requests.Pager"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/dto.AdminLedgerItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/t/{tenantCode}/v1/admin/media_assets": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
@@ -3015,6 +3134,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.AdminLedgerItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ledger": {
|
||||
"description": "Ledger 流水记录(租户内隔离)。",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.TenantLedger"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type_description": {
|
||||
"description": "TypeDescription 流水类型中文说明(用于前端展示)。",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.AdminMediaAssetUploadCompleteForm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4175,10 +4311,11 @@
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/types.JSONType-fields_OrdersSnapshot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"description": "订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致",
|
||||
@@ -4245,10 +4382,11 @@
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/types.JSONType-fields_OrderItemsSnapshot"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tenant_id": {
|
||||
"description": "租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致",
|
||||
@@ -4433,6 +4571,14 @@
|
||||
"description": "变更前可用余额:用于审计与对账回放",
|
||||
"type": "integer"
|
||||
},
|
||||
"biz_ref_id": {
|
||||
"description": "业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计",
|
||||
"type": "integer"
|
||||
},
|
||||
"biz_ref_type": {
|
||||
"description": "业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"description": "创建时间:默认 now()",
|
||||
"type": "string"
|
||||
@@ -4453,6 +4599,10 @@
|
||||
"description": "幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)",
|
||||
"type": "string"
|
||||
},
|
||||
"operator_user_id": {
|
||||
"description": "操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)",
|
||||
"type": "integer"
|
||||
},
|
||||
"order": {
|
||||
"$ref": "#/definitions/models.Order"
|
||||
},
|
||||
@@ -4603,6 +4753,12 @@
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.JSONType-fields_OrderItemsSnapshot": {
|
||||
"type": "object"
|
||||
},
|
||||
"types.JSONType-fields_OrdersSnapshot": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
@@ -257,6 +257,16 @@ definitions:
|
||||
description: UserID 目标用户ID。
|
||||
type: integer
|
||||
type: object
|
||||
dto.AdminLedgerItem:
|
||||
properties:
|
||||
ledger:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.TenantLedger'
|
||||
description: Ledger 流水记录(租户内隔离)。
|
||||
type_description:
|
||||
description: TypeDescription 流水类型中文说明(用于前端展示)。
|
||||
type: string
|
||||
type: object
|
||||
dto.AdminMediaAssetUploadCompleteForm:
|
||||
properties:
|
||||
content_type:
|
||||
@@ -1054,10 +1064,9 @@ definitions:
|
||||
description: 退款完成时间:退款落账成功后写入
|
||||
type: string
|
||||
snapshot:
|
||||
allOf:
|
||||
- $ref: '#/definitions/types.JSONType-fields_OrdersSnapshot'
|
||||
description: 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
status:
|
||||
allOf:
|
||||
- $ref: '#/definitions/consts.OrderStatus'
|
||||
@@ -1101,10 +1110,9 @@ definitions:
|
||||
description: 订单ID:关联 orders.id;用于聚合订单明细
|
||||
type: integer
|
||||
snapshot:
|
||||
allOf:
|
||||
- $ref: '#/definitions/types.JSONType-fields_OrderItemsSnapshot'
|
||||
description: 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
tenant_id:
|
||||
description: 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致
|
||||
type: integer
|
||||
@@ -1232,6 +1240,12 @@ definitions:
|
||||
balance_before:
|
||||
description: 变更前可用余额:用于审计与对账回放
|
||||
type: integer
|
||||
biz_ref_id:
|
||||
description: 业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计
|
||||
type: integer
|
||||
biz_ref_type:
|
||||
description: 业务引用类型:order/refund/topup/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键
|
||||
type: string
|
||||
created_at:
|
||||
description: 创建时间:默认 now()
|
||||
type: string
|
||||
@@ -1247,6 +1261,9 @@ definitions:
|
||||
idempotency_key:
|
||||
description: 幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)
|
||||
type: string
|
||||
operator_user_id:
|
||||
description: 操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)
|
||||
type: integer
|
||||
order:
|
||||
$ref: '#/definitions/models.Order'
|
||||
order_id:
|
||||
@@ -1350,6 +1367,10 @@ definitions:
|
||||
paging).
|
||||
type: integer
|
||||
type: object
|
||||
types.JSONType-fields_OrderItemsSnapshot:
|
||||
type: object
|
||||
types.JSONType-fields_OrdersSnapshot:
|
||||
type: object
|
||||
externalDocs:
|
||||
description: OpenAPI
|
||||
url: https://swagger.io/resources/open-api/
|
||||
@@ -1967,6 +1988,92 @@ paths:
|
||||
summary: 拒绝加入申请(租户管理)
|
||||
tags:
|
||||
- Tenant
|
||||
/t/{tenantCode}/v1/admin/ledgers:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Tenant Code
|
||||
in: path
|
||||
name: tenantCode
|
||||
required: true
|
||||
type: string
|
||||
- description: BizRefID 按业务引用ID过滤(可选)。
|
||||
in: query
|
||||
name: biz_ref_id
|
||||
type: integer
|
||||
- description: |-
|
||||
BizRefType 按业务引用类型过滤(可选)。
|
||||
约定:当前业务写入为 "order";未来可扩展为 refund/topup 等。
|
||||
in: query
|
||||
name: biz_ref_type
|
||||
type: string
|
||||
- description: CreatedAtFrom 创建时间起(可选)。
|
||||
in: query
|
||||
name: created_at_from
|
||||
type: string
|
||||
- description: CreatedAtTo 创建时间止(可选)。
|
||||
in: query
|
||||
name: created_at_to
|
||||
type: string
|
||||
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||
(otherwise defaults to 10).
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: |-
|
||||
OperatorUserID 按操作者用户ID过滤(可选)。
|
||||
典型场景:后台检索“某个管理员发起的充值/退款”等敏感操作流水。
|
||||
in: query
|
||||
name: operator_user_id
|
||||
type: integer
|
||||
- description: OrderID 按关联订单过滤(可选)。
|
||||
in: query
|
||||
name: order_id
|
||||
type: integer
|
||||
- description: Page is 1-based page index; values <= 0 are normalized to 1.
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Type 按流水类型过滤(可选)。
|
||||
enum:
|
||||
- credit_topup
|
||||
- debit_purchase
|
||||
- credit_refund
|
||||
- freeze
|
||||
- unfreeze
|
||||
- adjustment
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- TenantLedgerTypeCreditTopup
|
||||
- TenantLedgerTypeDebitPurchase
|
||||
- TenantLedgerTypeCreditRefund
|
||||
- TenantLedgerTypeFreeze
|
||||
- TenantLedgerTypeUnfreeze
|
||||
- TenantLedgerTypeAdjustment
|
||||
- description: |-
|
||||
UserID 按余额账户归属用户ID过滤(可选)。
|
||||
典型场景:查看某个租户成员的资金变化全链路。
|
||||
in: query
|
||||
name: user_id
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/requests.Pager'
|
||||
- properties:
|
||||
items:
|
||||
$ref: '#/definitions/dto.AdminLedgerItem'
|
||||
type: object
|
||||
summary: 余额流水列表(租户管理/审计)
|
||||
tags:
|
||||
- Tenant
|
||||
/t/{tenantCode}/v1/admin/media_assets:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
@@ -192,6 +192,59 @@ In this case:
|
||||
- Add a short Chinese comment explaining why, and that `atomctl gen model` should be run when DB is reachable.
|
||||
- Avoid spreading this pattern: keep it localized to one function.
|
||||
|
||||
---
|
||||
|
||||
## Async Jobs(River)
|
||||
|
||||
本项目使用 River(`github.com/riverqueue/river`)作为异步任务系统,并通过 `atomctl new job <name> [--cron]` 生成 `backend/app/jobs/*.go`。
|
||||
|
||||
- MUST:任务入队(调用 `job.Add(...)` / `client.Insert(...)`)只能在 `service` / `controller` / `event` 层编写;其它位置(例如 `middlewares` / `database` / `models` / `providers` / `jobs` 的 worker 实现等)禁止写入任务,避免耦合与隐式副作用。
|
||||
|
||||
### Job(一次性任务)
|
||||
|
||||
- `Kind() string`:任务类型标识(job kind);改名会导致“新旧任务类型不一致”。
|
||||
- `InsertOpts() river.InsertOpts`:默认入队参数(队列、优先级、最大重试、唯一任务策略等)。
|
||||
- `UniqueID() string`(项目约定):周期任务 handle 的稳定 key;通常 `return Kind()`。
|
||||
|
||||
### Worker(执行器)
|
||||
|
||||
- `Work(ctx, job)`:执行入口;返回 `nil` 成功;返回 `error` 失败并按 River 策略重试。
|
||||
- `river.JobSnooze(d)`:延后再跑一次,且 **不递增 attempt**;适合等待外部依赖就绪/限流等。
|
||||
- `river.JobCancel(err)`:永久取消并记录原因;适合业务上永远不可能成功的情况(参数非法/语义过期等)。
|
||||
- `NextRetry(job)`(可选):自定义该任务类型的重试节奏。
|
||||
|
||||
### CronJob(周期任务)
|
||||
|
||||
- `Prepare() error`:注册周期任务前做初始化/校验(避免重活/长阻塞)。
|
||||
- `Args() []contracts.CronJobArg`:声明周期任务(间隔、是否启动即跑、入队的 JobArgs)。
|
||||
|
||||
### 业务侧如何入队
|
||||
|
||||
- 在业务结构体中注入 `*job.Job`(见 `backend/providers/job`),然后调用 `obj.job.Add(jobs.XXXJob{...})` 入队。
|
||||
|
||||
---
|
||||
|
||||
## Events(Watermill)
|
||||
|
||||
本项目使用 `ThreeDotsLabs/watermill` 做事件驱动,并通过框架封装在 `backend/providers/event/` 中(支持 `Go`/`Kafka`/`Redis`/`Sql` 等 channel)。
|
||||
|
||||
- MUST:事件发布(调用 `PubSub.Publish(...)` 等)只能在 `service` / `controller` / `event` 层编写;其它位置(例如 `middlewares` / `database` / `models` / `providers` 等)禁止发布事件,避免耦合与隐式副作用。
|
||||
- MUST:事件订阅处理(subscriber handler)保持“薄”:只做反序列化/幂等与边界校验 → 调用 `services.*` 完成业务。
|
||||
|
||||
### 生成与结构
|
||||
|
||||
- 新增事件:`atomctl new event <Name>`
|
||||
- 会在 `backend/app/events/topics.go` 中新增 topic 常量(形如 `event:<snake_case>`)。
|
||||
- 会生成:
|
||||
- `backend/app/events/publishers/<snake_case>.go`(publisher:实现 `contracts.EventPublisher`,负责 `Marshal()` + `Topic()`)
|
||||
- `backend/app/events/subscribers/<snake_case>.go`(subscriber:实现 `contracts.EventHandler`,负责 `Topic()` + `Handler(...)`)
|
||||
- 生成后:按项目约定运行一次 `atomctl gen provider`(用于刷新 DI/provider 生成文件)。
|
||||
|
||||
### Topic 约定
|
||||
|
||||
- 统一在 `backend/app/events/topics.go` 维护 topic 常量,避免散落在各处形成“字符串协议”。
|
||||
- topic 字符串建议使用稳定前缀(例如 `event:`),并使用 `snake_case` 命名。
|
||||
|
||||
### 2.2 Enum strategy
|
||||
|
||||
- DO NOT use native DB ENUM.
|
||||
@@ -230,14 +283,23 @@ Common types:
|
||||
|
||||
### 2.5 一个字段多种结构(判别联合)
|
||||
|
||||
- 当同一个 `jsonb` 字段存在多种不同结构(例如订单快照:充值 vs 购买),不要让字段类型漂移为 `any/map`。
|
||||
- 当同一个 `jsonb` 字段存在多种不同结构(同一字段承载多个 payload),不要让字段类型漂移为 `any/map`。
|
||||
- 推荐统一包裹为“判别联合”结构:`type Xxx struct { Kind string; Data json.RawMessage }`,并将该字段映射为 `types.JSONType[fields.Xxx]`。
|
||||
- 写入时:
|
||||
- `Kind` 建议与业务枚举对齐(例如订单类型),便于 SQL/报表按 `kind` 过滤。
|
||||
- `Kind` 建议与业务枚举/事件类型对齐,便于 SQL/报表按 `kind` 过滤。
|
||||
- `Data` 写入对应 payload 的 JSON(payload 可以是多个不同 struct)。
|
||||
- 读取时:
|
||||
- 先 `snap := model.Snapshot.Data()`,再 `switch snap.Kind` 选择对应 payload 结构去 `json.Unmarshal(snap.Data, &payload)`。
|
||||
- 兼容历史数据(旧 JSON 没有 kind/data)时,`UnmarshalJSON` 可以将其标记为 `legacy` 并把原始 JSON 放入 `Data`,避免线上存量读取失败。
|
||||
|
||||
---
|
||||
|
||||
## 4) 审计与幂等(通用)
|
||||
|
||||
- 若你为任意表新增结构化审计字段(例如 `operator_user_id`、`biz_ref_type/biz_ref_id`),服务层写入必须同步补齐(避免只写 remark/JSON 导致追溯困难)。
|
||||
- 注意:PostgreSQL 的可空列在本项目的 gen model 中可能会生成非指针类型(例如 `string/int64`),这会导致“未赋值”落库为 `''/0`:
|
||||
- 若你要为 `(biz_ref_type,biz_ref_id,...)` 建唯一索引,**不要**只写 `IS NOT NULL` 条件;
|
||||
- 应额外排除空/0(例如 `biz_ref_type <> '' AND biz_ref_id <> 0`),否则会因默认值冲突导致大量写入失败。
|
||||
- Array: `types.Array[T]`
|
||||
- UUID: `types.UUID`, `types.BinUUID`
|
||||
- Date/Time: `types.Date`, `types.Time`
|
||||
|
||||
Reference in New Issue
Block a user