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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

413 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packagesilence
import (
"context"
"slices"
"strings"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// PolicyService 管理沉默期延期政策(技术方案 §3.6)。权限点 package_silence:policy 在路由层校验。
type PolicyService struct {
db *gorm.DB
audit *audit.Writer
perms PermissionChecker
now func() time.Time
}
// SetPermissionChecker 注入权限校验(package_silence:policy)。
func (s *PolicyService) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// NewPolicyService 创建延期政策用例。
func NewPolicyService(db *gorm.DB, writer *audit.Writer) *PolicyService {
return &PolicyService{db: db, audit: writer, now: time.Now}
}
// List 分页查询延期政策。
func (s *PolicyService) List(ctx context.Context, req *dto.SilencePolicyListRequest) ([]*dto.SilencePolicyResponse, int64, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, 0, err
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{})
if name := strings.TrimSpace(req.Name); name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
if req.ScopeType != "" {
query = query.Where("scope_type = ?", req.ScopeType)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.EffectiveAt != "" {
at, err := time.Parse(time.RFC3339, req.EffectiveAt)
if err != nil {
return nil, 0, errors.New(errors.CodeInvalidParam, "生效时间格式应为 RFC3339")
}
query = query.Where("effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", at, at)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计延期政策失败")
}
var policies []model.PackageSilenceExtensionPolicy
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&policies).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
items := make([]*dto.SilencePolicyResponse, 0, len(policies))
for index := range policies {
items = append(items, policyResponse(&policies[index]))
}
return items, total, nil
}
// Get 查询政策详情。
func (s *PolicyService) Get(ctx context.Context, id uint) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
policy, err := postgres.NewPackageSilencePolicyStore(s.db).GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期政策不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
return policyResponse(policy), nil
}
// Create 新增政策:校验字段、同级重叠,写审计。
func (s *PolicyService) Create(ctx context.Context, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
policy := &model.PackageSilenceExtensionPolicy{Creator: operatorID, Updater: operatorID, Status: constants.SilencePolicyStatusEnabled}
if err := applyPolicyRequest(policy, req); err != nil {
return nil, err
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
if err := s.validateInTx(ctx, tx, policy); err != nil {
return err
}
now := s.now()
policy.CreatedAt, policy.UpdatedAt = now, now
if err := store.Create(ctx, policy); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "新增延期政策失败")
}
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyCreated, "新增沉默期延期政策", policy, nil, policyAuditData(policy))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// Update 编辑政策:只影响之后发起的延期,已创建订单与已提交审批按快照执行。
func (s *PolicyService) Update(ctx context.Context, id uint, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := policyAuditData(current)
if err := applyPolicyRequest(current, req); err != nil {
return err
}
if err := s.validateInTx(ctx, tx, current); err != nil {
return err
}
current.Updater = middleware.GetUserIDFromContext(ctx)
current.UpdatedAt = s.now()
if _, err := store.Update(ctx, current.ID, map[string]any{
"name": current.Name, "scope_type": current.ScopeType, "scope_ids": current.ScopeIDs,
"unit_price": current.UnitPrice, "free_extension_limit": current.FreeExtensionLimit,
"paid_max_months": current.PaidMaxMonths, "single_max_months": current.SingleMaxMonths,
"approval_exempt_types": current.ApprovalExemptTypes, "effective_from": current.EffectiveFrom,
"effective_to": current.EffectiveTo, "status": current.Status, "remark": current.Remark,
"updater": current.Updater, "updated_at": current.UpdatedAt,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyUpdated, "修改沉默期延期政策", current, before, policyAuditData(current))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// SetStatus 启停政策;启用时同样做重叠校验。
func (s *PolicyService) SetStatus(ctx context.Context, id uint, status int) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
if status != constants.SilencePolicyStatusEnabled && status != constants.SilencePolicyStatusDisabled {
return nil, errors.New(errors.CodeInvalidParam, "政策状态非法")
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := current.Status
if before == status {
policy = current
return nil
}
current.Status = status
if status == constants.SilencePolicyStatusEnabled {
if err := s.validateOverlapInTx(ctx, tx, current); err != nil {
return err
}
}
current.Updater = middleware.GetUserIDFromContext(ctx)
if _, err := store.Update(ctx, current.ID, map[string]any{"status": status, "updater": current.Updater, "updated_at": s.now()}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策状态失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyStatusChanged, "启停沉默期延期政策", current,
map[string]any{"status": before}, map[string]any{"status": status})
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// MatchForPackage 查询某套餐当前匹配的政策。
func (s *PolicyService) MatchForPackage(ctx context.Context, packageID uint) (*dto.SilencePolicyMatchResponse, error) {
match, _, err := matchPolicyForPackage(ctx, s.db, packageID, s.now())
if err != nil {
return nil, err
}
resp := &dto.SilencePolicyMatchResponse{PackageID: packageID, Matched: match.Policy != nil}
if match.Policy != nil {
resp.Policy = policyResponse(match.Policy)
}
return resp, nil
}
func applyPolicyRequest(policy *model.PackageSilenceExtensionPolicy, req *dto.SilencePolicyRequest) error {
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > constants.SilencePolicyNameMaxLen {
return errors.New(errors.CodeInvalidParam, "政策名称不能为空且不超过 100 字")
}
scopeIDs := dedupeUint(req.ScopeIDs)
switch req.ScopeType {
case constants.SilencePolicyScopeAll:
scopeIDs = nil
case constants.SilencePolicyScopeSeries, constants.SilencePolicyScopePackage:
if len(scopeIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "按系列或指定套餐适用时必须选择适用范围")
}
default:
return errors.New(errors.CodeInvalidParam, "适用范围非法")
}
for _, exempt := range req.ApprovalExemptTypes {
if !slices.Contains(constants.SilenceApprovalExemptTypes, exempt) {
return errors.New(errors.CodeInvalidParam, "免审批场景非法")
}
}
if req.EffectiveTo != nil && !req.EffectiveTo.After(req.EffectiveFrom) {
return errors.New(errors.CodeInvalidParam, "失效时间必须晚于生效时间")
}
if req.UnitPrice < 0 || req.FreeExtensionLimit < 0 || req.PaidMaxMonths < 0 {
return errors.New(errors.CodeInvalidParam, "单价、免费次数与付费累计月数不能为负数")
}
policy.Name = name
policy.ScopeType = req.ScopeType
policy.ScopeIDs = model.UintJSONBArray(scopeIDs)
if policy.ScopeIDs == nil {
policy.ScopeIDs = model.UintJSONBArray{}
}
policy.UnitPrice = req.UnitPrice
policy.FreeExtensionLimit = req.FreeExtensionLimit
policy.PaidMaxMonths = req.PaidMaxMonths
policy.SingleMaxMonths = req.SingleMaxMonths
policy.ApprovalExemptTypes = model.StringJSONBArray(dedupeString(req.ApprovalExemptTypes))
if policy.ApprovalExemptTypes == nil {
policy.ApprovalExemptTypes = model.StringJSONBArray{}
}
policy.EffectiveFrom = req.EffectiveFrom
policy.EffectiveTo = req.EffectiveTo
if req.Status != nil {
policy.Status = *req.Status
}
policy.Remark = strings.TrimSpace(req.Remark)
return nil
}
func (s *PolicyService) validateInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
var sameName int64
if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{}).
Where("name = ? AND id <> ?", policy.Name, policy.ID).Count(&sameName).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验政策名称失败")
}
if sameName > 0 {
return errors.New(errors.CodeConflict, "政策名称已存在")
}
if err := s.validateScopeIDs(ctx, tx, policy); err != nil {
return err
}
return s.validateOverlapInTx(ctx, tx, policy)
}
func (s *PolicyService) validateScopeIDs(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if len(policy.ScopeIDs) == 0 {
return nil
}
var count int64
var target any = &model.Package{}
if policy.ScopeType == constants.SilencePolicyScopeSeries {
target = &model.PackageSeries{}
}
if err := tx.WithContext(ctx).Model(target).Where("id IN ?", []uint(policy.ScopeIDs)).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验适用范围失败")
}
if int(count) != len(policy.ScopeIDs) {
return errors.New(errors.CodeInvalidParam, "适用范围中存在不存在的系列或套餐")
}
return nil
}
// validateOverlapInTx 校验同一优先级的启用政策适用范围与生效时间段不重叠(silence_policy_overlap)。
func (s *PolicyService) validateOverlapInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if policy.Status != constants.SilencePolicyStatusEnabled {
return nil
}
existing, err := postgres.NewPackageSilencePolicyStore(tx).ListEnabled(ctx)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询启用中的延期政策失败")
}
candidate := policyCandidate(policy)
for index := range existing {
other := &existing[index]
if other.ID == policy.ID {
continue
}
if packagedomain.SilencePolicyOverlaps(candidate, policyCandidate(other)) {
return errors.NewWithData(errors.CodeSilencePolicyOverlap, map[string]any{"conflict_policy_id": other.ID, "conflict_policy_name": other.Name})
}
}
return nil
}
func (s *PolicyService) writeAudit(ctx context.Context, tx *gorm.DB, action, summary string, policy *model.PackageSilenceExtensionPolicy, before, after map[string]any) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceResource(ctx, tx, action, summary, audit.SilencePolicyResource(policy, before, after), nil)
}
func policyAuditData(policy *model.PackageSilenceExtensionPolicy) map[string]any {
return map[string]any{
"name": policy.Name, "scope_type": policy.ScopeType, "scope_ids": []uint(policy.ScopeIDs),
"unit_price": policy.UnitPrice, "free_extension_limit": policy.FreeExtensionLimit,
"paid_max_months": policy.PaidMaxMonths, "single_max_months": policy.SingleMaxMonths,
"approval_exempt_types": []string(policy.ApprovalExemptTypes), "effective_from": policy.EffectiveFrom,
"effective_to": policy.EffectiveTo, "status": policy.Status,
}
}
func policyResponse(policy *model.PackageSilenceExtensionPolicy) *dto.SilencePolicyResponse {
scopeIDs := []uint(policy.ScopeIDs)
if scopeIDs == nil {
scopeIDs = []uint{}
}
exempt := []string(policy.ApprovalExemptTypes)
if exempt == nil {
exempt = []string{}
}
return &dto.SilencePolicyResponse{
ID: policy.ID, Name: policy.Name, ScopeType: policy.ScopeType, ScopeTypeName: policyScopeName(policy.ScopeType),
ScopeIDs: scopeIDs, UnitPrice: policy.UnitPrice, FreeExtensionLimit: policy.FreeExtensionLimit,
PaidMaxMonths: policy.PaidMaxMonths, SingleMaxMonths: policy.SingleMaxMonths, ApprovalExemptTypes: exempt,
EffectiveFrom: policy.EffectiveFrom, EffectiveTo: policy.EffectiveTo, Status: policy.Status, Remark: policy.Remark,
Creator: policy.Creator, Updater: policy.Updater, CreatedAt: policy.CreatedAt, UpdatedAt: policy.UpdatedAt,
}
}
func policyScopeName(scope string) string {
switch scope {
case constants.SilencePolicyScopeAll:
return "全部套餐"
case constants.SilencePolicyScopeSeries:
return "套餐系列"
case constants.SilencePolicyScopePackage:
return "指定套餐"
default:
return ""
}
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}
func dedupeUint(values []uint) []uint {
result := make([]uint, 0, len(values))
for _, value := range values {
if value > 0 && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}
func dedupeString(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}