Files
MailUI4Agents/server/internal/handler/lunar.go
JianFeeeee 2da38bba83 农历走服务端端点:换算只在服务端做一次(两边各写一遍天文算法迟早差一天)
用户定的方案:「加 api 端点」。

## 为什么不移植到 ArkTS

`lunar-javascript` 的 `lunar.js` 有 **43 万字节**,内部是**日月位置的级数展开**
(实测:全文件最大的数字字面量是 16KB 的系数数组,**不是**"某年到某年的月长表")。
即"照搬一张小数据表"这条路**不存在** —— 移植等于在 ArkTS 里再实现一遍天文算法。
两份实现迟早会在某个闰月或某个朔日上差一天,而那种错**表现为日期错位、不是报错**,
界面上完全看不出(用户得自己去查日历才知道)。

所以:`GET /api/v1/calendar/lunar?from=&to=`(服务端 `internal/lunar`,同一作者的 lunar-go)。

## 形状是「按日期键索引的映射」,不是数组

客户端拿到 `map[iso] -> 标签` 直接按格子键查,不用自己遍历比对。
`text` 字段是**格子里直接显示的那个串**(初一=月名、其余=日名)——
由服务端定,两端同源。客户端各拼一份的话,同一天在两边日历上可能长得不一样
(例如闰月到底写不写「闰」)。

几个刻意的取舍:
- **不设默认 from/to**:默认范围会让「我要 3 月」与「服务端以为我要这个月」悄悄不一致;
- 入参只收 `YYYY-MM-DD`(**日期键**,不是 RFC3339):农历是"这一天是农历几号"的
  纯日期语义,混用时间戳会被时区挪一天;
- 换不出来的日子**不进 map**(客户端查不到 ⇒ 那格不显示农历),而不是塞空对象 ——
  空对象会让客户端以为"有农历、只是没内容";
- 区间上限 400 天(不是安全边界,是防客户端传十年前到十年后)。

## 判据(`server/internal/handler/lunar_test.go`)

参照物是服务端的 `internal/lunar`(权威实现),**不抄一份答案表** —— 库升级时判据跟着走。
钉的点各自对着一个会静默出错的错法:
- `Full` 与权威实现逐字一致(5 个日期,含春节、跨世纪、29 天月的边界年份);
- 日名表覆盖 1..30 且**五种前缀形态都在**(WebUI 那版漏过「二十」);
- ★ 初一显示**月名**、其余显示**日名**(与 WebUI `cellLunarLabel()` 同一口径);
- 闰月必须带「闰」字(不带的话闰六月与六月在格子里一样);
- 极端值(公元 1 年 / 1900 / 2100 / 9999)**不许 panic**,且换出来时文字里不许含
  「无效/NaN」这类失败标记。

★ 最后一条我第一版**写错了**:断言「`time.Time{}` 应当换不出来」,实测库**换得出来**
  (0001-01-01 → 「〇年冬月十八」)—— 我断言的是自己的想象而不是实际行为。
  改成断言真正的契约(不 panic / 失败就不给 / 给了就得是真结果)后才对。

## 客户端

`LunarLabels` / `LunarRangeResponse` 两个模型 + `CalendarApi.listLunar(fromIso, toIso)`;
`CalendarPage` 在 `loadEvents()` 之后**不 await** 地拉农历(附加信息不该拖慢事件列表),
失败**不算 `this.error`**(否则"农历服务抖一下"会变成"整个日历打不开"),
只写 hilog 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
2026-09-18 11:23:12 +08:00

200 lines
7.5 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 handler
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/agentmail/gateway/internal/lunar"
"github.com/agentmail/gateway/internal/middleware"
)
/*
* ── 农历换算(给客户端的日历格子用)──
*
* # 为什么要有这个端点(而不是让每个客户端自带一份换算)
*
* WebUI 用 `lunar-javascript`,服务端用 `lunar-go` —— 都是同一个作者(6tail)的实现,
* 所以换算结果一致。但这是**两份实现**,而农历是**天文算法**:
* `lunar-javascript` 的 `lunar.js` 有 43 万字节、内部是日月位置的级数展开,
* **没有一张可以照搬的小数据表**(实测:全文件最大的数字字面量是 16KB 的级数系数数组,
* 不是"某年到某年的月长表")。因此"把它移植到 ArkTS"这条路等于**再写一遍天文算法**,
* 两边迟早会在某个闰月或某个朔日上差一天 —— 而那种错**表现为日期错位,不是报错**。
*
* 所以换算只在服务端做一次,各客户端取结果。鸿蒙那边原来就是"没做"(`CalendarPage.ets`
* 的注释里写着),这个端点是把那条补上的前提。
*
* # 形状
*
* `GET /api/v1/calendar/lunar?from=YYYY-MM-DD&to=YYYY-MM-DD`
*
* 返回一个**按日期键索引的映射**(不是数组):客户端拿到后是 `map[iso] -> 标签`,
* 直接按格子键查,不需要自己遍历比对。范围是**闭区间**,且必须给全(不给默认值 ——
* 默认范围会让"我要的是 3 月"和"服务端以为我要这个月"悄悄不一致)。
*
* {"lunar": {"2026-09-18": {"day_name":"廿二","month_name":"七月","is_first_day":false,
* "text":"廿二","leap":false,"full":"二〇二六年七月廿二"}}}
*
* `text` 是**格子里直接显示的那个串**:初一显示月名(如「七月」)、其余显示日名
* (如「廿二」)—— 与 WebUI `lib/lunar.ts` 的 `cellLunarLabel()` 同一口径。
* 每格都写完整月日会让格子全是重复的月份字样,而格子只有几十像素宽。
*
* 范围上限:**不超过 400 天**。这不是安全边界(换算很便宜),而是防"客户端传了一个
* 十年前到十年后的范围"把响应撑到几 MB —— 日历一次最多显示 6 周。
*/
// lunarRangeMaxDays 是一次请求允许的最大天数(含首尾)。
const lunarRangeMaxDays = 400
// parseDateKey 解析 `YYYY-MM-DD`(**按 UTC**,与纯逻辑层同一口径)。
//
// 不接 RFC3339 时间戳:这个端点的入参是"日期键"(格子的键),
// 不是"瞬间"。混用会让时区偏移把日期挪一天 —— 而界面看起来完全正常。
func parseDateKey(s string) (time.Time, bool) {
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// LunarLabels 是单个日期的农历标签。
type LunarLabels struct {
// DayName 农历日名(初一…三十)
DayName string `json:"day_name"`
// MonthName 农历月名(正…冬/腊,闰月带「闰」)
MonthName string `json:"month_name"`
// IsFirstDay 是否初一(客户端据此决定格子里显示月名还是日名)
IsFirstDay bool `json:"is_first_day"`
// Text 格子里直接显示的那个串(初一=月名,其余=日名)
Text string `json:"text"`
// Leap 是否闰月
Leap bool `json:"leap"`
// Full 完整中文表示(「二〇二六年七月廿二」),给详情/表单预览用
Full string `json:"full"`
}
// lunarMonthNames 农历月名。十一/十二月习惯写「冬月」「腊月」——
// 与 `lunar-javascript` 的 `Lunar.getMonthInChinese()` 一致(实测它也是这两个字)。
var lunarMonthNames = []string{
"", "正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊",
}
// lunarDayNames 农历日名查表。
//
// ★ **不用正则从 `Date.String()` 里截**:实测日名前缀有五种形态
// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。
var lunarDayNames = []string{
"", "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十",
}
// labelsOf 把一个公历日期转成农历标签。
//
// ★ 越界/异常一律回 *零值+false*,**不猜**:农历换算是天文算法,
// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。
// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。
func labelsOf(t time.Time) (LunarLabels, bool) {
var d lunar.Date
ok := func() (good bool) {
defer func() {
if recover() != nil {
good = false
}
}()
d = lunar.FromSolar(t)
return true
}()
if !ok {
return LunarLabels{}, false
}
absMonth := d.Month
leap := false
if absMonth < 0 {
absMonth = -absMonth
leap = true
}
if absMonth < 1 || absMonth > 12 || d.Day < 1 || d.Day > 30 {
return LunarLabels{}, false
}
monthName := lunarMonthNames[absMonth] + "月"
if leap {
/* 闰月的「闰」必须带上:否则闰六月与六月在格子里长得一模一样 */
monthName = "闰" + monthName
}
dayName := lunarDayNames[d.Day]
if dayName == "" {
return LunarLabels{}, false
}
full := d.String()
/*
* 格子里显示什么:**初一显示月名、其余显示日名**(纸质日历的惯例)。
* 与 WebUI `cellLunarLabel()` 同一口径 —— 两端格子里必须是同一个串,
* 否则同一天在两端的日历上长得不一样。
*/
text := dayName
if d.Day == 1 {
text = monthName
}
return LunarLabels{
DayName: dayName,
MonthName: monthName,
IsFirstDay: d.Day == 1,
Text: text,
Leap: leap,
Full: full,
}, true
}
// LunarLabelsHandler 处理 `GET /api/v1/calendar/lunar?from=&to=`。
func LunarLabelsHandler(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
fromStr := strings.TrimSpace(r.URL.Query().Get("from"))
toStr := strings.TrimSpace(r.URL.Query().Get("to"))
if fromStr == "" || toStr == "" {
/* 不设默认值:默认范围会让"我要 3 月"与"服务端以为我要这个月"悄悄不一致 */
Error(w, http.StatusBadRequest, "from and to are required (YYYY-MM-DD)")
return
}
from, okFrom := parseDateKey(fromStr)
to, okTo := parseDateKey(toStr)
if !okFrom || !okTo {
Error(w, http.StatusBadRequest, "from and to must be YYYY-MM-DD")
return
}
if to.Before(from) {
Error(w, http.StatusBadRequest, "to must not be before from")
return
}
days := int(to.Sub(from).Hours()/24) + 1
if days > lunarRangeMaxDays {
Error(w, http.StatusBadRequest,
"range too large: at most "+strconv.Itoa(lunarRangeMaxDays)+" days")
return
}
out := make(map[string]LunarLabels, days)
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
if labels, ok := labelsOf(d); ok {
out[d.Format("2006-01-02")] = labels
}
/*
* 换不出来就**不放进 map**(客户端查不到 ⇒ 那一格不显示农历),
* 而不是塞一个空对象 —— 空对象会让客户端以为"这一天有农历、只是没内容"。
*/
}
JSON(w, http.StatusOK, map[string]interface{}{"lunar": out})
}