Files
junhong_cmp_fiber/internal/domain/package/silence.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

375 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 packagedomain
import (
"slices"
"time"
)
// 沉默期领域规则(add-package-silence-period,技术方案 §4.2、§5.2、§8.2、§3.6)。
// 本文件只放纯函数:不读库、不读配置。所有截止时间与延期计费只能由这里计算,禁止各处自写 AddDate 或 SQL interval。
const (
silenceDaysPerMonth = 30
silenceStatusWait = "waiting"
)
var silenceLocation = loadSilenceLocation()
func loadSilenceLocation() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return location
}
// SilenceLocation 返回沉默期计算使用的时区 Asia/Shanghai。
func SilenceLocation() *time.Location {
return silenceLocation
}
// SilenceDate 返回时间点在 Asia/Shanghai 下的日期(当日 00:00:00)。
func SilenceDate(t time.Time) time.Time {
local := t.In(silenceLocation)
year, month, day := local.Date()
return time.Date(year, month, day, 0, 0, 0, 0, silenceLocation)
}
// SilenceDeadline 返回 (日期 + days 天) 当日 23:59:59(Asia/Shanghai)。
// 例:2026-09-24 10:00 购买、30 天 → 2026-10-24 23:59:59。
func SilenceDeadline(date time.Time, days int) time.Time {
local := date.In(silenceLocation)
year, month, day := local.Date()
return time.Date(year, month, day+days, 23, 59, 59, 0, silenceLocation)
}
// SilenceDaysBetween 返回两个时间点按 Asia/Shanghai 日期相差的天数(to − from)。
func SilenceDaysBetween(from, to time.Time) int {
start := SilenceDate(from)
end := SilenceDate(to)
// 用 UTC 日期差避免夏令时影响(上海无夏令时,这里保持通用)。
startUTC := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
endUTC := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
return int(endUTC.Sub(startUTC).Hours() / 24)
}
// SilenceDecision 是一次沉默期判定的结果。
type SilenceDecision string
const (
// SilenceDecisionNoop 状态不变(不适用沉默期、已结束 / 已过期、仍在期内等待实名、调整审批中)。
SilenceDecisionNoop SilenceDecision = "noop"
// SilenceDecisionWait 仍在期内等待实名(与 noop 一样不写库,供展示区分)。
SilenceDecisionWait SilenceDecision = "wait"
// SilenceDecisionEnd 期内已实名,沉默期结束。
SilenceDecisionEnd SilenceDecision = "end"
// SilenceDecisionExpire 已到期且期内未实名,沉默期过期。
SilenceDecisionExpire SilenceDecision = "expire"
)
// SilenceFacts 是判定所需的事实。
type SilenceFacts struct {
Status string // 当前 silence_status(空表示不适用)
DeadlineAt *time.Time // 当前截止时间
Realnamed bool // 资产当前是否已实名
RealnameAt *time.Time // 实名时间(设备为绑定卡 max(实名, 绑定) 的最小值)
AdjustPending bool // 购买时调整审批是否进行中
Now time.Time
}
// SilenceJudgement 是判定结果与补写信息。
type SilenceJudgement struct {
Decision SilenceDecision
// BackfillRealnameAt 为 true 表示资产已实名但实名时间为空,按规则视为期内实名并需补写实名时间。
BackfillRealnameAt bool
}
// JudgeSilence 执行统一判定(技术方案 §5.2):
//
// waiting 且 期内已实名 → end
// waiting 且 已到期 且 期内未实名 且 无调整审批 → expire
// 其他 → 不变
//
// 已实名但实名时间为空的资产视为期内已实名(生产存在 first_realname_at 为空的已实名卡)。
func JudgeSilence(facts SilenceFacts) SilenceJudgement {
if facts.Status != silenceStatusWait || facts.DeadlineAt == nil {
return SilenceJudgement{Decision: SilenceDecisionNoop}
}
deadline := *facts.DeadlineAt
if facts.Realnamed {
if facts.RealnameAt == nil {
return SilenceJudgement{Decision: SilenceDecisionEnd, BackfillRealnameAt: true}
}
if !facts.RealnameAt.After(deadline) {
return SilenceJudgement{Decision: SilenceDecisionEnd}
}
}
if facts.Now.After(deadline) && !facts.AdjustPending {
return SilenceJudgement{Decision: SilenceDecisionExpire}
}
return SilenceJudgement{Decision: SilenceDecisionWait}
}
// IsSilenceEffectivelyExpired 判断展示层是否应显示"沉默期已过期":已过期,或待实名但已过截止且无调整审批。
func IsSilenceEffectivelyExpired(status string, deadlineAt *time.Time, adjustPending bool, now time.Time) bool {
if status == "expired" {
return true
}
return status == silenceStatusWait && deadlineAt != nil && now.After(*deadlineAt) && !adjustPending
}
// SilenceMonthsForDays 按每月 30 天向上取整:返回计费月数与计费 / 延长天数。
// 例:1→(1,30);30→(1,30);31→(2,60);60→(2,60)。
func SilenceMonthsForDays(inputDays int) (months int, billedDays int) {
if inputDays <= 0 {
return 0, 0
}
months = (inputDays + silenceDaysPerMonth - 1) / silenceDaysPerMonth
return months, months * silenceDaysPerMonth
}
// SilencePolicySnapshot 是延期计价与上限使用的政策快照。
type SilencePolicySnapshot struct {
PolicyID uint
Name string
UnitPrice int64
FreeExtensionLimit int
PaidMaxMonths int
SingleMaxMonths *int
ApprovalExemptTypes []string
}
// SilenceQuoteKind 是延期报价类型。
type SilenceQuoteKind string
const (
// SilenceQuotePaid 付费延期(在线与线下):受政策累计与单次上限约束。
SilenceQuotePaid SilenceQuoteKind = "paid"
// SilenceQuoteSpecial 特殊延期(含免费):只要求不少于最少月数。
SilenceQuoteSpecial SilenceQuoteKind = "special"
)
// SilenceQuoteInput 是延期报价输入。
type SilenceQuoteInput struct {
DeadlineAt time.Time
Now time.Time
InputDays int // 0 表示只算资格,不校验输入
Policy *SilencePolicySnapshot
PaidExtendedDays int
Kind SilenceQuoteKind
}
// SilenceQuoteOption 是一个月数档位的展示信息。
type SilenceQuoteOption struct {
Months int
BilledDays int
Amount int64
NewDeadline time.Time
}
// SilenceQuote 是延期报价结果。
type SilenceQuote struct {
OverdueDays int
MinMonths int
MaxMonths int // 付费延期上限;特殊延期无上限时为 0
MinDays int
MaxDays int
UnitPrice int64
// 以下字段仅在 InputDays > 0 时有值
Months int
BilledDays int
Amount int64
NewDeadline time.Time
// CanExtend 表示该类型(付费 / 特殊)能否办理;Reason 为不能办理的原因码
CanExtend bool
Reason string
// InputValid 表示输入天数换算后的月数是否在允许范围内(InputDays > 0 时有意义)
InputValid bool
}
// 报价原因码(与 constants.SilenceReason* 取值一致)。
const (
silenceReasonNoPolicy = "no_policy"
silenceReasonExceedsLimit = "exceeds_limit"
)
// QuoteSilenceExtension 计算延期报价(技术方案 §8.2):
//
// overdue_days = 今天日期 − 截止日期(不小于 0)
// min_months = floor(overdue_days / 30) + 1
// remain = paid_max_months − paid_extended_days / 30
// max_months = min(remain, single_max_months)
// months = ceil(input_days / 30);延长天数 = months × 30
// new_deadline = (截止日期 + 延长天数) 当日 23:59:59
// amount = months × unit_price(特殊延期由调用方按类型决定金额)
func QuoteSilenceExtension(input SilenceQuoteInput) SilenceQuote {
overdue := SilenceDaysBetween(input.DeadlineAt, input.Now)
if overdue < 0 {
overdue = 0
}
quote := SilenceQuote{
OverdueDays: overdue,
MinMonths: overdue/silenceDaysPerMonth + 1,
MinDays: overdue + 1,
}
if input.Policy != nil {
quote.UnitPrice = input.Policy.UnitPrice
}
switch input.Kind {
case SilenceQuoteSpecial:
quote.CanExtend = true
default:
if input.Policy == nil {
quote.Reason = silenceReasonNoPolicy
} else {
remain := input.Policy.PaidMaxMonths - input.PaidExtendedDays/silenceDaysPerMonth
if remain < 0 {
remain = 0
}
maxMonths := remain
if input.Policy.SingleMaxMonths != nil && *input.Policy.SingleMaxMonths < maxMonths {
maxMonths = *input.Policy.SingleMaxMonths
}
quote.MaxMonths = maxMonths
quote.MaxDays = maxMonths * silenceDaysPerMonth
quote.CanExtend = quote.MinMonths <= quote.MaxMonths
if !quote.CanExtend {
quote.Reason = silenceReasonExceedsLimit
}
}
}
if input.InputDays > 0 {
months, billed := SilenceMonthsForDays(input.InputDays)
quote.Months = months
quote.BilledDays = billed
quote.NewDeadline = SilenceDeadline(input.DeadlineAt, billed)
quote.Amount = int64(months) * quote.UnitPrice
quote.InputValid = quote.CanExtend && months >= quote.MinMonths &&
(input.Kind == SilenceQuoteSpecial || months <= quote.MaxMonths) &&
quote.NewDeadline.After(SilenceDeadline(input.Now, 0))
}
return quote
}
// SilenceQuoteOptions 返回付费延期 min~max 每档的计费天数、金额与新截止日;档位数最多 limit 个。
func SilenceQuoteOptions(quote SilenceQuote, deadlineAt time.Time, limit int) []SilenceQuoteOption {
if !quote.CanExtend || quote.MaxMonths < quote.MinMonths {
return nil
}
options := make([]SilenceQuoteOption, 0, quote.MaxMonths-quote.MinMonths+1)
for months := quote.MinMonths; months <= quote.MaxMonths && len(options) < limit; months++ {
billed := months * silenceDaysPerMonth
options = append(options, SilenceQuoteOption{
Months: months, BilledDays: billed, Amount: int64(months) * quote.UnitPrice,
NewDeadline: SilenceDeadline(deadlineAt, billed),
})
}
return options
}
// SilencePolicyCandidate 是政策匹配与重叠校验的输入。
type SilencePolicyCandidate struct {
ID uint
ScopeType string // all / series / package
ScopeIDs []uint
Enabled bool
EffectiveFrom time.Time
EffectiveTo *time.Time
}
func silenceScopePriority(scopeType string) int {
switch scopeType {
case "package":
return 3
case "series":
return 2
case "all":
return 1
default:
return 0
}
}
func (c SilencePolicyCandidate) effectiveAt(now time.Time) bool {
if !c.Enabled || now.Before(c.EffectiveFrom) {
return false
}
return c.EffectiveTo == nil || c.EffectiveTo.After(now)
}
func (c SilencePolicyCandidate) covers(packageID, seriesID uint) bool {
switch c.ScopeType {
case "all":
return true
case "series":
return seriesID != 0 && slices.Contains(c.ScopeIDs, seriesID)
case "package":
return packageID != 0 && slices.Contains(c.ScopeIDs, packageID)
default:
return false
}
}
// MatchSilencePolicy 按"指定套餐 > 套餐系列 > 全部套餐"选出当前生效的政策,返回下标;未匹配返回 -1。
// 同一优先级理论上不重叠(保存时校验),若历史数据重叠则取 ID 较大的一条。
func MatchSilencePolicy(candidates []SilencePolicyCandidate, packageID, seriesID uint, now time.Time) int {
best := -1
for index, candidate := range candidates {
if !candidate.effectiveAt(now) || !candidate.covers(packageID, seriesID) {
continue
}
if best < 0 {
best = index
continue
}
current := candidates[best]
if silenceScopePriority(candidate.ScopeType) > silenceScopePriority(current.ScopeType) ||
(candidate.ScopeType == current.ScopeType && candidate.ID > current.ID) {
best = index
}
}
return best
}
// SilencePolicyOverlaps 判断两条政策是否在同一优先级上适用范围与生效时间段重叠(只比较启用的政策)。
func SilencePolicyOverlaps(a, b SilencePolicyCandidate) bool {
if !a.Enabled || !b.Enabled || a.ScopeType != b.ScopeType {
return false
}
if a.ScopeType != "all" && !silenceIDsIntersect(a.ScopeIDs, b.ScopeIDs) {
return false
}
return silenceRangesOverlap(a.EffectiveFrom, a.EffectiveTo, b.EffectiveFrom, b.EffectiveTo)
}
func silenceIDsIntersect(a, b []uint) bool {
for _, id := range a {
if slices.Contains(b, id) {
return true
}
}
return false
}
// silenceRangesOverlap 判断 [fromA, toA) 与 [fromB, toB) 是否相交,to 为空表示长期。
func silenceRangesOverlap(fromA time.Time, toA *time.Time, fromB time.Time, toB *time.Time) bool {
aBeforeB := toA != nil && !toA.After(fromB)
bBeforeA := toB != nil && !toB.After(fromA)
return !aBeforeB && !bBeforeA
}
// IsSilenceApprovalExempt 判断场景是否在政策快照的免审批场景内;无政策快照时全部需要审批。
func IsSilenceApprovalExempt(exemptTypes []string, scene string) bool {
return scene != "" && slices.Contains(exemptTypes, scene)
}
// SilenceSpecialApprovalScene 返回特殊延期判断免审批时使用的场景:批量看 batch,否则看延期类型。
func SilenceSpecialApprovalScene(extensionType string, isBatch bool) string {
if isBatch {
return "batch"
}
return extensionType
}