feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
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:
@@ -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").
|
||||
|
||||
@@ -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").
|
||||
|
||||
320
internal/store/postgres/package_silence_store.go
Normal file
320
internal/store/postgres/package_silence_store.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user