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.
 
 
 
 
 
 

428 lines
14 KiB

import 'dart:convert';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart';
import '../models/memory_item.dart';
import 'network/api.dart';
/// 记忆中心(拾忆)的数据层。
///
/// 服务端是唯一真相源,本地 GetStorage 只做缓存与离线兜底。
///
/// ⚠️ **只缓存最近 [_cacheDays] 天**,不做全量镜像。GetStorage 是把整个 box 读进
/// 内存的 JSON 文件,记忆项一年三千多条,全量缓存会让每次启动越来越慢。
class MemoryService extends GetxService {
static MemoryService get to => Get.find<MemoryService>();
static const String _tag = 'MemoryService';
static const String _kItems = 'memory_items_cache';
static const String _kCacheDate = 'memory_items_cache_date';
/// 本地缓存保留的天数(以今天为中心,前后各这么多天)
static const int _cacheDays = 90;
final _storage = GetStorage();
/// 当前已加载的记忆项。页面 Obx 直接读它。
final RxList<MemoryItem> items = <MemoryItem>[].obs;
/// 是否正在拉取(用于列表下拉刷新的转圈,不用于全屏遮罩)
final RxBool loading = false.obs;
/// 最近一次拉取是否失败。失败时页面显示「显示的是本地缓存」而不是空白。
final RxBool staleFromCache = false.obs;
/// 待确认的周期报告 id,0 = 没有
final RxInt pendingReportId = 0.obs;
final RxString pendingReportType = ''.obs;
@override
void onInit() {
super.onInit();
_restore();
}
// ===================== 本地缓存 =====================
void _restore() {
try {
final raw = _storage.read<List>(_kItems);
if (raw == null) return;
final list = raw
.map((e) => MemoryItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
items.assignAll(list);
Logger.d(_tag, '恢复本地缓存 ${list.length} 条');
} catch (e) {
// 缓存结构变了(比如加了字段)就整份丢掉重拉,别让一份坏缓存把页面卡死
Logger.w(_tag, '本地缓存解析失败,已清空: $e');
items.clear();
_storage.remove(_kItems);
}
}
void _persist() {
try {
final today = DateTime.now();
final from = today.subtract(const Duration(days: _cacheDays));
final to = today.add(const Duration(days: _cacheDays));
final kept = items.where((e) {
final d = e.happenDateTime;
return d.isAfter(from) && d.isBefore(to);
}).toList();
_storage.write(_kItems, kept.map((e) => e.toJson()).toList());
_storage.write(_kCacheDate, _fmtDate(today));
} catch (e) {
Logger.w(_tag, '写本地缓存失败(不影响本次使用): $e');
}
}
/// 登出时清空。记忆项是账号数据,换账号必须清干净。
void clear() {
items.clear();
pendingReportId.value = 0;
pendingReportType.value = '';
staleFromCache.value = false;
_storage.remove(_kItems);
_storage.remove(_kCacheDate);
}
// ===================== 工具 =====================
static String _fmtDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
/// 本机时区名。拿不到就传空串——服务端会退到它自己的本地时区,
/// **不会**退成 UTC(那会让所有时刻静默偏移 8 小时)。
String _tz() {
try {
return DateTime.now().timeZoneName;
} catch (_) {
return '';
}
}
/// 把服务端返回的 data 取成 Map。
///
/// ⚠️ AuthInterceptor 在 code==0 且 data 为空时把 data 换成了 `{}` 而不是 null,
/// 在业务错误时才把 data 置成 null 并 resolve(**不抛**)。所以:
/// resp == null → 服务端明确返回了业务错误
/// resp == {} → 成功但没数据
/// 两者含义相反,绝不能合并判断。
Map<String, dynamic>? _data(dynamic resp) {
if (resp == null) return null;
if (resp.data == null) return null;
if (resp.data is Map) return Map<String, dynamic>.from(resp.data as Map);
return null;
}
List<MemoryItem> _parseItems(dynamic raw) {
if (raw is! List) return [];
final out = <MemoryItem>[];
for (final e in raw) {
try {
out.add(MemoryItem.fromJson(Map<String, dynamic>.from(e as Map)));
} catch (err) {
// 单条解析失败只跳过这一条。一条脏数据不该让整页空白——
// appconfig_model 那边就是因为 non-nullable 字段让整个 fromJson 挂掉,
// 表现成设备列表整体不显示。
Logger.w(_tag, '跳过一条无法解析的记忆项: $err');
}
}
return out;
}
// ===================== 读 =====================
/// 拉取日期范围内的记忆项,结果**合并**进 [items](不是整体替换)。
///
/// 合并而不是替换:日历会按需拉不同月份,整体替换会让上个月的数据在切回去时消失。
Future<bool> load({
DateTime? start,
DateTime? end,
List<String>? categories,
bool silent = false,
}) async {
final s = start ?? DateTime.now().subtract(const Duration(days: 30));
final e = end ?? DateTime.now().add(const Duration(days: 60));
if (!silent) loading.value = true;
try {
final resp = await Api.memoryList({
'start_date': _fmtDate(s),
'end_date': _fmtDate(e),
if (categories != null && categories.isNotEmpty) 'categories': categories,
'tz': _tz(),
'size': 1000,
});
final data = _data(resp);
if (data == null) {
staleFromCache.value = true;
Logger.w(_tag, '拉取记忆项失败(业务错误),沿用本地缓存');
return false;
}
final fetched = _parseItems(data['items']);
_mergeRange(fetched, _fmtDate(s), _fmtDate(e));
staleFromCache.value = false;
_persist();
Logger.d(_tag, '拉取 ${fetched.length} 条 (${_fmtDate(s)} ~ ${_fmtDate(e)})');
return true;
} catch (err) {
// 网络层异常:保留本地缓存,页面标成「离线」,绝不清空
staleFromCache.value = true;
Logger.w(_tag, '拉取记忆项异常,沿用本地缓存: $err');
return false;
} finally {
if (!silent) loading.value = false;
}
}
/// 用服务端返回的这一段替换掉本地同一段,段外的保持不动
void _mergeRange(List<MemoryItem> fetched, String start, String end) {
final kept = items.where((e) {
final d = e.happenDate;
return d.compareTo(start) < 0 || d.compareTo(end) > 0;
}).toList();
kept.addAll(fetched);
kept.sort(_compare);
items.assignAll(kept);
}
static int _compare(MemoryItem a, MemoryItem b) {
final c = a.happenDate.compareTo(b.happenDate);
if (c != 0) return c;
// 无时刻的排在当天最后(与服务端 listItems 的排序口径一致)
if (a.happenTime.isEmpty != b.happenTime.isEmpty) {
return a.happenTime.isEmpty ? 1 : -1;
}
final t = a.happenTime.compareTo(b.happenTime);
if (t != 0) return t;
return a.id.compareTo(b.id);
}
/// 当天事项(每日弹窗用)。顺带带回待确认报告标记。
///
/// ⚠️ **调用方不要 await 在启动路径上**。冷启动已经串了四次网络往返
/// (网络检查 → 版本检查 → getLoginToken → getAppConfig),再挂一次会把
/// 「页面立刻可见」这条刚做过的优化又推回去。进主页后异步拉、拿到再弹。
Future<List<MemoryItem>> loadToday() async {
try {
final resp = await Api.memoryToday({
'date': _fmtDate(DateTime.now()),
'tz': _tz(),
});
final data = _data(resp);
if (data == null) return _localToday();
final list = _parseItems(data['items']);
pendingReportId.value = memInt(data['pending_report_id']);
pendingReportType.value = (data['pending_report_type'] ?? '').toString();
_mergeRange(list, _fmtDate(DateTime.now()), _fmtDate(DateTime.now()));
_persist();
return list;
} catch (e) {
Logger.w(_tag, 'loadToday 异常,退回本地缓存: $e');
return _localToday();
}
}
List<MemoryItem> _localToday() {
final today = _fmtDate(DateTime.now());
return items.where((e) => e.happenDate == today).toList();
}
/// 未来 N 天的提醒时刻(重复项已由服务端展开成绝对时刻)
Future<List<MemoryRemindSlot>> loadUpcoming({int days = 7}) async {
try {
final resp = await Api.memoryUpcoming({'days': days, 'tz': _tz()});
final data = _data(resp);
if (data == null) return [];
final raw = data['slots'];
if (raw is! List) return [];
return raw
.map((e) => MemoryRemindSlot.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
Logger.w(_tag, 'loadUpcoming 异常: $e');
return [];
}
}
// ===================== 写 =====================
/// 新增。成功返回落库后的记录(含服务端分配的 id),失败返回 null。
Future<MemoryItem?> add(MemoryItem item) async {
try {
item.tz = item.tz.isEmpty ? _tz() : item.tz;
final resp = await Api.memoryAdd({'item': item.toJson()});
final data = _data(resp);
if (data == null) return null;
final saved = MemoryItem.fromJson(
Map<String, dynamic>.from(data['item'] as Map));
// 幂等命中时服务端返回既有记录,这里按 id 去重后再插,避免列表里出现两条
items.removeWhere((e) => e.id == saved.id);
items.add(saved);
items.sort(_compare);
items.refresh();
_persist();
return saved;
} catch (e) {
Logger.e(_tag, '新增失败: $e');
return null;
}
}
/// 修改。fields 点名要改的字段(json 名),空 = 按服务端白名单全量覆盖。
Future<MemoryItem?> update(MemoryItem item, {List<String>? fields}) async {
try {
final resp = await Api.memoryUpdate({
'id': item.id,
'item': item.toJson(),
if (fields != null && fields.isNotEmpty) 'fields': fields,
});
final data = _data(resp);
if (data == null) return null;
final saved = MemoryItem.fromJson(
Map<String, dynamic>.from(data['item'] as Map));
final idx = items.indexWhere((e) => e.id == saved.id);
if (idx >= 0) {
items[idx] = saved;
} else {
items.add(saved);
}
items.sort(_compare);
items.refresh();
_persist();
return saved;
} catch (e) {
Logger.e(_tag, '修改失败: $e');
return null;
}
}
/// 删除
Future<bool> remove(List<int> ids) async {
if (ids.isEmpty) return true;
try {
final resp = await Api.memoryDel({'ids': ids});
if (_data(resp) == null) return false;
items.removeWhere((e) => ids.contains(e.id));
items.refresh();
_persist();
return true;
} catch (e) {
Logger.e(_tag, '删除失败: $e');
return false;
}
}
/// 标记完成 / 退回未完成。
///
/// 先改本地再发请求(乐观更新):勾一下要等一次往返才变灰太迟钝。
/// 失败时**回滚**——不回滚会让用户以为已经勾上了,下次进页面又变回来。
Future<bool> complete(List<int> ids, bool done) async {
if (ids.isEmpty) return true;
final before = <int, int>{};
for (final e in items) {
if (ids.contains(e.id)) {
before[e.id] = e.state;
e.state = done ? MemoryState.done : MemoryState.pending;
}
}
items.refresh();
try {
final resp = await Api.memoryComplete({'ids': ids, 'done': done});
if (_data(resp) == null) throw Exception('业务错误');
_persist();
return true;
} catch (e) {
for (final it in items) {
if (before.containsKey(it.id)) it.state = before[it.id]!;
}
items.refresh();
Logger.e(_tag, '标记完成失败,已回滚: $e');
return false;
}
}
// ===================== 周期报告 =====================
/// 取最近一份未确认的报告。
///
/// 服务端在「生成中」时返回 MemoryReportNotReady(5205),AuthInterceptor 会把
/// data 置 null,这里拿到 null 就当「暂时没有」——**不要弹错**,报告还在生成
/// 不是故障。
Future<MemoryReport?> loadPendingReport() async {
try {
final resp = await Api.memoryGetReport({});
final data = _data(resp);
if (data == null) return null;
final r = data['report'];
if (r == null) return null;
return MemoryReport.fromJson(Map<String, dynamic>.from(r as Map));
} catch (e) {
Logger.w(_tag, 'loadPendingReport 异常: $e');
return null;
}
}
Future<bool> confirmReport(int reportId) async {
try {
final resp = await Api.memoryConfirmReport({'report_id': reportId});
if (_data(resp) == null) return false;
if (pendingReportId.value == reportId) {
pendingReportId.value = 0;
pendingReportType.value = '';
}
return true;
} catch (e) {
Logger.e(_tag, '确认报告失败: $e');
return false;
}
}
// ===================== 查询辅助(页面用)=====================
List<MemoryItem> forDate(DateTime day, {String? category}) {
final d = _fmtDate(day);
return items
.where((e) =>
e.happenDate == d &&
(category == null || category.isEmpty || e.category == category))
.toList();
}
List<MemoryItem> forRange(DateTime from, DateTime to, {String? category}) {
final f = _fmtDate(from);
final t = _fmtDate(to);
return items
.where((e) =>
e.happenDate.compareTo(f) >= 0 &&
e.happenDate.compareTo(t) <= 0 &&
(category == null || category.isEmpty || e.category == category))
.toList();
}
/// 范围内的花销小计,**按币种分组**。不做汇率换算。
Map<String, int> expenseCentsByCurrency(DateTime from, DateTime to) {
final out = <String, int>{};
for (final e in forRange(from, to)) {
if (!e.isExpense) continue;
out[e.currency] = (out[e.currency] ?? 0) + e.amountCents;
}
return out;
}
/// 解析报告的 stat_json。坏数据返回空 Map,不抛。
static Map<String, dynamic> parseStat(String statJson) {
if (statJson.isEmpty) return {};
try {
final v = jsonDecode(statJson);
return v is Map ? Map<String, dynamic>.from(v) : {};
} catch (_) {
return {};
}
}
}