Files
MailUI4Agents/server/internal/handler/lunar.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +08:00

202 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})
}