Browse Source

拾忆:周期报告弹窗 + 存量 DBTask 迁移

周期报告弹窗(MemoryReportSheet)
- 一次只弹最近一份未确认的。用户一个月不开 App 会攒 4 份周报 + 1 份月报,
  连弹 5 次没人受得了——服务端不带参数时就只返回最近一份。
- 不可关闭(需求 4 要求用户确认),但**确认失败也放人走**:把人扣在一个不可
  关闭的弹窗里比漏记一次确认糟得多,下次进来会再弹。
- summary 为空时不播报、只显示统计。文案生成失败时服务端是刻意只回写统计的
  (宁可少一句话也不让报告整个没有),这里对齐那个降级。
- 统计数字全部来自 stat_json,客户端不做任何加减——同一份数据在两处各算一遍,
  早晚会出现两个不同的结果。花销按币种分行,不做汇率换算。
- 排在每日安排弹窗之后:两个都要播报,同时弹会互相盖住,而「今天要做什么」
  比回顾更即时。

存量 DBTask → memory_item 迁移
模块 Start 时起 goroutine 跑一次,幂等(client_key="task:<id>" + 唯一索引)。

- **不删也不改 allhelp_task**。不可逆迁移的通例:出问题时旧表是唯一回退依据。
- **失败不阻断启动**。它是补数据不是启动前置;返回 error 会让 home 退出,
  而五个服务同镜像、entrypoint 会连坐全杀。
- trigger_time 用 time.Parse 保留原时区再取日期,**不先转 UTC**——
  那会让 00:30+08:00 这类凌晨任务整体退到前一天(有用例守着)。
- RemindAhead 置 0:旧体系「服务端仅记录不触发」,用户从没被这些任务提醒过,
  迁过来默认开提醒等于给所有人补发一堆历史提醒。
- UserEdited 置 true:迁移来的是用户资产,不该被会议重新生成之类的清理逻辑碰到。
- TaskType_Cron 按一次性迁,不猜重复规则——猜错会让用户每天被一个他没设过的
  闹钟吵醒。
- 标题与描述都空、没有 uid、既无触发时间也无创建时间的一律跳过,不往新表塞垃圾。

7 个映射用例覆盖:基本字段、凌晨不退日期、五种 TaskType、三种状态、
无触发时间退回创建日期、四类该跳过的、标题回退描述。

验证:go build / go vet 全通过,comm + memory + mcp 三个包测试通过;
dart analyze 零错误,flutter build apk --debug 通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
fc3f747dfb
  1. 8
      apps/client/lib/core/utils/app_lifecycle_manager.dart
  2. 235
      apps/client/lib/modules/main_tab/views/widgets/memory_report_sheet.dart
  3. 181
      apps/services/modules/memory/migrate_task.go
  4. 144
      apps/services/modules/memory/migrate_task_test.go
  5. 9
      apps/services/modules/memory/module.go

8
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_reminder_scheduler.dart';
import '../../data/services/memory_service.dart'; import '../../data/services/memory_service.dart';
import '../../modules/main_tab/views/widgets/memory_daily_sheet.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 '../services/native_localization_sync.dart';
import '../translations/app_translations.dart'; import '../translations/app_translations.dart';
//import '../../modules/settings/controllers/settings_controller.dart'; //import '../../modules/settings/controllers/settings_controller.dart';
@ -155,6 +156,13 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
} catch (e) { } catch (e) {
Logger.warning('拾忆提醒重排失败(已忽略): $e'); Logger.warning('拾忆提醒重排失败(已忽略): $e');
} }
// 周报/月报弹窗排在每日安排之后:两个都要播报,同时弹会互相盖住,
// 而每日安排是「今天要做什么」,比回顾更即时。
try {
await MemoryReportSheet.maybeShow();
} catch (e) {
Logger.warning('拾忆报告弹窗失败(已忽略): $e');
}
} }
/// 处理应用进入后台 /// 处理应用进入后台

235
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<void> 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<void> _speak() async {
final text = widget.report.summary;
// 文案生成失败时服务端只回写统计、summary 为空(那是刻意的降级:
// 宁可少一句话也不让报告整个没有)。没文案就不播,页面照常显示统计。
if (text.isEmpty) return;
if (!Get.isRegistered<TtsService>()) return;
try {
_tts = Get.find<TtsService>();
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<void> _stop() async {
try {
await _tts?.stop();
} catch (_) {}
if (mounted) setState(() => _speaking = false);
}
Future<void> _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<String, dynamic> stat, bool isDark) {
final tiles = <Widget>[];
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);
}
}

181
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:<id>",同一条任务再迁一次会被唯一索引挡掉。
⚠️ **不删 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
}
}

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

9
apps/services/modules/memory/module.go

@ -64,6 +64,15 @@ func (this *Memory) Start() (err error) {
if this.echo == nil { if this.echo == nil {
this.Warnf("未取到 echomeet 模块:周期报告将只有统计没有播报文案,会议待办抽取不可用") 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 return
} }

Loading…
Cancel
Save