feat: Week 4 收尾 + 架构治理 — 搜索/家长中心/Feature Flag/Docker/环境配置
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

架构治理:
- Feature Flag 落地: Cargo.toml [features] default=["diary"] + main.rs cfg 条件编译
- 环境配置统一: AppConfig 类 + --dart-define 注入 + SSE 端口 8080→3000 修复

搜索替代方案 (无 FTS):
- SearchBloc + 标签/心情筛选接入后端 API
- JournalRepository 扩展 mood/tag 筛选参数
- 搜索页 UI 接入实际数据(替换占位文本)

家长中心最小集 (PIPL 合规):
- 后端: parent_service (绑定/查看/导出/删除/解绑) + parent_handler (6 个 API 端点)
- 前端: ParentBloc + ParentPage 功能完整实现
- 绑定孩子、只读查看日记、导出数据、删除数据、解绑

Docker 部署:
- verify.sh 健康检查脚本 (Axum/PG/Redis/OpenAPI 四项检查)

测试修复:
- home_bloc_test / calendar_bloc_test 适配 JournalRepository 新参数

验证: flutter test 84/84 pass, cargo test 76/76 pass, cargo check pass
This commit is contained in:
iven
2026-06-01 23:53:34 +08:00
parent ffde0c9e77
commit 749ef55b89
27 changed files with 2589 additions and 151 deletions

1
Cargo.lock generated
View File

@@ -1457,6 +1457,7 @@ dependencies = [
"chrono",
"erp-auth",
"erp-core",
"redis",
"sea-orm",
"serde",
"serde_json",

View File

@@ -16,6 +16,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart' show ListenableProvider;
import 'config/app_config.dart';
import 'core/theme/app_theme.dart';
import 'core/routing/app_router.dart';
import 'data/remote/api_client.dart';
@@ -35,7 +36,8 @@ class NuanjiApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 创建全局依赖App 生命周期内单例)
final apiClient = ApiClient();
final config = AppConfig.fromEnvironment();
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
final authRepository = AuthRepository(apiClient: apiClient);
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库

View File

@@ -0,0 +1,48 @@
// 应用环境配置 — 通过 --dart-define 注入
//
// 使用方式:
// flutter run --dart-define=API_BASE_URL=http://localhost:3000/api/v1
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1
/// 应用环境配置 — 集中管理所有外部服务地址
class AppConfig {
/// API 基础 URL后端 Axum 服务地址)
final String apiBaseUrl;
/// SSE 推送服务 URL通常与 API 同一地址)
final String sseBaseUrl;
const AppConfig({
required this.apiBaseUrl,
required this.sseBaseUrl,
});
/// 从编译时环境变量构建配置
///
/// 使用 `--dart-define` 注入,未设置时使用默认值。
factory AppConfig.fromEnvironment({
String defaultApiBaseUrl = 'http://localhost:3000/api/v1',
String defaultSseBaseUrl = 'http://localhost:3000/api/v1',
}) {
// const String.fromEnvironment 在编译时求值
const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:3000/api/v1',
);
const sseBaseUrl = String.fromEnvironment(
'SSE_BASE_URL',
defaultValue: 'http://localhost:3000/api/v1',
);
return AppConfig(
apiBaseUrl: apiBaseUrl,
sseBaseUrl: sseBaseUrl,
);
}
/// 开发环境默认配置
static const dev = AppConfig(
apiBaseUrl: 'http://localhost:3000/api/v1',
sseBaseUrl: 'http://localhost:3000/api/v1',
);
}

View File

