@@ -0,0 +1,206 @@
|
||||
// Package domain 存放与存储无关的纯业务计算:日期、剩余时长、状态判定、追加换算。
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DateLayout = "2006-01-02"
|
||||
StampLayout = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
// Today 返回本地时区当天零点。
|
||||
func Today() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// ParseDate 解析 YYYY-MM-DD。
|
||||
func ParseDate(s string) (time.Time, error) {
|
||||
return time.ParseInLocation(DateLayout, s, time.Local)
|
||||
}
|
||||
|
||||
// ParseDateOr 解析失败时返回默认值。
|
||||
func ParseDateOr(s string, fallback time.Time) time.Time {
|
||||
if t, err := ParseDate(s); err == nil {
|
||||
return t
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Date 格式化为 YYYY-MM-DD。
|
||||
func Date(t time.Time) string { return t.Format(DateLayout) }
|
||||
|
||||
// DayDiff 返回 b-a 的自然日天数(不受夏令时影响)。
|
||||
func DayDiff(a, b time.Time) int {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
ua := time.Date(ay, am, ad, 0, 0, 0, 0, time.UTC)
|
||||
ub := time.Date(by, bm, bd, 0, 0, 0, 0, time.UTC)
|
||||
return int(ub.Sub(ua).Hours() / 24.0)
|
||||
}
|
||||
|
||||
// AddDays 加 n 天。
|
||||
func AddDays(t time.Time, n int) time.Time { return t.AddDate(0, 0, n) }
|
||||
|
||||
// MonthStart 返回当月 1 日。
|
||||
func MonthStart(y int, m time.Month) time.Time {
|
||||
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// MonthEnd 返回当月最后一天。
|
||||
func MonthEnd(y int, m time.Month) time.Time {
|
||||
return time.Date(y, m, 1, 0, 0, 0, 0, time.Local).AddDate(0, 1, -1)
|
||||
}
|
||||
|
||||
// WeekdayIndex 返回以周一为 0 的星期索引。
|
||||
func WeekdayIndex(t time.Time) int {
|
||||
wd := int(t.Weekday())
|
||||
if wd == 0 {
|
||||
return 6
|
||||
}
|
||||
return wd - 1
|
||||
}
|
||||
|
||||
// 订阅状态(存储值)
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
// 套餐类型
|
||||
const (
|
||||
UnitDay = "day"
|
||||
UnitCount = "count"
|
||||
)
|
||||
|
||||
// 派生展示状态
|
||||
const (
|
||||
StateActive = "active" // 进行中
|
||||
StatePending = "pending" // 未开始
|
||||
StatePaused = "paused" // 暂停中
|
||||
StateExpired = "expired" // 已到期
|
||||
StateUsedUp = "usedup" // 次数用尽
|
||||
StateClosed = "closed" // 已结束
|
||||
)
|
||||
|
||||
// 到期预警等级
|
||||
const (
|
||||
LevelOK = "ok"
|
||||
LevelWarn = "warn"
|
||||
LevelToday = "today"
|
||||
LevelExpired = "expired"
|
||||
LevelNone = "none" // 无期限(按次且未设有效期)
|
||||
)
|
||||
|
||||
// Eval 是订阅的计算结果。
|
||||
type Eval struct {
|
||||
State string
|
||||
DaysLeft int // 距结束日的天数:0=今天到期,>0=未来,-1=无结束日
|
||||
RemainingDays int
|
||||
RemainingCount int
|
||||
Level string
|
||||
}
|
||||
|
||||
// EvalSub 计算订阅的展示状态与剩余量。
|
||||
// - unit=day:剩余天数 = end - today + 1(含当天)
|
||||
// - unit=count:剩余次数 = total - used;end 为可选有效期
|
||||
func EvalSub(unit, status, startDate, endDate string, total, used int, today time.Time, warnDays int) Eval {
|
||||
e := Eval{Level: LevelNone, DaysLeft: -1}
|
||||
if warnDays <= 0 {
|
||||
warnDays = 3
|
||||
}
|
||||
start := ParseDateOr(startDate, today)
|
||||
end, hasEnd := time.Time{}, false
|
||||
if endDate != "" {
|
||||
if t, err := ParseDate(endDate); err == nil {
|
||||
end, hasEnd = t, true
|
||||
e.DaysLeft = DayDiff(today, end)
|
||||
}
|
||||
}
|
||||
|
||||
if unit == UnitDay {
|
||||
if hasEnd {
|
||||
e.RemainingDays = e.DaysLeft + 1
|
||||
}
|
||||
if e.RemainingDays < 0 {
|
||||
e.RemainingDays = 0
|
||||
}
|
||||
} else {
|
||||
e.RemainingCount = total - used
|
||||
if e.RemainingCount < 0 {
|
||||
e.RemainingCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
expired := false
|
||||
if hasEnd && end.Before(today) {
|
||||
expired = true
|
||||
}
|
||||
if unit == UnitCount && e.RemainingCount <= 0 {
|
||||
expired = true
|
||||
}
|
||||
|
||||
switch {
|
||||
case status == StatusClosed:
|
||||
e.State = StateClosed
|
||||
case status == StatusPaused:
|
||||
e.State = StatePaused
|
||||
case expired:
|
||||
if unit == UnitCount && e.RemainingCount <= 0 {
|
||||
e.State = StateUsedUp
|
||||
} else {
|
||||
e.State = StateExpired
|
||||
}
|
||||
case start.After(today):
|
||||
e.State = StatePending
|
||||
default:
|
||||
e.State = StateActive
|
||||
}
|
||||
|
||||
// 预警等级只对「进行中 / 未开始」有意义
|
||||
switch e.State {
|
||||
case StateExpired:
|
||||
e.Level = LevelExpired
|
||||
case StateActive, StatePending:
|
||||
switch {
|
||||
case !hasEnd:
|
||||
e.Level = LevelNone
|
||||
case e.DaysLeft <= 0:
|
||||
e.Level = LevelToday
|
||||
case e.DaysLeft <= warnDays:
|
||||
e.Level = LevelWarn
|
||||
default:
|
||||
e.Level = LevelOK
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ExtendPlan 描述一次「按日追加」的换算结果。
|
||||
type ExtendPlan struct {
|
||||
Base time.Time // 计算起点
|
||||
OldEnd time.Time
|
||||
NewEnd time.Time
|
||||
Days int
|
||||
SameDay bool // 新结束日等于原结束日(未增加)
|
||||
Backwards bool // 新结束日早于计算起点
|
||||
}
|
||||
|
||||
// PlanExtend 根据原结束日、今天与新结束日,算出计算起点与追加天数。
|
||||
// 规则(PRD R4):已到期从今天起算,未到期从原结束日次日起算。
|
||||
func PlanExtend(oldEnd, newEnd, today time.Time) ExtendPlan {
|
||||
p := ExtendPlan{OldEnd: oldEnd, NewEnd: newEnd}
|
||||
if !oldEnd.Before(today) {
|
||||
p.Base = AddDays(oldEnd, 1)
|
||||
} else {
|
||||
p.Base = today
|
||||
}
|
||||
p.Days = DayDiff(p.Base, newEnd) + 1
|
||||
p.SameDay = DayDiff(oldEnd, newEnd) == 0
|
||||
p.Backwards = p.Days <= 0
|
||||
if p.Days < 0 {
|
||||
p.Days = 0
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func d(y int, m time.Month, day int) time.Time {
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func TestDayDiff(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b time.Time
|
||||
want int
|
||||
}{
|
||||
{d(2026, 1, 1), d(2026, 1, 1), 0},
|
||||
{d(2026, 1, 1), d(2026, 1, 31), 30},
|
||||
{d(2026, 2, 28), d(2026, 3, 1), 1},
|
||||
{d(2024, 2, 28), d(2024, 3, 1), 2}, // 闰年
|
||||
{d(2026, 12, 31), d(2027, 1, 1), 1},
|
||||
{d(2026, 1, 10), d(2026, 1, 1), -9},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DayDiff(c.a, c.b); got != c.want {
|
||||
t.Errorf("DayDiff(%s,%s)=%d want %d", Date(c.a), Date(c.b), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDay(t *testing.T) {
|
||||
today := d(2026, 10, 18)
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
end string
|
||||
wantState string
|
||||
wantLevel string
|
||||
wantDays int
|
||||
}{
|
||||
{"进行中", "active", "2026-11-16", StateActive, LevelOK, 30},
|
||||
{"今天到期", "active", "2026-10-18", StateActive, LevelToday, 1},
|
||||
{"3天内", "active", "2026-10-20", StateActive, LevelWarn, 3},
|
||||
{"已到期", "active", "2026-10-17", StateExpired, LevelExpired, 0},
|
||||
{"暂停中", "paused", "2026-11-16", StatePaused, LevelNone, 30},
|
||||
{"已结束", "closed", "2026-11-16", StateClosed, LevelNone, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := EvalSub(UnitDay, c.status, "2026-10-01", c.end, 0, 0, today, 3)
|
||||
if e.State != c.wantState {
|
||||
t.Errorf("%s: state=%s want %s", c.name, e.State, c.wantState)
|
||||
}
|
||||
if e.Level != c.wantLevel {
|
||||
t.Errorf("%s: level=%s want %s", c.name, e.Level, c.wantLevel)
|
||||
}
|
||||
if e.RemainingDays != c.wantDays {
|
||||
t.Errorf("%s: remaining=%d want %d", c.name, e.RemainingDays, c.wantDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDayNotStarted(t *testing.T) {
|
||||
today := d(2026, 10, 18)
|
||||
e := EvalSub(UnitDay, "active", "2026-10-20", "2026-11-18", 0, 0, today, 3)
|
||||
if e.State != StatePending {
|
||||
t.Fatalf("state=%s want pending", e.State)
|
||||
}
|
||||
if e.RemainingDays != 32 {
|
||||
t.Fatalf("remaining=%d want 32", e.RemainingDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCount(t *testing.T) {
|
||||
today := d(2026, 10, 18)
|
||||
|
||||
// 有次数、有有效期,10 天后到期 → 正常
|
||||
e := EvalSub(UnitCount, "active", "2026-10-01", "2026-10-28", 10, 4, today, 3)
|
||||
if e.State != StateActive || e.Level != LevelOK || e.RemainingCount != 6 {
|
||||
t.Fatalf("got state=%s level=%s remain=%d", e.State, e.Level, e.RemainingCount)
|
||||
}
|
||||
|
||||
// 有效期就是今天 → 今天到期(而不是误判成 warn/ok)
|
||||
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-18", 10, 4, today, 3)
|
||||
if e.Level != LevelToday {
|
||||
t.Fatalf("level=%s want today", e.Level)
|
||||
}
|
||||
|
||||
// 只有次数,没有有效期 → 无预警等级
|
||||
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 4, today, 3)
|
||||
if e.Level != LevelNone || e.DaysLeft != -1 {
|
||||
t.Fatalf("level=%s daysLeft=%d", e.Level, e.DaysLeft)
|
||||
}
|
||||
|
||||
// 次数用尽
|
||||
e = EvalSub(UnitCount, "active", "2026-10-01", "", 10, 10, today, 3)
|
||||
if e.State != StateUsedUp {
|
||||
t.Fatalf("state=%s want usedup", e.State)
|
||||
}
|
||||
|
||||
// 次数还有,但有效期已过
|
||||
e = EvalSub(UnitCount, "active", "2026-10-01", "2026-10-10", 10, 4, today, 3)
|
||||
if e.State != StateExpired {
|
||||
t.Fatalf("state=%s want expired", e.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanExtend(t *testing.T) {
|
||||
today := d(2026, 10, 18)
|
||||
|
||||
// 未到期:从原结束日次日顺延,追加天数 = 新旧结束日之差
|
||||
oldEnd := d(2026, 11, 16)
|
||||
newEnd := d(2026, 12, 2)
|
||||
p := PlanExtend(oldEnd, newEnd, today)
|
||||
if Date(p.Base) != "2026-11-17" {
|
||||
t.Fatalf("base=%s", Date(p.Base))
|
||||
}
|
||||
if p.Days != 16 {
|
||||
t.Fatalf("days=%d want 16", p.Days)
|
||||
}
|
||||
|
||||
// 已到期:从今天起算
|
||||
oldEnd = d(2026, 10, 5)
|
||||
newEnd = d(2026, 11, 3)
|
||||
p = PlanExtend(oldEnd, newEnd, today)
|
||||
if Date(p.Base) != "2026-10-18" {
|
||||
t.Fatalf("base=%s", Date(p.Base))
|
||||
}
|
||||
if p.Days != 17 {
|
||||
t.Fatalf("days=%d want 17", p.Days)
|
||||
}
|
||||
|
||||
// 选的日期早于计算起点 → 无效
|
||||
p = PlanExtend(d(2026, 11, 16), d(2026, 11, 10), today)
|
||||
if !p.Backwards || p.Days != 0 {
|
||||
t.Fatalf("backwards=%v days=%d", p.Backwards, p.Days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthHelpers(t *testing.T) {
|
||||
if got := Date(MonthEnd(2026, time.February)); got != "2026-02-28" {
|
||||
t.Fatalf("MonthEnd=%s", got)
|
||||
}
|
||||
if got := Date(MonthEnd(2024, time.February)); got != "2024-02-29" {
|
||||
t.Fatalf("MonthEnd=%s", got)
|
||||
}
|
||||
if got := WeekdayIndex(d(2026, 10, 18)); got != 6 { // 周日
|
||||
t.Fatalf("WeekdayIndex=%d want 6", got)
|
||||
}
|
||||
if got := WeekdayIndex(d(2026, 10, 19)); got != 0 { // 周一
|
||||
t.Fatalf("WeekdayIndex=%d want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user