// 云函数 walkSave — 保存遛狗 + 原子更新 stats + 连续天数 + 成就判定 // 契约见 docs/03-云函数设计.md §3 / §6 // action: 'create'(默认,结束遛狗时调)| 'attach'(成果卡补充选填字段时调) const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); const _ = db.command; const DAY = 86400000; const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 旧档无 stride 时兜底用 // 时间戳 → 本地 yyyy-mm-dd(默认 +8) function toDateStr(ts, tz = 8) { const d = new Date(ts + tz * 3600 * 1000); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function diffDays(a, b) { return Math.round((Date.parse(b + 'T00:00:00Z') - Date.parse(a + 'T00:00:00Z')) / DAY); } // 本地小时(默认 +8),用于「早起鸟」场景判定 function localHour(ts, tz = 8) { return new Date(ts + tz * 3600 * 1000).getUTCHours(); } // 连续天数算法(docs/05 R10):从该狗全部 walkDate 重算,保证补记任意顺序都能正确续/断签。 // 同日多次自动去重;以最新日期为锚,向前连续 +1,遇断签停止。 async function computeStreak(openid, dogId) { const res = await db.collection('walks') .where({ _openid: openid, dogIds: dogId }) .field({ walkDate: true }) .orderBy('walkDate', 'desc') .limit(1000) .get(); const dates = Array.from(new Set(res.data.map((w) => w.walkDate).filter(Boolean))).sort(); // 升序 if (!dates.length) return { currentStreak: 0, lastWalkDate: '' }; const lastWalkDate = dates[dates.length - 1]; let streak = 1; for (let i = dates.length - 1; i > 0; i--) { if (diffDays(dates[i - 1], dates[i]) === 1) streak++; else break; } return { currentStreak: streak, lastWalkDate }; } // 累计/连续类成就(基于更新后的 stats) const STAT_ACHIEVEMENTS = [ { key: 'first_walk', name: '首遛', cond: (s) => s.totalWalks >= 1 }, { key: 'week_streak', name: '一周不断', cond: (s) => s.currentStreak >= 7 }, { key: 'month_streak', name: '满月坚持', cond: (s) => s.currentStreak >= 30 }, { key: 'hundred_km', name: '百里同行', cond: (s) => s.totalDistance >= 100 }, { key: 'early_bird', name: '早起鸟', cond: (s) => (s.earlyWalks || 0) >= 10 }, // 场景类:累计早遛 10 次(仅实时遛狗计入) ]; // 解锁成就(去重:同一 dog 同一 key 不重复) async function unlockAchievements(openid, dogId, candidates) { if (!candidates.length) return []; const col = db.collection('dog_achievements'); const existing = await col.where({ dogId }).field({ achievementKey: true }).get(); const have = new Set(existing.data.map((d) => d.achievementKey)); const fresh = candidates.filter((c) => !have.has(c.key)); const now = Date.now(); for (const c of fresh) { await col.add({ data: { _openid: openid, dogId, achievementKey: c.key, unlockedAt: now } }); } return fresh.map((c) => ({ key: c.key, name: c.name })); } exports.main = async (event) => { const { OPENID } = cloud.getWXContext(); const action = event.action || 'create'; // ===== attach:成果卡补充选填字段(docs/05 R6)===== if (action === 'attach') { const { walkId } = event; if (!walkId) return { ok: false, code: 'INVALID', msg: '缺少 walkId' }; const walkRes = await db.collection('walks').doc(walkId).get().catch(() => null); if (!walkRes || !walkRes.data || walkRes.data._openid !== OPENID) { return { ok: false, code: 'FORBIDDEN', msg: '无权操作' }; } const weather = event.weather || null; const patch = { weather, mood: event.mood || null, note: event.note || null, photos: event.photos || [], }; await db.collection('walks').doc(walkId).update({ data: patch }); // 场景类成就:雨天战士(天气是事后补的,故在此判定) let newAchievements = []; if (weather === 'rainy') { for (const dogId of walkRes.data.dogIds || []) { const got = await unlockAchievements(OPENID, dogId, [{ key: 'rainy_warrior', name: '雨天战士' }]); newAchievements = newAchievements.concat(got); } } return { ok: true, data: { newAchievements } }; } // ===== finish:结束遛狗,落库 + 统计 + 成就 ===== const { dogIds, startAt, endAt, duration } = event; // 客户端只传 GPS 距离(米,未授权为 null);狗步由服务端按每只狗 stride 算(docs/05 R3) const distanceMeters = event.distanceMeters != null ? Number(event.distanceMeters) : null; const distance = distanceMeters != null ? +(distanceMeters / 1000).toFixed(2) : null; // 公里 const pooped = !!event.pooped; if (!Array.isArray(dogIds) || dogIds.length === 0) { return { ok: false, code: 'INVALID', msg: '缺少遛狗对象' }; } const now = Date.now(); const walkDate = toDateStr(endAt || now); // 早起鸟:以开始时间的本地小时判定,<7 点记一次「早遛」(场景类,仅实时遛狗计入) const isEarly = startAt != null && localHour(startAt) < 7; const addRes = await db.collection('walks').add({ data: { _openid: OPENID, dogIds, startAt, endAt, duration, distance, dogSteps: null, pooped, // dogSteps(代表狗)循环后回填 weather: null, mood: null, note: null, photos: [], source: 'realtime', isManual: false, countsForRanking: true, walkDate, createdAt: now, }, }); const walkId = addRes._id; const results = []; let newAchievements = []; let repSteps = null; // 代表狗(dogIds[0])的狗步,写回 walk 用 for (const dogId of dogIds) { const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null); if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) continue; const dog = dogRes.data; const s = dog.stats || { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' }; // 每只狗按自身 stride 换算狗步(旧档无 stride 时兜底 0.35m) const stride = dog.stride || BASE_STRIDE[dog.sizeLevel] || 0.35; const steps = distanceMeters != null ? Math.round(distanceMeters / stride) : null; if (dogId === dogIds[0]) repSteps = steps; // 连续天数按全部历史重算(已含本条,刚 add 入库) const streakInfo = await computeStreak(OPENID, dogId); const newStats = { totalWalks: (s.totalWalks || 0) + 1, totalDistance: +(((s.totalDistance || 0) + (distance || 0)).toFixed(2)), totalSteps: (s.totalSteps || 0) + (steps || 0), earlyWalks: (s.earlyWalks || 0) + (isEarly ? 1 : 0), currentStreak: streakInfo.currentStreak, lastWalkDate: streakInfo.lastWalkDate, }; await db.collection('dogs').doc(dogId).update({ data: { stats: newStats } }); const satisfied = STAT_ACHIEVEMENTS .filter((a) => a.cond(newStats)) .map((a) => ({ key: a.key, name: a.name })); newAchievements = newAchievements.concat(await unlockAchievements(OPENID, dogId, satisfied)); results.push({ dogId, name: dog.name, duration, dogSteps: steps, distance, streak: newStats.currentStreak }); } if (repSteps != null) { await db.collection('walks').doc(walkId).update({ data: { dogSteps: repSteps } }); } // 用户累计次数 +1 await db.collection('users').where({ _openid: OPENID }).update({ data: { walkCount: _.inc(1) } }); return { ok: true, data: { walkId, results, newAchievements } }; };