monthPolicy.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. /**
  2. * 自动乐跑月度规则:在 auto_day 中均匀挑选最多 8 天跑 2 公里,其余自动日为 1 公里。
  3. * 【30 公里】仅为学校规定的「本月最低乐跑里程」参考值(用于进度展示等),不是可跑里程上限。
  4. */
  5. const Redis = require('../DataBase/Redis.js')
  6. const { fetchJkesMonthKm } = require('./stats.js')
  7. const jkesRedisKeys = require('./redisKeys.js')
  8. const { getJkesSettings } = require('./jkesSettings')
  9. /** 学校要求的本月最低乐跑总距离(公里),仅作展示/达标参考,不限制继续跑步 */
  10. const MONTH_TARGET_KM = 30
  11. const DOUBLE_RUNS_CAP = 8
  12. function parseAutoDay(raw) {
  13. if (Array.isArray(raw)) return raw.map(Number).filter((n) => n >= 0 && n <= 6)
  14. if (typeof raw === 'string') {
  15. try {
  16. const a = JSON.parse(raw)
  17. return parseAutoDay(a)
  18. } catch {
  19. return []
  20. }
  21. }
  22. return []
  23. }
  24. function normalizeMonthTargetKm(targetKm) {
  25. const n = Number(targetKm)
  26. if (!Number.isFinite(n) || n <= 0) return MONTH_TARGET_KM
  27. return Math.max(1, Math.round(n * 100) / 100)
  28. }
  29. /** 本月所有「落在 auto_day 星期」的日历日(1..31) */
  30. function candidateRunDates(year, month1to12, autoDays) {
  31. const set = new Set(autoDays.map(Number))
  32. const last = new Date(year, month1to12, 0).getDate()
  33. const out = []
  34. for (let dom = 1; dom <= last; dom++) {
  35. const wd = new Date(year, month1to12 - 1, dom).getDay()
  36. if (set.has(wd)) out.push(dom)
  37. }
  38. return out
  39. }
  40. /** 在候选日中均匀挑选最多 8 个作为 2 公里日 */
  41. function pickDoubleDays(candidates) {
  42. if (candidates.length === 0) return new Set()
  43. if (candidates.length <= DOUBLE_RUNS_CAP) return new Set(candidates)
  44. const out = []
  45. for (let i = 0; i < DOUBLE_RUNS_CAP; i++) {
  46. const idx = Math.min(candidates.length - 1, Math.floor((i + 0.5) * candidates.length / DOUBLE_RUNS_CAP))
  47. out.push(candidates[idx])
  48. }
  49. return new Set(out)
  50. }
  51. function monthKey(account, d = new Date()) {
  52. return jkesRedisKeys.monthState(account, d.getFullYear(), d.getMonth() + 1)
  53. }
  54. function monthTtlSec(d = new Date()) {
  55. const y = d.getFullYear()
  56. const m = d.getMonth()
  57. const end = new Date(y, m + 1, 7, 23, 59, 59)
  58. return Math.max(86400, Math.floor((end.getTime() - Date.now()) / 1000))
  59. }
  60. async function readState(account, d = new Date()) {
  61. const key = monthKey(account, d)
  62. const raw = await Redis.get(key)
  63. if (!raw) {
  64. return { km: 0, doubles: 0 }
  65. }
  66. try {
  67. const o = JSON.parse(raw)
  68. return {
  69. km: Number(o.km) || 0,
  70. doubles: Number(o.doubles) || 0
  71. }
  72. } catch {
  73. return { km: 0, doubles: 0 }
  74. }
  75. }
  76. async function writeState(account, state, d = new Date()) {
  77. const key = monthKey(account, d)
  78. const ttl = monthTtlSec(d)
  79. await Redis.set(key, JSON.stringify(state), { EX: ttl })
  80. }
  81. /**
  82. * 用官方列表刷新本月已跑千米数(不覆盖 doubles 计数)
  83. */
  84. async function refreshMonthKmFromApi(account, token, d = new Date()) {
  85. if (!token) return
  86. const y = d.getFullYear()
  87. const m = d.getMonth() + 1
  88. const apiKm = await fetchJkesMonthKm(token, y, m)
  89. const prev = await readState(account, d)
  90. /** 官方 mylist 里程、配速往往延迟数分钟~数十分钟才更新,取较大值避免覆盖 recordSuccess/本地进度 */
  91. const km = Math.max(apiKm, Number(prev.km) || 0)
  92. await writeState(account, { km, doubles: prev.doubles }, d)
  93. return km
  94. }
  95. /**
  96. * 成功跑完后更新本地月度缓存(千米,来自 end 接口或回退目标距离)
  97. * @param {number} actualKm 本次计入里程
  98. * @param {{ autoDoubleSlot?: boolean }} [options] 自动任务且为计划中的 2 公里日时递增 doubles
  99. */
  100. async function recordSuccess(account, actualKm, options = {}, d = new Date()) {
  101. const prev = await readState(account, d)
  102. const add = Math.max(0, Number(actualKm) || 0)
  103. const km = prev.km + add
  104. let doubles = prev.doubles
  105. if (options.autoDoubleSlot) {
  106. doubles = prev.doubles + 1
  107. }
  108. await writeState(account, { km, doubles }, d)
  109. }
  110. function isDoubleKmDom(year, month1to12, autoDays, dom) {
  111. const c = candidateRunDates(year, month1to12, autoDays)
  112. const doubles = pickDoubleDays(c)
  113. return doubles.has(dom)
  114. }
  115. function roundToStep(value, step = 0.5) {
  116. const s = Number(step)
  117. if (!Number.isFinite(s) || s <= 0) return Math.round(value * 100) / 100
  118. return Math.round(Math.ceil(value / s) * s * 100) / 100
  119. }
  120. /**
  121. * 自动任务:在 auto_day 且当日可跑时投递;2 公里日最多占满 DOUBLE_RUNS_CAP 次,之后均为 1 公里。
  122. * 不因已达 30km 而停止(30km 为最低要求参考,非额度)。
  123. */
  124. async function planJkesAutoRun(account, autoDayRaw, token, options = {}, d = new Date()) {
  125. const cfg = getJkesSettings()
  126. const monthTargetKm = normalizeMonthTargetKm(options.monthTargetKm)
  127. const stopAfterMinimum = options.stopAfterMinimum !== false
  128. const maxAutoSingleKm = Math.max(2, Number(cfg.autoSingleRunMaxKm) || 5)
  129. const stepKm = Number(cfg.autoDistanceStepKm) || 0.5
  130. const autoDays = parseAutoDay(autoDayRaw)
  131. const wd = d.getDay()
  132. if (autoDays.length > 0 && !autoDays.includes(wd)) {
  133. return { run: false, reason: '非自动乐跑星期' }
  134. }
  135. await refreshMonthKmFromApi(account, token, d)
  136. const state = await readState(account, d)
  137. if (stopAfterMinimum && state.km >= monthTargetKm) {
  138. return { run: false, reason: `本月已达目标 ${monthTargetKm}km` }
  139. }
  140. const y = d.getFullYear()
  141. const m = d.getMonth() + 1
  142. const dom = d.getDate()
  143. const candidates = candidateRunDates(y, m, autoDays).filter((x) => x >= dom)
  144. const remainingDays = candidates.length || 1
  145. const remainingKm = Math.max(0, monthTargetKm - state.km)
  146. // 不补跑前提下,为保证月底前达标,按剩余自动日动态放大单次公里
  147. const requiredBySchedule = remainingKm > 0 ? remainingKm / remainingDays : 1
  148. let targetKm = Math.max(1, roundToStep(requiredBySchedule, stepKm))
  149. // 至少 8 天达到 >=2km:按均匀双倍日 + 末期兜底两层保障
  150. const doubleRemaining = Math.max(0, DOUBLE_RUNS_CAP - (Number(state.doubles) || 0))
  151. const mustDoubleToday = doubleRemaining > 0 && doubleRemaining >= remainingDays
  152. const plannedDoubleToday = isDoubleKmDom(y, m, autoDays, dom)
  153. if (mustDoubleToday || plannedDoubleToday) {
  154. targetKm = Math.max(targetKm, 2)
  155. }
  156. targetKm = Math.min(maxAutoSingleKm, Math.max(1, Math.round(targetKm * 100) / 100))
  157. return { run: true, targetKm, monthTargetKm }
  158. }
  159. /**
  160. * 手动发起:不校验星期;单次 distanceKm 为 1–5。本月已跑里程不设上限(30km 仅为学校最低要求参考)。
  161. */
  162. async function planJkesManualRun(account, token, distanceKm = 1, d = new Date()) {
  163. const kmReq = Number(distanceKm)
  164. if (!Number.isFinite(kmReq) || kmReq < 1 || kmReq > 5) {
  165. return { run: false, reason: '单次距离需在 1–5 公里' }
  166. }
  167. await refreshMonthKmFromApi(account, token, d)
  168. return { run: true, targetKm: Math.round(kmReq * 1000) / 1000 }
  169. }
  170. module.exports = {
  171. MONTH_TARGET_KM,
  172. DOUBLE_RUNS_CAP,
  173. parseAutoDay,
  174. normalizeMonthTargetKm,
  175. monthKey,
  176. readState,
  177. writeState,
  178. refreshMonthKmFromApi,
  179. recordSuccess,
  180. planJkesAutoRun,
  181. planJkesManualRun
  182. }