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

658 lines
20 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.
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
export 'package:patbond_flutter/core/models/cursor_page.dart';
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
for (final value in values) {
if (value.name == raw) return value;
}
throw FormatException('未知的 $field 取值:$raw');
}
DateTime? _dateTimeOrNull(Object? value) =>
value == null ? null : DateTime.parse(value as String);
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
enum PostCategory {
general('general'),
help('help'),
aiCreation('ai_creation');
const PostCategory(this.wire);
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
final String wire;
static PostCategory fromJson(String value) {
for (final category in values) {
if (category.wire == value) return category;
}
throw FormatException('未知的 category 取值:$value');
}
}
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
/// 404/40403),枚举保持两值。
enum PostStatus {
draft,
published;
static PostStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 帖子可见性。M3 恒 publicfollowers/private 语义后置,字段保留)。
enum PostVisibility {
public;
static PostVisibility fromJson(String value) =>
_enumFromJson(values, value, 'visibility');
}
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
enum MediaKind {
image;
static MediaKind fromJson(String value) =>
_enumFromJson(values, value, 'kind');
}
/// 上传用途白名单(契约 v1.4.0 三值;决定 objectKey 前缀)。
///
/// **用途即引用侧的类型检查**:引用时服务端校验 purpose 相符,故帖图不能
/// 当头像、两种头像也互不通用(不符者 404/40405)。
enum MediaPurpose {
postImage('post_image'),
userAvatar('user_avatar'),
petAvatar('pet_avatar');
const MediaPurpose(this.wire);
final String wire;
}
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
enum MediaAssetStatus {
uploading,
ready,
failed;
static MediaAssetStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
class AuthorSummary {
const AuthorSummary({
required this.userId,
required this.nickname,
required this.avatarUrl,
});
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
return AuthorSummary(
userId: json['userId'] as String,
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
);
}
final String userId;
final String? nickname;
final String? avatarUrl;
/// 降级/墓碑形态(id-only):页面渲染统一占位。
bool get isDegraded => nickname == null && avatarUrl == null;
}
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
/// 每次响应现签,客户端不得持久化、过期即重取。
class PostMediaItem {
const PostMediaItem({
required this.assetId,
required this.position,
required this.isCover,
required this.url,
required this.widthPx,
required this.heightPx,
required this.caption,
});
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
return PostMediaItem(
assetId: json['assetId'] as String,
position: json['position'] as int,
isCover: json['isCover'] as bool,
url: json['url'] as String,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
caption: json['caption'] as String?,
);
}
final String assetId;
final int position;
final bool isCover;
final String url;
final int? widthPx;
final int? heightPx;
final String? caption;
}
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
/// 连续不重复,混合 400/40000);isCover 至多一个 truecaption trim 后 ≤300。
class PostMediaAttachRequest {
const PostMediaAttachRequest({
required this.assetId,
this.position,
this.isCover,
this.caption,
});
final String assetId;
final int? position;
final bool? isCover;
final String? caption;
Map<String, Object?> toJson() => {
'assetId': assetId,
if (position != null) 'position': position,
if (isCover != null) 'isCover': isCover,
if (caption != null) 'caption': caption,
};
}
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
/// status=published 即创建即发布(服务端写 publishedAt)。
class CreatePostRequest {
const CreatePostRequest({
required this.content,
this.title,
this.category,
this.status,
this.petId,
this.media,
});
final String content;
final String? title;
final PostCategory? category;
final PostStatus? status;
final String? petId;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'content': content,
if (title != null) 'title': title,
if (category != null) 'category': category!.wire,
if (status != null) 'status': status!.name,
if (petId != null) 'petId': petId,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
/// version 乐观锁必带)。
///
/// - [publish]`status: published` 状态迁移(draft→published 唯一开放迁移;
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
class UpdatePostRequest {
const UpdatePostRequest({
required this.version,
this.title,
this.content,
this.category,
this.petId,
this.publish = false,
this.media,
});
final int version;
final String? title;
final String? content;
final PostCategory? category;
final String? petId;
final bool publish;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'version': version,
if (title != null) 'title': title,
if (content != null) 'content': content,
if (category != null) 'category': category!.wire,
if (petId != null) 'petId': petId,
if (publish) 'status': PostStatus.published.name,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
class Post {
const Post({
required this.id,
required this.author,
required this.petId,
required this.category,
required this.title,
required this.content,
required this.status,
required this.visibility,
required this.media,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.createdAt,
required this.updatedAt,
required this.publishedAt,
required this.version,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
petId: json['petId'] as String?,
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
content: json['content'] as String,
status: PostStatus.fromJson(json['status'] as String),
visibility: PostVisibility.fromJson(json['visibility'] as String),
media: (json['media'] as List)
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
.toList(),
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
// 仅 published 非空(发布时恰写一次)。
publishedAt: _dateTimeOrNull(json['publishedAt']),
version: json['version'] as int,
);
}
final String id;
final AuthorSummary author;
final String? petId;
final PostCategory category;
final String? title;
final String content;
final PostStatus status;
final PostVisibility visibility;
final List<PostMediaItem> media;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? publishedAt;
final int version;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
Post copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return Post(
id: id,
author: author,
petId: petId,
category: category,
title: title,
content: content,
status: status,
visibility: visibility,
media: media,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
createdAt: createdAt,
updatedAt: updatedAt,
publishedAt: publishedAt,
version: version,
);
}
}
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
class FeedCard {
const FeedCard({
required this.id,
required this.author,
required this.category,
required this.title,
required this.contentPreview,
required this.coverImage,
required this.mediaCount,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.publishedAt,
});
factory FeedCard.fromJson(Map<String, dynamic> json) {
final cover = json['coverImage'];
return FeedCard(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
contentPreview: json['contentPreview'] as String,
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
coverImage: cover == null
? null
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
mediaCount: json['mediaCount'] as int,
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
publishedAt: DateTime.parse(json['publishedAt'] as String),
);
}
final String id;
final AuthorSummary author;
final PostCategory category;
final String? title;
final String contentPreview;
final PostMediaItem? coverImage;
final int mediaCount;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime publishedAt;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
FeedCard copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return FeedCard(
id: id,
author: author,
category: category,
title: title,
contentPreview: contentPreview,
coverImage: coverImage,
mediaCount: mediaCount,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
publishedAt: publishedAt,
);
}
}
/// 创建评论请求。content trim 后 1~2000replyToUserId 可选 @ 回复目标
/// (单层平铺,无楼中楼)。
class CreateCommentRequest {
const CreateCommentRequest({required this.content, this.replyToUserId});
final String content;
final String? replyToUserId;
Map<String, Object?> toJson() => {
'content': content,
if (replyToUserId != null) 'replyToUserId': replyToUserId,
};
}
/// 评论(M3 无评论编辑,不带 updatedAt)。
class PostComment {
const PostComment({
required this.id,
required this.postId,
required this.author,
required this.replyToUser,
required this.content,
required this.createdAt,
});
factory PostComment.fromJson(Map<String, dynamic> json) {
final replyTo = json['replyToUser'];
return PostComment(
id: json['id'] as String,
postId: json['postId'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
replyToUser: replyTo == null
? null
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
content: json['content'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String postId;
final AuthorSummary author;
final AuthorSummary? replyToUser;
final String content;
final DateTime createdAt;
}
/// 创建上传请求(两步上传第一步)。mimeType 白名单
/// image/jpeg|png|webpbyteSize ≤ 1048576010 MiB,服务端配置项);
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
class CreateMediaUploadRequest {
const CreateMediaUploadRequest({
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
this.sha256,
});
final MediaKind kind;
final MediaPurpose purpose;
final String mimeType;
final int byteSize;
final String? sha256;
Map<String, Object?> toJson() => {
'kind': kind.name,
'purpose': purpose.wire,
'mimeType': mimeType,
'byteSize': byteSize,
if (sha256 != null) 'sha256': sha256,
};
}
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
/// 重新创建上传;直传必须原样携带 requiredHeadersContent-Type 已签进
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
class MediaUploadCredentials {
const MediaUploadCredentials({
required this.assetId,
required this.uploadUrl,
required this.method,
required this.requiredHeaders,
required this.expiresAt,
});
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
return MediaUploadCredentials(
assetId: json['assetId'] as String,
uploadUrl: json['uploadUrl'] as String,
method: json['method'] as String,
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as String),
),
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
}
final String assetId;
final String uploadUrl;
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
final String method;
final Map<String, String> requiredHeaders;
final DateTime expiresAt;
}
/// media assetconfirm 后的可引用形态)。url 仅 ready 态非空——时效性
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
class MediaAsset {
const MediaAsset({
required this.id,
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
required this.widthPx,
required this.heightPx,
required this.status,
required this.url,
required this.readyAt,
required this.createdAt,
});
factory MediaAsset.fromJson(Map<String, dynamic> json) {
return MediaAsset(
id: json['id'] as String,
kind: MediaKind.fromJson(json['kind'] as String),
purpose: json['purpose'] as String,
mimeType: json['mimeType'] as String,
byteSize: json['byteSize'] as int?,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
status: MediaAssetStatus.fromJson(json['status'] as String),
url: json['url'] as String?,
readyAt: _dateTimeOrNull(json['readyAt']),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final MediaKind kind;
final String purpose;
final String mimeType;
final int? byteSize;
final int? widthPx;
final int? heightPx;
final MediaAssetStatus status;
final String? url;
final DateTime? readyAt;
final DateTime createdAt;
}
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
class LikeState {
const LikeState({required this.liked, required this.likeCount});
factory LikeState.fromJson(Map<String, dynamic> json) {
return LikeState(
liked: json['liked'] as bool,
likeCount: json['likeCount'] as int,
);
}
final bool liked;
final int likeCount;
}
/// 收藏权威终态(与点赞同构)。
class BookmarkState {
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
factory BookmarkState.fromJson(Map<String, dynamic> json) {
return BookmarkState(
bookmarked: json['bookmarked'] as bool,
bookmarkCount: json['bookmarkCount'] as int,
);
}
final bool bookmarked;
final int bookmarkCount;
}
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
class FollowState {
const FollowState({required this.following, required this.followerCount});
factory FollowState.fromJson(Map<String, dynamic> json) {
return FollowState(
following: json['following'] as bool,
followerCount: json['followerCount'] as int,
);
}
final bool following;
final int followerCount;
}
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
class FollowStats {
const FollowStats({
required this.followerCount,
required this.followingCount,
required this.followedByMe,
});
factory FollowStats.fromJson(Map<String, dynamic> json) {
return FollowStats(
followerCount: json['followerCount'] as int,
followingCount: json['followingCount'] as int,
followedByMe: json['followedByMe'] as bool,
);
}
final int followerCount;
final int followingCount;
final bool followedByMe;
}
/// 我的社区数字(`GET /api/v1/me/community-stats`,契约 v1.4.0 新增)。
///
/// 两数同一集合:本人的、已发布的、未软删的帖(草稿 / 软删 / hidden /
/// archived 均不计;自己赞自己**计入**,与帖子详情的 likeCount 同口径)。
/// 空数据为 `0` 而非 null,且该端点**永不 404**——任何已认证用户都有 stats。
class CommunityStats {
const CommunityStats({
required this.receivedLikeCount,
required this.publishedPostCount,
});
factory CommunityStats.fromJson(Map<String, dynamic> json) {
return CommunityStats(
receivedLikeCount: json['receivedLikeCount'] as int,
publishedPostCount: json['publishedPostCount'] as int,
);
}
final int receivedLikeCount;
final int publishedPostCount;
}