feat: 实现用户画像、占卜历史与后端用户管理模块
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
# 摇卦页面日期时间选择器优化设计
|
||||
|
||||
## 1. 现状问题
|
||||
|
||||
1. **硬编码日期格式**:`DateFormat('yyyy年MM月dd日 HH:mm')` 在3处硬编码,未做 l10n
|
||||
- `auto_divination_screen.dart:353`
|
||||
- `manual_divination_screen.dart:271`
|
||||
- `divination_result_screen.dart:455`
|
||||
|
||||
2. **原生 picker 样式简陋**:使用 Material `showDatePicker` + `showTimePicker`,交互体验差
|
||||
|
||||
## 2. 优化方案
|
||||
|
||||
### 2.1 自定义底部弹层时间选择器
|
||||
|
||||
- 使用 `CupertinoDatePicker`(iOS 滚轮样式)替代原生 Material picker
|
||||
- 底部弹层,带半透明遮罩和圆角动画
|
||||
- 日期/时间在同一个 picker 内通过 SegmentedControl 切换
|
||||
|
||||
### 2.2 Locale-aware 日期格式化
|
||||
|
||||
使用 `intl` 包实现:
|
||||
- 中文 locale:`DateFormat.yMd('zh_CN').add_Hm()` → `2026年4月3日 14:30`
|
||||
- 英文 locale:`DateFormat.yMd('en').add_Hm()` → `4/3/2026 14:30`
|
||||
|
||||
### 2.3 新增 l10n 键值
|
||||
|
||||
已有键值:
|
||||
- `autoSelectTime`: "选择起卦时间" / "Select time"
|
||||
- `manualSelectTime`: "选择起卦时间" / "Select time"
|
||||
- `divinationModify`: "修改" / "Modify"
|
||||
|
||||
无需新增键值,日期格式完全由 `intl` 包根据 locale 自动处理。
|
||||
|
||||
## 3. 组件结构
|
||||
|
||||
```
|
||||
apps/lib/shared/widgets/
|
||||
└── date_time_picker/
|
||||
└── date_time_picker_bottom_sheet.dart # 弹层容器
|
||||
```
|
||||
|
||||
**DateTimePickerBottomSheet** 接口:
|
||||
```dart
|
||||
Future<DateTime?> showDateTimePickerBottomSheet({
|
||||
required BuildContext context,
|
||||
required DateTime initialDateTime,
|
||||
DateTime? minDateTime,
|
||||
DateTime? maxDateTime,
|
||||
});
|
||||
```
|
||||
|
||||
## 4. 交互流程
|
||||
|
||||
1. 用户点击"修改"按钮
|
||||
2. 底部弹出 `DateTimePickerBottomSheet`
|
||||
3. SegmentedControl 切换"日期"/"时间"tab
|
||||
4. Cupertino 滚轮选择值
|
||||
5. 点击"确认"关闭弹层并更新状态
|
||||
|
||||
## 5. 涉及的改动文件
|
||||
|
||||
### 新建
|
||||
- `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart`
|
||||
|
||||
### 修改
|
||||
- `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart`
|
||||
- `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart`
|
||||
- `apps/lib/features/divination/presentation/screens/divination_result_screen.dart`
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 日期格式跟随系统语言:中文环境显示中文格式,英文环境显示英文格式
|
||||
2. 选择器使用 iOS 滚轮样式
|
||||
3. 底部弹层带遮罩动画
|
||||
4. 原硬编码格式完全移除
|
||||
@@ -1,341 +0,0 @@
|
||||
# 日期时间选择器优化实现计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 将摇卦页面的日期时间选择器改为 iOS 滚轮样式,并实现 locale-aware 格式化
|
||||
|
||||
**Architecture:** 创建共享的 `DateTimePickerBottomSheet` 组件,封装 `CupertinoDatePicker` 和底部弹层交互,替换现有的 `showDatePicker` + `showTimePicker` 调用
|
||||
|
||||
**Tech Stack:** Flutter, Cupertino widgets, intl package
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 DateTimePickerBottomSheet 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart`
|
||||
|
||||
**Step 1: 创建文件结构和基础代码**
|
||||
|
||||
```dart
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateTimePickerBottomSheet extends StatefulWidget {
|
||||
const DateTimePickerBottomSheet({
|
||||
super.key,
|
||||
required this.initialDateTime,
|
||||
this.minDateTime,
|
||||
this.maxDateTime,
|
||||
});
|
||||
|
||||
final DateTime initialDateTime;
|
||||
final DateTime? minDateTime;
|
||||
final DateTime? maxDateTime;
|
||||
|
||||
@override
|
||||
State<DateTimePickerBottomSheet> createState() => _DateTimePickerBottomSheetState();
|
||||
}
|
||||
|
||||
class _DateTimePickerBottomSheetState extends State<DateTimePickerBottomSheet> {
|
||||
late DateTime _selectedDateTime;
|
||||
int _selectedTab = 0; // 0=日期, 1=时间
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDateTime = widget.initialDateTime;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final locale = Localizations.localeOf(context);
|
||||
|
||||
return Container(
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
Text(
|
||||
l10n.autoSelectTime,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _selectedDateTime),
|
||||
child: Text(l10n.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SegmentedControl 切换日期/时间
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: CupertinoSlidingSegmentedControl<int>(
|
||||
groupValue: _selectedTab,
|
||||
children: {
|
||||
0: Text(l10n.dateTab),
|
||||
1: Text(l10n.timeTab),
|
||||
},
|
||||
onValueChanged: (value) => setState(() => _selectedTab = value ?? 0),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// CupertinoDatePicker
|
||||
Expanded(
|
||||
child: CupertinoDatePicker(
|
||||
mode: _selectedTab == 0
|
||||
? CupertinoDatePickerMode.date
|
||||
: CupertinoDatePickerMode.time,
|
||||
initialDateTime: _selectedDateTime,
|
||||
minimumDate: widget.minDateTime,
|
||||
maximumDate: widget.maxDateTime,
|
||||
onDateTimeChanged: (DateTime newDateTime) {
|
||||
setState(() => _selectedDateTime = newDateTime);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<DateTime?> showDateTimePickerBottomSheet({
|
||||
required BuildContext context,
|
||||
required DateTime initialDateTime,
|
||||
DateTime? minDateTime,
|
||||
DateTime? maxDateTime,
|
||||
}) {
|
||||
return showModalBottomSheet<DateTime>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => DateTimePickerBottomSheet(
|
||||
initialDateTime: initialDateTime,
|
||||
minDateTime: minDateTime,
|
||||
maxDateTime: maxDateTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加 l10n 键值**
|
||||
|
||||
在 `apps/lib/l10n/app_zh.arb` 添加:
|
||||
```json
|
||||
"dateTab": "日期",
|
||||
"timeTab": "时间",
|
||||
"confirm": "确认",
|
||||
"cancel": "取消"
|
||||
```
|
||||
|
||||
在 `apps/lib/l10n/app_en.arb` 添加:
|
||||
```json
|
||||
"dateTab": "Date",
|
||||
"timeTab": "Time",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
```
|
||||
|
||||
运行 `flutter gen-l10n` 生成代码
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/shared/widgets/date_time_picker/ apps/lib/l10n/
|
||||
git commit -m "feat(divination): add DateTimePickerBottomSheet with iOS wheel style"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 修改 auto_divination_screen.dart 使用新选择器
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:208-230`
|
||||
- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:353`
|
||||
|
||||
**Step 1: 添加 import**
|
||||
|
||||
在文件顶部添加:
|
||||
```dart
|
||||
import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
```
|
||||
|
||||
**Step 2: 修改 _pickTime 方法**
|
||||
|
||||
将:
|
||||
```dart
|
||||
Future<void> _pickTime() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedTime,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (date == null || !mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_selectedTime),
|
||||
);
|
||||
if (time == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedTime = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
```dart
|
||||
Future<void> _pickTime() async {
|
||||
final result = await showDateTimePickerBottomSheet(
|
||||
context: context,
|
||||
initialDateTime: _selectedTime,
|
||||
minDateTime: DateTime(2000),
|
||||
maxDateTime: DateTime(2100),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedTime = result;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 修改日期显示格式**
|
||||
|
||||
将:
|
||||
```dart
|
||||
DateFormat('yyyy年MM月dd日 HH:mm').format(selectedTime)
|
||||
```
|
||||
|
||||
替换为:
|
||||
```dart
|
||||
DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(selectedTime)
|
||||
```
|
||||
|
||||
需要添加 import:
|
||||
```dart
|
||||
import 'package:intl/intl.dart';
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/divination/presentation/screens/auto_divination_screen.dart
|
||||
git commit -m "feat(divination): use DateTimePickerBottomSheet in auto_divination_screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修改 manual_divination_screen.dart 使用新选择器
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:142-168`
|
||||
- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:271`
|
||||
|
||||
**Step 1: 添加 import**
|
||||
|
||||
```dart
|
||||
import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
```
|
||||
|
||||
**Step 2: 修改 _pickTime 方法和日期显示格式**
|
||||
|
||||
同 Task 2 的修改方式
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/divination/presentation/screens/manual_divination_screen.dart
|
||||
git commit -m "feat(divination): use DateTimePickerBottomSheet in manual_divination_screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 修改 divination_result_screen.dart 的日期格式
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/divination/presentation/screens/divination_result_screen.dart:455-457`
|
||||
|
||||
**Step 1: 添加 import**
|
||||
|
||||
```dart
|
||||
import 'package:intl/intl.dart';
|
||||
```
|
||||
|
||||
**Step 2: 修改日期格式**
|
||||
|
||||
将:
|
||||
```dart
|
||||
DateFormat(
|
||||
'yyyy年MM月dd日 HH:mm',
|
||||
).format(data.params.divinationTime),
|
||||
```
|
||||
|
||||
替换为:
|
||||
```dart
|
||||
DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(data.params.divinationTime),
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/divination/presentation/screens/divination_result_screen.dart
|
||||
git commit -m "refactor(divination): use locale-aware date format in divination_result_screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 运行验证
|
||||
|
||||
**Step 1: 生成 l10n**
|
||||
|
||||
```bash
|
||||
cd apps && flutter gen-l10n
|
||||
```
|
||||
|
||||
**Step 2: 运行静态分析**
|
||||
|
||||
```bash
|
||||
cd apps && flutter analyze
|
||||
```
|
||||
|
||||
预期: 无错误
|
||||
|
||||
**Step 3: 运行相关测试**
|
||||
|
||||
```bash
|
||||
cd apps && flutter test test/features/divination/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Plan complete.** Two execution options:
|
||||
|
||||
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
@@ -1,505 +0,0 @@
|
||||
# Eryao 用户档案/积分/会话数据模型设计
|
||||
|
||||
日期:2026-04-03
|
||||
状态:已确认(待实现)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本设计用于 Eryao 后端新增并对齐以下 5 张表:
|
||||
|
||||
1. 用户档案表:`profiles`
|
||||
2. 用户积分表:`user_points`
|
||||
3. 积分流水表:`points_ledger`
|
||||
4. 会话表:`sessions`
|
||||
5. 对话历史表:`messages`
|
||||
|
||||
来源原则:
|
||||
|
||||
- `profiles`、`sessions`、`messages` 参考并吸收 `social-app` 现有设计。
|
||||
- 会话能力按“结构完整复制,但业务先停用 automation”执行。
|
||||
- 本文档为设计方案,不包含迁移脚本与代码实现。
|
||||
|
||||
## 2. 关键确认项
|
||||
|
||||
### 2.1 profiles.username 不做唯一约束
|
||||
|
||||
已确认:`profiles.username` **不需要唯一**。
|
||||
|
||||
设计落地:
|
||||
|
||||
- 不创建 `UNIQUE(username)` 约束。
|
||||
- 可保留普通索引 `ix_profiles_username` 以支持检索。
|
||||
- 若后续产品要支持“唯一用户名登录/提及”,另行引入唯一标识字段(例如 `handle`)。
|
||||
|
||||
### 2.2 settings 需要 JSONB 模板
|
||||
|
||||
`profiles.settings` 使用 `jsonb not null default '{}'::jsonb`,并约定版本化模板:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"preferences": {
|
||||
"interface_language": "zh-CN",
|
||||
"ai_language": "zh-CN",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"country": "CN"
|
||||
},
|
||||
"privacy": {
|
||||
"profile_visibility": "public",
|
||||
},
|
||||
"notification": {
|
||||
"push_enabled": true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `version` 为配置结构版本,后续结构升级通过版本迁移处理。
|
||||
- `timezone` 作为运行时时区回退来源之一。
|
||||
- `default_runtime_mode` 当前仅允许 `chat` 生效。
|
||||
|
||||
## 3. 表结构设计
|
||||
|
||||
## 3.1 profiles(吸收 social-app)
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id uuid primary key`(外键指向 `auth.users(id)`,`on delete cascade`)
|
||||
- `username varchar(30) not null`(非唯一)
|
||||
- `avatar_url text null`
|
||||
- `bio varchar(200) null`
|
||||
- `settings jsonb not null default '{}'::jsonb`
|
||||
- `created_at timestamptz not null default now()`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
- `deleted_at timestamptz null`
|
||||
|
||||
索引建议:
|
||||
|
||||
- `ix_profiles_username (username)`
|
||||
- `ix_profiles_settings_gin using gin(settings)`
|
||||
|
||||
初始化建议:
|
||||
|
||||
- 与 `auth.users` 建立注册触发器,自动插入 profile 默认记录。
|
||||
- `settings` 初始化值应写入上述模板(而非空对象)。
|
||||
|
||||
## 3.2 user_points(用户积分账户)
|
||||
|
||||
职责:保存用户积分余额与累计统计,1 用户 1 行。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `user_id uuid primary key`(FK `auth.users(id)`)
|
||||
- `balance bigint not null default 0`
|
||||
- `frozen_balance bigint not null default 0`
|
||||
- `lifetime_earned bigint not null default 0`
|
||||
- `lifetime_spent bigint not null default 0`
|
||||
- `version int not null default 0`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
|
||||
约束建议:
|
||||
|
||||
- `check (balance >= 0)`
|
||||
- `check (frozen_balance >= 0)`
|
||||
- `check (lifetime_earned >= 0)`
|
||||
- `check (lifetime_spent >= 0)`
|
||||
|
||||
## 3.3 points_ledger(积分流水)
|
||||
|
||||
职责:记录每次积分变更,支持审计、对账、幂等。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `user_id uuid not null`(FK `auth.users(id)`)
|
||||
- `direction smallint not null`(1 增加,-1 减少)
|
||||
- `amount bigint not null`
|
||||
- `balance_after bigint not null`
|
||||
- `change_type varchar(16) not null`(约束:`register/consume/grant/adjust`)
|
||||
- `biz_type varchar(16) not null`(约束:当前仅 `chat`)
|
||||
- `biz_id uuid not null`(当前语义:指向 `sessions.id`)
|
||||
- `event_id varchar(64) not null`
|
||||
- `operator_id uuid null`
|
||||
- `metadata jsonb not null default '{}'::jsonb`
|
||||
- `created_at timestamptz not null default now()`
|
||||
|
||||
约束与索引建议:
|
||||
|
||||
- `check (amount > 0)`
|
||||
- `check (direction in (1, -1))`
|
||||
- `check (change_type in ('register', 'consume', 'grant', 'adjust'))`
|
||||
- `check (biz_type = 'chat')`
|
||||
- `foreign key (biz_id) references sessions(id)`
|
||||
- `unique (user_id, event_id)`(用户维度幂等)
|
||||
- `index (user_id, created_at desc)`
|
||||
- `index (biz_type, biz_id)`
|
||||
|
||||
## 3.4 sessions(完整复制结构,先停用 automation)
|
||||
|
||||
来源:`social-app` 的 `sessions` 表结构。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `user_id uuid not null`
|
||||
- `session_type varchar(20) not null`(结构保留 `chat/automation`)
|
||||
- `job_id uuid null`
|
||||
- `title varchar(255) null`
|
||||
- `status varchar(20) not null`
|
||||
- `last_activity_at timestamptz not null default now()`
|
||||
- `message_count int not null default 0`
|
||||
- `total_tokens int not null default 0`
|
||||
- `total_cost numeric(12,6) not null default 0`
|
||||
- `state_snapshot jsonb null`
|
||||
- `created_at/updated_at/deleted_at`
|
||||
|
||||
业务启用策略(当前阶段):
|
||||
|
||||
- 应用层仅允许 `session_type='chat'`。
|
||||
- 应用层要求 `job_id is null`。
|
||||
- 数据结构不删减,保留未来 automation 扩展能力。
|
||||
|
||||
## 3.5 messages(完整复制结构)
|
||||
|
||||
来源:`social-app` 的 `messages` 表结构。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `session_id uuid not null`(FK `sessions(id)`,`on delete cascade`)
|
||||
- `seq int not null`
|
||||
- `role varchar(20) not null`(`user/assistant/system/tool`)
|
||||
- `content text not null`
|
||||
- `model_code varchar(50) null`
|
||||
- `tool_name varchar(100) null`
|
||||
- `input_tokens int not null default 0`
|
||||
- `output_tokens int not null default 0`
|
||||
- `cost numeric(12,6) not null default 0`
|
||||
- `latency_ms int null`
|
||||
- `visibility_mask bigint not null default 0`
|
||||
- `metadata jsonb null`
|
||||
- `created_at/updated_at/deleted_at`
|
||||
|
||||
约束与索引建议:
|
||||
|
||||
- `unique (session_id, seq)`
|
||||
- `index (session_id)`
|
||||
- `index (session_id, seq, visibility_mask)`
|
||||
|
||||
## 4. 一致性与事务约定
|
||||
|
||||
- 积分变更必须在单事务内同时更新:`user_points` + `points_ledger`。
|
||||
- 通过 `event_id` 做幂等写保护,避免重试导致重复扣发。
|
||||
- `sessions.total_tokens/total_cost/message_count` 作为聚合字段,由写消息流程维护。
|
||||
|
||||
## 5. 安全与权限
|
||||
|
||||
- 所有业务写入走后端服务层,不信任客户端传入 `owner_id/user_id`。
|
||||
- 表级策略沿用项目约定(RLS + 服务端授权控制)。
|
||||
- `metadata/settings` 禁止写入密钥类敏感信息。
|
||||
|
||||
## 6. 兼容与演进
|
||||
|
||||
- 本期兼容策略:新增表/字段为主,不做破坏式变更。
|
||||
- automation 能力延后启用,仅在业务层放开,不需变更当前 DDL。
|
||||
- 若后续需要唯一用户名,应新增独立唯一字段,不直接改造 `username` 历史数据。
|
||||
|
||||
## 7. 关于“用户实际成本核算表”的结论
|
||||
|
||||
结论:建议二期引入,不阻塞本期 5 张表上线。
|
||||
|
||||
理由:
|
||||
|
||||
- 本期已有 `messages.cost` 与 `sessions.total_cost`,可支持展示级统计。
|
||||
- 若进入财务对账、补贴结算、重算审计场景,需要独立不可变成本流水表。
|
||||
|
||||
建议二期最小表:`user_cost_ledger`,记录 provider/model/tokens/raw_cost/billable_cost/event_id。
|
||||
|
||||
## 8. 字段释义(5 张表逐字段)
|
||||
|
||||
本节作为实施、联调、排障时的字段字典,避免同名字段被不同团队误读。
|
||||
|
||||
### 8.1 profiles
|
||||
|
||||
- `id`:用户主键,直接对应 `auth.users.id`,生命周期与认证用户绑定。
|
||||
- `username`:展示名/昵称,不承担唯一身份语义。
|
||||
- `avatar_url`:头像地址。
|
||||
- `bio`:用户简介。
|
||||
- `settings`:用户配置 JSON,承载语言、时区、隐私、通知等可扩展偏好。
|
||||
- `created_at`:记录创建时间。
|
||||
- `updated_at`:最近一次更新记录时间。
|
||||
- `deleted_at`:软删除时间,`null` 表示有效。
|
||||
|
||||
### 8.2 user_points
|
||||
|
||||
- `user_id`:积分账户所属用户,1:1 对应 `auth.users.id`。
|
||||
- `balance`:当前可计入总余额的积分值(含可用与冻结)。
|
||||
- `frozen_balance`:冻结中的积分,暂不可消费。
|
||||
- `lifetime_earned`:历史累计获得积分(单调递增)。
|
||||
- `lifetime_spent`:历史累计消费积分(单调递增)。
|
||||
- `version`:乐观锁版本号,用于并发更新防冲突。
|
||||
- `updated_at`:积分账户最近一次变更时间。
|
||||
|
||||
### 8.3 points_ledger
|
||||
|
||||
- `id`:流水主键。
|
||||
- `user_id`:该条积分流水所属用户。
|
||||
- `direction`:变更方向,`1` 表示加分,`-1` 表示扣分。
|
||||
- `amount`:变更绝对值,始终为正数。
|
||||
- `balance_after`:本次变更完成后的账户余额快照。
|
||||
- `change_type`:变更分类,仅允许 `register/consume/grant/adjust`。
|
||||
- `biz_type`:业务域类型,当前固定 `chat`。
|
||||
- `biz_id`:业务侧引用 ID,当前固定引用 `sessions.id`。
|
||||
- `event_id`:幂等事件 ID,同一用户下不可重复。
|
||||
- `operator_id`:操作人(系统/管理员/服务账号)用户 ID,可空。
|
||||
- `metadata`:扩展信息 JSON(上下文参数、备注、来源等)。
|
||||
- `created_at`:流水写入时间。
|
||||
|
||||
### 8.4 sessions
|
||||
|
||||
- `id`:会话主键。
|
||||
- `user_id`:会话所属用户。
|
||||
- `session_type`:会话类型,当前只启用 `chat`,结构保留 `automation`。
|
||||
- `job_id`:自动化任务 ID(当前阶段应为 `null`)。
|
||||
- `title`:会话标题。
|
||||
- `status`:会话状态(如 active/archived/closed)。
|
||||
- `last_activity_at`:最近活动时间,用于排序与回收策略。
|
||||
- `message_count`:消息总数聚合值。
|
||||
- `total_tokens`:会话累计 token 聚合值。
|
||||
- `total_cost`:会话累计成本聚合值。
|
||||
- `state_snapshot`:会话状态快照(用于上下文恢复/调试)。
|
||||
- `created_at`:创建时间。
|
||||
- `updated_at`:更新时间。
|
||||
- `deleted_at`:软删除时间。
|
||||
|
||||
### 8.5 messages
|
||||
|
||||
- `id`:消息主键。
|
||||
- `session_id`:所属会话 ID,级联删除。
|
||||
- `seq`:会话内消息序号(从小到大单调)。
|
||||
- `role`:消息角色(`user/assistant/system/tool`)。
|
||||
- `content`:消息主体文本。
|
||||
- `model_code`:生成该消息的模型标识。
|
||||
- `tool_name`:工具消息对应工具名。
|
||||
- `input_tokens`:本条请求输入 token。
|
||||
- `output_tokens`:本条响应输出 token。
|
||||
- `cost`:本条消息成本。
|
||||
- `latency_ms`:本条消息处理耗时(毫秒)。
|
||||
- `visibility_mask`:可见性位掩码,用于多视图过滤。
|
||||
- `metadata`:扩展信息 JSON。
|
||||
- `created_at`:创建时间。
|
||||
- `updated_at`:更新时间。
|
||||
- `deleted_at`:软删除时间。
|
||||
|
||||
## 9. 审查结论(重点:user_points / points_ledger)
|
||||
|
||||
结论:当前字段集可支撑一期上线,但若目标是“高并发 + 强审计 + 低误用”,建议在 DDL 层补 4 项硬约束、1 项审计字段,能显著降低后续事故概率。
|
||||
|
||||
### 9.1 user_points 审查
|
||||
|
||||
现状可用点:
|
||||
|
||||
- 账户余额、冻结、累计收支、版本号齐全,满足账户模型最小闭环。
|
||||
- 非负约束已覆盖核心数值字段,能防止明显脏数据。
|
||||
|
||||
主要风险与建议:
|
||||
|
||||
1. 缺少 `frozen_balance <= balance` 约束。
|
||||
- 风险:可能出现“冻结金额大于总余额”的不合法状态。
|
||||
- 建议:新增 `check (frozen_balance <= balance)`。
|
||||
|
||||
2. 缺少 `created_at`。
|
||||
- 风险:无法直接追溯账户初始化时间,审计链不完整。
|
||||
- 建议:新增 `created_at timestamptz not null default now()`。
|
||||
|
||||
3. 并发写依赖应用层版本控制,需明确 SQL 写法。
|
||||
- 风险:若更新语句未携带 `version` 条件,可能发生覆盖写。
|
||||
- 建议:约定更新模板 `... where user_id=? and version=?`,成功后 `version=version+1`。
|
||||
|
||||
### 9.2 points_ledger 审查
|
||||
|
||||
现状可用点:
|
||||
|
||||
- `direction + amount + balance_after + event_id` 组合,已具备审计、幂等、对账基础能力。
|
||||
- `(user_id, event_id)` 唯一约束符合“同一用户维度幂等”场景。
|
||||
|
||||
主要风险与建议:
|
||||
|
||||
1. 缺少 `balance_after >= 0` 约束。
|
||||
- 风险:极端并发或逻辑 bug 时可能落负余额快照。
|
||||
- 建议:新增 `check (balance_after >= 0)`。
|
||||
|
||||
2. `operator_id` 未声明外键语义。
|
||||
- 风险:排障时难确认操作者主体是否存在。
|
||||
- 建议:若业务允许,增加 FK `operator_id -> auth.users(id)`(可 `on delete set null`)。
|
||||
|
||||
3. `change_type/biz_type` 为自由文本。
|
||||
- 风险:枚举漂移(同义不同写)导致统计口径分裂。
|
||||
- 建议:通过 `check in (...)` 或字典表约束可选值。
|
||||
|
||||
4. 缺少“业务发生时间”字段。
|
||||
- 风险:`created_at` 仅表示入库时间,异步补偿场景下难对齐业务时序。
|
||||
- 建议:二期可加 `occurred_at timestamptz`。
|
||||
|
||||
### 9.3 一期最低增强清单(建议)
|
||||
|
||||
若只做最小改动,优先加以下 5 项:
|
||||
|
||||
1. `user_points`: `check (frozen_balance <= balance)`。
|
||||
2. `user_points`: `created_at timestamptz not null default now()`。
|
||||
3. `points_ledger`: `check (balance_after >= 0)`。
|
||||
4. `points_ledger`: 明确 `operator_id` 外键策略。
|
||||
5. 统一 `change_type/biz_type` 枚举口径(约束或字典表)。
|
||||
|
||||
## 10. points_ledger 约束模型(定稿草案)
|
||||
|
||||
本节将 `change_type`、`biz_type`、`metadata` 固化为可执行约束,作为后续 DDL 实现依据。
|
||||
|
||||
### 10.1 change_type / biz_type / biz_id 约束
|
||||
|
||||
- `change_type`:`register | consume | grant | adjust`
|
||||
- `biz_type`:当前仅允许 `chat`
|
||||
- `biz_id`:`uuid not null`,并 `FK -> sessions(id)`
|
||||
|
||||
配套业务约束建议:
|
||||
|
||||
- `register/grant` 必须 `direction = 1`
|
||||
- `consume` 必须 `direction = -1`
|
||||
- `adjust` 允许 `direction in (1, -1)`
|
||||
|
||||
建议 SQL(可直接迁移化):
|
||||
|
||||
```sql
|
||||
alter table points_ledger
|
||||
add constraint ck_points_ledger_change_type
|
||||
check (change_type in ('register', 'consume', 'grant', 'adjust')),
|
||||
add constraint ck_points_ledger_biz_type
|
||||
check (biz_type = 'chat'),
|
||||
add constraint ck_points_ledger_direction_by_change_type
|
||||
check (
|
||||
(change_type in ('register', 'grant') and direction = 1)
|
||||
or (change_type = 'consume' and direction = -1)
|
||||
or (change_type = 'adjust' and direction in (1, -1))
|
||||
),
|
||||
add constraint fk_points_ledger_biz_session
|
||||
foreign key (biz_id) references sessions(id);
|
||||
```
|
||||
|
||||
### 10.2 metadata 结构(基于现有 chat 数据的定制模型)
|
||||
|
||||
设计依据(来自当前代码里的真实字段):
|
||||
|
||||
- `messages.metadata` 已稳定存在 `run_id`(见 `AgentChatMessageMetadata.run_id`)。
|
||||
- `messages` 表已有计费上下文列:`id/seq/model_code/input_tokens/output_tokens/cost`。
|
||||
- chat 业务主键是 `session_id`,本设计里已对应 `points_ledger.biz_id`。
|
||||
|
||||
因此,`points_ledger.metadata` 不再使用泛化字段,直接锚定现有运行时和消息数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"reason_code": "REGISTER_WELCOME|CHAT_CONSUME|CHAT_GRANT|CHAT_ADJUST",
|
||||
"operator_type": "user|system|admin",
|
||||
"run_id": "string",
|
||||
"request_id": "string|null",
|
||||
"charge": {
|
||||
"message_id": "uuid",
|
||||
"message_seq": 1,
|
||||
"model_code": "string",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cost": "0.000000"
|
||||
},
|
||||
"ext": {}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明(按现有数据来源):
|
||||
|
||||
- `schema_version`:固定 `1`。
|
||||
- `reason_code`:固定业务原因码,不允许自由文本。
|
||||
- `operator_type`:与 `operator_id` 搭配使用,表达操作者身份类型。
|
||||
- `run_id`:来自 agent 运行主键(`messages.metadata.run_id` 同源)。
|
||||
- `request_id`:来自 `X-Request-ID`(可空,排障用)。
|
||||
- `charge`:消费/赠金/调整时的“消息快照”,字段全部来自 `messages` 现有列。
|
||||
- `ext`:仅允许对象,承载少量扩展审计信息(如工单号)。
|
||||
|
||||
按 `change_type` 的必填规则(不是通用模板,直接按你当前业务):
|
||||
|
||||
- `register`:必须有 `reason_code/operator_type/run_id`,`charge` 必须不存在。
|
||||
- `consume`:必须有 `reason_code/operator_type/run_id/charge`,且 `charge.message_id/message_seq/model_code/input_tokens/output_tokens/cost` 全必填。
|
||||
- `grant`:必须有 `reason_code/operator_type/run_id`;若是“按会话补偿赠金”,允许并建议带 `charge`。
|
||||
- `adjust`:必须有 `reason_code/operator_type/run_id` 与 `ext.ticket_id`;`charge` 可选。
|
||||
|
||||
建议 SQL(JSON 约束可执行最小集):
|
||||
|
||||
```sql
|
||||
alter table points_ledger
|
||||
add constraint ck_points_ledger_metadata_object
|
||||
check (jsonb_typeof(metadata) = 'object'),
|
||||
add constraint ck_points_ledger_metadata_common
|
||||
check (
|
||||
metadata->>'schema_version' = '1'
|
||||
and metadata->>'reason_code' in ('REGISTER_WELCOME', 'CHAT_CONSUME', 'CHAT_GRANT', 'CHAT_ADJUST')
|
||||
and metadata->>'operator_type' in ('user', 'system', 'admin')
|
||||
and coalesce(metadata->>'run_id', '') <> ''
|
||||
and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')
|
||||
),
|
||||
add constraint ck_points_ledger_metadata_register_shape
|
||||
check (
|
||||
change_type <> 'register'
|
||||
or (
|
||||
metadata->>'reason_code' = 'REGISTER_WELCOME'
|
||||
and not (metadata ? 'charge')
|
||||
)
|
||||
),
|
||||
add constraint ck_points_ledger_metadata_consume_shape
|
||||
check (
|
||||
change_type <> 'consume'
|
||||
or (
|
||||
metadata->>'reason_code' = 'CHAT_CONSUME'
|
||||
and (metadata ? 'charge')
|
||||
and jsonb_typeof(metadata->'charge') = 'object'
|
||||
and (metadata->'charge' ? 'message_id')
|
||||
and (metadata->'charge' ? 'message_seq')
|
||||
and (metadata->'charge' ? 'model_code')
|
||||
and (metadata->'charge' ? 'input_tokens')
|
||||
and (metadata->'charge' ? 'output_tokens')
|
||||
and (metadata->'charge' ? 'cost')
|
||||
)
|
||||
),
|
||||
add constraint ck_points_ledger_metadata_adjust_shape
|
||||
check (
|
||||
change_type <> 'adjust'
|
||||
or (
|
||||
metadata->>'reason_code' = 'CHAT_ADJUST'
|
||||
and (metadata ? 'ext')
|
||||
and (metadata->'ext' ? 'ticket_id')
|
||||
and coalesce(metadata #>> '{ext,ticket_id}', '') <> ''
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
可选强化(建议二期加触发器,而不是只靠 CHECK):
|
||||
|
||||
- 校验 `metadata.charge.message_id` 真正存在于 `messages.id`,且 `messages.session_id = points_ledger.biz_id`。
|
||||
- 校验 `metadata.charge.message_seq` 与该 `message_id` 的真实 `seq` 一致。
|
||||
|
||||
### 10.3 operator_id 与 created_by/updated_by 是否重复
|
||||
|
||||
不重复,语义不同:
|
||||
|
||||
- `operator_id`:业务操作者(“谁触发了积分变更”),是业务审计字段。
|
||||
- `created_by/updated_by`:数据行审计字段(“谁写了这条数据库记录”)。
|
||||
|
||||
对 `points_ledger`(不可变流水)而言:
|
||||
|
||||
- `updated_by` 基本无意义(流水不应更新)。
|
||||
- `created_by` 常等于服务账号,无法表达真实业务操作者。
|
||||
- 因此保留 `operator_id` 是必要的,且建议允许空值(纯系统任务)。
|
||||
|
||||
推荐实践:
|
||||
|
||||
- `points_ledger`:保留 `operator_id`,不强制引入 `created_by/updated_by`。
|
||||
- `user_points`:如项目需要统一审计基类,可在账户表引入 `updated_by`,但不替代流水里的 `operator_id`。
|
||||
@@ -0,0 +1,241 @@
|
||||
# Eryao 解卦历史与个人档案后端单一数据源改造计划
|
||||
|
||||
日期:2026-04-05
|
||||
状态:评审中(未开始编码)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前移动端存在两类不符合目标架构的问题:
|
||||
|
||||
1. 个人档案(昵称、简介、头像)仍有前端本地状态路径,非后端权威数据源。
|
||||
2. 首页历史解卦无法稳定由后端快照直接重建结果页,前端被迫做本地兜底。
|
||||
|
||||
本计划目标:
|
||||
|
||||
- 实现“后端为唯一数据源,前端仅缓存”。
|
||||
- 将 `DIVINATION_DERIVED` 的完整结构进入消息 `metadata.agent_output` 并持久化。
|
||||
- 历史接口返回可被前端直接解析的结构化 assistant 输出(不再依赖 `ui_schema`)。
|
||||
- 个人档案全链路后端化,头像使用 `avatars` bucket。
|
||||
|
||||
非目标:
|
||||
|
||||
- 本计划不直接提交代码实现。
|
||||
- 本计划不包含 UI 视觉细节改稿。
|
||||
|
||||
## 2. 现状核对(基于仓库代码)
|
||||
|
||||
### 2.1 历史接口与消息转换
|
||||
|
||||
- 历史接口:`GET /api/v1/agent/history`,定义于 `backend/src/v1/agent/router.py`。
|
||||
- 当前转换逻辑在 `backend/src/v1/agent/utils.py`:
|
||||
- `user` 消息主要输出 `content` 与 `attachments`。
|
||||
- `assistant` 消息默认走 `ui_hints -> ui_schema` 编译路径。
|
||||
- 历史响应结构 `HistoryMessage` 当前包含 `ui_schema`,不直接暴露结构化 `agent_output`。
|
||||
|
||||
### 2.2 DIVINATION_DERIVED 与落库断点
|
||||
|
||||
- 运行时会发出 `DIVINATION_DERIVED`(见 `backend/src/core/agentscope/runtime/runner.py`)。
|
||||
- 消息落库由 `backend/src/core/agentscope/events/store.py` 负责。
|
||||
- 当前 `TEXT_MESSAGE_END` 持久化字段包含 `sign_level/summary/.../ui_hints`,未包含 `divination` 结构。
|
||||
- 结果:历史快照难以完整重建结果页结构。
|
||||
|
||||
### 2.3 Profile 与头像
|
||||
|
||||
- 后端配置已有 `storage.avatar.bucket`,默认 `avatars`(`backend/src/core/config/settings.py`)。
|
||||
- 当前 `v1` 仅挂载 `auth/agent/points` 路由(`backend/src/v1/router.py`),尚无 profile 专用路由。
|
||||
|
||||
## 3. 核心设计决策
|
||||
|
||||
### 决策 A:把 `divination_derived` 放入 `metadata.agent_output`
|
||||
|
||||
- 在 `AgentOutput` 增加字段 `divination_derived`(强类型,禁止裸 `dict`)。
|
||||
- 事件落库时把 `DIVINATION_DERIVED` 内容并入 assistant 的 `metadata.agent_output.divination_derived`。
|
||||
- 与 `sign_level/summary/advice/...` 同时持久化,形成一条可回放的 assistant 结构化输出。
|
||||
|
||||
理由:
|
||||
|
||||
- 最小改动复用现有消息表,不新增历史结果表即可满足回放需求。
|
||||
- 前端可直接从历史响应解析结果页,避免本地拼装。
|
||||
|
||||
### 决策 B:历史接口返回 `assistant.agent_output`,移除 `ui_schema`
|
||||
|
||||
- `HistoryMessage` 改为:
|
||||
- `user`: `content + attachments`
|
||||
- `assistant`: `content + agent_output`
|
||||
- `ui_schema` 从接口协议中移除(迁移自通用模块的历史遗留,不在本项目范围)。
|
||||
|
||||
理由:
|
||||
|
||||
- 减少中间编译层,契约更稳定、语义更清晰。
|
||||
- 前端直接消费业务数据,不依赖通用 UI 编译器。
|
||||
|
||||
### 决策 C:Profile 全后端化 + 头像对象存储
|
||||
|
||||
- 新增 users/profile API,前端只保留缓存层。
|
||||
- 头像上传走预签名 URL,bucket 固定 `avatars`,路径按用户隔离。
|
||||
|
||||
## 4. 协议与接口计划(先文档,后实现)
|
||||
|
||||
## 4.1 新增/修改协议文档
|
||||
|
||||
按“协议先行”更新以下文档:
|
||||
|
||||
1. `docs/protocols/divination/divination-run-protocol.md`
|
||||
- 增补:历史回放时 assistant `agent_output.divination_derived` 的字段契约。
|
||||
- 标记:`ui_schema` 已废弃并移除。
|
||||
2. 新增:`docs/protocols/profile/profile-protocol.md`
|
||||
- 定义 profile 读写与头像上传签名协议。
|
||||
3. 如涉及错误码新增,更新:
|
||||
- `docs/protocols/common/http-error-codes.md`
|
||||
|
||||
### 4.2 后端 API 契约(目标)
|
||||
|
||||
#### A. 历史快照(改造)
|
||||
|
||||
- `GET /api/v1/agent/history`
|
||||
- 响应中 assistant 消息新增(或替换为)`agent_output`:
|
||||
- `sign_level`
|
||||
- `summary`
|
||||
- `conclusion`
|
||||
- `focus_points`
|
||||
- `advice`
|
||||
- `keywords`
|
||||
- `answer`
|
||||
- `divination_derived`(完整卦象结构)
|
||||
|
||||
#### B. Profile(新增)
|
||||
|
||||
- `GET /api/v1/users/me/profile`
|
||||
- `PATCH /api/v1/users/me/profile`
|
||||
- `POST /api/v1/users/me/avatar/upload-url`
|
||||
- (可选)`GET /api/v1/users/me/avatar/signed-url`
|
||||
|
||||
#### C. 头像上传约束
|
||||
|
||||
- bucket 固定:`config.storage.avatar.bucket`
|
||||
- 路径前缀建议:`avatars/{user_id}/...`
|
||||
- 文件类型:`image/png|image/jpeg|image/webp`
|
||||
- 体积上限:`config.storage.avatar.max_size_mb`
|
||||
|
||||
## 5. 数据模型改造计划
|
||||
|
||||
### 5.1 Runtime 模型
|
||||
|
||||
- 文件:`backend/src/schemas/agent/runtime_models.py`
|
||||
- 变更:`AgentOutput` 增加 `divination_derived` 字段(类型复用 `schemas/domain/divination.py`)。
|
||||
- 规则:保持 `extra="forbid"`,禁止无类型漂移。
|
||||
|
||||
### 5.2 事件到落库链路
|
||||
|
||||
- 文件:`backend/src/core/agentscope/runtime/stage_emitter.py`
|
||||
- `TEXT_MESSAGE_END` payload 带上 `divination_derived`。
|
||||
- 文件:`backend/src/core/agentscope/events/store.py`
|
||||
- `worker_output_fields` 纳入 `divination_derived` 并写入 `metadata.agent_output`。
|
||||
|
||||
### 5.3 历史响应转换
|
||||
|
||||
- 文件:`backend/src/v1/agent/utils.py`
|
||||
- 删除 `ui_hints -> ui_schema` 编译路径。
|
||||
- assistant 消息改为抽取并返回受控 `agent_output`。
|
||||
- 文件:`backend/src/v1/agent/schemas.py`
|
||||
- `HistoryMessage` 改字段定义(去 `ui_schema`,加 `agent_output`)。
|
||||
|
||||
## 6. 前端消费与缓存策略
|
||||
|
||||
### 6.1 历史与结果页
|
||||
|
||||
- 历史列表数据源改为后端 `agent/history`。
|
||||
- 点开历史项时:
|
||||
- 直接解析 `assistant.agent_output.divination_derived` + 解释文本字段。
|
||||
- 本地仅做缓存,不做真源 fallback。
|
||||
|
||||
### 6.2 Profile
|
||||
|
||||
- 设置页资料读取改为 `GET /users/me/profile`。
|
||||
- 编辑资料写入 `PATCH /users/me/profile`。
|
||||
- 头像更新走 upload-url + 上传 + profile 更新引用路径。
|
||||
|
||||
### 6.3 点数
|
||||
|
||||
- 保持后端余额接口作为权威数据源(现有已接)。
|
||||
- 前端只做短期缓存,解卦完成后强制 refresh。
|
||||
|
||||
## 7. 代码清理边界(你关心的“删除通用遗留”)
|
||||
|
||||
原则:先去引用,再删定义,最后删文件,避免误删。
|
||||
|
||||
分三步:
|
||||
|
||||
1. 第一阶段(本次改造内)
|
||||
- 删除 `agent/history` 对 `ui_schema` 的输出与依赖。
|
||||
- 删除前端对 `ui_schema` 的消费路径(若存在)。
|
||||
2. 第二阶段(安全清理)
|
||||
- 搜索 `schemas/domain` 与 `schemas/agent/ui_hints` 的实际引用。
|
||||
- 对“零引用 + 非协议字段”进行清理。
|
||||
3. 第三阶段(文档与测试补齐)
|
||||
- 更新协议文档、错误码、回归测试。
|
||||
|
||||
备注:
|
||||
|
||||
- 不建议在同一 PR 里“功能改造 + 大规模 schema 删除”,建议拆成两个 PR,降低回归风险。
|
||||
|
||||
## 8. 测试计划(必须项)
|
||||
|
||||
### 8.1 后端单元/集成
|
||||
|
||||
1. `TEXT_MESSAGE_END` 持久化:`metadata.agent_output.divination_derived` 落库断言。
|
||||
2. `GET /api/v1/agent/history`:assistant 返回 `agent_output`,且不再返回 `ui_schema`。
|
||||
3. 历史分页与 owner 校验不回退。
|
||||
4. profile API:读写、权限、字段约束、头像路径安全性。
|
||||
5. 头像签名 URL:bucket/path/mime/size 约束。
|
||||
|
||||
### 8.2 前端
|
||||
|
||||
1. 历史列表从后端数据渲染。
|
||||
2. 点击历史项成功进入结果页,字段一致性校验。
|
||||
3. profile 页面读写闭环(昵称/简介/头像)。
|
||||
4. 点数刷新与缓存失效策略验证。
|
||||
|
||||
## 9. 风险与回滚
|
||||
|
||||
主要风险:
|
||||
|
||||
- 历史消息中旧数据可能没有 `divination_derived`,前端需兼容空值。
|
||||
- `ui_schema` 下线后,若有隐藏调用方会断。
|
||||
|
||||
回滚策略:
|
||||
|
||||
- 协议层采用短期双读兼容窗口(仅过渡期):
|
||||
- 新字段优先;旧字段仅用于读,不再写。
|
||||
- 若线上异常,先回滚 history 响应变更,再保持落库新增字段不删。
|
||||
|
||||
## 10. 实施顺序(最小风险)
|
||||
|
||||
1. 协议文档更新并评审通过。
|
||||
2. 后端:`AgentOutput` + 事件落库 + history 响应新增 `agent_output`(先加后切)。
|
||||
3. 前端:改消费到 `agent_output`,移除本地真源。
|
||||
4. 后端:移除 `ui_schema` 输出。
|
||||
5. profile API + 前端接入头像上传。
|
||||
6. 清理无用 schema(独立 PR)。
|
||||
|
||||
## 11. 验收标准(DoD)
|
||||
|
||||
全部满足才算完成:
|
||||
|
||||
1. 解卦后写入的 assistant 消息在 DB 中可见 `metadata.agent_output.divination_derived`。
|
||||
2. 首页历史完全来自后端,清空本地缓存后仍可正确展示。
|
||||
3. 历史详情可完整还原结果页,不依赖 `ui_schema`。
|
||||
4. profile 读写走后端,头像实际落 `avatars` bucket。
|
||||
5. 前端不再把 profile/history 作为本地权威数据源。
|
||||
6. 协议文档与实现一致,相关测试通过。
|
||||
|
||||
## 12. GSTACK REVIEW REPORT
|
||||
|
||||
| Review | Trigger | Why | Runs | Status | Findings |
|
||||
|--------|---------|-----|------|--------|----------|
|
||||
| Eng Review | `/plan-eng-review` | 锁定架构、契约、测试闭环 | 1 | Done | 确认后端单一数据源方向;建议分阶段移除 `ui_schema` 并将 schema 清理拆分独立 PR |
|
||||
| CEO Review | `/plan-ceo-review` | 范围与优先级 | 0 | — | — |
|
||||
| Design Review | `/plan-design-review` | UI/UX 风险 | 0 | — | — |
|
||||
| DX Review | `/plan-devex-review` | 开发体验风险 | 0 | — | — |
|
||||
|
||||
VERDICT:可以进入实现阶段,但必须先完成协议文档更新并冻结字段契约。
|
||||
@@ -0,0 +1,403 @@
|
||||
# Eryao 工程计划:历史解卦与个人档案后端化(单一数据源)
|
||||
|
||||
日期:2026-04-05
|
||||
状态:规划中(Planning Only)
|
||||
|
||||
## 0. 约束与决策前提
|
||||
|
||||
本计划基于已确认前提:
|
||||
|
||||
1. 当前无生产兼容压力,旧字段可直接不兼容。
|
||||
2. 前端只做缓存层,不做权威数据源。
|
||||
3. `ui_schema` 属于通用迁移遗留,不在本项目范围,目标是移除。
|
||||
4. 头像存储必须使用 `avatars` bucket(`config.storage.avatar.bucket`)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不引入额外业务表的前提下,完成以下工程目标:
|
||||
|
||||
1. assistant 消息落库时,`metadata.agent_output` 持久化完整 `divination_derived`。
|
||||
2. `GET /api/v1/agent/history` 返回前端可直接消费的 `assistant.agent_output`(移除 `ui_schema`)。
|
||||
3. 新增 profile 后端 API,前端设置页改为后端读写。
|
||||
4. 头像上传改为预签名 + `avatars` bucket,后端校验路径和类型。
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统边界与职责
|
||||
|
||||
### 2.1 边界图
|
||||
|
||||
```text
|
||||
[Flutter App]
|
||||
| Auth Token
|
||||
v
|
||||
[API Router v1]
|
||||
|---- /agent/runs + /agent/history
|
||||
|---- /users/me/profile + /users/me/avatar/upload-url
|
||||
v
|
||||
[Service Layer]
|
||||
|---- AgentService: 会话、历史、消息转换
|
||||
|---- UserProfileService: 档案读写、头像签名
|
||||
v
|
||||
[Repository Layer]
|
||||
|---- sessions/messages/profiles CRUD
|
||||
v
|
||||
[Postgres + Supabase Storage]
|
||||
|---- messages.metadata_json
|
||||
|---- profiles
|
||||
|---- bucket: avatars
|
||||
```
|
||||
|
||||
### 2.2 分层职责
|
||||
|
||||
- Router:参数校验、鉴权入口、RFC7807 错误转换。
|
||||
- Service:业务规则与信任边界控制。
|
||||
- Repository:纯查询和写入,不做鉴权决策。
|
||||
- Schema:协议强类型、禁止松散 dict 漂移。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据流设计
|
||||
|
||||
## 3.1 解卦写入链路(新增 `divination_derived`)
|
||||
|
||||
```text
|
||||
POST /agent/runs
|
||||
-> Runner emit DIVINATION_DERIVED(divination)
|
||||
-> StageEmitter merge into TEXT_MESSAGE_END payload
|
||||
-> EventStore picks worker_output_fields
|
||||
-> metadata.agent_output.divination_derived persisted
|
||||
-> messages.metadata_json
|
||||
```
|
||||
|
||||
### 关键点
|
||||
|
||||
1. `AgentOutput` 增加 `divination_derived` 强类型字段。
|
||||
2. `EventStore` 字段白名单纳入 `divination_derived`。
|
||||
3. `extra="forbid"` 保留,防止脏字段入库。
|
||||
|
||||
## 3.2 历史读取链路(移除 `ui_schema`)
|
||||
|
||||
```text
|
||||
GET /agent/history
|
||||
-> AgentService.get_history_snapshot
|
||||
-> convert_message_to_history
|
||||
user -> content + attachments
|
||||
assistant -> content + agent_output
|
||||
-> HistoryMessage response
|
||||
```
|
||||
|
||||
### 关键点
|
||||
|
||||
1. 停止 `ui_hints -> ui_schema` 编译。
|
||||
2. assistant 返回受控 `agent_output` 子集,不透传任意 metadata。
|
||||
3. 前端结果页以 `agent_output.divination_derived` 为主数据源。
|
||||
|
||||
## 3.3 Profile 与头像链路
|
||||
|
||||
```text
|
||||
GET /users/me/profile
|
||||
-> read profiles
|
||||
|
||||
PATCH /users/me/profile
|
||||
-> validate payload
|
||||
-> update profiles
|
||||
|
||||
POST /users/me/avatar/upload-url
|
||||
-> validate mime/size/path
|
||||
-> create signed upload url (bucket=avatars)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 契约(冻结版)
|
||||
|
||||
## 4.1 History 响应(目标结构)
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "history_day",
|
||||
"threadId": "uuid",
|
||||
"day": "2026-04-05",
|
||||
"hasMore": false,
|
||||
"messages": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"seq": 12,
|
||||
"role": "assistant",
|
||||
"content": "...",
|
||||
"timestamp": "2026-04-05T12:34:56Z",
|
||||
"agent_output": {
|
||||
"sign_level": "中上签",
|
||||
"summary": "...",
|
||||
"conclusion": ["..."],
|
||||
"focus_points": ["..."],
|
||||
"advice": ["..."],
|
||||
"keywords": ["..."],
|
||||
"answer": "...",
|
||||
"divination_derived": {
|
||||
"binaryCode": "101001",
|
||||
"changedBinaryCode": "100001",
|
||||
"guaName": "...",
|
||||
"targetGuaName": "...",
|
||||
"ganzhi": {},
|
||||
"yaoInfoList": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 本接口不再返回 `ui_schema`。
|
||||
- user 消息仍可返回 `attachments`。
|
||||
|
||||
## 4.2 Profile API
|
||||
|
||||
### `GET /api/v1/users/me/profile`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"display_name": "string",
|
||||
"bio": "string",
|
||||
"avatar_path": "avatars/{user_id}/...",
|
||||
"avatar_url": "https://...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /api/v1/users/me/profile`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "string<=30",
|
||||
"bio": "string<=200",
|
||||
"avatar_path": "avatars/{user_id}/..."
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/users/me/avatar/upload-url`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"mime_type": "image/png",
|
||||
"file_size": 123456,
|
||||
"ext": "png"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"bucket": "avatars",
|
||||
"path": "avatars/{user_id}/{uuid}.png",
|
||||
"upload_url": "https://...",
|
||||
"expires_in": 600
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 信任边界与安全规则
|
||||
|
||||
1. `user_id` 只能取 JWT `sub`,禁止客户端传 owner。
|
||||
2. 头像 path 必须前缀匹配:`avatars/{current_user.id}/`。
|
||||
3. bucket 必须等于 `config.storage.avatar.bucket`。
|
||||
4. mime 白名单:`image/png|image/jpeg|image/webp`。
|
||||
5. size 上限:`config.storage.avatar.max_size_mb`。
|
||||
6. history 读取严格校验 session owner。
|
||||
7. 错误统一 RFC7807 + `code`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败模式与处理
|
||||
|
||||
## 6.1 消息落库阶段
|
||||
|
||||
1. `divination_derived` 校验失败
|
||||
- 行为:拒绝写入该字段并记录结构化日志。
|
||||
- 错误码:`AGENT_OUTPUT_DIVINATION_INVALID`(新)。
|
||||
2. TEXT_MESSAGE_END 缺失关键字段
|
||||
- 行为:整条 assistant 消息按失败路径处理,不写半残对象。
|
||||
|
||||
## 6.2 history 读取阶段
|
||||
|
||||
1. `agent_output` 缺失或损坏
|
||||
- 行为:assistant 消息返回 `content`,并标记 `agent_output=null`。
|
||||
- 前端:展示“历史记录不完整”提示,不崩溃。
|
||||
2. 非 owner 访问
|
||||
- 行为:403,`code=AGENT_SESSION_FORBIDDEN`。
|
||||
|
||||
## 6.3 头像上传阶段
|
||||
|
||||
1. bucket/path 越权
|
||||
- 422,`AVATAR_PATH_SCOPE_INVALID`。
|
||||
2. mime/size 非法
|
||||
- 422,`AVATAR_FILE_INVALID`。
|
||||
3. storage 签名失败
|
||||
- 502,`AVATAR_SIGNED_URL_FAILED`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键边缘场景
|
||||
|
||||
1. 用户连续点击“保存资料”两次:
|
||||
- 以后端最后一次写入为准,前端按钮防抖。
|
||||
2. 上传头像成功但 profile 更新失败:
|
||||
- 前端重试 profile PATCH,不重复上传。
|
||||
3. history 返回空列表:
|
||||
- 前端展示空态,不触发本地假数据。
|
||||
4. 助手消息存在但缺 `divination_derived`:
|
||||
- 卡片可展示摘要,不允许进入完整结果页。
|
||||
5. 解卦完成后 history 立即读取:
|
||||
- 允许短暂读到旧快照,前端做一次重拉。
|
||||
|
||||
---
|
||||
|
||||
## 8. 技术取舍
|
||||
|
||||
### 方案 A(推荐):在现有 messages.metadata 扩展
|
||||
|
||||
- 优点:
|
||||
- 最小变更,不新增表。
|
||||
- 复用当前会话与历史体系。
|
||||
- 缺点:
|
||||
- metadata 体积增大,需要关注单条消息大小。
|
||||
|
||||
### 方案 B:新增 `divination_results` 独立表
|
||||
|
||||
- 优点:
|
||||
- 结构更纯,查询更明确。
|
||||
- 缺点:
|
||||
- 迁移、回写、关联复杂度明显增加。
|
||||
|
||||
结论:
|
||||
|
||||
- 当前阶段选 A,满足速度与复杂度平衡。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施切片(按风险顺序)
|
||||
|
||||
### Slice 1:协议与 schema
|
||||
|
||||
1. 更新协议文档:history + profile + 错误码。
|
||||
2. 更新 `AgentOutput` 模型字段。
|
||||
|
||||
### Slice 2:写链路改造
|
||||
|
||||
1. runner/emitter/store 打通 `divination_derived` 落库。
|
||||
2. 增加单元测试与集成测试。
|
||||
|
||||
### Slice 3:读链路改造
|
||||
|
||||
1. history 转换改为返回 `agent_output`。
|
||||
2. 移除 `ui_schema` 响应字段。
|
||||
|
||||
### Slice 4:profile API + 头像
|
||||
|
||||
1. users 路由、service、schema。
|
||||
2. 头像 upload-url 接口。
|
||||
|
||||
### Slice 5:前端切换
|
||||
|
||||
1. 历史列表/详情改消费后端 `agent_output`。
|
||||
2. 设置页改 profile 接口。
|
||||
3. 清理本地真源。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试覆盖计划
|
||||
|
||||
## 10.1 后端测试矩阵
|
||||
|
||||
### A. AgentOutput 落库
|
||||
|
||||
1. `divination_derived` 正常写入。
|
||||
2. `divination_derived` 非法结构拒绝写入。
|
||||
|
||||
### B. history 接口
|
||||
|
||||
1. assistant 返回 `agent_output`。
|
||||
2. 响应不含 `ui_schema`。
|
||||
3. 非 owner 403。
|
||||
4. 空历史返回空数组。
|
||||
|
||||
### C. profile 接口
|
||||
|
||||
1. GET 返回当前用户档案。
|
||||
2. PATCH 字段边界(空、超长、非法字符)。
|
||||
3. 并发 PATCH 最终一致性。
|
||||
|
||||
### D. avatar upload-url
|
||||
|
||||
1. 合法 mime/size/path 成功签名。
|
||||
2. bucket/path 越权失败。
|
||||
3. mime/size 超限失败。
|
||||
4. storage 异常返回 502 问题体。
|
||||
|
||||
## 10.2 前端测试矩阵
|
||||
|
||||
1. history 列表从接口渲染。
|
||||
2. 点击历史项进入结果页并解析 `divination_derived`。
|
||||
3. profile 读写回显。
|
||||
4. 头像上传后刷新显示。
|
||||
5. 异常提示(网络失败、数据缺失)不崩溃。
|
||||
|
||||
---
|
||||
|
||||
## 11. 可观测性
|
||||
|
||||
新增日志字段建议:
|
||||
|
||||
1. history 响应统计:`thread_id`, `message_count`, `assistant_with_agent_output_count`。
|
||||
2. profile 更新:`user_id`, `updated_fields`。
|
||||
3. avatar 签名:`user_id`, `mime_type`, `file_size`, `success/failure_code`。
|
||||
|
||||
指标建议:
|
||||
|
||||
1. `history_agent_output_missing_rate`。
|
||||
2. `avatar_upload_url_failure_rate`。
|
||||
3. `profile_patch_error_rate`。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与回滚
|
||||
|
||||
### 风险
|
||||
|
||||
1. 单条 metadata 变大,可能影响查询性能。
|
||||
2. 前端解析新结构时存在字段名误配风险。
|
||||
|
||||
### 回滚
|
||||
|
||||
1. 若读链路异常,先回滚 history 输出层(保持落库不回滚)。
|
||||
2. profile 接口异常时,可临时只读禁写,保护账户信息。
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收标准(Done)
|
||||
|
||||
1. 新产生 assistant 消息均含 `metadata.agent_output.divination_derived`。
|
||||
2. history 接口返回 `agent_output`,且不再返回 `ui_schema`。
|
||||
3. 前端历史页与结果页不依赖本地真源。
|
||||
4. profile 读写和头像上传全走后端。
|
||||
5. 测试矩阵项全部落地并通过。
|
||||
|
||||
---
|
||||
|
||||
## 14. NOT in Scope
|
||||
|
||||
1. 大规模清理 `backend/src/schemas/domain/**`。
|
||||
2. 历史数据回填脚本。
|
||||
3. 新增独立 `divination_results` 表。
|
||||
Reference in New Issue
Block a user