diff --git a/apps/client/lib/core/utils/app_lifecycle_manager.dart b/apps/client/lib/core/utils/app_lifecycle_manager.dart index 4d97f4d9..56090710 100644 --- a/apps/client/lib/core/utils/app_lifecycle_manager.dart +++ b/apps/client/lib/core/utils/app_lifecycle_manager.dart @@ -11,6 +11,7 @@ import '../../data/models/user_Info.dart'; import '../../data/services/memory_reminder_scheduler.dart'; import '../../data/services/memory_service.dart'; import '../../modules/main_tab/views/widgets/memory_daily_sheet.dart'; +import '../../modules/main_tab/views/widgets/memory_report_sheet.dart'; import '../services/native_localization_sync.dart'; import '../translations/app_translations.dart'; //import '../../modules/settings/controllers/settings_controller.dart'; @@ -155,6 +156,13 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver { } catch (e) { Logger.warning('拾忆提醒重排失败(已忽略): $e'); } + // 周报/月报弹窗排在每日安排之后:两个都要播报,同时弹会互相盖住, + // 而每日安排是「今天要做什么」,比回顾更即时。 + try { + await MemoryReportSheet.maybeShow(); + } catch (e) { + Logger.warning('拾忆报告弹窗失败(已忽略): $e'); + } } /// 处理应用进入后台 diff --git a/apps/client/lib/modules/main_tab/views/widgets/memory_report_sheet.dart b/apps/client/lib/modules/main_tab/views/widgets/memory_report_sheet.dart new file mode 100644 index 00000000..a594bcaa --- /dev/null +++ b/apps/client/lib/modules/main_tab/views/widgets/memory_report_sheet.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '../../../../core/utils/logger.dart'; +import '../../../../data/models/memory_item.dart'; +import '../../../../data/services/memory_service.dart'; +import '../../../../data/services/tts_service.dart'; + +/// 周报/月报弹窗:播报 AI 文案 + 用户确认。 +/// +/// ⚠️ **一次只弹最近一份未确认的**。用户一个月不开 App 会攒下 4 份周报 + 1 份 +/// 月报,连弹 5 次没人受得了——服务端的 memory_getreport 不带参数时就只返回 +/// 最近一份,其余去历史列表里翻。 +/// +/// ⚠️ 报告还在生成时服务端返回 MemoryReportNotReady(5205),MemoryService 那边 +/// 拿到 null 就当「暂时没有」。**不要弹错**:报告没生成完不是故障。 +class MemoryReportSheet { + static const String _tag = 'MemoryReport'; + static const String _sessionId = 'memory_report_briefing'; + + static bool _showing = false; + + /// 有待确认的报告就弹出来。没有则静默返回。 + static Future maybeShow() async { + if (_showing) return; + _showing = true; + try { + final report = await MemoryService.to.loadPendingReport(); + if (report == null || report.confirmed) return; + await Get.bottomSheet( + _ReportSheetView(report: report), + isScrollControlled: true, + isDismissible: false, // 需求 4:播报后需要用户确认 + ); + } catch (e) { + Logger.w(_tag, '报告弹窗失败(不影响主流程): $e'); + } finally { + _showing = false; + } + } +} + +class _ReportSheetView extends StatefulWidget { + final MemoryReport report; + const _ReportSheetView({required this.report}); + + @override + State<_ReportSheetView> createState() => _ReportSheetViewState(); +} + +class _ReportSheetViewState extends State<_ReportSheetView> { + bool _speaking = false; + bool _confirming = false; + TtsService? _tts; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _speak()); + } + + @override + void dispose() { + _stop(); + super.dispose(); + } + + Future _speak() async { + final text = widget.report.summary; + // 文案生成失败时服务端只回写统计、summary 为空(那是刻意的降级: + // 宁可少一句话也不让报告整个没有)。没文案就不播,页面照常显示统计。 + if (text.isEmpty) return; + if (!Get.isRegistered()) return; + try { + _tts = Get.find(); + setState(() => _speaking = true); + await _tts!.startspeak(MemoryReportSheet._sessionId); + await _tts!.speakOnce(MemoryReportSheet._sessionId, text); + } catch (e) { + Logger.w(MemoryReportSheet._tag, '播报失败: $e'); + } finally { + if (mounted) setState(() => _speaking = false); + } + } + + Future _stop() async { + try { + await _tts?.stop(); + } catch (_) {} + if (mounted) setState(() => _speaking = false); + } + + Future _confirm() async { + setState(() => _confirming = true); + await _stop(); + // 确认失败也放用户走:把人扣在一个不可关闭的弹窗里比漏记一次确认糟得多, + // 下次进来会再弹一次(服务端仍认为未确认),可以接受。 + final ok = await MemoryService.to.confirmReport(widget.report.id); + if (!ok) { + Logger.w(MemoryReportSheet._tag, '确认报告失败,仍关闭弹窗(下次会再弹)'); + } + if (mounted) Get.back(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final stat = MemoryService.parseStat(widget.report.statJson); + return PopScope( + // 需求 4:播报后需要用户确认,所以不给系统返回键直接关掉 + canPop: false, + child: Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1C1C1E) : Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + ), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 20.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + (widget.report.isWeek + ? 'memReportWeekTitle' + : 'memReportMonthTitle') + .tr, + style: + TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), + ), + ), + if (widget.report.summary.isNotEmpty) + TextButton.icon( + onPressed: _speaking ? _stop : _speak, + icon: Icon(_speaking ? Icons.stop : Icons.volume_up, + size: 18.sp), + label: Text( + (_speaking ? 'memReportStop' : 'memReportPlay').tr, + style: TextStyle(fontSize: 13.sp)), + ), + ], + ), + Text( + '${widget.report.periodStart} ~ ${widget.report.periodEnd}', + style: TextStyle( + fontSize: 11.sp, + color: isDark ? Colors.white38 : Colors.black38), + ), + SizedBox(height: 14.h), + _statRow(stat, isDark), + if (widget.report.summary.isNotEmpty) ...[ + SizedBox(height: 14.h), + Flexible( + child: SingleChildScrollView( + child: Text(widget.report.summary, + style: TextStyle(fontSize: 14.sp, height: 1.6)), + ), + ), + ], + SizedBox(height: 20.h), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _confirming ? null : _confirm, + child: _confirming + ? SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator(strokeWidth: 2)) + : Text('memReportConfirm'.tr), + ), + ), + ], + ), + ), + ); + } + + /// 统计条。数字全部来自服务端 SQL 算好的 stat_json,**不在客户端做任何加减** + /// ——同一份数据在两处各算一遍,早晚会出现两个不同的结果。 + Widget _statRow(Map stat, bool isDark) { + final tiles = []; + + void add(String labelKey, String value) { + tiles.add(Container( + margin: EdgeInsets.only(right: 10.w, bottom: 8.h), + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + decoration: BoxDecoration( + color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, + style: + TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w800)), + SizedBox(height: 2.h), + Text(labelKey.tr, + style: TextStyle( + fontSize: 10.sp, + color: isDark ? Colors.white54 : Colors.black45)), + ], + ), + )); + } + + final todoDone = memInt(stat['todo_done']); + final todoTotal = memInt(stat['todo_total']); + if (todoTotal > 0) add('memCatTodo', '$todoDone/$todoTotal'); + final ideaCount = memInt(stat['idea_count']); + if (ideaCount > 0) add('memCatIdea', '$ideaCount'); + final alarmTotal = memInt(stat['alarm_total']); + if (alarmTotal > 0) add('memCatAlarm', '$alarmTotal'); + + // 花销按币种分行:服务端不做汇率换算,客户端也不做。 + final exps = stat['expenses']; + if (exps is List) { + for (final e in exps) { + if (e is! Map) continue; + final cents = memInt(e['total_cents']); + if (cents == 0) continue; + final cur = memStr(e['currency'], 'CNY'); + add('memCatExpense', '$cur ${(cents / 100).toStringAsFixed(2)}'); + } + } + + if (tiles.isEmpty) return const SizedBox.shrink(); + return Wrap(children: tiles); + } +} diff --git a/apps/services/modules/memory/migrate_task.go b/apps/services/modules/memory/migrate_task.go new file mode 100644 index 00000000..dd9ab5d9 --- /dev/null +++ b/apps/services/modules/memory/migrate_task.go @@ -0,0 +1,181 @@ +package memory + +import ( + "fmt" + "strings" + "time" + + "yunyan/comm" + "yunyan/lego/sys/mysql" + "yunyan/pb" +) + +/* +存量 DBTask(allhelp_task)→ memory_item 的一次性迁移。 + +在模块 Start 时跑一次,**幂等**:靠 (uid, client_key) 唯一索引挡重复, +client_key 用 "task:",同一条任务再迁一次会被唯一索引挡掉。 + +⚠️ **不删 allhelp_task 表,也不改它的数据**。这是不可逆迁移的通例: +出问题时旧表是唯一的回退依据。确认新表跑稳后由人手工处置。 + +⚠️ 迁移失败**不阻断服务启动**。它是补数据,不是启动的前置条件; +在这里返回 error 会让 home 退出,而五个服务同镜像、entrypoint 会连坐全杀。 +*/ + +// 每轮最多搬多少条,避免一次把全表读进内存 +const migrateBatchSize = 500 + +// migrateTasks 把存量任务搬进记忆项。返回搬了几条。 +func (this *modelComp) migrateTasks() (int, error) { + // 先看有没有活儿。表不存在(老部署没有 allhelp 模块)时这里会报错, + // 直接当作「没有存量」返回,不是故障。 + var pending int64 + if err := mysql.Table(comm.TableAllhelpTask).Count(&pending).Error; err != nil { + return 0, nil + } + if pending == 0 { + return 0, nil + } + + migrated := 0 + var lastID uint64 + for { + tasks := make([]*pb.DBTask, 0, migrateBatchSize) + if err := mysql.Table(comm.TableAllhelpTask). + Where("id > ?", lastID). + Order("id asc").Limit(migrateBatchSize). + Find(&tasks).Error; err != nil { + return migrated, err + } + if len(tasks) == 0 { + break + } + for _, t := range tasks { + lastID = t.Id + item := taskToMemoryItem(t) + if item == nil { + continue + } + // 已迁过就跳过:查一次比撞唯一索引再吞错误干净, + // 而且能区分「已迁过」和「真的写失败了」。 + if exist, err := this.findItemByClientKey(item.Uid, item.ClientKey); err != nil { + this.module.Warnf("迁移任务 %d 查重失败已跳过: %v", t.Id, err) + continue + } else if exist != nil { + continue + } + if err := this.addItem(item); err != nil { + this.module.Warnf("迁移任务 %d 写入失败已跳过: %v", t.Id, err) + continue + } + migrated++ + } + if len(tasks) < migrateBatchSize { + break + } + } + return migrated, nil +} + +// taskToMemoryItem 字段映射。无法映射出日期的条目返回 nil(跳过而不是硬塞今天)。 +func taskToMemoryItem(t *pb.DBTask) *pb.DBMemoryItem { + if t == nil || t.Uid == "" { + return nil + } + title := strings.TrimSpace(t.TaskName) + if title == "" { + title = strings.TrimSpace(t.TaskDesc) + } + if title == "" { + // 标题和描述都空的任务没有迁的价值,迁过去也只是一条空白记录 + return nil + } + + // trigger_time 是带时区的 RFC3339(如 2025-04-08T15:00:00+08:00)。 + // ⚠️ 用 time.Parse 保留原时区再取日期,**不要先转 UTC**—— + // 那会让 00:30 这类凌晨任务整体退到前一天。 + var ( + happenDate string + happenTime string + tz string + ) + if ts := strings.TrimSpace(t.TriggerTime); ts != "" { + if parsed, err := time.Parse(time.RFC3339, ts); err == nil { + happenDate = comm.FormatMemoryDate(parsed) + happenTime = parsed.Format("15:04") + // 原串里的时区偏移信息只有偏移没有 IANA 名,存不了 tz, + // 留空让服务端按容器时区处理——这是已知的精度损失。 + _ = tz + } + } + if happenDate == "" { + // 没有可用的触发时间就退回创建日期:记忆项的 happen_date 永不为空, + // 日历按日期排,没有日期的项根本渲染不出来。 + if t.CreateTime > 0 { + happenDate = comm.FormatMemoryDate(time.Unix(t.CreateTime, 0)) + } else { + return nil + } + } + + item := &pb.DBMemoryItem{ + Uid: t.Uid, + Category: comm.MemoryCatTodo, // 存量任务一律按待办迁,不猜分类 + Source: comm.MemorySrcAssistant, + SourceId: fmt.Sprintf("task:%d", t.Id), + // 幂等键:同一条任务重复迁移会被 (uid, client_key) 唯一索引挡掉 + ClientKey: fmt.Sprintf("task:%d", t.Id), + Title: title, + Detail: strings.TrimSpace(t.TaskDesc), + HappenDate: happenDate, + HappenTime: happenTime, + DateCertain: happenTime != "", + RepeatRule: taskTypeToRepeat(t.TaskType), + Currency: "CNY", + State: taskStatusToState(t.Status), + // 存量任务在旧体系里「服务端仅记录、不自动触发」,用户从没被它提醒过。 + // 迁过来就默认开提醒等于突然给所有人补发一堆历史提醒,所以置 0。 + RemindAhead: 0, + // 迁移来的数据算用户资产,不该被会议重新生成之类的清理逻辑碰到 + UserEdited: true, + CreateTime: t.CreateTime, + FinishTime: t.FinishTime, + } + if item.RepeatRule == comm.MemoryRepeatWeekly { + if d, ok := comm.ParseMemoryDate(happenDate); ok { + w := int32(d.Weekday()) + if w == 0 { + w = 7 // 周日:Go 的 0 → 客户端口径的 7 + } + item.Weekday = w + } + } + return item +} + +func taskTypeToRepeat(t pb.TaskType) string { + switch t { + case pb.TaskType_TaskType_Daily: + return comm.MemoryRepeatDaily + case pb.TaskType_TaskType_Weekly: + return comm.MemoryRepeatWeekly + case pb.TaskType_TaskType_Monthly: + return comm.MemoryRepeatMonthly + default: + // Cron 表达式没有对应的 repeat_rule,按一次性迁—— + // 猜错的重复规则会让用户每天被一个他没设过的闹钟吵醒。 + return comm.MemoryRepeatOnce + } +} + +func taskStatusToState(s pb.TaskStatus) pb.MemoryState { + switch s { + case pb.TaskStatus_TaskStatus_Completed: + return pb.MemoryState_MemoryState_Done + case pb.TaskStatus_TaskStatus_Cancelled: + return pb.MemoryState_MemoryState_Dropped + default: + return pb.MemoryState_MemoryState_Pending + } +} diff --git a/apps/services/modules/memory/migrate_task_test.go b/apps/services/modules/memory/migrate_task_test.go new file mode 100644 index 00000000..423c538f --- /dev/null +++ b/apps/services/modules/memory/migrate_task_test.go @@ -0,0 +1,144 @@ +package memory + +import ( + "testing" + "time" + + "yunyan/comm" + "yunyan/pb" +) + +func TestTaskToMemoryItem_Basic(t *testing.T) { + task := &pb.DBTask{ + Id: 42, Uid: "u1", + TaskName: "跟进A客户", TaskDesc: "电话联系", + TaskType: pb.TaskType_TaskType_Once, + TriggerTime: "2026-04-08T15:00:00+08:00", + Status: pb.TaskStatus_TaskStatus_Pending, + CreateTime: 1700000000, + } + it := taskToMemoryItem(task) + if it == nil { + t.Fatal("不该返回 nil") + } + if it.HappenDate != "2026-04-08" { + t.Errorf("日期期望 2026-04-08,得到 %s", it.HappenDate) + } + if it.HappenTime != "15:00" { + t.Errorf("时刻期望 15:00,得到 %s", it.HappenTime) + } + if it.ClientKey != "task:42" { + t.Errorf("幂等键期望 task:42,得到 %s", it.ClientKey) + } + if it.Category != comm.MemoryCatTodo { + t.Errorf("存量任务应一律迁成 todo,得到 %s", it.Category) + } + // 旧体系里「服务端仅记录不触发」,用户从没被提醒过。 + // 迁过来默认开提醒 = 给所有人补发一堆历史提醒。 + if it.RemindAhead != 0 { + t.Errorf("迁移来的任务不该默认开提醒,得到 %d", it.RemindAhead) + } + if !it.UserEdited { + t.Error("迁移来的是用户资产,应标 user_edited 避免被自动清理碰到") + } +} + +// ⚠️ 凌晨任务:如果先转 UTC 再取日期,00:30+08:00 会退到前一天。 +func TestTaskToMemoryItem_EarlyMorningKeepsLocalDate(t *testing.T) { + task := &pb.DBTask{ + Id: 1, Uid: "u1", TaskName: "晨会", + TriggerTime: "2026-04-08T00:30:00+08:00", + CreateTime: 1700000000, + } + it := taskToMemoryItem(task) + if it == nil { + t.Fatal("不该返回 nil") + } + if it.HappenDate != "2026-04-08" { + t.Errorf("凌晨任务的日期不该退到前一天,期望 2026-04-08 得到 %s", it.HappenDate) + } +} + +func TestTaskToMemoryItem_RepeatMapping(t *testing.T) { + cases := map[pb.TaskType]string{ + pb.TaskType_TaskType_Once: comm.MemoryRepeatOnce, + pb.TaskType_TaskType_Daily: comm.MemoryRepeatDaily, + pb.TaskType_TaskType_Weekly: comm.MemoryRepeatWeekly, + pb.TaskType_TaskType_Monthly: comm.MemoryRepeatMonthly, + // cron 没有对应的 repeat_rule,按一次性迁:猜错的重复规则会让用户 + // 每天被一个他没设过的闹钟吵醒 + pb.TaskType_TaskType_Cron: comm.MemoryRepeatOnce, + } + for tt, want := range cases { + task := &pb.DBTask{ + Id: 1, Uid: "u1", TaskName: "x", TaskType: tt, + TriggerTime: "2026-04-08T15:00:00+08:00", + } + it := taskToMemoryItem(task) + if it == nil || it.RepeatRule != want { + t.Errorf("TaskType %v 期望 %s,得到 %v", tt, want, it) + } + } +} + +func TestTaskToMemoryItem_StatusMapping(t *testing.T) { + cases := map[pb.TaskStatus]pb.MemoryState{ + pb.TaskStatus_TaskStatus_Pending: pb.MemoryState_MemoryState_Pending, + pb.TaskStatus_TaskStatus_Completed: pb.MemoryState_MemoryState_Done, + pb.TaskStatus_TaskStatus_Cancelled: pb.MemoryState_MemoryState_Dropped, + } + for st, want := range cases { + if got := taskStatusToState(st); got != want { + t.Errorf("状态 %v 期望 %v 得到 %v", st, want, got) + } + } +} + +// 没有触发时间时退回创建日期——happen_date 永不为空是拾忆页能工作的前提 +func TestTaskToMemoryItem_FallsBackToCreateTime(t *testing.T) { + created := time.Date(2026, 3, 1, 10, 0, 0, 0, time.Local) + task := &pb.DBTask{ + Id: 1, Uid: "u1", TaskName: "无时间任务", + CreateTime: created.Unix(), + } + it := taskToMemoryItem(task) + if it == nil { + t.Fatal("不该返回 nil") + } + if it.HappenDate != comm.FormatMemoryDate(created) { + t.Errorf("应退回创建日期 %s,得到 %s", comm.FormatMemoryDate(created), it.HappenDate) + } + if it.DateCertain { + t.Error("没有具体时刻时 date_certain 应为 false") + } +} + +// 该跳过的都要跳过,别往新表里塞垃圾 +func TestTaskToMemoryItem_SkipsUnusable(t *testing.T) { + cases := []struct { + name string + task *pb.DBTask + }{ + {"nil", nil}, + {"没有 uid", &pb.DBTask{Id: 1, TaskName: "x", CreateTime: 1}}, + {"标题与描述都空", &pb.DBTask{Id: 1, Uid: "u1", CreateTime: 1}}, + {"既无触发时间也无创建时间", &pb.DBTask{Id: 1, Uid: "u1", TaskName: "x"}}, + } + for _, c := range cases { + if got := taskToMemoryItem(c.task); got != nil { + t.Errorf("%s: 应跳过,却返回了 %+v", c.name, got) + } + } +} + +// 描述为空时用标题;标题为空时用描述 +func TestTaskToMemoryItem_TitleFallback(t *testing.T) { + task := &pb.DBTask{ + Id: 1, Uid: "u1", TaskDesc: "只有描述", + TriggerTime: "2026-04-08T15:00:00+08:00", + } + it := taskToMemoryItem(task) + if it == nil || it.Title != "只有描述" { + t.Errorf("标题空时应退回描述,得到 %v", it) + } +} diff --git a/apps/services/modules/memory/module.go b/apps/services/modules/memory/module.go index c10545b9..e56094ac 100644 --- a/apps/services/modules/memory/module.go +++ b/apps/services/modules/memory/module.go @@ -64,6 +64,15 @@ func (this *Memory) Start() (err error) { if this.echo == nil { this.Warnf("未取到 echomeet 模块:周期报告将只有统计没有播报文案,会议待办抽取不可用") } + // 存量 DBTask → memory_item 的一次性迁移。幂等,失败不阻断启动 + // (它是补数据不是启动前置;这里返回 error 会让 home 退出、五个服务连坐全挂)。 + go func() { + if n, err := this.model.migrateTasks(); err != nil { + this.Errorf("存量任务迁移失败(不影响服务): %v", err) + } else if n > 0 { + this.Infof("存量任务迁移完成,搬入 %d 条记忆项", n) + } + }() return }