修复动态发布与互动问题

This commit is contained in:
2026-06-23 18:43:03 +08:00
parent 702b578e1e
commit cda8685956
151 changed files with 4993 additions and 914 deletions

View File

@@ -28,9 +28,104 @@ function formatTimeText(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
@@ -48,35 +143,45 @@ exports.main = async event => {
query.visibility = _.in(['public', 'friends'])
}
if (event.cursor) {
query.createdAt = _.lt(Number(event.cursor))
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 || '')
}
const res = await db.collection('posts')
.where(query)
.orderBy('createdAt', 'desc')
.limit(pageSize)
.get()
if (!matchedPosts.length) return { list: [], nextCursor: '', topics }
if (!res.data.length) return { list: [], nextCursor: '' }
const postIds = matchedPosts.map(p => p._id)
const postIds = res.data.map(p => p._id)
// Batch-check liked/favorited status — skip if no valid OPENID (non-WeChat context)
// Batch-check liked status, skip if no valid OPENID (non-WeChat context).
let likedSet = new Set()
let favSet = new Set()
if (OPENID) {
const [likedRes, favRes] = await Promise.all([
safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) }),
safeGet('postFavorites', { openid: OPENID, postId: _.in(postIds) })
])
const likedRes = await safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) })
likedSet = new Set(likedRes.data.map(l => l.postId))
favSet = new Set(favRes.data.map(f => f.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(res.data.map(p => p.authorId).filter(Boolean))]
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)
@@ -84,36 +189,70 @@ exports.main = async event => {
usersRes.data.forEach(u => authorMap.set(u._id, u))
}
const list = res.data.map(post => {
const author = authorMap.get(post.authorId)
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: post.authorId || '',
id: (author && author._id) || post.authorId || '',
name: (author && author.nickname) || post.authorSnapshot?.name || '用户',
avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
avatarUrl: (author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || ''
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: Array.isArray(post.media)
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
: [],
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,
favorites: post.counts?.favorites || 0
comments: post.counts?.comments || 0
},
likedByMe: likedSet.has(post._id),
favoritedByMe: favSet.has(post._id)
likedByMe: likedSet.has(post._id)
}
})
const last = res.data[res.data.length - 1]
return { list, nextCursor: last ? String(last.createdAt) : '' }
return { list, nextCursor, topics }
}

View File

@@ -3,12 +3,25 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
// Reading a collection that was never created throws in WeChat 云开发, and this
// function reads `follows` before it ever adds to it — so on a fresh database the
// first follow would always fail. Create it up-front (no-op if it already exists).
async function ensureCollection(name) {
try {
await db.createCollection(name)
} catch (e) {
// Already-exists error is expected on every call after the first.
}
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
const { targetUserId, follow } = event
if (!targetUserId) throw new Error('targetUserId is required')
await ensureCollection('follows')
const meRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
if (!meRes.data.length) throw new Error('user not found')
const meId = meRes.data[0]._id

View File

@@ -5,17 +5,36 @@ const db = cloud.database()
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
// Reading a non-existent collection throws in WeChat 云开发, and this function
// starts with a read — so the collection would never get created by the later
// add(). Create it up-front (no-op if it already exists) to break that deadlock.
try {
await db.createCollection('locations')
} catch (e) {
// -501001 / "already exists" is expected on every call after the first.
}
const now = Date.now()
const loc = event.location || null
const visible = Boolean(event.visible)
const hasCoord = loc && typeof loc.latitude === 'number' && typeof loc.longitude === 'number'
const data = {
openid: OPENID,
location: event.location || null,
visible: Boolean(event.visible),
location: hasCoord ? { latitude: loc.latitude, longitude: loc.longitude } : null,
// `nearbyPets` currently filters by distance in memory off `location`, so the
// `visible` flag below is what gates discovery. `geo` is kept (Point form) only
// to enable a future geoNear migration if the user base outgrows the in-memory
// scan — it needs a manually-created geo index to be queried.
geo: hasCoord && visible ? db.Geo.Point(loc.longitude, loc.latitude) : null,
visible,
updatedAt: now
}
const existed = await db.collection('locations').where({ openid: OPENID }).limit(1).get()
if (existed.data.length) await db.collection('locations').doc(existed.data[0]._id).update({ data })
else await db.collection('locations').add({ data })
return { ok: true }
}

View File

