feat(chat): add ChatBubble widget and mock data for home screen
- Add ChatBubble reusable widget for chat messages - Add HomeMockData for chat list mock data - Add HomeScreen widget tests - Add AG-UI chat design and implementation plan docs - Add friendship design docs - Ignore backend/logs directory
This commit is contained in:
@@ -287,6 +287,7 @@ infra/cloud/volcano/env/*.env
|
|||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
/logs/
|
/logs/
|
||||||
|
backend/logs/
|
||||||
# Docker volumes (local data)
|
# Docker volumes (local data)
|
||||||
docker/supabase/volumes/db/data/
|
docker/supabase/volumes/db/data/
|
||||||
infra/docker/volumes/db/data/
|
infra/docker/volumes/db/data/
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../shared/widgets/chat_bubble.dart';
|
||||||
|
|
||||||
|
enum ChatItemType { message, schedule }
|
||||||
|
|
||||||
|
abstract class ChatListItem {
|
||||||
|
String get id;
|
||||||
|
DateTime get timestamp;
|
||||||
|
ChatItemType get type;
|
||||||
|
MessageSender get sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomeMockData {
|
||||||
|
static List<ChatListItem> getTodayItems() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
return _getMockItems().where((item) {
|
||||||
|
final itemDate = DateTime(
|
||||||
|
item.timestamp.year,
|
||||||
|
item.timestamp.month,
|
||||||
|
item.timestamp.day,
|
||||||
|
);
|
||||||
|
return itemDate == today;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<ChatListItem>> loadMoreItems(DateTime beforeDate) async {
|
||||||
|
return _getOlderMockItems(beforeDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ChatListItem> _getMockItems() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final todayStart = DateTime(today.year, today.month, today.day);
|
||||||
|
|
||||||
|
return [
|
||||||
|
ChatMessageItem(
|
||||||
|
id: 'm4',
|
||||||
|
content: '明天提醒我开会',
|
||||||
|
timestamp: todayStart.add(const Duration(hours: 14)),
|
||||||
|
sender: MessageSender.user,
|
||||||
|
),
|
||||||
|
ScheduleItemWrapper(
|
||||||
|
id: 's1',
|
||||||
|
scheduleItem: ScheduleItemModel(
|
||||||
|
id: 's1',
|
||||||
|
title: '产品评审会议',
|
||||||
|
description: '讨论Q2产品路线图',
|
||||||
|
startAt: todayStart.add(const Duration(days: 1, hours: 10)),
|
||||||
|
endAt: todayStart.add(const Duration(days: 1, hours: 11)),
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
sourceType: ScheduleSourceType.agentGenerated,
|
||||||
|
status: ScheduleStatus.active,
|
||||||
|
metadata: ScheduleMetadata(
|
||||||
|
color: '#4F46E5',
|
||||||
|
location: '会议室A / 在线',
|
||||||
|
notes: '需要提前准备Q2数据',
|
||||||
|
attachments: [
|
||||||
|
Attachment(
|
||||||
|
name: 'Q2路线图.pdf',
|
||||||
|
type: AttachmentType.document,
|
||||||
|
url: 'https://example.com/q2.pdf',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
createdAt: todayStart.subtract(const Duration(hours: 5)),
|
||||||
|
),
|
||||||
|
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
ChatMessageItem(
|
||||||
|
id: 'm5',
|
||||||
|
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
|
||||||
|
timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ChatListItem> _getOlderMockItems(DateTime beforeDate) {
|
||||||
|
final before = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
|
||||||
|
final dayBefore = before.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
|
return [
|
||||||
|
ChatMessageItem(
|
||||||
|
id: 'm1',
|
||||||
|
content: '你好,我有什么可以帮你的?',
|
||||||
|
timestamp: dayBefore.add(const Duration(hours: 10)),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
ChatMessageItem(
|
||||||
|
id: 'm2',
|
||||||
|
content: '下周一之前提交项目报告',
|
||||||
|
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 55)),
|
||||||
|
sender: MessageSender.user,
|
||||||
|
),
|
||||||
|
ScheduleItemWrapper(
|
||||||
|
id: 's0',
|
||||||
|
scheduleItem: ScheduleItemModel(
|
||||||
|
id: 's0',
|
||||||
|
title: '提交项目报告',
|
||||||
|
description: '完成并提交Q2项目报告',
|
||||||
|
startAt: before.subtract(const Duration(days: 3)),
|
||||||
|
endAt: null,
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
sourceType: ScheduleSourceType.agentGenerated,
|
||||||
|
status: ScheduleStatus.active,
|
||||||
|
metadata: ScheduleMetadata(
|
||||||
|
color: '#F59E0B',
|
||||||
|
location: null,
|
||||||
|
notes: '记得附上数据附件',
|
||||||
|
attachments: [],
|
||||||
|
),
|
||||||
|
createdAt: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||||
|
),
|
||||||
|
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
ChatMessageItem(
|
||||||
|
id: 'm3',
|
||||||
|
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
|
||||||
|
timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessageItem extends ChatListItem {
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
final String content;
|
||||||
|
@override
|
||||||
|
final DateTime timestamp;
|
||||||
|
@override
|
||||||
|
final MessageSender sender;
|
||||||
|
|
||||||
|
ChatMessageItem({
|
||||||
|
required this.id,
|
||||||
|
required this.content,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.sender,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ChatItemType get type => ChatItemType.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduleItemWrapper extends ChatListItem {
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
final ScheduleItemModel scheduleItem;
|
||||||
|
@override
|
||||||
|
final DateTime timestamp;
|
||||||
|
@override
|
||||||
|
final MessageSender sender;
|
||||||
|
|
||||||
|
ScheduleItemWrapper({
|
||||||
|
required this.id,
|
||||||
|
required this.scheduleItem,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.sender,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ChatItemType get type => ChatItemType.schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScheduleSourceType { manual, imported, agentGenerated }
|
||||||
|
|
||||||
|
enum ScheduleStatus { active, completed, canceled, archived }
|
||||||
|
|
||||||
|
class ScheduleItemModel {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final DateTime startAt;
|
||||||
|
final DateTime? endAt;
|
||||||
|
final String timezone;
|
||||||
|
final ScheduleSourceType sourceType;
|
||||||
|
final ScheduleStatus status;
|
||||||
|
final ScheduleMetadata? metadata;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
ScheduleItemModel({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
required this.startAt,
|
||||||
|
this.endAt,
|
||||||
|
required this.timezone,
|
||||||
|
required this.sourceType,
|
||||||
|
required this.status,
|
||||||
|
this.metadata,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ScheduleItemModel(
|
||||||
|
id: json['id'],
|
||||||
|
title: json['title'],
|
||||||
|
description: json['description'],
|
||||||
|
startAt: DateTime.parse(json['start_at']),
|
||||||
|
endAt: json['end_at'] != null ? DateTime.parse(json['end_at']) : null,
|
||||||
|
timezone: json['timezone'] ?? 'UTC',
|
||||||
|
sourceType: ScheduleSourceType.values.firstWhere(
|
||||||
|
(e) => e.name == json['source_type'],
|
||||||
|
orElse: () => ScheduleSourceType.manual,
|
||||||
|
),
|
||||||
|
status: ScheduleStatus.values.firstWhere(
|
||||||
|
(e) => e.name == json['status'],
|
||||||
|
orElse: () => ScheduleStatus.active,
|
||||||
|
),
|
||||||
|
metadata: json['metadata'] != null
|
||||||
|
? ScheduleMetadata(
|
||||||
|
color: json['metadata']['color'],
|
||||||
|
location: json['metadata']['location'],
|
||||||
|
notes: json['metadata']['notes'],
|
||||||
|
attachments:
|
||||||
|
(json['metadata']['attachments'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(a) => Attachment(
|
||||||
|
name: a['name'],
|
||||||
|
type: a['type'] == 'document'
|
||||||
|
? AttachmentType.document
|
||||||
|
: AttachmentType.reminder,
|
||||||
|
url: a['url'],
|
||||||
|
content: a['content'],
|
||||||
|
note: a['note'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
createdAt: DateTime.parse(json['created_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduleMetadata {
|
||||||
|
final String? color;
|
||||||
|
final String? location;
|
||||||
|
final String? notes;
|
||||||
|
final List<Attachment> attachments;
|
||||||
|
|
||||||
|
ScheduleMetadata({
|
||||||
|
this.color,
|
||||||
|
this.location,
|
||||||
|
this.notes,
|
||||||
|
this.attachments = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AttachmentType { document, reminder }
|
||||||
|
|
||||||
|
class Attachment {
|
||||||
|
final String name;
|
||||||
|
final AttachmentType type;
|
||||||
|
final String? url;
|
||||||
|
final String? content;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Attachment({
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.url,
|
||||||
|
this.content,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScheduleSourceTypeExtension on ScheduleSourceType {
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case ScheduleSourceType.manual:
|
||||||
|
return '手动创建';
|
||||||
|
case ScheduleSourceType.imported:
|
||||||
|
return '导入';
|
||||||
|
case ScheduleSourceType.agentGenerated:
|
||||||
|
return 'AI生成';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get icon {
|
||||||
|
switch (this) {
|
||||||
|
case ScheduleSourceType.manual:
|
||||||
|
return Icons.edit_calendar;
|
||||||
|
case ScheduleSourceType.imported:
|
||||||
|
return Icons.download;
|
||||||
|
case ScheduleSourceType.agentGenerated:
|
||||||
|
return Icons.auto_awesome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
|
enum MessageSender { user, ai }
|
||||||
|
|
||||||
|
class ChatBubble extends StatelessWidget {
|
||||||
|
final MessageSender sender;
|
||||||
|
final String content;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool showTimestamp;
|
||||||
|
final Widget? extraContent;
|
||||||
|
|
||||||
|
const ChatBubble({
|
||||||
|
super.key,
|
||||||
|
required this.sender,
|
||||||
|
required this.content,
|
||||||
|
required this.timestamp,
|
||||||
|
this.showTimestamp = true,
|
||||||
|
this.extraContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isUser = sender == MessageSender.user;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: isUser
|
||||||
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showTimestamp)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Text(
|
||||||
|
_formatTimestamp(timestamp),
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.slate400),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUser ? AppColors.blue500 : AppColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: content.isNotEmpty
|
||||||
|
? Text(
|
||||||
|
content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: isUser ? AppColors.white : AppColors.slate700,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: (extraContent ?? const SizedBox.shrink()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final msgDate = DateTime(time.year, time.month, time.day);
|
||||||
|
|
||||||
|
String dateStr;
|
||||||
|
if (msgDate == today) {
|
||||||
|
dateStr = '今天';
|
||||||
|
} else if (msgDate == today.subtract(const Duration(days: 1))) {
|
||||||
|
dateStr = '昨天';
|
||||||
|
} else {
|
||||||
|
dateStr = '${time.month}月${time.day}日';
|
||||||
|
}
|
||||||
|
|
||||||
|
final timeStr =
|
||||||
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
return '$dateStr $timeStr';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||||
|
import 'package:social_app/shared/widgets/chat_bubble.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('HomeScreen Widget Tests', () {
|
||||||
|
testWidgets('displays chat messages with ChatBubble', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.byType(ChatBubble), findsAtLeastNWidgets(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays user request message', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.textContaining('明天提醒我开会'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays AI response message', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.textContaining('已为你创建日程'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays calendar schedule cards in chat flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.byType(ChatBubble), findsAtLeastNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('input field is present', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
expect(find.text('输入消息...'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('header icons are present', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
||||||
|
|
||||||
|
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
# AG-UI 聊天功能设计文档
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
本文档描述如何使用 AG-UI 协议实现 AI 聊天功能,包括:
|
||||||
|
- 消息的发送与接收(通过 AG-UI 事件流)
|
||||||
|
- AI 工具调用(Tool Call)机制
|
||||||
|
- 日历卡片作为 Tool Result 渲染
|
||||||
|
- 前端工具注册与执行
|
||||||
|
- 本地持久化
|
||||||
|
|
||||||
|
## 2. 架构设计
|
||||||
|
|
||||||
|
### 2.1 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入消息
|
||||||
|
↓
|
||||||
|
AgUiService.sendMessage()
|
||||||
|
↓
|
||||||
|
[Mock Mode] 规则引擎决策 → 事件流模拟
|
||||||
|
[Real Mode] POST /api/chat → SSE 监听
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ AG-UI Event Stream (按序处理) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END │
|
||||||
|
│ TOOL_CALL_START → TOOL_CALL_ARGS* → TOOL_CALL_END │
|
||||||
|
│ TOOL_CALL_RESULT │
|
||||||
|
│ RUN_STARTED → ... → RUN_FINISHED │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
ChatListItem 渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心组件
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `AgUiEvent` | AG-UI 事件数据模型 |
|
||||||
|
| `AgUiService` | 事件流处理:发送消息、解析事件 |
|
||||||
|
| `ToolRegistry` | 前端工具注册表:定义工具 + handler |
|
||||||
|
| `AiDecisionEngine` | Mock 模式:规则引擎决定是否调用工具 |
|
||||||
|
| `UiSchemaParser` | 解析 tool result 中的 UI Schema |
|
||||||
|
| `UiSchemaRenderer` | 根据 schema 渲染对应组件 |
|
||||||
|
| `ChatHistoryRepository` | 本地持久化:IndexedDB/localStorage |
|
||||||
|
|
||||||
|
### 2.3 状态管理
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatState {
|
||||||
|
messages: ChatListItem[] // 渲染列表
|
||||||
|
pendingToolCalls: Map<call_id, ToolCallState>
|
||||||
|
isLoading: bool
|
||||||
|
runId: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 AG-UI 事件模型
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 基类
|
||||||
|
abstract class AgUiEvent {
|
||||||
|
final String type;
|
||||||
|
final String? timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期事件
|
||||||
|
class RunStartedEvent extends AgUiEvent {
|
||||||
|
final String threadId;
|
||||||
|
final String runId;
|
||||||
|
final String? parentRunId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RunFinishedEvent extends AgUiEvent {
|
||||||
|
final String threadId;
|
||||||
|
final String runId;
|
||||||
|
final dynamic result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本消息事件
|
||||||
|
class TextMessageStartEvent extends AgUiEvent {
|
||||||
|
final String messageId;
|
||||||
|
final String role; // "user" | "assistant" | "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextMessageContentEvent extends AgUiEvent {
|
||||||
|
final String messageId;
|
||||||
|
final String delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextMessageEndEvent extends AgUiEvent {
|
||||||
|
final String messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用事件
|
||||||
|
class ToolCallStartEvent extends AgUiEvent {
|
||||||
|
final String toolCallId;
|
||||||
|
final String toolCallName;
|
||||||
|
final String? parentMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolCallArgsEvent extends AgUiEvent {
|
||||||
|
final String toolCallId;
|
||||||
|
final String delta; // JSON fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolCallEndEvent extends AgUiEvent {
|
||||||
|
final String toolCallId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolCallResultEvent extends AgUiEvent {
|
||||||
|
final String messageId;
|
||||||
|
final String toolCallId;
|
||||||
|
final ToolResult result; // 给 AI 的原始结果
|
||||||
|
final UiCard? ui; // 给 UI 的渲染数据
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolCallErrorEvent extends AgUiEvent {
|
||||||
|
final String toolCallId;
|
||||||
|
final String error;
|
||||||
|
final String? code;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Tool Result Schema(v1)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"version": "v1",
|
||||||
|
"call_id": "call_abc123",
|
||||||
|
"tool_name": "create_calendar_event",
|
||||||
|
"result": {
|
||||||
|
"eventId": "evt_xxx",
|
||||||
|
"ok": true,
|
||||||
|
"message": "日程已创建"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"type": "card",
|
||||||
|
"cardType": "calendar_card.v1",
|
||||||
|
"data": {
|
||||||
|
"id": "evt_xxx",
|
||||||
|
"title": "产品评审会议",
|
||||||
|
"description": "讨论Q2路线图",
|
||||||
|
"startAt": "2026-03-01T10:00:00+08:00",
|
||||||
|
"endAt": "2026-03-01T11:00:00+08:00",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"location": "会议室A",
|
||||||
|
"color": "#4F46E5",
|
||||||
|
"sourceType": "agentGenerated"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{"type": "open", "label": "打开", "target": "calendar/evt_xxx"},
|
||||||
|
{"type": "edit", "label": "编辑", "action": "edit_event"},
|
||||||
|
{"type": "delete", "label": "删除", "action": "delete_event"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 工具定义(前端 Tool Registry)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 工具定义
|
||||||
|
class ToolDefinition {
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final Map<String, dynamic> parameters;
|
||||||
|
final ToolHandler handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create_calendar_event 工具
|
||||||
|
{
|
||||||
|
"name": "create_calendar_event",
|
||||||
|
"description": "创建一个日历事件或待办事项",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "事件标题",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 100
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "事件描述"
|
||||||
|
},
|
||||||
|
"startAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "开始时间 (ISO8601)"
|
||||||
|
},
|
||||||
|
"endAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "结束时间 (ISO8601)"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Asia/Shanghai"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "startAt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 ChatListItem 模型
|
||||||
|
|
||||||
|
```dart
|
||||||
|
enum ChatItemType {
|
||||||
|
message, // 纯文本消息
|
||||||
|
toolCall, // 工具调用中
|
||||||
|
toolResult, // 工具结果卡片
|
||||||
|
schedule // 日历事件(兼容旧数据)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ChatListItem {
|
||||||
|
String get id;
|
||||||
|
DateTime get timestamp;
|
||||||
|
ChatItemType get type;
|
||||||
|
MessageSender get sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextMessageItem extends ChatListItem {
|
||||||
|
final String id;
|
||||||
|
final String content;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final MessageSender sender;
|
||||||
|
final bool isStreaming; // 是否正在流式输出
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolCallItem extends ChatListItem {
|
||||||
|
final String id;
|
||||||
|
final String callId;
|
||||||
|
final String toolName;
|
||||||
|
final Map<String, dynamic> args; // 解析后的参数
|
||||||
|
final ToolCallStatus status; // pending | executing | completed | error
|
||||||
|
final ToolResult? result;
|
||||||
|
final UiCard? uiCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CalendarCardItem extends ChatListItem {
|
||||||
|
final String id;
|
||||||
|
final String callId; // 关联的 tool call
|
||||||
|
final CalendarCardData data;
|
||||||
|
final List<CardAction> actions;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 核心流程
|
||||||
|
|
||||||
|
### 4.1 发送消息
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> sendMessage(String content) async {
|
||||||
|
// 1. 添加用户消息到列表
|
||||||
|
final userMessage = TextMessageItem(
|
||||||
|
id: generateId(),
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
sender: MessageSender.user,
|
||||||
|
);
|
||||||
|
_chatItems.add(userMessage);
|
||||||
|
|
||||||
|
// 2. 发起请求
|
||||||
|
if (Env.isMockApi) {
|
||||||
|
await _mockEventStream(content);
|
||||||
|
} else {
|
||||||
|
await _realEventStream(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Mock 事件流(规则引擎)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AiDecisionEngine {
|
||||||
|
// 意图关键词映射
|
||||||
|
static final Map<Intent, List<Pattern>> _intentPatterns = {
|
||||||
|
Intent.createEvent: [
|
||||||
|
RegExp(r'提醒|开会|预约|日程|安排'),
|
||||||
|
RegExp(r'明天|今天|后天|下周'),
|
||||||
|
RegExp(r'\d{1,2}点|\d{1,2}:\d{2}'),
|
||||||
|
],
|
||||||
|
Intent.searchEvent: [
|
||||||
|
RegExp(r'查看|有什么|今天.*日程|明天.*安排'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
Intent? matchIntent(String text) {
|
||||||
|
for (final entry in _intentPatterns.entries) {
|
||||||
|
for (final pattern in entry.value) {
|
||||||
|
if (pattern.hasMatch(text)) {
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持强制触发:#tool:create_calendar_event {"title": "test"}
|
||||||
|
bool tryForceTrigger(String text) {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 事件解析与处理
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _processEvent(AgUiEvent event) async {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'TEXT_MESSAGE_START':
|
||||||
|
_handleTextMessageStart(event);
|
||||||
|
break;
|
||||||
|
case 'TEXT_MESSAGE_CONTENT':
|
||||||
|
_handleTextMessageContent(event);
|
||||||
|
break;
|
||||||
|
case 'TEXT_MESSAGE_END':
|
||||||
|
_handleTextMessageEnd(event);
|
||||||
|
break;
|
||||||
|
case 'TOOL_CALL_START':
|
||||||
|
_handleToolCallStart(event);
|
||||||
|
break;
|
||||||
|
case 'TOOL_CALL_ARGS':
|
||||||
|
_handleToolCallArgs(event);
|
||||||
|
break;
|
||||||
|
case 'TOOL_CALL_END':
|
||||||
|
await _handleToolCallEnd(event);
|
||||||
|
break;
|
||||||
|
case 'TOOL_CALL_RESULT':
|
||||||
|
_handleToolCallResult(event);
|
||||||
|
break;
|
||||||
|
case 'TOOL_CALL_ERROR':
|
||||||
|
_handleToolCallError(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleToolCallStart(ToolCallStartEvent event) {
|
||||||
|
// 创建 pending 状态的 tool call item
|
||||||
|
final item = ToolCallItem(
|
||||||
|
id: event.toolCallId,
|
||||||
|
callId: event.toolCallId,
|
||||||
|
toolName: event.toolCallName,
|
||||||
|
args: {},
|
||||||
|
status: ToolCallStatus.pending,
|
||||||
|
);
|
||||||
|
_chatItems.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleToolCallEnd(ToolCallEndEvent event) async {
|
||||||
|
// 1. 找到对应的 pending tool call
|
||||||
|
final toolCall = _findPendingToolCall(event.toolCallId);
|
||||||
|
if (toolCall == null) return;
|
||||||
|
|
||||||
|
// 2. 校验参数 JSON Schema
|
||||||
|
final validation = validateToolArgs(toolCall.toolName, toolCall.args);
|
||||||
|
if (!validation.ok) {
|
||||||
|
_emitToolCallError(event.toolCallId, validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 执行工具 handler
|
||||||
|
toolCall.status = ToolCallStatus.executing;
|
||||||
|
final result = await ToolRegistry.execute(
|
||||||
|
toolCall.toolName,
|
||||||
|
toolCall.args,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 构建 tool result(包含 result + ui)
|
||||||
|
final toolResult = ToolResult(
|
||||||
|
eventId: result['eventId'],
|
||||||
|
ok: result['ok'] ?? true,
|
||||||
|
message: result['message'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final uiCard = _buildUiCard(toolCall.toolName, result);
|
||||||
|
|
||||||
|
// 5. 发送 TOOL_CALL_RESULT 事件
|
||||||
|
_emitToolCallResult(event.toolCallId, toolResult, uiCard);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 UI Schema 渲染
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class UiSchemaRenderer {
|
||||||
|
static final Map<String, Widget Function(UiCard)> _renderers = {
|
||||||
|
'calendar_card.v1': (card) => CalendarCardWidget(
|
||||||
|
data: CalendarCardData.fromJson(card.data),
|
||||||
|
actions: card.actions,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
static Widget render(UiCard card) {
|
||||||
|
final renderer = _renderers[card.cardType];
|
||||||
|
if (renderer != null) {
|
||||||
|
return renderer(card);
|
||||||
|
}
|
||||||
|
// Unknown card type fallback
|
||||||
|
return _renderUnknownCard(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _renderUnknownCard(UiCard card) {
|
||||||
|
return GenericCardWidget(
|
||||||
|
rawJson: jsonEncode(card.toJson()),
|
||||||
|
cardType: card.cardType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 日历卡片组件
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CalendarCardWidget extends StatelessWidget {
|
||||||
|
final CalendarCardData data;
|
||||||
|
final List<CardAction> actions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = ColorExt.parse(data.color ?? '#4F46E5');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [...],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 颜色条
|
||||||
|
Container(
|
||||||
|
height: 4,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
// 内容
|
||||||
|
Padding(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(data.title, style: ...),
|
||||||
|
if (data.description != null) ...,
|
||||||
|
_buildTimeRow(),
|
||||||
|
if (data.location != null) ...,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Actions
|
||||||
|
if (actions.isNotEmpty) _buildActions(actions),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 持久化设计
|
||||||
|
|
||||||
|
### 5.1 存储结构
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// localStorage / IndexedDB
|
||||||
|
{
|
||||||
|
"chat_sessions": {
|
||||||
|
"current_thread_id": {
|
||||||
|
"messages": [...], // ChatListItem JSON
|
||||||
|
"lastRunId": "run_xxx",
|
||||||
|
"updatedAt": "2026-02-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"calendar_events": {
|
||||||
|
"evt_xxx": {...} // 独立存储的日历事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 恢复逻辑
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> restoreSession() async {
|
||||||
|
final session = await ChatHistoryRepository.load('current_thread_id');
|
||||||
|
if (session != null) {
|
||||||
|
_chatItems.clear();
|
||||||
|
_chatItems.addAll(session.messages);
|
||||||
|
_runId = session.lastRunId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 错误处理
|
||||||
|
|
||||||
|
### 6.1 Tool Call 错误
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void _emitToolCallError(String callId, String error) {
|
||||||
|
// 1. 更新 item 状态
|
||||||
|
final item = _findToolCallItem(callId);
|
||||||
|
item?.status = ToolCallStatus.error;
|
||||||
|
item?.errorMessage = error;
|
||||||
|
|
||||||
|
// 2. 渲染错误卡片
|
||||||
|
final errorCard = UiCard(
|
||||||
|
cardType: 'error_card.v1',
|
||||||
|
data: {'message': error},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 触发 UI 更新
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 事件流重连
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 断线重连时从 snapshot 恢复
|
||||||
|
Future<void> reconnect() async {
|
||||||
|
final snapshot = await _fetchMessagesSnapshot();
|
||||||
|
_chatItems.clear();
|
||||||
|
_chatItems.addAll(snapshot.messages);
|
||||||
|
|
||||||
|
// 重新订阅事件流
|
||||||
|
_subscribeToEvents();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 实施计划
|
||||||
|
|
||||||
|
### Phase 1: 基础框架
|
||||||
|
- [ ] 定义 AG-UI 事件模型
|
||||||
|
- [ ] 实现 AgUiService 基础结构
|
||||||
|
- [ ] 实现 ToolRegistry
|
||||||
|
|
||||||
|
### Phase 2: Mock 实现
|
||||||
|
- [ ] 实现 AiDecisionEngine 规则引擎
|
||||||
|
- [ ] 实现 Mock 事件流
|
||||||
|
- [ ] 集成现有 HomeScreen
|
||||||
|
|
||||||
|
### Phase 3: UI 渲染
|
||||||
|
- [ ] 实现 UiSchemaParser
|
||||||
|
- [ ] 实现 CalendarCardWidget
|
||||||
|
- [ ] 实现 ToolPending / ToolError 状态卡片
|
||||||
|
|
||||||
|
### Phase 4: 持久化
|
||||||
|
- [ ] 实现 ChatHistoryRepository
|
||||||
|
- [ ] 实现会话恢复
|
||||||
|
|
||||||
|
### Phase 5: 真实后端对接
|
||||||
|
- [ ] 实现 SSE 客户端
|
||||||
|
- [ ] 实现事件流解析器
|
||||||
|
|
||||||
|
## 8. 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.0 | 2026-02-28 | 初始版本 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
# 好友申请与待办消息功能设计
|
||||||
|
|
||||||
|
**Date:** 2026-02-28
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## 1. 数据模型
|
||||||
|
|
||||||
|
### Friendship 表 (已存在)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | UUID | 主键 |
|
||||||
|
| user_low_id | UUID | 用户A ID (固定排序小值) |
|
||||||
|
| user_high_id | UUID | 用户B ID (固定排序大值) |
|
||||||
|
| initiator_id | UUID? | 发起请求者 |
|
||||||
|
| status | VARCHAR(20) | pending/accepted/blocked/declined/canceled |
|
||||||
|
| requested_at | TIMESTAMP? | 请求时间 |
|
||||||
|
| accepted_at | TIMESTAMP? | 接受时间 |
|
||||||
|
| blocked_by | UUID? | 被谁屏蔽 |
|
||||||
|
| created_by/updated_by | UUID? | 审计字段 |
|
||||||
|
|
||||||
|
### InboxMessage 表 (复用)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | UUID | 主键 |
|
||||||
|
| recipient_id | UUID | 接收方 |
|
||||||
|
| sender_id | UUID? | 发送方 |
|
||||||
|
| message_type | VARCHAR(20) | FRIEND_REQUEST / CALENDAR / SYSTEM / GROUP |
|
||||||
|
| friendship_id | UUID? | 关联 Friendship |
|
||||||
|
| content | TEXT? | 附加消息 |
|
||||||
|
| is_read | BOOLEAN | 已读状态 |
|
||||||
|
| status | VARCHAR(20) | pending/accepted/rejected/dismissed |
|
||||||
|
|
||||||
|
## 2. API 设计
|
||||||
|
|
||||||
|
| 方法 | 路径 | 功能 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | /friends/requests | 发送好友请求 |
|
||||||
|
| GET | /friends/requests/outgoing | 获取我发出的请求 |
|
||||||
|
| GET | /friends/requests/inbox | 获取收到的好友请求 |
|
||||||
|
| POST | /friends/requests/{id}/accept | 接受好友请求 |
|
||||||
|
| POST | /friends/requests/{id}/decline | 拒绝好友请求 |
|
||||||
|
| DELETE | /friends/requests/{id} | 取消我的请求 |
|
||||||
|
| GET | /friends | 获取好友列表 |
|
||||||
|
| DELETE | /friends/{id} | 删除好友 |
|
||||||
|
|
||||||
|
## 3. 业务逻辑流程
|
||||||
|
|
||||||
|
### 3.1 发送好友请求
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 验证 target_user_id != current_user_id
|
||||||
|
2. 检查是否已存在 Friendship 记录
|
||||||
|
- 已 accepted: 返回 409
|
||||||
|
- 已 pending: 返回 409
|
||||||
|
- 已 blocked: 返回 403
|
||||||
|
3. 创建 Friendship (status=pending, initiator_id=current_user)
|
||||||
|
4. 创建 InboxMessage (message_type=FRIEND_REQUEST, recipient=target_user)
|
||||||
|
5. 提交事务
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 接受好友请求
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查询 Friendship 和 InboxMessage
|
||||||
|
2. 验证 current_user == recipient
|
||||||
|
3. 更新 Friendship (status=accepted, accepted_at=now)
|
||||||
|
4. 更新 InboxMessage (status=accepted)
|
||||||
|
5. 提交事务
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 拒绝好友请求
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查询 Friendship 和 InboxMessage
|
||||||
|
2. 验证 current_user == recipient
|
||||||
|
3. 更新 Friendship (status=declined)
|
||||||
|
4. 更新 InboxMessage (status=rejected)
|
||||||
|
5. 提交事务
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 获取好友列表
|
||||||
|
|
||||||
|
```
|
||||||
|
查询 Friendship WHERE (user_low_id=current OR user_high_id=current) AND status=accepted
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 响应 Schema
|
||||||
|
|
||||||
|
### FriendRequestResponse
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"sender": {"id": "uuid", "username": "string", "avatar_url": "string?"},
|
||||||
|
"recipient": {"id": "uuid", "username": "string", "avatar_url": "string?"},
|
||||||
|
"content": "string?",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "datetime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FriendResponse
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"friend": {"id": "uuid", "username": "string", "avatar_url": "string?"},
|
||||||
|
"status": "accepted",
|
||||||
|
"created_at": "datetime",
|
||||||
|
"accepted_at": "datetime?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 边界处理
|
||||||
|
|
||||||
|
| 场景 | 状态码 | 响应 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 对自己发送请求 | 400 | Cannot send friend request to yourself |
|
||||||
|
| 已是好友 | 409 | Already friends |
|
||||||
|
| 已有待处理请求 | 409 | Friend request already exists |
|
||||||
|
| 被对方屏蔽 | 403 | Blocked by user |
|
||||||
|
| 请求不存在 | 404 | Friend request not found |
|
||||||
|
| 无权限操作 | 403 | Not authorized |
|
||||||
|
|
||||||
|
## 6. 测试用例
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
- FriendshipService 业务逻辑
|
||||||
|
- 状态转换验证
|
||||||
|
- 边界条件处理
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
- POST /friends/requests - 成功/失败场景
|
||||||
|
- GET /friends/requests/inbox - 返回正确列表
|
||||||
|
- POST /friends/requests/{id}/accept - 状态更新
|
||||||
|
- DELETE /friends/{id} - 删除好友
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
# 好友申请功能实现计划
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 实现好友申请、待办消息、添加/删除好友等系列功能的后端API
|
||||||
|
|
||||||
|
**Architecture:** 使用 repository/service/router 模式,复用已有的 Friendship 和 InboxMessage 模型,通过 inbox_messages 表存储好友请求通知
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 创建 friendships 模块目录结构和基础文件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/src/v1/friendships/__init__.py`
|
||||||
|
- Create: `backend/src/v1/friendships/schemas.py`
|
||||||
|
- Create: `backend/src/v1/friendships/repository.py`
|
||||||
|
- Create: `backend/src/v1/friendships/service.py`
|
||||||
|
- Create: `backend/src/v1/friendships/dependencies.py`
|
||||||
|
- Create: `backend/src/v1/friendships/router.py`
|
||||||
|
|
||||||
|
**Step 1: 创建目录和基础 schema**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 创建 Pydantic schemas**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/schemas.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserBasicInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FriendRequestCreate(BaseModel):
|
||||||
|
target_user_id: UUID
|
||||||
|
content: Optional[str] = Field(None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class FriendRequestResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
sender: UserBasicInfo
|
||||||
|
recipient: UserBasicInfo
|
||||||
|
content: Optional[str]
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FriendResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
friend: UserBasicInfo
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
accepted_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class FriendRequestAction(BaseModel):
|
||||||
|
# For accept/decline - no body needed but kept for extensibility
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/v1/friendships/
|
||||||
|
git commit -m "feat(friendships): create module structure and schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 实现 FriendshipRepository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/v1/friendships/repository.py`
|
||||||
|
|
||||||
|
**Step 1: 写入失败的测试**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/tests/unit/v1/friendships/test_friendship_repository.py
|
||||||
|
import pytest
|
||||||
|
from uuid import uuid4
|
||||||
|
from v1.friendships.repository import FriendshipRepository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session():
|
||||||
|
# Create mock async session
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_friendship_request(mock_session):
|
||||||
|
repository = FriendshipRepository(mock_session)
|
||||||
|
# Test creating friendship request
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_pending_request_between_users(mock_session):
|
||||||
|
repository = FriendshipRepository(mock_session)
|
||||||
|
# Test checking existing requests
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
**Step 3: 实现 repository**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/repository.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.friendships import Friendship, FriendshipStatus
|
||||||
|
from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FriendshipRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def create_request(
|
||||||
|
self,
|
||||||
|
user_low_id: UUID,
|
||||||
|
user_high_id: UUID,
|
||||||
|
initiator_id: UUID,
|
||||||
|
recipient_id: UUID,
|
||||||
|
content: Optional[str] = None,
|
||||||
|
) -> tuple[Friendship, InboxMessage]:
|
||||||
|
friendship = Friendship(
|
||||||
|
user_low_id=user_low_id,
|
||||||
|
user_high_id=user_high_id,
|
||||||
|
initiator_id=initiator_id,
|
||||||
|
status=FriendshipStatus.PENDING,
|
||||||
|
)
|
||||||
|
self._session.add(friendship)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
inbox_message = InboxMessage(
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
sender_id=initiator_id,
|
||||||
|
message_type=InboxMessageType.FRIEND_REQUEST,
|
||||||
|
friendship_id=friendship.id,
|
||||||
|
content=content,
|
||||||
|
status=InboxMessageStatus.PENDING,
|
||||||
|
)
|
||||||
|
self._session.add(inbox_message)
|
||||||
|
return friendship, inbox_message
|
||||||
|
|
||||||
|
async def get_friendship_between_users(
|
||||||
|
self, user_a_id: UUID, user_b_id: UUID
|
||||||
|
) -> Optional[Friendship]:
|
||||||
|
low_id = min(user_a_id, user_b_id)
|
||||||
|
high_id = max(user_a_id, user_b_id)
|
||||||
|
stmt = select(Friendship).where(
|
||||||
|
and_(
|
||||||
|
Friendship.user_low_id == low_id,
|
||||||
|
Friendship.user_high_id == high_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_pending_inbox_for_recipient(
|
||||||
|
self, friendship_id: UUID, recipient_id: UUID
|
||||||
|
) -> Optional[InboxMessage]:
|
||||||
|
stmt = select(InboxMessage).where(
|
||||||
|
and_(
|
||||||
|
InboxMessage.friendship_id == friendship_id,
|
||||||
|
InboxMessage.recipient_id == recipient_id,
|
||||||
|
InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_friendship_by_id(self, friendship_id: UUID) -> Optional[Friendship]:
|
||||||
|
stmt = select(Friendship).where(Friendship.id == friendship_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_inbox_messages_for_user(
|
||||||
|
self, user_id: UUID, status: Optional[InboxMessageStatus] = None
|
||||||
|
) -> list[InboxMessage]:
|
||||||
|
stmt = select(InboxMessage).where(
|
||||||
|
and_(
|
||||||
|
InboxMessage.recipient_id == user_id,
|
||||||
|
InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(InboxMessage.status == status)
|
||||||
|
stmt = stmt.order_by(InboxMessage.created_at.desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_outgoing_requests(
|
||||||
|
self, user_id: UUID, status: Optional[FriendshipStatus] = None
|
||||||
|
) -> list[Friendship]:
|
||||||
|
stmt = select(Friendship).where(Friendship.initiator_id == user_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Friendship.status == status)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Friendship.status == FriendshipStatus.PENDING)
|
||||||
|
stmt = stmt.order_by(Friendship.created_at.desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
|
||||||
|
stmt = select(Friendship).where(
|
||||||
|
or_(
|
||||||
|
Friendship.user_low_id == user_id,
|
||||||
|
Friendship.user_high_id == user_id,
|
||||||
|
),
|
||||||
|
Friendship.status == FriendshipStatus.ACCEPTED,
|
||||||
|
).order_by(Friendship.updated_at.desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/v1/friendships/repository.py backend/tests/unit/v1/friendships/
|
||||||
|
git commit -m "feat(friendships): implement repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 实现 FriendshipService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/v1/friendships/service.py`
|
||||||
|
|
||||||
|
**Step 1: 写入失败的测试**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/tests/unit/v1/friendships/test_friendship_service.py
|
||||||
|
import pytest
|
||||||
|
from uuid import uuid4
|
||||||
|
from v1.friendships.service import FriendshipService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_repository():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_friend_request_success(mock_repository):
|
||||||
|
service = FriendshipService(mock_repository, current_user)
|
||||||
|
# Test successful friend request
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_friend_request_to_self_fails():
|
||||||
|
# Test that sending to self returns 400
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_friend_request_when_already_friends():
|
||||||
|
# Test that sending to existing friend returns 409
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
**Step 3: 实现 service**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/service.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth.models import CurrentUser
|
||||||
|
from core.db.base_service import BaseService
|
||||||
|
from core.logging import get_logger
|
||||||
|
from models.friendships import Friendship, FriendshipStatus
|
||||||
|
from models.inbox_messages import InboxMessageStatus
|
||||||
|
from models.profile import Profile
|
||||||
|
from v1.friendships.repository import FriendshipRepository
|
||||||
|
from v1.friendships.schemas import (
|
||||||
|
FriendRequestCreate,
|
||||||
|
FriendRequestResponse,
|
||||||
|
FriendResponse,
|
||||||
|
UserBasicInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("v1.friendships.service")
|
||||||
|
|
||||||
|
|
||||||
|
class FriendshipService(BaseService):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: FriendshipRepository,
|
||||||
|
session: AsyncSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(current_user=current_user)
|
||||||
|
self._repository = repository
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def send_request(
|
||||||
|
self, payload: FriendRequestCreate
|
||||||
|
) -> FriendRequestResponse:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
target_user_id = payload.target_user_id
|
||||||
|
|
||||||
|
if current_user_id == target_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot send friend request to yourself"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check existing relationship
|
||||||
|
existing = await self._repository.get_friendship_between_users(
|
||||||
|
current_user_id, target_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing.status == FriendshipStatus.ACCEPTED:
|
||||||
|
raise HTTPException(status_code=409, detail="Already friends")
|
||||||
|
if existing.status == FriendshipStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Friend request already exists")
|
||||||
|
if existing.status == FriendshipStatus.BLOCKED:
|
||||||
|
raise HTTPException(status_code=403, detail="Blocked by user")
|
||||||
|
|
||||||
|
user_low_id = min(current_user_id, target_user_id)
|
||||||
|
user_high_id = max(current_user_id, target_user_id)
|
||||||
|
|
||||||
|
friendship, inbox = await self._repository.create_request(
|
||||||
|
user_low_id=user_low_id,
|
||||||
|
user_high_id=user_high_id,
|
||||||
|
initiator_id=current_user_id,
|
||||||
|
recipient_id=target_user_id,
|
||||||
|
content=payload.content,
|
||||||
|
)
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
sender_info = await self._get_profile_info(current_user_id)
|
||||||
|
recipient_info = await self._get_profile_info(target_user_id)
|
||||||
|
|
||||||
|
return FriendRequestResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
sender=sender_info,
|
||||||
|
recipient=recipient_info,
|
||||||
|
content=payload.content,
|
||||||
|
status=friendship.status.value,
|
||||||
|
created_at=friendship.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
|
||||||
|
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||||
|
if not friendship:
|
||||||
|
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||||
|
|
||||||
|
# Determine recipient - must be the current user
|
||||||
|
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
|
||||||
|
if recipient_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||||
|
friendship_id, current_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
friendship.status = FriendshipStatus.ACCEPTED
|
||||||
|
friendship.accepted_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if inbox:
|
||||||
|
inbox.status = InboxMessageStatus.ACCEPTED
|
||||||
|
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
initiator_info = await self._get_profile_info(friendship.initiator_id)
|
||||||
|
recipient_info = await self._get_profile_info(current_user_id)
|
||||||
|
|
||||||
|
return FriendRequestResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
sender=initiator_info,
|
||||||
|
recipient=recipient_info,
|
||||||
|
content=inbox.content if inbox else None,
|
||||||
|
status=friendship.status.value,
|
||||||
|
created_at=friendship.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
|
||||||
|
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||||
|
if not friendship:
|
||||||
|
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||||
|
|
||||||
|
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
|
||||||
|
if recipient_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||||
|
friendship_id, current_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
friendship.status = FriendshipStatus.DECLINED
|
||||||
|
|
||||||
|
if inbox:
|
||||||
|
inbox.status = InboxMessageStatus.REJECTED
|
||||||
|
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
initiator_info = await self._get_profile_info(friendship.initiator_id)
|
||||||
|
recipient_info = await self._get_profile_info(current_user_id)
|
||||||
|
|
||||||
|
return FriendRequestResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
sender=initiator_info,
|
||||||
|
recipient=recipient_info,
|
||||||
|
content=inbox.content if inbox else None,
|
||||||
|
status=friendship.status.value,
|
||||||
|
created_at=friendship.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel_request(self, friendship_id: UUID) -> None:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
|
||||||
|
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||||
|
if not friendship:
|
||||||
|
raise HTTPException(status_code=404, detail="Friend request not found")
|
||||||
|
|
||||||
|
if friendship.initiator_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
if friendship.status != FriendshipStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only cancel pending requests")
|
||||||
|
|
||||||
|
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||||
|
friendship_id, friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
|
||||||
|
)
|
||||||
|
|
||||||
|
friendship.status = FriendshipStatus.CANCELED
|
||||||
|
|
||||||
|
if inbox:
|
||||||
|
inbox.status = InboxMessageStatus.DISMISSED
|
||||||
|
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
async def get_inbox(self) -> list[FriendRequestResponse]:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
inbox_messages = await self._repository.get_pending_inbox_for_user(
|
||||||
|
current_user_id, InboxMessageStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for msg in inbox_messages:
|
||||||
|
friendship = await self._repository.get_friendship_by_id(msg.friendship_id)
|
||||||
|
if not friendship:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sender_info = await self._get_profile_info(msg.sender_id)
|
||||||
|
recipient_info = await self._get_profile_info(current_user_id)
|
||||||
|
|
||||||
|
results.append(FriendRequestResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
sender=sender_info,
|
||||||
|
recipient=recipient_info,
|
||||||
|
content=msg.content,
|
||||||
|
status=msg.status.value,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
friendships = await self._repository.get_outgoing_requests(current_user_id)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for friendship in friendships:
|
||||||
|
sender_info = await self._get_profile_info(current_user_id)
|
||||||
|
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
|
||||||
|
recipient_info = await self._get_profile_info(recipient_id)
|
||||||
|
|
||||||
|
inbox = await self._repository.get_pending_inbox_for_recipient(
|
||||||
|
friendship.id, recipient_id
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(FriendRequestResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
sender=sender_info,
|
||||||
|
recipient=recipient_info,
|
||||||
|
content=inbox.content if inbox else None,
|
||||||
|
status=friendship.status.value,
|
||||||
|
created_at=friendship.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def get_friends_list(self) -> list[FriendResponse]:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
friendships = await self._repository.get_friends_list(current_user_id)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for friendship in friendships:
|
||||||
|
friend_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
|
||||||
|
friend_info = await self._get_profile_info(friend_id)
|
||||||
|
|
||||||
|
results.append(FriendResponse(
|
||||||
|
id=friendship.id,
|
||||||
|
friend=friend_info,
|
||||||
|
status=friendship.status.value,
|
||||||
|
created_at=friendship.created_at,
|
||||||
|
accepted_at=friendship.accepted_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def remove_friend(self, friendship_id: UUID) -> None:
|
||||||
|
current_user_id = self.require_user_id()
|
||||||
|
|
||||||
|
friendship = await self._repository.get_friendship_by_id(friendship_id)
|
||||||
|
if not friendship:
|
||||||
|
raise HTTPException(status_code=404, detail="Friendship not found")
|
||||||
|
|
||||||
|
if friendship.status != FriendshipStatus.ACCEPTED:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only remove accepted friends")
|
||||||
|
|
||||||
|
# Verify user is part of this friendship
|
||||||
|
if friendship.user_low_id != current_user_id and friendship.user_high_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Soft delete - mark as canceled
|
||||||
|
friendship.status = FriendshipStatus.CANCELED
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
|
async def _get_profile_info(self, user_id: UUID) -> UserBasicInfo:
|
||||||
|
from sqlalchemy import select
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
|
stmt = select(Profile).where(Profile.id == user_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
profile = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not profile:
|
||||||
|
return UserBasicInfo(id=str(user_id), username="Unknown")
|
||||||
|
|
||||||
|
return UserBasicInfo(
|
||||||
|
id=str(profile.id),
|
||||||
|
username=profile.username,
|
||||||
|
avatar_url=profile.avatar_url,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/v1/friendships/service.py
|
||||||
|
git commit -m "feat(friendships): implement service layer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 实现 Dependencies 和 Router
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/v1/friendships/dependencies.py`
|
||||||
|
- Modify: `backend/src/v1/friendships/router.py`
|
||||||
|
|
||||||
|
**Step 1: 实现 dependencies**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/dependencies.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth.models import CurrentUser
|
||||||
|
from core.db import get_db
|
||||||
|
from v1.friendships.repository import FriendshipRepository
|
||||||
|
from v1.friendships.service import FriendshipService
|
||||||
|
from v1.users.dependencies import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_friendship_repository(
|
||||||
|
session: Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
) -> FriendshipRepository:
|
||||||
|
return FriendshipRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_friendship_service(
|
||||||
|
repository: Annotated[FriendshipRepository, Depends(get_friendship_repository)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||||
|
) -> FriendshipService:
|
||||||
|
return FriendshipService(
|
||||||
|
repository=repository,
|
||||||
|
session=session,
|
||||||
|
current_user=user,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 实现 router**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/friendships/router.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, HTTPException
|
||||||
|
|
||||||
|
from v1.friendships.dependencies import get_friendship_service
|
||||||
|
from v1.friendships.schemas import (
|
||||||
|
FriendRequestCreate,
|
||||||
|
FriendRequestResponse,
|
||||||
|
FriendResponse,
|
||||||
|
)
|
||||||
|
from v1.friendships.service import FriendshipService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/friends", tags=["friends"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests", response_model=FriendRequestResponse, status_code=201)
|
||||||
|
async def send_friend_request(
|
||||||
|
payload: FriendRequestCreate,
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> FriendRequestResponse:
|
||||||
|
return await service.send_request(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/inbox", response_model=list[FriendRequestResponse])
|
||||||
|
async def get_inbox(
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> list[FriendRequestResponse]:
|
||||||
|
return await service.get_inbox()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/outgoing", response_model=list[FriendRequestResponse])
|
||||||
|
async def get_outgoing_requests(
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> list[FriendRequestResponse]:
|
||||||
|
return await service.get_outgoing_requests()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{friendship_id}/accept", response_model=FriendRequestResponse)
|
||||||
|
async def accept_friend_request(
|
||||||
|
friendship_id: Annotated[UUID, Path()],
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> FriendRequestResponse:
|
||||||
|
return await service.accept_request(friendship_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{friendship_id}/decline", response_model=FriendRequestResponse)
|
||||||
|
async def decline_friend_request(
|
||||||
|
friendship_id: Annotated[UUID, Path()],
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> FriendRequestResponse:
|
||||||
|
return await service.decline_request(friendship_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/requests/{friendship_id}", status_code=204)
|
||||||
|
async def cancel_friend_request(
|
||||||
|
friendship_id: Annotated[UUID, Path()],
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> None:
|
||||||
|
await service.cancel_request(friendship_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[FriendResponse])
|
||||||
|
async def get_friends_list(
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> list[FriendResponse]:
|
||||||
|
return await service.get_friends_list()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{friendship_id}", status_code=204)
|
||||||
|
async def remove_friend(
|
||||||
|
friendship_id: Annotated[UUID, Path()],
|
||||||
|
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||||
|
) -> None:
|
||||||
|
await service.remove_friend(friendship_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 注册 router 到主路由**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/src/v1/router.py
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from v1.auth.router import router as auth_router
|
||||||
|
from v1.users.router import router as users_router
|
||||||
|
from v1.profile.router import router as profile_router
|
||||||
|
from v1.friendships.router import router as friendships_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(auth_router)
|
||||||
|
router.include_router(users_router)
|
||||||
|
router.include_router(profile_router)
|
||||||
|
router.include_router(friendships_router)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/v1/friendships/dependencies.py backend/src/v1/friendships/router.py backend/src/v1/router.py
|
||||||
|
git commit -m "feat(friendships): implement router and dependencies"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 集成测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/tests/integration/test_friendship_routes.py`
|
||||||
|
|
||||||
|
**Step 1: 写入测试**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/tests/integration/test_friendship_routes.py
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from main import app # FastAPI app
|
||||||
|
from core.db.base import Base
|
||||||
|
from core.db import get_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def async_client():
|
||||||
|
# Setup test database
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_friend_request_requires_auth(async_client):
|
||||||
|
response = await async_client.post(
|
||||||
|
"/api/v1/friends/requests",
|
||||||
|
json={"target_user_id": "..."}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# More tests...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行测试**
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 运行 Lint 和 Typecheck
|
||||||
|
|
||||||
|
**Step 1: 运行 ruff**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run ruff check src/v1/friendships/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行 typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run basedpyright src/v1/friendships/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit (if any fixes needed)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 更新文档
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/runtime/runtime-route.md`
|
||||||
|
|
||||||
|
**Step 1: 添加 API 文档**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Friends
|
||||||
|
|
||||||
|
### Send Friend Request
|
||||||
|
- **POST** `/api/v1/friends/requests`
|
||||||
|
- **Auth:** Required
|
||||||
|
- **Body:** `{ "target_user_id": "uuid", "content": "string?" }`
|
||||||
|
- **Response:** `FriendRequestResponse`
|
||||||
|
|
||||||
|
### Get Inbox
|
||||||
|
- **GET** `/api/v1/friends/requests/inbox`
|
||||||
|
- **Auth:** Required
|
||||||
|
- **Response:** `FriendRequestResponse[]`
|
||||||
|
|
||||||
|
### Accept Request
|
||||||
|
- **POST** `/api/v1/friends/requests/{id}/accept`
|
||||||
|
- **Auth:** Required
|
||||||
|
- **Response:** `FriendRequestResponse`
|
||||||
|
|
||||||
|
### Decline Request
|
||||||
|
- **POST** `/api/v1/friends/requests/{id}/decline`
|
||||||
|
- **Auth:** Required
|
||||||
|
- **Response:** `FriendRequestResponse`
|
||||||
|
|
||||||
|
### Get Friends List
|
||||||
|
- **GET** `/api/v1/friends`
|
||||||
|
- **Auth:** Required
|
||||||
|
- **Response:** `FriendResponse[]`
|
||||||
|
|
||||||
|
### Remove Friend
|
||||||
|
- **DELETE** `/api/v1/friends/{id}`
|
||||||
|
- **Auth:** Required
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/runtime/runtime-route.md
|
||||||
|
git commit -m "docs: add friendship API documentation"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user