const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command // Query a collection that may not exist yet (created lazily on first write). // Returns an empty result instead of throwing "collection not exists". async function safeGet(collection, where, limit = 200) { try { return await db.collection(collection).where(where).limit(limit).get() } catch (e) { return { data: [] } } } function formatTimeText(ts) { const diff = Date.now() - ts const mins = Math.floor(diff / 60000) if (mins < 1) return '刚刚' if (mins < 60) return `${mins} 分钟前` const hours = Math.floor(mins / 60) if (hours < 24) return `${hours} 小时前` const days = Math.floor(hours / 24) if (days === 1) return '昨天' if (days < 7) return `${days} 天前` const d = new Date(ts) return `${d.getMonth() + 1}月${d.getDate()}日` } function normalizeKeyword(keyword) { return String(keyword || '').trim().toLowerCase() } function postMatchesKeyword(post, keyword) { if (!keyword) return true return [ post.content, post.locationText, post.authorSnapshot && post.authorSnapshot.name, post.petSnapshot && post.petSnapshot.name, post.petSnapshot && post.petSnapshot.breed, ...(Array.isArray(post.topics) ? post.topics : []) ] .filter(Boolean) .join(' ') .toLowerCase() .includes(keyword) } function isCloudFileId(value) { return typeof value === 'string' && value.startsWith('cloud://') } function extractMediaUrl(media) { if (!media) return '' if (typeof media === 'string') return media return media.url || media.fileId || media.fileID || '' } async function resolveTempUrlMap(urls) { const fileIds = [...new Set(urls.filter(isCloudFileId))] if (!fileIds.length) return new Map() try { const result = await cloud.getTempFileURL({ fileList: fileIds }) const fileList = Array.isArray(result.fileList) ? result.fileList : [] return new Map(fileList.map(file => [ file.fileID || file.fileId, file.tempFileURL || file.tempFileUrl || '' ]).filter(([fileId, tempUrl]) => fileId && tempUrl)) } catch (e) { return new Map() } } function readableUrl(url, tempUrlMap) { if (!url) return '' if (!isCloudFileId(url)) return url return tempUrlMap.get(url) || '' } async function queryPosts(query, limit, cursor) { const nextQuery = { ...query } if (cursor) nextQuery.createdAt = _.lt(Number(cursor)) return db.collection('posts') .where(nextQuery) .orderBy('createdAt', 'desc') .limit(limit) .get() } async function loadTopicOptions(query) { try { const topicQuery = { ...query } delete topicQuery.topics delete topicQuery.createdAt const res = await db.collection('posts') .where(topicQuery) .orderBy('createdAt', 'desc') .limit(100) .get() const counts = new Map() res.data.forEach(post => { const postTopics = post.topics || [] postTopics.forEach(topic => { if (!topic) return counts.set(topic, (counts.get(topic) || 0) + 1) }) }) return [...counts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 12) .map(([topic]) => topic) } catch (e) { return [] } } exports.main = async event => { const { OPENID } = cloud.getWXContext() const pageSize = Math.min(event.pageSize || 20, 50) const keyword = normalizeKeyword(event.keyword) const query = { visibility: 'public' } if (event.topic) query.topics = event.topic // Follow channel: only posts from users I follow (plus my own). if (event.channel === 'follow') { if (!OPENID) return { list: [], nextCursor: '' } const meRes = await db.collection('users').where({ openid: OPENID }).limit(1).get() if (!meRes.data.length) return { list: [], nextCursor: '' } const meId = meRes.data[0]._id const followRes = await safeGet('follows', { followerId: meId }, 500) const authorOpenids = followRes.data.map(f => f.followeeOpenid).filter(Boolean) authorOpenids.push(OPENID) query.authorOpenid = _.in(authorOpenids) query.visibility = _.in(['public', 'friends']) } const topics = event.cursor ? [] : await loadTopicOptions(query) const fetchLimit = keyword ? 50 : pageSize const matchedPosts = [] let scanCursor = event.cursor || '' let nextCursor = '' while (matchedPosts.length < pageSize) { const res = await queryPosts(query, fetchLimit, scanCursor) if (!res.data.length) break for (const post of res.data) { scanCursor = String(post.createdAt || '') if (!postMatchesKeyword(post, keyword)) continue matchedPosts.push(post) if (matchedPosts.length >= pageSize) { nextCursor = scanCursor break } } if (matchedPosts.length >= pageSize || res.data.length < fetchLimit) break scanCursor = String(res.data[res.data.length - 1].createdAt || '') } if (!matchedPosts.length) return { list: [], nextCursor: '', topics } const postIds = matchedPosts.map(p => p._id) // Batch-check liked status, skip if no valid OPENID (non-WeChat context). let likedSet = new Set() if (OPENID) { const likedRes = await safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) }) likedSet = new Set(likedRes.data.map(l => l.postId)) } // Resolve authors' current profile (real avatar image) so feed avatars load // and stay fresh, even for posts whose snapshot predates avatar upload. const authorIds = [...new Set(matchedPosts.map(p => p.authorId).filter(Boolean))] const authorMap = new Map() for (let i = 0; i < authorIds.length; i += 100) { const chunk = authorIds.slice(i, i + 100) const usersRes = await safeGet('users', { _id: _.in(chunk) }, 100) usersRes.data.forEach(u => authorMap.set(u._id, u)) } const authorOpenids = [...new Set(matchedPosts.map(p => p.authorOpenid).filter(Boolean))] const authorOpenidMap = new Map() for (let i = 0; i < authorOpenids.length; i += 100) { const chunk = authorOpenids.slice(i, i + 100) const usersRes = await safeGet('users', { openid: _.in(chunk) }, 100) usersRes.data.forEach(u => authorOpenidMap.set(u.openid, u)) } const findAuthor = post => authorMap.get(post.authorId) || authorOpenidMap.get(post.authorOpenid) // Real presence per author: a recent, visible location (same signal as 附近). const ONLINE_WINDOW_MS = 5 * 60 * 1000 const onlineNow = Date.now() const onlineSet = new Set() for (let i = 0; i < authorOpenids.length; i += 100) { const chunk = authorOpenids.slice(i, i + 100) const locRes = await safeGet('locations', { openid: _.in(chunk) }, 100) locRes.data.forEach(loc => { if (loc.visible && loc.updatedAt && onlineNow - loc.updatedAt < ONLINE_WINDOW_MS) onlineSet.add(loc.openid) }) } const assetUrls = [] matchedPosts.forEach(post => { const author = findAuthor(post) assetUrls.push((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '') if (Array.isArray(post.media)) { post.media.forEach(media => assetUrls.push(extractMediaUrl(media))) } }) const tempUrlMap = await resolveTempUrlMap(assetUrls) const list = matchedPosts.map(post => { const author = findAuthor(post) const media = Array.isArray(post.media) ? post.media .map(extractMediaUrl) .map(url => readableUrl(url, tempUrlMap)) .filter(Boolean) : [] return { _id: post._id, author: { id: (author && author._id) || post.authorId || '', name: (author && author.nickname) || post.authorSnapshot?.name || '用户', avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1', avatarUrl: readableUrl((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '', tempUrlMap), online: onlineSet.has(post.authorOpenid) }, pet: post.petSnapshot || { name: '', breed: '' }, body: post.content || '', topics: post.topics || [], media, mediaTone: post.mediaTone || 'pet-1', sticker: post.sticker || undefined, locationText: post.locationText || '', timeText: formatTimeText(post.createdAt || Date.now()), counts: { likes: post.counts?.likes || 0, comments: post.counts?.comments || 0 }, likedByMe: likedSet.has(post._id) } }) return { list, nextCursor, topics } }