Compare commits

...

6 Commits

Author SHA1 Message Date
iven
99db8e5cb0 fix(app): 家长同意验证流程 — PIPL 第28条合规
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框
- 角色选择流程: 学生 → 家长同意确认 → 班级码输入
- Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole
- ParentalConsentAccepted 事件: 记录同意时间戳
- 路由守卫: 注册 /parental-consent 路径和重定向逻辑
- 非学生角色(老师/家长/独立用户)不需要经过同意流程

审计 ID: S-03
2026-06-03 10:25:23 +08:00
iven
a34c9fd176 fix(app): 强制 HTTPS — Android 网络安全配置 + 生产默认 HTTPS
- Android: 添加 network_security_config.xml,默认禁止明文流量
- Android: 仅允许 localhost/127.0.0.1/10.0.2.2 明文(开发调试)
- Android: 更新 AndroidManifest 引用网络安全配置
- ApiClient: 默认 URL 改为 https://api.nuanji.app/api/v1
- AppConfig: fromEnvironment 默认值改为 HTTPS 生产地址
- AppConfig: dev 常量保留 localhost(仅用于本地开发)
- iOS: ATS 默认已强制 HTTPS,无需修改

审计 ID: 6b-C01
2026-06-03 10:13:20 +08:00
iven
45949e3ed0 fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新
- ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新
- ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环)
- ApiClient: 刷新成功后自动重试原始请求
- AuthRepository: 注册 _handleAutoRefresh 回调
- 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖

审计 ID: 9a-AUTH-01
2026-06-03 10:07:33 +08:00
iven
c4b2de8294 fix(diary): 家长绑定改为两步验证 — 孩子确认后才生效
- bind_child: 创建 pending 状态绑定(不再自动 verified)
- validate_child_user: 验证目标用户存在且有 student 角色
- confirm_binding: 孩子确认后状态变为 verified,家长获得访问权限
- reject_binding: 孩子拒绝绑定请求
- list_pending_for_child: 孩子查看待确认绑定列表
- 新增 3 个 API 端点: /parent/pending, /bindings/{id}/confirm, /bindings/{id}/reject
- 防止未授权绑定(任何人不验证即可绑定孩子的漏洞)

审计 ID: S-10
2026-06-03 10:03:50 +08:00
iven
cca2d77ea2 fix(diary): 班级码改用字母数字混合 — 16^6 提升到 62^6(568 亿组合)
- 从 UUID hex 后 6 位(0-9a-f,16^6 ≈ 1677 万)改为字母数字混合
- 字符集: 0-9A-Za-z(62 字符,62^6 ≈ 568 亿)
- 使用 UUID v7 后 8 字节随机部分作为熵源,避免同毫秒碰撞
- 符合 CLAUDE.md 设计规格要求

审计 ID: 7b-C01
2026-06-03 09:56:24 +08:00
iven
6d7ac05d0f fix(auth): Token 黑名单改用 SHA-256 替代 SipHash
- access token 黑名单 hash 函数从 std::collections::DefaultHasher (SipHash)
  改为 sha2::Sha256,与 refresh token 存储一致
- SipHash 是非密码学 hash,理论上可被构造碰撞绕过黑名单检查
- SHA-256 提供密码学安全保证,且 sha2 已在 Cargo.toml 依赖中

审计 ID: S-01
2026-06-03 09:51:47 +08:00
15 changed files with 730 additions and 46 deletions

View File

@@ -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"

View 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>

View File

@@ -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 # 生产模式
//
// 安全说明:
// - 生产环境强制 HTTPSAndroid network_security_config 禁止明文流量)
// - 开发模式使用 localhostAndroid 网络安全配置已允许 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',

View File

@@ -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',

View File

@@ -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);

View File

@@ -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 字段

View File

@@ -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,
));
}
/// 班级码加入

View File

@@ -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;

View File

@@ -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,
);
}

View 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));
}
}

View File

@@ -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.

View File

@@ -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 逻辑一致,

View File

@@ -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),
)
}
}

View File

@@ -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

View File

@@ -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::*;