Files
patbond-flutter/lib/features/auth/auth_models.dart
T
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

126 lines
4.6 KiB
Dart
Raw 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.
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
library;
import 'package:patbond_flutter/core/models/patch_field.dart';
export 'package:patbond_flutter/core/models/patch_field.dart';
class AuthTokens {
const AuthTokens({
required this.userId,
required this.tokenType,
required this.accessToken,
required this.accessTokenExpiresAt,
required this.refreshToken,
required this.refreshTokenExpiresAt,
});
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
userId: json['userId'] as String,
tokenType: json['tokenType'] as String,
accessToken: json['accessToken'] as String,
accessTokenExpiresAt: DateTime.parse(
json['accessTokenExpiresAt'] as String,
),
refreshToken: json['refreshToken'] as String,
refreshTokenExpiresAt: DateTime.parse(
json['refreshTokenExpiresAt'] as String,
),
);
}
final String userId;
final String tokenType;
final String accessToken;
final DateTime accessTokenExpiresAt;
final String refreshToken;
final DateTime refreshTokenExpiresAt;
}
/// `GET` / `PATCH /api/v1/me` 的用户资料(契约 v1.4.0 恰好这 6 个字段)。
///
/// **不含 `avatarAssetId`**:客户端对头像 asset 只写不读,「有头像」等价于
/// [avatarUrl] 非 null。
class UserProfile {
const UserProfile({
required this.userId,
required this.username,
required this.nickname,
required this.avatarUrl,
required this.phone,
required this.createdAt,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
userId: json['userId'] as String,
username: json['username'] as String,
// DB 原值:服务端**刻意不做 username 回退**v1.4.0 定型,理由见
// [displayName]),未设置即 null。
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签;不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
// 服务端可返回 null(历史数据或未来第三方注册),不得非空强转。
phone: json['phone'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String userId;
final String username;
/// 昵称 DB 原值,未设置为 null。**编辑态预填只能用它**,不能用
/// [displayName]——否则保存时会把展示回退值固化成真实昵称。
final String? nickname;
/// 头像预签名 GET URL(会过期,禁止入本地存储);无头像 / asset 非
/// ready / 对象存储未配置三种情况均为 null。
final String? avatarUrl;
final String? phone;
final DateTime createdAt;
/// 本人视角的展示名:`nickname ?? username`。
///
/// **回退刻意做在客户端展示层**:服务端 `/me` 返回 DB 原值不回退(契约
/// v1.4.0 §Me),因为 `/me` 是本人编辑态——若服务端回退,编辑页会把
/// `llx` 预填进昵称框,用户误以为设过昵称,下次保存即把这个展示约定
/// **固化成真实数据**`/internal/users/profiles` 的 SQL 回退链从此再不
/// 触发。他人视角(Feed 作者名)的回退由服务端 SQL 层承担,客户端不重复。
String get displayName => nickname ?? username;
/// 是否已有头像(响应不回显 avatarAssetId,判定只看 URL 是否现签成功)。
bool get hasAvatar => avatarUrl != null;
}
/// `PATCH /api/v1/me` 请求体(契约 v1.4.0**三态语义**
/// 键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置)。
///
/// 两字段都 absent 即**空 patch**——服务端答 400/40000 而非静默 200
/// 故调用前应以 [isEmpty] 短路(无变更不发请求)。
///
/// 昵称的服务端校验:btrim 后长度按**码点**计 1~32;纯空白或空串是
/// 400/40000 而**不是**隐式清空(清空只走 [PatchField.clear] 一条路)。
class UpdateMeRequest {
const UpdateMeRequest({
this.nickname = const PatchField<String>.absent(),
this.avatarAssetId = const PatchField<String>.absent(),
});
final PatchField<String> nickname;
/// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。
final PatchField<String> avatarAssetId;
/// 本次 patch 未触及任何字段(发出去必得 400/40000)。
bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent;
Map<String, Object?> toJson() {
final json = <String, Object?>{};
nickname.writeTo(json, 'nickname');
avatarAssetId.writeTo(json, 'avatarAssetId');
return json;
}
}