fix(community): persist post liked status in MySQL and local storage to prevent state loss on page switch
这个提交包含在:
@@ -397,30 +397,44 @@ export async function universalVerifyCheckin(code, operatorId = 1001) {
|
|||||||
/**
|
/**
|
||||||
* 获取友圈广场动态列表 (真实读取 MySQL posts 表)
|
* 获取友圈广场动态列表 (真实读取 MySQL posts 表)
|
||||||
*/
|
*/
|
||||||
export async function getPosts(category) {
|
export async function getPosts(category, userId) {
|
||||||
try {
|
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}`);
|
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) {
|
if (Array.isArray(res) && res.length > 0) {
|
||||||
return res.map(p => ({
|
return res.map(p => {
|
||||||
id: p.id,
|
const strId = String(p.id);
|
||||||
author: p.author || '神秘同好',
|
const isLocallyLiked = localLikedIds.includes(strId);
|
||||||
avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
|
const isServerLiked = p.liked === true || p.liked === 1;
|
||||||
tag: p.tag || '同城认证',
|
const finalLiked = p.liked !== undefined ? isServerLiked : isLocallyLiked;
|
||||||
tagType: p.tagType || 'warm-tag',
|
|
||||||
location: p.location || '同城热门商圈',
|
return {
|
||||||
distance: p.distance || '1km内',
|
id: p.id,
|
||||||
time: p.createdAt ? '刚刚' : '近期',
|
author: p.author || '神秘同好',
|
||||||
content: p.content || '',
|
avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
|
||||||
topics: Array.isArray(p.topics) ? p.topics : [],
|
tag: p.tag || '同城认证',
|
||||||
images: Array.isArray(p.images) ? p.images : [],
|
tagType: p.tagType || 'warm-tag',
|
||||||
actionText: p.actionText || '聊一聊',
|
location: p.location || '同城热门商圈',
|
||||||
likes: Number(p.likes) || 0,
|
distance: p.distance || '1km内',
|
||||||
liked: false,
|
time: p.createdAt ? '刚刚' : '近期',
|
||||||
comments: Number(p.comments) || 0,
|
content: p.content || '',
|
||||||
views: Number(p.views) || 1,
|
topics: Array.isArray(p.topics) ? p.topics : [],
|
||||||
category: p.category || 'partner'
|
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) {
|
} catch (err) {
|
||||||
console.warn('[API] 获取友圈动态列表异常:', err.message);
|
console.warn('[API] 获取友圈动态列表异常:', err.message);
|
||||||
@@ -444,9 +458,14 @@ export async function createPost(payload) {
|
|||||||
/**
|
/**
|
||||||
* 动态点赞 (落盘 MySQL)
|
* 动态点赞 (落盘 MySQL)
|
||||||
*/
|
*/
|
||||||
export async function likePost(id) {
|
export async function likePost(id, action, userId) {
|
||||||
try {
|
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;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[API] 点赞动态异常:', err.message);
|
console.warn('[API] 点赞动态异常:', err.message);
|
||||||
|
|||||||
@@ -376,9 +376,14 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
const loadRemotePosts = async () => {
|
const loadRemotePosts = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await getPosts();
|
const list = await getPosts(currentTab.value);
|
||||||
if (list && list.length > 0) {
|
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) {
|
} catch (err) {
|
||||||
console.warn('[Community] 获取动态列表失败,保持本地列表:', err.message);
|
console.warn('[Community] 获取动态列表失败,保持本地列表:', err.message);
|
||||||
@@ -411,7 +416,20 @@ const feedTabs = ref([
|
|||||||
|
|
||||||
const currentTab = ref('all');
|
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,
|
id: 1,
|
||||||
author: '小甜橙',
|
author: '小甜橙',
|
||||||
@@ -476,7 +494,7 @@ const posts = ref([
|
|||||||
views: 520,
|
views: 520,
|
||||||
category: 'partner'
|
category: 'partner'
|
||||||
}
|
}
|
||||||
]);
|
]));
|
||||||
|
|
||||||
const filteredPosts = computed(() => {
|
const filteredPosts = computed(() => {
|
||||||
if (currentTab.value === 'all') return posts.value;
|
if (currentTab.value === 'all') return posts.value;
|
||||||
@@ -488,16 +506,43 @@ const switchFeedTab = (key) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleLike = async (post) => {
|
const toggleLike = async (post) => {
|
||||||
post.liked = !post.liked;
|
const willLike = !post.liked;
|
||||||
post.likes += post.liked ? 1 : -1;
|
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({
|
uni.showToast({
|
||||||
title: post.liked ? '已点赞' : '已取消点赞',
|
title: willLike ? '已点赞' : '已取消点赞',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 1000
|
duration: 1000
|
||||||
});
|
});
|
||||||
if (post.liked && post.id) {
|
|
||||||
|
if (post.id) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.warn('[Community] 点赞落盘异常:', err.message);
|
console.warn('[Community] 点赞落盘异常:', err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,7 +313,20 @@ export async function initDatabase(): Promise<void> {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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(`
|
await conn.query(`
|
||||||
CREATE TABLE IF NOT EXISTS banners (
|
CREATE TABLE IF NOT EXISTS banners (
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
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 = `
|
let sql = `
|
||||||
SELECT
|
SELECT
|
||||||
id, user_id as userId, author, avatar, tag, tag_type as tagType,
|
id, user_id as userId, author, avatar, tag, tag_type as tagType,
|
||||||
@@ -2257,6 +2269,7 @@ export class MysqlRepository {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
liked: likedPostIdSet.has(String(r.id)),
|
||||||
isTop: Boolean(r.isTop),
|
isTop: Boolean(r.isTop),
|
||||||
status: r.status || '正常',
|
status: r.status || '正常',
|
||||||
topics: topicsArr,
|
topics: topicsArr,
|
||||||
@@ -2354,10 +2367,41 @@ export class MysqlRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async likePost(id: string): Promise<any> {
|
async likePost(id: string, userId: number = 1001, action?: 'like' | 'unlike'): Promise<any> {
|
||||||
await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]);
|
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]);
|
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. 订单评价与评分业务
|
// 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) => {
|
app.get('/api/posts', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const category = req.query.category ? String(req.query.category) : undefined;
|
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({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: posts
|
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) => {
|
app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
|
||||||
try {
|
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({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: result,
|
data: result,
|
||||||
message: '点赞成功'
|
message: result.liked ? '点赞成功' : '已取消点赞'
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(400).json({ code: 40000, message: err.message });
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
|||||||
@@ -945,6 +945,60 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
|||||||
expect(emojiRegex.test(communityContent)).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户