Files
gongxue-base/docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md

10 KiB
Raw Blame History

DingTalk 导入链路修复 — 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修复钉钉导入链路的两个阻塞 bug前端叶子节点判断、后端 CampusScope 过滤导致学生/考勤数据不可见。

Architecture: 教学域(学生、考勤)移除 CampusScope 部门过滤,改为依赖 RBAC 权限码控制访问;管理域(宿舍、财务)保留 CampusScope 不变。

Tech Stack: React 19 + TypeScript + NestJS 11 + TypeORM

Global Constraints

  • 遵循现有 NestJS 模块结构
  • 后端编译检查:npx tsc --noEmit -p tsconfig.build.json
  • 前端编译检查:npx tsc --noEmit
  • 不改动 syncAllimportDingTalkUsers、RBAC 权限码
  • 不改动 OccupanciesServiceRoomsServiceBillsServiceExpensesService(保留 CampusScope

Task 1: 前端 — 叶子节点判断

Files:

  • Modify: apps/admin/src/pages/IntegrationConfig/index.tsx:375-393

Interfaces:

  • Consumes: DingOrgTreeNodeExt (has children: DingOrgTreeNodeExt[], users: Array<...>)

  • Produces: DataNode[] tree nodes with conditional class-mark button

  • Step 1: Read current code to confirm line numbers

Run: read apps/admin/src/pages/IntegrationConfig/index.tsx:373-395

  • Step 2: Modify buildTreeData — add leaf-node condition

当前 node.childrennode.users 渲染逻辑不需要改,只需要在 title 渲染部分(第 373395 行)用条件包裹"标为班级"按钮:

const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
  return nodes.map((node) => ({
    title: (
      <Space size="small">
        <span>{node.name}</span>
        {node.children.length === 0 && node.users.length > 0 && (
          classMarks[node.id] ? (
            <Tag
              color="blue"
              style={{ cursor: 'pointer' }}
              onClick={() => openClassModal(node.id, node.name)}
            >
              班级: {classMarks[node.id].name} [已标记]
            </Tag>
          ) : (
            <Button
              size="small"
              type="link"
              icon={<span>🏫</span>}
              onClick={() => openClassModal(node.id, node.name)}
            >
              标为班级
            </Button>
          )
        )}
      </Space>
    ),
    key: `dept-${node.id}`,
    children: [
      ...buildTreeData(node.children),
      ...node.users.map((u) => ({ ... })),
    ],
  }));
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);

改动要点:用 {node.children.length === 0 && node.users.length > 0 && ( ... )} 包裹原来的三元表达式。只有无子部门且有用户的叶子节点才显示按钮。

  • Step 3: 验证 TypeScript 编译

Run: cd /Users/tiku1/code/gongxue-base/apps/admin && npx tsc --noEmit Expected: no errors

  • Step 4: Commit
git add apps/admin/src/pages/IntegrationConfig/index.tsx
git commit -m "fix(admin): only show class-mark button on leaf departments in DingTalk org import"

Task 2: 后端 — StudentsService 移除 CampusScope

Files:

  • Modify: apps/server/src/students/students.service.ts

Interfaces:

  • Consumes: (none external — internal change only)

  • Produces: findAll(query) returns Promise<Student[]> — same signature, no scope filtering

  • Step 1: Read current code

Run: read apps/server/src/students/students.service.ts

  • Step 2: Remove CampusScope import

Delete line 4:

import { CampusScope } from '../common/campus-scope';
  • Step 3: Remove constructor parameter

SWAP lines 14-20 — remove private readonly scope: CampusScope and trailing comma on line 18:

  constructor(
    @InjectRepository(Student) private repo: Repository<Student>,
    @InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
    @InjectRepository(Class) private classRepo: Repository<Class>,
    @InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
  ) {}
  • Step 4: Remove scope.filter call in findAll

SWAP lines 22-33 — remove the filteredWhere intermediate. findAll now uses where directly:

  async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
    const where: FindOptionsWhere<Student> = {};
    if (query?.name) where.name = Like(`%${query.name}%`);
    if (query?.tenantId) where.tenantId = Number(query.tenantId);
    if (query?.status) {
      where.status = query.status;
    } else if (!query?.includeArchived) {
      where.status = Not(In(['archived']));
    }
    return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
  }
  • Step 5: Verify TypeScript compile

