Browse Source
周期报告弹窗(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
5 changed files with 577 additions and 0 deletions
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue