You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

188 lines
6.2 KiB

package comm
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
)
/*
记忆中心(拾忆)的跨模块公共定义。
放在 comm 而不是 modules/memory 的理由:`mcp` 是独立进程、独立服务,它的工具要按
同一套口径读同一张表;分类白名单、日期口径、重复规则展开这三件事一旦两边各写一份,
改了一边不会报错、只会让 EMAI 答得和 App 里显示的不一样。
*/
// 记忆项分类白名单。新增分类在这里加一行即可——不改表、不迁移。
//
// ⚠️ 客户端对不认识的分类必须兜底成 todo 渲染(老包收到新分类不能崩),
// 所以这里加分类是安全的,但客户端的兜底一定要先发出去。
const (
MemoryCatAlarm = "alarm" //闹钟
MemoryCatTodo = "todo" //待办
MemoryCatIdea = "idea" //灵感
MemoryCatExpense = "expense" //花销
)
// 记忆项来源
const (
MemorySrcMeeting = "meeting" //会议纪要抽取
MemorySrcAssistant = "assistant" //EMAI 端侧指令
MemorySrcManual = "manual" //用户手动录入
MemorySrcDevice = "device" //设备侧
)
// 重复规则
const (
MemoryRepeatOnce = "once"
MemoryRepeatDaily = "daily"
MemoryRepeatWeekly = "weekly"
MemoryRepeatWeekdays = "weekdays"
MemoryRepeatWeekend = "weekend"
MemoryRepeatMonthly = "monthly"
)
// 周期报告类型
const (
MemoryPeriodWeek = "week"
MemoryPeriodMonth = "month"
)
var memoryCategories = map[string]bool{
MemoryCatAlarm: true,
MemoryCatTodo: true,
MemoryCatIdea: true,
MemoryCatExpense: true,
}
var memoryRepeatRules = map[string]bool{
MemoryRepeatOnce: true,
MemoryRepeatDaily: true,
MemoryRepeatWeekly: true,
MemoryRepeatWeekdays: true,
MemoryRepeatWeekend: true,
MemoryRepeatMonthly: true,
}
// IsValidMemoryCategory 分类是否在白名单里
func IsValidMemoryCategory(c string) bool { return memoryCategories[c] }
// IsValidMemoryRepeat 重复规则是否合法(空串等同 once)
func IsValidMemoryRepeat(r string) bool { return r == "" || memoryRepeatRules[r] }
// MemoryDateLayout 记忆项日期的唯一格式。happen_date 是「用户本地日期」,
// 不是 UTC 日期——服务端不做时区推断,一律以客户端上报的为准。
const MemoryDateLayout = "2006-01-02"
// ParseMemoryDate 解析 YYYY-MM-DD。失败返回零值与 false。
func ParseMemoryDate(s string) (time.Time, bool) {
t, err := time.Parse(MemoryDateLayout, strings.TrimSpace(s))
if err != nil {
return time.Time{}, false
}
return t, true
}
// FormatMemoryDate 按统一格式输出
func FormatMemoryDate(t time.Time) string { return t.Format(MemoryDateLayout) }
// LoadMemoryLocation 解析客户端上报的 IANA 时区名。
//
// ⚠️ 认不出来一律回退到容器本地时区(Asia/Shanghai,见 Dockerfile 与 compose 的 TZ),
// **绝不回退成 UTC**——那会让所有时刻整体偏移 8 小时,而且是静默的。
// 容器已 import _ "time/tzdata",缺 tzdata 的镜像也能解析。
func LoadMemoryLocation(tz string) *time.Location {
tz = strings.TrimSpace(tz)
if tz == "" {
return time.Local
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return loc
}
// ParseMemoryClock 解析 HH:mm。空串或非法返回 (0,0,false)。
func ParseMemoryClock(s string) (hour, minute int, ok bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0, false
}
t, err := time.Parse("15:04", s)
if err != nil {
return 0, 0, false
}
return t.Hour(), t.Minute(), true
}
// NewMemoryClientKey 生成幂等键。
//
// ⚠️ client_key 在库里参与 (uid, client_key) 唯一索引,而 **MySQL 的唯一索引对空串
// 不像对 NULL 那样放行重复**——留空会让同一用户的第二条手动记录直接插入失败。
// 所以客户端没传时服务端必须现生成一个。用 crypto/rand 而不是 math/rand:
// 这个值会被当成去重依据,可预测就意味着可被别人抢占。
func NewMemoryClientKey() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// crypto/rand 失败是极端情况;退化成时间戳仍然唯一(同一用户不可能同纳秒两次插入)
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// MemoryWeekKey 返回 ISO 周键,形如 2026-W35。
//
// ⚠️ 口径固定为 ISO-8601(周一为一周之首)。美国等地区习惯周日起,
// 这里不做地区适配——一个应用里「上周」必须只有一种含义,否则报告的
// 时间范围会因用户所在地而异,同一份数据算出两个结果。
func MemoryWeekKey(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
}
// MemoryMonthKey 返回月键,形如 2026-08
func MemoryMonthKey(t time.Time) string { return t.Format("2006-01") }
// MemoryWeekRange 返回 t 所在 ISO 周的周一与周日(按 t 自身的时区)
func MemoryWeekRange(t time.Time) (start, end time.Time) {
// Go 的 Weekday() 里周日是 0,ISO 周一为首,先换算成「距周一几天」
offset := (int(t.Weekday()) + 6) % 7
start = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
end = start.AddDate(0, 0, 6)
return
}
// MemoryMonthRange 返回 t 所在自然月的首末日(按 t 自身的时区)
func MemoryMonthRange(t time.Time) (start, end time.Time) {
start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
end = start.AddDate(0, 1, -1)
return
}
// MemoryRepeatHits 判断某一天是否命中重复规则。
//
// weekday 用 1-7 表示周一到周日(与客户端 Dart 的 DateTime.weekday 一致,
// **不是** Go 的 time.Weekday——Go 里周日是 0,直接比会把周日错判成周一)。
func MemoryRepeatHits(rule string, weekday int32, day time.Time, anchor time.Time) bool {
dartWeekday := int32(day.Weekday())
if dartWeekday == 0 {
dartWeekday = 7 // 周日:Go 的 0 → Dart 的 7
}
switch rule {
case MemoryRepeatDaily:
return true
case MemoryRepeatWeekly:
return dartWeekday == weekday
case MemoryRepeatWeekdays:
return dartWeekday >= 1 && dartWeekday <= 5
case MemoryRepeatWeekend:
return dartWeekday == 6 || dartWeekday == 7
case MemoryRepeatMonthly:
return day.Day() == anchor.Day()
}
return false
}