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:
@@ -49,7 +49,7 @@ export function optionalDate(value: unknown, field: string): ToolInputResult<str
|
|||||||
return { ok: false, error: `${field} 必须是 YYYY-MM-DD 日期` };
|
return { ok: false, error: `${field} 必须是 YYYY-MM-DD 日期` };
|
||||||
}
|
}
|
||||||
const date = new Date(`${value}T00:00:00Z`);
|
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: false, error: `${field} 不是有效日期` };
|
||||||
}
|
}
|
||||||
return { ok: true, value };
|
return { ok: true, value };
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class AttendanceCalendarService {
|
|||||||
const start = new Date(weekStart);
|
const start = new Date(weekStart);
|
||||||
const end = new Date(start);
|
const end = new Date(start);
|
||||||
end.setDate(start.getDate() + 6);
|
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({
|
const records = await this.attendanceRepo.find({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class AttendanceGenerationService {
|
|||||||
const entities: AttendanceRecord[] = [];
|
const entities: AttendanceRecord[] = [];
|
||||||
const end = new Date(dateTo);
|
const end = new Date(dateTo);
|
||||||
for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
|
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();
|
const weekDay = d.getDay() === 0 ? 7 : d.getDay();
|
||||||
|
|
||||||
for (const sched of schedules) {
|
for (const sched of schedules) {
|
||||||
@@ -158,7 +158,7 @@ export class AttendanceGenerationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCourseClock(date: Date): { date: string; minutes: number } {
|
private getCourseClock(date: Date): { date: string; minutes: number } {
|
||||||
const c = dayjs.utc(date).add(8, 'hour');
|
const c = dayjs(date).utcOffset(8);
|
||||||
return {
|
return {
|
||||||
date: c.format('YYYY-MM-DD'),
|
date: c.format('YYYY-MM-DD'),
|
||||||
minutes: c.hour() * 60 + c.minute(),
|
minutes: c.hour() * 60 + c.minute(),
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export class AttendanceImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatDate(value: Date): string {
|
private formatDate(value: Date): string {
|
||||||
return dayjs.utc(value).format('YYYY-MM-DD');
|
return dayjs(value).utcOffset(8).format('YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export class AttendanceLeaveSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatDate(value: Date): string {
|
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> {
|
private async resolveStudentName(dingUserId: string): Promise<string> {
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
source: record.source || '',
|
source: record.source || '',
|
||||||
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
punchDevice: record.punchDeviceName || record.punchDeviceId || '',
|
||||||
punchTime: record.punchTime
|
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 || '',
|
remark: record.remark || '',
|
||||||
createdAt: record.createdAt
|
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')
|
||||||
: '',
|
: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class AttendanceReportService {
|
|||||||
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
|
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
cutoff.setDate(cutoff.getDate() - days);
|
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
|
const qb = this.attendanceRepo
|
||||||
.createQueryBuilder('a')
|
.createQueryBuilder('a')
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export class AttendanceSettlementService {
|
|||||||
|
|
||||||
private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } {
|
private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } {
|
||||||
// Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl 时区格式化等价
|
// 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();
|
const weekDay = c.day() === 0 ? 7 : c.day();
|
||||||
return {
|
return {
|
||||||
date: c.format('YYYY-MM-DD'),
|
date: c.format('YYYY-MM-DD'),
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export class BillsGenerationService {
|
|||||||
private isValidDate(value: string) {
|
private isValidDate(value: string) {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||||
const date = new Date(`${value}T00:00:00Z`);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class RentalScheduleService {
|
|||||||
const current = this.toUtcDate(startDate);
|
const current = this.toUtcDate(startDate);
|
||||||
const end = this.toUtcDate(endDate);
|
const end = this.toUtcDate(endDate);
|
||||||
while (current <= end) {
|
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);
|
current.setUTCDate(current.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export class RentalScheduleService {
|
|||||||
const startWeekDay = current.getUTCDay() || 7;
|
const startWeekDay = current.getUTCDay() || 7;
|
||||||
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
||||||
while (current <= end) {
|
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);
|
current.setUTCDate(current.getUTCDate() + 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export class ClassroomsService {
|
|||||||
const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime()));
|
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()));
|
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)) {
|
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 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()));
|
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)) {
|
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ describe('normalizeDateOnly', () => {
|
|||||||
expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02');
|
expect(normalizeDateOnly('2026-07-02')).toBe('2026-07-02');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts legacy ISO timestamps to their UTC calendar date', () => {
|
it('converts legacy ISO timestamps to their China calendar date (UTC+8)', () => {
|
||||||
expect(normalizeDateOnly('2026-07-01T16:00:00.000Z')).toBe('2026-07-01');
|
// 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', () => {
|
it('rejects unsupported date formats', () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function normalizeDateOnly(value?: string | null): string | null | undefi
|
|||||||
const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1];
|
const isoPrefix = ISO_DATE_PREFIX_PATTERN.exec(value)?.[1];
|
||||||
if (isoPrefix) {
|
if (isoPrefix) {
|
||||||
const date = new Date(value);
|
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}`);
|
throw new Error(`无效日期格式: ${value}`);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class ExpenseOperationsService {
|
|||||||
private isValidDate(value: string) {
|
private isValidDate(value: string) {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||||
const date = new Date(`${value}T00:00:00Z`);
|
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) {
|
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export class ExpensesService {
|
|||||||
private isValidDate(value: string) {
|
private isValidDate(value: string) {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||||
const date = new Date(`${value}T00:00:00Z`);
|
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) {
|
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class OccupancyOperationsService {
|
|||||||
const transferDate = new Date(dto.transferDate);
|
const transferDate = new Date(dto.transferDate);
|
||||||
const nextDay = new Date(transferDate);
|
const nextDay = new Date(transferDate);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
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(
|
this.assertDateOrder(
|
||||||
dto.transferDate,
|
dto.transferDate,
|
||||||
dto.newBillingStartDate || defaultBillingStart,
|
dto.newBillingStartDate || defaultBillingStart,
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ import dayjs from '../common/dayjs';
|
|||||||
|
|
||||||
export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {
|
export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {
|
||||||
// Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl en-CA 格式化等价
|
// 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();
|
const weekDay = c.day() === 0 ? 7 : c.day();
|
||||||
return { date: c.format('YYYY-MM-DD'), weekDay };
|
return { date: c.format('YYYY-MM-DD'), weekDay };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function buildDailySchedulePlans(
|
|||||||
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
|
const toDate = new Date(`${syncTo}T00:00:00.000Z`);
|
||||||
|
|
||||||
for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
|
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();
|
const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.attendanceImportService.importFromDingTalk({
|
const result = await this.attendanceImportService.importFromDingTalk({
|
||||||
startDate: dayjs(startDate).utc().format('YYYY-MM-DD'),
|
startDate: dayjs(startDate).utcOffset(8).format('YYYY-MM-DD'),
|
||||||
endDate: dayjs(endDate).utc().format('YYYY-MM-DD'),
|
endDate: dayjs(endDate).utcOffset(8).format('YYYY-MM-DD'),
|
||||||
userIds,
|
userIds,
|
||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user