Files
junhong_cmp_fiber/internal/application/packagesilence/extension_service.go
邹进 62ec94adec
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
- 字段 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>
2026-10-27 16:47:02 +09:00

345 lines
13 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"
"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
}