Files
Pet3/cloudfunctions/walkSave/index.js
2026-06-25 18:24:29 +08:00

174 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 云函数 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 } };
};