Files
frontend/src/data/repositories/PostRepository.ts
lafay 738e75a79d
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m43s
Frontend CI / ota-android (push) Successful in 10m32s
Frontend CI / build-android-apk (push) Successful in 47m43s
feat(ui): add emoji picker with FlatList virtualization and improve input components
- Add full emoji picker with virtualized FlatList to CreatePostScreen and PostDetailScreen
- Add autoExpand prop to PostMentionInput for XHS-style content area
- Improve mention item styling with @ icon and hint text
- Fix HomeScreen tab switching race condition with requestAnimationFrame
- Fix PostRepository pagination to prefer cursor over page parameter
- Fix user store imports to use explicit UserManager path
- Refactor useCurrentUser hook to use sessionStore directly
2026-04-24 16:44:01 +08:00

576 lines
16 KiB
TypeScript
Raw 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.
/**
* PostRepository - 帖子仓库实现
* 实现IPostRepository接口封装帖子相关的数据访问逻辑
*/
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { localDataSource } from '@/database';
import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post';
import type {
IPostRepository,
GetPostsParams,
CreatePostData,
UpdatePostData,
PostsResult,
SearchPostsParams,
} from './interfaces/IPostRepository';
// ==================== 类型定义 ====================
/**
* API帖子响应类型
*/
interface PostApiResponse {
id: string;
user_id: string;
title: string;
content: string;
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
likes_count: number;
comments_count: number;
shares_count: number;
views_count: number;
favorites_count: number;
is_liked: boolean;
is_favorited: boolean;
is_pinned: boolean;
status: string;
channel_id?: string;
tags?: string[];
created_at: string;
updated_at: string;
content_edited_at?: string;
channel?: { id: string; name: string } | null;
author?: {
id: string;
username: string;
nickname?: string;
avatar?: string;
is_following?: boolean;
is_following_me?: boolean;
};
}
/**
* API帖子列表响应类型
*/
interface PostsListApiResponse {
list: PostApiResponse[];
has_more?: boolean;
next_cursor?: string;
total?: number;
page?: number;
page_size?: number;
total_pages?: number;
}
/**
* 缓存的帖子数据
*/
interface CachedPost {
id: string;
data: string;
updatedAt: string;
}
// ==================== PostRepository 实现 ====================
export class PostRepository implements IPostRepository {
private api: ApiDataSource;
private localDb = localDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000;
constructor(api: ApiDataSource) {
this.api = api;
}
// ==================== 私有辅助方法 ====================
/**
* 将API响应转换为领域实体
* 同时设置 camelCase 和 snake_case 字段以保持向后兼容
*/
private mapToPost(response: PostApiResponse): Post {
const model = PostMapper.fromApiResponse(response as any);
return {
// camelCase 字段(新标准)
id: model.id,
authorId: model.authorId,
author: model.author ? {
id: model.author.id,
username: model.author.username,
nickname: model.author.nickname,
avatar: model.author.avatar,
// 关注关系直接透传 API 字段,供详情页关注按钮状态使用
is_following: response.author?.is_following,
is_following_me: response.author?.is_following_me,
} : undefined,
title: model.title,
content: model.content,
images: (model.images || []).map(url => ({
url,
width: undefined,
height: undefined,
})) as PostImage[],
likesCount: model.likeCount,
commentsCount: model.commentCount,
sharesCount: model.shareCount,
viewsCount: model.viewCount,
favoritesCount: model.favoriteCount,
isLiked: model.isLiked,
isFavorited: model.isFavorited,
isPinned: model.isTop,
status: model.status,
channelId: model.channelId,
channel: model.channel,
tags: model.tags || [],
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
contentEditedAt: response.content_edited_at,
// snake_case 字段(向后兼容)
likes_count: model.likeCount,
comments_count: model.commentCount,
favorites_count: model.favoriteCount,
shares_count: model.shareCount,
views_count: model.viewCount,
is_liked: model.isLiked,
is_favorited: model.isFavorited,
is_pinned: model.isTop,
user_id: model.authorId,
created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(),
content_edited_at: response.content_edited_at,
channel_id: model.channelId,
};
}
/**
* 从内存缓存获取帖子
*/
private getFromMemoryCache(id: string): Post | null {
const cached = this.memoryCache.get(id);
if (!cached) return null;
// 检查缓存是否过期
if (Date.now() - cached.timestamp > this.CACHE_TTL) {
this.memoryCache.delete(id);
return null;
}
return cached.post;
}
/**
* 保存帖子到内存缓存
*/
private saveToMemoryCache(post: Post): void {
this.memoryCache.set(post.id, {
post,
timestamp: Date.now(),
});
}
/**
* 从本地数据库获取缓存的帖子
*/
private async getFromLocalCache(id: string): Promise<Post | null> {
try {
const result = await this.localDb.getFirst<CachedPost>(
'SELECT * FROM posts_cache WHERE id = ?',
[id]
);
if (!result) return null;
const post = JSON.parse(result.data) as Post;
return post;
} catch (error) {
console.error('[PostRepository] 获取本地缓存失败:', error);
return null;
}
}
/**
* 保存帖子到本地缓存
*/
private async saveToLocalCache(post: Post): Promise<void> {
try {
await this.localDb.enqueueWrite(async () => {
await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[post.id, JSON.stringify(post), new Date().toISOString()]
);
});
} catch (error) {
console.error('[PostRepository] 保存本地缓存失败:', error);
}
}
/**
* 清除帖子的本地缓存
*/
private async clearLocalCache(id: string): Promise<void> {
try {
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
});
} catch (error) {
console.error('[PostRepository] 清除本地缓存失败:', error);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[PostRepository] ${operation} 失败:`, error);
throw new Error(`帖子${operation}失败: ${message}`);
}
// ==================== 帖子查询 ====================
/**
* 获取帖子列表
*/
async getPosts(params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {};
if (params?.pageSize) queryParams.page_size = params.pageSize;
// cursor 与 page 互斥cursor 存在(含空字符串)时走游标分页,否则走页码分页
if (params?.cursor !== undefined && params?.cursor !== null) {
queryParams.cursor = params.cursor;
} else if (params?.page !== undefined) {
queryParams.page = params.page;
}
if (params?.post_type) queryParams.tab = params.post_type;
if (params?.channelId) queryParams.channel_id = params.channelId;
if (params?.authorId) queryParams.author_id = params.authorId;
if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(',');
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
if (params?.pinnedOnly) queryParams.pinned_only = true;
const response = await this.api.get<PostsListApiResponse>('/posts', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
// 不等待本地缓存写入完成,避免阻塞 UI
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
// 计算 hasMore优先使用 has_more否则通过 page/total_pages 计算
let hasMore = response.has_more;
if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) {
hasMore = response.page < response.total_pages;
}
hasMore = hasMore ?? false;
return {
posts,
hasMore,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取帖子列表');
}
}
/**
* 获取单个帖子详情
*/
async getPostById(id: string): Promise<Post | null> {
try {
// 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期
const response = await this.api.get<PostApiResponse>(`/posts/${id}`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
// API失败时再回退缓存保证离线/弱网可用
const memoryCached = this.getFromMemoryCache(id);
if (memoryCached) {
console.warn('[PostRepository] API获取失败使用内存缓存:', error);
return memoryCached;
}
const localCached = await this.getFromLocalCache(id);
if (localCached) {
console.warn('[PostRepository] API获取失败使用本地缓存:', error);
this.saveToMemoryCache(localCached);
return localCached;
}
this.handleError(error, '获取帖子详情');
}
}
/**
* 获取用户发布的帖子列表
*/
async getPostsByUser(userId: string, params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = { author_id: userId };
if (params?.page) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize;
if (params?.cursor) queryParams.cursor = params.cursor;
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
const response = await this.api.get<PostsListApiResponse>(`/users/${userId}/posts`, queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取用户帖子列表');
}
}
/**
* 搜索帖子
*/
async searchPosts(params: SearchPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {
keyword: params.keyword,
};
if (params.page) queryParams.page = params.page;
if (params.pageSize) queryParams.page_size = params.pageSize;
if (params.scope) queryParams.scope = params.scope;
if (params.channelId) queryParams.channel_id = params.channelId;
const response = await this.api.get<PostsListApiResponse>('/posts/search', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '搜索帖子');
}
}
// ==================== 帖子操作 ====================
/**
* 创建帖子
*/
async createPost(data: CreatePostData): Promise<Post> {
try {
const requestData = PostMapper.createPostRequest(
data.title,
data.content,
data.images?.map(img => img.url),
data.channelId
);
if (data.tags && data.tags.length > 0) {
requestData.tags = data.tags;
}
if (data.status) {
requestData.status = data.status;
}
const response = await this.api.post<PostApiResponse>('/posts', requestData);
const post = this.mapToPost(response);
// 缓存新创建的帖子
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '创建帖子');
}
}
/**
* 更新帖子
*/
async updatePost(id: string, data: UpdatePostData): Promise<Post> {
try {
const requestData: Record<string, any> = {};
if (data.title !== undefined) requestData.title = data.title;
if (data.content !== undefined) requestData.content = data.content;
if (data.images !== undefined) requestData.images = data.images.map(img => img.url);
if (data.tags !== undefined) requestData.tags = data.tags;
if (data.status !== undefined) requestData.status = data.status;
if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned;
const response = await this.api.put<PostApiResponse>(`/posts/${id}`, requestData);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '更新帖子');
}
}
/**
* 删除帖子
*/
async deletePost(id: string): Promise<void> {
try {
await this.api.delete(`/posts/${id}`);
// 清除缓存
this.memoryCache.delete(id);
await this.clearLocalCache(id);
} catch (error) {
this.handleError(error, '删除帖子');
}
}
// ==================== 互动操作 ====================
/**
* 点赞帖子
*/
async likePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '点赞帖子');
}
}
/**
* 取消点赞
*/
async unlikePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消点赞');
}
}
/**
* 收藏帖子
*/
async favoritePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '收藏帖子');
}
}
/**
* 取消收藏
*/
async unfavoritePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消收藏');
}
}
// ==================== 缓存管理 ====================
/**
* 清除所有缓存
*/
async clearAllCache(): Promise<void> {
this.memoryCache.clear();
try {
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache');
});
} catch (error) {
console.error('[PostRepository] 清除所有缓存失败:', error);
}
}
/**
* 使指定帖子的缓存失效
*/
async invalidateCache(id: string): Promise<void> {
this.memoryCache.delete(id);
await this.clearLocalCache(id);
}
}
// ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource);
export default postRepository;