/** * 自动乐跑月度规则:在 auto_day 中均匀挑选最多 8 天跑 2 公里,其余自动日为 1 公里。 * 【30 公里】仅为学校规定的「本月最低乐跑里程」参考值(用于进度展示等),不是可跑里程上限。 */ const Redis = require('../DataBase/Redis.js') const { fetchJkesMonthKm } = require('./stats.js') const jkesRedisKeys = require('./redisKeys.js') const { getJkesSettings } = require('./jkesSettings') /** 学校要求的本月最低乐跑总距离(公里),仅作展示/达标参考,不限制继续跑步 */ const MONTH_TARGET_KM = 30 const DOUBLE_RUNS_CAP = 8 function parseAutoDay(raw) { if (Array.isArray(raw)) return raw.map(Number).filter((n) => n >= 0 && n <= 6) if (typeof raw === 'string') { try { const a = JSON.parse(raw) return parseAutoDay(a) } catch { return [] } } return [] } function normalizeMonthTargetKm(targetKm) { const n = Number(targetKm) if (!Number.isFinite(n) || n <= 0) return MONTH_TARGET_KM return Math.max(1, Math.round(n * 100) / 100) } /** 本月所有「落在 auto_day 星期」的日历日(1..31) */ function candidateRunDates(year, month1to12, autoDays) { const set = new Set(autoDays.map(Number)) const last = new Date(year, month1to12, 0).getDate() const out = [] for (let dom = 1; dom <= last; dom++) { const wd = new Date(year, month1to12 - 1, dom).getDay() if (set.has(wd)) out.push(dom) } return out } /** 在候选日中均匀挑选最多 8 个作为 2 公里日 */ function pickDoubleDays(candidates) { if (candidates.length === 0) return new Set() if (candidates.length <= DOUBLE_RUNS_CAP) return new Set(candidates) const out = [] for (let i = 0; i < DOUBLE_RUNS_CAP; i++) { const idx = Math.min(candidates.length - 1, Math.floor((i + 0.5) * candidates.length / DOUBLE_RUNS_CAP)) out.push(candidates[idx]) } return new Set(out) } function monthKey(account, d = new Date()) { return jkesRedisKeys.monthState(account, d.getFullYear(), d.getMonth() + 1) } function monthTtlSec(d = new Date()) { const y = d.getFullYear() const m = d.getMonth() const end = new Date(y, m + 1, 7, 23, 59, 59) return Math.max(86400, Math.floor((end.getTime() - Date.now()) / 1000)) } async function readState(account, d = new Date()) { const key = monthKey(account, d) const raw = await Redis.get(key) if (!raw) { return { km: 0, doubles: 0 } } try { const o = JSON.parse(raw) return { km: Number(o.km) || 0, doubles: Number(o.doubles) || 0 } } catch { return { km: 0, doubles: 0 } } } async function writeState(account, state, d = new Date()) { const key = monthKey(account, d) const ttl = monthTtlSec(d) await Redis.set(key, JSON.stringify(state), { EX: ttl }) } /** * 用官方列表刷新本月已跑千米数(不覆盖 doubles 计数) */ async function refreshMonthKmFromApi(account, token, d = new Date()) { if (!token) return const y = d.getFullYear() const m = d.getMonth() + 1 const apiKm = await fetchJkesMonthKm(token, y, m) const prev = await readState(account, d) /** * 官方 mylist 里程、配速往往延迟数分钟~数十分钟才更新。 * 但本地 Redis 可能因历史累计/异常写入变得非常大,不能无条件取 max, * 否则会导致“未达标却被判定已达标”。 * 仅在差距较小(<=5km)时才允许用本地值兜底。 */ const prevKm = Number(prev.km) || 0 const drift = prevKm - apiKm const km = drift > 0 && drift <= 5 ? prevKm : apiKm await writeState(account, { km, doubles: prev.doubles }, d) return km } /** * 成功跑完后更新本地月度缓存(千米,来自 end 接口或回退目标距离) * @param {number} actualKm 本次计入里程 * @param {{ autoDoubleSlot?: boolean }} [options] 自动任务且为计划中的 2 公里日时递增 doubles */ async function recordSuccess(account, actualKm, options = {}, d = new Date()) { const prev = await readState(account, d) const add = Math.max(0, Number(actualKm) || 0) const km = prev.km + add let doubles = prev.doubles if (options.autoDoubleSlot) { doubles = prev.doubles + 1 } await writeState(account, { km, doubles }, d) } function isDoubleKmDom(year, month1to12, autoDays, dom) { const c = candidateRunDates(year, month1to12, autoDays) const doubles = pickDoubleDays(c) return doubles.has(dom) } function roundToStep(value, step = 0.5) { const s = Number(step) if (!Number.isFinite(s) || s <= 0) return Math.round(value * 100) / 100 return Math.round(Math.ceil(value / s) * s * 100) / 100 } /** * 自动任务:在 auto_day 且当日可跑时投递;2 公里日最多占满 DOUBLE_RUNS_CAP 次,之后均为 1 公里。 * 不因已达 30km 而停止(30km 为最低要求参考,非额度)。 */ async function planJkesAutoRun(account, autoDayRaw, token, options = {}, d = new Date()) { const cfg = getJkesSettings() const monthTargetKm = normalizeMonthTargetKm(options.monthTargetKm) const stopAfterMinimum = options.stopAfterMinimum !== false const maxAutoSingleKm = Math.max(2, Number(cfg.autoSingleRunMaxKm) || 5) const stepKm = Number(cfg.autoDistanceStepKm) || 0.5 const autoDays = parseAutoDay(autoDayRaw) const wd = d.getDay() if (autoDays.length > 0 && !autoDays.includes(wd)) { return { run: false, reason: '非自动乐跑星期' } } await refreshMonthKmFromApi(account, token, d) const state = await readState(account, d) if (stopAfterMinimum && state.km >= monthTargetKm) { return { run: false, reason: `本月已达目标 ${monthTargetKm}km` } } const y = d.getFullYear() const m = d.getMonth() + 1 const dom = d.getDate() const candidates = candidateRunDates(y, m, autoDays).filter((x) => x >= dom) const remainingDays = candidates.length || 1 const remainingKm = Math.max(0, monthTargetKm - state.km) // 不补跑前提下,为保证月底前达标,按剩余自动日动态放大单次公里 const requiredBySchedule = remainingKm > 0 ? remainingKm / remainingDays : 1 let targetKm = Math.max(1, roundToStep(requiredBySchedule, stepKm)) // 至少 8 天达到 >=2km:按均匀双倍日 + 末期兜底两层保障 const doubleRemaining = Math.max(0, DOUBLE_RUNS_CAP - (Number(state.doubles) || 0)) const mustDoubleToday = doubleRemaining > 0 && doubleRemaining >= remainingDays const plannedDoubleToday = isDoubleKmDom(y, m, autoDays, dom) if (mustDoubleToday || plannedDoubleToday) { targetKm = Math.max(targetKm, 2.5) } targetKm = Math.min(maxAutoSingleKm, Math.max(1, Math.round(targetKm * 100) / 100)) return { run: true, targetKm, monthTargetKm } } /** * 手动发起:不校验星期;单次 distanceKm 为 1–5。本月已跑里程不设上限(30km 仅为学校最低要求参考)。 */ async function planJkesManualRun(account, token, distanceKm = 1, d = new Date()) { const kmReq = Number(distanceKm) if (!Number.isFinite(kmReq) || kmReq < 1 || kmReq > 5) { return { run: false, reason: '单次距离需在 1–5 公里' } } await refreshMonthKmFromApi(account, token, d) return { run: true, targetKm: Math.round(kmReq * 1000) / 1000 } } module.exports = { MONTH_TARGET_KM, DOUBLE_RUNS_CAP, parseAutoDay, normalizeMonthTargetKm, monthKey, readState, writeState, refreshMonthKmFromApi, recordSuccess, planJkesAutoRun, planJkesManualRun }