feat: 添加账号删除功能
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
# Bug: 追问1次后无法进入查看历史记录
|
||||
|
||||
日期:2026-04-08
|
||||
状态:已确认(未修复)
|
||||
|
||||
## 问题描述
|
||||
|
||||
追问1次之后追问入口关闭,但用户也没法点击进去查看追问的历史记录。
|
||||
|
||||
## 根因分析
|
||||
|
||||
`_loadFollowUpEligibility()` 方法(`divination_result_screen.dart:65-83`)将两个概念混为一谈:
|
||||
|
||||
```dart
|
||||
Future<void> _loadFollowUpEligibility() async {
|
||||
...
|
||||
final messages = await widget.divinationApi!.getSessionMessages(
|
||||
threadId: widget.data.threadId!,
|
||||
);
|
||||
final userCount = messages.where((msg) => msg.role == 'user').length;
|
||||
...
|
||||
setState(() {
|
||||
_canFollowUp = userCount < 2; // 同时控制"能追问"和"能进入"
|
||||
...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
按钮逻辑(`divination_result_screen.dart:367`):
|
||||
```dart
|
||||
onPressed: (!_canFollowUp || _followUpEligibilityLoading)
|
||||
? null // _canFollowUp = false 时按钮完全禁用
|
||||
: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => FollowUpChatScreen(...),
|
||||
),
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- `_canFollowUp = userCount < 2` 控制的是"是否还有追问配额"
|
||||
- 但 `onPressed` 把它和"是否能进入查看历史"混用了
|
||||
- 导致用户追问1次后(userCount=2),`_canFollowUp=false`,按钮被禁用
|
||||
- 用户**无法进入追问页面查看历史记录**
|
||||
|
||||
**业务逻辑分析**:
|
||||
- 每 session 最多2次追问(首问1次 + 追问1次)
|
||||
- 追问1次后,用户不能再发送新追问
|
||||
- 但**应该仍能进入查看历史记录**
|
||||
|
||||
### 正确的逻辑应该是
|
||||
|
||||
```dart
|
||||
// 追问配额判断
|
||||
_canFollowUp = userCount < 2;
|
||||
|
||||
// 能否进入查看历史(与追问次数无关,只要有历史消息就能进入)
|
||||
_canEnterFollowUpChat = messages.isNotEmpty; // 或者只要有 threadId 就能进
|
||||
|
||||
// 按钮文案
|
||||
buttonText = _canFollowUp ? l10n.followUpEntryHint : l10n.followUpViewHistory;
|
||||
|
||||
// 按钮是否禁用
|
||||
onPressed = (_followUpEligibilityLoading)
|
||||
? null // 只在加载中时禁用
|
||||
: () { ... } // 始终可点击进入查看
|
||||
```
|
||||
|
||||
## 证据
|
||||
|
||||
- 结果页代码:`apps/lib/features/divination/presentation/screens/divination_result_screen.dart`
|
||||
- `_buildFollowUpBar`: 行 338-394
|
||||
- `_loadFollowUpEligibility`: 行 65-83
|
||||
- 按钮 onPress: 行 367
|
||||
- 追问聊天页:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
|
||||
|
||||
## 修复方向
|
||||
|
||||
1. 分离"能否追问"和"能否进入查看历史"的逻辑
|
||||
2. 按钮文案根据状态显示不同内容(能追问显示追问提示,已用完显示"查看历史")
|
||||
3. 只要有 `threadId`,用户就应该能进入查看历史
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 随访工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
|
||||
@@ -1,261 +0,0 @@
|
||||
# Bug: 追问页面 UI 问题
|
||||
|
||||
日期:2026-04-08
|
||||
状态:已确认(未修复)
|
||||
|
||||
## Bug 1: 追问页发送时顶部和消息下方重复加载UI
|
||||
|
||||
### 问题描述
|
||||
|
||||
在追问页面,用户输入信息发送后,顶部和消息下方同时出现加载UI,但顶部的加载UI是多余的。
|
||||
|
||||
### 根因分析
|
||||
|
||||
**文件**:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
|
||||
|
||||
**问题代码**:
|
||||
|
||||
1. 顶部 step 指示器(第 85-124 行):
|
||||
```dart
|
||||
if (_sending && _currentStepName != null)
|
||||
Container(
|
||||
// 显示 step 进度,如 "解读中..."、"推理中..."
|
||||
child: Row(
|
||||
children: [
|
||||
CircularProgressIndicator(...),
|
||||
Text(_stepLabel(_currentStepName!)),
|
||||
],
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
2. 消息下方 streaming placeholder(第 565-584 行):
|
||||
```dart
|
||||
isStreamingPlaceholder
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(...),
|
||||
Text(l10n.followUpGenerating), // "生成中..."
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**事件触发流程**:
|
||||
|
||||
| 顺序 | 事件 | 顶部指示器 | 消息下方 |
|
||||
|------|------|----------|---------|
|
||||
| 1 | `_submitText()` 调用 | 无 | streaming placeholder 显示 "生成中..." |
|
||||
| 2 | `STEP_STARTED(stepName='divination')` | 显示 "解读中..." | 继续显示 |
|
||||
| 3 | `STEP_FINISHED` | 消失 | 继续显示 |
|
||||
| 4 | `STEP_STARTED(stepName='worker')` | 显示 "推理中..." | **同时显示** "生成中..." |
|
||||
| 5 | `STEP_FINISHED` | 消失 | 继续显示 |
|
||||
| 6 | `TEXT_MESSAGE_END` | 无 | placeholder 消失,显示真实内容 |
|
||||
|
||||
**问题**:在 worker 阶段(步骤 4),顶部显示 "推理中..." 同时消息下方显示 "生成中...",造成重复反馈。用户只需要一个加载反馈即可。
|
||||
|
||||
**修复方向**:
|
||||
|
||||
1. **方案A**:只在 divination 阶段显示顶部 step 指示器,worker 阶段隐藏(因为 streaming placeholder 已经提供反馈)
|
||||
|
||||
```dart
|
||||
// 第 85 行修改
|
||||
if (_sending && _currentStepName != null && _currentStepName != 'worker')
|
||||
```
|
||||
|
||||
2. **方案B**:移除顶部 step 指示器,完全依赖 streaming placeholder
|
||||
|
||||
3. **方案C**:在 worker 阶段隐藏 streaming placeholder,只显示顶部指示器
|
||||
|
||||
推荐 **方案A**,因为:
|
||||
- divination 阶段没有 streaming placeholder,需要顶部指示器提供反馈
|
||||
- worker 阶段有 streaming placeholder,不需要顶部指示器
|
||||
- 改动最小,只加一个条件
|
||||
|
||||
---
|
||||
|
||||
## Bug 2: 语音模式录制时缺少动画UI
|
||||
|
||||
### 问题描述
|
||||
|
||||
根据 social-app 的实现,按住说话模式下录音时应该有动画效果,但 eryao 的实现只有文字,没有动画。
|
||||
|
||||
### 根因分析
|
||||
|
||||
**对比**:
|
||||
|
||||
| 功能 | social-app | eryao |
|
||||
|------|------------|-------|
|
||||
| 录音中动画 | `recordingAnimation` widget(外部传入) | 只有文字 "录音中..." |
|
||||
| 录音提示文字 | 带动画的 widget | 只有文字 |
|
||||
| 录音中隐藏输入区域 | `IgnorePointer` + `Opacity` | 未实现 |
|
||||
|
||||
**eryao 当前实现**(`message_composer.dart:159-172`):
|
||||
```dart
|
||||
if (_isRecording) {
|
||||
if (!showRecordingInlineFeedback) {
|
||||
return Text(recordingText, ...);
|
||||
}
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(width: 16, height: 16), // 空白占位
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(recordingText, ...), // "录音中..."
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(recordingHintText, ...), // "上滑取消"
|
||||
],
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**social-app 实现**(`message_composer.dart:222-239`):
|
||||
```dart
|
||||
if (_isRecording) {
|
||||
if (!showRecordingInlineFeedback) {
|
||||
return Text(resolvedRecordingText, ...);
|
||||
}
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
recordingAnimation, // 动画widget(外部传入)
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(resolvedRecordingText, ...),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(resolvedRecordingHintText, ...),
|
||||
],
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**差异**:
|
||||
1. `recordingAnimation` widget 缺失 - 需要外部传入,eryao 直接用空白占位
|
||||
2. 录音中图标/按钮没有动画效果
|
||||
|
||||
**修复方向**:
|
||||
|
||||
1. 在 `FollowUpChatScreen` 或其父组件中创建 `recordingAnimation` widget
|
||||
2. 将 `recordingAnimation` 通过 `MessageComposer` 传入
|
||||
3. 参考 social-app 的实现,使用脉冲动画或波形动画
|
||||
|
||||
**参考实现**(需要添加 `recording_animation.dart`):
|
||||
|
||||
```dart
|
||||
class RecordingAnimation extends StatefulWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<RecordingAnimation> createState() => _RecordingAnimationState();
|
||||
}
|
||||
|
||||
class _RecordingAnimationState extends State<RecordingAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.color.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Icon(Icons.mic, color: widget.color, size: widget.size * 0.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2 & 3: 直接复用 social-app 输入框(不需要 plus 按钮)
|
||||
|
||||
### 需求
|
||||
|
||||
**直接复用** social-app 的 `MessageComposer`,但**不需要 plus 按钮**。
|
||||
|
||||
### 复用清单
|
||||
|
||||
| 功能 | social-app | eryao | 处理 |
|
||||
|------|------------|-------|------|
|
||||
| `recordingAnimation` | required Widget | 空白占位 | **需要添加** |
|
||||
| 双层阴影 | 有 | 无 | **需要添加** |
|
||||
| `AppRadius.xxl` | 用于圆角 | 无此值 | **需要添加** |
|
||||
| Plus 按钮 | 有 | 无 | **不需要**,保持无 |
|
||||
| 图标 | `LucideIcons` | Material Icons | 保持 Material Icons |
|
||||
|
||||
### 实现步骤
|
||||
|
||||
#### 1. 添加 `AppRadius.xxl` 到 `design_tokens.dart`
|
||||
|
||||
```dart
|
||||
class AppRadius {
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 20;
|
||||
static const double xxl = 32; // 新增
|
||||
static const double full = 999;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 创建 `RecordingAnimation` widget
|
||||
|
||||
放在 `apps/lib/shared/widgets/` 下,参考 social-app 的脉冲动画效果。
|
||||
|
||||
#### 3. 修改 `MessageComposer`
|
||||
|
||||
- 添加 `recordingAnimation` 参数(required)
|
||||
- 添加双层阴影
|
||||
- 使用 `AppRadius.xxl` 替代 `AppRadius.full`
|
||||
- 移除 plus 按钮(eryao 原有的无 plus 逻辑保持不变)
|
||||
- 保持 Material Icons
|
||||
|
||||
#### 4. 修改 `FollowUpChatScreen`
|
||||
|
||||
- 创建 `RecordingAnimation` 实例
|
||||
- 传入 `MessageComposer`
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart`
|
||||
- `apps/lib/shared/widgets/message_composer.dart`
|
||||
- `apps/lib/shared/widgets/recording_animation.dart`(新建)
|
||||
- `apps/lib/shared/theme/design_tokens.dart`
|
||||
- `/home/qzl/Code/social-app/apps/lib/shared/widgets/message_composer.dart`
|
||||
|
||||
## 修复优先级
|
||||
|
||||
1. **高优先级**:Bug 1(重复加载UI)
|
||||
2. **高优先级**:Bug 2 & 3(直接复用 social-app 输入框)
|
||||
@@ -1,200 +0,0 @@
|
||||
# Bug: 追问时 agent_output 被重新生成,导致 sign_level 被覆盖
|
||||
|
||||
日期:2026-04-08
|
||||
状态:已确认根因(未修复)
|
||||
|
||||
## 问题描述
|
||||
|
||||
首次解卦完成后,用户继续追问时,agent 会重新生成 `agent_output`,重新计算卦的结论和标签。
|
||||
|
||||
**具体表现**:
|
||||
- 首次解卦结论:`中下签`
|
||||
- 追问后结论:`下下签`(同一个卦,结果被重新生成)
|
||||
|
||||
## 根因分析
|
||||
|
||||
**问题定位**:`backend/src/core/agentscope/runtime/runner.py`
|
||||
|
||||
### 根因链条
|
||||
|
||||
1. **`runner.py:execute()` 方法(第 70-133 行)**
|
||||
|
||||
无论 `runtime_mode` 是 `chat` 还是 `follow_up`,都会执行以下逻辑:
|
||||
|
||||
```python
|
||||
# 第 105-119 行:始终推导卦象
|
||||
derived_divination = self._resolve_derived_divination(run_input=run_input)
|
||||
await self._emit_step_event(
|
||||
pipeline=pipeline,
|
||||
run_input=run_input,
|
||||
step_name="divination",
|
||||
event_type="DIVINATION_DERIVED", # <-- 追问时不应发射此事件
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
2. **`runner.py:_execute_worker_step()` 方法(第 200-245 行)**
|
||||
|
||||
始终将 `derived_divination` 传递给 worker:
|
||||
|
||||
```python
|
||||
# 第 294-302 行:始终将 divination_derived 放入 worker_output
|
||||
await emitter.emit_final_text_end(
|
||||
worker_output={
|
||||
**worker_payload.model_dump(mode="json", exclude_none=True),
|
||||
"divination_derived": derived_divination.model_dump(...), # <-- 追问时不应包含
|
||||
},
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
3. **`stage_emitter.py:emit_final_text_end()` 方法(第 46-73 行)**
|
||||
|
||||
始终将所有字段放入 `TEXT_MESSAGE_END` 事件:
|
||||
|
||||
```python
|
||||
payload = {
|
||||
"messageId": message_id,
|
||||
"role": "assistant",
|
||||
"stage": self._stage,
|
||||
"status": worker_output.get("status"),
|
||||
"sign_level": worker_output.get("sign_level"), # <-- 追问时不应有
|
||||
"conclusion": worker_output.get("conclusion", []), # <-- 追问时不应有
|
||||
"focus_points": worker_output.get("focus_points", []),
|
||||
"advice": worker_output.get("advice", []),
|
||||
"keywords": worker_output.get("keywords", []),
|
||||
"answer": worker_output.get("answer", ""),
|
||||
"error": worker_output.get("error"),
|
||||
"divination_derived": worker_output.get("divination_derived"), # <-- 追问时不应有
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
4. **`store.py:_persist_text_message()` 方法(第 125-160 行)**
|
||||
|
||||
从事件中提取所有字段并完整存储:
|
||||
|
||||
```python
|
||||
worker_output_fields = (
|
||||
"status",
|
||||
"sign_level", # <-- 追问时被重新生成并覆盖
|
||||
"conclusion", # <-- 追问时被重新生成并覆盖
|
||||
"focus_points",
|
||||
"advice",
|
||||
"keywords",
|
||||
"answer",
|
||||
"error",
|
||||
"divination_derived", # <-- 追问时被重新生成
|
||||
"ui_hints",
|
||||
)
|
||||
```
|
||||
|
||||
### 问题本质
|
||||
|
||||
`runtime_mode=follow_up` 时,系统仍在:
|
||||
1. 重新推导卦象(调用 `_resolve_derived_divination`)
|
||||
2. 发射完整的 `DIVINATION_DERIVED` 事件
|
||||
3. 生成包含所有结构化字段的 `worker_output`
|
||||
4. 将所有字段存储到数据库
|
||||
|
||||
但根据工程计划(`docs/plans/2026-04-08-followup-session-history-eng-plan.md:37-40`),追问时的预期行为是:
|
||||
|
||||
```
|
||||
[一次追问]
|
||||
user -> /agent/runs(runtime_mode=follow_up)
|
||||
-> assistant(content [+ optional metadata.agent_output.answer])
|
||||
```
|
||||
|
||||
即:追问时只输出 `answer`(内容),不重新生成卦象结构。
|
||||
|
||||
## 证据
|
||||
|
||||
### 数据库证据
|
||||
|
||||
Session `015fe0f9-0500-43ab-911a-4ce8e3160032`:
|
||||
|
||||
| 时间 | 角色 | sign_level | divination_derived | 问题 |
|
||||
|------|------|------------|-------------------|------|
|
||||
| 05:21:19 | user | - | - | 首问问题 |
|
||||
| 05:21:28 | assistant | 中下签 | 完整 | 首答(正确) |
|
||||
| 05:22:24 | user | - | - | 追问 |
|
||||
| 05:22:33 | assistant | **下下签** | 完整 | 追问答(sign_level 被重新生成) |
|
||||
|
||||
两个 assistant 消息的 `divination_derived` 完全相同,但 `sign_level` 不同,证明是重新生成的。
|
||||
|
||||
## 修复方向
|
||||
|
||||
### 1. `runner.py`
|
||||
|
||||
在 `execute()` 方法中,根据 `runtime_mode` 决定是否推导卦象:
|
||||
|
||||
```python
|
||||
async def execute(self, ...):
|
||||
runtime_mode = self._resolve_runtime_mode(run_input=run_input)
|
||||
|
||||
if runtime_mode == RuntimeMode.CHAT:
|
||||
derived_divination = self._resolve_derived_divination(run_input=run_input)
|
||||
await self._emit_step_event(...) # DIVINATION_DERIVED
|
||||
else:
|
||||
derived_divination = None # follow_up 不推导
|
||||
```
|
||||
|
||||
### 2. `stage_emitter.py`
|
||||
|
||||
`emit_final_text_end()` 根据 `runtime_mode` 决定发送哪些字段:
|
||||
|
||||
```python
|
||||
async def emit_final_text_end(self, ..., runtime_mode: str):
|
||||
payload = {"messageId": ..., "role": "assistant", "stage": self._stage}
|
||||
|
||||
if runtime_mode == "chat":
|
||||
payload.update({
|
||||
"status": worker_output.get("status"),
|
||||
"sign_level": worker_output.get("sign_level"),
|
||||
"conclusion": worker_output.get("conclusion", []),
|
||||
"focus_points": worker_output.get("focus_points", []),
|
||||
"advice": worker_output.get("advice", []),
|
||||
"keywords": worker_output.get("keywords", []),
|
||||
"divination_derived": worker_output.get("divination_derived"),
|
||||
...
|
||||
})
|
||||
else: # follow_up
|
||||
payload["answer"] = worker_output.get("answer", "")
|
||||
```
|
||||
|
||||
### 3. `store.py`
|
||||
|
||||
`_persist_text_message()` 根据 `runtime_mode` 决定提取哪些字段:
|
||||
|
||||
```python
|
||||
async def _persist_text_message(self, ...):
|
||||
runtime_mode = self._resolve_runtime_mode(event=event)
|
||||
|
||||
if runtime_mode == "chat":
|
||||
worker_output_fields = ("status", "sign_level", "conclusion", ...)
|
||||
else: # follow_up
|
||||
worker_output_fields = ("answer",) # 只存储 answer
|
||||
```
|
||||
|
||||
### 4. `runtime_models.py`
|
||||
|
||||
`resolve_worker_output_model()` 应根据 `runtime_mode` 返回不同 schema:
|
||||
|
||||
```python
|
||||
def resolve_worker_output_model(runtime_mode: RuntimeMode = RuntimeMode.CHAT) -> type[WorkerAgentOutputLite]:
|
||||
if runtime_mode == RuntimeMode.FOLLOW_UP:
|
||||
return WorkerAgentOutputLite # 只有 answer
|
||||
return AgentOutput # 完整结构(继承自 WorkerAgentOutputRich)
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `backend/src/core/agentscope/runtime/runner.py` - 主编排逻辑
|
||||
- `backend/src/core/agentscope/runtime/stage_emitter.py` - 事件发射
|
||||
- `backend/src/core/agentscope/events/store.py` - 事件持久化
|
||||
- `backend/src/schemas/agent/runtime_models.py` - 输出 schema 定义
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
|
||||
- 协议文档:`docs/protocols/divination/divination-run-protocol.md`
|
||||
@@ -1,90 +0,0 @@
|
||||
# Bug: 首页历史 Session 只显示4条,"更多"按钮缺失
|
||||
|
||||
日期:2026-04-08
|
||||
状态:功能缺失(未完成)
|
||||
|
||||
## 问题描述
|
||||
|
||||
前端主页接收后端传来的历史 session,永远只显示四个,"more"按钮不见了。
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 1. 前端硬编码 `take(4)`
|
||||
|
||||
`apps/lib/features/home/presentation/screens/home_screen.dart:287`:
|
||||
```dart
|
||||
children: historyItems.take(4).map((item) {
|
||||
```
|
||||
|
||||
这是在 commit `6e82053`(重构首页为底部导航栏布局)时**有意添加**的设计选择,用于限制首页展示的历史记录数量。
|
||||
|
||||
### 2. 前端未实现"更多"按钮
|
||||
|
||||
- `l10n.more` 本地化字符串存在(`'更多'`/`'More'`),定义于 `apps/lib/l10n/app_localizations_zh.dart:115` 和 `app_localizations_en.dart:116`
|
||||
- 但搜索整个 Dart 代码库,`l10n.more` **没有任何地方使用它**
|
||||
- "更多"功能从未被实现
|
||||
|
||||
### 3. 后端 `hasMore` 硬编码为 `False`
|
||||
|
||||
`backend/src/v1/agent/service.py:655-659`:
|
||||
```python
|
||||
return HistorySnapshotResponse(
|
||||
scope="history_sessions_latest_assistant",
|
||||
threadId=None,
|
||||
day=None,
|
||||
hasMore=False, # 硬编码,未实际计算
|
||||
messages=messages,
|
||||
)
|
||||
```
|
||||
|
||||
后端 schema 定义了 `hasMore` 字段(`schemas.py:238`),但 service 层返回时**硬编码为 `False`**,从未实际计算是否还有更多数据。
|
||||
|
||||
### 4. 后端 API 行为正确
|
||||
|
||||
`backend/src/v1/agent/service.py:641-646`:
|
||||
```python
|
||||
raw_messages = await self._repository.get_latest_assistant_messages_by_user_sessions(
|
||||
user_id=str(current_user.id),
|
||||
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
|
||||
session_limit=50, # 后端返回最多50条
|
||||
)
|
||||
```
|
||||
|
||||
后端正确返回最多 50 条历史 session,前端只是没有利用这些数据。
|
||||
|
||||
## 问题性质
|
||||
|
||||
| 层级 | 现象 | 性质 |
|
||||
|------|------|------|
|
||||
| 后端 | `hasMore=False` 硬编码 | 缺陷:响应语义不正确 |
|
||||
| 前端 | `take(4)` 只显示4条 | 设计选择:有意的 UI 限制 |
|
||||
| 前端 | 无"更多"按钮 | 功能缺失:有 `l10n.more` 但未实现 |
|
||||
|
||||
## 证据
|
||||
|
||||
- 前端代码:`apps/lib/features/home/presentation/screens/home_screen.dart:287`
|
||||
- 后端代码:`backend/src/v1/agent/service.py:655`
|
||||
- 本地化字符串:`apps/lib/l10n/app_localizations_zh.dart:115`
|
||||
- 提交记录:`6e82053`(feat(home): 重构首页为底部导航栏布局)
|
||||
|
||||
## 修复方向
|
||||
|
||||
### 方案 A:实现完整分页(推荐)
|
||||
|
||||
1. **后端**:实现真正的 `hasMore` 计算逻辑
|
||||
- 添加 `offset` 参数支持分页
|
||||
- 实际计算 `hasMore = total_count > offset + limit`
|
||||
|
||||
2. **前端**:实现"更多"按钮或无限滚动
|
||||
- 监听滚动位置,滚动到底部时加载更多
|
||||
- 或添加"更多"按钮手动触发加载
|
||||
|
||||
### 方案 B:移除不存在的字符串
|
||||
|
||||
如果业务确定只需要显示4条,则:
|
||||
- 移除 `l10n.more` 本地化字符串,避免误导
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 工程计划:`docs/plans/2026-04-05-divination-history-profile-eng-plan.md`
|
||||
- 随访计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
|
||||
@@ -0,0 +1,131 @@
|
||||
你是一个资深系统设计与代码分析助手。你的任务不是立刻编写代码,而是先深入理解当前项目中与“通知系统”相关的现有实现,然后基于现有代码结构输出一份可靠、可落地的实现方案。
|
||||
|
||||
本次任务聚焦于 App 通知系统设计,重点包括但不限于:
|
||||
- 通知中心(notification list / inbox)
|
||||
- 用户通知存储
|
||||
- 已读 / 已看状态
|
||||
- 推送触达状态
|
||||
- 前台实时通知
|
||||
- 后台/离线系统推送
|
||||
- 新版本通知、活动通知等业务通知类型
|
||||
- Flutter 客户端、后端服务、Supabase 之间的协作方式
|
||||
|
||||
你的首要目标是“理解现状并制定方案”,而不是直接进入编码。
|
||||
|
||||
请严格遵循以下工作方式:
|
||||
|
||||
1. 先理解现有代码,再做设计
|
||||
- 主动查找项目中与通知相关的现有代码、模块、接口、表结构、状态管理、服务封装和配置文件。
|
||||
- 特别关注以下内容:
|
||||
- Flutter 端是否已有本地通知、消息中心、badge、页面入口、深链跳转
|
||||
- 后端是否已有 notification / message / event / push / reminder 等相关模型、接口或 service
|
||||
- Supabase 中是否已有相关表、RLS、Realtime、设备 token 存储
|
||||
- 是否已有 APNs / FCM / flutter_local_notifications / Firebase Messaging / Supabase Realtime 等集成痕迹
|
||||
- 是否已有版本检查机制、活动通知机制、用户收件箱机制
|
||||
- 不要假设项目是空白的。必须优先复用现有架构与已有能力。
|
||||
|
||||
2. 基于现有架构设计,而不是脱离项目另起炉灶
|
||||
- 方案必须尽量贴合当前项目的技术栈、目录结构、分层方式、命名风格和已有约束。
|
||||
- 优先考虑如何在现有模块上扩展,而不是重新设计一整套无关架构。
|
||||
- 如果当前实现存在明显缺陷或冲突,可以指出问题,但仍要给出“在现有基础上渐进演进”的方案。
|
||||
|
||||
3. 明确区分几个概念
|
||||
你在分析和设计时,必须区分以下概念,避免混淆:
|
||||
- 应用内通知记录
|
||||
- 系统推送通知
|
||||
- 前台实时同步
|
||||
- 本地通知
|
||||
- 已看状态
|
||||
- 已读状态
|
||||
- 推送是否成功
|
||||
- 用户是否真正查看
|
||||
|
||||
4. 方案输出要覆盖的核心问题
|
||||
在最终方案中,至少要回答以下问题:
|
||||
- 现有代码里已经有什么,缺什么
|
||||
- 通知数据应该如何建模
|
||||
- 是否需要 notifications / notification_receipts / user_push_devices 等表
|
||||
- 已读、已看、点击、删除等状态如何设计
|
||||
- Flutter 端如何读取通知列表、显示未读数、更新已读状态
|
||||
- Supabase Realtime 在这个项目里适合承担什么职责
|
||||
- APNs / FCM 或其他推送通道应该如何接入
|
||||
- 后端应该如何组织通知写入、fanout、推送发送、状态回写
|
||||
- 新版本通知与活动通知如何落地
|
||||
- 如何保证权限安全,例如 RLS、用户只能访问自己的通知
|
||||
- 如何分阶段实施,避免一次性改动过大
|
||||
|
||||
5. 输出必须先分析,后给建议
|
||||
不要一上来直接写“建议这样做”。
|
||||
你必须先给出:
|
||||
- 当前代码现状梳理
|
||||
- 已有能力
|
||||
- 缺失点
|
||||
- 架构约束
|
||||
然后再给出推荐方案。
|
||||
|
||||
6. 不直接修改代码
|
||||
- 本轮目标是产出实现方案,而不是直接提交代码。
|
||||
- 除非我明确要求,否则不要直接创建文件、修改代码或生成迁移。
|
||||
- 可以提出建议的文件改动点,但不要直接实现。
|
||||
|
||||
请按以下结构输出:
|
||||
|
||||
# 1. 需求理解
|
||||
- 这次通知系统要解决的核心问题
|
||||
- 涉及的通知类型
|
||||
- 系统边界(Flutter / Backend / Supabase / Push Provider)
|
||||
|
||||
# 2. 现有代码调研结果
|
||||
- 已发现的相关模块
|
||||
- 已有能力
|
||||
- 可复用部分
|
||||
- 当前缺口
|
||||
- 潜在冲突或风险
|
||||
|
||||
# 3. 当前架构判断
|
||||
- 当前项目更适合采用什么通知架构
|
||||
- 为什么
|
||||
- 哪些方案不适合当前项目
|
||||
|
||||
# 4. 推荐实现方案
|
||||
至少包括:
|
||||
- 数据模型设计
|
||||
- 状态字段设计
|
||||
- 客户端交互流程
|
||||
- 服务端处理流程
|
||||
- 实时通知与系统推送的职责划分
|
||||
- 已读/已看/触达状态方案
|
||||
- 版本通知与活动通知方案
|
||||
- 权限与安全策略
|
||||
|
||||
# 5. 分阶段落地计划
|
||||
请拆分为多个阶段,例如:
|
||||
- 第一阶段:最小可用通知中心
|
||||
- 第二阶段:接入系统推送
|
||||
- 第三阶段:完善版本通知/活动通知/统计能力
|
||||
每个阶段说明:
|
||||
- 目标
|
||||
- 改动范围
|
||||
- 主要任务
|
||||
- 依赖项
|
||||
- 风险点
|
||||
- 验收标准
|
||||
|
||||
# 6. 建议改动清单
|
||||
- 建议新增或修改的表
|
||||
- 建议新增或修改的后端模块
|
||||
- 建议新增或修改的 Flutter 模块
|
||||
- 建议新增的接口 / RPC / service
|
||||
- 建议新增的配置项
|
||||
|
||||
# 7. 最终推荐
|
||||
- 推荐采用的总体方案
|
||||
- 推荐原因
|
||||
- 不确定点
|
||||
- 实施优先级排序
|
||||
|
||||
额外要求:
|
||||
- 如果代码库中已经存在通知、提醒、消息、推送等相近实现,优先尝试整合,而不是重复建设。
|
||||
- 如果某些信息无法从当前代码中确认,要明确写出“不确定项”和“推断依据”。
|
||||
- 方案必须可执行、可渐进落地,避免空泛。
|
||||
- 优先给出最贴合当前代码库的设计,不要输出与项目现状脱节的理想化架构。
|
||||
@@ -0,0 +1,419 @@
|
||||
# 积分审计与注册赠分策略改造计划(gstack / plan-eng-review)
|
||||
|
||||
## 1. 目标与结论
|
||||
|
||||
本计划解决三个问题:
|
||||
|
||||
1. 用户删除账号后,积分与成本审计数据不能随业务数据一起丢失。
|
||||
2. 同邮箱重复注册时,不应再次拿到注册赠分。
|
||||
3. 积分消耗审计必须记录真实 `input_tokens` / `output_tokens` / `cost`,不能再写占位值。
|
||||
4. LLM 失败/取消时若平台已产生真实成本,该成本不转嫁用户积分,但必须进入审计账本。
|
||||
|
||||
结论:采用 **双账本 + 资格账本**。
|
||||
|
||||
- 保留业务账本:`user_points`、`points_ledger`(在线业务能力)
|
||||
- 新增审计账本:`points_audit_ledger`(不可变审计)
|
||||
- 新增资格账本:`register_bonus_claims`(注册奖励去重)
|
||||
- 注册赠分策略从 DB trigger 移出,改为应用层策略(配置驱动)
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统边界
|
||||
|
||||
### 2.1 业务域(可删除)
|
||||
|
||||
- `user_points`:余额视图
|
||||
- `points_ledger`:业务流水
|
||||
- `messages` / `sessions`:会话与消息
|
||||
|
||||
### 2.2 审计域(不可级联删除)
|
||||
|
||||
- `points_audit_ledger`:审计流水,保留用户快照和成本快照(含用户承担/平台承担归属)
|
||||
- `register_bonus_claims`:注册奖励领取资格记录
|
||||
|
||||
### 2.3 策略域(应用层)
|
||||
|
||||
- `register_bonus_points` 配置项(默认 60)
|
||||
- `register_bonus_hmac_key` 配置项(环境变量注入)
|
||||
- 首登赠分是否发放由服务层决定,不写死在数据库 trigger
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状问题(基于当前代码)
|
||||
|
||||
1. 注册赠分写死在 DB trigger。
|
||||
当前函数:`public.initialize_profile_and_invite_code_on_signup()`,历史上出现过 100/60 改动漂移。
|
||||
|
||||
2. 积分消费审计写占位值。
|
||||
在 `backend/src/v1/points/service.py` 中,`consume_successful_run_points` 写入 `input_tokens=0`、`output_tokens=0`、`cost=0`。
|
||||
|
||||
3. 删号会丢审计线索。
|
||||
当前业务删除路径会清理业务数据,缺少独立审计账本保留策略。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型(按项目风格精简命名)
|
||||
|
||||
说明:不引入陌生“模板字段”,沿用当前 `points_ledger` 命名风格。
|
||||
|
||||
### 4.1 新表:`points_audit_ledger`
|
||||
|
||||
- `id` UUID PK
|
||||
- `event_id` VARCHAR(64) UNIQUE NOT NULL
|
||||
- `user_id_snapshot` UUID NULL
|
||||
- `user_email_snapshot` TEXT NULL
|
||||
- `change_type` VARCHAR(16) NOT NULL
|
||||
- `biz_type` VARCHAR(16) NULL
|
||||
- `biz_id` UUID NULL
|
||||
- `direction` SMALLINT NOT NULL
|
||||
- `amount` BIGINT NOT NULL
|
||||
- `balance_after` BIGINT NOT NULL
|
||||
- `billed_to` VARCHAR(16) NOT NULL -- `user` | `platform`
|
||||
- `run_id` VARCHAR(128) NULL
|
||||
- `request_id` VARCHAR(128) NULL
|
||||
- `input_tokens` INTEGER NOT NULL DEFAULT 0
|
||||
- `output_tokens` INTEGER NOT NULL DEFAULT 0
|
||||
- `cost` NUMERIC(12,6) NOT NULL DEFAULT 0
|
||||
- `metadata` JSONB NOT NULL DEFAULT '{}'
|
||||
- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
|
||||
索引建议:
|
||||
|
||||
- `uq_points_audit_ledger_event_id`
|
||||
- `ix_points_audit_ledger_user_id_created_at` (`user_id_snapshot`, `created_at DESC`)
|
||||
- `ix_points_audit_ledger_change_type_created_at` (`change_type`, `created_at DESC`)
|
||||
|
||||
### 4.2 新表:`register_bonus_claims`
|
||||
|
||||
- `id` UUID PK
|
||||
- `email_hash` VARCHAR(64) UNIQUE NOT NULL
|
||||
- `user_email_snapshot` TEXT NOT NULL
|
||||
- `first_user_id` UUID NULL
|
||||
- `grant_event_id` VARCHAR(64) UNIQUE NOT NULL
|
||||
- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
|
||||
注:`email_hash` 由标准化邮箱(trim + lower)计算(HMAC-SHA256,key 来自 `register_bonus_hmac_key`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据流设计
|
||||
|
||||
### 5.1 注册赠分流程(应用层,非 trigger)
|
||||
|
||||
```text
|
||||
[用户首登/注册完成]
|
||||
-> PointsPolicyService.load(register_bonus_points)
|
||||
-> normalize(email) -> email_hash
|
||||
-> INSERT register_bonus_claims(email_hash, ...)
|
||||
- 成功: 继续发放积分
|
||||
- 唯一冲突: 说明历史已领取,跳过发放
|
||||
-> 更新 user_points
|
||||
-> 写 points_ledger
|
||||
-> 写 points_audit_ledger
|
||||
-> commit
|
||||
```
|
||||
|
||||
### 5.2 运行消耗积分流程(写真实成本)
|
||||
|
||||
```text
|
||||
[run completed]
|
||||
-> 从持久化消息/会话聚合真实 usage
|
||||
(input_tokens, output_tokens, cost)
|
||||
-> PointsService.consume_successful_run_points(...)
|
||||
-> 更新 user_points
|
||||
-> 写 points_ledger
|
||||
-> 写 points_audit_ledger(真实 usage)
|
||||
-> commit
|
||||
```
|
||||
|
||||
### 5.3 运行失败/取消但平台发生成本流程(不扣用户,记平台账)
|
||||
|
||||
```text
|
||||
[run failed/canceled]
|
||||
-> 从持久化消息/事件聚合真实 usage
|
||||
-> 若 cost > 0:
|
||||
- 不调用用户扣分
|
||||
- 写 points_audit_ledger(
|
||||
direction=0,
|
||||
amount=0,
|
||||
billed_to='platform',
|
||||
input_tokens/output_tokens/cost=真实值,
|
||||
metadata.reason='run_failed_or_canceled_platform_billed'
|
||||
)
|
||||
-> commit
|
||||
```
|
||||
|
||||
### 5.4 删除账号流程
|
||||
|
||||
```text
|
||||
[delete account]
|
||||
-> 删除 user_points / points_ledger / sessions / messages / profile / auth
|
||||
-> 保留 points_audit_ledger / register_bonus_claims
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败模式与处理
|
||||
|
||||
### 6.1 双写不一致(P0)
|
||||
|
||||
- 场景:`points_ledger` 写成功,`points_audit_ledger` 写失败。
|
||||
- 策略:同事务写入,任一失败全部回滚。
|
||||
|
||||
### 6.2 并发重复注册(P0)
|
||||
|
||||
- 场景:同邮箱并发首登,发放多次。
|
||||
- 策略:`register_bonus_claims.email_hash` 唯一约束 + 冲突即跳过。
|
||||
|
||||
### 6.3 邮箱规范化不一致(P1)
|
||||
|
||||
- 场景:`User@A.com` 与 `user@a.com` 被当成不同人。
|
||||
- 策略:统一 normalize(trim + lower)后再 hash。
|
||||
|
||||
### 6.4 成本快照缺失(P1)
|
||||
|
||||
- 场景:run 成功但 usage 聚合取不到,写入 0。
|
||||
- 策略:
|
||||
- 业务是否扣分与成本写入解耦:允许扣分,但审计需标记 `metadata.usage_missing=true`
|
||||
- 记录 warning 日志并纳入告警指标
|
||||
|
||||
### 6.5 失败/取消真实成本归属(P0)
|
||||
|
||||
- 场景:LLM 回调失败或用户取消,但上游已计费。
|
||||
- 策略:
|
||||
- 不扣用户积分(`user_points`、`points_ledger`不变)
|
||||
- 审计账本强制落一条平台承担记录(`billed_to='platform'`)
|
||||
- 该记录必须包含真实 `input_tokens` / `output_tokens` / `cost`
|
||||
|
||||
---
|
||||
|
||||
## 7. 信任边界与安全
|
||||
|
||||
1. `user_email_snapshot` 必须来自服务端认证上下文,不接受客户端传入。
|
||||
2. `input_tokens/output_tokens/cost` 必须来自服务端持久化记录,不接受客户端上报。
|
||||
3. 审计表只允许后端 service-role 写入,不暴露客户端写接口。
|
||||
4. `register_bonus_claims` 不应被普通业务接口更新/删除。
|
||||
5. `register_bonus_hmac_key` 仅后端可读,不下发客户端,不写日志。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施步骤(最小改动优先)
|
||||
|
||||
### Phase 1: 协议与配置
|
||||
|
||||
- 更新协议文档:
|
||||
- `docs/protocols/common/user-points-chat-data-protocol.md`
|
||||
- 新增“审计留存与注册奖励策略”章节
|
||||
- 新增配置:`register_bonus_points`(默认 60)
|
||||
|
||||
### Phase 2: 数据库迁移
|
||||
|
||||
- 新增表:`points_audit_ledger`
|
||||
- 新增表:`register_bonus_claims`
|
||||
- 不改现有 `points_ledger`、`user_points` 结构
|
||||
|
||||
### Phase 3: 服务层改造
|
||||
|
||||
- 移除 trigger 中注册送分逻辑(trigger 只保留 profile/invite 初始化)
|
||||
- 在应用层增加注册奖励发放逻辑(带资格检查)
|
||||
- 在积分消费路径改造为真实 usage 写审计
|
||||
- 在失败/取消路径增加平台承担成本审计(不扣用户)
|
||||
|
||||
### Phase 4: 删除链路校验
|
||||
|
||||
- 删除账号后验证业务表清理
|
||||
- 验证审计表与资格表仍可查
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试覆盖计划
|
||||
|
||||
### 9.0 P0 测试门槛(实现前锁定)
|
||||
|
||||
以下测试为上线前阻断项,任一缺失不得合并:
|
||||
|
||||
1. **幂等回放**:同一 `event_id` 重放不重复写 `points_audit_ledger`。
|
||||
2. **注册送分去重**:同邮箱(normalize 后)重复注册不重复发放积分。
|
||||
3. **事务一致性**:业务账本写入成功但审计写入失败时,整体回滚。
|
||||
4. **删除后重注册**:删号后同邮箱重注册仍不再发放首登奖励。
|
||||
5. **失败/取消审计**:run 失败与取消场景写审计但不扣积分。
|
||||
6. **成本归属**:失败/取消且 `cost>0` 的记录必须为 `billed_to='platform'`。
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
- 邮箱 normalize/hash 一致性
|
||||
- 注册奖励配置读取与默认值
|
||||
- usage 聚合函数(含空值和异常值)
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- 首次注册发放奖励成功
|
||||
- 同邮箱重复注册不再发放
|
||||
- 并发注册仅一次成功发放
|
||||
- 消费积分写入真实 tokens/cost 审计
|
||||
- 失败/取消且平台发生成本时,写平台承担审计且不扣用户积分
|
||||
- 删号后审计数据保留
|
||||
|
||||
### 9.3 回归测试
|
||||
|
||||
- 现有积分余额查询和扣分逻辑不回归
|
||||
- 邀请码流程不回归
|
||||
|
||||
---
|
||||
|
||||
## 10. 文件级改造清单
|
||||
|
||||
### 数据库 / 模型
|
||||
|
||||
- `backend/alembic/versions/*` 新增迁移:创建两张新表
|
||||
- `backend/src/models/points_audit_ledger.py` 新增
|
||||
- `backend/src/models/register_bonus_claims.py` 新增
|
||||
|
||||
### 积分服务与仓储
|
||||
|
||||
- `backend/src/v1/points/repository.py`:新增审计写入、资格检查方法
|
||||
- `backend/src/v1/points/service.py`:
|
||||
- 新增注册奖励发放入口(配置驱动)
|
||||
- 消费路径写真实 usage 审计
|
||||
- 失败/取消路径写平台承担成本审计
|
||||
|
||||
### 运行时调用链
|
||||
|
||||
- `backend/src/core/agentscope/runtime/tasks.py`:
|
||||
- 在扣分点传入真实 usage(或可计算上下文)
|
||||
- 在 run 异常/取消路径传入 usage 并落平台承担审计
|
||||
|
||||
### 协议文档
|
||||
|
||||
- `docs/protocols/common/user-points-chat-data-protocol.md` 更新
|
||||
|
||||
---
|
||||
|
||||
## 11. 取舍说明
|
||||
|
||||
### 为什么不直接改 `points_ledger` 为审计表
|
||||
|
||||
- 会把在线业务与审计诉求耦合在一张表,后续权限和迁移风险高。
|
||||
- 当前最小改动方案是新增审计表,保持业务链路稳定。
|
||||
|
||||
### 为什么保留 `event_id`
|
||||
|
||||
- `id` 是技术主键,只保证行唯一。
|
||||
- `event_id` 是业务幂等键,防重放、防重试重复记账、支持跨表对账。
|
||||
|
||||
---
|
||||
|
||||
## 12. 未决事项
|
||||
|
||||
1. `user_email_snapshot` 是否明文存储,还是仅内部可解密存储。
|
||||
2. 审计数据保留时长(默认建议至少 1 年)。
|
||||
3. 成本单位与精度是否统一沿用 `NUMERIC(12,6)`。
|
||||
|
||||
---
|
||||
|
||||
## 13. PR 拆分与执行顺序(可直接实现)
|
||||
|
||||
### PR1:数据库与协议落地(不改业务行为)
|
||||
|
||||
目标:先建立新数据边界,不改变线上积分逻辑。
|
||||
|
||||
改动范围:
|
||||
|
||||
- `backend/alembic/versions/*`:新增迁移,创建 `points_audit_ledger`、`register_bonus_claims`
|
||||
- `backend/src/models/points_audit_ledger.py`:新增模型
|
||||
- `backend/src/models/register_bonus_claims.py`:新增模型
|
||||
- `docs/protocols/common/user-points-chat-data-protocol.md`:补审计与注册送分策略契约
|
||||
|
||||
验收标准:
|
||||
|
||||
- 迁移可执行、可回滚
|
||||
- 新表索引与唯一约束生效
|
||||
- 协议文档与表结构一致
|
||||
|
||||
测试要求:
|
||||
|
||||
- 迁移 smoke test
|
||||
- 约束与索引存在性校验
|
||||
|
||||
### PR2:注册送分策略迁移到应用层(去 trigger 固化)
|
||||
|
||||
目标:把注册送分从 DB trigger 移到应用层唯一触发点(注册回调)。
|
||||
|
||||
改动范围:
|
||||
|
||||
- `backend/src/v1/points/service.py`:新增注册奖励发放入口与资格校验
|
||||
- `backend/src/v1/points/repository.py`:新增 `register_bonus_claims` 检查/写入
|
||||
- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_points`
|
||||
- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_hmac_key`
|
||||
- 相关注册回调调用链文件:接入 `grant_register_bonus_if_eligible(...)`
|
||||
- 迁移调整:更新 trigger,移除注册送分写入逻辑,仅保留 profile/invite 初始化
|
||||
|
||||
验收标准:
|
||||
|
||||
- 新注册触发一次赠分
|
||||
- 同邮箱重复注册不再赠分
|
||||
- 配置变更可控制赠分值(默认 60)
|
||||
- 邮箱哈希稳定且不可逆(同邮箱同哈希,不暴露明文)
|
||||
|
||||
测试要求:
|
||||
|
||||
- 并发注册去重测试(唯一约束 + 冲突路径)
|
||||
- 删除账号后同邮箱重注册不赠分
|
||||
- event_id 幂等回放不重复发放
|
||||
- 缺失 `register_bonus_hmac_key` 时服务启动失败(fail fast)
|
||||
|
||||
### PR3:真实成本审计与删除链路联调
|
||||
|
||||
目标:将 run 真实 usage 写入审计,并覆盖成功/失败/取消三种对话轮次;失败/取消场景发生真实成本时记平台承担。
|
||||
|
||||
改动范围:
|
||||
|
||||
- `backend/src/v1/points/service.py`:消费路径审计写入(真实 tokens/cost)
|
||||
- `backend/src/v1/points/repository.py`:新增 `append_audit_ledger(...)`
|
||||
- `backend/src/core/agentscope/runtime/tasks.py`:传递该轮次必要上下文
|
||||
- 账号删除服务链路:确认保留 `points_audit_ledger/register_bonus_claims`
|
||||
|
||||
验收标准:
|
||||
|
||||
- 成功对话:扣分 + 审计
|
||||
- 失败/取消对话:不扣分 + 审计(若有成本则 `billed_to='platform'`)
|
||||
- 审计中的 `input_tokens/output_tokens/cost` 为真实值,不再占位 0
|
||||
|
||||
测试要求:
|
||||
|
||||
- 成功/失败/取消三路径集成测试
|
||||
- 事务一致性测试(业务写成功 + 审计写失败 -> 回滚)
|
||||
- 删除后审计保留验证
|
||||
- 失败/取消 + `cost>0` 平台承担场景回归测试
|
||||
|
||||
### PR4:观测与运维保障(建议同迭代完成)
|
||||
|
||||
目标:避免审计静默失真。
|
||||
|
||||
改动范围:
|
||||
|
||||
- 指标与日志:
|
||||
- `points_audit_write_failed_total`
|
||||
- `points_usage_missing_total`
|
||||
- 告警阈值:连续失败或短时突增告警
|
||||
- 运维文档:异常重放与人工核对流程
|
||||
|
||||
验收标准:
|
||||
|
||||
- 审计写入失败可被监控发现
|
||||
- usage 缺失可被监控发现并可追溯到事件
|
||||
|
||||
---
|
||||
|
||||
## 14. 实施完成定义(DoD)
|
||||
|
||||
满足以下全部条件才算完成:
|
||||
|
||||
1. 计划中的 P0 测试门槛全部通过。
|
||||
2. 注册赠分不再依赖 DB trigger 写死值。
|
||||
3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消。
|
||||
3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消;失败/取消有真实成本时归属 `platform`。
|
||||
4. 删除账号后业务数据清理,审计与资格数据保留。
|
||||
5. 关键失败有指标与告警,不允许静默失败。
|
||||
@@ -40,6 +40,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
|
||||
|---|---:|---|---|
|
||||
| `PROFILE_PAYLOAD_INVALID` | 422 | Profile update payload invalid (length/type/empty constraints) | Highlight invalid fields and block submit |
|
||||
| `PROFILE_NOT_FOUND` | 404 | User profile row missing | Show retry and optionally trigger profile bootstrap |
|
||||
| `PROFILE_DELETE_FAILED` | 502 | Backend failed to complete account hard deletion | Show delete-failed message and allow retry |
|
||||
|
||||
## Avatar
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Profile Protocol (Frontend <-> Backend)
|
||||
|
||||
This document defines the canonical backend contract for user profile read/write and avatar upload signing.
|
||||
This document defines the canonical backend contract for user profile read/write, avatar upload signing, and account hard deletion.
|
||||
|
||||
Protocol verification status:
|
||||
|
||||
@@ -9,7 +9,7 @@ Protocol verification status:
|
||||
- Backend service source: `backend/src/v1/users/service.py`
|
||||
- Frontend mapping source: `apps/lib/features/settings/data/apis/profile_api.dart`
|
||||
- Storage config source: `backend/src/core/config/settings.py`
|
||||
- Current status: aligned
|
||||
- Current status: profile/avatar aligned; account deletion backend implemented (frontend wiring pending)
|
||||
|
||||
## Compatibility strategy
|
||||
|
||||
@@ -23,6 +23,7 @@ Protocol verification status:
|
||||
- Update settings: `PATCH /api/v1/users/me/settings`
|
||||
- Create avatar upload url: `POST /api/v1/users/me/avatar/upload-url`
|
||||
- Upload avatar directly: `POST /api/v1/users/me/avatar` (multipart)
|
||||
- Delete account and personal data (hard delete): `DELETE /api/v1/users/me`
|
||||
|
||||
## Auth and trust boundary
|
||||
|
||||
@@ -179,3 +180,57 @@ Behavior:
|
||||
|
||||
- All errors must follow RFC7807 `application/problem+json`.
|
||||
- `code` values must be registered in `docs/protocols/common/http-error-codes.md`.
|
||||
|
||||
## Account hard deletion contract
|
||||
|
||||
### `DELETE /api/v1/users/me`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Permanently delete the current account and associated personal data from developer records.
|
||||
- This is hard delete behavior, not soft delete and not temporary deactivation.
|
||||
|
||||
Request:
|
||||
|
||||
- No request body.
|
||||
- Auth required (same JWT trust boundary as other `/users/me/*` routes).
|
||||
|
||||
Success response:
|
||||
|
||||
- `204 No Content`
|
||||
|
||||
Behavior contract:
|
||||
|
||||
1. Deletion target is always the authenticated user (`sub`), never a client-supplied `user_id`.
|
||||
2. Deletion must remove account identity and associated user data for this product scope.
|
||||
3. Deletion must be irreversible from client perspective.
|
||||
4. After successful deletion, existing local session must be treated as invalid by client and backend.
|
||||
|
||||
### Deletion scope (current product contract)
|
||||
|
||||
The delete operation must remove data owned by the authenticated user in the following domains:
|
||||
|
||||
- Identity: `auth.users` row for current user.
|
||||
- Profile: `profiles` row.
|
||||
- Points: `user_points`, `points_ledger` rows linked to user.
|
||||
- Chat: `sessions`, `messages` rows linked to user/session ownership.
|
||||
- Avatar storage objects under prefix `avatars/{user_id}/`.
|
||||
|
||||
Notes:
|
||||
|
||||
- If future legal/compliance requirements introduce mandatory retention, retained fields must be explicitly documented and user-visible in deletion UI copy.
|
||||
- This protocol version assumes no regulated retention exemption for current product scope.
|
||||
|
||||
### Error semantics
|
||||
|
||||
The route follows common RFC7807 error payload and registry codes. Expected HTTP classes:
|
||||
|
||||
- `401` when auth is missing/invalid.
|
||||
- `403` when auth context is valid but action is not permitted by policy.
|
||||
- `409` when server cannot complete deletion due to a conflict that requires user action.
|
||||
- `5xx` for unexpected server/upstream failure (must not fail silently).
|
||||
|
||||
### Consistency and idempotency expectations
|
||||
|
||||
- API behavior is request-idempotent at user intent level: once account is deleted, repeating the action should not recreate state and should not produce partial undeleted data.
|
||||
- Client should treat any post-deletion authenticated call failure as terminal session invalidation and force logout flow.
|
||||
|
||||
Reference in New Issue
Block a user