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>
413 lines
16 KiB
Go
413 lines
16 KiB
Go
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
|
||
}
|