首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+206
View File
@@ -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
}
+152
View File
@@ -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)
}
}