Files
OJ2/apps/api/src/time.ts
T
xuyueandClaude Opus 5.5 29c31640b5 refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:07 +08:00

141 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TIME_ZONE, TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
import { sql, type SQLWrapper } from 'drizzle-orm'
/**
* 后端的日历换算全在这里,锚点是契约里的 `TIME_ZONE`(东八区,固定偏移)。
*
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里**;SQL 里按日历切
* 就用 `localTime()`。不要写 `new Date(x).getHours()` / `setHours(0,0,0,0)` /
* `getFullYear()` / `new Date(y, m, d)` 这类跟**进程时区**走的代码,也不要依赖数据库
* 会话时区:容器(UTC)和开发机给出不同答案,而且不报错。
*/
export { TIME_ZONE }
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const DAY_MS = 86_400_000
/**
* 真实时刻 → 「东八区墙上时钟」。平移之后 `getUTC*` 读出来的就是北京时间的年月日时分,
* 日历运算可以整套用 UTC 那批 API 做。`fromWallClock` 是逆运算。
*/
function toWallClock(value: Date | number | string = new Date()): Date {
return new Date(new Date(value).getTime() + OFFSET_MS)
}
function fromWallClock(wall: Date): Date {
return new Date(wall.getTime() - OFFSET_MS)
}
/** 北京时间的日历日,形如 `2026-09-14` */
export function calendarDay(value: Date | number | string = new Date()): string {
return toWallClock(value).toISOString().slice(0, 10)
}
/** 北京时间的钟点,0–23 */
export function localHour(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCHours()
}
/** 北京时间的年份 */
export function localYear(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCFullYear()
}
/**
* 日历日序号(1970-01-01 为 0)。
*
* 「差几天」一律用它算,别拿两个 Date 相减:夏令时地区的相邻两天可能相差
* 23 或 25 小时,除 86400000 得到的不是 1,`=== 1` 这种判据会静默失效。
*/
export function dayNumber(day: string): number {
const [year, month, date] = day.split('-').map(Number)
return Date.UTC(year!, month! - 1, date!) / DAY_MS
}
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂任务用 */
export function localDateTime(day: string, time: string): string {
const [hour, minute] = time.split(':').map(Number)
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
}
/** 日历日序号 → `YYYY-MM-DD` */
export function dayText(day: number): string {
return new Date(day * DAY_MS).toISOString().slice(0, 10)
}
/** 日历日序号是周几,0 = 周日(和 `Date#getDay()` 同一套编号) */
export function localWeekday(day: number): number {
return new Date(day * DAY_MS).getUTCDay()
}
/**
* `instant` 所在东八区日历日的零点,返回 ISO 字符串,缺省是今天。
* 提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它
*/
export function dayStart(instant: Date | number | string = new Date()): string {
return new Date(dayNumber(calendarDay(instant)) * DAY_MS - OFFSET_MS).toISOString()
}
/**
* 「东八区本周一」的零点,返回 ISO 字符串。周榜按自然周清零,周一起算。
*
* `localWeekday` 的 0 是周日(跟 `Date#getDay()` 同一套编号),直接拿来减会把周日
* 算成「本周第一天」,于是周日一整天单独成一周、周一又清零一次 —— 所以先把 0 折成 7,
* 得到的 `weekday - 1` 才是「从本周一到今天过了几个日历日」。
*/
export function weekStart(now: Date | number | string = new Date()): string {
const today = dayNumber(calendarDay(now))
const weekday = localWeekday(today) || 7
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
}
/**
* 「这学期」从哪天零点(东八区)算起:秋季学期 9 月 1 日起(到次年 1 月都算),春季学期
* 2 月 1 日起(7、8 月的暑假也归春季,开学前没人做题,不影响)。返回 ISO 字符串。
*/
export function termStart(now: Date | number | string = new Date()): string {
const wall = toWallClock(now)
const year = wall.getUTCFullYear()
const month = wall.getUTCMonth() + 1
const [termYear, termMonth] = month >= 9 ? [year, 9] : month >= 2 ? [year, 2] : [year - 1, 9]
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
}
/** 「东八区本月 1 日」的零点,返回 ISO 字符串 */
export function monthStart(now: Date | number | string = new Date()): string {
const wall = toWallClock(now)
return fromWallClock(new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), 1))).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)
const date = wall.getUTCDate()
wall.setUTCDate(1)
wall.setUTCMonth(wall.getUTCMonth() + months)
const lastDay = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate()
wall.setUTCDate(Math.min(date, lastDay))
return fromWallClock(wall)
}
/**
* 时区名直接拼成 SQL 字面量,**不走参数绑定**:同一个表达式在 select 和 group by 里
* 各出现一次,绑定成参数会拿到两个不同的占位符,PG 就不认为它们是同一个表达式,报
* 「must appear in the GROUP BY clause」。常量拼接,没有注入面。
*/
const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
/**
* `timestamptz` 列 → 北京墙上时间(`timestamp`),供 `extract(hour from …)` /
* `date(…)` 这类日历函数用。每次调用渲染出的 SQL 文本相同,select 和 group by
* 各调一次也能匹配上。
*/
export function localTime(column: SQLWrapper) {
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
}
/** 时刻所在学年的起始年:9 月 1 日起算,2025 = 25–26 学年 */
export function schoolYearOf(column: SQLWrapper) {
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
}