7 Commits

12 changed files with 180 additions and 61 deletions

View File

@@ -16,7 +16,10 @@ instance.interceptors.request.use((config) => {
instance.interceptors.response.use( instance.interceptors.response.use(
(res) => res.data, (res) => res.data,
(err) => { (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('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('permissions'); localStorage.removeItem('permissions');

View File

@@ -3,6 +3,7 @@ import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
import { BellOutlined } from '@ant-design/icons'; import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../api'; import api from '../api';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
interface NotificationItem { interface NotificationItem {
id: number; id: number;
@@ -14,18 +15,6 @@ interface NotificationItem {
createdAt: string; createdAt: string;
} }
const typeLabels: Record<string, string> = {
bill_generated: '账单',
bill_paid: '账单',
check_in: '入住',
check_out: '退宿',
deposit_due: '押金',
deposit_refunded: '押金',
class_change: '班级',
schedule_conflict: '排课',
announcement: '公告',
};
function timeAgo(dateStr: string): string { function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@@ -163,7 +152,7 @@ const NotificationBell: React.FC = () => {
strong={!item.isRead} strong={!item.isRead}
style={{ fontSize: 14 }} style={{ fontSize: 14 }}
> >
[{typeLabels[item.type] || item.type}] {item.title} [{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)}
</Typography.Text> </Typography.Text>
} }
description={ description={

View File

@@ -17,7 +17,6 @@ import {
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
@@ -91,10 +90,9 @@ const DepositsPage: React.FC = () => {
const studentOptions = useMemo( const studentOptions = useMemo(
() => () =>
students students
.filter((s: any) => s.status === 'active')
.map((s: any) => ({ .map((s: any) => ({
value: s.id, 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], [students],
); );

View File

@@ -31,6 +31,11 @@ import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const ExpensesPage: React.FC = () => { const ExpensesPage: React.FC = () => {
@@ -157,16 +162,16 @@ const ExpensesPage: React.FC = () => {
const handleRoomExpense = async () => { const handleRoomExpense = async () => {
setSaving(true); 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 { 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) { if (editingRoom) {
await api.put(`/expenses/room/${editingRoom.id}`, payload); await api.put(`/expenses/room/${editingRoom.id}`, payload);
message.success('更新成功'); message.success('更新成功');
@@ -179,7 +184,9 @@ const ExpensesPage: React.FC = () => {
roomForm.resetFields(); roomForm.resetFields();
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -187,16 +194,16 @@ const ExpensesPage: React.FC = () => {
const handlePersonalExpense = async () => { const handlePersonalExpense = async () => {
setSaving(true); 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 { 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) { if (editingPersonal) {
await api.put(`/expenses/personal/${editingPersonal.id}`, payload); await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
message.success('更新成功'); message.success('更新成功');
@@ -209,7 +216,9 @@ const ExpensesPage: React.FC = () => {
personalForm.resetFields(); personalForm.resetFields();
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -10,6 +10,7 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../api'; import api from '../../api';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { formatNotificationText } from '../../utils/notification-display';
const { Sider, Content } = Layout; const { Sider, Content } = Layout;
@@ -169,7 +170,7 @@ const NotificationsPage: React.FC = () => {
strong={!item.isRead} strong={!item.isRead}
style={{ fontSize: 15 }} style={{ fontSize: 15 }}
> >
{item.title} {formatNotificationText(item.title)}
</Typography.Text> </Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{timeAgo(item.createdAt)} {timeAgo(item.createdAt)}
@@ -183,7 +184,7 @@ const NotificationsPage: React.FC = () => {
ellipsis={{ rows: 1 }} ellipsis={{ rows: 1 }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
> >
{item.content} {formatNotificationText(item.content)}
</Typography.Paragraph> </Typography.Paragraph>
) )
} }

View File

@@ -322,7 +322,28 @@ const StudentsPage: React.FC = () => {
}, },
{ title: '民族', dataIndex: 'ethnicity', width: 90 }, { title: '民族', dataIndex: 'ethnicity', width: 90 },
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 }, { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 }, {
title: '紧急联系人电话',
dataIndex: 'emergencyPhone',
width: 150,
render: (v: string, record: any) => {
if (!v) return '-';
return (
<span>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button
type="link"
size="small"
style={{ padding: '8px 4px' }}
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
</span>
);
},
},
{ {
title: '所属机构', title: '所属机构',
dataIndex: 'organization', dataIndex: 'organization',

View File

@@ -0,0 +1,25 @@
export const notificationTypeLabels: Record<string, string> = {
bill_generated: '账单',
bill_paid: '账单',
check_in: '入住',
check_out: '退宿',
deposit_due: '押金',
deposit_refunded: '押金',
class_change: '班级',
schedule_conflict: '排课',
announcement: '公告',
};
const teacherRoleLabels: Record<string, string> = {
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,
);
}

View File

@@ -6,7 +6,7 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"author": "", "author": "",
"scripts": { "scripts": {
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json", "dev": "cross-env SEED_DEV=true nest start --watch -p tsconfig.build.json",
"build": "nest build -p tsconfig.build.json", "build": "nest build -p tsconfig.build.json",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"format": "oxfmt", "format": "oxfmt",
@@ -67,6 +67,7 @@
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",

View File

@@ -29,6 +29,7 @@ import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import { TeacherRoleType } from '../entities';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization'; import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
@@ -36,6 +37,13 @@ interface AuthenticatedRequest {
user: AuthenticatedUser; user: AuthenticatedUser;
} }
const teacherRoleLabels: Record<string, string> = {
[TeacherRoleType.SUBJECT_TEACHER]: '任课老师',
[TeacherRoleType.HEAD_TEACHER]: '班主任',
[TeacherRoleType.LIFE_TEACHER]: '生活老师',
[TeacherRoleType.ACADEMIC_TEACHER]: '学服老师',
};
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('classes') @Controller('classes')
export class ClassesController { export class ClassesController {
@@ -302,7 +310,7 @@ export class ClassesController {
recipientIds: [dto.userId], recipientIds: [dto.userId],
type: NotificationType.CLASS_CHANGE, type: NotificationType.CLASS_CHANGE,
title: '班级分配', title: '班级分配',
content: `您已被分配到班级担任${dto.roleType}角色`, content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,
}); });
} catch {} } catch {}
return result; return result;

View File

@@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { IntegrationConfigService } from './config/integration-config.service';
// ── Types ── // ── Types ──
@@ -18,6 +19,11 @@ interface DingTalkTokenResponse {
expireIn: number; expireIn: number;
} }
interface DingTalkCredentials {
appKey: string;
appSecret: string;
}
interface DingTalkUserListResponse { interface DingTalkUserListResponse {
errcode: number; errcode: number;
errmsg: string; errmsg: string;
@@ -168,6 +174,7 @@ export interface DingTalkScheduleResult {
export class DingTalkService { export class DingTalkService {
private readonly logger = new Logger(DingTalkService.name); private readonly logger = new Logger(DingTalkService.name);
private accessToken: string | null = null; private accessToken: string | null = null;
private accessTokenCredentialKey: string | null = null;
private tokenExpiresAt = 0; private tokenExpiresAt = 0;
private apiRequestCount = 0; private apiRequestCount = 0;
@@ -180,10 +187,28 @@ export class DingTalkService {
private readonly studentRepo: Repository<Student>, private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping) @InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>, private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
private readonly integrationConfigService?: IntegrationConfigService,
) {} ) {}
private get configured(): boolean { private async getCredentials(): Promise<DingTalkCredentials | null> {
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET); 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<boolean> {
return !!(await this.getCredentials());
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -191,16 +216,24 @@ export class DingTalkService {
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
private async getAccessToken(): Promise<string> { private async getAccessToken(): Promise<string> {
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; 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', { const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appKey, appSecret }), body: JSON.stringify(credentials),
}); });
const body: DingTalkTokenResponse = await res.json(); const body: DingTalkTokenResponse = await res.json();
@@ -209,6 +242,7 @@ export class DingTalkService {
} }
this.accessToken = body.accessToken; this.accessToken = body.accessToken;
this.accessTokenCredentialKey = credentialKey;
this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000; this.tokenExpiresAt = Date.now() + (body.expireIn || 7200) * 1000;
this.logger.log('钉钉 access_token 获取成功'); this.logger.log('钉钉 access_token 获取成功');
return this.accessToken; return this.accessToken;
@@ -259,7 +293,7 @@ export class DingTalkService {
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { 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),跳过同步'); this.logger.warn('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET),跳过同步');
return { deptCount: 0, userCount: 0 }; return { deptCount: 0, userCount: 0 };
} }
@@ -333,7 +367,7 @@ export class DingTalkService {
/** 获取钉钉组织部门树(只含部门) */ /** 获取钉钉组织部门树(只含部门) */
async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> { async fetchOrgTree(rootDeptId = 1): Promise<OrgDeptNode[]> {
if (!this.configured) return []; if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId); const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return []; if (!rootInfo) return [];
@@ -343,7 +377,7 @@ export class DingTalkService {
/** 获取钉钉组织部门树(含用户) */ /** 获取钉钉组织部门树(含用户) */
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> { async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<OrgDeptNodeWithUsers[]> {
if (!this.configured) return []; if (!(await this.isConfigured())) return [];
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const rootInfo = await this.getDeptInfo(token, rootDeptId); const rootInfo = await this.getDeptInfo(token, rootDeptId);
if (!rootInfo) return []; if (!rootInfo) return [];
@@ -441,7 +475,7 @@ export class DingTalkService {
endDate: string; endDate: string;
userIds?: string[]; userIds?: string[];
}): Promise<DingTalkAttendanceResult[]> { }): Promise<DingTalkAttendanceResult[]> {
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) throw new Error('钉钉考勤 userIds 不能为空');
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人'); if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
@@ -496,7 +530,7 @@ export class DingTalkService {
/** 创建或修改班次。id 不传=创建,传了=修改 */ /** 创建或修改班次。id 不传=创建,传了=修改 */
async upsertShift(params: DingTalkShiftParams): Promise<number> { async upsertShift(params: DingTalkShiftParams): Promise<number> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
@@ -547,7 +581,7 @@ export class DingTalkService {
/** 查询所有班次摘要每页最多200条 */ /** 查询所有班次摘要每页最多200条 */
async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> { async queryShifts(opUserId = 'manager'): Promise<DingTalkShiftSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const all: DingTalkShiftSummary[] = []; const all: DingTalkShiftSummary[] = [];
@@ -598,7 +632,7 @@ export class DingTalkService {
/** 创建排班制考勤组 */ /** 创建排班制考勤组 */
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> { async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const topGroup = this.buildAttendanceGroupBody(params); const topGroup = this.buildAttendanceGroupBody(params);
@@ -627,7 +661,7 @@ export class DingTalkService {
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */ /** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> { async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id }; const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
@@ -687,7 +721,7 @@ export class DingTalkService {
/** 查询所有考勤组摘要分页每页10条 */ /** 查询所有考勤组摘要分页每页10条 */
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> { async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const all: DingTalkGroupSummary[] = []; const all: DingTalkGroupSummary[] = [];
@@ -729,7 +763,7 @@ export class DingTalkService {
} }
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> { async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
await this.rateLimit(); await this.rateLimit();
@@ -778,7 +812,7 @@ export class DingTalkService {
async scheduleUsers( async scheduleUsers(
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
): Promise<void> { ): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
if (schedules.length === 0) return; if (schedules.length === 0) return;
if (schedules.length > 200) { if (schedules.length > 200) {
throw new Error(`排班单次最多200条当前 ${schedules.length}`); throw new Error(`排班单次最多200条当前 ${schedules.length}`);
@@ -818,7 +852,7 @@ export class DingTalkService {
async queryScheduleByUsers( async queryScheduleByUsers(
userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
): Promise<DingTalkScheduleResult[]> { ): Promise<DingTalkScheduleResult[]> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); if (!(await this.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken(); const token = await this.getAccessToken();
await this.rateLimit(); await this.rateLimit();

View File

@@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { User, Student, StudentDingMapping, Class } from '../entities'; import { User, Student, StudentDingMapping, Class } from '../entities';
import { DingTalkService } from './dingtalk.service'; import { DingTalkService } from './dingtalk.service';
import { WeComService } from './wecom.service'; import { WeComService } from './wecom.service';
import { IntegrationConfigModule } from './config/config.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class])], imports: [
TypeOrmModule.forFeature([User, Student, StudentDingMapping, Class]),
IntegrationConfigModule,
],
providers: [DingTalkService, WeComService], providers: [DingTalkService, WeComService],
exports: [DingTalkService, WeComService], exports: [DingTalkService, WeComService],
}) })

26
package-lock.json generated
View File

@@ -98,6 +98,7 @@
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
@@ -1133,6 +1134,13 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -8697,6 +8705,24 @@
"url": "https://ko-fi.com/intcreator" "url": "https://ko-fi.com/intcreator"
} }
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",