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.
 
 
 
 
 
 

310 lines
9.9 KiB

package memory
import (
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
"yunyan/lego/sys/mysql"
"yunyan/pb"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
type modelComp struct {
cbase.ModuleCompBase
module *Memory
}
// Init 建表。
//
// ⚠️ 这里**绝不能把 err 返回出去**。lego.Run 里 service.Init 一旦拿到非 nil 就
// log.Panicf 让 home 退出,而 gateway/home/api/mcp/timer 五个服务打在同一个镜像里,
// entrypoint 见任一 pid 退出就 kill 掉其余全部 —— 一次建表失败 = 全站 502。
// (allhelp/model.go 那段用了命名返回值 err,第二张表失败时会漏出去,是反例,别照抄。)
func (this *modelComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) error {
this.ModuleCompBase.Init(service, module, comp, opt)
this.module = module.(*Memory)
if e := mysql.CreateTable(comm.TableMemoryItem, &pb.DBMemoryItem{}); e != nil {
this.module.Errorf("建表 %s 失败(本模块功能不可用,但不阻断服务启动): %v", comm.TableMemoryItem, e)
}
if e := mysql.CreateTable(comm.TableMemoryReport, &pb.DBMemoryReport{}); e != nil {
this.module.Errorf("建表 %s 失败(本模块功能不可用,但不阻断服务启动): %v", comm.TableMemoryReport, e)
}
return nil
}
// ===== 记忆项 =====
// itemQuery listItems 的查询条件
type itemQuery struct {
uid string
startDate string
endDate string
categories []string
states []int32
page int32
size int32
}
func (this *modelComp) addItem(item *pb.DBMemoryItem) error {
now := time.Now().Unix()
item.CreateTime = now
item.UpdateTime = now
return mysql.Insert(comm.TableMemoryItem, item)
}
func (this *modelComp) findItemByClientKey(uid, clientKey string) (*pb.DBMemoryItem, error) {
if clientKey == "" {
return nil, nil
}
item := &pb.DBMemoryItem{}
err := mysql.FindOne(comm.TableMemoryItem, item, "uid=? and client_key=?", uid, clientKey)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return item, nil
}
// getItem 按 (uid, id) 取。**必须带 uid**——只按 id 查等于任何登录用户都能读别人的记忆项。
func (this *modelComp) getItem(uid string, id uint64) (*pb.DBMemoryItem, error) {
item := &pb.DBMemoryItem{}
err := mysql.FindOne(comm.TableMemoryItem, item, "uid=? and id=?", uid, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return item, nil
}
func (this *modelComp) saveItem(item *pb.DBMemoryItem) error {
item.UpdateTime = time.Now().Unix()
return mysql.Save(comm.TableMemoryItem, item)
}
func (this *modelComp) listItems(q *itemQuery) ([]*pb.DBMemoryItem, int64, error) {
items := make([]*pb.DBMemoryItem, 0)
tx := mysql.Table(comm.TableMemoryItem).Where("uid = ?", q.uid)
if q.startDate != "" {
tx = tx.Where("happen_date >= ?", q.startDate)
}
if q.endDate != "" {
tx = tx.Where("happen_date <= ?", q.endDate)
}
if len(q.categories) > 0 {
tx = tx.Where("category in ?", q.categories)
}
if len(q.states) > 0 {
tx = tx.Where("state in ?", q.states)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
// 排序口径:先按日期,再按时刻(无时刻的排在当天最后),最后按 id 保证稳定
tx = tx.Order("happen_date asc").Order("happen_time = '' asc").Order("happen_time asc").Order("id asc")
if q.size > 0 {
offset := 0
if q.page > 1 {
offset = int(q.page-1) * int(q.size)
}
tx = tx.Offset(offset).Limit(int(q.size))
}
if err := tx.Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (this *modelComp) delItems(uid string, ids []uint64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
tx := mysql.Table(comm.TableMemoryItem).Where("uid = ? and id in ?", uid, ids).Delete(&pb.DBMemoryItem{})
return tx.RowsAffected, tx.Error
}
func (this *modelComp) completeItems(uid string, ids []uint64, done bool) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now().Unix()
values := map[string]interface{}{"update_time": now}
if done {
values["state"] = int32(pb.MemoryState_MemoryState_Done)
values["finish_time"] = now
} else {
values["state"] = int32(pb.MemoryState_MemoryState_Pending)
values["finish_time"] = 0
}
tx := mysql.Table(comm.TableMemoryItem).Where("uid = ? and id in ?", uid, ids).Updates(values)
return tx.RowsAffected, tx.Error
}
// nextGenRound 这条会议下一轮的轮次号。没有历史项时返回 1。
//
// 查不到或出错一律返回 1:宁可这一轮清不干净(留下几条重复待办,用户能自己删),
// 也不要返回一个大数把用户改过的项之外的东西全删了。
func (this *modelComp) nextGenRound(sourceID string) int32 {
var maxRound int32
err := mysql.Table(comm.TableMemoryItem).
Where("source = ? and source_id = ?", comm.MemorySrcMeeting, sourceID).
Select("coalesce(max(gen_round), 0)").Scan(&maxRound).Error
if err != nil {
this.module.Warnf("取会议 %s 的 gen_round 失败,按第 1 轮处理: %v", sourceID, err)
return 1
}
return maxRound + 1
}
// delMeetingItems 会议重新生成时清掉上一轮自动抽取的项。
// user_edited=1 的一律保留——不做这个,用户点一次「重新生成」就丢掉自己的修改。
func (this *modelComp) delMeetingItems(sourceID string, genRound int32) (int64, error) {
tx := mysql.Table(comm.TableMemoryItem).
Where("source = ? and source_id = ? and user_edited = ? and gen_round < ?",
comm.MemorySrcMeeting, sourceID, false, genRound).
Delete(&pb.DBMemoryItem{})
return tx.RowsAffected, tx.Error
}
// ===== 周期报告 =====
func (this *modelComp) upsertReport(rec *pb.DBMemoryReport) error {
old, err := this.getReportByPeriod(rec.Uid, rec.PeriodType, rec.PeriodKey)
if err != nil {
return err
}
now := time.Now().Unix()
if old != nil {
// 已确认的报告不重算——用户看过的东西不要在他背后变
if old.Confirmed {
proto.Merge(rec, old) // 不能 *rec = *old:pb 结构体含 sync.Mutex,值拷贝会被 vet 拦
rec.Id = old.Id
return nil
}
rec.Id = old.Id
rec.CreateTime = old.CreateTime
rec.UpdateTime = now
return mysql.Save(comm.TableMemoryReport, rec)
}
rec.CreateTime = now
rec.UpdateTime = now
return mysql.Insert(comm.TableMemoryReport, rec)
}
func (this *modelComp) saveReport(rec *pb.DBMemoryReport) error {
rec.UpdateTime = time.Now().Unix()
return mysql.Save(comm.TableMemoryReport, rec)
}
func (this *modelComp) getReport(id uint64) (*pb.DBMemoryReport, error) {
rec := &pb.DBMemoryReport{}
err := mysql.FindOne(comm.TableMemoryReport, rec, "id=?", id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return rec, nil
}
func (this *modelComp) getReportByPeriod(uid, ptype, pkey string) (*pb.DBMemoryReport, error) {
rec := &pb.DBMemoryReport{}
err := mysql.FindOne(comm.TableMemoryReport, rec, "uid=? and period_type=? and period_key=?", uid, ptype, pkey)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return rec, nil
}
// latestUnconfirmedReport 最近一份「已生成完、用户还没确认」的报告。
// 用户一个月不开 App 会攒下 4 份周报 + 1 份月报,一次弹 5 次没人受得了,
// 所以弹窗只认这一份,其余去历史列表里翻。
func (this *modelComp) latestUnconfirmedReport(uid string) (*pb.DBMemoryReport, error) {
recs := make([]*pb.DBMemoryReport, 0)
err := mysql.Table(comm.TableMemoryReport).
Where("uid = ? and state = ? and confirmed = ?", uid, int32(pb.MemoryReportState_MemoryReportState_Done), false).
Order("period_end desc").Limit(1).Find(&recs).Error
if err != nil {
return nil, err
}
if len(recs) == 0 {
return nil, nil
}
return recs[0], nil
}
func (this *modelComp) listReports(uid, ptype string, page, size int32) ([]*pb.DBMemoryReport, int64, error) {
recs := make([]*pb.DBMemoryReport, 0)
tx := mysql.Table(comm.TableMemoryReport).Where("uid = ?", uid)
if ptype != "" {
tx = tx.Where("period_type = ?", ptype)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
if size <= 0 {
size = 20
}
offset := 0
if page > 1 {
offset = int(page-1) * int(size)
}
err := tx.Order("period_end desc").Offset(offset).Limit(int(size)).Find(&recs).Error
return recs, total, err
}
// uidsWithItemsBetween 上周期里记录数达到阈值的用户。
//
// 只给这些人生成报告:空报告没意义还白烧 LLM 调用。阈值也是当前唯一的成本闸门
// ——既有的额度桶(Meetintegral/Aichatintegral)全是「用户主动发起才扣」的语义,
// cron 主动生成的东西扣用户额度说不通,见设计文档 §12 待确认 1。
func (this *modelComp) uidsWithItemsBetween(start, end string, minItems int) ([]string, error) {
uids := make([]string, 0)
err := mysql.Table(comm.TableMemoryItem).
Select("uid").
Where("happen_date >= ? and happen_date <= ?", start, end).
Group("uid").
Having(fmt.Sprintf("count(*) >= %d", minItems)).
Pluck("uid", &uids).Error
return uids, err
}
// itemFieldWhitelist memory_update 允许客户端点名更新的字段。
// 白名单而不是黑名单:漏挡一个 uid/id/create_time 就是越权改别人的数据。
var itemFieldWhitelist = map[string]bool{
"category": true, "title": true, "detail": true,
"happen_date": true, "happen_time": true, "date_certain": true, "due_raw": true, "tz": true,
"repeat_rule": true, "repeat_raw": true, "weekday": true,
"amount_cents": true, "currency": true, "owner": true,
"state": true, "remind_ahead": true, "extra": true,
}
func normalizeFields(fields []string) []string {
out := make([]string, 0, len(fields))
seen := map[string]bool{}
for _, f := range fields {
f = strings.ToLower(strings.TrimSpace(f))
if itemFieldWhitelist[f] && !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}