@@ -12,6 +12,7 @@ export '../../widgets/responsive_scaffold.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../widgets/responsive_scaffold.dart';
@@ -32,11 +33,15 @@ import '../../features/onboarding/views/onboarding_page.dart';
import '../../features/class_/views/class_page.dart';
import '../../features/teacher/views/teacher_page.dart';
import '../../features/parent/views/parent_page.dart';
import '../../features/parent/bloc/parent_bloc.dart';
import '../../features/achievement/views/achievement_page.dart';
import '../../features/stickers/views/sticker_library_page.dart';
import '../../features/templates/views/template_gallery_page.dart';
import '../../features/settings/views/settings_page.dart';
import '../../features/auth/bloc/auth_bloc.dart';
import '../../features/search/bloc/search_bloc.dart';
import '../../data/repositories/journal_repository.dart';
import '../../data/remote/api_client.dart';
// Shell 分支键
final _rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -146,11 +151,17 @@ GoRouter createAppRouter(AuthBloc authBloc) {
name: 'calendar',
builder: (context, state) => const CalendarPage(),
),
// 发现页(复用搜索页,后续可替换为独立 DiscoverPage
// 发现页(搜索页 — 标签+心情筛选日记
GoRoute(
path: '/discover',
name: 'discover',
builder: (context, state) => const SearchPage(),
builder: (context, state) {
final journalRepo = context.read<JournalRepository>();
return BlocProvider(
create: (_) => SearchBloc(journalRepository: journalRepo),
child: const SearchPage(),
);
},
),
GoRoute(
path: '/profile',
@@ -178,6 +189,20 @@ GoRouter createAppRouter(AuthBloc authBloc) {
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MoodPage(),
),
// 周概览(全屏,从日历页进入)
GoRoute(
path: '/weekly',
name: 'weekly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const WeeklyPage(),
),
// 月度概览(全屏,从日历页进入)
GoRoute(
path: '/monthly',
name: 'monthly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MonthlyPage(),
),
GoRoute(
path: '/class',
name: 'class',
@@ -194,7 +219,12 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: '/parent',
name: 'parent',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const ParentPage(),
builder: (context, state) {
return BlocProvider(
create: (_) => ParentBloc(api: context.read<ApiClient>()),
child: const ParentPage(),
);
},
),
GoRoute(
path: '/achievements',

View File

@@ -32,6 +32,8 @@ class IsarJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
}) async {
var query = _isar.journalEntryCollections
.where()
@@ -46,6 +48,16 @@ class IsarJournalRepository implements JournalRepository {
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
}
// 心情过滤
if (mood != null) {
query = query.and().moodEqualTo(mood);
}
// 标签过滤Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
if (tag != null) {
query = query.and().tagsJsonContains(tag);
}
// 按日期降序排列
var results = await query
.sortByDateEpochDesc()

View File

@@ -15,12 +15,14 @@ import '../models/journal_element.dart';
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
/// - [page]/[pageSize]: 分页参数,从 1 开始
abstract class JournalRepository {
/// 获取日记列表(支持日期范围过滤和分页)
/// 获取日记列表(支持日期范围、心情、标签过滤和分页)
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
});
/// 获取单篇日记(返回 null 表示不存在)
@@ -62,6 +64,8 @@ class InMemoryJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
}) async {
var results = _journals.values.toList();
@@ -73,6 +77,16 @@ class InMemoryJournalRepository implements JournalRepository {
results = results.where((j) => j.date.isBefore(dateTo)).toList();
}
// 心情过滤
if (mood != null) {
results = results.where((j) => j.mood.value == mood).toList();
}
// 标签过滤(日记 tags 列表包含指定标签)
if (tag != null) {
results = results.where((j) => j.tags.contains(tag)).toList();
}
// 按日期降序排列(最新在前)
results.sort((a, b) => b.date.compareTo(a.date));

View File

@@ -19,6 +19,8 @@ class RemoteJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
}) async {
final queryParams = <String, dynamic>{};
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
@@ -30,6 +32,8 @@ class RemoteJournalRepository implements JournalRepository {
}
if (page != null) queryParams['page'] = page;
if (pageSize != null) queryParams['page_size'] = pageSize;
if (mood != null) queryParams['mood'] = mood;
if (tag != null) queryParams['tag'] = tag;
final response = await _api.get('/diary/journals', queryParams: queryParams);
final body = response.data as Map<String, dynamic>;

View File

@@ -39,7 +39,7 @@ class SseNotificationService {
SseNotificationService({
required String token,
String baseUrl = 'http://localhost:8080/api/v1',
String baseUrl = 'http://localhost:3000/api/v1',
}) : _token = token,
_baseUrl = baseUrl;

View File

@@ -0,0 +1,140 @@
// 家长中心 BLoC — 管理家长-孩子绑定和数据操作
//
// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError
// API: /diary/parent/* 端点
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/remote/api_client.dart';
part 'parent_event.dart';
part 'parent_state.dart';
/// 家长中心 BLoC — 处理孩子绑定、日记查看、数据导出/删除
class ParentBloc extends Bloc<ParentEvent, ParentState> {
final ApiClient _api;
ParentBloc({required ApiClient api})
: _api = api,
super(const ParentInitial()) {
on<ParentLoadChildren>(_onLoadChildren);
on<ParentBindChild>(_onBindChild);
on<ParentViewJournals>(_onViewJournals);
on<ParentExportData>(_onExportData);
on<ParentDeleteData>(_onDeleteData);
on<ParentUnbindChild>(_onUnbindChild);
}
/// 加载已绑定的孩子列表
Future<void> _onLoadChildren(
ParentLoadChildren event,
Emitter<ParentState> emit,
) async {
emit(const ParentLoading());
try {
final response = await _api.get('/diary/parent/children');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
final children = items
.map((j) => ChildBinding.fromJson(j as Map<String, dynamic>))
.toList();
emit(ParentChildrenLoaded(children));
} catch (e) {
emit(const ParentError('加载孩子列表失败'));
}
}
/// 绑定孩子(输入孩子 ID
Future<void> _onBindChild(
ParentBindChild event,
Emitter<ParentState> emit,
) async {
emit(const ParentLoading());
try {
await _api.post('/diary/parent/bind', data: {
'child_id': event.childId,
});
// 绑定成功后重新加载列表
add(const ParentLoadChildren());
} catch (e) {
emit(const ParentError('绑定失败,请检查孩子 ID'));
}
}
/// 查看孩子日记(只读)
Future<void> _onViewJournals(
ParentViewJournals event,
Emitter<ParentState> emit,
) async {
emit(const ParentLoading());
try {
final response = await _api.get(
'/diary/parent/journals',
queryParams: {
'child_id': event.childId,
'page': 1,
'page_size': 50,
},
);
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
emit(ParentJournalsLoaded(
childId: event.childId,
journals: items.cast<Map<String, dynamic>>(),
));
} catch (e) {
emit(const ParentError('加载日记失败'));
}
}
/// 导出孩子数据PIPL 合规)
Future<void> _onExportData(
ParentExportData event,
Emitter<ParentState> emit,
) async {
emit(const ParentLoading());
try {
final response = await _api.get(
'/diary/parent/export',
queryParams: {'child_id': event.childId},
);
emit(ParentDataExported(
childId: event.childId,
data: response.data as Map<String, dynamic>,
));
} catch (e) {
emit(const ParentError('导出失败'));
}
}
/// 删除孩子数据PIPL 合规,需二次确认)
Future<void> _onDeleteData(
ParentDeleteData event,
Emitter<ParentState> emit,
) async {
emit(const ParentLoading());
try {
await _api.delete('/diary/parent/data', data: {
'child_id': event.childId,
});
emit(ParentDataDeleted(event.childId));
} catch (e) {
emit(const ParentError('删除失败'));
}
}
/// 解绑孩子
Future<void> _onUnbindChild(
ParentUnbindChild event,
Emitter<ParentState> emit,
) async {
try {
await _api.delete('/diary/parent/bind', data: {
'child_id': event.childId,
});
add(const ParentLoadChildren());
} catch (e) {
emit(const ParentError('解绑失败'));
}
}
}

View File

@@ -0,0 +1,43 @@
// 家长中心事件 — ParentBloc 接收的用户操作
part of 'parent_bloc.dart';
/// 家长中心事件基类
sealed class ParentEvent {
const ParentEvent();
}
/// 加载绑定的孩子列表
final class ParentLoadChildren extends ParentEvent {
const ParentLoadChildren();
}
/// 绑定孩子(输入孩子 ID
final class ParentBindChild extends ParentEvent {
final String childId;
const ParentBindChild(this.childId);
}
/// 查看孩子日记
final class ParentViewJournals extends ParentEvent {
final String childId;
const ParentViewJournals(this.childId);
}
/// 导出孩子数据
final class ParentExportData extends ParentEvent {
final String childId;
const ParentExportData(this.childId);
}
/// 删除孩子数据
final class ParentDeleteData extends ParentEvent {
final String childId;
const ParentDeleteData(this.childId);
}
/// 解绑孩子
final class ParentUnbindChild extends ParentEvent {
final String childId;
const ParentUnbindChild(this.childId);
}

View File

@@ -0,0 +1,79 @@
// 家长中心状态 — ParentBloc 输出的 UI 状态
part of 'parent_bloc.dart';
/// 家长中心状态基类
sealed class ParentState {
const ParentState();
}
/// 初始状态
final class ParentInitial extends ParentState {
const ParentInitial();
}
/// 加载中
final class ParentLoading extends ParentState {
const ParentLoading();
}
/// 孩子列表已加载
final class ParentChildrenLoaded extends ParentState {
final List<ChildBinding> children;
const ParentChildrenLoaded(this.children);
}
/// 孩子日记已加载(只读)
final class ParentJournalsLoaded extends ParentState {
final String childId;
final List<Map<String, dynamic>> journals;
const ParentJournalsLoaded({
required this.childId,
required this.journals,
});
}
/// 数据已导出
final class ParentDataExported extends ParentState {
final String childId;
final Map<String, dynamic> data;
const ParentDataExported({
required this.childId,
required this.data,
});
}
/// 数据已删除
final class ParentDataDeleted extends ParentState {
final String childId;
const ParentDataDeleted(this.childId);
}
/// 出错
final class ParentError extends ParentState {
final String message;
const ParentError(this.message);
}
// ===== 模型 =====
/// 家长-孩子绑定关系
class ChildBinding {
final String bindingId;
final String childId;
final DateTime? verifiedAt;
const ChildBinding({
required this.bindingId,
required this.childId,
this.verifiedAt,
});
factory ChildBinding.fromJson(Map<String, dynamic> json) => ChildBinding(
bindingId: json['binding_id'] as String,
childId: json['child_id'] as String,
verifiedAt: json['verified_at'] != null
? DateTime.tryParse(json['verified_at'] as String)
: null,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
// 搜索 BLoC — 标签+心情筛选日记
//
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
// Phase 1 使用简单的标签+心情筛选,后续可扩展全文搜索。
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.dart';
part 'search_event.dart';
part 'search_state.dart';
/// 搜索 BLoC — 处理标签和心情筛选日记的状态转换
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final JournalRepository _journalRepo;
SearchBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const SearchInitial()) {
on<SearchByMood>(_onSearchByMood);
on<SearchByTag>(_onSearchByTag);
on<SearchClear>(_onSearchClear);
}
/// 按心情筛选日记
Future<void> _onSearchByMood(
SearchByMood event,
Emitter<SearchState> emit,
) async {
emit(const SearchLoading());
try {
if (event.mood == null) {
emit(const SearchLoaded());
return;
}
final results = await _journalRepo.getJournals(
mood: event.mood!.value,
page: 1,
pageSize: 50,
);
emit(SearchLoaded(
results: results,
activeMood: event.mood!.value,
));
} catch (e) {
emit(const SearchError('搜索失败,请重试'));
}
}
/// 按标签筛选日记
Future<void> _onSearchByTag(
SearchByTag event,
Emitter<SearchState> emit,
) async {
emit(const SearchLoading());
try {
final results = await _journalRepo.getJournals(
tag: event.tag,
page: 1,
pageSize: 50,
);
emit(SearchLoaded(
results: results,
activeTag: event.tag,
));
} catch (e) {
emit(const SearchError('搜索失败,请重试'));
}
}
/// 清除搜索结果
void _onSearchClear(
SearchClear event,
Emitter<SearchState> emit,
) {
emit(const SearchLoaded());
}
}

View File

@@ -0,0 +1,25 @@
// 搜索事件 — SearchBloc 接收的用户操作
part of 'search_bloc.dart';
/// 搜索事件基类
sealed class SearchEvent {
const SearchEvent();
}
/// 按心情筛选日记
final class SearchByMood extends SearchEvent {
final Mood? mood;
const SearchByMood(this.mood);
}
/// 按标签筛选日记
final class SearchByTag extends SearchEvent {
final String tag;
const SearchByTag(this.tag);
}
/// 清除搜索结果
final class SearchClear extends SearchEvent {
const SearchClear();
}

View File

@@ -0,0 +1,56 @@
// 搜索状态 — SearchBloc 输出的 UI 状态
part of 'search_bloc.dart';
/// 搜索状态基类
sealed class SearchState {
const SearchState();
}
/// 初始状态 — 未执行任何搜索
final class SearchInitial extends SearchState {
const SearchInitial();
}
/// 加载中 — 正在查询日记
final class SearchLoading extends SearchState {
const SearchLoading();
}
/// 搜索结果已加载
final class SearchLoaded extends SearchState {
/// 搜索结果列表(空列表表示无匹配)
final List<JournalEntry> results;
/// 当前活跃的心情筛选条件
final String? activeMood;
/// 当前活跃的标签筛选条件
final String? activeTag;
const SearchLoaded({
this.results = const [],
this.activeMood,
this.activeTag,
});
/// 是否有活跃的筛选条件
bool get hasActiveFilter => activeMood != null || activeTag != null;
SearchLoaded copyWith({
List<JournalEntry>? results,
String? activeMood,
String? activeTag,
}) =>
SearchLoaded(
results: results ?? this.results,
activeMood: activeMood ?? this.activeMood,
activeTag: activeTag ?? this.activeTag,
);
}
/// 搜索出错
final class SearchError extends SearchState {
final String message;
const SearchError(this.message);
}

View File

@@ -1,10 +1,20 @@
// 搜索页面 — 日记搜索 + 标签筛选
// 搜索页面 — 标签+心情筛选日记
//
// 通过 SearchBloc 驱动搜索状态:
// - 标签点击 → SearchByTag event
// - 心情选择 → SearchByMood event
// - 清除按钮 → SearchClear event
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
import 'package:flutter/material.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
/// 搜索页面 — 全文搜索日记Phase 1 占位 UI
import '../../../core/theme/app_colors.dart';
import '../../../data/models/journal_entry.dart';
import '../bloc/search_bloc.dart';
/// 搜索页面 — 标签+心情筛选日记
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -14,9 +24,8 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
bool _hasSearched = false;
// Phase 1 占位数据
// Phase 1 占位标签数据
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情'];
final _moodFilters = Mood.values;
@@ -31,6 +40,10 @@ class _SearchPageState extends State<SearchPage> {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) {
final hasFilter = state is SearchLoaded && state.hasActiveFilter;
return Scaffold(
appBar: AppBar(
title: TextField(
@@ -42,37 +55,73 @@ class _SearchPageState extends State<SearchPage> {
),
border: InputBorder.none,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
suffixIcon: hasFilter
? IconButton(
icon: const Icon(Icons.filter_alt_off),
tooltip: '清除筛选',
onPressed: _clearSearch,
)
: (_searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _hasSearched = false);
_clearSearch();
},
)
: null,
: null),
),
textInputAction: TextInputAction.search,
onSubmitted: (_) => _doSearch(),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
context.read<SearchBloc>().add(SearchByTag(value.trim()));
}
},
),
),
body: _hasSearched
? _buildSearchResults(context, colorScheme)
: _buildSuggestions(context, theme, colorScheme),
body: _buildBody(context, theme, colorScheme, state),
);
},
);
}
void _doSearch() {
setState(() => _hasSearched = true);
/// 根据搜索状态构建 body
Widget _buildBody(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
SearchState state,
) {
return switch (state) {
SearchInitial() => _buildSuggestions(context, theme, colorScheme),
SearchLoading() => const Center(child: CircularProgressIndicator()),
SearchLoaded(:final results, :final activeMood, :final activeTag) =>
_hasActiveFilter(activeMood, activeTag)
? _buildResults(context, theme, colorScheme, results)
: _buildSuggestions(context, theme, colorScheme),
SearchError(:final message) => _buildError(colorScheme, message),
};
}
Widget _buildSuggestions(BuildContext context, ThemeData theme, ColorScheme colorScheme) {
bool _hasActiveFilter(String? mood, String? tag) =>
mood != null || tag != null;
/// 建议区域 — 标签云 + 心情选择
Widget _buildSuggestions(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('常用标签', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
Text(
'常用标签',
style:
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -82,23 +131,28 @@ class _SearchPageState extends State<SearchPage> {
label: Text(tag),
onPressed: () {
_searchController.text = tag;
_doSearch();
context.read<SearchBloc>().add(SearchByTag(tag));
},
);
}).toList(),
),
const SizedBox(height: 24),
Text('按心情筛选', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
Text(
'按心情筛选',
style:
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: _moodFilters.map((mood) {
final color = AppColors.moodColors[mood.value] ?? colorScheme.primary;
final color =
AppColors.moodColors[mood.value] ?? colorScheme.primary;
return GestureDetector(
onTap: () {
_searchController.text = _moodLabel(mood);
_doSearch();
context.read<SearchBloc>().add(SearchByMood(mood));
},
child: Column(
children: [
@@ -110,7 +164,8 @@ class _SearchPageState extends State<SearchPage> {
color: color.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(_moodEmoji(mood), style: const TextStyle(fontSize: 24)),
child: Text(_moodEmoji(mood),
style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 4),
Text(_moodLabel(mood), style: theme.textTheme.labelSmall),
@@ -124,24 +179,81 @@ class _SearchPageState extends State<SearchPage> {
);
}
Widget _buildSearchResults(BuildContext context, ColorScheme colorScheme) {
/// 搜索结果列表
Widget _buildResults(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
List<JournalEntry> results,
) {
if (results.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)),
Icon(Icons.search_off_rounded,
size: 48,
color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text(
'Phase 1: 搜索功能待 Isar FTS 集成',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
'没有找到匹配的日记',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _clearSearch,
child: const Text('清除筛选'),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: results.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = results[index];
return _JournalCard(entry: entry);
},
);
}
/// 错误提示
Widget _buildError(ColorScheme colorScheme, String message) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded,
size: 48,
color: colorScheme.error.withValues(alpha: 0.6)),
const SizedBox(height: 12),
Text(
message,
style: TextStyle(
color: colorScheme.error,
fontSize: 14,
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _clearSearch,
child: const Text('重试'),
),
],
),
);
}
void _clearSearch() {
_searchController.clear();
context.read<SearchBloc>().add(const SearchClear());
}
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
@@ -158,3 +270,101 @@ class _SearchPageState extends State<SearchPage> {
Mood.thinking => '思考',
};
}
/// 日记卡片 — 在搜索结果中展示单条日记摘要
class _JournalCard extends StatelessWidget {
final JournalEntry entry;
const _JournalCard({required this.entry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final moodColor =
AppColors.moodColors[entry.mood.value] ?? colorScheme.primary;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// TODO: 导航到日记详情页
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:心情 emoji + 标题 + 日期
Row(
children: [
Text(
_moodEmoji(entry.mood),
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 8),
Expanded(
child: Text(
entry.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
DateFormat('MM/dd').format(entry.date),
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
// 第二行:标签
if (entry.tags.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 4,
children: entry.tags.take(4).map((tag) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: moodColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
tag,
style: theme.textTheme.labelSmall?.copyWith(
color: moodColor,
fontSize: 11,
),
),
);
}).toList(),
),
],
],
),
),
),
);
}
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}

