新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射 (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等), 未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用 - community_repository:13 路径 19 操作全覆盖;createPost/createComment 必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/ 关注走 PUT/DELETE 语义幂等 - community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射 - toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫, 点赞/收藏共用一套参数化状态机,权威终态对账收敛 - community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 + 游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态 - app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher, 登出同步 reset - 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
/// 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 恒 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<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 至多一个 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<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~2000;replyToUserId 可选 @ 回复目标
|
||||
/// (单层平铺,无楼中楼)。
|
||||
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|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<String, Object?> 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<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 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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user