Compare commits
5 Commits
4c393bb427
...
Iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
| c8052df8eb | |||
| 586a1cccd5 | |||
| b5285877bf | |||
| cbadf77517 | |||
| 6883b5b42b |
@@ -240,6 +240,7 @@ export JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_WORKER_ROLE='all'
|
||||
export JUNHONG_WORKER_INSTANCE_NAME='worker-all-1'
|
||||
export JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED='false'
|
||||
export JUNHONG_WECOM_BASE_URL='https://qyapi.weixin.qq.com'
|
||||
export JUNHONG_WECOM_TIMEOUT='10s'
|
||||
export JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED='true'
|
||||
|
||||
@@ -45,7 +45,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/api ./cmd/api
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
|
||||
```
|
||||
|
||||
本次迭代更新只上传 API 与 Worker 二进制;配置变化由维护者直接修改各运行目录的 `.env.prod`。生产迁移文件必须随本次发布上传到 API 目录下的 `migrations/`,供 `migrate` 记录和执行。生产服务器当前尚未安装 `migrate`;安装路径和版本待首次迁移前确认。
|
||||
本次迭代更新上传 API 与 Worker 二进制;配置变化由维护者直接修改各运行目录的 `.env.prod`。生产迁移文件必须随本次发布上传到 API 目录下的 `migrations/`,并同步上传当前分支的 `scripts/migrate.sh`,供 `migrate` 记录和执行。生产服务器当前尚未安装 `migrate`;安装路径和版本待首次迁移前确认。
|
||||
|
||||
## 配置规则
|
||||
|
||||
@@ -60,7 +60,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
|
||||
|
||||
已确认顺序:备份二进制和数据库 → 上传二进制及迁移文件 → 停止 systemd 服务 → 执行迁移 → 启动服务。
|
||||
|
||||
生产当前迁移版本已于 2026-08-13 由维护者在 API 目录验证为 `140`(非 dirty)。历史迁移已归档,生产从 `140` 向后执行根目录 `migrations/` 的 `141+` 迁移;七月分支最终目标为 `206`。根目录不存在 `173` 号迁移,这是正常编号空档。执行前先以显式 `DB_*` 参数运行:
|
||||
生产当前迁移版本已于 2026-08-13 由维护者在 API 目录验证为 `140`(非 dirty)。历史迁移已归档,生产从 `140` 向后执行根目录 `migrations/` 的 `141+` 迁移;七月分支最终目标为 `209`。根目录不存在 `173` 号迁移,这是正常编号空档。执行前先以显式 `DB_*` 参数运行:
|
||||
|
||||
```bash
|
||||
DB_HOST=<生产主机> DB_PORT=<端口> DB_USER=<用户> \
|
||||
|
||||
@@ -117,7 +117,7 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
|
||||
logger.Warn("扫描待计算订单失败", zap.Error(err))
|
||||
} else {
|
||||
for _, order := range orders {
|
||||
recoverOne(ctx, db, repository, EventCommissionCalculate, order.ID, order.ID, &orderStats, logger)
|
||||
recoverOne(ctx, db, repository, EventCommissionCalculate, order.ID, order.ID, &orderStats, logger, false)
|
||||
}
|
||||
}
|
||||
var refunds []model.RefundRequest
|
||||
@@ -126,10 +126,10 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
|
||||
} else {
|
||||
for _, refund := range refunds {
|
||||
if !refund.CommissionDeducted {
|
||||
recoverOne(ctx, db, repository, EventRefundCommissionDeduct, refund.ID, refund.OrderID, &refundStats, logger)
|
||||
recoverOne(ctx, db, repository, EventRefundCommissionDeduct, refund.ID, refund.OrderID, &refundStats, logger, true)
|
||||
}
|
||||
if !refund.AssetReset {
|
||||
recoverOne(ctx, db, repository, EventRefundAssetProcess, refund.ID, refund.OrderID, &refundStats, logger)
|
||||
recoverOne(ctx, db, repository, EventRefundAssetProcess, refund.ID, refund.OrderID, &refundStats, logger, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,16 +138,21 @@ func Recover(ctx context.Context, db *gorm.DB, repository *outbox.Repository, li
|
||||
zap.Int("退款已补发", refundStats.Resent), zap.Int("退款无需补发", refundStats.Unchanged), zap.Int("退款失败", refundStats.Failed))
|
||||
}
|
||||
|
||||
func recoverOne(ctx context.Context, db *gorm.DB, repository *outbox.Repository, eventType string, aggregateID, orderID uint, stats *RecoveryStats, logger *zap.Logger) {
|
||||
func recoverOne(ctx context.Context, db *gorm.DB, repository *outbox.Repository, eventType string, aggregateID, orderID uint, stats *RecoveryStats, logger *zap.Logger, retryDelivered bool) {
|
||||
eventID := outboxid.Stable(eventType+":", strconv.FormatUint(uint64(aggregateID), 10))
|
||||
var event model.OutboxEvent
|
||||
err := db.WithContext(ctx).Where("event_id = ?", eventID).First(&event).Error
|
||||
if err == nil {
|
||||
if event.Status != constants.OutboxStatusFailed {
|
||||
if event.Status == constants.OutboxStatusPending || event.Status == constants.OutboxStatusDelivering {
|
||||
stats.Unchanged++
|
||||
return
|
||||
}
|
||||
result := db.WithContext(ctx).Model(&model.OutboxEvent{}).Where("id = ? AND status = ?", event.ID, constants.OutboxStatusFailed).Updates(map[string]any{
|
||||
// 已投递只代表入队成功,不代表业务处理成功;业务幂等的退款后处理允许重投。
|
||||
if event.Status == constants.OutboxStatusDelivered && !retryDelivered {
|
||||
stats.Unchanged++
|
||||
return
|
||||
}
|
||||
result := db.WithContext(ctx).Model(&model.OutboxEvent{}).Where("id = ? AND status = ?", event.ID, event.Status).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "retry_count": 0, "next_attempt_at": time.Now().UTC(),
|
||||
"last_error_code": "", "last_error_summary": "", "updated_at": time.Now().UTC(),
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ type PackageUsage struct {
|
||||
HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"`
|
||||
PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:'';comment:套餐名称快照(从Package复制,用于历史记录展示)" json:"package_name"`
|
||||
PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照(分,从订单 actual_paid_amount 复制,无订单或线下支付时为null)" json:"paid_amount,omitempty"`
|
||||
PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买成本价快照(分,从订单 seller_cost_price 复制,无订单或线下支付时为 null)" json:"paid_amount,omitempty"`
|
||||
RetailAmount *int64 `gorm:"column:retail_amount;type:bigint;comment:购买零售价快照(分,从订单 total_amount 复制,无订单关联时为null)" json:"retail_amount,omitempty"`
|
||||
PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"`
|
||||
PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -55,17 +56,17 @@ func Load(ctx context.Context, db *gorm.DB, sources ...Source) (Info, error) {
|
||||
}
|
||||
|
||||
func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *time.Location) (time.Time, bool, error) {
|
||||
var cleanedEnd *time.Time
|
||||
var cleanedEnd sql.NullTime
|
||||
if err := db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
||||
Where("source = ? AND cleaned_at IS NOT NULL", source).
|
||||
Select("MAX(range_end)").Scan(&cleanedEnd).Error; err != nil {
|
||||
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存清理边界失败")
|
||||
}
|
||||
if cleanedEnd != nil {
|
||||
return cleanedEnd.In(location), true, nil
|
||||
if cleanedEnd.Valid {
|
||||
return cleanedEnd.Time.In(location), true, nil
|
||||
}
|
||||
|
||||
var earliest *time.Time
|
||||
var earliest sql.NullTime
|
||||
table, column := "tb_audit_event", "occurred_at"
|
||||
if source == SourceIntegration {
|
||||
table, column = "tb_integration_log", "created_at"
|
||||
@@ -73,8 +74,8 @@ func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *t
|
||||
if err := db.WithContext(ctx).Table(table).Select("MIN(" + column + ")").Scan(&earliest).Error; err != nil {
|
||||
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计在线数据边界失败")
|
||||
}
|
||||
if earliest != nil {
|
||||
return earliest.In(location), false, nil
|
||||
if earliest.Valid {
|
||||
return earliest.Time.In(location), false, nil
|
||||
}
|
||||
now := time.Now().In(location)
|
||||
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), false, nil
|
||||
|
||||
@@ -2581,7 +2581,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
Priority: priority,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
@@ -2676,7 +2676,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
ActivatedAt: &activatedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &addonRetailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
|
||||
@@ -268,7 +268,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
|
||||
return err
|
||||
}
|
||||
|
||||
refund, err := s.refundStore.GetByID(ctx, id)
|
||||
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
refund, err := s.refundStore.GetByID(ctx, id)
|
||||
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||||
}
|
||||
@@ -835,8 +835,8 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) {
|
||||
}
|
||||
}
|
||||
|
||||
// deductSingleCommission 扣减单条佣金记录对应的代理钱包余额
|
||||
// 使用乐观锁扣减(允许余额为负数),并创建交易流水
|
||||
// deductSingleCommission 回扣单条佣金记录:先拒绝该店铺待审核提现释放冻结余额,
|
||||
// 再扣减佣金钱包(允许余额为负),最后失效佣金记录并创建流水。
|
||||
func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.CommissionRecord
|
||||
@@ -869,6 +869,9 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu
|
||||
First(&wallet).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败")
|
||||
}
|
||||
if err := s.rejectPendingWithdrawals(ctx, tx, &wallet, current.ShopID, refund); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||
Updates(map[string]any{"balance": gorm.Expr("balance - ?", current.Amount), "version": gorm.Expr("version + 1")})
|
||||
@@ -903,6 +906,97 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPendingWithdrawals 回扣佣金前拒绝该店铺所有待审核提现。
|
||||
// 提现冻结的是佣金余额,退款回扣优先级更高;先解冻并拒绝,避免已回扣佣金仍被提现。
|
||||
func (s *Service) rejectPendingWithdrawals(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, shopID uint, refund *model.RefundRequest) error {
|
||||
var withdrawals []model.CommissionWithdrawalRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalStatusPending).
|
||||
Find(&withdrawals).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待审核佣金提现失败")
|
||||
}
|
||||
if len(withdrawals) == 0 {
|
||||
return nil
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).First(&shop, shopID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺失败")
|
||||
}
|
||||
for i := range withdrawals {
|
||||
w := &withdrawals[i]
|
||||
before := withdrawalRejectState(w)
|
||||
if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, w.Amount); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "解冻提现冻结余额失败")
|
||||
}
|
||||
refType := constants.ReferenceTypeWithdrawal
|
||||
remark := "退款佣金回扣,自动拒绝提现"
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: wallet.ID, ShopID: shopID, UserID: refund.Creator,
|
||||
TransactionType: constants.AgentTransactionTypeRefund, Amount: w.Amount,
|
||||
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &w.ID,
|
||||
Remark: &remark, Creator: refund.Creator, ShopIDTag: shopID,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建自动拒绝提现流水失败")
|
||||
}
|
||||
now := time.Now()
|
||||
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
|
||||
Where("id = ? AND status = ?", w.ID, constants.WithdrawalStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": constants.WithdrawalStatusRejected,
|
||||
"processed_at": now,
|
||||
"reject_reason": remark,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "拒绝待审核提现失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "提现申请状态已变化")
|
||||
}
|
||||
w.Status = constants.WithdrawalStatusRejected
|
||||
w.ProcessedAt = &now
|
||||
w.RejectReason = remark
|
||||
if err := s.appendWithdrawalRejectAudit(ctx, tx, w, wallet, transaction, &shop, before); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withdrawalRejectState(w *model.CommissionWithdrawalRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"status": w.Status, "amount": w.Amount, "fee": w.Fee, "actual_amount": w.ActualAmount,
|
||||
"withdrawal_method": w.WithdrawalMethod, "processed_at": w.ProcessedAt, "reject_reason": w.RejectReason,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) appendWithdrawalRejectAudit(ctx context.Context, tx *gorm.DB, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, shop *model.Shop, before map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
|
||||
}
|
||||
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
|
||||
before, withdrawalRejectState(withdrawal))
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "退款佣金回扣自动拒绝提现"
|
||||
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
|
||||
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance - withdrawal.Amount})
|
||||
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
|
||||
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
shopResource := audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
|
||||
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(withdrawal.ID), 10) + ":refund-rejected",
|
||||
ActionCode: constants.AuditActionCommissionWithdrawalRejected, Summary: "退款佣金回扣自动拒绝提现",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: withdrawal.WithdrawalNo, Metadata: map[string]any{"amount": withdrawal.Amount, "status": withdrawal.Status},
|
||||
Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource},
|
||||
})
|
||||
}
|
||||
|
||||
// handleRefundAssetProcessing 幂等处理退款后的资产状态。
|
||||
// 包括退款套餐精准失效、尝试接续待生效主套餐和必要时停机;全部完成后才设置完成标记。
|
||||
func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) {
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *RefundStore) Create(ctx context.Context, req *model.RefundRequest) erro
|
||||
return s.db.WithContext(ctx).Create(req).Error
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 查询退款申请(含数据权限过滤)
|
||||
// GetByID 根据 ID 查询退款申请详情(含读取数据权限过滤)。
|
||||
func (s *RefundStore) GetByID(ctx context.Context, id uint) (*model.RefundRequest, error) {
|
||||
var req model.RefundRequest
|
||||
query := s.db.WithContext(ctx).
|
||||
@@ -41,7 +41,22 @@ func (s *RefundStore) GetByID(ctx context.Context, id uint) (*model.RefundReques
|
||||
Select("tb_refund_request.*, tb_shop.shop_name AS shop_name").
|
||||
Joins("LEFT JOIN tb_shop ON tb_shop.id = tb_refund_request.shop_id AND tb_shop.deleted_at IS NULL").
|
||||
Where("tb_refund_request.id = ?", id)
|
||||
query = applyRefundScope(ctx, query)
|
||||
query = applyRefundReadScope(ctx, query)
|
||||
if err := query.First(&req).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// GetByIDForOperation 根据 ID 查询退款申请(含写操作数据权限过滤)。
|
||||
func (s *RefundStore) GetByIDForOperation(ctx context.Context, id uint) (*model.RefundRequest, error) {
|
||||
var req model.RefundRequest
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&model.RefundRequest{}).
|
||||
Select("tb_refund_request.*, tb_shop.shop_name AS shop_name").
|
||||
Joins("LEFT JOIN tb_shop ON tb_shop.id = tb_refund_request.shop_id AND tb_shop.deleted_at IS NULL").
|
||||
Where("tb_refund_request.id = ?", id)
|
||||
query = applyRefundOperationScope(ctx, query)
|
||||
if err := query.First(&req).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +85,7 @@ func (s *RefundStore) List(ctx context.Context, opts *store.QueryOptions, filter
|
||||
Model(&model.RefundRequest{}).
|
||||
Select("tb_refund_request.*, tb_shop.shop_name AS shop_name").
|
||||
Joins("LEFT JOIN tb_shop ON tb_shop.id = tb_refund_request.shop_id AND tb_shop.deleted_at IS NULL")
|
||||
query = applyRefundScope(ctx, query)
|
||||
query = applyRefundReadScope(ctx, query)
|
||||
|
||||
if filters != nil {
|
||||
if filters.Status != nil {
|
||||
@@ -113,9 +128,25 @@ func (s *RefundStore) List(ctx context.Context, opts *store.QueryOptions, filter
|
||||
return requests, total, nil
|
||||
}
|
||||
|
||||
// applyRefundScope 应用退款单专属数据权限。
|
||||
// 退款申请对代理按创建人隔离,避免上级代理通过店铺层级看到下级申请。
|
||||
func applyRefundScope(ctx context.Context, query *gorm.DB) *gorm.DB {
|
||||
// applyRefundReadScope 应用退款单读取数据权限。
|
||||
// 代理仅可查看直接所属店铺的申请,不包含下级代理店铺。
|
||||
func applyRefundReadScope(ctx context.Context, query *gorm.DB) *gorm.DB {
|
||||
switch middleware.GetUserTypeFromContext(ctx) {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
return query
|
||||
case constants.UserTypeAgent:
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
if shopID == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("tb_refund_request.shop_id = ?", shopID)
|
||||
default:
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
}
|
||||
|
||||
// applyRefundOperationScope 应用退款单写操作数据权限。
|
||||
func applyRefundOperationScope(ctx context.Context, query *gorm.DB) *gorm.DB {
|
||||
switch middleware.GetUserTypeFromContext(ctx) {
|
||||
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
|
||||
return query
|
||||
|
||||
@@ -689,7 +689,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
@@ -809,7 +809,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
ExpiresAt: expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PaidAmount: &order.SellerCostPrice,
|
||||
RetailAmount: &addonRetailAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 回滚到旧的交易类型白名单(不含 commission_deduct 与 adjustment)。
|
||||
-- 注意:若已存在 commission_deduct 或 adjustment 流水,回滚会因违反检查约束而失败,属预期保护。
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
DROP CONSTRAINT chk_agent_tx_type;
|
||||
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
ADD CONSTRAINT chk_agent_tx_type
|
||||
CHECK (transaction_type IN ('recharge', 'deduct', 'refund', 'commission', 'withdrawal'));
|
||||
|
||||
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 adjustment-人工调整 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现';
|
||||
20
migrations/000207_fix_agent_wallet_tx_type_constraint.up.sql
Normal file
20
migrations/000207_fix_agent_wallet_tx_type_constraint.up.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 修复代理钱包交易类型检查约束缺少 commission_deduct 与 adjustment 的问题。
|
||||
-- 代码常量与列注释早已包含这两种类型,但 chk_agent_tx_type 从未同步,
|
||||
-- 导致写入退款佣金回扣(commission_deduct)和人工余额调整(adjustment)流水时
|
||||
-- 违反检查约束,退款佣金回扣因此持续失败、无法收回佣金。
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
DROP CONSTRAINT chk_agent_tx_type;
|
||||
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
ADD CONSTRAINT chk_agent_tx_type
|
||||
CHECK (transaction_type IN (
|
||||
'recharge',
|
||||
'deduct',
|
||||
'refund',
|
||||
'commission',
|
||||
'withdrawal',
|
||||
'commission_deduct',
|
||||
'adjustment'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 adjustment-人工调整 deduct-扣款 refund-退款 commission-分佣 commission_deduct-退款佣金回扣 withdrawal-提现';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 回滚到旧的交易类型白名单(不含 exchange)。
|
||||
-- 注意:若已存在 exchange 流水,回滚会因违反检查约束而失败,属预期保护。
|
||||
ALTER TABLE tb_asset_wallet_transaction
|
||||
DROP CONSTRAINT chk_card_tx_type;
|
||||
|
||||
ALTER TABLE tb_asset_wallet_transaction
|
||||
ADD CONSTRAINT chk_card_tx_type
|
||||
CHECK (transaction_type IN ('recharge', 'deduct', 'refund'));
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 修复资产钱包交易类型检查约束缺少 exchange 的问题。
|
||||
-- 代码常量 AssetTransactionTypeExchange 已用于换货余额迁移,
|
||||
-- 但 chk_card_tx_type 从未同步,导致写入 exchange 流水时违反检查约束。
|
||||
ALTER TABLE tb_asset_wallet_transaction
|
||||
DROP CONSTRAINT chk_card_tx_type;
|
||||
|
||||
ALTER TABLE tb_asset_wallet_transaction
|
||||
ADD CONSTRAINT chk_card_tx_type
|
||||
CHECK (transaction_type IN ('recharge', 'deduct', 'refund', 'exchange'));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 回滚:恢复佣金(commission)钱包不允许负余额的旧约束。
|
||||
-- 注意:若已存在负余额佣金数据,回滚会因违反检查约束而失败,属预期保护。
|
||||
ALTER TABLE tb_agent_wallet
|
||||
DROP CONSTRAINT chk_agent_wallet_available_balance;
|
||||
|
||||
ALTER TABLE tb_agent_wallet
|
||||
ADD CONSTRAINT chk_agent_wallet_available_balance
|
||||
CHECK (
|
||||
(wallet_type = 'main' AND (balance::numeric - frozen_balance::numeric +
|
||||
CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0::numeric END) >= 0::numeric)
|
||||
OR
|
||||
(wallet_type = 'commission' AND balance >= 0 AND frozen_balance <= balance)
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 允许佣金(commission)钱包出现负余额:退款佣金回扣优先于提现,
|
||||
-- 回扣后店铺佣金可能倒欠平台,负余额由回扣流程显式允许。
|
||||
-- 提现冻结仍受 frozen_balance <= GREATEST(balance, 0) 约束:负余额时不允许冻结新提现。
|
||||
ALTER TABLE tb_agent_wallet
|
||||
DROP CONSTRAINT chk_agent_wallet_available_balance;
|
||||
|
||||
ALTER TABLE tb_agent_wallet
|
||||
ADD CONSTRAINT chk_agent_wallet_available_balance
|
||||
CHECK (
|
||||
(wallet_type = 'main' AND (balance::numeric - frozen_balance::numeric +
|
||||
CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0::numeric END) >= 0::numeric)
|
||||
OR
|
||||
(wallet_type = 'commission' AND frozen_balance <= GREATEST(balance, 0))
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
## 1. 请求契约与筛选实现
|
||||
|
||||
- [x] 1.1 在资金概况列表请求 DTO 中增加可选正整数 `shop_id` 查询字段及中文 OpenAPI 描述。
|
||||
- [x] 1.2 在资金概况 Handler 的 HTTP 边界拒绝显式零值,并保持解析失败返回统一参数错误。
|
||||
- [x] 1.3 在既有店铺数据权限查询上叠加 `shop_id` 主键精确条件,使其与名称和用户名条件取交集。
|
||||
|
||||
## 2. 文档与验证
|
||||
|
||||
- [ ] 2.1 运行 `gofmt` 并重新生成 OpenAPI,核对资金概况接口包含 `shop_id` 正整数查询参数。
|
||||
- [ ] 2.2 运行 `go build ./cmd/api ./cmd/worker`、`openspec doctor --json`、`openspec validate --all` 和 `./scripts/context-health.sh`。
|
||||
- [ ] 2.3 在隔离环境 smoke 验证可见店铺精确命中、越权或不存在返回空分页、与其他条件取交集、缺省保持兼容,以及零/负数/非数字返回参数错误;若隔离环境不可用则如实记录未验证项。
|
||||
@@ -0,0 +1,11 @@
|
||||
## 1. 请求契约与筛选实现
|
||||
|
||||
- [x] 1.1 在资金概况列表请求 DTO 中增加可选正整数 `shop_id` 查询字段及中文 OpenAPI 描述。
|
||||
- [x] 1.2 在资金概况 Handler 的 HTTP 边界拒绝显式零值,并保持解析失败返回统一参数错误。
|
||||
- [x] 1.3 在既有店铺数据权限查询上叠加 `shop_id` 主键精确条件,使其与名称和用户名条件取交集。
|
||||
|
||||
## 2. 文档与验证
|
||||
|
||||
- [x] 2.1 运行 `gofmt` 并重新生成 OpenAPI,核对资金概况接口包含 `shop_id` 正整数查询参数。(gofmt 无差异;`docs/admin-openapi.yaml` 的 fund-summary 接口已含 `shop_id` 正整数查询参数)
|
||||
- [x] 2.2 运行 `go build ./cmd/api ./cmd/worker`、`openspec doctor --json`、`openspec validate --all` 和 `./scripts/context-health.sh`。(build 通过;doctor healthy;validate 全通过;context-health 因已存在的 `.scratch` 目录报「禁止目录或文件仍存在」)
|
||||
- [x] 2.3 在隔离环境 smoke 验证可见店铺精确命中、越权或不存在返回空分页、与其他条件取交集、缺省保持兼容,以及零/负数/非数字返回参数错误;若隔离环境不可用则如实记录未验证项。(隔离环境不可用:PostgreSQL 5432 与 Redis 6379 均未运行,如实记录为未验证)
|
||||
@@ -0,0 +1,48 @@
|
||||
## Context
|
||||
|
||||
套餐使用记录 `tb_package_usage` 已存在两个价格快照字段:`paid_amount`(迁移 000129 引入,语义演进为成本价,见迁移 000150「paid_amount 继续存储成本价」)与 `retail_amount`(迁移 000150 引入,存储零售价)。`retail_amount` 的写入来源已经是 `order.TotalAmount`,语义正确;但 `paid_amount` 的 4 个写入点仍从 `order.ActualPaidAmount` 复制,与成本价语义不符。参见 proposal.md - Why。
|
||||
|
||||
`order.SellerCostPrice`(`int64`,非指针)已在订单各购买路径被正确填充为「销售成本价」:代理自购/代购为操作方成本价,平台代扣为目标店铺成本价,平台代购为买家成本价,个人客户下单为卖家店铺成本价,赠送/平台自营为 0。该字段即「成本价」的正确来源。
|
||||
|
||||
### 语义演进证据(git 历史)
|
||||
|
||||
`paid_amount` 当初回填 `actual_paid_amount`(实付金额)不是笔误,而是语义中途漂移留下的不一致:
|
||||
|
||||
1. **迁移 000129(2026-04-18,commit `2b3a9cb`,change `add-paid-amount-snapshot-to-package-usage`)**:当时需求即「快照购买时的实付金额」,proposal 原文「用于快照购买时的实付金额」「无法在不 JOIN 订单表的情况下展示历史购买价格」。因此回填 `actual_paid_amount` 在当时语义下是正确实现。
|
||||
2. **迁移 000150(2026-06-01,commit `944526d`,change `fix-order-price-semantics`)**:该 change 将 `Order.ActualPaidAmount` 重新定义为「实际支付成本价」,并声明 `PaidAmount` 继续快照成本价、仍取 `order.ActualPaidAmount`。但其 spec 仅覆盖三个场景(代理钱包支付、平台代购、赠送),三者恰好都满足「实付金额 = 成本价」,唯独遗漏「个人客户购买」场景,导致该假设未被验证。
|
||||
3. **本次修复的代码事实**:个人客户下单(`client_order`)时 `SellerCostPrice` 取店铺成本价;而第三方支付回调(`HandlePaymentCallback` / 钱包支付 `payOrderByWallet`)把 `ActualPaidAmount` 写成实付金额=零售价。因此个人客户与平台自营 offline 场景下 `ActualPaidAmount ≠ SellerCostPrice`,`paid_amount` 取前者即错误地快照了零售价。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 统一 4 个 `PackageUsage` 创建点的 `PaidAmount` 快照来源为 `order.SellerCostPrice`。
|
||||
- 修正 `model.PackageUsage.PaidAmount` 的字段注释以反映成本价语义。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不回填历史已写入错误的存量 `paid_amount`(用户明确要求历史数据保持原样)。
|
||||
- 不新增迁移、不改 `retail_amount` 语义、不改资产套餐接口的按角色过滤逻辑。
|
||||
- 不拆分订单级金额到逐套餐明细(多套餐订单的逐条成本拆分是既有话题,不在本变更范围)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 以 `order.SellerCostPrice` 作为 `PaidAmount` 的唯一来源
|
||||
|
||||
`paid_amount` 语义为「成本价」,而 `seller_cost_price` 在所有购买路径中都被填充为卖家向平台结算的成本价。选择直接取 `order.SellerCostPrice`,而非按 `buyer_type`/`purchase_role` 分支计算,因为分支计算需要重述订单价格逻辑,且 `seller_cost_price` 已是这些路径各自算好的结论值。
|
||||
|
||||
备选方案(已排除):
|
||||
- 继续用 `actual_paid_amount` 并按 `buyer_type = personal` 特判改用 `seller_cost_price`:引入与订单侧重复的分支判断,且「平台自营 offline」场景(`actual_paid_amount` 回退为零售价、`seller_cost_price = 0`)仍会错,覆盖不全。
|
||||
- 在 `PackageUsage` 新增独立 `seller_cost_price` 快照字段并保留 `paid_amount` 原义:需要新迁移,且 `paid_amount` 字段语义已被迁移 000150 与 DTO 定义为成本价,重复字段造成语义分裂。
|
||||
|
||||
### 4 个写入点统一取址赋值
|
||||
|
||||
`SellerCostPrice` 为 `int64`,`PaidAmount` 为 `*int64`,统一写 `PaidAmount: &order.SellerCostPrice`。4 个写入点必须同步修改,避免后台购买与自动购包两条链路继续产生不一致快照。
|
||||
|
||||
### 不写数据回填迁移
|
||||
|
||||
用户明确接受历史错误数据。保留迁移目录现状,不新增成对迁移;后续新产生的记录由修正后的代码正确写入。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [历史错误 `paid_amount` 仍显示错误成本价] → 接受现状,仅修正新写入记录;如后续需要可另行发起数据修复。
|
||||
- [多套餐订单的 `seller_cost_price` 是订单级合计] → 与现有 `paid_amount`/`retail_amount` 同为订单级快照,保持一致;逐套餐拆分不在本变更范围。
|
||||
- [`SellerCostPrice` 在个别历史订单可能为 0 或不准确] → 本变更只改新写入逻辑,不触碰历史订单;新订单各路径均已填充该字段。
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
平台/管理员查看资产套餐列表时,`paid_amount`(成本价)字段取值错误。该字段在创建套餐使用记录时从订单 `actual_paid_amount`(实付金额)复制;个人客户(C 端)下单时实付金额等于零售价而非店铺成本价 `seller_cost_price`,导致平台视角「成本价」与「零售价」显示成同一个值(如成本 109 元的套餐被显示为成本价 159 元)。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将 4 个 `PackageUsage` 创建点的 `PaidAmount` 快照来源从 `order.ActualPaidAmount` 改为 `order.SellerCostPrice`(销售成本价)。
|
||||
- 同步更新 `model.PackageUsage.PaidAmount` 字段注释,反映成本价语义(来源 `seller_cost_price`)。
|
||||
- 不做历史数据回填:已写入错误的存量 `paid_amount` 保持原样。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- 无 -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `package-lifecycle`: 套餐使用记录 `paid_amount`(成本价)快照的取值来源从订单实付金额修正为订单销售成本价。
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/service/order/service.go`:`activateMainPackage`、`activateAddonPackage` 两处 `PaidAmount` 赋值。
|
||||
- `internal/task/auto_purchase.go`:自动购包主套餐、加油包两处 `PaidAmount` 赋值。
|
||||
- `internal/model/package.go`:`PackageUsage.PaidAmount` 字段注释。
|
||||
- API 形状不变(仍返回 `paid_amount` / `retail_amount`),仅 `paid_amount` 取值语义变化;不新增迁移、不新增外部依赖。
|
||||
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐使用记录价格快照
|
||||
|
||||
系统 SHALL 在创建套餐使用记录时分别快照套餐成本价与零售价:`paid_amount`(成本价)SHALL 取订单 `seller_cost_price`(销售成本价,即卖家店铺向平台结算的成本),`retail_amount`(零售价)SHALL 取订单 `total_amount`(零售总价)。成本价与实付金额在个人客户场景下不相等时,`paid_amount` MUST 使用成本价而非实付金额。
|
||||
|
||||
#### Scenario: 个人客户购买时快照成本价与零售价
|
||||
|
||||
- **WHEN** 个人客户为资产购买套餐,店铺成本价 10900 分,零售价 15900 分,客户实付 15900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 10900`,`retail_amount = 15900`
|
||||
|
||||
#### Scenario: 代理钱包自购时快照成本价与零售价
|
||||
|
||||
- **WHEN** 代理以钱包支付为自有资产购买套餐,成本价 7000 分,零售价 9900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 7000`,`retail_amount = 9900`
|
||||
|
||||
#### Scenario: 赠送套餐时快照零成本价与零售价
|
||||
|
||||
- **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 0`,`retail_amount = 9900`
|
||||
@@ -0,0 +1,17 @@
|
||||
## 1. 修正套餐使用记录成本价快照来源
|
||||
|
||||
- [x] 1.1 `internal/service/order/service.go` 中 `activateMainPackage` 的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.2 `internal/service/order/service.go` 中 `activateAddonPackage` 的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.3 `internal/task/auto_purchase.go` 中自动购包主套餐的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
- [x] 1.4 `internal/task/auto_purchase.go` 中自动购包加油包的 `PaidAmount: order.ActualPaidAmount` 改为 `PaidAmount: &order.SellerCostPrice`
|
||||
|
||||
## 2. 同步字段注释
|
||||
|
||||
- [x] 2.1 `internal/model/package.go` 中 `PackageUsage.PaidAmount` 的 gorm 注释由「从订单 actual_paid_amount 复制」改为「购买成本价快照(分,从订单 seller_cost_price 复制,无订单或线下支付时为 null)」
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [x] 3.1 `gofmt -w` 修改过的 Go 文件
|
||||
- [x] 3.2 `go build ./cmd/api ./cmd/worker` 通过
|
||||
- [x] 3.3 `openspec validate --all` 通过
|
||||
- [x] 3.4 人工验证:个人客户为设备购买套餐后,平台账号查询资产套餐列表时 `paid_amount` 等于店铺成本价、`retail_amount` 等于零售价
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-13
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-18
|
||||
@@ -0,0 +1,31 @@
|
||||
## Context
|
||||
|
||||
退款列表和详情当前共用按创建人过滤的数据访问方法。详情读取还被退款审批、退回和重新提交等写操作用于加载退款申请,因此直接把该方法改为按店铺过滤,会扩大既有写操作的对象范围。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 让代理仅在退款列表和详情读取时按当前登录账号的直接所属店铺查看退款。
|
||||
- 保持下级代理店铺不可见,并保持退款写操作的既有创建人权限边界。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不改变平台和超级管理员的可见范围。
|
||||
- 不改变代理创建退款、审批、退回、拒绝或重新提交的授权规则。
|
||||
- 不补齐历史退款中缺失的店铺归属数据。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 读取路径以认证上下文中的直接 `ShopID` 与退款申请的 `ShopID` 精确匹配,不使用包含下级店铺的 `SubordinateShopIDs`。这是账号所属店铺的稳定事实,且能直接满足排除下级代理的要求。
|
||||
- 为列表与详情读取使用店铺可见范围;写操作继续通过创建人范围加载退款申请。读取与写入分别显式选择权限范围,避免共享查询方法导致查看权限扩展为操作权限。
|
||||
- 代理上下文没有有效 `ShopID` 时施加恒假条件,不回退至未过滤查询。备选方案是返回参数错误;选择空结果/不存在以与现有未授权对象查询行为一致,并避免暴露数据存在性。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [历史退款的 `shop_id` 为空] → 该类记录不会对代理可见,仍由平台和超级管理员查询;不在本次改动中推断或回填归属。
|
||||
- [读取与写入使用不同权限方法] → 在方法名称和调用处明确区分读取可见性与操作授权,并在实现后逐一检查退款服务中全部按 ID 加载的调用者。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 发布应用代码;无需数据迁移。
|
||||
2. 使用同店铺不同账号、下级店铺和未绑定店铺账号分别验证列表及详情。
|
||||
3. 如需回滚,恢复代理退款读取的创建人过滤;数据库数据不受影响。
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
代理账号当前仅能按退款申请创建人查看退款,导致同一店铺的其他账号创建退款后,本店代理无法在退款管理中查询或查看详情。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将代理账号的退款列表和详情可见范围从“本人创建”调整为“与当前代理同一店铺”。
|
||||
- 同店铺内其他账号创建的退款申请对该店铺代理可见。
|
||||
- 代理账号仍不得因本次调整看到下级代理店铺的退款申请;平台和超级管理员既有全量可见范围不变。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `order-refund-exchange`: 调整代理账号查询退款列表和详情时的数据可见范围。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `GET /api/admin/refunds` 与 `GET /api/admin/refunds/{id}` 的代理数据权限过滤。
|
||||
- 预计修改退款数据访问层及其调用所依赖的当前账号店铺范围获取逻辑;不新增接口、依赖或数据库结构。
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理退款查询按所属店铺隔离
|
||||
系统 SHALL 允许代理账号通过 `GET /api/admin/refunds` 查询其当前所属店铺的全部退款申请,并通过 `GET /api/admin/refunds/{id}` 查询其中任一申请详情,不以申请创建账号作为查询条件。该范围 SHALL 不包含下级代理店铺、其他店铺或未关联店铺的退款申请;代理账号未关联店铺时,列表 SHALL 为空且详情 SHALL 返回不存在。平台和超级管理员的既有退款查询范围 SHALL 保持不变。
|
||||
|
||||
#### Scenario: 查看同店铺其他账号提交的退款
|
||||
- **GIVEN** 当前代理所属店铺存在由另一账号创建的退款申请
|
||||
- **WHEN** 该代理查询退款列表或该申请详情
|
||||
- **THEN** 系统返回该退款申请
|
||||
|
||||
#### Scenario: 查询下级代理店铺的退款
|
||||
- **GIVEN** 当前代理的下级代理店铺存在退款申请
|
||||
- **WHEN** 当前代理查询退款列表或该申请详情
|
||||
- **THEN** 系统不返回该退款申请,详情查询返回不存在
|
||||
|
||||
#### Scenario: 未绑定店铺的代理查询退款
|
||||
- **GIVEN** 当前代理账号未关联店铺
|
||||
- **WHEN** 该代理查询退款列表或退款申请详情
|
||||
- **THEN** 系统返回空列表或不存在,且不泄露任何退款申请
|
||||
@@ -0,0 +1,10 @@
|
||||
## 1. 退款读取权限
|
||||
|
||||
- [x] 1.1 在退款数据访问层区分读取可见范围与写操作创建人范围:代理读取按当前直接所属店铺精确过滤,未关联店铺时拒绝返回数据。
|
||||
- [x] 1.2 将退款列表和详情读取接入店铺可见范围,并逐一核对退款服务所有按 ID 加载的写操作仍使用既有创建人范围。
|
||||
|
||||
## 2. 验证
|
||||
|
||||
- [x] 2.1 执行 gofmt,并构建 `./cmd/api` 和 `./cmd/worker`,确认修改可编译。
|
||||
- [x] 2.2 以同店铺其他账号、下级代理店铺和未绑定店铺的代理身份,分别验证退款列表和详情的可见性(按用户决定跳过隔离环境人工验证)。
|
||||
- [x] 2.3 执行 `openspec validate agent-refund-shop-visibility --strict`,确认变更工件有效。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-18
|
||||
45
openspec/changes/fix-audit-retention-null-boundary/design.md
Normal file
45
openspec/changes/fix-audit-retention-null-boundary/design.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Context
|
||||
|
||||
`internal/query/retention` 以聚合查询计算审计与外部交互日志的在线留存边界。PostgreSQL 在没有匹配行时会为 `MAX` 和 `MIN` 返回 `NULL`,而当前扫描目标不能接收空值,导致所有依赖该边界的审计调查接口失败。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 将空聚合结果识别为“尚无边界”,而非数据库错误。
|
||||
- 保持已有已清理边界、最早在线记录和当月兜底的语义。
|
||||
- 修复所有通过同一留存边界查询函数进入的审计接口。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不执行审计物理清理,不改变归档或保留策略。
|
||||
- 不修改数据库结构、迁移记录或历史审计数据。
|
||||
- 不修改 API 路由、权限或响应字段。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 使用可空时间承接 SQL 聚合结果
|
||||
|
||||
留存边界查询使用标准库可空时间值承接 `MAX(range_end)` 与 `MIN(<time-column>)`。仅在值有效时转换为目标时区并作为边界返回。
|
||||
|
||||
拒绝将聚合结果用当前时间或固定时间 SQL `COALESCE`:这会把“尚未清理”误判为已归档,改变响应中的留存语义。
|
||||
|
||||
### 同时覆盖已清理和最早在线两个聚合路径
|
||||
|
||||
`MAX(range_end)` 是本次线上错误入口;`MIN` 在在线表为空时具有相同的空值扫描风险。两个路径共用相同的可空聚合边界,应一次修复。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [风险] 空边界被误判为已清理,错误拒绝历史查询 → 仅在聚合值有效时设置已清理标记与归档边界。
|
||||
- [风险] 修复遗漏其他审计接口 → 保持修改在所有审计查询共用的留存边界函数内。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 修改留存边界的空时间扫描逻辑。
|
||||
2. 格式化并构建 API。
|
||||
3. 仅替换 API 二进制并重启 API Unit。
|
||||
4. 以 `GET /api/admin/audit/events?page=1&page_size=20` 验证无已清理记录时接口不再返回 500。
|
||||
|
||||
### Rollback
|
||||
|
||||
无数据库迁移。若 API 启动或查询异常,覆盖回本次发布前 API 二进制并重启 API Unit。
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
生产环境尚未执行审计物理清理时,留存边界查询中的 `MAX(range_end)` 返回 SQL `NULL`,当前实现无法扫描该值,导致 `GET /api/admin/audit/events` 等审计查询返回服务端错误。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将审计及外部交互日志留存边界的可空聚合结果作为可空时间处理。
|
||||
- 没有完成物理清理记录时,审计查询继续返回在线数据,且 `archived_before` 保持为空。
|
||||
- 没有在线审计或集成交互数据时,保留现有的当月起始时间兜底边界。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `operations-audit`: 审计调查在尚无已完成物理清理记录时仍可查询在线审计事实。
|
||||
|
||||
## Impact
|
||||
|
||||
- 代码:`internal/query/retention/retention.go`。
|
||||
- API:修复全局审计事件、时间线、资源活动、风险和资金审计等依赖留存边界的既有查询。
|
||||
- 数据库:无迁移、无数据修复。
|
||||
- 部署:仅需重新构建和发布 API 二进制。
|
||||
@@ -0,0 +1,49 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 审计时间线
|
||||
|
||||
系统 SHALL 支持按事件、操作者、资源、请求、关联标识和资金维度查询已记录的审计事实。新建的 `tb_integration_log` 在已取得稳定审计事件时 SHALL 写入其内部 ID 作为 `audit_event_id`,并且该日志的非空 `integration_id` SHALL 出现在对应事件列表和事件详情的 `investigation_refs.integration_refs` 中;关联生成失败时系统 MUST 保留 Integration Log 并记录可排查告警,且 MUST NOT 按名称、时间或摘要推断关联。历史 Integration Log 不在本要求的回填范围内。审计事件的构造、校验或持久化失败 MUST 记录可关联的结构化错误日志和二次失败记录,且 MUST NOT 改变已通过业务校验的业务操作结果或接口响应。尚无完成物理清理记录时,审计调查接口 MUST 继续查询在线审计事实,且返回的留存边界不得将数据标记为已归档。
|
||||
|
||||
#### Scenario: 审计时间线
|
||||
|
||||
- **GIVEN** 审计事实已存在
|
||||
- **WHEN** 使用对应维度查询
|
||||
- **THEN** 返回匹配的事实与稳定动作编码,不用访问日志替代
|
||||
|
||||
#### Scenario: 事件返回已关联外部交互引用
|
||||
|
||||
- **GIVEN** 一个在线审计事件有 `tb_integration_log.audit_event_id` 指向其内部 ID 的外部交互记录
|
||||
- **WHEN** 查询全局审计事件列表或该事件详情
|
||||
- **THEN** 该事件的 `investigation_refs.integration_refs` 返回该记录的 `integration_id`
|
||||
|
||||
#### Scenario: 事件没有关联外部交互引用
|
||||
|
||||
- **GIVEN** 一个在线审计事件没有稳定关联的外部交互记录
|
||||
- **WHEN** 查询全局审计事件列表或该事件详情
|
||||
- **THEN** `investigation_refs.integration_refs` 返回空数组
|
||||
|
||||
#### Scenario: 新外部交互日志具有稳定审计关联
|
||||
|
||||
- **GIVEN** 系统即将记录一次新的外部调用、入站回调或未发送裁决
|
||||
- **WHEN** 写入对应 Integration Log
|
||||
- **THEN** 该记录保存非空 `audit_event_id`,且其目标 Audit Event 已存在
|
||||
|
||||
#### Scenario: 缺少审计关联时保留外部交互日志
|
||||
|
||||
- **GIVEN** 一次新的外部交互日志没有稳定审计事件关联
|
||||
- **WHEN** 系统尝试写入该 Integration Log
|
||||
- **THEN** 系统持久化该 Integration Log、记录可排查告警,并返回空 `integration_refs`
|
||||
|
||||
#### Scenario: 审计写入失败不阻断业务
|
||||
|
||||
- **GIVEN** 一个业务操作已通过自身输入、权限和状态校验
|
||||
- **WHEN** 该操作的审计事件构造、校验或持久化失败
|
||||
- **THEN** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败
|
||||
|
||||
#### Scenario: 尚无物理清理记录时查询在线审计
|
||||
|
||||
- **GIVEN** 审计或外部交互日志尚无完成物理清理的归档运行记录
|
||||
- **WHEN** 调用任一依赖留存边界的审计调查接口
|
||||
- **THEN** 系统返回当前在线数据或空列表
|
||||
- **AND** 响应中的 `archived_before` 为空
|
||||
- **AND** 系统不得因空留存边界返回服务端错误
|
||||
10
openspec/changes/fix-audit-retention-null-boundary/tasks.md
Normal file
10
openspec/changes/fix-audit-retention-null-boundary/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. 留存边界空值处理
|
||||
|
||||
- [x] 1.1 将已清理边界与最早在线记录的聚合结果改为可空时间扫描,仅在结果有效时设置对应边界。
|
||||
- [x] 1.2 保持无已清理记录时 `archived_before` 为空,以及无在线记录时按当月起始时间兜底的现有语义。
|
||||
|
||||
## 2. 验证与发布
|
||||
|
||||
- [x] 2.1 格式化修改文件并构建 API。
|
||||
- [ ] 2.2 验证未执行物理清理时 `GET /api/admin/audit/events?page=1&page_size=20` 不再返回服务端错误。
|
||||
- [ ] 2.3 仅发布并重启 API 二进制;不执行数据库迁移、不改 Worker 配置。
|
||||
@@ -73,6 +73,30 @@
|
||||
- **WHEN** 补偿流程发现已退款且 `commission_deducted=false` 的退款单
|
||||
- **THEN** 系统恢复该退款单的唯一后处理请求,并在既有回扣成功后更新其回扣完成标记
|
||||
|
||||
### Requirement: 代理商资金概况按店铺 ID 检索
|
||||
|
||||
系统 SHALL 允许通过可选的正整数 `shop_id` 查询参数精确筛选 `GET /api/admin/shops/fund-summary` 的店铺资金概况;该条件 MUST 与当前账号的店铺数据范围及其他已提供筛选条件取交集,未提供时 MUST 保持既有列表行为。
|
||||
|
||||
#### Scenario: 按可见店铺 ID 精确检索
|
||||
|
||||
- **WHEN** 当前账号请求资金概况列表并提供其数据范围内的 `shop_id`
|
||||
- **THEN** 系统仅返回该 ID 且同时满足其他已提供筛选条件的店铺资金概况
|
||||
|
||||
#### Scenario: 店铺 ID 不匹配或超出数据范围
|
||||
|
||||
- **WHEN** 当前账号提供不存在、超出其数据范围或不满足其他已提供筛选条件的 `shop_id`
|
||||
- **THEN** 系统返回成功的空分页结果且不披露该店铺是否存在
|
||||
|
||||
#### Scenario: 店铺 ID 参数无效
|
||||
|
||||
- **WHEN** 当前账号提供零、负数或无法解析为正整数的 `shop_id`
|
||||
- **THEN** 系统返回参数错误且不执行资金概况查询
|
||||
|
||||
#### Scenario: 未提供店铺 ID
|
||||
|
||||
- **WHEN** 当前账号请求资金概况列表但未提供 `shop_id`
|
||||
- **THEN** 系统继续按既有分页、数据范围、店铺名称和主账号用户名条件返回结果
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
@@ -92,30 +116,3 @@
|
||||
### 提现配置管理
|
||||
|
||||
`GET /api/admin/commission/withdrawal-settings`(提现配置列表);`POST /api/admin/commission/withdrawal-settings`(新增提现配置);`GET /api/admin/commission/withdrawal-settings/current`(获取当前生效的提现配置)。
|
||||
|
||||
### Requirement: 佣金待计算状态可恢复
|
||||
|
||||
系统 SHALL 将已支付订单的佣金待计算状态与可靠投递事实关联;投递异常不得静默遗留为无法继续处理的待计算订单。
|
||||
|
||||
#### Scenario: 佣金投递链路异常
|
||||
|
||||
- **WHEN** 佣金计算任务提交或消费链路发生可恢复异常
|
||||
- **THEN** 订单维持待计算且投递状态、重试次数和失败摘要可查询,恢复投递后按既有规则得出有佣金、无佣金或待人工修正结果
|
||||
|
||||
### Requirement: 退款佣金回扣可靠完成
|
||||
|
||||
系统 SHALL 在退款审批生效时持久化佣金回扣请求;回扣请求的投递或处理异常不得静默遗留,且退款单在全部应回扣佣金失效并完成对应钱包流水前不得标记为已回扣。
|
||||
|
||||
#### Scenario: 已退款订单佣金回扣失败后恢复
|
||||
|
||||
- **WHEN** 已退款订单的佣金回扣首次处理失败或进程中断
|
||||
- **THEN** 退款单保持佣金未回扣状态并保留可重试事实,后续成功处理后佣金记录失效、佣金钱包按既有规则扣减且退款单标记为已回扣
|
||||
|
||||
### Requirement: 退款后处理可补偿
|
||||
|
||||
系统 SHALL 对已退款但佣金未回扣或资产未完成后处理的退款单提供幂等补偿;重复补偿不得重复扣减佣金钱包、重复写回扣流水或重复处理资产。
|
||||
|
||||
#### Scenario: 遗留退款单补偿
|
||||
|
||||
- **WHEN** 补偿流程发现已退款且 `commission_deducted=false` 的退款单
|
||||
- **THEN** 系统恢复该退款单的唯一后处理请求,并在既有回扣成功后更新其回扣完成标记
|
||||
|
||||
@@ -49,6 +49,25 @@
|
||||
- **WHEN** 查询临期资产列表并执行每日临期扫描
|
||||
- **THEN** 列表返回红色等级,且扫描创建当天的套餐临期通知
|
||||
|
||||
### Requirement: 卡业务观测可靠事件标识
|
||||
|
||||
系统 SHALL 为卡与设备控制及其后续网络、流量观测生成不超过公共可靠事件存储上限的稳定事件标识,相同业务事实重试时 SHALL 保持同一标识。
|
||||
|
||||
#### Scenario: 停复机成功写入观测事件
|
||||
|
||||
- **WHEN** 卡或设备停复机的上游调用成功且本地事务记录业务结果
|
||||
- **THEN** 系统在同一事务写入合法长度的业务观测可靠事件,不因事件标识超长回滚本地结果
|
||||
|
||||
#### Scenario: 网络或流量变化写入可靠事件
|
||||
|
||||
- **WHEN** 一次具有长观测标识的观测产生网络状态变化或流量正增量
|
||||
- **THEN** 系统写入合法长度且可重复计算的可靠事件标识
|
||||
|
||||
#### Scenario: 非法可靠事件标识被边界拒绝
|
||||
|
||||
- **WHEN** 生产者向公共可靠事件存储提交超过字段上限的事件标识或父事件标识
|
||||
- **THEN** 系统在持久化边界返回明确的参数错误而不是数据库字段错误
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
@@ -16,6 +16,28 @@
|
||||
- **WHEN** 再次审批、发货、完成、取消或重新提交
|
||||
- **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态
|
||||
|
||||
### Requirement: 代理退款查询按所属店铺隔离
|
||||
|
||||
系统 SHALL 允许代理账号通过 `GET /api/admin/refunds` 查询其当前所属店铺的全部退款申请,并通过 `GET /api/admin/refunds/{id}` 查询其中任一申请详情,不以申请创建账号作为查询条件。该范围 SHALL 不包含下级代理店铺、其他店铺或未关联店铺的退款申请;代理账号未关联店铺时,列表 SHALL 为空且详情 SHALL 返回不存在。平台和超级管理员的既有退款查询范围 SHALL 保持不变。
|
||||
|
||||
#### Scenario: 查看同店铺其他账号提交的退款
|
||||
|
||||
- **GIVEN** 当前代理所属店铺存在由另一账号创建的退款申请
|
||||
- **WHEN** 该代理查询退款列表或该申请详情
|
||||
- **THEN** 系统返回该退款申请
|
||||
|
||||
#### Scenario: 查询下级代理店铺的退款
|
||||
|
||||
- **GIVEN** 当前代理的下级代理店铺存在退款申请
|
||||
- **WHEN** 当前代理查询退款列表或该申请详情
|
||||
- **THEN** 系统不返回该退款申请,详情查询返回不存在
|
||||
|
||||
#### Scenario: 未绑定店铺的代理查询退款
|
||||
|
||||
- **GIVEN** 当前代理账号未关联店铺
|
||||
- **WHEN** 该代理查询退款列表或退款申请详情
|
||||
- **THEN** 系统返回空列表或不存在,且不泄露任何退款申请
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
@@ -38,6 +38,25 @@
|
||||
- **WHEN** 操作者为已有系列授权添加套餐
|
||||
- **THEN** 系统返回同一店铺和系列的候选套餐及其授权状态,且不改变现有套餐管理提交接口的调价和删除语义
|
||||
|
||||
### Requirement: 套餐使用记录价格快照
|
||||
|
||||
系统 SHALL 在创建套餐使用记录时分别快照套餐成本价与零售价:`paid_amount`(成本价)SHALL 取订单 `seller_cost_price`(销售成本价,即卖家店铺向平台结算的成本),`retail_amount`(零售价)SHALL 取订单 `total_amount`(零售总价)。成本价与实付金额在个人客户场景下不相等时,`paid_amount` MUST 使用成本价而非实付金额。
|
||||
|
||||
#### Scenario: 个人客户购买时快照成本价与零售价
|
||||
|
||||
- **WHEN** 个人客户为资产购买套餐,店铺成本价 10900 分,零售价 15900 分,客户实付 15900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 10900`,`retail_amount = 15900`
|
||||
|
||||
#### Scenario: 代理钱包自购时快照成本价与零售价
|
||||
|
||||
- **WHEN** 代理以钱包支付为自有资产购买套餐,成本价 7000 分,零售价 9900 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 7000`,`retail_amount = 9900`
|
||||
|
||||
#### Scenario: 赠送套餐时快照零成本价与零售价
|
||||
|
||||
- **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分
|
||||
- **THEN** 创建的套餐使用记录 `paid_amount = 0`,`retail_amount = 9900`
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
Reference in New Issue
Block a user