From 619b3b22bdaa599427f9e9a71f931c7973ea124d Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 11:43:11 +0800 Subject: [PATCH 1/6] fix: use saved dingtalk config for integration calls --- .../src/integration/dingtalk.service.ts | 70 ++++++++++++++----- .../src/integration/integration.module.ts | 6 +- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 3e01989..a08cf4e 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; +import { IntegrationConfigService } from './config/integration-config.service'; // ── Types ── @@ -18,6 +19,11 @@ interface DingTalkTokenResponse { expireIn: number; } +interface DingTalkCredentials { + appKey: string; + appSecret: string; +} + interface DingTalkUserListResponse { errcode: number; errmsg: string; @@ -168,6 +174,7 @@ export interface DingTalkScheduleResult { export class DingTalkService { private readonly logger = new Logger(DingTalkService.name); private accessToken: string | null = null; + private accessTokenCredentialKey: string | null = null; private tokenExpiresAt = 0; private apiRequestCount = 0; @@ -180,10 +187,28 @@ export class DingTalkService { private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, + private readonly integrationConfigService?: IntegrationConfigService, ) {} - private get configured(): boolean { - return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); + private async getCredentials(): Promise { + const rawConfig = await this.integrationConfigService?.getRawConfig('DINGTALK'); + const dbAppKey = typeof rawConfig?.agentId === 'string' ? rawConfig.agentId.trim() : ''; + const dbAppSecret = typeof rawConfig?.appSecret === 'string' ? rawConfig.appSecret.trim() : ''; + if (dbAppKey && dbAppSecret) { + return { appKey: dbAppKey, appSecret: dbAppSecret }; + } + + const envAppKey = process.env.DINGTALK_APP_KEY?.trim(); + const envAppSecret = process.env.DINGTALK_APP_SECRET?.trim(); + if (envAppKey && envAppSecret) { + return { appKey: envAppKey, appSecret: envAppSecret }; + } + + return null; + } + + private async isConfigured(): Promise { + return !!(await this.getCredentials()); } // ═══════════════════════════════════════════ @@ -191,16 +216,24 @@ export class DingTalkService { // ═══════════════════════════════════════════ private async getAccessToken(): Promise { - if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + const credentials = await this.getCredentials(); + if (!credentials) { + throw new Error('DingTalk not configured'); + } + + const credentialKey = `${credentials.appKey}:${credentials.appSecret}`; + if ( + this.accessToken && + this.accessTokenCredentialKey === credentialKey && + Date.now() < this.tokenExpiresAt - 60_000 + ) { return this.accessToken; } - const appKey = process.env.DINGTALK_APP_KEY!; - const appSecret = process.env.DINGTALK_APP_SECRET!; const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appKey, appSecret }), + body: JSON.stringify(credentials), }); const body: DingTalkTokenResponse = await res.json(); @@ -209,6 +242,7 @@ export class DingTalkService { } this.accessToken = body.accessToken; + this.accessTokenCredentialKey = credentialKey; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.logger.log('钉钉 access_token 获取成功'); return this.accessToken; @@ -259,7 +293,7 @@ export class DingTalkService { // ═══════════════════════════════════════════ async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { - if (!this.configured) { + if (!(await this.isConfigured())) { this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步'); return { deptCount: 0, userCount: 0 }; } @@ -333,7 +367,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(只含部门) */ async fetchOrgTree(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -343,7 +377,7 @@ export class DingTalkService { /** 获取钉钉组织部门树(含用户) */ async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { - if (!this.configured) return []; + if (!(await this.isConfigured())) return []; const token = await this.getAccessToken(); const rootInfo = await this.getDeptInfo(token, rootDeptId); if (!rootInfo) return []; @@ -441,7 +475,7 @@ export class DingTalkService { endDate: string; userIds?: string[]; }): Promise { - if (!this.configured) throw new Error('DingTalk not configured'); + if (!(await this.isConfigured())) throw new Error('DingTalk not configured'); if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空'); if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); const token = await this.getAccessToken(); @@ -496,7 +530,7 @@ export class DingTalkService { /** 创建或修改班次。id 不传=创建,传了=修改 */ async upsertShift(params: DingTalkShiftParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const body: Record = { @@ -547,7 +581,7 @@ export class DingTalkService { /** 查询所有班次摘要(每页最多200条) */ async queryShifts(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkShiftSummary[] = []; @@ -598,7 +632,7 @@ export class DingTalkService { /** 创建排班制考勤组 */ async createAttendanceGroup(params: DingTalkGroupParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = this.buildAttendanceGroupBody(params); @@ -627,7 +661,7 @@ export class DingTalkService { /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; @@ -687,7 +721,7 @@ export class DingTalkService { /** 查询所有考勤组摘要(分页,每页10条) */ async queryAttendanceGroups(opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); const all: DingTalkGroupSummary[] = []; @@ -729,7 +763,7 @@ export class DingTalkService { } async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); @@ -778,7 +812,7 @@ export class DingTalkService { async scheduleUsers( groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); if (schedules.length === 0) return; if (schedules.length > 200) { throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); @@ -818,7 +852,7 @@ export class DingTalkService { async queryScheduleByUsers( userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', ): Promise { - if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置'); const token = await this.getAccessToken(); await this.rateLimit(); diff --git a/apps/server/src/integration/integration.module.ts b/apps/server/src/integration/integration.module.ts index 6c98c9e..e003536 100644 --- a/apps/server/src/integration/integration.module.ts +++ b/apps/server/src/integration/integration.module.ts @@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User, Student, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from './dingtalk.service'; import { WeComService } from './wecom.service'; +import { IntegrationConfigModule } from './config/config.module'; @Module({ - imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])], + imports: [ + TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]), + IntegrationConfigModule, + ], providers: [DingTalkService, WeComService], exports: [DingTalkService, WeComService], }) -- 2.49.1 From 187e4f2af026460ea2b2592280b54d8751b30c2a Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 12:16:39 +0800 Subject: [PATCH 2/6] fix: improve error handling in expense form submissions --- apps/admin/src/pages/Expenses/index.tsx | 49 +++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 9411be6..30f241c 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -31,6 +31,11 @@ import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; +const isFormValidationError = (error: unknown) => + typeof error === 'object' + && error !== null + && Array.isArray((error as { errorFields?: unknown }).errorFields); + const ExpensesPage: React.FC = () => { @@ -157,16 +162,16 @@ const ExpensesPage: React.FC = () => { const handleRoomExpense = async () => { setSaving(true); - const values = await roomForm.validateFields(); - const payload = { - roomId: values.roomId, - expenseType: values.expenseType, - amount: values.amount, - periodStart: values.period[0].format('YYYY-MM-DD'), - periodEnd: values.period[1].format('YYYY-MM-DD'), - description: values.description, - }; try { + const values = await roomForm.validateFields(); + const payload = { + roomId: values.roomId, + expenseType: values.expenseType, + amount: values.amount, + periodStart: values.period[0].format('YYYY-MM-DD'), + periodEnd: values.period[1].format('YYYY-MM-DD'), + description: values.description, + }; if (editingRoom) { await api.put(`/expenses/room/${editingRoom.id}`, payload); message.success('更新成功'); @@ -179,7 +184,9 @@ const ExpensesPage: React.FC = () => { roomForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } @@ -187,16 +194,16 @@ const ExpensesPage: React.FC = () => { const handlePersonalExpense = async () => { setSaving(true); - const values = await personalForm.validateFields(); - const payload = { - studentId: values.studentId, - roomId: values.roomId, - expenseType: values.expenseType, - amount: values.amount, - expenseDate: values.expenseDate.format('YYYY-MM-DD'), - description: values.description, - }; try { + const values = await personalForm.validateFields(); + const payload = { + studentId: values.studentId, + roomId: values.roomId, + expenseType: values.expenseType, + amount: values.amount, + expenseDate: values.expenseDate.format('YYYY-MM-DD'), + description: values.description, + }; if (editingPersonal) { await api.put(`/expenses/personal/${editingPersonal.id}`, payload); message.success('更新成功'); @@ -209,7 +216,9 @@ const ExpensesPage: React.FC = () => { personalForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } -- 2.49.1 From 70283d9dfe74863382234586bdfde516fa0fcfd6 Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 14:22:22 +0800 Subject: [PATCH 3/6] fix: enhance 401 error handling to exclude login requests --- apps/admin/src/api/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 35aa42e..978f547 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => { instance.interceptors.response.use( (res) => res.data, (err) => { - if (err.response?.status === 401) { + const isLoginRequest = + err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; + + if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); localStorage.removeItem('user'); localStorage.removeItem('permissions'); -- 2.49.1 From 6d4224191879d8ef4d0f337f26abd69c1140607a Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 14:29:01 +0800 Subject: [PATCH 4/6] fix: enhance emergency contact phone display with masking and view option --- apps/admin/src/pages/Students/index.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index e5bfdc4..0eca35a 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => { }, { title: '民族', dataIndex: 'ethnicity', width: 90 }, { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 }, - { title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 }, + { + title: '紧急联系人电话', + dataIndex: 'emergencyPhone', + width: 150, + render: (v: string, record: any) => { + if (!v) return '-'; + return ( + + {maskPhone(v)} + + + ); + }, + }, { title: '所属机构', dataIndex: 'organization', -- 2.49.1 From 910a4263f6318f704abd130c40657d38b30670f5 Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 14:52:03 +0800 Subject: [PATCH 5/6] fix: refactor notification display logic and add format function --- .../admin/src/components/NotificationBell.tsx | 15 ++--------- apps/admin/src/pages/Notifications/index.tsx | 5 ++-- apps/admin/src/utils/notification-display.ts | 25 +++++++++++++++++++ apps/server/src/classes/classes.controller.ts | 10 +++++++- 4 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 apps/admin/src/utils/notification-display.ts diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index 53d3fdb..91e7fb1 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../api'; +import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display'; interface NotificationItem { id: number; @@ -14,18 +15,6 @@ interface NotificationItem { createdAt: string; } -const typeLabels: Record = { - bill_generated: '账单', - bill_paid: '账单', - check_in: '入住', - check_out: '退宿', - deposit_due: '押金', - deposit_refunded: '押金', - class_change: '班级', - schedule_conflict: '排课', - announcement: '公告', -}; - function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => { strong={!item.isRead} style={{ fontSize: 14 }} > - [{typeLabels[item.type] || item.type}] {item.title} + [{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)} } description={ diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index e247956..0762ba0 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -10,6 +10,7 @@ import { import { useNavigate } from 'react-router-dom'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { formatNotificationText } from '../../utils/notification-display'; const { Sider, Content } = Layout; @@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => { strong={!item.isRead} style={{ fontSize: 15 }} > - {item.title} + {formatNotificationText(item.title)} {timeAgo(item.createdAt)} @@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => { ellipsis={{ rows: 1 }} style={{ marginBottom: 0 }} > - {item.content} + {formatNotificationText(item.content)} ) } diff --git a/apps/admin/src/utils/notification-display.ts b/apps/admin/src/utils/notification-display.ts new file mode 100644 index 0000000..909ec64 --- /dev/null +++ b/apps/admin/src/utils/notification-display.ts @@ -0,0 +1,25 @@ +export const notificationTypeLabels: Record = { + bill_generated: '账单', + bill_paid: '账单', + check_in: '入住', + check_out: '退宿', + deposit_due: '押金', + deposit_refunded: '押金', + class_change: '班级', + schedule_conflict: '排课', + announcement: '公告', +}; + +const teacherRoleLabels: Record = { + subject_teacher: '任课老师', + head_teacher: '班主任', + life_teacher: '生活老师', + academic_teacher: '学服老师', +}; + +export function formatNotificationText(text: string): string { + return Object.entries(teacherRoleLabels).reduce( + (result, [roleType, label]) => result.replaceAll(roleType, label), + text, + ); +} diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index d79af3d..1b121a9 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; +import { TeacherRoleType } from '../entities'; import * as ExcelJS from 'exceljs'; import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization'; @@ -36,6 +37,13 @@ interface AuthenticatedRequest { user: AuthenticatedUser; } +const teacherRoleLabels: Record = { + [TeacherRoleType.SUBJECT_TEACHER]: '任课老师', + [TeacherRoleType.HEAD_TEACHER]: '班主任', + [TeacherRoleType.LIFE_TEACHER]: '生活老师', + [TeacherRoleType.ACADEMIC_TEACHER]: '学服老师', +}; + @UseGuards(JwtAuthGuard) @Controller('classes') export class ClassesController { @@ -302,7 +310,7 @@ export class ClassesController { recipientIds: [dto.userId], type: NotificationType.CLASS_CHANGE, title: '班级分配', - content: `您已被分配到班级担任${dto.roleType}角色`, + content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`, }); } catch {} return result; -- 2.49.1 From ebce2463f0dc589a4e5158db49fd8f01d55e99db Mon Sep 17 00:00:00 2001 From: xyx Date: Mon, 13 Jul 2026 15:27:17 +0800 Subject: [PATCH 6/6] fix: remove sensitive data masking from student options label --- apps/admin/src/pages/Deposits/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 2792b04..16c29c0 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -17,7 +17,6 @@ import { import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; -import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; @@ -91,10 +90,9 @@ const DepositsPage: React.FC = () => { const studentOptions = useMemo( () => students - .filter((s: any) => s.status === 'active') .map((s: any) => ({ value: s.id, - label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`, + label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name, })), [students], ); -- 2.49.1