## 主体是 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)`,那种绿灯不算数。
202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
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})
|
||
}
|