monthPolicy.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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. /**
  91. * 官方 mylist 里程、配速往往延迟数分钟~数十分钟才更新。
  92. * 但本地 Redis 可能因历史累计/异常写入变得非常大,不能无条件取 max,
  93. * 否则会导致“未达标却被判定已达标”。
  94. * 仅在差距较小(<=5km)时才允许用本地值兜底。
  95. */
  96. const prevKm = Number(prev.km) || 0
  97. const drift = prevKm - apiKm
  98. const km = drift > 0 && drift <= 5 ? prevKm : apiKm
  99. await writeState(account, { km, doubles: prev.doubles }, d)
  100. return km
  101. }
  102. /**
  103. * 成功跑完后更新本地月度缓存(千米,来自 end 接口或回退目标距离)
  104. * @param {number} actualKm 本次计入里程
  105. * @param {{ autoDoubleSlot?: boolean }} [options] 自动任务且为计划中的 2 公里日时递增 doubles
  106. */
  107. async function recordSuccess(account, actualKm, options = {}, d = new Date()) {
  108. const prev = await readState(account, d)
  109. const add = Math.max(0, Number(actualKm) || 0)
  110. const km = prev.km + add
  111. let doubles = prev.doubles
  112. if (options.autoDoubleSlot) {
  113. doubles = prev.doubles + 1
  114. }
  115. await writeState(account, { km, doubles }, d)
  116. }
  117. function isDoubleKmDom(year, month1to12, autoDays, dom) {
  118. const c = candidateRunDates(year, month1to12, autoDays)
  119. const doubles = pickDoubleDays(c)
  120. return doubles.has(dom)
  121. }
  122. function roundToStep(value, step = 0.5) {
  123. const s = Number(step)
  124. if (!Number.isFinite(s) || s <= 0) return Math.round(value * 100) / 100
  125. return Math.round(Math.ceil(value / s) * s * 100) / 100
  126. }
  127. /**
  128. * 自动任务:在 auto_day 且当日可跑时投递;2 公里日最多占满 DOUBLE_RUNS_CAP 次,之后均为 1 公里。
  129. * 不因已达 30km 而停止(30km 为最低要求参考,非额度)。
  130. */
  131. async function planJkesAutoRun(account, autoDayRaw, token, options = {}, d = new Date()) {
  132. const cfg = getJkesSettings()
  133. const monthTargetKm = normalizeMonthTargetKm(options.monthTargetKm)
  134. const stopAfterMinimum = options.stopAfterMinimum !== false
  135. const maxAutoSingleKm = Math.max(2, Number(cfg.autoSingleRunMaxKm) || 5)
  136. const stepKm = Number(cfg.autoDistanceStepKm) || 0.5
  137. const autoDays = parseAutoDay(autoDayRaw)
  138. const wd = d.getDay()
  139. if (autoDays.length > 0 && !autoDays.includes(wd)) {
  140. return { run: false, reason: '非自动乐跑星期' }
  141. }
  142. await refreshMonthKmFromApi(account, token, d)
  143. const state = await readState(account, d)
  144. if (stopAfterMinimum && state.km >= monthTargetKm) {
  145. return { run: false, reason: `本月已达目标 ${monthTargetKm}km` }
  146. }
  147. const y = d.getFullYear()
  148. const m = d.getMonth() + 1
  149. const dom = d.getDate()
  150. const candidates = candidateRunDates(y, m, autoDays).filter((x) => x >= dom)
  151. const remainingDays = candidates.length || 1
  152. const remainingKm = Math.max(0, monthTargetKm - state.km)
  153. // 不补跑前提下,为保证月底前达标,按剩余自动日动态放大单次公里
  154. const requiredBySchedule = remainingKm > 0 ? remainingKm / remainingDays : 1
  155. let targetKm = Math.max(1, roundToStep(requiredBySchedule, stepKm))
  156. // 至少 8 天达到 >=2km:按均匀双倍日 + 末期兜底两层保障
  157. const doubleRemaining = Math.max(0, DOUBLE_RUNS_CAP - (Number(state.doubles) || 0))
  158. const mustDoubleToday = doubleRemaining > 0 && doubleRemaining >= remainingDays
  159. const plannedDoubleToday = isDoubleKmDom(y, m, autoDays, dom)
  160. if (mustDoubleToday || plannedDoubleToday) {
  161. targetKm = Math.max(targetKm, 2)
  162. }
  163. targetKm = Math.min(maxAutoSingleKm, Math.max(1, Math.round(targetKm * 100) / 100))
  164. return { run: true, targetKm, monthTargetKm }
  165. }
  166. /**
  167. * 手动发起:不校验星期;单次 distanceKm 为 1–5。本月已跑里程不设上限(30km 仅为学校最低要求参考)。
  168. */
  169. async function planJkesManualRun(account, token, distanceKm = 1, d = new Date()) {
  170. const kmReq = Number(distanceKm)
  171. if (!Number.isFinite(kmReq) || kmReq < 1 || kmReq > 5) {
  172. return { run: false, reason: '单次距离需在 1–5 公里' }
  173. }
  174. await refreshMonthKmFromApi(account, token, d)
  175. return { run: true, targetKm: Math.round(kmReq * 1000) / 1000 }
  176. }
  177. module.exports = {
  178. MONTH_TARGET_KM,
  179. DOUBLE_RUNS_CAP,
  180. parseAutoDay,
  181. normalizeMonthTargetKm,
  182. monthKey,
  183. readState,
  184. writeState,
  185. refreshMonthKmFromApi,
  186. recordSuccess,
  187. planJkesAutoRun,
  188. planJkesManualRun
  189. }