feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s

- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-27 16:32:58 +09:00
parent d2b0d2f534
commit 909f4d0ec2
112 changed files with 10473 additions and 82 deletions

View File

@@ -231,6 +231,12 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi
Select("device_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
if start, ok := filters["outbound_start"].(time.Time); ok {
query = query.Where("outbound_at >= ?", start)
}
if end, ok := filters["outbound_end"].(time.Time); ok {
query = query.Where("outbound_at <= ?", end)
}
if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_device_authorization").
Select("device_id").

View File

@@ -915,6 +915,12 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D
Select("card_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
if start, ok := filters["outbound_start"].(time.Time); ok {
query = query.Where("outbound_at >= ?", start)
}
if end, ok := filters["outbound_end"].(time.Time); ok {
query = query.Where("outbound_at <= ?", end)
}
if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization").
Select("card_id").

View File

@@ -0,0 +1,320 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 沉默期相关 Store(add-package-silence-period)。
// 所有 Store 只封装持久化;状态迁移一律用"带期望状态的条件更新",返回影响行数由调用方判断是否幂等命中。
// 需要事务时用 NewXxxStore(tx) 绑定事务连接。
// PackageSilencePolicyStore 延期政策 Store。
type PackageSilencePolicyStore struct{ db *gorm.DB }
// NewPackageSilencePolicyStore 创建延期政策 Store。
func NewPackageSilencePolicyStore(db *gorm.DB) *PackageSilencePolicyStore {
return &PackageSilencePolicyStore{db: db}
}
// Create 新增政策。
func (s *PackageSilencePolicyStore) Create(ctx context.Context, policy *model.PackageSilenceExtensionPolicy) error {
return s.db.WithContext(ctx).Create(policy).Error
}
// GetByID 按 ID 查询政策。
func (s *PackageSilencePolicyStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionPolicy, error) {
var policy model.PackageSilenceExtensionPolicy
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&policy).Error; err != nil {
return nil, err
}
return &policy, nil
}
// ListEnabled 返回全部启用政策(政策数量很少,匹配与重叠校验在领域层完成)。
func (s *PackageSilencePolicyStore) ListEnabled(ctx context.Context) ([]model.PackageSilenceExtensionPolicy, error) {
var policies []model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Where("status = ?", constants.SilencePolicyStatusEnabled).Order("id ASC").Find(&policies).Error
return policies, err
}
// Update 按 ID 更新政策字段。
func (s *PackageSilencePolicyStore) Update(ctx context.Context, id uint, values map[string]any) (int64, error) {
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{}).Where("id = ?", id).Updates(values)
return result.RowsAffected, result.Error
}
// LockAll 以事务级咨询锁串行化政策写入,避免并发保存绕过重叠校验。
func (s *PackageSilencePolicyStore) LockAll(ctx context.Context) error {
return s.db.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext('package_silence_policy'))").Error
}
// PackageSilenceRecordStore 沉默期流水 Store(只新增)。
type PackageSilenceRecordStore struct{ db *gorm.DB }
// NewPackageSilenceRecordStore 创建沉默期流水 Store。
func NewPackageSilenceRecordStore(db *gorm.DB) *PackageSilenceRecordStore {
return &PackageSilenceRecordStore{db: db}
}
// Create 写入一条流水。
func (s *PackageSilenceRecordStore) Create(ctx context.Context, record *model.PackageSilenceRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
// ListByUsage 按套餐记录查询流水(时间正序)。
func (s *PackageSilenceRecordStore) ListByUsage(ctx context.Context, usageID uint) ([]model.PackageSilenceRecord, error) {
var records []model.PackageSilenceRecord
err := s.db.WithContext(ctx).Where("package_usage_id = ?", usageID).Order("id ASC").Find(&records).Error
return records, err
}
// PackageSilenceBatchStore 延期批次 Store。
type PackageSilenceBatchStore struct{ db *gorm.DB }
// NewPackageSilenceBatchStore 创建延期批次 Store。
func NewPackageSilenceBatchStore(db *gorm.DB) *PackageSilenceBatchStore {
return &PackageSilenceBatchStore{db: db}
}
// Create 新增批次。
func (s *PackageSilenceBatchStore) Create(ctx context.Context, batch *model.PackageSilenceExtensionBatch) error {
return s.db.WithContext(ctx).Create(batch).Error
}
// GetByID 按 ID 查询批次。
func (s *PackageSilenceBatchStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionBatch, error) {
var batch model.PackageSilenceExtensionBatch
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&batch).Error; err != nil {
return nil, err
}
return &batch, nil
}
// LockByID 行锁查询批次。
func (s *PackageSilenceBatchStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionBatch, error) {
var batch model.PackageSilenceExtensionBatch
err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&batch).Error
if err != nil {
return nil, err
}
return &batch, nil
}
// TransitionStatus 条件更新批次状态,返回影响行数。
func (s *PackageSilenceBatchStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) {
updates := copyValues(values)
updates["status"] = to
updates["updated_at"] = time.Now()
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("id = ? AND status IN ?", id, from).Updates(updates)
return result.RowsAffected, result.Error
}
// Update 无条件更新批次字段(只用于已持锁的计数与快照写入)。
func (s *PackageSilenceBatchStore) Update(ctx context.Context, id uint, values map[string]any) error {
updates := copyValues(values)
updates["updated_at"] = time.Now()
return s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", id).Updates(updates).Error
}
// PackageSilenceExtensionStore 延期明细 Store。
type PackageSilenceExtensionStore struct{ db *gorm.DB }
// NewPackageSilenceExtensionStore 创建延期明细 Store。
func NewPackageSilenceExtensionStore(db *gorm.DB) *PackageSilenceExtensionStore {
return &PackageSilenceExtensionStore{db: db}
}
// Create 新增明细。
func (s *PackageSilenceExtensionStore) Create(ctx context.Context, extension *model.PackageSilenceExtension) error {
return s.db.WithContext(ctx).Create(extension).Error
}
// GetByID 按 ID 查询明细。
func (s *PackageSilenceExtensionStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtension, error) {
var extension model.PackageSilenceExtension
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&extension).Error; err != nil {
return nil, err
}
return &extension, nil
}
// LockByID 行锁查询明细。
func (s *PackageSilenceExtensionStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtension, error) {
var extension model.PackageSilenceExtension
err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&extension).Error
if err != nil {
return nil, err
}
return &extension, nil
}
// ListByBatch 按批次查询明细。
func (s *PackageSilenceExtensionStore) ListByBatch(ctx context.Context, batchID uint) ([]model.PackageSilenceExtension, error) {
var extensions []model.PackageSilenceExtension
err := s.db.WithContext(ctx).Where("batch_id = ?", batchID).Order("row_no ASC, id ASC").Find(&extensions).Error
return extensions, err
}
// HasOpenByUsage 判断套餐是否已有未结束明细。
func (s *PackageSilenceExtensionStore) HasOpenByUsage(ctx context.Context, usageID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.PackageSilenceExtension{}).
Where("package_usage_id = ? AND status IN ?", usageID, OpenSilenceExtensionStatuses()).Count(&count).Error
return count > 0, err
}
// TransitionStatus 条件更新明细状态,返回影响行数。
func (s *PackageSilenceExtensionStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) {
updates := copyValues(values)
updates["status"] = to
updates["updated_at"] = time.Now()
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtension{}).
Where("id = ? AND status IN ?", id, from).Updates(updates)
return result.RowsAffected, result.Error
}
// OpenSilenceExtensionStatuses 返回未结束的明细状态(与部分唯一索引一致)。
func OpenSilenceExtensionStatuses() []string {
return []string{
constants.SilenceExtensionStatusPendingPayment, constants.SilenceExtensionStatusPendingApproval,
constants.SilenceExtensionStatusApproved, constants.SilenceExtensionStatusExecuting,
constants.SilenceExtensionStatusException,
}
}
// PackageSilenceOrderStore 延期订单 Store。
type PackageSilenceOrderStore struct{ db *gorm.DB }
// NewPackageSilenceOrderStore 创建延期订单 Store。
func NewPackageSilenceOrderStore(db *gorm.DB) *PackageSilenceOrderStore {
return &PackageSilenceOrderStore{db: db}
}
// Create 新增订单。
func (s *PackageSilenceOrderStore) Create(ctx context.Context, order *model.PackageSilenceExtensionOrder) error {
return s.db.WithContext(ctx).Create(order).Error
}
// GetByID 按 ID 查询订单。
func (s *PackageSilenceOrderStore) GetByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionOrder, error) {
var order model.PackageSilenceExtensionOrder
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
// GetByOrderNo 按订单号查询订单。
func (s *PackageSilenceOrderStore) GetByOrderNo(ctx context.Context, orderNo string) (*model.PackageSilenceExtensionOrder, error) {
var order model.PackageSilenceExtensionOrder
if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
// LockByID 行锁查询订单。
func (s *PackageSilenceOrderStore) LockByID(ctx context.Context, id uint) (*model.PackageSilenceExtensionOrder, error) {
var order model.PackageSilenceExtensionOrder
err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// FindOpenByUsage 查询套餐当前未结束(待支付 / 待审批)的订单,没有时返回 nil。
func (s *PackageSilenceOrderStore) FindOpenByUsage(ctx context.Context, usageID uint) (*model.PackageSilenceExtensionOrder, error) {
var order model.PackageSilenceExtensionOrder
err := s.db.WithContext(ctx).Where("package_usage_id = ? AND status IN ?", usageID, []string{
constants.SilenceOrderStatusPendingPayment, constants.SilenceOrderStatusPendingApproval,
}).Order("id DESC").First(&order).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
// FindByRequestID 按下单幂等键查询订单,没有时返回 nil。
func (s *PackageSilenceOrderStore) FindByRequestID(ctx context.Context, requestID string) (*model.PackageSilenceExtensionOrder, error) {
var order model.PackageSilenceExtensionOrder
err := s.db.WithContext(ctx).Where("request_id = ?", requestID).First(&order).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
// ListPendingPayment 查询待支付订单(支付恢复任务用),按 ID 正序。
func (s *PackageSilenceOrderStore) ListPendingPayment(ctx context.Context, createdBefore time.Time, limit int) ([]model.PackageSilenceExtensionOrder, error) {
var orders []model.PackageSilenceExtensionOrder
err := s.db.WithContext(ctx).
Where("status = ? AND created_at < ?", constants.SilenceOrderStatusPendingPayment, createdBefore).
Order("id ASC").Limit(limit).Find(&orders).Error
return orders, err
}
// TransitionStatus 条件更新订单状态,返回影响行数。
func (s *PackageSilenceOrderStore) TransitionStatus(ctx context.Context, id uint, from []string, to string, values map[string]any) (int64, error) {
updates := copyValues(values)
updates["status"] = to
updates["updated_at"] = time.Now()
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).
Where("id = ? AND status IN ?", id, from).Updates(updates)
return result.RowsAffected, result.Error
}
// AssetOutboundRecordStore 资产出库记录 Store(只新增与填写结束信息)。
type AssetOutboundRecordStore struct{ db *gorm.DB }
// NewAssetOutboundRecordStore 创建出库记录 Store。
func NewAssetOutboundRecordStore(db *gorm.DB) *AssetOutboundRecordStore {
return &AssetOutboundRecordStore{db: db}
}
// CreateIfAbsent 插入一条出库记录;同一资产同一世代已存在时不插入,返回是否插入。
func (s *AssetOutboundRecordStore) CreateIfAbsent(ctx context.Context, record *model.AssetOutboundRecord) (bool, error) {
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}, {Name: "generation"}},
DoNothing: true,
}).Create(record)
return result.RowsAffected == 1, result.Error
}
// EndGeneration 为某资产某世代的出库记录填写结束时间与原因,返回影响行数。
func (s *AssetOutboundRecordStore) EndGeneration(ctx context.Context, assetType string, assetID uint, generation int, endedAt time.Time, reason string) (int64, error) {
result := s.db.WithContext(ctx).Model(&model.AssetOutboundRecord{}).
Where("asset_type = ? AND asset_id = ? AND generation = ? AND ended_at IS NULL", assetType, assetID, generation).
Updates(map[string]any{"ended_at": endedAt, "end_reason": reason})
return result.RowsAffected, result.Error
}
// ListByAsset 按资产查询各世代出库记录(世代倒序)。
func (s *AssetOutboundRecordStore) ListByAsset(ctx context.Context, assetType string, assetID uint) ([]model.AssetOutboundRecord, error) {
var records []model.AssetOutboundRecord
err := s.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID).
Order("generation DESC").Find(&records).Error
return records, err
}
func copyValues(values map[string]any) map[string]any {
updates := make(map[string]any, len(values)+2)
for key, value := range values {
updates[key] = value
}
return updates
}

View File

@@ -86,6 +86,9 @@ func (s *PackageStore) List(ctx context.Context, opts *store.QueryOptions, filte
if packageType, ok := filters["package_type"].(string); ok && packageType != "" {
query = query.Where("tb_package.package_type = ?", packageType)
}
if unconfigured, ok := filters["silence_unconfigured"].(bool); ok && unconfigured {
query = query.Where(SilenceUnconfiguredPackageCondition("tb_package"))
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
@@ -149,3 +152,9 @@ func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int6
}
return count, nil
}
// SilenceUnconfiguredPackageCondition 返回"未配置默认沉默期的实名即生效正式套餐"的条件(总开关打开前检查与补齐清单共用)。
func SilenceUnconfiguredPackageCondition(alias string) string {
return alias + ".package_type <> '" + constants.PackageTypeAddon + "' AND " + alias + ".expiry_base = '" +
constants.PackageExpiryBaseFromActivation + "' AND " + alias + ".silence_period_days IS NULL"
}