| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- /**
- * 自动乐跑月度规则:在 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)
- }
- 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
- }
|