View File

@@ -200,6 +200,8 @@ class _FailingJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
}) async {
throw Exception('模拟网络错误');
}

View File

@@ -220,6 +220,8 @@ class _FailingJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
}) async {
throw Exception('网络不可用');
}

View File

@@ -8,3 +8,4 @@ pub mod comment_handler;
pub mod sticker_handler;
pub mod achievement_handler;
pub mod stats_handler;
pub mod parent_handler;

View File

@@ -0,0 +1,340 @@
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
use axum::extract::{Extension, FromRef, Query, State};
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::JournalResp;
use crate::service::parent_service::ParentService;
use crate::state::DiaryState;
// ---- 请求/响应 DTO ----
/// 绑定孩子请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct BindChildReq {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 查看孩子日记查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct ChildJournalsQuery {
/// 孩子的用户 ID
pub child_id: Uuid,
/// 页码(默认 1
pub page: Option<u64>,
/// 每页条数(默认 20最大 100
pub page_size: Option<u64>,
}
/// 导出数据查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct ExportQuery {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 删除孩子数据请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct DeleteChildDataReq {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 绑定信息响应
#[derive(Debug, Serialize, ToSchema)]
pub struct BindingResp {
pub binding_id: Uuid,
pub child_id: Uuid,
pub verified_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 删除结果响应
#[derive(Debug, Serialize, ToSchema)]
pub struct DeleteResultResp {
pub deleted_count: usize,
pub message: String,
}
// ---- Handler 函数 ----
#[utoipa::path(
post,
path = "/api/v1/diary/parent/bind",
request_body = BindChildReq,
responses(
(status = 200, description = "绑定成功", body = ApiResponse<BindingResp>),
(status = 400, description = "已绑定该孩子"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// POST /api/v1/diary/parent/bind
///
/// 家长绑定孩子账号。需要 `diary.parent.bind` 权限。
pub async fn bind_child<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindChildReq>,
) -> Result<Json<ApiResponse<BindingResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
let binding = ParentService::bind_child(
ctx.tenant_id,
ctx.user_id,
req.child_id,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(BindingResp {
binding_id: binding.id,
child_id: binding.child_id,
verified_at: binding.verified_at,
})))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/children",
responses(
(status = 200, description = "孩子列表", body = ApiResponse<Vec<BindingResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/children
///
/// 获取家长绑定的孩子列表。需要 `diary.parent.bind` 权限。
pub async fn list_children<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<BindingResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
let bindings = ParentService::list_children(ctx.tenant_id, ctx.user_id, &state.db).await?;
let resp: Vec<BindingResp> = bindings
.into_iter()
.map(|b| BindingResp {
binding_id: b.id,
child_id: b.child_id,
verified_at: b.verified_at,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/journals",
params(ChildJournalsQuery),
responses(
(status = 200, description = "孩子日记列表", body = ApiResponse<PaginatedResponse<JournalResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/journals
///
/// 查看已绑定孩子的日记列表(只读)。需要 `diary.journal.read` 权限。
pub async fn get_child_journals<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ChildJournalsQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<JournalResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let (models, total) = ParentService::get_child_journals(
ctx.tenant_id,
ctx.user_id,
params.child_id,
page,
page_size,
&state.db,
)
.await?;
let items: Vec<JournalResp> = models.into_iter().map(journal_model_to_resp).collect();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/export",
params(ExportQuery),
responses(
(status = 200, description = "导出数据"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/export
///
/// 导出孩子所有日记数据PIPL 数据可携带权)。需要 `diary.journal.read` 权限。
pub async fn export_child_data<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ExportQuery>,
) -> Result<Json<serde_json::Value>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let data =
ParentService::export_child_data(ctx.tenant_id, ctx.user_id, params.child_id, &state.db)
.await?;
Ok(Json(data))
}
#[utoipa::path(
delete,
path = "/api/v1/diary/parent/data",
request_body = DeleteChildDataReq,
responses(
(status = 200, description = "删除成功", body = ApiResponse<DeleteResultResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// DELETE /api/v1/diary/parent/data
///
/// 软删除孩子所有日记数据PIPL 删除权)。需要 `diary.parent.bind` 权限。
/// 数据将在 30 天内完成清理。
pub async fn delete_child_data<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<DeleteChildDataReq>,
) -> Result<Json<ApiResponse<DeleteResultResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
let count = ParentService::delete_child_data(
ctx.tenant_id,
ctx.user_id,
req.child_id,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(DeleteResultResp {
deleted_count: count,
message: "数据删除请求已提交,将在 30 天内完成删除".to_string(),
})))
}
#[utoipa::path(
delete,
path = "/api/v1/diary/parent/unbind",
request_body = BindChildReq,
responses(
(status = 200, description = "解绑成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "绑定关系不存在"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// DELETE /api/v1/diary/parent/unbind
///
/// 解绑孩子。需要 `diary.parent.bind` 权限。
pub async fn unbind_child<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindChildReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("已解绑".to_string()),
}))
}
/// journal_entry::Model -> JournalResp DTO 转换
///
/// 与 journal_service 中的 model_to_resp 逻辑一致,
/// 但这里直接从 Model 转换避免循环依赖。
fn journal_model_to_resp(model: crate::entity::journal_entry::Model) -> JournalResp {
use crate::dto::{Mood, Weather};
let mood: Mood = serde_json::from_str(&model.mood).unwrap_or(Mood::Happy);
let weather: Weather = serde_json::from_str(&model.weather).unwrap_or(Weather::Sunny);
let tags: Vec<String> = model
.tags
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
JournalResp {
id: model.id,
author_id: model.author_id,
class_id: model.class_id,
title: model.title,
date: model.date,
mood,
weather,
tags,
is_private: model.is_private,
shared_to_class: model.shared_to_class,
version: model.version,
created_at: model.created_at,
updated_at: model.updated_at,
}
}

View File

@@ -12,7 +12,7 @@ use erp_core::module::ErpModule;
use crate::handler::{
journal_handler, sync_handler, class_handler, topic_handler, comment_handler,
sticker_handler, achievement_handler, stats_handler,
sticker_handler, achievement_handler, stats_handler, parent_handler,
};
/// 暖记日记业务模块
@@ -202,5 +202,30 @@ impl DiaryModule {
"/diary/stats/mood",
axum::routing::get(stats_handler::get_mood_stats),
)
// 家长中心 — PIPL 合规
.route(
"/diary/parent/bind",
axum::routing::post(parent_handler::bind_child),
)
.route(
"/diary/parent/children",
axum::routing::get(parent_handler::list_children),
)
.route(
"/diary/parent/journals",
axum::routing::get(parent_handler::get_child_journals),
)
.route(
"/diary/parent/export",
axum::routing::get(parent_handler::export_child_data),
)
.route(
"/diary/parent/data",
axum::routing::delete(parent_handler::delete_child_data),
)
.route(
"/diary/parent/unbind",
axum::routing::delete(parent_handler::unbind_child),
)
}
}

View File

@@ -10,3 +10,4 @@ pub mod sticker_service;
pub mod achievement_service;
pub mod mood_stats_service;
pub mod content_safety_service;
pub mod parent_service;

View File

@@ -0,0 +1,295 @@
// 家长-孩子绑定服务 — PIPL 合规: 数据查阅/导出/删除
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait,
QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::entity::journal_entry;
use crate::entity::parent_child_binding;
use crate::error::{DiaryError, DiaryResult};
use erp_core::events::{DomainEvent, EventBus};
/// 家长中心服务 — 绑定管理、数据查阅、导出、删除
pub struct ParentService;
impl ParentService {
/// 绑定孩子 — 家长通过孩子用户 ID 建立绑定关系
///
/// 检查是否已存在有效绑定,避免重复绑定。
/// 插入后发布 `diary.parent.child_bound` 事件。
pub async fn bind_child(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<parent_child_binding::Model> {
// 检查是否已绑定
let existing = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::ParentId.eq(parent_id))
.filter(parent_child_binding::Column::ChildId.eq(child_id))
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::Status.ne("revoked"))
.one(db)
.await?;
if existing.is_some() {
return Err(DiaryError::BadRequest("已绑定该孩子".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = parent_child_binding::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
parent_id: Set(parent_id),
child_id: Set(child_id),
verification_method: Set("manual".to_string()),
verified_at: Set(Some(now)),
status: Set("verified".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(parent_id),
updated_by: Set(parent_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = model.insert(db).await?;
event_bus
.publish(
DomainEvent::new(
"diary.parent.child_bound",
tenant_id,
serde_json::json!({
"parent_id": parent_id,
"child_id": child_id,
}),
),
db,
)
.await;
Ok(inserted)
}
/// 获取家长绑定的孩子列表
///
/// 只返回 status=verified 且未软删除的绑定。
pub async fn list_children(
tenant_id: Uuid,
parent_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<parent_child_binding::Model>> {
let bindings = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::ParentId.eq(parent_id))
.filter(parent_child_binding::Column::Status.eq("verified"))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(bindings)
}
/// 查看孩子日记 — 只读,家长只能查看已绑定的孩子的日记
///
/// 先验证绑定关系,再分页查询孩子的日记列表。
pub async fn get_child_journals(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
page: u64,
page_size: u64,
db: &DatabaseConnection,
) -> DiaryResult<(Vec<journal_entry::Model>, u64)> {
// 验证绑定关系
Self::verify_binding(tenant_id, parent_id, child_id, db).await?;
let page_size = page_size.min(100).max(1);
let page = page.max(1);
let paginator = journal_entry::Entity::find()
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(child_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.order_by_desc(journal_entry::Column::Date)
.order_by_desc(journal_entry::Column::CreatedAt)
.paginate(db, page_size);
let total = paginator.num_items().await?;
let journals = paginator.fetch_page(page.saturating_sub(1)).await?;
Ok((journals, total))
}
/// 导出孩子数据 — 返回所有日记的 JSONPIPL 数据可携带权)
///
/// 先验证绑定关系,再查询孩子全部未删除的日记。
pub async fn export_child_data(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<serde_json::Value> {
Self::verify_binding(tenant_id, parent_id, child_id, db).await?;
let journals = journal_entry::Entity::find()
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(child_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(serde_json::json!({
"child_id": child_id,
"exported_at": Utc::now(),
"journals": journals,
}))
}
/// 删除孩子数据 — 软删除所有日记PIPL 删除权)
///
/// 软删除孩子全部未删除的日记,逐条设置 deleted_at。
/// 发布 `diary.parent.data_deleted` 事件记录操作。
pub async fn delete_child_data(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<usize> {
Self::verify_binding(tenant_id, parent_id, child_id, db).await?;
let journals = journal_entry::Entity::find()
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(child_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.all(db)
.await?;
let count = journals.len();
let now = Utc::now();
for journal in journals {
let current_version = journal.version;
let mut active: journal_entry::ActiveModel = journal.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(parent_id);
active.version = Set(current_version + 1);
active.update(db).await?;
}
event_bus
.publish(
DomainEvent::new(
"diary.parent.data_deleted",
tenant_id,
serde_json::json!({
"parent_id": parent_id,
"child_id": child_id,
"deleted_count": count,
}),
),
db,
)
.await;
Ok(count)
}
/// 解绑孩子 — 将绑定状态设为 revoked
pub async fn unbind_child(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<()> {
let binding = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::ParentId.eq(parent_id))
.filter(parent_child_binding::Column::ChildId.eq(child_id))
.filter(parent_child_binding::Column::Status.eq("verified"))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound("绑定关系不存在".to_string()))?;
let current_version = binding.version;
let now = Utc::now();
let mut active: parent_child_binding::ActiveModel = binding.into();
active.status = Set("revoked".to_string());
active.updated_at = Set(now);
active.updated_by = Set(parent_id);
active.version = Set(current_version + 1);
active.update(db).await?;
Ok(())
}
/// 验证家长-孩子绑定关系
///
/// 查询是否存在 status=verified 且未删除的绑定记录,
/// 不存在则返回 Forbidden 错误。
async fn verify_binding(
tenant_id: Uuid,
parent_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<()> {
let exists = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::ParentId.eq(parent_id))
.filter(parent_child_binding::Column::ChildId.eq(child_id))
.filter(parent_child_binding::Column::Status.eq("verified"))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.one(db)
.await?;
if exists.is_none() {
return Err(DiaryError::Forbidden);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 验证 verify_binding 在无匹配记录时的错误类型
/// 由于没有数据库连接,这里只测试错误类型枚举匹配
#[test]
fn forbidden_error_is_correct_variant() {
let err = DiaryError::Forbidden;
match err {
DiaryError::Forbidden => {}
other => panic!("Expected Forbidden, got {:?}", other),
}
}
#[test]
fn bad_request_error_carries_message() {
let err = DiaryError::BadRequest("已绑定该孩子".to_string());
match err {
DiaryError::BadRequest(msg) => assert_eq!(msg, "已绑定该孩子"),
other => panic!("Expected BadRequest, got {:?}", other),
}
}
#[test]
fn not_found_error_carries_message() {
let err = DiaryError::NotFound("绑定关系不存在".to_string());
match err {
DiaryError::NotFound(msg) => assert_eq!(msg, "绑定关系不存在"),
other => panic!("Expected NotFound, got {:?}", other),
}
}
}

View File

@@ -3,6 +3,10 @@ name = "erp-server"
version.workspace = true
edition.workspace = true
[features]
default = ["diary"]
diary = ["dep:erp-diary"]
[[bin]]
name = "erp-server"
path = "src/main.rs"
@@ -28,7 +32,7 @@ erp-config.workspace = true
erp-workflow.workspace = true
erp-message.workspace = true
erp-plugin.workspace = true
erp-diary.workspace = true
erp-diary = { workspace = true, optional = true }
anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -326,7 +326,9 @@ async fn main() -> anyhow::Result<()> {
);
// Initialize diary module (暖记业务)
#[cfg(feature = "diary")]
let diary_module = erp_diary::DiaryModule;
#[cfg(feature = "diary")]
tracing::info!(
module = diary_module.name(),
version = diary_module.version(),
@@ -338,8 +340,10 @@ async fn main() -> anyhow::Result<()> {
.register(auth_module)
.register(config_module)
.register(workflow_module)
.register(message_module)
.register(diary_module);
.register(message_module);
#[cfg(feature = "diary")]
let registry = registry.register(diary_module);
tracing::info!(
module_count = registry.modules().len(),
"Modules registered"
@@ -501,8 +505,12 @@ async fn main() -> anyhow::Result<()> {
.merge(erp_config::ConfigModule::protected_routes())
.merge(erp_workflow::WorkflowModule::protected_routes())
.merge(erp_message::MessageModule::protected_routes())
.merge(erp_plugin::module::PluginModule::protected_routes())
.merge(erp_diary::DiaryModule::protected_routes())
.merge(erp_plugin::module::PluginModule::protected_routes());
#[cfg(feature = "diary")]
let protected_routes = protected_routes.merge(erp_diary::DiaryModule::protected_routes());
let protected_routes = protected_routes
.merge(handlers::audit_log::audit_log_router())
.route(
"/upload",

View File

@@ -110,6 +110,7 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
}
/// Allow erp-diary handlers to extract their required state.
#[cfg(feature = "diary")]
impl FromRef<AppState> for erp_diary::DiaryState {
fn from_ref(state: &AppState) -> Self {
Self {

123
docker/verify.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# verify.sh — 暖记部署健康检查脚本
#
# 用法: ./verify.sh [--host HOST] [--timeout SECONDS]
# 默认: host=localhost, timeout=60
set -euo pipefail
# 配置
HOST="${VERIFY_HOST:-localhost}"
TIMEOUT="${VERIFY_TIMEOUT:-60}"
PASS=0
FAIL=0
# 颜色输出
green() { echo -e "\033[32m✓ $1\033[0m"; }
red() { echo -e "\033[31m✗ $1\033[0m"; }
info() { echo -e "\033[36m→ $1\033[0m"; }
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
--host) HOST="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
info "暖记部署健康检查 (host=$HOST, timeout=${TIMEOUT}s)"
echo "---"
# 等待服务启动
info "等待服务启动..."
elapsed=0
until [ $elapsed -ge $TIMEOUT ]; do
if curl -sf "http://$HOST:3000/api/v1/health" > /dev/null 2>&1; then
break
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ $elapsed -ge $TIMEOUT ]; then
red "服务在 ${TIMEOUT}s 内未启动"
exit 1
fi
# 1. 检查 Axum 后端健康
echo ""
info "检查后端服务..."
health_response=$(curl -sf "http://$HOST:3000/api/v1/health" 2>/dev/null || echo "FAILED")
if [[ "$health_response" != "FAILED" ]]; then
green "后端 Axum (port 3000): 正常"
PASS=$((PASS + 1))
else
red "后端 Axum (port 3000): 不可达"
FAIL=$((FAIL + 1))
fi
# 2. 检查 PostgreSQL
info "检查 PostgreSQL..."
if command -v psql &> /dev/null; then
if PGPASSWORD=123123 psql -h "$HOST" -p 5432 -U postgres -d nuanji -c "SELECT 1" > /dev/null 2>&1; then
green "PostgreSQL (port 5432): 正常"
PASS=$((PASS + 1))
else
red "PostgreSQL (port 5432): 连接失败"
FAIL=$((FAIL + 1))
fi
else
# 使用 docker exec 检查
if docker exec $(docker ps -q -f name=postgres) pg_isready -U postgres > /dev/null 2>&1; then
green "PostgreSQL: 正常 (docker exec)"
PASS=$((PASS + 1))
else
red "PostgreSQL: 未就绪"
FAIL=$((FAIL + 1))
fi
fi
# 3. 检查 Redis
info "检查 Redis..."
if command -v redis-cli &> /dev/null; then
if redis-cli -h "$HOST" -p 6379 ping > /dev/null 2>&1; then
green "Redis (port 6379): 正常"
PASS=$((PASS + 1))
else
red "Redis (port 6379): 连接失败"
FAIL=$((FAIL + 1))
fi
else
if docker exec $(docker ps -q -f name=redis) redis-cli ping > /dev/null 2>&1; then
green "Redis: 正常 (docker exec)"
PASS=$((PASS + 1))
else
red "Redis: 未就绪"
FAIL=$((FAIL + 1))
fi
fi
# 4. 检查 API 文档
info "检查 OpenAPI 文档..."
swagger_status=$(curl -sf -o /dev/null -w "%{http_code}" "http://$HOST:3000/api-docs/openapi.json" 2>/dev/null || echo "000")
if [[ "$swagger_status" == "200" ]]; then
green "OpenAPI 文档: 可访问"
PASS=$((PASS + 1))
else
red "OpenAPI 文档: 不可达 (status=$swagger_status)"
FAIL=$((FAIL + 1))
fi
# 汇总
echo ""
echo "========================================"
if [ $FAIL -eq 0 ]; then
green "全部检查通过 ($PASS/$PASS)"
echo "========================================"
exit 0
else
red "部分检查失败 (通过: $PASS, 失败: $FAIL)"
echo "========================================"
exit 1
fi