/// 认证接口的响应模型(接口契约冻结稿,字段名与后端一致)。 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 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 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.absent(), this.avatarAssetId = const PatchField.absent(), }); final PatchField nickname; /// 头像 asset(两步上传产物,`purpose` 须为 `user_avatar`)。 final PatchField avatarAssetId; /// 本次 patch 未触及任何字段(发出去必得 400/40000)。 bool get isEmpty => !nickname.isPresent && !avatarAssetId.isPresent; Map toJson() { final json = {}; nickname.writeTo(json, 'nickname'); avatarAssetId.writeTo(json, 'avatarAssetId'); return json; } }