Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名 - 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段 - 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
345 lines
13 KiB
Go
345 lines
13 KiB
Go
package packagesilence
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
|
||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||
"github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||
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"
|
||
)
|
||
|
||
// OwnershipChecker 校验 H5 客户是否拥有资产(由客户绑定服务实现)。
|
||
type OwnershipChecker interface {
|
||
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
|
||
}
|
||
|
||
// PaymentPorts 是在线支付渠道 Adapter(与代理在线充值同一套)。
|
||
type PaymentPorts struct {
|
||
Wechat agentrechargeApp.OnlinePaymentPort
|
||
Alipay agentrechargeApp.OnlinePaymentPort
|
||
Fuiou agentrechargeApp.OnlinePaymentPort
|
||
}
|
||
|
||
// ExtensionService 是沉默期延期用例集合(技术方案 §7、§8):资格、在线下单、支付确认、线下登记、特殊延期、执行与异常。
|
||
type ExtensionService struct {
|
||
db *gorm.DB
|
||
sw *Switch
|
||
resolver *Resolver
|
||
audit *audit.Writer
|
||
approval approvalapp.Port
|
||
perms PermissionChecker
|
||
ownership OwnershipChecker
|
||
runtime *merchantpayment.RuntimeLoader
|
||
payments PaymentPorts
|
||
refunder refundchannel.Refunder
|
||
logger *zap.Logger
|
||
now func() time.Time
|
||
}
|
||
|
||
// NewExtensionService 创建延期用例。
|
||
func NewExtensionService(db *gorm.DB, resolver *Resolver, writer *audit.Writer, logger *zap.Logger) *ExtensionService {
|
||
if logger == nil {
|
||
logger = zap.NewNop()
|
||
}
|
||
return &ExtensionService{db: db, sw: resolver.Switch(), resolver: resolver, audit: writer, logger: logger, now: time.Now}
|
||
}
|
||
|
||
// SetApprovalPort 注入审批创建接缝。
|
||
func (s *ExtensionService) SetApprovalPort(port approvalapp.Port) { s.approval = port }
|
||
|
||
// SetPermissionChecker 注入权限校验。
|
||
func (s *ExtensionService) SetPermissionChecker(checker PermissionChecker) { s.perms = checker }
|
||
|
||
// SetOwnershipChecker 注入 H5 资产归属校验。
|
||
func (s *ExtensionService) SetOwnershipChecker(checker OwnershipChecker) { s.ownership = checker }
|
||
|
||
// SetPayment 注入商户池路由、在线支付渠道与原路退款渠道。
|
||
func (s *ExtensionService) SetPayment(runtime *merchantpayment.RuntimeLoader, ports PaymentPorts, refunder refundchannel.Refunder) {
|
||
s.runtime = runtime
|
||
s.payments = ports
|
||
s.refunder = refunder
|
||
}
|
||
|
||
// Scope 描述调用方身份:CustomerID 为 0 表示后台账号(叠加店铺数据范围),否则为 H5 客户(校验资产归属)。
|
||
type Scope struct {
|
||
CustomerID uint
|
||
}
|
||
|
||
// requireEnabled 在总开关关闭时拒绝新的延期申请(silence_feature_disabled);已发生的回调不调用它。
|
||
func (s *ExtensionService) requireEnabled(ctx context.Context) error {
|
||
if !s.sw.Enabled(ctx) {
|
||
return errors.New(errors.CodeSilenceFeatureDisabled)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *ExtensionService) requirePermission(ctx context.Context, code string) error {
|
||
return requirePermission(ctx, s.perms, code)
|
||
}
|
||
|
||
// requirePermission 校验后台账号权限点;超级管理员不受限。
|
||
func requirePermission(ctx context.Context, perms PermissionChecker, code string) error {
|
||
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
|
||
return nil
|
||
}
|
||
if perms == nil {
|
||
return errors.New(errors.CodeForbidden)
|
||
}
|
||
allowed, err := perms.CheckPermission(ctx, middleware.GetUserIDFromContext(ctx), code, constants.PlatformWeb)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !allowed {
|
||
return errors.New(errors.CodeForbidden, "缺少权限:"+code)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadUsage 读取套餐记录并校验调用方可见:后台叠加店铺数据范围,H5 校验资产归属。
|
||
func (s *ExtensionService) loadUsage(ctx context.Context, db *gorm.DB, usageID uint, scope Scope) (*model.PackageUsage, error) {
|
||
var usage model.PackageUsage
|
||
if err := db.WithContext(ctx).Where("id = ?", usageID).First(&usage).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐记录不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐记录失败")
|
||
}
|
||
assetType, assetID := usageAsset(&usage)
|
||
if scope.CustomerID > 0 {
|
||
if s.ownership == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "资产归属校验未配置")
|
||
}
|
||
owned, err := s.ownership.OwnsAsset(ctx, scope.CustomerID, assetType, assetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !owned {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||
}
|
||
return &usage, nil
|
||
}
|
||
var count int64
|
||
query := db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID)
|
||
if err := middleware.ApplyStrictShopFilter(ctx, query).Count(&count).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
|
||
}
|
||
if count == 0 {
|
||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
return &usage, nil
|
||
}
|
||
|
||
// eligibility 是资格判定的中间事实。
|
||
type eligibility struct {
|
||
usage *model.PackageUsage
|
||
pkg *model.Package
|
||
match PolicyMatch
|
||
paid packagedomain.SilenceQuote
|
||
special packagedomain.SilenceQuote
|
||
effectiveStatus string
|
||
expired bool
|
||
reason string
|
||
specialAvailable bool
|
||
freeAvailable bool
|
||
openOrder *model.PackageSilenceExtensionOrder
|
||
openDetail bool
|
||
}
|
||
|
||
// computeEligibility 计算付费与特殊延期资格(技术方案 §8.1、§8.2)。
|
||
func (s *ExtensionService) computeEligibility(ctx context.Context, db *gorm.DB, usage *model.PackageUsage, now time.Time) (*eligibility, error) {
|
||
result := &eligibility{usage: usage}
|
||
if usage.SilenceStatus == nil || *usage.SilenceStatus == "" {
|
||
result.reason = constants.SilenceReasonNoSilence
|
||
return result, nil
|
||
}
|
||
result.effectiveStatus = *usage.SilenceStatus
|
||
if usage.Status == constants.PackageUsageStatusInvalidated {
|
||
result.reason = constants.SilenceReasonRefunded
|
||
return result, nil
|
||
}
|
||
if usage.Status != constants.PackageUsageStatusPending || result.effectiveStatus == constants.SilenceStatusEnded {
|
||
result.reason = constants.SilenceReasonActivated
|
||
return result, nil
|
||
}
|
||
result.expired = packagedomain.IsSilenceEffectivelyExpired(result.effectiveStatus, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now)
|
||
if result.expired {
|
||
result.effectiveStatus = constants.SilenceStatusExpired
|
||
}
|
||
if !result.expired {
|
||
result.reason = constants.SilenceReasonNotExpired
|
||
if usage.SilenceAdjustPending {
|
||
result.reason = constants.SilenceReasonAdjustPending
|
||
}
|
||
return result, nil
|
||
}
|
||
match, pkg, err := matchPolicyForPackage(ctx, db, usage.PackageID, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result.match, result.pkg = match, pkg
|
||
order, err := postgres.NewPackageSilenceOrderStore(db).FindOpenByUsage(ctx, usage.ID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
|
||
}
|
||
result.openOrder = order
|
||
openDetail, err := postgres.NewPackageSilenceExtensionStore(db).HasOpenByUsage(ctx, usage.ID)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期明细失败")
|
||
}
|
||
result.openDetail = openDetail
|
||
deadline := *usage.SilenceDeadlineAt
|
||
result.paid = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
|
||
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, PaidExtendedDays: usage.SilencePaidExtendedDays,
|
||
Kind: packagedomain.SilenceQuotePaid,
|
||
})
|
||
result.special = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
|
||
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuoteSpecial,
|
||
})
|
||
switch {
|
||
case openDetail || order != nil:
|
||
result.reason = constants.SilenceReasonOrderPending
|
||
case !pkg.SilenceExtensionEnabled:
|
||
result.reason = constants.SilenceReasonExtensionDisabled
|
||
case match.Snapshot == nil:
|
||
result.reason = constants.SilenceReasonNoPolicy
|
||
case !result.paid.CanExtend:
|
||
result.reason = constants.SilenceReasonExceedsLimit
|
||
}
|
||
result.specialAvailable = !openDetail && order == nil
|
||
result.freeAvailable = result.specialAvailable && usage.SilenceFreeExtensionUsed < match.FreeExtensionLimit()
|
||
return result, nil
|
||
}
|
||
|
||
// canPay 表示可以办理付费延期。
|
||
func (e *eligibility) canPay() bool {
|
||
return e.expired && e.reason == ""
|
||
}
|
||
|
||
// paidError 把不能付费延期的原因转换为错误码。
|
||
func (e *eligibility) paidError() error {
|
||
switch e.reason {
|
||
case "":
|
||
return nil
|
||
case constants.SilenceReasonNotExpired, constants.SilenceReasonNoSilence, constants.SilenceReasonActivated,
|
||
constants.SilenceReasonRefunded, constants.SilenceReasonAdjustPending:
|
||
return errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"reason": e.reason})
|
||
case constants.SilenceReasonOrderPending:
|
||
return errors.New(errors.CodeSilenceExtensionOrderPending)
|
||
case constants.SilenceReasonExtensionDisabled:
|
||
return errors.New(errors.CodeSilenceExtensionDisabled)
|
||
case constants.SilenceReasonNoPolicy:
|
||
return errors.New(errors.CodeSilenceExtensionNoPolicy)
|
||
case constants.SilenceReasonExceedsLimit:
|
||
return errors.NewWithData(errors.CodeSilenceExtensionExceedsLimit, map[string]any{"min_months": e.paid.MinMonths, "max_months": e.paid.MaxMonths})
|
||
default:
|
||
return errors.New(errors.CodeSilenceExtensionNotExpired)
|
||
}
|
||
}
|
||
|
||
// Eligibility 查询延期资格(后台与 H5 同语义);waiting 但已到期的先判定为过期。
|
||
func (s *ExtensionService) Eligibility(ctx context.Context, usageID uint, scope Scope) (*dto.SilenceEligibilityResponse, error) {
|
||
if err := s.requireEnabled(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.loadUsage(ctx, s.db, usageID, scope); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.resolver.Resolve(ctx, usageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
|
||
return nil, err
|
||
}
|
||
usage, err := s.loadUsage(ctx, s.db, usageID, Scope{CustomerID: scope.CustomerID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := s.now()
|
||
facts, err := s.computeEligibility(ctx, s.db, usage, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return eligibilityResponse(facts), nil
|
||
}
|
||
|
||
// Quote 按输入天数试算付费延期。
|
||
func (s *ExtensionService) Quote(ctx context.Context, usageID uint, days int, scope Scope) (*dto.SilenceQuoteResponse, error) {
|
||
if err := s.requireEnabled(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
usage, err := s.loadUsage(ctx, s.db, usageID, scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := s.now()
|
||
facts, err := s.computeEligibility(ctx, s.db, usage, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !facts.expired || usage.SilenceDeadlineAt == nil {
|
||
return nil, facts.paidError()
|
||
}
|
||
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
|
||
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: days, Policy: facts.match.Snapshot,
|
||
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
|
||
})
|
||
deadline := quote.NewDeadline
|
||
return &dto.SilenceQuoteResponse{
|
||
InputDays: days, Months: quote.Months, BilledDays: quote.BilledDays, Amount: quote.Amount,
|
||
NewDeadline: formatDate(&deadline), Valid: quote.InputValid && facts.canPay(),
|
||
}, nil
|
||
}
|
||
|
||
func eligibilityResponse(facts *eligibility) *dto.SilenceEligibilityResponse {
|
||
usage := facts.usage
|
||
resp := &dto.SilenceEligibilityResponse{
|
||
PackageUsageID: usage.ID, SilenceStatus: facts.effectiveStatus, CanExtend: facts.canPay(),
|
||
SpecialExtensionAvailable: facts.expired && facts.specialAvailable, Reason: facts.reason,
|
||
ExtendedDays: usage.SilenceExtendedDays, PaidExtendedDays: usage.SilencePaidExtendedDays,
|
||
FreeExtensionUsed: usage.SilenceFreeExtensionUsed, FreeExtensionLimit: facts.match.FreeExtensionLimit(),
|
||
FreeExtensionAvailable: facts.expired && facts.freeAvailable, Options: []dto.SilenceExtensionOption{},
|
||
}
|
||
if usage.SilenceDeadlineAt != nil {
|
||
resp.OriginalDeadline = formatDate(usage.SilenceDeadlineAt)
|
||
}
|
||
if facts.pkg != nil {
|
||
resp.ExtensionEnabled = facts.pkg.SilenceExtensionEnabled
|
||
}
|
||
if facts.match.Policy != nil {
|
||
id := facts.match.Policy.ID
|
||
resp.PolicyID = &id
|
||
resp.PolicyName = facts.match.Policy.Name
|
||
resp.UnitPrice = facts.match.Policy.UnitPrice
|
||
}
|
||
if facts.openOrder != nil {
|
||
resp.PendingOrderNo = facts.openOrder.OrderNo
|
||
}
|
||
if !facts.expired {
|
||
return resp
|
||
}
|
||
resp.OverdueDays = facts.paid.OverdueDays
|
||
resp.MinDays = facts.paid.MinDays
|
||
resp.MinMonths = facts.paid.MinMonths
|
||
resp.MaxMonths = facts.paid.MaxMonths
|
||
resp.MaxDays = facts.paid.MaxDays
|
||
if facts.canPay() && usage.SilenceDeadlineAt != nil {
|
||
for _, option := range packagedomain.SilenceQuoteOptions(facts.paid, *usage.SilenceDeadlineAt, constants.SilenceExtensionOptionsMax) {
|
||
deadline := option.NewDeadline
|
||
resp.Options = append(resp.Options, dto.SilenceExtensionOption{
|
||
Months: option.Months, BilledDays: option.BilledDays, Amount: option.Amount, NewDeadline: formatDate(&deadline),
|
||
})
|
||
}
|
||
}
|
||
return resp
|
||
}
|