Run: cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json Expected: no errors (ignore pre-existing errors in files not touched)

  • Step 6: Commit
git add apps/server/src/students/students.service.ts
git commit -m "fix(server): remove CampusScope from StudentsService, classes are teacher-managed not dept-filtered"

Task 3: 后端 — AttendanceService 移除 CampusScope

Files:

  • Modify: apps/server/src/attendance/attendance.service.ts

Interfaces:

  • Consumes: (none external — internal change only)

  • Produces: all public method signatures unchanged, no scope filtering applied

  • Step 1: Read current code to confirm line numbers

Run: read apps/server/src/attendance/attendance.service.ts:1-40

  • Step 2: Remove CampusScope import (line 9)
import { CampusScope } from '../common/campus-scope';

→ Delete this line.

  • Step 3: Remove constructor parameter (line 39)

SWAP the constructor body — remove private readonly scope: CampusScope:

  constructor(
    @InjectRepository(AttendanceRecord)
    private attendanceRepo: Repository<AttendanceRecord>,
    @InjectRepository(DingAttendanceRaw)
    private dingRawRepo: Repository<DingAttendanceRaw>,
    @InjectRepository(Class)
    private classRepo: Repository<Class>,
    @InjectRepository(Student)
    private studentRepo: Repository<Student>,
    @InjectRepository(ClassSchedule)
    private scheduleRepo: Repository<ClassSchedule>,
    @InjectRepository(ClassStudent)
    private classStudentRepo: Repository<ClassStudent>,
    @InjectRepository(UserDingMapping)
    private userDingMappingRepo: Repository<UserDingMapping>,
  ) {}
  • Step 4: Remove scope block in getSummary (lines 182-185)

Delete lines 182-185:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
    }
  • Step 5: Remove scope block in findAll (lines 287-290)

Delete lines 287-290:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
    }
  • Step 6: Remove scope blocks in getClasses (lines 327-330 and 339)

Delete lines 327-330:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
    }

SWAP line 339 — replace await this.scope.filter({ id: In(classIds) }) with { id: In(classIds) }:

    const classes = await this.classRepo.find({ where: { id: In(classIds) } });
  • Step 7: Remove scope block in findAllForExport (lines 422-425)

Delete lines 422-425:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
    }
  • Step 8: Remove scope guard in update (lines 460-463)

Delete lines 460-463:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds && !scopeIds.includes(record.departmentId)) {
      throw new NotFoundException(`AttendanceRecord ${id} not found`);
    }
  • Step 9: Remove scope guard in remove (lines 482-485)

Delete lines 482-485:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds && !scopeIds.includes(record.departmentId)) {
      throw new NotFoundException(`AttendanceRecord ${id} not found`);
    }
  • Step 10: Remove scope block in getReport (lines 494-497)

Delete lines 494-497:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
    }
  • Step 11: Remove scope block in getAlerts (lines 570-573)

Delete lines 570-573:

    const scopeIds = await this.scope.getScopeDepartmentIds();
    if (scopeIds) {
      qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
    }
  • Step 12: Verify TypeScript compile

Run: cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json Expected: no errors (ignore pre-existing errors in files not touched)

  • Step 13: Commit
git add apps/server/src/attendance/attendance.service.ts
git commit -m "fix(server): remove CampusScope from AttendanceService, attendance is teacher-managed not dept-filtered"

Task 4: 端到端验证

  • Step 1: 启动后端
cd /Users/tiku1/code/gongxue-base/apps/server && npm run start:dev
  • Step 2: 启动前端
cd /Users/tiku1/code/gongxue-base/apps/admin && npm run dev
  • Step 3: 验证场景
  1. 以非超管老师身份登录,打开集成配置页面
  2. 获取钉钉组织架构 → 确认父部门没有"标为班级"按钮,叶子部门有
  3. 标记班级、勾选老师、执行导入 → 确认导入成功
  4. 导航到学生管理页面 → 确认能看到导入的学生
  5. 导航到考勤管理页面 → 确认有数据
  • Step 4: Commit verification notes
git add -A && git commit -m "chore: end-to-end verification notes"