Files
OJ2/apps/api/src/time.ts
T
xuyueandClaude Opus 5.5 b344bd1dd6
Deploy / deploy (push) Canceled after 0s
feat(布置作业): 能按知识点点选题目;一个班一天能布置几节课;数据统计认老师布置的课;后台能管布置过的课
- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排;
  只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=)
- 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行;
  看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。
  不带 lessonId/newLesson 的老请求照旧改今天最后一节
- 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」),
  这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间
- 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节;
  看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用

⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:41:25 +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`
}