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.
This commit is contained in:
2025-12-22 21:35:10 +08:00
parent 3cb2a6f586
commit 5dc0f89ac0
17 changed files with 983 additions and 171 deletions

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
}