Compare commits
6 Commits
11d0971a67
...
99db8e5cb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99db8e5cb0 | ||
|
|
a34c9fd176 | ||
|
|
45949e3ed0 | ||
|
|
c4b2de8294 | ||
|
|
cca2d77ea2 | ||
|
|
6d7ac05d0f |
@@ -2,7 +2,8 @@
|
||||
<application
|
||||
android:label="nuanji_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
20
app/android/app/src/main/res/xml/network_security_config.xml
Normal file
20
app/android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 网络安全配置 — 强制 HTTPS,仅允许 localhost 明文(开发用)
|
||||
审计 ID: 6b-C01 — Flutter 默认 HTTP 明文传输修复
|
||||
-->
|
||||
<network-security-config>
|
||||
<!-- 生产配置:强制 HTTPS -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- 开发配置:允许 localhost/10.0.2.2 明文(模拟器/本地调试)
|
||||
生产构建时应移除此段 -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,8 +1,12 @@
|
||||
// 应用环境配置 — 通过 --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
|
||||
// flutter run # 开发模式(localhost)
|
||||
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
|
||||
//
|
||||
// 安全说明:
|
||||
// - 生产环境强制 HTTPS(Android network_security_config 禁止明文流量)
|
||||
// - 开发模式使用 localhost(Android 网络安全配置已允许 localhost 明文)
|
||||
|
||||
/// 应用环境配置 — 集中管理所有外部服务地址
|
||||
class AppConfig {
|
||||
@@ -19,19 +23,20 @@ class AppConfig {
|
||||
|
||||
/// 从编译时环境变量构建配置
|
||||
///
|
||||
/// 使用 `--dart-define` 注入,未设置时使用默认值。
|
||||
/// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
|
||||
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
|
||||
factory AppConfig.fromEnvironment({
|
||||
String defaultApiBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultSseBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
}) {
|
||||
// const String.fromEnvironment 在编译时求值
|
||||
const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
const sseBaseUrl = String.fromEnvironment(
|
||||
'SSE_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
@@ -40,7 +45,7 @@ class AppConfig {
|
||||
);
|
||||
}
|
||||
|
||||
/// 开发环境默认配置
|
||||
/// 开发环境默认配置(localhost 明文 — 仅用于本地调试)
|
||||
static const dev = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:3000/api/v1',
|
||||
sseBaseUrl: 'http://localhost:3000/api/v1',
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../features/profile/views/profile_page.dart';
|
||||
import '../../features/editor/views/editor_page.dart';
|
||||
import '../../features/auth/views/login_page.dart';
|
||||
import '../../features/auth/views/role_selection_page.dart';
|
||||
import '../../features/auth/views/parental_consent_page.dart';
|
||||
import '../../features/auth/views/class_code_join_page.dart';
|
||||
import '../../features/onboarding/views/splash_page.dart';
|
||||
import '../../features/onboarding/views/onboarding_page.dart';
|
||||
@@ -49,7 +50,7 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 不需要认证的白名单路径
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/class-code'];
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code'];
|
||||
|
||||
/// 创建路由配置 — 需要注入 AuthBloc
|
||||
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
@@ -74,6 +75,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
// 已认证 + 访问公开页面 → 根据状态重定向
|
||||
if (isAuthenticated && isPublicPath) {
|
||||
if (authState.needsRoleSelection) return '/role-selection';
|
||||
if (authState.needsParentalConsent) return '/parental-consent';
|
||||
if (authState.needsClassCode) return '/class-code';
|
||||
return '/home';
|
||||
}
|
||||
@@ -83,9 +85,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
if (authState.needsRoleSelection && currentPath != '/role-selection') {
|
||||
return '/role-selection';
|
||||
}
|
||||
if (authState.needsParentalConsent &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/parental-consent';
|
||||
}
|
||||
if (authState.needsClassCode &&
|
||||
currentPath != '/class-code' &&
|
||||
currentPath != '/role-selection') {
|
||||
currentPath != '/role-selection' &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/class-code';
|
||||
}
|
||||
return null;
|
||||
@@ -125,6 +132,11 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
name: 'roleSelection',
|
||||
builder: (context, state) => const RoleSelectionPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parental-consent',
|
||||
name: 'parentalConsent',
|
||||
builder: (context, state) => const ParentalConsentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class-code',
|
||||
name: 'classCode',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
|
||||
//
|
||||
// 核心职责:
|
||||
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
|
||||
// - JWT token 自动注入(请求拦截器)
|
||||
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01)
|
||||
// - 离线状态感知(网络不可用时抛出明确异常)
|
||||
// - 为 SyncEngine 提供远程操作能力
|
||||
|
||||
@@ -26,7 +27,21 @@ class ApiClient {
|
||||
/// 基础 URL,默认指向本地开发服务器
|
||||
final String baseUrl;
|
||||
|
||||
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
|
||||
/// Token 刷新回调 — 由 AuthRepository 在构造后注册
|
||||
///
|
||||
/// 返回新的 access token,失败返回 null。
|
||||
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
|
||||
Future<String?> Function()? onRefreshToken;
|
||||
|
||||
/// 是否正在刷新 token(防止并发 401 触发多次刷新)
|
||||
bool _isRefreshing = false;
|
||||
|
||||
/// 创建 API 客户端
|
||||
///
|
||||
/// [baseUrl] 默认使用 HTTPS 生产地址。
|
||||
/// 开发环境可通过构造参数覆盖为 http://localhost:3000/api/v1
|
||||
/// (Android 网络安全配置已允许 localhost 明文)。
|
||||
ApiClient({this.baseUrl = 'https://api.nuanji.app/api/v1'}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
@@ -48,11 +63,37 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
// 响应拦截器:401 自动刷新 token + 重试
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
// 401 时自动清除 token(需要重新登录)
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// 不对刷新端点本身重试(避免无限循环)
|
||||
final isRefreshRequest =
|
||||
error.requestOptions.path.endsWith('/auth/refresh');
|
||||
|
||||
if (!isRefreshRequest &&
|
||||
onRefreshToken != null &&
|
||||
!_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final newToken = await onRefreshToken!();
|
||||
if (newToken != null) {
|
||||
_token = newToken;
|
||||
// 用新 token 重试原始请求
|
||||
error.requestOptions.headers['Authorization'] =
|
||||
'Bearer $newToken';
|
||||
_isRefreshing = false;
|
||||
return handler.resolve(
|
||||
await _dio.fetch(error.requestOptions),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败,继续走 401 逻辑
|
||||
}
|
||||
_isRefreshing = false;
|
||||
}
|
||||
|
||||
// 刷新失败或无刷新回调 → 清除 token
|
||||
_token = null;
|
||||
}
|
||||
handler.next(error);
|
||||
|
||||
@@ -47,7 +47,10 @@ class AuthRepository {
|
||||
required ApiClient apiClient,
|
||||
required SecureTokenStore tokenStore,
|
||||
}) : _apiClient = apiClient,
|
||||
_tokenStore = tokenStore;
|
||||
_tokenStore = tokenStore {
|
||||
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||
}
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
User? get currentUser => _currentUser;
|
||||
@@ -215,6 +218,33 @@ class AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Token 自动刷新 =====
|
||||
|
||||
/// ApiClient 401 拦截器调用的自动刷新处理
|
||||
///
|
||||
/// 使用 refresh_token 获取新 access_token,更新 ApiClient 的 token,
|
||||
/// 返回新 access_token(失败返回 null)。
|
||||
Future<String?> _handleAutoRefresh() async {
|
||||
if (_currentToken == null) return null;
|
||||
|
||||
_logger.d('自动刷新令牌(401 触发)');
|
||||
try {
|
||||
final response = await _apiClient.post('/auth/refresh', data: {
|
||||
'refresh_token': _currentToken!.refreshToken,
|
||||
});
|
||||
|
||||
final data = _extractData(response.data);
|
||||
final token = AuthToken.fromJson(data);
|
||||
|
||||
await _saveToken(token);
|
||||
_logger.i('自动刷新令牌成功');
|
||||
return token.accessToken;
|
||||
} catch (e) {
|
||||
_logger.w('自动刷新令牌失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/// 从 API 响应中提取 data 字段
|
||||
|
||||
@@ -34,6 +34,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
on<RegisterRequested>(_onRegisterRequested);
|
||||
on<RoleSelected>(_onRoleSelected);
|
||||
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
|
||||
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
on<TokenRefreshed>(_onTokenRefreshed);
|
||||
@@ -124,16 +125,38 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
// 学生角色需要先经过家长同意确认(PIPL 第28条)
|
||||
final needsParentalConsent = event.role == UserRoleType.student;
|
||||
|
||||
// 根据角色决定下一步
|
||||
final needsClassCode =
|
||||
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsRoleSelection: false,
|
||||
needsClassCode: needsClassCode,
|
||||
needsParentalConsent: needsParentalConsent,
|
||||
needsClassCode: needsClassCode && !needsParentalConsent,
|
||||
selectedRole: event.role,
|
||||
));
|
||||
|
||||
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
|
||||
_logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent');
|
||||
}
|
||||
|
||||
/// 家长/监护人同意信息收集(PIPL 合规)
|
||||
Future<void> _onParentalConsentAccepted(
|
||||
ParentalConsentAccepted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
_logger.i('家长同意已确认: ${event.consentAt}');
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsParentalConsent: false,
|
||||
needsClassCode: true,
|
||||
parentalConsentAt: event.consentAt,
|
||||
));
|
||||
}
|
||||
|
||||
/// 班级码加入
|
||||
|
||||
@@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent {
|
||||
const RoleSelected(this.role);
|
||||
}
|
||||
|
||||
/// 家长/监护人同意 PIPL 信息收集(审计 S-03)
|
||||
final class ParentalConsentAccepted extends AuthEvent {
|
||||
final DateTime consentAt;
|
||||
const ParentalConsentAccepted(this.consentAt);
|
||||
}
|
||||
|
||||
/// 班级码加入(学生/家长加入班级)
|
||||
final class ClassCodeSubmitted extends AuthEvent {
|
||||
final String classCode;
|
||||
|
||||
@@ -37,6 +37,9 @@ final class Authenticated extends AuthState {
|
||||
/// 是否需要角色选择(新注册用户还没有角色)
|
||||
final bool needsRoleSelection;
|
||||
|
||||
/// 是否需要家长/监护人同意(PIPL 第28条 — 学生角色)
|
||||
final bool needsParentalConsent;
|
||||
|
||||
/// 是否需要班级码加入(学生/家长角色)
|
||||
final bool needsClassCode;
|
||||
|
||||
@@ -46,27 +49,43 @@ final class Authenticated extends AuthState {
|
||||
/// 班级码验证错误信息
|
||||
final String? classCodeError;
|
||||
|
||||
/// 已选择的角色(角色选择后暂存)
|
||||
final UserRoleType? selectedRole;
|
||||
|
||||
/// 家长同意时间戳
|
||||
final DateTime? parentalConsentAt;
|
||||
|
||||
const Authenticated({
|
||||
required this.user,
|
||||
this.needsRoleSelection = false,
|
||||
this.needsParentalConsent = false,
|
||||
this.needsClassCode = false,
|
||||
this.isLoading = false,
|
||||
this.classCodeError,
|
||||
this.selectedRole,
|
||||
this.parentalConsentAt,
|
||||
});
|
||||
|
||||
Authenticated copyWith({
|
||||
User? user,
|
||||
bool? needsRoleSelection,
|
||||
bool? needsParentalConsent,
|
||||
bool? needsClassCode,
|
||||
bool? isLoading,
|
||||
String? classCodeError,
|
||||
UserRoleType? selectedRole,
|
||||
DateTime? parentalConsentAt,
|
||||
}) =>
|
||||
Authenticated(
|
||||
user: user ?? this.user,
|
||||
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
|
||||
needsParentalConsent:
|
||||
needsParentalConsent ?? this.needsParentalConsent,
|
||||
needsClassCode: needsClassCode ?? this.needsClassCode,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
classCodeError: classCodeError,
|
||||
selectedRole: selectedRole ?? this.selectedRole,
|
||||
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
254
app/lib/features/auth/views/parental_consent_page.dart
Normal file
254
app/lib/features/auth/views/parental_consent_page.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
// 家长同意确认页面 — PIPL 第28条合规
|
||||
//
|
||||
// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。
|
||||
// 页面展示隐私政策要点,要求家长勾选同意并确认。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 家长同意确认页面
|
||||
class ParentalConsentPage extends StatefulWidget {
|
||||
const ParentalConsentPage({super.key});
|
||||
|
||||
@override
|
||||
State<ParentalConsentPage> createState() => _ParentalConsentPageState();
|
||||
}
|
||||
|
||||
class _ParentalConsentPageState extends State<ParentalConsentPage> {
|
||||
bool _consentGiven = false;
|
||||
bool _privacyPolicyAccepted = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canProceed = _consentGiven && _privacyPolicyAccepted;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('家长/监护人确认'),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Icon(
|
||||
Icons.shield_rounded,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text(
|
||||
'儿童个人信息保护',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
'根据《中华人民共和国个人信息保护法》第28条,'
|
||||
'未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 信息收集说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: '我们会收集哪些信息',
|
||||
items: const [
|
||||
'昵称和年级(不收集真实姓名和身份证号)',
|
||||
'日记内容和手写笔画',
|
||||
'心情标签和照片',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
// 用途说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.security_rounded,
|
||||
title: '信息如何保护',
|
||||
items: const [
|
||||
'所有数据加密存储和传输',
|
||||
'仅用于日记记录和班级互动',
|
||||
'不会用于商业广告或分享给第三方',
|
||||
'您可以随时查阅、更正或删除孩子数据',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 同意复选框
|
||||
_buildCheckbox(
|
||||
value: _privacyPolicyAccepted,
|
||||
onChanged: (v) =>
|
||||
setState(() => _privacyPolicyAccepted = v ?? false),
|
||||
text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing4),
|
||||
|
||||
_buildCheckbox(
|
||||
value: _consentGiven,
|
||||
onChanged: (v) => setState(() => _consentGiven = v ?? false),
|
||||
text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing32),
|
||||
|
||||
// 确认按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: canProceed ? _onConfirm : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('确认同意,继续'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
|
||||
// 拒绝按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('不同意,返回'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<String> items,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: DesignTokens.spacing4,
|
||||
left: DesignTokens.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckbox({
|
||||
required bool value,
|
||||
required ValueChanged<bool?> onChanged,
|
||||
required String text,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: () => onChanged(!value),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing4,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing4),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 确认同意 — 发出事件继续注册流程
|
||||
void _onConfirm() {
|
||||
final consentAt = DateTime.now();
|
||||
context.read<AuthBloc>().add(ParentalConsentAccepted(consentAt));
|
||||
}
|
||||
}
|
||||
@@ -61,10 +61,12 @@ fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
token.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
format!("{:016x}", u64::from_be_bytes(
|
||||
hasher.finalize().as_slice()[0..8].try_into().unwrap_or([0u8; 8])
|
||||
))
|
||||
}
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
|
||||
|
||||
use axum::extract::{Extension, FromRef, Query, State};
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
@@ -309,6 +309,124 @@ where
|
||||
}))
|
||||
}
|
||||
|
||||
/// 确认绑定请求的路径参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct BindingIdPath {
|
||||
/// 绑定请求 ID
|
||||
pub binding_id: Uuid,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/parent/pending",
|
||||
responses(
|
||||
(status = 200, description = "待确认绑定列表", body = ApiResponse<Vec<BindingResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// GET /api/v1/diary/parent/pending
|
||||
///
|
||||
/// 孩子查看自己的待确认绑定请求列表。
|
||||
pub async fn list_pending_bindings<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<BindingResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let bindings =
|
||||
ParentService::list_pending_for_child(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.parent_id, // 对于孩子端,显示家长 ID
|
||||
verified_at: b.verified_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/diary/parent/bindings/{binding_id}/confirm",
|
||||
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
|
||||
responses(
|
||||
(status = 200, description = "确认成功", body = ApiResponse<BindingResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "无权确认此绑定"),
|
||||
(status = 404, description = "绑定请求不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// POST /api/v1/diary/parent/bindings/:binding_id/confirm
|
||||
///
|
||||
/// 孩子确认家长绑定请求。确认后家长获得查看日记等权限。
|
||||
pub async fn confirm_binding<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(binding_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<BindingResp>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let binding = ParentService::confirm_binding(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
binding_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BindingResp {
|
||||
binding_id: binding.id,
|
||||
child_id: binding.parent_id,
|
||||
verified_at: binding.verified_at,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/diary/parent/bindings/{binding_id}/reject",
|
||||
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
|
||||
responses(
|
||||
(status = 200, description = "拒绝成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "无权拒绝此绑定"),
|
||||
(status = 404, description = "绑定请求不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// POST /api/v1/diary/parent/bindings/:binding_id/reject
|
||||
///
|
||||
/// 孩子拒绝家长绑定请求。
|
||||
pub async fn reject_binding<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(binding_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
ParentService::reject_binding(ctx.tenant_id, ctx.user_id, binding_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 逻辑一致,
|
||||
|
||||
@@ -255,5 +255,18 @@ impl DiaryModule {
|
||||
"/diary/parent/unbind",
|
||||
axum::routing::delete(parent_handler::unbind_child),
|
||||
)
|
||||
// 孩子确认/拒绝绑定
|
||||
.route(
|
||||
"/diary/parent/pending",
|
||||
axum::routing::get(parent_handler::list_pending_bindings),
|
||||
)
|
||||
.route(
|
||||
"/diary/parent/bindings/{binding_id}/confirm",
|
||||
axum::routing::post(parent_handler::confirm_binding),
|
||||
)
|
||||
.route(
|
||||
"/diary/parent/bindings/{binding_id}/reject",
|
||||
axum::routing::post(parent_handler::reject_binding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,20 +520,21 @@ impl ClassService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 6 位班级码(UUID 前 6 位字符)
|
||||
/// 生成 6 位字母数字混合班级码(62^6 ≈ 568 亿种组合)
|
||||
///
|
||||
/// CLAUDE.md 要求"6 位字母数字混合(62^6 ≈ 568 亿种组合)"。
|
||||
/// 使用 UUID v7 后 8 字节(随机部分)作为熵源,映射到 [0-9A-Za-z] 字符集。
|
||||
/// 前 8 字节含时间戳,同毫秒内重复概率高,因此只用后 8 字节。
|
||||
fn generate_class_code() -> String {
|
||||
// UUID v7 毫秒级时间戳前缀在紧凑循环中可能重复
|
||||
// 取后 6 位(随机部分)而非前 6 位(时间戳部分)
|
||||
Uuid::now_v7()
|
||||
.to_string()
|
||||
.replace("-", "")
|
||||
.chars()
|
||||
.rev()
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
const CHARSET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let bytes = *Uuid::now_v7().as_bytes();
|
||||
let mut code = String::with_capacity(6);
|
||||
// UUID v7: bytes[0..8] = 时间戳, bytes[8..16] = 随机部分
|
||||
// 从随机部分取 6 个字节,避免同毫秒碰撞
|
||||
for i in 0..6 {
|
||||
code.push(CHARSET[(bytes[8 + i] as usize) % CHARSET.len()] as char);
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// school_class::Model -> ClassResp
|
||||
|
||||
@@ -16,10 +16,10 @@ use erp_core::events::{DomainEvent, EventBus};
|
||||
pub struct ParentService;
|
||||
|
||||
impl ParentService {
|
||||
/// 绑定孩子 — 家长通过孩子用户 ID 建立绑定关系
|
||||
/// 绑定孩子 — 家长发起绑定请求(需要孩子确认)
|
||||
///
|
||||
/// 检查是否已存在有效绑定,避免重复绑定。
|
||||
/// 插入后发布 `diary.parent.child_bound` 事件。
|
||||
/// 创建 pending 状态的绑定记录,孩子需调用 confirm_binding 确认后
|
||||
/// 才能获得 verified 状态。防止未授权绑定(审计 S-10)。
|
||||
pub async fn bind_child(
|
||||
tenant_id: Uuid,
|
||||
parent_id: Uuid,
|
||||
@@ -27,30 +27,38 @@ impl ParentService {
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> DiaryResult<parent_child_binding::Model> {
|
||||
// 检查是否已绑定
|
||||
// 验证孩子用户存在且角色为 student
|
||||
let child_exists = validate_child_user(tenant_id, child_id, db).await?;
|
||||
if !child_exists {
|
||||
return Err(DiaryError::BadRequest("目标用户不存在或不是学生角色".to_string()));
|
||||
}
|
||||
|
||||
// 检查是否已存在有效绑定(pending 或 verified 均视为已绑定)
|
||||
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"))
|
||||
.filter(parent_child_binding::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(DiaryError::BadRequest("已绑定该孩子".to_string()));
|
||||
return Err(DiaryError::BadRequest("已存在绑定关系(待确认或已生效)".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
// 创建 pending 状态 — 需要孩子确认后才变为 verified
|
||||
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()),
|
||||
verification_method: Set("child_confirm".to_string()),
|
||||
verified_at: Set(None), // 确认后填入
|
||||
status: Set("pending".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(parent_id),
|
||||
@@ -64,11 +72,12 @@ impl ParentService {
|
||||
event_bus
|
||||
.publish(
|
||||
DomainEvent::new(
|
||||
"diary.parent.child_bound",
|
||||
"diary.parent.binding_requested",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"parent_id": parent_id,
|
||||
"child_id": child_id,
|
||||
"binding_id": id,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
@@ -78,6 +87,114 @@ impl ParentService {
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// 孩子确认绑定 — 将 pending 状态变为 verified
|
||||
///
|
||||
/// 只有绑定中的 child_id 本人才能确认。确认后家长获得
|
||||
/// 查看孩子日记/导出数据/删除数据的权限。
|
||||
pub async fn confirm_binding(
|
||||
tenant_id: Uuid,
|
||||
child_id: Uuid,
|
||||
binding_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> DiaryResult<parent_child_binding::Model> {
|
||||
let binding = parent_child_binding::Entity::find()
|
||||
.filter(parent_child_binding::Column::Id.eq(binding_id))
|
||||
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
|
||||
.filter(parent_child_binding::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound("绑定请求不存在".to_string()))?;
|
||||
|
||||
// 只有绑定目标孩子能确认
|
||||
if binding.child_id != child_id {
|
||||
return Err(DiaryError::Forbidden);
|
||||
}
|
||||
|
||||
if binding.status != "pending" {
|
||||
return Err(DiaryError::BadRequest("绑定请求不在待确认状态".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let current_version = binding.version;
|
||||
let mut active: parent_child_binding::ActiveModel = binding.into();
|
||||
active.status = Set("verified".to_string());
|
||||
active.verified_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(child_id);
|
||||
active.version = Set(current_version + 1);
|
||||
let updated = active.update(db).await?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
DomainEvent::new(
|
||||
"diary.parent.binding_confirmed",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"parent_id": updated.parent_id,
|
||||
"child_id": child_id,
|
||||
"binding_id": binding_id,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 孩子拒绝绑定 — 将 pending 状态变为 revoked
|
||||
pub async fn reject_binding(
|
||||
tenant_id: Uuid,
|
||||
child_id: Uuid,
|
||||
binding_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<()> {
|
||||
let binding = parent_child_binding::Entity::find()
|
||||
.filter(parent_child_binding::Column::Id.eq(binding_id))
|
||||
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
|
||||
.filter(parent_child_binding::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound("绑定请求不存在".to_string()))?;
|
||||
|
||||
if binding.child_id != child_id {
|
||||
return Err(DiaryError::Forbidden);
|
||||
}
|
||||
|
||||
if binding.status != "pending" {
|
||||
return Err(DiaryError::BadRequest("绑定请求不在待确认状态".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let current_version = binding.version;
|
||||
let mut active: parent_child_binding::ActiveModel = binding.into();
|
||||
active.status = Set("revoked".to_string());
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(child_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active.update(db).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取孩子的待确认绑定列表
|
||||
pub async fn list_pending_for_child(
|
||||
tenant_id: Uuid,
|
||||
child_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::ChildId.eq(child_id))
|
||||
.filter(parent_child_binding::Column::Status.eq("pending"))
|
||||
.filter(parent_child_binding::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(bindings)
|
||||
}
|
||||
|
||||
/// 获取家长绑定的孩子列表
|
||||
///
|
||||
/// 只返回 status=verified 且未软删除的绑定。
|
||||
@@ -267,6 +384,28 @@ impl ParentService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证目标用户存在且角色为 student
|
||||
async fn validate_child_user(
|
||||
tenant_id: Uuid,
|
||||
child_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<bool> {
|
||||
use erp_auth::entity::{role, user_role};
|
||||
|
||||
// 检查用户存在且有 student 角色
|
||||
let count = user_role::Entity::find()
|
||||
.filter(user_role::Column::UserId.eq(child_id))
|
||||
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_role::Column::DeletedAt.is_null())
|
||||
.inner_join(role::Entity)
|
||||
.filter(role::Column::Code.eq("student"))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user