@@ -9,11 +9,16 @@ function makeHandle(openid) {
return `@user_${suffix}`
}
// Default avatar pool to randomise new user avatars
const AVATAR_KEYS = [
'gradient-avatar-1', 'gradient-avatar-2', 'gradient-avatar-3',
'gradient-avatar-4', 'gradient-avatar-5', 'gradient-avatar-6'
]
// Deterministic gradient swatch (.gradient-avatar-1..6) from the user's openid, so
// a user who hasn't set a real avatar always gets the same stable colour.
function gradientAvatarKey(seed) {
let h = 5381
for (let i = 0; i < seed.length; i++) {
h = ((h << 5) + h) ^ seed.charCodeAt(i)
h >>>= 0
}
return `gradient-avatar-${(h % 6) + 1}`
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
@@ -35,7 +40,7 @@ exports.main = async event => {
}
// New user — create with sensible defaults
const avatarKey = AVATAR_KEYS[Math.floor(Math.random() * AVATAR_KEYS.length)]
const avatarKey = gradientAvatarKey(OPENID)
const user = {
openid: OPENID,
nickname: event.userInfo?.nickname || '宠物达人',
@@ -48,7 +53,7 @@ exports.main = async event => {
level: 1,
bio: '',
verified: false,
stats: { posts: 0, following: 0, followers: 0, favorites: 0 },
stats: { posts: 0, following: 0, followers: 0 },
preferences: { notifications: true, nearbyVisible: true },
createdAt: now,
lastLoginAt: now

View File

@@ -4,11 +4,25 @@ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
// Reading a collection that was never created throws in WeChat 云开发, and this
// function reads `matches` before it ever adds to it — so the collection would
// never get created and every "like" would fail. Create it up-front (no-op if it
// already exists) to break that deadlock.
async function ensureCollection(name) {
try {
await db.createCollection(name)
} catch (e) {
// Already-exists error is expected on every call after the first.
}
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const { petId } = event
if (!petId) throw new Error('petId is required')
await ensureCollection('matches')
// Get current user and their pets
const userRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
if (!userRes.data.length) throw new Error('user not found')

View File

@@ -40,6 +40,17 @@ async function fetchUsers(ids) {
return out
}
async function fetchUsersByOpenids(openids) {
if (!openids.length) return []
const out = []
for (let i = 0; i < openids.length; i += 100) {
const chunk = openids.slice(i, i + 100)
const res = await db.collection('users').where({ openid: _.in(chunk) }).limit(100).get()
out.push(...res.data)
}
return out
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
@@ -70,22 +81,46 @@ exports.main = async event => {
convRes = { data: [] }
}
const peerOpenids = [
...new Set(
convRes.data
.filter(c => (c.type || 'single') === 'single')
.map(c => (c.memberOpenids || []).find(memberOpenid => memberOpenid !== OPENID))
.filter(Boolean)
)
]
const peerUsers = await fetchUsersByOpenids(peerOpenids)
const peerUserByOpenid = new Map(peerUsers.map(user => [user.openid, user]))
const conversations = convRes.data.map(c => {
const unreadOpenids = c.unreadOpenids || []
const unreadCounts = c.unreadCounts || {}
const unreadCount = unreadCounts[OPENID] || (unreadOpenids.includes(OPENID) ? 1 : 0)
const type = c.type || 'single'
const peerOpenid = type === 'single'
? (c.memberOpenids || []).find(memberOpenid => memberOpenid !== OPENID)
: ''
const peer = peerOpenid ? peerUserByOpenid.get(peerOpenid) : null
return {
_id: c._id,
type: c.type || 'single',
title: c.title || '未知用户',
type,
title: type === 'single'
? peer?.nickname || c.title || '未知用户'
: c.title || '系统消息',
petName: c.petName || undefined,
avatarKey: c.avatarKey || 'gradient-avatar-1',
avatarKey: type === 'single'
? peer?.avatarKey || c.avatarKey || 'gradient-avatar-1'
: c.avatarKey || 'gradient-avatar-1',
avatarUrl: type === 'single'
? peer?.avatarUrl || c.avatarUrl || ''
: c.avatarUrl || '',
preview: c.lastMessage?.content || c.preview || '',
timeText: formatTimeText(c.updatedAt || c.createdAt),
unreadCount,
pinned: Boolean(c.pinned),
muted: Boolean(c.muted),
online: Boolean(c.online)
online: type === 'single' ? Boolean(peer?.online || c.online) : Boolean(c.online)
}
})

View File

@@ -3,14 +3,148 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
const SERVER_MESSAGE_LIMIT = 500
const CONVERSATIONS_COLLECTION = 'conversations'
const MESSAGES_COLLECTION = 'messages'
const CHAT_MESSAGE_TEMPLATE_ID = process.env.WQ_MESSAGE_TEMPLATE_ID || process.env.TARO_APP_MESSAGE_TEMPLATE_ID || ''
function isCollectionMissing(error) {
const text = String(error?.errMsg || error?.message || error || '')
return (
text.includes('collection not exist') ||
text.includes('collection not exists') ||
text.includes('collection is not exists') ||
text.includes('DATABASE_COLLECTION_NOT_EXIST') ||
text.includes('-502005')
)
}
function isCollectionAlreadyExists(error) {
const text = String(error?.errMsg || error?.message || error || '')
return text.includes('collection already exists') || text.includes('DATABASE_COLLECTION_ALREADY_EXIST')
}
async function ensureCollection(name) {
if (typeof db.createCollection !== 'function') {
throw new Error(`${name} collection does not exist`)
}
try {
await db.createCollection(name)
} catch (error) {
if (!isCollectionAlreadyExists(error)) throw error
}
}
async function getExistingConversation(openid, toOpenid) {
try {
return await db.collection(CONVERSATIONS_COLLECTION)
.where({ type: 'single', memberOpenids: _.all([openid, toOpenid]) })
.limit(1)
.get()
} catch (error) {
if (!isCollectionMissing(error)) throw error
await ensureCollection(CONVERSATIONS_COLLECTION)
return { data: [] }
}
}
async function getConversationById(conversationId) {
try {
return await db.collection(CONVERSATIONS_COLLECTION).doc(conversationId).get()
} catch (error) {
if (!isCollectionMissing(error)) throw error
return { data: null }
}
}
async function addConversation(data) {
try {
return await db.collection(CONVERSATIONS_COLLECTION).add({ data })
} catch (error) {
if (!isCollectionMissing(error)) throw error
await ensureCollection(CONVERSATIONS_COLLECTION)
return db.collection(CONVERSATIONS_COLLECTION).add({ data })
}
}
async function addMessage(data) {
try {
return await db.collection(MESSAGES_COLLECTION).add({ data })
} catch (error) {
if (!isCollectionMissing(error)) throw error
await ensureCollection(MESSAGES_COLLECTION)
return db.collection(MESSAGES_COLLECTION).add({ data })
}
}
async function trimConversationMessages(conversationId) {
for (;;) {
const oldMessages = await db.collection(MESSAGES_COLLECTION)
.where({ conversationId })
.orderBy('createdAt', 'desc')
.skip(SERVER_MESSAGE_LIMIT)
.limit(100)
.get()
.catch(() => ({ data: [] }))
if (!oldMessages.data.length) return
await Promise.all(
oldMessages.data.map(item =>
db.collection(MESSAGES_COLLECTION).doc(item._id).remove().catch(() => null)
)
)
if (oldMessages.data.length < 100) return
}
}
function pad(value) {
return String(value).padStart(2, '0')
}
function formatDateTime(ts) {
const d = new Date(ts)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function templateValue(value, max = 20) {
const text = String(value || '').trim()
return text.length > max ? `${text.slice(0, max - 1)}` : text
}
async function sendChatNotifications({ recipients, conversationId, senderName, content, type, createdAt }) {
if (!CHAT_MESSAGE_TEMPLATE_ID || !recipients.length || !cloud.openapi?.subscribeMessage?.send) return
const preview = type === 'image' ? '[图片]' : content
await Promise.all(
recipients.map(touser =>
cloud.openapi.subscribeMessage.send({
touser,
templateId: CHAT_MESSAGE_TEMPLATE_ID,
page: `pages/chat/index?conversationId=${conversationId}`,
data: {
thing31: { value: templateValue(senderName || '汪友') },
thing2: { value: templateValue(preview || '你收到一条新消息') },
time3: { value: formatDateTime(createdAt) }
}
}).catch(error => {
console.log('[messageSend] subscribe message skipped', error?.errMsg || error?.message || error)
})
)
)
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const content = String(event.content || '').trim()
if (!content) throw new Error('content is required')
if (!OPENID) throw new Error('no openid in wx context')
const now = Date.now()
let convId = event.conversationId
let conversation = null
// Create a 1:1 conversation if not provided
if (!convId) {
@@ -19,53 +153,64 @@ exports.main = async event => {
if (!toOpenid && event.toUserId) {
const toUserById = await db.collection('users').doc(event.toUserId).get().catch(() => null)
if (toUserById && toUserById.data) toOpenid = toUserById.data.openid
if (!toOpenid) {
const toUserByOpenid = await db.collection('users')
.where({ openid: event.toUserId })
.limit(1)
.get()
.catch(() => ({ data: [] }))
if (toUserByOpenid.data[0]) toOpenid = toUserByOpenid.data[0].openid
}
}
if (!toOpenid) throw new Error('conversationId or toOpenid/toUserId is required')
if (!toOpenid) throw new Error('target user not found')
// Look for existing conversation between these two users
const existing = await db.collection('conversations')
.where({ type: 'single', memberOpenids: _.all([OPENID, toOpenid]) })
.limit(1)
.get()
const existing = await getExistingConversation(OPENID, toOpenid)
if (existing.data.length) {
convId = existing.data[0]._id
conversation = existing.data[0]
convId = conversation._id
} else {
const toUserRes = await db.collection('users').where({ openid: toOpenid }).limit(1).get()
const toUser = toUserRes.data[0] || { nickname: '用户', avatarKey: 'gradient-avatar-1' }
const newConv = await db.collection('conversations').add({
data: {
type: 'single',
memberOpenids: [OPENID, toOpenid],
unreadOpenids: [toOpenid],
unreadCounts: { [toOpenid]: 1 },
title: toUser.nickname,
avatarKey: toUser.avatarKey,
lastMessage: { content: content.slice(0, 50), createdAt: now },
pinned: false,
muted: false,
createdAt: now,
updatedAt: now
}
})
conversation = {
type: 'single',
memberOpenids: [OPENID, toOpenid],
unreadOpenids: [],
unreadCounts: {},
title: toUser.nickname,
avatarKey: toUser.avatarKey,
lastMessage: { content: content.slice(0, 50), createdAt: now },
pinned: false,
muted: false,
createdAt: now,
updatedAt: now
}
const newConv = await addConversation(conversation)
convId = newConv._id
}
}
if (!conversation) {
const convRes = await getConversationById(convId)
conversation = convRes.data
}
if (!conversation) throw new Error('conversation not found')
const members = conversation.memberOpenids || []
if (!members.includes(OPENID)) throw new Error('permission denied')
if ((conversation.type || 'single') !== 'single') throw new Error('unsupported conversation type')
// Add message to messages collection
const msgResult = await db.collection('messages').add({
data: {
conversationId: convId,
fromOpenid: OPENID,
content: content.slice(0, 1000),
type: event.msgType || 'text',
createdAt: now
}
const msgResult = await addMessage({
conversationId: convId,
fromOpenid: OPENID,
content: content.slice(0, 1000),
type: event.msgType || 'text',
createdAt: now
})
// Update conversation: last message + unread counts for all recipients
const convRes = await db.collection('conversations').doc(convId).get()
const members = convRes.data.memberOpenids || []
const recipients = members.filter(id => id !== OPENID)
const updateData = {
@@ -78,11 +223,21 @@ exports.main = async event => {
})
// Rebuild unreadOpenids to include all recipients (remove duplicates)
const existingUnread = convRes.data.unreadOpenids || []
const existingUnread = conversation.unreadOpenids || []
const newUnread = [...new Set([...existingUnread, ...recipients])]
updateData.unreadOpenids = newUnread
await db.collection('conversations').doc(convId).update({ data: updateData })
await db.collection(CONVERSATIONS_COLLECTION).doc(convId).update({ data: updateData })
await trimConversationMessages(convId)
const senderRes = await db.collection('users').where({ openid: OPENID }).limit(1).get().catch(() => ({ data: [] }))
await sendChatNotifications({
recipients,
conversationId: convId,
senderName: senderRes.data[0]?.nickname || conversation.title || '汪友',
content,
type: event.msgType || 'text',
createdAt: now
})
return { messageId: msgResult._id, conversationId: convId }
}

View File

@@ -3,6 +3,7 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
const SERVER_MESSAGE_LIMIT = 50
function timeText(ts) {
if (!ts) return ''
@@ -18,12 +19,30 @@ async function userToPeer(u) {
return { id: u._id, name: u.nickname || '用户', avatarKey: u.avatarKey || 'gradient-avatar-1', avatarUrl: u.avatarUrl || '' }
}
function normalizeLimit(value) {
const limit = Number(value || SERVER_MESSAGE_LIMIT)
if (!Number.isFinite(limit)) return SERVER_MESSAGE_LIMIT
return Math.max(1, Math.min(Math.floor(limit), SERVER_MESSAGE_LIMIT))
}
function messageToClient(m, openid) {
return {
_id: m._id,
fromMe: m.fromOpenid === openid,
content: m.content,
type: m.type || 'text',
createdAt: m.createdAt,
timeText: timeText(m.createdAt)
}
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
let convId = event.conversationId || ''
let peer = null
const pageSize = normalizeLimit(event.pageSize || event.limit)
// Open by target user — resolve peer and find an existing conversation.
if (!convId && event.targetUserId) {
@@ -56,27 +75,32 @@ exports.main = async event => {
}
let messages = []
let nextCursor = ''
if (convId) {
const query = { conversationId: convId }
const cursor = Number(event.cursor || 0)
if (Number.isFinite(cursor) && cursor > 0) query.createdAt = _.lt(cursor)
const res = await db.collection('messages')
.where({ conversationId: convId })
.where(query)
.orderBy('createdAt', 'desc')
.limit(50)
.limit(pageSize + 1)
.get()
.catch(() => ({ data: [] }))
messages = res.data.reverse().map(m => ({
_id: m._id,
fromMe: m.fromOpenid === OPENID,
content: m.content,
type: m.type || 'text',
createdAt: m.createdAt,
timeText: timeText(m.createdAt)
}))
const page = res.data.slice(0, pageSize)
messages = page.reverse().map(m => messageToClient(m, OPENID))
if (res.data.length > pageSize && messages[0]) {
nextCursor = String(messages[0].createdAt || '')
}
// Mark this conversation read for me.
await db.collection('conversations').doc(convId).update({
data: { unreadOpenids: _.pull(OPENID), [`unreadCounts.${OPENID}`]: 0 }
}).catch(() => {})
// Mark this conversation read for me only when the client asks for it.
// Silent polling and loading older history should not write the conversation.
if (event.markRead !== false) {
await db.collection('conversations').doc(convId).update({
data: { unreadOpenids: _.pull(OPENID), [`unreadCounts.${OPENID}`]: 0 }
}).catch(() => {})
}
}
return { conversationId: convId, peer, messages }
return { conversationId: convId, peer, messages, nextCursor }
}

View File

@@ -4,108 +4,172 @@ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
async function fetchUsers(ids) {
if (!ids.length) return []
// Only people within this radius are "nearby". Online = location reported recently.
const RADIUS_METERS = 5000
const ONLINE_WINDOW_MS = 5 * 60 * 1000
// How many visible locations to scan in memory. No geo index is required this way
// (geoNear would need a manually-created index in the cloud console); fine for an
// early-stage user base. Switch to geoNear on `locations.geo` if this grows large.
const MAX_SCAN = 500
const MAX_RESULTS = 50
// In WeChat 云开发, querying a collection that was never created throws
// ("collection not exists"). Treat that as an empty result so an unused
// collection (e.g. follows/matches before anyone follows/likes) can't break
// the whole nearby query.
function isMissingCollection(e) {
if (!e) return false
const msg = String(e.errMsg || e.message || '')
return e.errCode === -502005 || /not exist/i.test(msg) || /CollectionNotExists/i.test(msg)
}
async function safeGet(query) {
try {
return await query.get()
} catch (e) {
if (isMissingCollection(e)) return { data: [] }
throw e
}
}
async function fetchByChunks(collection, field, values) {
const out = []
for (let i = 0; i < ids.length; i += 100) {
const chunk = ids.slice(i, i + 100)
const res = await db.collection('users').where({ _id: _.in(chunk) }).limit(100).get()
for (let i = 0; i < values.length; i += 100) {
const chunk = values.slice(i, i + 100)
const res = await safeGet(db.collection(collection).where({ [field]: _.in(chunk) }).limit(100))
out.push(...res.data)
}
return out
}
function hashId(id) {
let h = 5381
for (let i = 0; i < id.length; i++) {
h = ((h << 5) + h) ^ id.charCodeAt(i)
h = h >>> 0 // keep as unsigned 32-bit
}
return h
}
// Generate a stable map pin position from pet id (fallback abstract layer)
function stablePin(id) {
const h = hashId(id)
const left = 10 + (h % 70)
const top = 15 + ((h >> 8) % 55)
return { left, top }
}
// Scatter a pet deterministically within roughly ±0.9km of the user, so on a
// real map markers cluster nearby (stable per pet id across requests).
function syntheticCoord(id, userLoc) {
const h = hashId(id)
const dLat = ((h % 1600) - 800) / 100000 // ±0.008° ≈ ±0.9km
const dLng = (((h >> 8) % 1600) - 800) / 100000
return { latitude: userLoc.latitude + dLat, longitude: userLoc.longitude + dLng }
}
// Rough distance text from coordinate delta (fallback when no real distance)
function distanceText(petLoc, userLoc) {
if (!petLoc || !userLoc) return null
// Great-circle distance in metres between two {latitude, longitude} points.
function distanceMeters(a, b) {
if (!a || !b || typeof a.latitude !== 'number' || typeof b.latitude !== 'number') return Infinity
const R = 6371000
const dLat = ((petLoc.latitude - userLoc.latitude) * Math.PI) / 180
const dLon = ((petLoc.longitude - userLoc.longitude) * Math.PI) / 180
const a =
const dLat = ((b.latitude - a.latitude) * Math.PI) / 180
const dLon = ((b.longitude - a.longitude) * Math.PI) / 180
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((userLoc.latitude * Math.PI) / 180) *
Math.cos((petLoc.latitude * Math.PI) / 180) *
Math.cos((a.latitude * Math.PI) / 180) *
Math.cos((b.latitude * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2
const meters = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)))
if (meters < 1000) return `${meters}m`
return `${(meters / 1000).toFixed(1)}km`
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
}
function distanceText(meters) {
if (!isFinite(meters)) return null
const m = Math.round(meters)
if (m < 1000) return `${m}m`
return `${(m / 1000).toFixed(1)}km`
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const filter = event.filters?.type || 'all'
const userLoc = event.location || null
const query = {}
if (filter === 'dog' || filter === 'cat') query.species = filter
if (filter === 'online') query.online = true
// No usable origin → nothing to anchor "nearby" to.
if (!userLoc || typeof userLoc.latitude !== 'number' || typeof userLoc.longitude !== 'number') {
return { list: [] }
}
// Identify the caller and their follow graph (for friend/following badges).
let meId = ''
let followingSet = new Set()
let followerSet = new Set()
if (OPENID) {
const meRes = await db.collection('users').where({ openid: OPENID }).limit(1).get()
const meRes = await safeGet(db.collection('users').where({ openid: OPENID }).limit(1))
const me = meRes.data[0]
if (me) {
meId = me._id
const [followingRes, followerRes] = await Promise.all([
db.collection('follows').where({ followerId: meId }).limit(500).get(),
db.collection('follows').where({ followeeId: meId }).limit(500).get()
safeGet(db.collection('follows').where({ followerId: meId }).limit(500)),
safeGet(db.collection('follows').where({ followeeId: meId }).limit(500))
])
followingSet = new Set(followingRes.data.map(f => f.followeeId))
followerSet = new Set(followerRes.data.map(f => f.followerId))
}
}
const res = await db.collection('pets').where(query).limit(50).get()
const ownerIds = [...new Set(res.data.map(pet => pet.ownerId).filter(Boolean))]
const owners = await fetchUsers(ownerIds)
const ownerMap = new Map(owners.map(owner => [owner._id, owner]))
const list = res.data.map(pet => {
const realCoord = pet.location && typeof pet.location.latitude === 'number' ? pet.location : null
const coord = realCoord || (userLoc ? syntheticCoord(pet._id, userLoc) : null)
const owner = ownerMap.get(pet.ownerId)
const isFollowing = followingSet.has(pet.ownerId)
const isFollowedBy = followerSet.has(pet.ownerId)
return {
...pet,
ownerName: owner?.nickname || '',
isMine: Boolean(meId && pet.ownerId === meId),
isFollowing,
isFriend: isFollowing && isFollowedBy,
latitude: coord ? coord.latitude : undefined,
longitude: coord ? coord.longitude : undefined,
distanceText: distanceText(coord, userLoc) || pet.distanceText || null,
pin: stablePin(pet._id)
// 1) Scan visible locations (paginated) and keep those inside the radius.
const near = []
let scanned = 0
for (let skip = 0; skip < MAX_SCAN; skip += 100) {
const res = await safeGet(db.collection('locations').where({ visible: true }).skip(skip).limit(100))
scanned += res.data.length
if (!res.data.length) break
for (const rec of res.data) {
if (!rec.openid || !rec.location) continue
const dist = distanceMeters(userLoc, rec.location)
if (dist <= RADIUS_METERS) near.push({ rec, dist })
}
if (res.data.length < 100) break
}
near.sort((a, b) => a.dist - b.dist)
const top = near.slice(0, MAX_RESULTS)
console.log('[nearbyPets] origin=', userLoc, 'visibleScanned=', scanned, 'inRadius=', near.length, 'nearestKm=', near[0] ? (near[0].dist / 1000).toFixed(2) : 'n/a')
if (!top.length) return { list: [] }
const distByOpenid = new Map()
const recByOpenid = new Map()
const openids = []
top.forEach(({ rec, dist }) => {
if (recByOpenid.has(rec.openid)) return
distByOpenid.set(rec.openid, dist)
recByOpenid.set(rec.openid, rec)
openids.push(rec.openid)
})
return { list }
// 2) Resolve those openids to user records.
const users = await fetchByChunks('users', 'openid', openids)
const userByOpenid = new Map(users.map(u => [u.openid, u]))
const openidByUserId = new Map(users.map(u => [u._id, u.openid]))
const ownerIds = users.map(u => u._id)
if (!ownerIds.length) return { list: [] }
// 3) Their pets (optionally narrowed by species).
let pets = await fetchByChunks('pets', 'ownerId', ownerIds)
if (filter === 'dog' || filter === 'cat') pets = pets.filter(p => p.species === filter)
// 4) Which of these pets the caller already liked.
const likedSet = new Set()
const petIds = pets.map(p => p._id).filter(Boolean)
if (OPENID && petIds.length) {
const liked = await fetchByChunks('matches', 'petId', petIds).then(rows =>
rows.filter(m => m.fromOpenid === OPENID)
)
liked.forEach(m => likedSet.add(m.petId))
}
const now = Date.now()
const list = pets
.map(pet => {
const ownerOpenid = openidByUserId.get(pet.ownerId)
const rec = ownerOpenid ? recByOpenid.get(ownerOpenid) : null
const coord = rec && rec.location ? rec.location : null
const dist = ownerOpenid != null && distByOpenid.has(ownerOpenid) ? distByOpenid.get(ownerOpenid) : Infinity
const owner = userByOpenid.get(ownerOpenid)
const isFollowing = followingSet.has(pet.ownerId)
const isFollowedBy = followerSet.has(pet.ownerId)
return {
...pet,
ownerName: owner?.nickname || '',
isMine: Boolean(meId && pet.ownerId === meId),
isFollowing,
isFriend: isFollowing && isFollowedBy,
likedByMe: likedSet.has(pet._id),
latitude: coord ? coord.latitude : undefined,
longitude: coord ? coord.longitude : undefined,
distanceText: distanceText(dist),
online: rec ? now - (rec.updatedAt || 0) < ONLINE_WINDOW_MS : false,
_dist: dist
}
})
.sort((a, b) => a._dist - b._dist)
list.forEach(p => delete p._dist)
const finalList = filter === 'online' ? list.filter(p => p.online) : list
console.log('[nearbyPets] nearbyUsers=', openids.length, 'matchedUsers=', users.length, 'pets=', pets.length, 'returned=', finalList.length)
return { list: finalList }
}

View File

@@ -2,6 +2,7 @@ const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const MAX_PETS_PER_USER = 10
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
@@ -20,8 +21,12 @@ exports.main = async event => {
return { petId: event.pet._id }
}
const countRes = await db.collection('pets').where({ ownerId: users.data[0]._id }).count()
if ((countRes.total || 0) >= MAX_PETS_PER_USER) {
throw new Error(`最多只能添加 ${MAX_PETS_PER_USER} 只宠物`)
}
pet.createdAt = Date.now()
const result = await db.collection('pets').add({ data: pet })
return { petId: result._id }
}

View File

@@ -37,7 +37,7 @@ exports.main = async event => {
const post = {
authorOpenid: OPENID,
authorId: user._id,
authorSnapshot: { name: user.nickname, avatarKey: user.avatarKey },
authorSnapshot: { name: user.nickname, avatarKey: user.avatarKey, avatarUrl: user.avatarUrl || '' },
petId: event.petId || '',
petSnapshot,
content: content.slice(0, 2000),
@@ -50,7 +50,7 @@ exports.main = async event => {
? { latitude: event.latitude, longitude: event.longitude }
: null,
visibility: event.visibility || 'public',
counts: { likes: 0, comments: 0, shares: 0, favorites: 0 },
counts: { likes: 0, comments: 0, shares: 0 },
createdAt: now,
updatedAt: now
}

View File

@@ -0,0 +1,66 @@
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
function isCloudFileId(v) {
return typeof v === 'string' && v.startsWith('cloud://')
}
function extractMediaId(m) {
if (!m) return ''
if (typeof m === 'string') return m
return m.fileId || m.fileID || m.url || ''
}
// Remove all docs matching `where`, tolerating a collection that was never created.
async function safeRemoveWhere(collection, where) {
try {
await db.collection(collection).where(where).remove()
} catch (e) {
/* collection missing or nothing to remove */
}
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
if (!event.postId) throw new Error('postId is required')
let postRes
try {
postRes = await db.collection('posts').doc(event.postId).get()
} catch (_) {
return { ok: true } // already gone
}
const post = postRes.data
if (!post) return { ok: true }
if (post.authorOpenid !== OPENID) throw new Error('not allowed')
await db.collection('posts').doc(event.postId).remove()
// Decrement the author's post count (never below 0 in practice).
if (post.authorId) {
await db.collection('users').doc(post.authorId).update({
data: { 'stats.posts': _.inc(-1) }
}).catch(() => {})
}
// Best-effort cleanup of derived data so no orphans linger.
await Promise.all([
safeRemoveWhere('postLikes', { postId: event.postId }),
safeRemoveWhere('postFavorites', { postId: event.postId }),
safeRemoveWhere('comments', { postId: event.postId })
])
// Best-effort removal of the post's uploaded media from storage.
const fileList = (Array.isArray(post.media) ? post.media : [])
.map(extractMediaId)
.filter(isCloudFileId)
if (fileList.length) {
await cloud.deleteCloudFile({ fileList }).catch(() => {})
}
return { ok: true }
}

View File

@@ -1,9 +1,8 @@
{
"name": "postFavorite",
"name": "postDelete",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}

View File

@@ -29,63 +29,116 @@ function formatTimeText(ts) {
return `${d.getMonth() + 1}${d.getDate()}`
}
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) || ''
}
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const postId = String(event.postId || '').trim()
if (!postId) throw new Error('postId is required')
const commentPageSize = Math.max(1, Math.min(Number(event.commentPageSize || 50), 100))
const postRes = await db.collection('posts').doc(postId).get().catch(() => null)
if (!postRes || !postRes.data) return { post: null, comments: [] }
const post = postRes.data
// Resolve the author's current profile (fresh avatar), liked/favorited state.
// Resolve the author's current profile (fresh avatar) and liked state.
let author = null
if (post.authorId) {
const authorRes = await safeGet('users', { _id: post.authorId }, 1)
author = authorRes.data[0] || null
}
if (!author && post.authorOpenid) {
const authorRes = await safeGet('users', { openid: post.authorOpenid }, 1)
author = authorRes.data[0] || null
}
let likedByMe = false
let favoritedByMe = false
if (OPENID) {
const [likedRes, favRes] = await Promise.all([
safeGet('postLikes', { openid: OPENID, postId }, 1),
safeGet('postFavorites', { openid: OPENID, postId }, 1)
])
const likedRes = await safeGet('postLikes', { openid: OPENID, postId }, 1)
likedByMe = likedRes.data.length > 0
favoritedByMe = favRes.data.length > 0
}
const postAssetUrls = [
(author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '',
...(Array.isArray(post.media) ? post.media.map(extractMediaUrl) : [])
]
const postTempUrlMap = await resolveTempUrlMap(postAssetUrls)
const postMedia = Array.isArray(post.media)
? post.media
.map(extractMediaUrl)
.map(url => readableUrl(url, postTempUrlMap))
.filter(Boolean)
: []
const mappedPost = {
_id: post._id,
author: {
id: post.authorId || '',
id: (author && author._id) || post.authorId || '',
name: (author && author.nickname) || post.authorSnapshot?.name || '用户',
avatarKey: (author && author.avatarKey) || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
avatarUrl: (author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || ''
avatarUrl: readableUrl((author && author.avatarUrl) || post.authorSnapshot?.avatarUrl || '', postTempUrlMap)
},
pet: post.petSnapshot || { name: '', breed: '' },
body: post.content || '',
topics: post.topics || [],
media: Array.isArray(post.media)
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
: [],
media: postMedia,
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,
favorites: post.counts?.favorites || 0
comments: post.counts?.comments || 0
},
likedByMe,
favoritedByMe
likedByMe
}
// Comments, oldest first. Resolve each author's current profile for avatars.
const commentsRes = await safeGet('comments', { postId }, 200, { field: 'createdAt', direction: 'asc' })
const commentAuthorIds = [...new Set(commentsRes.data.map(c => c.authorId).filter(Boolean))]
const commentWhere = { postId }
const commentCursor = Number(event.commentCursor || 0)
if (Number.isFinite(commentCursor) && commentCursor > 0) commentWhere.createdAt = _.gt(commentCursor)
const commentsRes = await safeGet(
'comments',
commentWhere,
commentPageSize + 1,
{ field: 'createdAt', direction: 'asc' }
)
const commentPage = commentsRes.data.slice(0, commentPageSize)
const commentsNextCursor = commentsRes.data.length > commentPageSize && commentPage[commentPage.length - 1]
? String(commentPage[commentPage.length - 1].createdAt || '')
: ''
const commentAuthorIds = [...new Set(commentPage.map(c => c.authorId).filter(Boolean))]
const authorMap = new Map()
for (let i = 0; i < commentAuthorIds.length; i += 100) {
const chunk = commentAuthorIds.slice(i, i + 100)
@@ -93,20 +146,34 @@ exports.main = async event => {
usersRes.data.forEach(u => authorMap.set(u._id, u))
}
const comments = commentsRes.data.map(c => {
const u = authorMap.get(c.authorId)
const commentAuthorOpenids = [...new Set(commentPage.map(c => c.openid).filter(Boolean))]
const authorOpenidMap = new Map()
for (let i = 0; i < commentAuthorOpenids.length; i += 100) {
const chunk = commentAuthorOpenids.slice(i, i + 100)
const usersRes = await safeGet('users', { openid: _.in(chunk) }, 100)
usersRes.data.forEach(u => authorOpenidMap.set(u.openid, u))
}
const findCommentAuthor = comment => authorMap.get(comment.authorId) || authorOpenidMap.get(comment.openid)
const commentTempUrlMap = await resolveTempUrlMap(commentPage.map(c => {
const u = findCommentAuthor(c)
return (u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || ''
}))
const comments = commentPage.map(c => {
const u = findCommentAuthor(c)
return {
_id: c._id,
author: {
id: c.authorId || '',
id: (u && u._id) || c.authorId || '',
name: (u && u.nickname) || c.authorSnapshot?.name || '用户',
avatarKey: (u && u.avatarKey) || c.authorSnapshot?.avatarKey || 'gradient-avatar-1',
avatarUrl: (u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || ''
avatarUrl: readableUrl((u && u.avatarUrl) || c.authorSnapshot?.avatarUrl || '', commentTempUrlMap)
},
content: c.content || '',
timeText: formatTimeText(c.createdAt || Date.now())
}
})
return { post: mappedPost, comments }
return { post: mappedPost, comments, commentsNextCursor }
}

View File

@@ -1,32 +0,0 @@
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
const { postId, favorited } = event
if (!postId) throw new Error('postId is required')
const existed = await db.collection('postFavorites').where({ postId, openid: OPENID }).limit(1).get()
let delta = 0
if (favorited && !existed.data.length) {
await db.collection('postFavorites').add({ data: { postId, openid: OPENID, createdAt: Date.now() } })
delta = 1
}
if (!favorited && existed.data.length) {
await db.collection('postFavorites').doc(existed.data[0]._id).remove()
delta = -1
}
if (delta) {
await db.collection('posts').doc(postId).update({ data: { 'counts.favorites': _.inc(delta) } })
}
const post = await db.collection('posts').doc(postId).get()
return { favorited: Boolean(favorited), favorites: post.data.counts?.favorites || 0 }
}

View File

@@ -0,0 +1,36 @@
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const ALLOWED_VISIBILITY = ['public', 'friends', 'private']
// Lightweight edit: only the post's text content and visibility can change here.
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
if (!event.postId) throw new Error('postId is required')
let postRes
try {
postRes = await db.collection('posts').doc(event.postId).get()
} catch (_) {
throw new Error('post not found')
}
const post = postRes.data
if (!post) throw new Error('post not found')
if (post.authorOpenid !== OPENID) throw new Error('not allowed')
const data = { updatedAt: Date.now() }
if (typeof event.content === 'string') {
const content = event.content.trim()
if (!content) throw new Error('content is required')
data.content = content.slice(0, 2000)
}
if (typeof event.visibility === 'string' && ALLOWED_VISIBILITY.includes(event.visibility)) {
data.visibility = event.visibility
}
await db.collection('posts').doc(event.postId).update({ data })
return { ok: true }
}

View File

@@ -0,0 +1,8 @@
{
"name": "postUpdate",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}

View File

@@ -16,13 +16,12 @@ async function safeCount(collection, where) {
// Derive live stats from the source collections so the displayed numbers are
// always accurate instead of relying on a stored field that can drift.
async function computeStats(user) {
const [posts, favorites, following, followers] = await Promise.all([
const [posts, following, followers] = await Promise.all([
safeCount('posts', { authorId: user._id }),
safeCount('postFavorites', { openid: user.openid }),
safeCount('follows', { followerId: user._id }),
safeCount('follows', { followeeId: user._id })
])
return { posts, following, followers, favorites }
return { posts, following, followers }
}
exports.main = async event => {

View File

@@ -26,11 +26,44 @@ function formatTimeText(ts) {
return `${d.getMonth() + 1}${d.getDate()}`
}
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) || ''
}
// List a user's own posts. Defaults to the caller; pass authorId to view
// another user (only public posts are returned for others).
exports.main = async event => {
const { OPENID } = cloud.getWXContext()
if (!OPENID) throw new Error('no openid in wx context')
const pageSize = Math.max(1, Math.min(Number(event.pageSize || 20), 50))
let authorOpenid = OPENID
let viewingSelf = true
@@ -43,35 +76,59 @@ exports.main = async event => {
const where = { authorOpenid }
if (!viewingSelf) where.visibility = 'public'
const cursor = Number(event.cursor || 0)
if (Number.isFinite(cursor) && cursor > 0) where.createdAt = _.lt(cursor)
const res = await db.collection('posts')
.where(where)
.orderBy('createdAt', 'desc')
.limit(50)
.limit(pageSize + 1)
.get()
if (!res.data.length) return { list: [] }
if (!res.data.length) return { list: [], nextCursor: '' }
const postIds = res.data.map(p => p._id)
const [likedRes, favRes] = await Promise.all([
safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) }),
safeGet('postFavorites', { openid: OPENID, postId: _.in(postIds) })
])
const page = res.data.slice(0, pageSize)
const nextCursor = res.data.length > pageSize && page[page.length - 1]
? String(page[page.length - 1].createdAt || '')
: ''
const authorRes = await db.collection('users').where({ openid: authorOpenid }).limit(1).get().catch(() => null)
const authorUser = authorRes && authorRes.data ? authorRes.data[0] : null
// Real presence: author has a recent, visible location (same signal as 附近).
const ONLINE_WINDOW_MS = 5 * 60 * 1000
const locRes = await safeGet('locations', { openid: authorOpenid }, 1)
const authorLoc = locRes.data[0]
const authorOnline = Boolean(
authorLoc && authorLoc.visible && authorLoc.updatedAt && Date.now() - authorLoc.updatedAt < ONLINE_WINDOW_MS
)
const postIds = page.map(p => p._id)
const likedRes = await safeGet('postLikes', { openid: OPENID, postId: _.in(postIds) })
const likedSet = new Set(likedRes.data.map(l => l.postId))
const favSet = new Set(favRes.data.map(f => f.postId))
const assetUrls = [
authorUser?.avatarUrl || '',
...page.flatMap(post => Array.isArray(post.media) ? post.media.map(extractMediaUrl) : [])
]
const tempUrlMap = await resolveTempUrlMap(assetUrls)
const list = res.data.map(post => ({
const list = page.map(post => ({
_id: post._id,
author: {
id: post.authorId || '',
name: post.authorSnapshot?.name || '用户',
avatarKey: post.authorSnapshot?.avatarKey || 'gradient-avatar-1'
avatarKey: authorUser?.avatarKey || post.authorSnapshot?.avatarKey || 'gradient-avatar-1',
avatarUrl: readableUrl(authorUser?.avatarUrl || post.authorSnapshot?.avatarUrl || '', tempUrlMap),
online: authorOnline
},
pet: post.petSnapshot || { name: '', breed: '' },
body: post.content || '',
topics: post.topics || [],
visibility: post.visibility || 'public',
media: Array.isArray(post.media)
? post.media.map(m => (typeof m === 'string' ? m : m && (m.url || m.fileId))).filter(Boolean)
? post.media
.map(extractMediaUrl)
.map(url => readableUrl(url, tempUrlMap))
.filter(Boolean)
: [],
mediaTone: post.mediaTone || 'pet-1',
sticker: post.sticker || undefined,
@@ -79,12 +136,10 @@ exports.main = async event => {
timeText: formatTimeText(post.createdAt || Date.now()),
counts: {
likes: post.counts?.likes || 0,
comments: post.counts?.comments || 0,
favorites: post.counts?.favorites || 0
comments: post.counts?.comments || 0
},
likedByMe: likedSet.has(post._id),
favoritedByMe: favSet.has(post._id)
likedByMe: likedSet.has(post._id)
}))
return { list }
return { list, nextCursor }
}