fix(server): 时间戳→业务日期/时间全部统一为中国时区(UTC+8)

此前仅'今天'默认值统一为中国日期,Date→字符串的转换仍是 UTC:
- sync 考勤同步窗口 lastSyncAt 时间戳按 UTC 取日期,凌晨会偏一天
- 考勤导出 punchTime/createdAt 显示 UTC 时间
- normalizeDateOnly 把 ISO datetime 按 UTC 归一化,业务日期少一天
- getCourseClock/getChinaDateParts 用 dayjs.utc(date).add(8h) 隐式转换

统一为 dayjs(date).utcOffset(8),纯日期字符串运算(shiftDate/addDays/
daysInMonth/nextMonth)保留 dayjs.utc;date-normalization 测试断言
同步更新为北京时间语义。
This commit is contained in:
2026-08-08 18:01:35 +08:00
parent 7905f1ee23
commit 4dd7b5a0b1
19 changed files with 26 additions and 25 deletions

View File

@@ -49,7 +49,7 @@ export function optionalDate(value: unknown, field: string): ToolInputResult<str
return { ok: false, error: `${field} 必须是 YYYY-MM-DD 日期` };
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime()) || dayjs(date).utc().format('YYYY-MM-DD') !== value) {
if (Number.isNaN(date.getTime()) || dayjs(date).utcOffset(8).format('YYYY-MM-DD') !== value) {
return { ok: false, error: `${field} 不是有效日期` };
}
return { ok: true, value };

View File

@@ -76,7 +76,7 @@ export class AttendanceCalendarService {
const start = new Date(weekStart);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const endStr = dayjs(end).utc().format('YYYY-MM-DD');
const endStr = dayjs(end).utcOffset(8).format('YYYY-MM-DD');
const records = await this.attendanceRepo.find({
where: {

View File

@@ -97,7 +97,7 @@ export class AttendanceGenerationService {
const entities: AttendanceRecord[] = [];
const end = new Date(dateTo);
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = dayjs(d).utc().format('YYYY-MM-DD');
const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD');
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
for (const sched of schedules) {
@@ -158,7 +158,7 @@ export class AttendanceGenerationService {
}
private getCourseClock(date: Date): { date: string; minutes: number } {
const c = dayjs.utc(date).add(8, 'hour');
const c = dayjs(date).utcOffset(8);
return {
date: c.format('YYYY-MM-DD'),
minutes: c.hour() * 60 + c.minute(),

View File

@@ -239,7 +239,7 @@ export class AttendanceImportService {
}
private formatDate(value: Date): string {
return dayjs.utc(value).format('YYYY-MM-DD');
return dayjs(value).utcOffset(8).format('YYYY-MM-DD');
}
/**

View File

@@ -153,7 +153,7 @@ export class AttendanceLeaveSyncService {
}
private formatDate(value: Date): string {
return dayjs.utc(value).format('YYYY-MM-DD');
return dayjs(value).utcOffset(8).format('YYYY-MM-DD');
}
private async resolveStudentName(dingUserId: string): Promise<string> {

View File

@@ -196,11 +196,11 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
source: record.source || '',
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
punchTime: record.punchTime
? dayjs(record.punchTime).utc().format('YYYY-MM-DD HH:mm:ss')
? dayjs(record.punchTime).utcOffset(8).format('YYYY-MM-DD HH:mm:ss')
: '',
remark: record.remark || '',
createdAt: record.createdAt
? dayjs(record.createdAt).utc().format('YYYY-MM-DD HH:mm:ss')
? dayjs(record.createdAt).utcOffset(8).format('YYYY-MM-DD HH:mm:ss')
: '',
});
}

View File

@@ -142,7 +142,7 @@ export class AttendanceReportService {
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = dayjs(cutoff).utc().format('YYYY-MM-DD');
const cutoffStr = dayjs(cutoff).utcOffset(8).format('YYYY-MM-DD');
const qb = this.attendanceRepo
.createQueryBuilder('a')

View File

@@ -285,7 +285,7 @@ export class AttendanceSettlementService {
private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } {
// Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl 时区格式化等价
const c = dayjs.utc(date).add(8, 'hour');
const c = dayjs(date).utcOffset(8);
const weekDay = c.day() === 0 ? 7 : c.day();
return {
date: c.format('YYYY-MM-DD'),

View File

@@ -259,7 +259,7 @@ export class BillsGenerationService {
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value;
return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value;
}

View File

@@ -135,7 +135,7 @@ export class RentalScheduleService {
const current = this.toUtcDate(startDate);
const end = this.toUtcDate(endDate);
while (current <= end) {
dates.add(dayjs(current).utc().format('YYYY-MM-DD'));
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
current.setUTCDate(current.getUTCDate() + 1);
}
}
@@ -155,7 +155,7 @@ export class RentalScheduleService {
const startWeekDay = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
while (current <= end) {
dates.add(dayjs(current).utc().format('YYYY-MM-DD'));
dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD'));
current.setUTCDate(current.getUTCDate() + 7);
}
}

View File

@@ -392,7 +392,7 @@ export class ClassroomsService {
const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
rentalDaysByRoom[r.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD'));
rentalDaysByRoom[r.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));
}
}
@@ -401,7 +401,7 @@ export class ClassroomsService {
const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
scheduleDaysByRoom[s.classroomId].add(dayjs(d).utc().format('YYYY-MM-DD'));
scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));
}
}

View File

@@ -5,8 +5,9 @@ describe('normalizeDateOnly', () => {
expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02');
});
it('converts legacy ISO timestamps to their UTC calendar date', () => {
expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-01');
it('converts legacy ISO timestamps to their China calendar date (UTC+8)', () => {
// 2026-07-01T16:00:00Z = 北京时间 2026-07-02 00:00
expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-02');
});
it('rejects unsupported date formats', () => {

View File

@@ -9,7 +9,7 @@ export function normalizeDateOnly(value?: string | null): string | null | undefi
const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1];
if (isoPrefix) {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return dayjs(date).utc().format('YYYY-MM-DD');
if (!Number.isNaN(date.getTime())) return dayjs(date).utcOffset(8).format('YYYY-MM-DD');
}
throw new Error(`无效日期格式: ${value}`);

View File

@@ -29,7 +29,7 @@ export class ExpenseOperationsService {
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value;
return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value;
}
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {

View File

@@ -342,7 +342,7 @@ export class ExpensesService {
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && dayjs(date).utc().format('YYYY-MM-DD') === value;
return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value;
}
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {

View File

@@ -159,7 +159,7 @@ export class OccupancyOperationsService {
const transferDate = new Date(dto.transferDate);
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = dayjs(nextDay).utc().format('YYYY-MM-DD');
const defaultBillingStart = dayjs(nextDay).utcOffset(8).format('YYYY-MM-DD');
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,

View File

@@ -148,7 +148,7 @@ import dayjs from '../common/dayjs';
export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {
// Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl en-CA 格式化等价
const c = dayjs.utc(date).add(8, 'hour');
const c = dayjs(date).utcOffset(8);
const weekDay = c.day() === 0 ? 7 : c.day();
return { date: c.format('YYYY-MM-DD'), weekDay };
}

View File

@@ -98,7 +98,7 @@ export function buildDailySchedulePlans(
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
const dateStr = dayjs(date).utc().format('YYYY-MM-DD');
const dateStr = dayjs(date).utcOffset(8).format('YYYY-MM-DD');
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
for (const schedule of schedules) {

View File

@@ -71,8 +71,8 @@ export class SyncService {
}
const result = await this.attendanceImportService.importFromDingTalk({
startDate: dayjs(startDate).utc().format('YYYY-MM-DD'),
endDate: dayjs(endDate).utc().format('YYYY-MM-DD'),
startDate: dayjs(startDate).utcOffset(8).format('YYYY-MM-DD'),
endDate: dayjs(endDate).utcOffset(8).format('YYYY-MM-DD'),
userIds,
autoMatch: true,
});