6176f2c341
- records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换 - leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署) - index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡 - settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px - theme: 新增 teal 主题;暗黑背景统一对齐 - 圆形头像双保险(image 自身 border-radius)修复月/年榜方图 注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
106 lines
3.9 KiB
JavaScript
106 lines
3.9 KiB
JavaScript
const cloud = require('wx-server-sdk')
|
|
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
|
const db = cloud.database()
|
|
|
|
const COLLECTION = 'plank_data'
|
|
const PAGE_SIZE = 100
|
|
|
|
/**
|
|
* One-shot admin tool: deduplicate plank_data docs by _openid.
|
|
*
|
|
* Background: before the client-side fix in utils/cloud.js (always locate
|
|
* by _openid before add()), every cold start of the mini program created
|
|
* a brand-new cloud doc for the same user. The leaderboard cloud function
|
|
* then summed duration across N docs for the same _openid, inflating the
|
|
* board. This function walks the whole collection, groups by _openid, and
|
|
* deletes every doc except the most-recently-updated one for each user.
|
|
*
|
|
* DEPLOYMENT NOTES — read before invoking:
|
|
* 1. Upload + deploy this function from the WeChat DevTools or `wx-cli`.
|
|
* 2. The default collection security rule only allows reading docs whose
|
|
* `doc._openid == auth.openid`, which prevents this function from
|
|
* seeing other users' docs. Temporarily flip the collection's read
|
|
* permission to "所有用户可读" in the CloudBase console, OR configure
|
|
* the function's role to a privileged account that bypasses the
|
|
* custom rule.
|
|
* 3. Invoke the function once from the CloudBase console test panel.
|
|
* 4. **Delete or disable this function immediately afterwards** so it
|
|
* can't be re-run by a user. It's destructive.
|
|
*/
|
|
exports.main = async () => {
|
|
// 安全闸门:仅允许白名单内的管理员调用,否则直接拒绝,防止被任意用户
|
|
// 触发批量删除(该函数以特权身份运行且会删文档)。白名单在云函数环境变量
|
|
// ADMIN_OPENIDS 中以逗号分隔配置;未配置时默认拒绝(失败安全),避免误部署后被滥用。
|
|
const ctx = cloud.getWXContext()
|
|
const caller = (ctx && ctx.OPENID) || ''
|
|
const allowlist = (process.env.ADMIN_OPENIDS || '')
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
if (!caller || allowlist.length === 0 || !allowlist.includes(caller)) {
|
|
return { err: 'forbidden', msg: '无权限执行该操作' }
|
|
}
|
|
|
|
// 1. Page through every doc in the collection.
|
|
const docs = []
|
|
let skip = 0
|
|
while (true) {
|
|
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
|
|
if (!res.data || res.data.length === 0) break
|
|
docs.push(...res.data)
|
|
if (res.data.length < PAGE_SIZE) break
|
|
skip += PAGE_SIZE
|
|
}
|
|
|
|
// 2. Sort by updatedAt descending so the freshest doc per openid is
|
|
// first; that one is the one we keep.
|
|
const tsOf = (d) => {
|
|
if (!d.updatedAt) return 0
|
|
// CloudBase sometimes returns Date objects, sometimes ISO strings
|
|
// depending on the SDK version — normalize both.
|
|
if (d.updatedAt instanceof Date) return d.updatedAt.getTime()
|
|
const t = new Date(d.updatedAt).getTime()
|
|
return Number.isFinite(t) ? t : 0
|
|
}
|
|
docs.sort((a, b) => tsOf(b) - tsOf(a))
|
|
|
|
// 3. Walk the sorted list, marking all but the first occurrence of
|
|
// each openid for deletion.
|
|
const seen = new Set()
|
|
const toDelete = []
|
|
const keptByOpenid = {}
|
|
for (const doc of docs) {
|
|
const openid = doc._openid || 'unknown'
|
|
if (seen.has(openid)) {
|
|
toDelete.push({ id: doc._id, openid })
|
|
} else {
|
|
seen.add(openid)
|
|
keptByOpenid[openid] = doc._id
|
|
}
|
|
}
|
|
|
|
// 4. Delete. Remove one at a time so a single permission/permission
|
|
// failure doesn't abort the whole cleanup.
|
|
let deleted = 0
|
|
const errors = []
|
|
for (const { id, openid } of toDelete) {
|
|
try {
|
|
await db.collection(COLLECTION).doc(id).remove()
|
|
deleted++
|
|
} catch (e) {
|
|
errors.push({ id, openid, msg: (e && e.message) || String(e) })
|
|
}
|
|
}
|
|
|
|
return {
|
|
scanned: docs.length,
|
|
uniqueOpenids: seen.size,
|
|
duplicatesFound: toDelete.length,
|
|
duplicatesDeleted: deleted,
|
|
duplicatesFailed: errors.length,
|
|
errors,
|
|
sampleKept: Object.fromEntries(
|
|
Object.entries(keptByOpenid).slice(0, 5)
|
|
)
|
|
}
|
|
} |