a4a97c03c2
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,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>
126 lines
4.6 KiB
Dart
126 lines
4.6 KiB
Dart
/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。
|
||
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;
|
||
}
|
||
}
|