/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0, /// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException] /// 以便契约漂移在测试期暴露而非静默吞掉)。 library; export 'package:patbond_flutter/core/models/cursor_page.dart'; T _enumFromJson(List 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 恒 public(followers/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'); } /// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。 enum MediaPurpose { postImage('post_image'); 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 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 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 至多一个 true;caption 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 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? media; Map 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? media; Map 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 json) { return Post( id: json['id'] as String, author: AuthorSummary.fromJson(json['author'] as Map), 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)) .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 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 json) { final cover = json['coverImage']; return FeedCard( id: json['id'] as String, author: AuthorSummary.fromJson(json['author'] as Map), 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), 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~2000;replyToUserId 可选 @ 回复目标 /// (单层平铺,无楼中楼)。 class CreateCommentRequest { const CreateCommentRequest({required this.content, this.replyToUserId}); final String content; final String? replyToUserId; Map 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 json) { final replyTo = json['replyToUser']; return PostComment( id: json['id'] as String, postId: json['postId'] as String, author: AuthorSummary.fromJson(json['author'] as Map), // @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。 replyToUser: replyTo == null ? null : AuthorSummary.fromJson(replyTo as Map), 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|webp,byteSize ≤ 10485760(10 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 toJson() => { 'kind': kind.name, 'purpose': purpose.wire, 'mimeType': mimeType, 'byteSize': byteSize, if (sha256 != null) 'sha256': sha256, }; } /// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后 /// 重新创建上传;直传必须原样携带 requiredHeaders(Content-Type 已签进 /// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。 class MediaUploadCredentials { const MediaUploadCredentials({ required this.assetId, required this.uploadUrl, required this.method, required this.requiredHeaders, required this.expiresAt, }); factory MediaUploadCredentials.fromJson(Map json) { return MediaUploadCredentials( assetId: json['assetId'] as String, uploadUrl: json['uploadUrl'] as String, method: json['method'] as String, requiredHeaders: (json['requiredHeaders'] as Map).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 requiredHeaders; final DateTime expiresAt; } /// media asset(confirm 后的可引用形态)。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 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 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 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 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 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; }