fix(community): persist post liked status in MySQL and local storage to prevent state loss on page switch

这个提交包含在:
xcbyy
2026-10-13 15:46:04 +08:00
父节点 3d895a1a0a
当前提交 497fb65359
共有 6 个文件被更改,包括 218 次插入 和 40 次删除

查看文件

@@ -397,30 +397,44 @@ export async function universalVerifyCheckin(code, operatorId = 1001) {
/**
* 获取友圈广场动态列表 (真实读取 MySQL posts 表)
*/
export async function getPosts(category) {
export async function getPosts(category, userId) {
try {
const query = category && category !== 'all' ? `?category=${encodeURIComponent(category)}` : '';
const uid = userId || uni.getStorageSync('userId') || 1001;
let query = `?userId=${uid}`;
if (category && category !== 'all') {
query += `&category=${encodeURIComponent(category)}`;
}
const res = await http.get(`/posts${query}`);
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds)) localLikedIds = [];
if (Array.isArray(res) && res.length > 0) {
return res.map(p => ({
id: p.id,
author: p.author || '神秘同好',
avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
tag: p.tag || '同城认证',
tagType: p.tagType || 'warm-tag',
location: p.location || '同城热门商圈',
distance: p.distance || '1km内',
time: p.createdAt ? '刚刚' : '近期',
content: p.content || '',
topics: Array.isArray(p.topics) ? p.topics : [],
images: Array.isArray(p.images) ? p.images : [],
actionText: p.actionText || '聊一聊',
likes: Number(p.likes) || 0,
liked: false,
comments: Number(p.comments) || 0,
views: Number(p.views) || 1,
category: p.category || 'partner'
}));
return res.map(p => {
const strId = String(p.id);
const isLocallyLiked = localLikedIds.includes(strId);
const isServerLiked = p.liked === true || p.liked === 1;
const finalLiked = p.liked !== undefined ? isServerLiked : isLocallyLiked;
return {
id: p.id,
author: p.author || '神秘同好',
avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
tag: p.tag || '同城认证',
tagType: p.tagType || 'warm-tag',
location: p.location || '同城热门商圈',
distance: p.distance || '1km内',
time: p.createdAt ? '刚刚' : '近期',
content: p.content || '',
topics: Array.isArray(p.topics) ? p.topics : [],
images: Array.isArray(p.images) ? p.images : [],
actionText: p.actionText || '聊一聊',
likes: Number(p.likes) || 0,
liked: finalLiked || isLocallyLiked,
comments: Number(p.comments) || 0,
views: Number(p.views) || 1,
category: p.category || 'partner'
};
});
}
} catch (err) {
console.warn('[API] 获取友圈动态列表异常:', err.message);
@@ -444,9 +458,14 @@ export async function createPost(payload) {
/**
* 动态点赞 (落盘 MySQL)
*/
export async function likePost(id) {
export async function likePost(id, action, userId) {
try {
const res = await http.post(`/posts/${id}/like`);
const uid = userId || uni.getStorageSync('userId') || 1001;
const payload = { userId: uid };
if (action) {
payload.action = action;
}
const res = await http.post(`/posts/${id}/like`, payload);
return res;
} catch (err) {
console.warn('[API] 点赞动态异常:', err.message);

查看文件

@@ -376,9 +376,14 @@ onUnmounted(() => {
const loadRemotePosts = async () => {
try {
const list = await getPosts();
const list = await getPosts(currentTab.value);
if (list && list.length > 0) {
posts.value = list;
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds)) localLikedIds = [];
posts.value = list.map(item => ({
...item,
liked: item.liked !== undefined ? item.liked : localLikedIds.includes(String(item.id))
}));
}
} catch (err) {
console.warn('[Community] 获取动态列表失败,保持本地列表:', err.message);
@@ -411,7 +416,20 @@ const feedTabs = ref([
const currentTab = ref('all');
const posts = ref([
const syncInitialLikedState = (rawPosts) => {
try {
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds) || localLikedIds.length === 0) return rawPosts;
return rawPosts.map(p => ({
...p,
liked: localLikedIds.includes(String(p.id)) ? true : p.liked
}));
} catch (_) {
return rawPosts;
}
};
const posts = ref(syncInitialLikedState([
{
id: 1,
author: '小甜橙',
@@ -476,7 +494,7 @@ const posts = ref([
views: 520,
category: 'partner'
}
]);
]));
const filteredPosts = computed(() => {
if (currentTab.value === 'all') return posts.value;
@@ -488,16 +506,43 @@ const switchFeedTab = (key) => {
};
const toggleLike = async (post) => {
post.liked = !post.liked;
post.likes += post.liked ? 1 : -1;
const willLike = !post.liked;
post.liked = willLike;
post.likes += willLike ? 1 : -1;
// 1. 同步更新本地存储的点赞记录 (保障切页、断网或刷新时不丢失状态)
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds)) localLikedIds = [];
const strId = String(post.id);
if (willLike) {
if (!localLikedIds.includes(strId)) localLikedIds.push(strId);
} else {
localLikedIds = localLikedIds.filter(id => id !== strId);
}
uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', localLikedIds);
uni.showToast({
title: post.liked ? '已点赞' : '已取消点赞',
title: willLike ? '已点赞' : '已取消点赞',
icon: 'none',
duration: 1000
});
if (post.liked && post.id) {
if (post.id) {
try {
await likePost(post.id);
const res = await likePost(post.id, willLike ? 'like' : 'unlike');
if (res && res.likes !== undefined) {
post.likes = res.likes;
}
if (res && res.liked !== undefined) {
post.liked = res.liked;
if (res.liked && !localLikedIds.includes(strId)) {
localLikedIds.push(strId);
uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', localLikedIds);
} else if (!res.liked && localLikedIds.includes(strId)) {
localLikedIds = localLikedIds.filter(id => id !== strId);
uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', localLikedIds);
}
}
} catch (err) {
console.warn('[Community] 点赞落盘异常:', err.message);
}

查看文件

@@ -313,7 +313,20 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 16. 首页轮播图运营配置表
// 16. 友圈动态用户点赞明细表 (用于持久化用户点赞状态并防重)
await conn.query(`
CREATE TABLE IF NOT EXISTS post_likes (
id VARCHAR(64) PRIMARY KEY,
post_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_post_user (post_id, user_id),
INDEX idx_post_likes_user (user_id),
INDEX idx_post_likes_post (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 17. 首页轮播图运营配置表
await conn.query(`
CREATE TABLE IF NOT EXISTS banners (
id INT PRIMARY KEY AUTO_INCREMENT,

查看文件

@@ -2215,7 +2215,19 @@ export class MysqlRepository {
// 友圈动态相关
async getPosts(category?: string, keyword?: string, status?: string): Promise<any[]> {
async getPosts(category?: string, keyword?: string, status?: string, userId?: number): Promise<any[]> {
const currentUserId = userId !== undefined ? Number(userId) : 1001;
const likedPostIdSet = new Set<string>();
try {
const [likedRows]: any = await pool.query(
'SELECT post_id FROM post_likes WHERE user_id = ?',
[currentUserId]
);
if (Array.isArray(likedRows)) {
likedRows.forEach((row: any) => likedPostIdSet.add(String(row.post_id)));
}
} catch (_) {}
let sql = `
SELECT
id, user_id as userId, author, avatar, tag, tag_type as tagType,
@@ -2257,6 +2269,7 @@ export class MysqlRepository {
return {
...r,
liked: likedPostIdSet.has(String(r.id)),
isTop: Boolean(r.isTop),
status: r.status || '正常',
topics: topicsArr,
@@ -2354,10 +2367,41 @@ export class MysqlRepository {
};
}
async likePost(id: string): Promise<any> {
await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]);
async likePost(id: string, userId: number = 1001, action?: 'like' | 'unlike'): Promise<any> {
const currentUserId = Number(userId) || 1001;
let liked = false;
try {
const [existing]: any = await pool.query(
'SELECT id FROM post_likes WHERE post_id = ? AND user_id = ?',
[id, currentUserId]
);
const isCurrentlyLiked = existing && existing.length > 0;
const shouldLike = action ? action === 'like' : !isCurrentlyLiked;
if (!shouldLike) {
if (isCurrentlyLiked) {
await pool.query('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?', [id, currentUserId]);
await pool.query('UPDATE posts SET likes = GREATEST(0, likes - 1) WHERE id = ?', [id]);
}
liked = false;
} else {
if (!isCurrentlyLiked) {
const likeId = `like_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
await pool.query(
'INSERT IGNORE INTO post_likes (id, post_id, user_id, created_at) VALUES (?, ?, ?, NOW())',
[likeId, id, currentUserId]
);
await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]);
}
liked = true;
}
} catch (_) {
// 容错兜底递增
await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]);
liked = true;
}
const [rows]: any = await pool.query('SELECT likes FROM posts WHERE id = ?', [id]);
return { id, likes: rows[0]?.likes || 1 };
return { id, likes: rows[0]?.likes || 0, liked };
}
// 13. 订单评价与评分业务

查看文件

@@ -1694,7 +1694,8 @@ app.get('/api/users/:id/finance-records', async (req: Request, res: Response) =>
app.get('/api/posts', async (req: Request, res: Response) => {
try {
const category = req.query.category ? String(req.query.category) : undefined;
const posts = await repo.getPosts(category);
const userId = Number(req.query.userId || req.headers['x-user-id']) || 1001;
const posts = await repo.getPosts(category, undefined, '正常', userId);
res.json({
code: 20000,
data: posts
@@ -1804,11 +1805,13 @@ app.post('/api/posts', async (req: Request, res: Response) => {
app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
try {
const result = await repo.likePost(String(req.params.id));
const userId = Number(req.body?.userId || req.query.userId || req.headers['x-user-id']) || 1001;
const action = req.body?.action as 'like' | 'unlike' | undefined;
const result = await repo.likePost(String(req.params.id), userId, action);
res.json({
code: 20000,
data: result,
message: '点赞成功'
message: result.liked ? '点赞成功' : '已取消点赞'
});
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });

查看文件

@@ -945,6 +945,60 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
expect(emojiRegex.test(communityContent)).toBe(false);
});
});
describe('22. 友圈广场点赞状态跨页面持久化与防重闭环验证 (MySQL post_likes & UniApp 同步)', () => {
it('MySQL 结构中必须包含 post_likes 用户点赞明细表及唯一防重键', () => {
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
const content = fs.readFileSync(mysqlPath, 'utf-8');
expect(content).toContain('CREATE TABLE IF NOT EXISTS post_likes');
expect(content).toContain('post_id');
expect(content).toContain('user_id');
expect(content).toContain('uq_post_user');
});
it('Repository getPosts 与 likePost 必须支持用户点赞状态关联查询与双向落盘', () => {
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('SELECT post_id FROM post_likes WHERE user_id = ?');
expect(content).toContain('INSERT IGNORE INTO post_likes');
expect(content).toContain('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?');
});
it('服务端路由 /api/posts 与 /api/posts/:id/like 必须支持透传 userId 与 action', () => {
const indexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
expect(content).toContain("app.get('/api/posts'");
expect(content).toContain("app.post('/api/posts/:id/like'");
expect(content).toContain("'like' | 'unlike'");
});
it('UniApp API 层与社区页面必须通过本地存储双重保障切页不丢点赞状态', () => {
const apiPath = path.join(rootDir, 'pwuniapp', 'common', 'api.js');
const apiContent = fs.readFileSync(apiPath, 'utf-8');
expect(apiContent).toContain('COMMUNITY_LIKED_POST_IDS');
const communityPath = path.join(rootDir, 'pwuniapp', 'pages', 'community', 'index.vue');
const communityContent = fs.readFileSync(communityPath, 'utf-8');
expect(communityContent).toContain('COMMUNITY_LIKED_POST_IDS');
expect(communityContent).toContain('syncInitialLikedState');
expect(communityContent).toContain('uni.setStorageSync(\'COMMUNITY_LIKED_POST_IDS\'');
});
it('所有新增的点赞持久化代码与测试严禁包含任何系统 Emoji 违规字符', () => {
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const indexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const apiPath = path.join(rootDir, 'pwuniapp', 'common', 'api.js');
const communityPath = path.join(rootDir, 'pwuniapp', 'pages', 'community', 'index.vue');
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
expect(emojiRegex.test(fs.readFileSync(mysqlPath, 'utf-8'))).toBe(false);
expect(emojiRegex.test(fs.readFileSync(repoPath, 'utf-8'))).toBe(false);
expect(emojiRegex.test(fs.readFileSync(indexPath, 'utf-8'))).toBe(false);
expect(emojiRegex.test(fs.readFileSync(apiPath, 'utf-8'))).toBe(false);
expect(emojiRegex.test(fs.readFileSync(communityPath, 'utf-8'))).toBe(false);
});
});
});