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

138 lines
4.9 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:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/auth/auth_repository.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
/// 资料主链路四态(沿 pets/community 两域惯例)。
enum ProfileLoadPhase { initial, loading, ready, error }
/// 统计块的独立三态:`/me` 成功而统计失败时**只降级这一块**,不把整页
/// 打成 error——昵称与头像已经拿到了,为两个数字丢掉整页是过度反应。
enum ProfileStatsPhase { loading, ready, error }
/// 「我的资料」状态控制器(T3.5-08)。
///
/// 数据源三处:
/// - `GET /api/v1/me` → 昵称 / 头像(主链路,失败即页面 error 态)
/// - `GET /api/v1/me/community-stats` → 获赞总数 / 作品数
/// - `GET /api/v1/users/{me}/follow-stats` → 粉丝数 / 关注数
///
/// 首页问候语(T3.5-10)与资料页共用本控制器的 [displayName]
/// 保证两处展示名同源、改昵称后一起变(一次 `/me` 供两个消费点)。
class ProfileController extends ChangeNotifier {
ProfileController({
required AuthRepository authRepository,
required CommunityRepository communityRepository,
}) : _auth = authRepository,
_community = communityRepository;
final AuthRepository _auth;
final CommunityRepository _community;
ProfileLoadPhase _phase = ProfileLoadPhase.initial;
ProfileStatsPhase _statsPhase = ProfileStatsPhase.loading;
UserProfile? _profile;
CommunityStats? _communityStats;
FollowStats? _followStats;
ApiException? _lastError;
bool _disposed = false;
ProfileLoadPhase get phase => _phase;
ProfileStatsPhase get statsPhase => _statsPhase;
/// 本人资料(ready 态非 null)。
UserProfile? get profile => _profile;
CommunityStats? get communityStats => _communityStats;
FollowStats? get followStats => _followStats;
ApiException? get lastError => _lastError;
/// 展示名 `nickname ?? username`;资料未到手时为 null(调用方不造假名)。
String? get displayName => _profile?.displayName;
/// 加载 / 重试。主链路错误收敛为 error 态供页面渲染 + 重试,不外抛。
///
/// 已有资料副本时刷新失败**保留副本**(`_phase` 停在 ready):与详情页
/// 「有副本即不打断阅读」的既有取舍一致。
Future<void> refresh() async {
if (_profile == null) {
_phase = ProfileLoadPhase.loading;
}
_lastError = null;
_notify();
try {
_profile = await _auth.me();
_phase = ProfileLoadPhase.ready;
} on ApiException catch (error) {
_lastError = error;
if (_profile == null) {
_phase = ProfileLoadPhase.error;
_notify();
return;
}
}
_notify();
await refreshStats();
}
/// 只重取两块统计(统计块的「重试」入口;主链路资料不动)。
Future<void> refreshStats() async {
final userId = _profile?.userId;
if (userId == null) return;
_statsPhase = ProfileStatsPhase.loading;
_notify();
try {
// 顺序取(同一服务、量级极小):任一失败即整块 error。两个数字并列
// 在同一张卡上,只有一半是数字、另一半是「—」比整块「加载失败 +
// 重试」更费解。
final community = await _community.getMyCommunityStats();
final follow = await _community.getFollowStats(userId);
_communityStats = community;
_followStats = follow;
_statsPhase = ProfileStatsPhase.ready;
} on ApiException {
_statsPhase = ProfileStatsPhase.error;
}
_notify();
}
/// 提交资料变更(三态语义见 [UpdateMeRequest])。
///
/// **空 patch 直接短路不发请求**:服务端对空 patch 答 400/40000(刻意不
/// 静默 200),客户端没有理由去撞这一枪。
/// 成功即以服务端回显的全量资料替换本地副本(`avatarUrl` 现签,
/// 不做本地拼装)。失败按类型化异常外抛给编辑页分层呈现。
Future<UserProfile?> save(UpdateMeRequest request) async {
if (request.isEmpty) return _profile;
final updated = await _auth.updateMe(request);
_profile = updated;
_phase = ProfileLoadPhase.ready;
_notify();
return updated;
}
/// 登出清空:回 initial 并清资料,避免上一账号昵称/头像跨会话泄漏
/// (沿 `PetsController.reset` 先例)。
void reset() {
_phase = ProfileLoadPhase.initial;
_statsPhase = ProfileStatsPhase.loading;
_profile = null;
_communityStats = null;
_followStats = null;
_lastError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}