Files
MailUI4Agents/server/internal/lunar/lunar.go

206 lines
7.3 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 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)
}