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>
375 lines
13 KiB
Go
375 lines
13 KiB
Go
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
|
||
}
|