feat(lunar): 双端农历换算层(Go + TS,同作者同算法)

日历要支持「每农历月十五」「农历生日」这类规则。公历与农历的换算不能
自己算,两端各引一个库:Go 用 6tail/lunar-go v1.4.6,前端用同作者的
lunar-javascript 1.7.7 —— 同算法保证两端结果一致(前端要在格子上显示
农历日、在编辑器里预览接下来几次触发)。

为什么要包一层而不直接用库:

**1. 库在非法日期上 panic 而不是返回 error。**
`NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar year
2027 month 9")。农历月是 29 或 30 天不定,「每月农历三十」这条规则必然
撞上短月份。调度器里一次 panic 就让那条提醒永久卡住。
修法是夹到该月实际天数并返回 clamped 标记 —— 夹而不滚:「每月三十」的
语义是「月末那天」,滚到下月初一会让提醒与前一次只隔一天。

**2. 闰月用负数月份表示**(-6 = 闰六月),这个约定藏在库内部。
2025 有闰六月、2028 有闰五月,2026/2027 没有。AddYears 从闰月出发而
目标年没有同一闰月时退回正月份 —— 静默让重复事件消失更糟。

**3. 按农历推进不能加固定天数。**
农历月 29~30 天、农历年 353~385 天(闰年多一整月),AddDate 近似一年
能偏半个月。

前端另有一个 TS 陷阱:日名有五种前缀形态(初一/十一/二十/廿一/三十),
原来用正则从 toString() 截取时漏了「二十」,20 号会显示整串「七月二十」。
改成查表。

测试:Go 12 例 / TS 37 例。含「同一农历日在六年公历里落到至少 4 个不同
月日上」—— 那正是农历重复存在的理由(公历 yearly 会固定在同一天)。
This commit is contained in:
2026-09-04 06:27:15 +08:00
parent 473c46659a
commit 4d211c84d6
9 changed files with 1085 additions and 1 deletions

View File

@ -0,0 +1,205 @@
// Package lunar 把公历与农历互转,并提供「按农历推进」的重复规则计算。
//
// 为什么要单独一层而不直接用 lunar-go
//
// 1. **lunar-go 在非法日期上 panic 而不是返回 error**。
// `NewLunarFromYmd(2027, 9, 30)` 直接 panic("only 29 days in lunar
// year 2027 month 9") —— 农历月是 29 或 30 天不定,「每月农历三十」
// 这条规则必然会撞上 29 天的月份。调度器里一次 panic 就让那一轮所有
// 提醒全部落空(虽然有 recover 兜底,但结果是那一条提醒永久卡住)。
//
// 2. **闰月用负数月份表示**-6 = 闰六月),这个约定藏在库内部。
// 2025 有闰六月、2028 有闰五月,而 2026/2027 没有 —— 「每年农历某月
// 某日」跨过闰月年份时必须决定落在哪个月,这个决策不该散落在 repo 里。
//
// 3. **按农历推进不能靠加固定天数**。农历月 29~30 天、农历年 353~385 天
// (闰年多一个月)。用 AddDate 近似会越推越偏,一年下来能差半个月。
package lunar
import (
"fmt"
"time"
"github.com/6tail/lunar-go/calendar"
)
// Date 是一个农历日期。
//
// Month 为负数表示闰月(-6 = 闰六月),与 lunar-go 的约定一致 ——
// 刻意沿用而不另造一个 IsLeap bool两种表示混用时转换处极易写反
// 而负数在数值比较里天然排在正数前面(闰六月在六月之后,需要注意这一点,
// 见 monthsInYear 的排序)。
type Date struct {
Year int
Month int // 负数 = 闰月
Day int
}
// FromSolar 把公历时刻转成农历日期。
//
// 只取年月日,时分秒由调用方保留 —— 农历只定义到「日」,
// 「农历七月十五早上九点」的「九点」是公历时钟的概念。
func FromSolar(t time.Time) Date {
s := calendar.NewSolarFromYmd(t.Year(), int(t.Month()), t.Day())
l := s.GetLunar()
return Date{Year: l.GetYear(), Month: l.GetMonth(), Day: l.GetDay()}
}
// ToSolar 把农历日期转回公历,并带上给定的时分秒。
//
// **日期会被夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
// 返回廿九,而不是 panic 也不是滚到下个月的初一。
//
// 夹而不滚的理由:「每月农历三十」的语义是「月末那天」,滚到下月初一会让
// 提醒出现在完全错误的日子(且与前一次提醒只隔一天)。
//
// 返回的 clamped 说明是否发生了夹取 —— 调用方据此决定是否要在 UI 上提示。
func (d Date) ToSolar(loc *time.Location, hour, min, sec, nsec int) (t time.Time, clamped bool, err error) {
days, err := DaysInMonth(d.Year, d.Month)
if err != nil {
return time.Time{}, false, err
}
day := d.Day
if day > days {
day = days
clamped = true
}
if day < 1 {
return time.Time{}, false, fmt.Errorf("农历日 %d 非法", d.Day)
}
// lunar-go 在非法输入上 panic这里兜住转成 error
// 上面已经夹过日期,理论上不会触发,但闰月不存在之类的组合仍可能进来。
var solar *calendar.Solar
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("农历 %d-%d-%d 无法转公历: %v", d.Year, d.Month, day, r)
}
}()
solar = calendar.NewLunarFromYmd(d.Year, d.Month, day).GetSolar()
}()
if err != nil {
return time.Time{}, false, err
}
if solar == nil {
return time.Time{}, false, fmt.Errorf("农历 %d-%d-%d 转公历得到空值", d.Year, d.Month, day)
}
return time.Date(solar.GetYear(), time.Month(solar.GetMonth()), solar.GetDay(),
hour, min, sec, nsec, loc), clamped, nil
}
// DaysInMonth 返回某个农历月有多少天29 或 30
//
// Month 为负数时查闰月。该年没有这个闰月则返回错误 ——
// 这不是异常情况:「每年农历闰六月十五」这条规则在没有闰六月的年份
// 本来就无法落地,调用方需要据此跳过而不是猜一个日子。
func DaysInMonth(year, month int) (int, error) {
var days int
var found bool
// LunarYear.GetMonths() 是 *list.List元素为 *LunarMonth。
// 一个 LunarYear 对象里会带上跨年边界的月份,因此必须同时比对 year。
ly := calendar.NewLunarYear(year)
for e := ly.GetMonths().Front(); e != nil; e = e.Next() {
lm, ok := e.Value.(*calendar.LunarMonth)
if !ok {
continue
}
if lm.GetYear() == year && lm.GetMonth() == month {
days = lm.GetDayCount()
found = true
break
}
}
if !found {
if month < 0 {
return 0, fmt.Errorf("农历 %d 年没有闰 %d 月", year, -month)
}
return 0, fmt.Errorf("农历 %d 年没有 %d 月", year, month)
}
return days, nil
}
// LeapMonth 返回某农历年的闰月0 = 无闰月)。
func LeapMonth(year int) int {
return calendar.NewLunarYear(year).GetLeapMonth()
}
// AddMonths 在农历上推进若干个月。
//
// 逐月走而不是「月份数 + n 再取模」:中间可能夹着闰月,
// 而闰月是否存在取决于年份,没有闭式公式。
//
// 闰月的处理:**推进时跳过闰月**。从六月推一个月得七月,不是闰六月。
// 理由是「每月十五」这类规则的用户期望是一年 12 次,
// 把闰月算进去会让闰年多出一次提醒 —— 那是农历年的性质,不是提醒的性质。
// 想要闰月本身的提醒应该用 lunar_yearly 指定 -6 月。
func (d Date) AddMonths(n int) Date {
y, m := d.Year, d.Month
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
if m < 0 {
m = -m
}
for i := 0; i < n; i++ {
m++
if m > 12 {
m = 1
y++
}
}
return Date{Year: y, Month: m, Day: d.Day}
}
// AddYears 在农历上推进若干年,月份与日期保持不变。
//
// 从闰月出发时Month < 0目标年没有同一个闰月退回对应的正月份 ——
// 「去年闰六月十五」在今年最接近的对应日就是六月十五。
// 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
func (d Date) AddYears(n int) Date {
y := d.Year + n
m := d.Month
if m < 0 && LeapMonth(y) != -m {
m = -m
}
return Date{Year: y, Month: m, Day: d.Day}
}
// String 给出「二〇二六年七月廿二」这样的中文农历表示。
//
// UI 上必须显示它:农历事件的公历日期每年都在变,
// 只显示公历会让人无法确认「这条规则是不是我想的那个农历日子」。
func (d Date) String() string {
days, err := DaysInMonth(d.Year, d.Month)
if err != nil {
return fmt.Sprintf("农历 %d 年%d月%d日无效", d.Year, d.Month, d.Day)
}
day := d.Day
if day > days {
day = days
}
var out string
func() {
defer func() {
if r := recover(); r != nil {
out = fmt.Sprintf("农历 %d-%d-%d", d.Year, d.Month, d.Day)
}
}()
out = calendar.NewLunarFromYmd(d.Year, d.Month, day).String()
}()
return out
}
// FormatSolar 把公历时刻渲染成「2026-09-03农历七月廿二」。
//
// 给提醒正文与 UI 用:两种历都写出来,人才能确认规则没被理解错。
func FormatSolar(t time.Time) string {
d := FromSolar(t)
full := d.String()
// 去掉年份部分(「二〇二六年」共 4 个中文字符 + 「年」),只留月日 ——
// 公历年份已经在前面写了,重复一遍反而更难读。
if r := []rune(full); len(r) > 5 && r[4] == '年' {
full = string(r[5:])
}
return fmt.Sprintf("%s农历%s", t.Format("2006-01-02"), full)
}

View File

@ -0,0 +1,216 @@
package lunar
import (
"testing"
"time"
)
// 已知对照点。农历换算错了不会报错,只会让提醒发在错误的日子,
// 因此必须钉住几个可人工核对的锚点。
func TestFromSolarKnownDates(t *testing.T) {
cases := []struct {
solar string
year int
month int
day int
}{
{"2026-09-03", 2026, 7, 22},
{"2026-01-01", 2025, 11, 13},
// 2025 有闰六月:闰月里的日子 Month 应为负
{"2025-07-25", 2025, -6, 1},
{"2025-06-25", 2025, 6, 1},
}
for _, c := range cases {
st, err := time.Parse("2006-01-02", c.solar)
if err != nil {
t.Fatalf("解析 %s: %v", c.solar, err)
}
got := FromSolar(st)
if got.Year != c.year || got.Month != c.month || got.Day != c.day {
t.Errorf("%s → 农历 %d-%d-%d期望 %d-%d-%d",
c.solar, got.Year, got.Month, got.Day, c.year, c.month, c.day)
}
}
}
func TestToSolarRoundtrip(t *testing.T) {
loc := time.Local
for _, solar := range []string{"2026-09-03", "2027-02-14", "2028-06-30", "2025-07-25"} {
st, _ := time.Parse("2006-01-02", solar)
d := FromSolar(st)
back, clamped, err := d.ToSolar(loc, 9, 30, 0, 0)
if err != nil {
t.Errorf("%s 往返失败: %v", solar, err)
continue
}
if clamped {
t.Errorf("%s 往返不该发生夹取", solar)
}
if back.Format("2006-01-02") != solar {
t.Errorf("%s 往返得到 %s", solar, back.Format("2006-01-02"))
}
if back.Hour() != 9 || back.Minute() != 30 {
t.Errorf("%s 往返丢了时分:%v", solar, back)
}
}
}
// 农历月是 29 或 30 天不定,「每月农历三十」必然撞上 29 天的月份。
// lunar-go 在这种输入上**panic** 而不是返回 error —— 必须被夹住。
func TestToSolarClampsShortMonth(t *testing.T) {
// 2027 农历九月只有 29 天
days, err := DaysInMonth(2027, 9)
if err != nil {
t.Fatalf("查天数: %v", err)
}
if days != 29 {
t.Fatalf("前提变了2027 农历九月现在是 %d 天", days)
}
got, clamped, err := Date{Year: 2027, Month: 9, Day: 30}.ToSolar(time.Local, 9, 0, 0, 0)
if err != nil {
t.Fatalf("三十日应被夹到廿九而不是报错,得到 %v", err)
}
if !clamped {
t.Error("应报告发生了夹取")
}
// 夹取后应等于该月廿九
want, _, _ := Date{Year: 2027, Month: 9, Day: 29}.ToSolar(time.Local, 9, 0, 0, 0)
if !got.Equal(want) {
t.Errorf("夹取后 %v期望与廿九相同 %v", got, want)
}
// 且必须仍在同一个农历月内 —— 滚到下月初一是错的
if FromSolar(got).Month != 9 {
t.Errorf("夹取后跑出了农历九月:%s", FromSolar(got).String())
}
}
func TestToSolarRejectsNonexistentLeapMonth(t *testing.T) {
// 2026 无闰月
if LeapMonth(2026) != 0 {
t.Fatalf("前提变了2026 闰月 = %d", LeapMonth(2026))
}
_, _, err := Date{Year: 2026, Month: -6, Day: 1}.ToSolar(time.Local, 9, 0, 0, 0)
if err == nil {
t.Error("不存在的闰月应返回错误而不是猜一个日子")
}
}
func TestLeapMonth(t *testing.T) {
cases := map[int]int{2025: 6, 2026: 0, 2027: 0, 2028: 5}
for y, want := range cases {
if got := LeapMonth(y); got != want {
t.Errorf("LeapMonth(%d) = %d期望 %d", y, got, want)
}
}
}
func TestDaysInMonth(t *testing.T) {
if d, err := DaysInMonth(2026, 1); err != nil || d != 30 {
t.Errorf("2026 正月应 30 天,得到 %d err=%v", d, err)
}
if d, err := DaysInMonth(2027, 9); err != nil || d != 29 {
t.Errorf("2027 九月应 29 天,得到 %d err=%v", d, err)
}
if _, err := DaysInMonth(2026, -6); err == nil {
t.Error("2026 没有闰六月,应返回错误")
}
if _, err := DaysInMonth(2026, 13); err == nil {
t.Error("13 月应返回错误")
}
}
// 按农历推进不能靠加固定天数:农历月 29~30 天,闰年 13 个月。
func TestAddMonths(t *testing.T) {
d := Date{Year: 2026, Month: 7, Day: 22}
if got := d.AddMonths(1); got.Month != 8 || got.Year != 2026 {
t.Errorf("+1 月 = %d-%d期望 2026-8", got.Year, got.Month)
}
// 跨年
if got := (Date{Year: 2026, Month: 12, Day: 5}).AddMonths(1); got.Year != 2027 || got.Month != 1 {
t.Errorf("腊月 +1 = %d-%d期望 2027-1", got.Year, got.Month)
}
// 推 12 次回到同月次年
if got := d.AddMonths(12); got.Year != 2027 || got.Month != 7 {
t.Errorf("+12 月 = %d-%d期望 2027-7", got.Year, got.Month)
}
// 从闰月出发先归正:闰六月 +1 → 七月(一年 12 次,不因闰年多一次)
if got := (Date{Year: 2025, Month: -6, Day: 15}).AddMonths(1); got.Month != 7 {
t.Errorf("闰六月 +1 = %d 月,期望 7 月", got.Month)
}
}
func TestAddYears(t *testing.T) {
if got := (Date{Year: 2026, Month: 7, Day: 22}).AddYears(1); got.Year != 2027 || got.Month != 7 {
t.Errorf("+1 年 = %d-%d", got.Year, got.Month)
}
// 从闰月出发、目标年没有同一闰月 → 退回正月份而不是静默消失
got := (Date{Year: 2025, Month: -6, Day: 15}).AddYears(1)
if got.Year != 2026 || got.Month != 6 {
t.Errorf("闰六月 +1 年 = %d-%d期望 2026-6退回正六月", got.Year, got.Month)
}
// 目标年恰好也有同一闰月 → 保持闰月
// 2025 闰六月 → 2028 闰五月,所以这里构造 2028 的闰五月 +0 年
if LeapMonth(2028) == 5 {
keep := (Date{Year: 2028, Month: -5, Day: 1}).AddYears(0)
if keep.Month != -5 {
t.Errorf("目标年有同一闰月时应保持,得到 %d", keep.Month)
}
}
}
// 「每年农历某月某日」的公历日期每年都在漂移 —— 这正是需要农历重复的理由。
// 如果用公历 yearly日子会固定与用户的期望过农历生日/祭日)不符。
func TestYearlyLunarDriftsInSolar(t *testing.T) {
seen := map[string]bool{}
d := Date{Year: 2026, Month: 7, Day: 22}
for i := 0; i < 6; i++ {
st, _, err := d.AddYears(i).ToSolar(time.Local, 9, 0, 0, 0)
if err != nil {
t.Fatalf("第 %d 年转换失败: %v", i, err)
}
seen[st.Format("01-02")] = true
}
if len(seen) < 4 {
t.Errorf("六年里公历月日只有 %d 种,农历重复应当漂移", len(seen))
}
}
func TestStringChinese(t *testing.T) {
got := (Date{Year: 2026, Month: 7, Day: 22}).String()
if got != "二〇二六年七月廿二" {
t.Errorf("String() = %q期望 二〇二六年七月廿二", got)
}
// 闰月要带「闰」字,否则人分不清是哪个月
leap := (Date{Year: 2025, Month: -6, Day: 1}).String()
if leap != "二〇二五年闰六月初一" {
t.Errorf("闰月 String() = %q", leap)
}
// 非法日期不 panic
bad := (Date{Year: 2026, Month: 13, Day: 1}).String()
if bad == "" {
t.Error("非法日期应返回可读文本而不是空串")
}
}
func TestFormatSolar(t *testing.T) {
st, _ := time.Parse("2006-01-02", "2026-09-03")
got := FormatSolar(st)
if got != "2026-09-03农历七月廿二" {
t.Errorf("FormatSolar = %q期望 2026-09-03农历七月廿二", got)
}
}
// 时区:农历只定义到「日」,时分秒是公历时钟的概念,必须原样带过去。
func TestToSolarKeepsClockTime(t *testing.T) {
got, _, err := (Date{Year: 2026, Month: 7, Day: 22}).ToSolar(time.Local, 14, 45, 30, 0)
if err != nil {
t.Fatalf("转换: %v", err)
}
if got.Hour() != 14 || got.Minute() != 45 || got.Second() != 30 {
t.Errorf("时钟被改动:%v", got)
}
if got.Location() != time.Local {
t.Errorf("时区被改动:%v", got.Location())
}
}