Files
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码
demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。

- `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落
  显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」
  也编码成 null,用户只改昵称就会连头像一起被服务端清掉。
- `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username`
  **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值
  预填,避免把展示约定固化成真实昵称。
- `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级
  这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。
- 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口,
  未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。
- 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐;
  纯空白是校验失败而非隐式清空。
- `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet`
  (单图、预览确认后才交付 ready assetId,孤儿防护不变)。
- 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository`
  此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被
  触发;本单是第一个真实消费者,桌面实测即暴露。

测试 526 → 588(+62)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:00 +08:00

172 lines
5.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:patbond_flutter/analytics/analytics_service.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:uuid/uuid.dart';
/// Splash 会话恢复的裁决结果。状态切换由 Splash 在最短停留时间后执行。
enum SessionRestoreResult { authenticated, noSession }
/// 认证仓库接口(页面依赖此抽象,widget 测试注入假实现)。
abstract class AuthRepository {
Future<void> login({required String username, required String password});
Future<void> register({
required String username,
required String phone,
required String password,
});
/// 撤销当前会话并清除本地凭证。服务端调用失败也保证本地清除。
Future<void> logout();
/// 启动恢复:本地有 refresh token 则静默刷新。
/// 网络失败抛 [ApiNetworkException]token 保留);
/// refresh 失效抛 [SessionExpiredException](会话已清)。
Future<SessionRestoreResult> restoreSession();
Future<UserProfile> me();
/// 本人资料部分更新(昵称 / 头像,三态语义见 [UpdateMeRequest])。
/// 成功返回**更新后的全量 [UserProfile]**(服务端回显,含现签 avatarUrl)。
Future<UserProfile> updateMe(UpdateMeRequest request);
}
/// 基于 [ApiClient] 的实现。
///
/// **端口线路**(ADR-002 无网关,分端口直连):注册/登录/登出/刷新走 auth
/// 服务(:8081);`GET` 与 `PATCH /api/v1/me` 由 **user 服务**:8082
/// `MeController`)提供,故经 [userApi] 直连——auth 上没有 `/api/v1/me`
/// 路由,走主客户端会得到 404。未提供 [userApi] 时回落主客户端(既有测试
/// 桩场景)。同一线路分离手法与 `ApiCommunityRepository` 的 `mediaApi` 一致。
class ApiAuthRepository implements AuthRepository {
ApiAuthRepository({
required ApiClient api,
required this._session,
required this._refresher,
ApiClient? userApi,
this._analytics,
this._uuid = const Uuid(),
}) : _api = api,
// 缺省回落主客户端:既有测试桩只注入一个 ApiClient。
_userApi = userApi ?? api;
final ApiClient _api;
final ApiClient _userApi;
final SessionManager _session;
final TokenRefresher _refresher;
final AnalyticsService? _analytics;
final Uuid _uuid;
@override
Future<void> login({
required String username,
required String password,
}) async {
final startTime = DateTime.now();
try {
final data = await _api.request(
'/api/v1/auth/login',
body: {'username': username, 'password': password},
);
await _session.saveSession(
AuthTokens.fromJson(data! as Map<String, dynamic>),
);
_analytics?.identify(_session.userId!);
_analytics?.trackEvent('auth_login_succeeded', {
'identifierType': 'username',
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
});
} catch (error) {
_analytics?.trackEvent('auth_login_failed', {
'identifierType': 'username',
'failureReason': error.toString(),
});
rethrow;
}
}
@override
Future<void> register({
required String username,
required String phone,
required String password,
}) async {
final startTime = DateTime.now();
try {
final data = await _api.request(
'/api/v1/auth/register',
body: {'username': username, 'phone': phone, 'password': password},
// 每次提交一个幂等键;token 刷新后的自动重放沿用同一个键。
headers: {'Idempotency-Key': _uuid.v4()},
);
await _session.saveSession(
AuthTokens.fromJson(data! as Map<String, dynamic>),
);
_analytics?.identify(_session.userId!);
_analytics?.trackEvent('auth_register_succeeded', {
'durationMs': DateTime.now().difference(startTime).inMilliseconds,
});
} catch (error) {
_analytics?.trackEvent('auth_register_failed', {
'failureReason': error.toString(),
});
rethrow;
}
}
@override
Future<void> logout() async {
final refreshToken = _session.refreshToken;
try {
await _api.request(
'/api/v1/auth/logout',
body: {'refreshToken': refreshToken},
requiresAuth: true,
);
} on ApiException {
// 服务端撤销失败不阻塞本地登出;refresh 侧最终会自然过期。
} finally {
await _session.clearSession();
_analytics?.reset();
_analytics?.trackEvent('auth_logout', {'serverRevoked': true});
}
}
@override
Future<SessionRestoreResult> restoreSession() async {
await _session.loadFromStorage();
if (_session.refreshToken == null) {
return SessionRestoreResult.noSession;
}
await _refresher.refresh();
return SessionRestoreResult.authenticated;
}
@override
Future<UserProfile> me() async {
final data = await _userApi.request(
'/api/v1/me',
method: 'GET',
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
@override
Future<UserProfile> updateMe(UpdateMeRequest request) async {
// 无乐观锁、无幂等键(契约定型:唯一合法写者 + 列级选择性 UPDATE,
// 同 body 重放天然幂等)。三态由 request.toJson 表达——「不改」的字段
// 根本不出现在 body 里,绝不发成 null。
final data = await _userApi.request(
'/api/v1/me',
method: 'PATCH',
body: request.toJson(),
requiresAuth: true,
);
return UserProfile.fromJson(data! as Map<String, dynamic>);
}
}