0.0.0
This commit is contained in:
96
cloudfunctions/dogManage/index.js
Normal file
96
cloudfunctions/dogManage/index.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// 云函数 dogManage — 狗狗 create/update/list,建档与编辑复用
|
||||
// 契约见 docs/03-云函数设计.md §2
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
|
||||
// 体型档位 → 步幅模型(与 miniprogram/utils/dogStep.js 保持一致,docs/05 R3)
|
||||
const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 米/步
|
||||
const STD_WEIGHT = { xs: 3, s: 7, m: 15, l: 28, xl: 45 }; // 档位标准体重 kg
|
||||
// 犬种 → 体型档位;未列出的(中华田园犬/串串/其他)需前端让用户手动选 sizeLevel
|
||||
const BREED_SIZE = {
|
||||
柯基: 's', 比熊: 'xs', 泰迪: 's', 柴犬: 'm', 金毛: 'l',
|
||||
拉布拉多: 'l', 哈士奇: 'l', 边境牧羊犬: 'm', 萨摩耶: 'l', 法斗: 's',
|
||||
};
|
||||
const resolveSize = (breed, clientSize) => BREED_SIZE[breed] || clientSize || null;
|
||||
|
||||
function computeStride(sizeLevel, weight) {
|
||||
const base = BASE_STRIDE[sizeLevel];
|
||||
if (!base) return { baseStride: null, stride: null };
|
||||
let stride = base;
|
||||
if (weight) {
|
||||
let f = 1 + (Number(weight) - STD_WEIGHT[sizeLevel]) * 0.005;
|
||||
f = Math.max(0.85, Math.min(1.15, f));
|
||||
stride = +(base * f).toFixed(3);
|
||||
}
|
||||
return { baseStride: base, stride };
|
||||
}
|
||||
|
||||
// 规范化狗狗字段;返回 { error } 表示校验失败
|
||||
function normalize(dog) {
|
||||
const sizeLevel = resolveSize(dog.breed, dog.sizeLevel);
|
||||
if (!sizeLevel) return { error: '请选择体型档位' }; // 未知犬种必须手动选
|
||||
const weight = dog.weight != null ? Number(dog.weight) : null;
|
||||
const { baseStride, stride } = computeStride(sizeLevel, weight);
|
||||
return {
|
||||
data: {
|
||||
name: String(dog.name || '').trim(),
|
||||
breed: String(dog.breed || '').trim(),
|
||||
sizeLevel,
|
||||
baseStride,
|
||||
stride,
|
||||
gender: dog.gender || null,
|
||||
ageStage: dog.ageStage || null,
|
||||
ageText: dog.ageText || '',
|
||||
weight,
|
||||
avatar: dog.avatar || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
exports.main = async (event, context) => {
|
||||
const { OPENID } = cloud.getWXContext();
|
||||
const { action, dog } = event;
|
||||
const dogsCol = db.collection('dogs');
|
||||
|
||||
// ---- list:列出我的狗 ----
|
||||
if (action === 'list') {
|
||||
const res = await dogsCol.where({ _openid: OPENID }).orderBy('createdAt', 'asc').get();
|
||||
return { ok: true, data: res.data };
|
||||
}
|
||||
|
||||
// ---- create:建档 ----
|
||||
if (action === 'create') {
|
||||
if (!dog || !dog.name || !dog.breed) {
|
||||
return { ok: false, code: 'INVALID', msg: '名字和犬种必填' };
|
||||
}
|
||||
const norm = normalize(dog);
|
||||
if (norm.error) return { ok: false, code: 'INVALID', msg: norm.error };
|
||||
const now = Date.now();
|
||||
const doc = {
|
||||
_openid: OPENID,
|
||||
...norm.data,
|
||||
stats: { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' },
|
||||
createdAt: now,
|
||||
};
|
||||
const addRes = await dogsCol.add({ data: doc });
|
||||
return { ok: true, data: { _id: addRes._id, ...doc } };
|
||||
}
|
||||
|
||||
// ---- update:编辑(校验归属,防越权改他人狗)----
|
||||
if (action === 'update') {
|
||||
if (!dog || !dog._id) return { ok: false, code: 'INVALID', msg: '缺少狗狗ID' };
|
||||
if (!dog.name || !dog.breed) return { ok: false, code: 'INVALID', msg: '名字和犬种必填' };
|
||||
|
||||
const target = await dogsCol.doc(dog._id).get().catch(() => null);
|
||||
if (!target || !target.data || target.data._openid !== OPENID) {
|
||||
return { ok: false, code: 'FORBIDDEN', msg: '无权操作' };
|
||||
}
|
||||
const norm = normalize(dog);
|
||||
if (norm.error) return { ok: false, code: 'INVALID', msg: norm.error };
|
||||
await dogsCol.doc(dog._id).update({ data: norm.data });
|
||||
return { ok: true, data: { ...target.data, ...norm.data } };
|
||||
}
|
||||
|
||||
return { ok: false, code: 'UNKNOWN_ACTION', msg: '未知操作' };
|
||||
};
|
||||
9
cloudfunctions/dogManage/package.json
Normal file
9
cloudfunctions/dogManage/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "dogManage",
|
||||
"version": "1.0.0",
|
||||
"description": "狗狗 create/update/list,建档与编辑复用",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
40
cloudfunctions/getDogDetail/index.js
Normal file
40
cloudfunctions/getDogDetail/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// 云函数 getDogDetail — 狗狗主页聚合:dog + 成就墙 + 近期记录
|
||||
// 契约见 docs/03-云函数设计.md §5
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
|
||||
const TOTAL_ACHIEVEMENTS = 6; // 与 miniprogram/utils/achievements.js 保持一致
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { OPENID } = cloud.getWXContext();
|
||||
const { dogId } = event;
|
||||
if (!dogId) return { ok: false, code: 'INVALID', msg: '缺少 dogId' };
|
||||
|
||||
// 校验归属
|
||||
const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null);
|
||||
if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) {
|
||||
return { ok: false, code: 'FORBIDDEN', msg: '无权访问' };
|
||||
}
|
||||
|
||||
const unlocked = await db.collection('dog_achievements')
|
||||
.where({ dogId })
|
||||
.orderBy('unlockedAt', 'desc')
|
||||
.get();
|
||||
|
||||
// dogIds 是数组,等值查询匹配「数组包含」语义
|
||||
const recent = await db.collection('walks')
|
||||
.where({ _openid: OPENID, dogIds: dogId })
|
||||
.orderBy('startAt', 'desc')
|
||||
.limit(10)
|
||||
.get();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
dog: dogRes.data,
|
||||
achievements: { unlocked: unlocked.data, total: TOTAL_ACHIEVEMENTS },
|
||||
recentWalks: recent.data,
|
||||
},
|
||||
};
|
||||
};
|
||||
9
cloudfunctions/getDogDetail/package.json
Normal file
9
cloudfunctions/getDogDetail/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "getDogDetail",
|
||||
"version": "1.0.0",
|
||||
"description": "狗狗主页聚合:dog + 成就墙 + 近期记录",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
35
cloudfunctions/getHistory/index.js
Normal file
35
cloudfunctions/getHistory/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// 云函数 getHistory — 遛狗历史列表(跨全部狗狗,附狗名)
|
||||
// 供「遛狗历史」页与「记录」Tab 使用。
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { OPENID } = cloud.getWXContext();
|
||||
const limit = Math.min(event.limit || 50, 100);
|
||||
|
||||
// 狗名映射
|
||||
const dogsRes = await db.collection('dogs').where({ _openid: OPENID }).get();
|
||||
const nameMap = {};
|
||||
dogsRes.data.forEach((d) => { nameMap[d._id] = d.name; });
|
||||
|
||||
const res = await db.collection('walks')
|
||||
.where({ _openid: OPENID })
|
||||
.orderBy('startAt', 'desc')
|
||||
.limit(limit)
|
||||
.get();
|
||||
|
||||
const walks = res.data.map((w) => ({
|
||||
_id: w._id,
|
||||
dogIds: w.dogIds,
|
||||
dogNames: (w.dogIds || []).map((id) => nameMap[id] || '狗狗'),
|
||||
startAt: w.startAt,
|
||||
duration: w.duration,
|
||||
distance: w.distance,
|
||||
dogSteps: w.dogSteps,
|
||||
isManual: w.isManual,
|
||||
walkDate: w.walkDate,
|
||||
}));
|
||||
|
||||
return { ok: true, data: { walks, total: walks.length } };
|
||||
};
|
||||
9
cloudfunctions/getHistory/package.json
Normal file
9
cloudfunctions/getHistory/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "getHistory",
|
||||
"version": "1.0.0",
|
||||
"description": "遛狗历史列表(跨全部狗狗,附狗名)",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
48
cloudfunctions/login/index.js
Normal file
48
cloudfunctions/login/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// 云函数 login — 登录 + 首次建用户,返回 hasDog 决定跳建档/首页
|
||||
// 契约见 docs/03-云函数设计.md §1
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
|
||||
exports.main = async (event, context) => {
|
||||
const { OPENID } = cloud.getWXContext(); // 可信用户标识
|
||||
const usersCol = db.collection('users');
|
||||
const dogsCol = db.collection('dogs');
|
||||
|
||||
// 1. 查用户,不存在则建档
|
||||
let userInfo;
|
||||
const userRes = await usersCol.where({ _openid: OPENID }).limit(1).get();
|
||||
if (userRes.data.length === 0) {
|
||||
const now = Date.now();
|
||||
const doc = {
|
||||
_openid: OPENID,
|
||||
nickname: '铲屎官',
|
||||
avatar: '',
|
||||
walkCount: 0,
|
||||
joinedAt: now,
|
||||
createdAt: now,
|
||||
};
|
||||
const addRes = await usersCol.add({ data: doc });
|
||||
userInfo = { _id: addRes._id, ...doc };
|
||||
} else {
|
||||
userInfo = userRes.data[0];
|
||||
}
|
||||
|
||||
// 2. 是否已有狗狗 → 决定前端跳建档(0b)还是首页(1)
|
||||
const countRes = await dogsCol.where({ _openid: OPENID }).count();
|
||||
const hasDog = countRes.total > 0;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
userInfo: {
|
||||
openid: OPENID,
|
||||
nickname: userInfo.nickname,
|
||||
avatar: userInfo.avatar,
|
||||
walkCount: userInfo.walkCount,
|
||||
joinedAt: userInfo.joinedAt,
|
||||
},
|
||||
hasDog,
|
||||
},
|
||||
};
|
||||
};
|
||||
9
cloudfunctions/login/package.json
Normal file
9
cloudfunctions/login/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "login",
|
||||
"version": "1.0.0",
|
||||
"description": "登录 + 首次建用户,返回 hasDog 决定跳建档/首页",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
46
cloudfunctions/sendReminder/index.js
Normal file
46
cloudfunctions/sendReminder/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// 云函数 sendReminder — 每日定时给「今天还没遛」的主人发订阅提醒(云调用)
|
||||
// 依赖:① 公众平台创建订阅消息模板,把 ID 填到下方 TEMPLATE_ID(与前端 utils/config.js 一致)
|
||||
// ② 定时触发器(见 cloudbaserc.json 的 triggers / 或控制台配置)
|
||||
// ③ 用户在成果卡点「完成」时已 requestSubscribeMessage 授权(一次授权=一次可发)
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
const _ = db.command;
|
||||
|
||||
const TEMPLATE_ID = ''; // TODO: 填入订阅消息模板 ID
|
||||
|
||||
function todayStr(tz = 8) {
|
||||
const d = new Date(Date.now() + tz * 3600 * 1000);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
exports.main = async () => {
|
||||
if (!TEMPLATE_ID) return { ok: true, skipped: true, msg: '未配置 TEMPLATE_ID,跳过' };
|
||||
|
||||
const today = todayStr();
|
||||
// 今天 lastWalkDate 不是今天的狗 → 其主人需要提醒
|
||||
const dogs = await db.collection('dogs')
|
||||
.where({ 'stats.lastWalkDate': _.neq(today) })
|
||||
.field({ _openid: true })
|
||||
.get();
|
||||
const owners = [...new Set(dogs.data.map((d) => d._openid))];
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const openid of owners) {
|
||||
try {
|
||||
await cloud.openapi.subscribeMessage.send({
|
||||
touser: openid,
|
||||
templateId: TEMPLATE_ID,
|
||||
page: 'pages/home/home',
|
||||
// data 字段需与实际模板字段一一对应,下面为占位示例
|
||||
data: { thing1: { value: '该带狗狗出门遛一圈啦' } },
|
||||
});
|
||||
sent++;
|
||||
} catch (e) {
|
||||
failed++; // 多为「无有效订阅授权」,属正常
|
||||
}
|
||||
}
|
||||
return { ok: true, sent, failed, owners: owners.length };
|
||||
};
|
||||
9
cloudfunctions/sendReminder/package.json
Normal file
9
cloudfunctions/sendReminder/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "sendReminder",
|
||||
"version": "1.0.0",
|
||||
"description": "每日定时:给今天还没遛狗的主人发订阅提醒",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
111
cloudfunctions/walkManual/index.js
Normal file
111
cloudfunctions/walkManual/index.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// 云函数 walkManual — 手动补记(docs/05 R7)
|
||||
// 规则:isManual=true、countsForRanking=false(不计排行榜,防刷);可维持连续打卡不断签。
|
||||
const cloud = require('wx-server-sdk');
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
|
||||
const db = cloud.database();
|
||||
const _ = db.command;
|
||||
|
||||
const DAY = 86400000;
|
||||
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);
|
||||
}
|
||||
// 连续天数:从该狗全部 walkDate 重算(与 walkSave 一致),保证补记任意顺序都能正确续/断签(docs/05 R10)
|
||||
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 };
|
||||
}
|
||||
|
||||
// 补记仅触发连续/累计类成就(不触发分享/场景类)
|
||||
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 },
|
||||
];
|
||||
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 { dogId, startAt, duration } = event;
|
||||
const distance = event.distance != null ? event.distance : null;
|
||||
if (!dogId || !startAt || !duration) {
|
||||
return { ok: false, code: 'INVALID', msg: '缺少必要信息' };
|
||||
}
|
||||
|
||||
// 校验归属
|
||||
const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null);
|
||||
if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) {
|
||||
return { ok: false, code: 'FORBIDDEN', msg: '无权操作' };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const walkDate = toDateStr(startAt);
|
||||
const addRes = await db.collection('walks').add({
|
||||
data: {
|
||||
_openid: OPENID,
|
||||
dogIds: [dogId],
|
||||
startAt,
|
||||
endAt: startAt + duration * 1000,
|
||||
duration,
|
||||
distance, dogSteps: null, pooped: false,
|
||||
weather: null, mood: null, note: null, photos: [],
|
||||
source: 'manual', // 来源标识
|
||||
isManual: true, // 补记标识
|
||||
countsForRanking: false, // 不计排行榜
|
||||
walkDate,
|
||||
createdAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// 更新狗狗统计(维持连续);连续天数按全部历史重算(已含本条补记)
|
||||
const dog = dogRes.data;
|
||||
const s = dog.stats || { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' };
|
||||
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,
|
||||
earlyWalks: s.earlyWalks || 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 }));
|
||||
const newAchievements = await unlockAchievements(OPENID, dogId, satisfied);
|
||||
|
||||
await db.collection('users').where({ _openid: OPENID }).update({ data: { walkCount: _.inc(1) } });
|
||||
|
||||
return { ok: true, data: { walkId: addRes._id, newAchievements } };
|
||||
};
|
||||
9
cloudfunctions/walkManual/package.json
Normal file
9
cloudfunctions/walkManual/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "walkManual",
|
||||
"version": "1.0.0",
|
||||
"description": "手动补记:维持连续但不计排行榜",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
173
cloudfunctions/walkSave/index.js
Normal file
173
cloudfunctions/walkSave/index.js
Normal file
@@ -0,0 +1,173 @@
|
||||
// 云函数 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 } };
|
||||
};
|
||||
9
cloudfunctions/walkSave/package.json
Normal file
9
cloudfunctions/walkSave/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "walkSave",
|
||||
"version": "1.0.0",
|
||||
"description": "保存遛狗 + 原子更新stats + 连续天数 + 成就判定",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "~2.6.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user