From 497fb653597811acad1526de5b97d9941c4fd820 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Tue, 13 Oct 2026 15:46:04 +0800 Subject: [PATCH] fix(community): persist post liked status in MySQL and local storage to prevent state loss on page switch --- pwuniapp/common/api.js | 65 +++++++++++++++++---------- pwuniapp/pages/community/index.vue | 63 ++++++++++++++++++++++---- server/src/db/mysql.ts | 15 ++++++- server/src/db/repository.ts | 52 +++++++++++++++++++-- server/src/index.ts | 9 ++-- server/tests/content-security.spec.ts | 54 ++++++++++++++++++++++ 6 files changed, 218 insertions(+), 40 deletions(-) diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 6eea670..23a8a07 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -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); diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index b2a1177..3aad7b0 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -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); } diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index f7098d4..2f0a37e 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -313,7 +313,20 @@ export async function initDatabase(): Promise { ) 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, diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 988e83c..7f5561f 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -2215,7 +2215,19 @@ export class MysqlRepository { // 友圈动态相关 - async getPosts(category?: string, keyword?: string, status?: string): Promise { + async getPosts(category?: string, keyword?: string, status?: string, userId?: number): Promise { + const currentUserId = userId !== undefined ? Number(userId) : 1001; + const likedPostIdSet = new Set(); + 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 { - await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]); + async likePost(id: string, userId: number = 1001, action?: 'like' | 'unlike'): Promise { + 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. 订单评价与评分业务 diff --git a/server/src/index.ts b/server/src/index.ts index 084441c..2b81de5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 }); diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index 092d7db..9fc0db6 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -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); + }); + }); });