Compare commits

..

4 Commits

Author SHA1 Message Date
201c55f055 feat: 添加事件驱动规则和生成结构说明 2025-12-22 22:35:04 +08:00
a869540d9c update llm.txt 2025-12-22 21:54:39 +08:00
5dc0f89ac0 feat: add operator and business reference fields to tenant ledgers
- Added `operator_user_id`, `biz_ref_type`, and `biz_ref_id` fields to the TenantLedger model for enhanced auditing and traceability.
- Updated the tenant ledgers query generation to include new fields.
- Introduced new API endpoint for retrieving tenant ledger records with filtering options based on the new fields.
- Enhanced Swagger documentation to reflect the new endpoint and its parameters.
- Created DTOs for admin ledger filtering and item representation.
- Implemented the admin ledger retrieval logic in the tenant service.
- Added database migration scripts to introduce new fields and indexes for efficient querying.
2025-12-22 21:35:10 +08:00
3cb2a6f586 update llm.txt 2025-12-22 21:30:17 +08:00
18 changed files with 1047 additions and 173 deletions

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

View 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)
}

View File

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

View File

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

View File

@@ -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(&current).Error; err != nil {
return err
}
out.Ledger = &existing
out.TenantUser = &current
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,8 +28,8 @@ type OrderItem struct {
Snapshot types.JSONType[fields.OrderItemsSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照JSON建议包含 title/price/discount 等,用于历史展示与审计
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now()
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now()
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

View File

@@ -35,18 +35,18 @@ func newOrderItem(db *gorm.DB, opts ...gen.DOOption) orderItemQuery {
_orderItemQuery.Snapshot = field.NewJSONB(tableName, "snapshot")
_orderItemQuery.CreatedAt = field.NewTime(tableName, "created_at")
_orderItemQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
_orderItemQuery.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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 JobsRiver
本项目使用 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{...})` 入队。
---
## EventsWatermill
本项目使用 `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 的 JSONpayload 可以是多个不同 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`