From 9e6633b8ff91268a488babcaa37d8ff7143b1b84 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:24:34 +0800 Subject: [PATCH 01/12] =?UTF-8?q?build(backend):=20Phase=200=20=E2=80=94?= =?UTF-8?q?=20TypeScript=20infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish TypeScript toolchain for gradual JS→TS migration per docs/后端TypeScript迁移实施计划.md Phase 0. No business code changed. - Install typescript 7.0.2, tsx, @types/{node,express,jsonwebtoken,multer,cors} - Add tsconfig.json (NodeNext, allowJs, checkJs:false, strict:false) + tsconfig.build.json (production, excludes tests) - package.json: add build/typecheck scripts; dev → tsx watch; start → dist/ - Add src/types/mammoth.d.ts ambient declaration (mammoth has no @types) - vitest.config.js → vitest.config.ts (include matches .test.{js,ts}) Deviations from plan (TS 7.0 + npm registry reality): - baseUrl removed — TS 5102: option removed in TS 7.0 (paths was empty) - @types/uuid, @types/jwt-decode skipped — deprecated stubs, packages ship own types - @types/mammoth → custom ambient declaration (404 on npm registry) - setupFiles uses concrete setup.js — vitest does not expand {js,ts} brace glob in setupFiles (the include glob does support it) Verified: tsc --noEmit (0 errors) · pnpm test (176/176) · pnpm build (dist/ emitted with index.js) · tsx boots server without load errors. Co-Authored-By: Claude Fable 5 --- backend/package.json | 13 +- backend/pnpm-lock.yaml | 643 +++++++++++++++++- backend/src/types/mammoth.d.ts | 38 ++ backend/tsconfig.build.json | 15 + backend/tsconfig.json | 37 + .../{vitest.config.js => vitest.config.ts} | 2 +- 6 files changed, 721 insertions(+), 27 deletions(-) create mode 100644 backend/src/types/mammoth.d.ts create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json rename backend/{vitest.config.js => vitest.config.ts} (81%) diff --git a/backend/package.json b/backend/package.json index 901af8b..d893a3d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,8 +4,10 @@ "description": "", "main": "index.js", "scripts": { - "start": "cross-env NODE_ENV=production node src/index.js", - "dev": "cross-env NODE_ENV=development node src/index.js", + "start": "cross-env NODE_ENV=production node dist/index.js", + "dev": "cross-env NODE_ENV=development tsx watch src/index.js", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -36,8 +38,15 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.2.0", + "@types/node": "^26.1.1", "@vitest/coverage-v8": "^3.1.1", "cross-env": "^7.0.3", + "tsx": "^4.23.0", + "typescript": "^7.0.2", "vitest": "^3.1.1" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 4464c30..49d4914 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -66,15 +66,36 @@ importers: specifier: ^13.0.0 version: 13.0.0 devDependencies: + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/multer': + specifier: ^2.2.0 + version: 2.2.0 + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@26.1.1)(tsx@4.23.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 + tsx: + specifier: ^4.23.0 + version: 4.23.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^3.1.1 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@26.1.1)(tsx@4.23.0) packages: @@ -128,156 +149,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@huggingface/jinja@0.5.5': resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} engines: {node: '>=18'} @@ -702,9 +879,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -714,14 +900,41 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.2.2': - resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -729,6 +942,126 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -1087,6 +1420,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -2030,6 +2368,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -2045,11 +2388,16 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + underscore@1.13.7: resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} unique-filename@3.0.0: resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} @@ -2246,81 +2594,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@huggingface/jinja@0.5.5': {} '@huggingface/transformers@3.8.1': @@ -2639,11 +3065,24 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.1.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.1.1 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 26.1.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -2652,19 +3091,116 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 26.1.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 26.1.1 + '@types/ms@2.1.0': {} - '@types/node@25.2.2': + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': dependencies: - undici-types: 7.16.0 + '@types/node': 26.1.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.1.1 '@types/uuid@10.0.0': {} '@types/validator@13.15.10': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.2))': + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@26.1.1)(tsx@4.23.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2679,7 +3215,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@26.1.1)(tsx@4.23.0) transitivePeerDependencies: - supports-color @@ -2691,13 +3227,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@26.1.1)(tsx@4.23.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.2) + vite: 7.3.1(@types/node@26.1.1)(tsx@4.23.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -3036,6 +3572,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -3721,7 +4286,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.2.2 + '@types/node': 26.1.1 long: 5.3.2 proxy-addr@2.0.7: @@ -4083,6 +4648,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-fest@0.13.1: {} type-is@1.6.18: @@ -4098,9 +4669,32 @@ snapshots: typedarray@0.0.6: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + underscore@1.13.7: {} - undici-types@7.16.0: {} + undici-types@8.3.0: {} unique-filename@3.0.0: dependencies: @@ -4124,13 +4718,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@25.2.2): + vite-node@3.2.4(@types/node@26.1.1)(tsx@4.23.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.2.2) + vite: 7.3.1(@types/node@26.1.1)(tsx@4.23.0) transitivePeerDependencies: - '@types/node' - jiti @@ -4145,7 +4739,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.2.2): + vite@7.3.1(@types/node@26.1.1)(tsx@4.23.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -4154,14 +4748,15 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.2 + '@types/node': 26.1.1 fsevents: 2.3.3 + tsx: 4.23.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@26.1.1)(tsx@4.23.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.2.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@26.1.1)(tsx@4.23.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -4179,12 +4774,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.2.2) - vite-node: 3.2.4(@types/node@25.2.2) + vite: 7.3.1(@types/node@26.1.1)(tsx@4.23.0) + vite-node: 3.2.4(@types/node@26.1.1)(tsx@4.23.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 25.2.2 + '@types/node': 26.1.1 transitivePeerDependencies: - jiti - less @@ -4214,7 +4809,7 @@ snapshots: wkx@0.5.0: dependencies: - '@types/node': 25.2.2 + '@types/node': 26.1.1 wrap-ansi@7.0.0: dependencies: diff --git a/backend/src/types/mammoth.d.ts b/backend/src/types/mammoth.d.ts new file mode 100644 index 0000000..bd9a308 --- /dev/null +++ b/backend/src/types/mammoth.d.ts @@ -0,0 +1,38 @@ +// mammoth 没有 @types 包,此处提供本项目实际使用的最小类型声明 +// 参考:https://www.npmjs.com/package/mammoth +declare module 'mammoth' { + /** DOCX 转换结果 */ + export interface MammothResult { + /** 提取出的文本 / HTML */ + value: string; + /** 转换过程中产生的消息(警告等) */ + messages: Array<{ type: string; message: string }>; + } + + /** 输入参数:path(文件路径)或 buffer/arrayBuffer */ + export interface ExtractInput { + path?: string; + arrayBuffer?: ArrayBuffer; + buffer?: Buffer; + } + + /** 提取纯文本 */ + export function extractRawText(input: ExtractInput): Promise; + + /** 转换为 HTML */ + export function convertToHtml(input: ExtractInput): Promise; + + /** 图片转换器集合 */ + export const images: { + imgElement( + func: (image: unknown) => Promise> + ): unknown; + }; + + const _default: { + extractRawText: typeof extractRawText; + convertToHtml: typeof convertToHtml; + images: typeof images; + }; + export default _default; +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..a45cbf9 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "checkJs": false, + "sourceMap": false, + "removeComments": true + }, + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/*.test.ts", + "vitest.config.*" + ] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..2986001 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + // 模块与目标 + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + + // 渐进迁移核心:允许 JS/TS 共存 + "allowJs": true, + "checkJs": false, + + // 输出 + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "declaration": false, + "removeComments": false, + + // 严格度(初始宽松,Phase 9 收紧) + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": false, + + // 互操作 + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**"] +} diff --git a/backend/vitest.config.js b/backend/vitest.config.ts similarity index 81% rename from backend/vitest.config.js rename to backend/vitest.config.ts index 3eac069..599429a 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - include: ['src/**/__tests__/**/*.test.js'], + include: ['src/**/__tests__/**/*.test.{js,ts}'], setupFiles: ['src/__tests__/setup.js'], coverage: { provider: 'v8', From 73ed803aaed85dd1de06c40ddad6976b39293c94 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:31:01 +0800 Subject: [PATCH 02/12] =?UTF-8?q?build(backend):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20migrate=20utils=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 7 pure-function files in src/utils/ from .js to .ts with type annotations. Logic unchanged; types only. - array.ts: chunk, runWithConcurrency generics - response.ts: ApiResponse interface, typed success/error/paginated - retry.ts: RetryOptions interface, withRetry generic, AxiosLikeError cast - crypto.ts: Buffer/string types, decrypt generic preserves passthrough - assignee.ts: ProjectMember interface - syncCompare.ts: RemoteItem / SyncedRecord interfaces - workItem.ts: WorkloadUnit / WorkItemIds types, Map for lookups Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Confirmed vitest resolves .js import specifiers to .ts source (test files stay .js until Phase 8). Co-Authored-By: Claude Fable 5 --- backend/src/utils/{array.js => array.ts} | 22 +++--- .../src/utils/{assignee.js => assignee.ts} | 24 +++++-- backend/src/utils/{crypto.js => crypto.ts} | 20 +++--- backend/src/utils/response.js | 34 --------- backend/src/utils/response.ts | 49 +++++++++++++ backend/src/utils/retry.js | 52 -------------- backend/src/utils/retry.ts | 70 +++++++++++++++++++ .../utils/{syncCompare.js => syncCompare.ts} | 36 ++++++++-- .../src/utils/{workItem.js => workItem.ts} | 41 ++++++++--- 9 files changed, 219 insertions(+), 129 deletions(-) rename backend/src/utils/{array.js => array.ts} (56%) rename backend/src/utils/{assignee.js => assignee.ts} (72%) rename backend/src/utils/{crypto.js => crypto.ts} (86%) delete mode 100644 backend/src/utils/response.js create mode 100644 backend/src/utils/response.ts delete mode 100644 backend/src/utils/retry.js create mode 100644 backend/src/utils/retry.ts rename backend/src/utils/{syncCompare.js => syncCompare.ts} (57%) rename backend/src/utils/{workItem.js => workItem.ts} (78%) diff --git a/backend/src/utils/array.js b/backend/src/utils/array.ts similarity index 56% rename from backend/src/utils/array.js rename to backend/src/utils/array.ts index 5a2bd95..7db461d 100644 --- a/backend/src/utils/array.js +++ b/backend/src/utils/array.ts @@ -1,8 +1,8 @@ /** * 将数组分割为指定大小的块 */ -export function chunk(arr, size) { - const result = []; +export function chunk(arr: T[], size: number): T[][] { + const result: T[][] = []; for (let i = 0; i < arr.length; i += size) { result.push(arr.slice(i, i + size)); } @@ -12,16 +12,19 @@ export function chunk(arr, size) { /** * 以受限并发执行异步任务(避免 Promise.all 一次性打满下游 API 限流)。 * - * @param {Array<() => Promise>} tasks - 返回 Promise 的函数数组 - * @param {number} limit - 最大并发数 - * @returns {Promise} 按输入顺序排列的结果数组 + * @param tasks - 返回 Promise 的函数数组 + * @param limit - 最大并发数 + * @returns 按输入顺序排列的结果数组 */ -export async function runWithConcurrency(tasks, limit) { +export async function runWithConcurrency( + tasks: Array<() => Promise>, + limit: number, +): Promise { if (!tasks.length) return []; - const results = new Array(tasks.length); + const results: T[] = new Array(tasks.length); let index = 0; - async function runNext() { + async function runNext(): Promise { while (true) { const i = index++; if (i >= tasks.length) return; @@ -31,9 +34,8 @@ export async function runWithConcurrency(tasks, limit) { const workers = Array.from( { length: Math.min(limit, tasks.length) }, - () => runNext() + () => runNext(), ); await Promise.all(workers); return results; } - diff --git a/backend/src/utils/assignee.js b/backend/src/utils/assignee.ts similarity index 72% rename from backend/src/utils/assignee.js rename to backend/src/utils/assignee.ts index 6ef8825..5f8ad71 100644 --- a/backend/src/utils/assignee.js +++ b/backend/src/utils/assignee.ts @@ -5,12 +5,26 @@ * 1. 精确匹配 name / display_name(忽略大小写、首尾空格) * 2. 包含匹配(assignee_name 出现在 name 或 display_name 中,或反之) * 3. 无匹配 → 返回 null(调用方回退到当前用户) - * - * @param {string|null} assigneeName - 从需求文档识别的负责人姓名 - * @param {Array} members - PingCode 成员列表,每项形如 { id, name, display_name, email, ... } - * @returns {string|null} 匹配到的成员 ID,未匹配返回 null */ -export function resolveAssigneeId(assigneeName, members) { + +/** PingCode 项目成员(仅声明本工具依赖的字段) */ +export interface ProjectMember { + id: string; + name?: string; + display_name?: string; + email?: string; + [key: string]: unknown; +} + +/** + * @param assigneeName - 从需求文档识别的负责人姓名 + * @param members - PingCode 成员列表,每项形如 { id, name, display_name, email, ... } + * @returns 匹配到的成员 ID,未匹配返回 null + */ +export function resolveAssigneeId( + assigneeName: string | null | undefined, + members: ProjectMember[], +): string | null { if (!assigneeName || !Array.isArray(members) || members.length === 0) { return null; } diff --git a/backend/src/utils/crypto.js b/backend/src/utils/crypto.ts similarity index 86% rename from backend/src/utils/crypto.js rename to backend/src/utils/crypto.ts index 667e310..4bade77 100644 --- a/backend/src/utils/crypto.js +++ b/backend/src/utils/crypto.ts @@ -12,7 +12,7 @@ import crypto from 'node:crypto'; const PREFIX = 'enc:v1:'; /** 解析 32 字节主密钥:优先 hex/base64,否则按 SHA-256 派生 */ -function resolveKey() { +function resolveKey(): Buffer { const raw = process.env.ENCRYPTION_KEY || ''; if (!raw) { // 开发环境兜底:派生固定 key(生产环境必须设置 ENCRYPTION_KEY) @@ -28,19 +28,19 @@ function resolveKey() { return crypto.createHash('sha256').update(raw).digest(); } -let _key = null; -function getKey() { +let _key: Buffer | null = null; +function getKey(): Buffer { if (!_key) _key = resolveKey(); return _key; } /** 重置缓存的密钥(主要供测试切换 ENCRYPTION_KEY 后使用) */ -export function _resetKey() { +export function _resetKey(): void { _key = null; } /** 是否为加密格式(以 enc:v1: 前缀开头) */ -export function isEncrypted(value) { +export function isEncrypted(value: unknown): boolean { return typeof value === 'string' && value.startsWith(PREFIX); } @@ -48,7 +48,7 @@ export function isEncrypted(value) { * 加密明文,返回 `enc:v1:::` 格式字符串。 * 传入 null/undefined/空串原样返回。 */ -export function encrypt(plaintext) { +export function encrypt(plaintext: string | null | undefined): string | null | undefined { if (plaintext === null || plaintext === undefined) return plaintext; if (plaintext === '') return ''; const str = String(plaintext); @@ -64,7 +64,7 @@ export function encrypt(plaintext) { /** * 解密加密字符串。若输入不是加密格式(明文旧数据),原样返回。 */ -export function decrypt(value) { +export function decrypt(value: T): T { if (typeof value !== 'string' || !isEncrypted(value)) return value; try { @@ -78,7 +78,7 @@ export function decrypt(value) { const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(ct), decipher.final()]); - return decrypted.toString('utf8'); + return decrypted.toString('utf8') as unknown as T; } catch { // 解密失败(密钥变更等),返回原值避免崩溃 console.warn('[Crypto] 解密失败,可能密钥已变更或数据损坏,返回原值'); @@ -90,10 +90,10 @@ export function decrypt(value) { * 对 API Key 等敏感串做脱敏:保留首尾少量字符,中间用 **** 代替。 * `sk-abcd1234efgh` → `sk-ab****efgh` */ -export function maskSecret(value) { +export function maskSecret(value: string | null | undefined): string { if (!value || typeof value !== 'string') return ''; // 先解密(如果存储时被加密),再脱敏 - const plain = isEncrypted(value) ? decrypt(value) : value; + const plain = isEncrypted(value) ? (decrypt(value) as string) : value; if (!plain) return ''; if (plain.length <= 8) return '****'; const head = plain.slice(0, Math.min(6, Math.floor(plain.length / 4))); diff --git a/backend/src/utils/response.js b/backend/src/utils/response.js deleted file mode 100644 index c3bc93d..0000000 --- a/backend/src/utils/response.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 统一响应格式工具 - */ - -/** 成功响应 */ -export function success(data = null, message = '操作成功') { - return { - success: true, - message, - data, - }; -} - -/** 错误响应(直接返回给客户端时使用) */ -export function error(message = '操作失败', code = 500) { - return { - success: false, - error: message, - code, - }; -} - -/** 分页响应 */ -export function paginated(data, total, page, pageSize) { - return { - success: true, - data, - pagination: { - total, - page, - pageSize, - }, - }; -} diff --git a/backend/src/utils/response.ts b/backend/src/utils/response.ts new file mode 100644 index 0000000..4a3089d --- /dev/null +++ b/backend/src/utils/response.ts @@ -0,0 +1,49 @@ +/** + * 统一响应格式工具 + */ + +/** 统一 API 响应结构 */ +export interface ApiResponse { + success: boolean; + message?: string; + data?: T; + error?: string; + code?: number; + pagination?: { total: number; page: number; pageSize: number }; +} + +/** 成功响应 */ +export function success(data: unknown = null, message = '操作成功'): ApiResponse { + return { + success: true, + message, + data, + }; +} + +/** 错误响应(直接返回给客户端时使用) */ +export function error(message = '操作失败', code = 500): ApiResponse { + return { + success: false, + error: message, + code, + }; +} + +/** 分页响应 */ +export function paginated( + data: unknown, + total: number, + page: number, + pageSize: number, +): ApiResponse { + return { + success: true, + data, + pagination: { + total, + page, + pageSize, + }, + }; +} diff --git a/backend/src/utils/retry.js b/backend/src/utils/retry.js deleted file mode 100644 index 2e4b38a..0000000 --- a/backend/src/utils/retry.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 带指数退避的重试工具 - * - * 默认不对 4xx 客户端错误重试(参数错误、权限不足等不会因重试而成功), - * 仅对 5xx 服务端错误和网络错误重试。可通过 retryOnAllErrors: true 关闭此行为。 - * - * @param {Function} fn - 需要重试的异步函数 - * @param {object} options - 配置 - * @param {number} [options.maxRetries=3] - 最大重试次数 - * @param {number} [options.baseDelay=1000] - 基础延迟(毫秒) - * @param {number} [options.maxDelay=10000] - 最大延迟(毫秒) - * @param {string} [options.label=''] - 日志标签 - * @param {boolean} [options.retryOnAllErrors=false] - 为 true 时对所有错误(含 4xx)重试 - * @returns {Promise<*>} 函数执行结果 - */ -export async function withRetry(fn, options = {}) { - const { - maxRetries = 3, - baseDelay = 1000, - maxDelay = 10000, - label = '', - retryOnAllErrors = false, - } = options; - - let lastError; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (err) { - lastError = err; - - if (attempt === maxRetries) { - break; - } - - // 4xx 客户端错误不重试(除非显式要求) - const status = err?.response?.status; - if (!retryOnAllErrors && status >= 400 && status < 500) { - break; - } - - // 指数退避 + 随机抖动 - const delay = Math.min(baseDelay * Math.pow(2, attempt) + Math.random() * 500, maxDelay); - const tag = label ? `[${label}]` : ''; - console.warn(`${tag} 第 ${attempt + 1} 次失败,${Math.round(delay)}ms 后重试: ${err.message}`); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - throw lastError; -} diff --git a/backend/src/utils/retry.ts b/backend/src/utils/retry.ts new file mode 100644 index 0000000..fc378fe --- /dev/null +++ b/backend/src/utils/retry.ts @@ -0,0 +1,70 @@ +/** + * 带指数退避的重试工具 + * + * 默认不对 4xx 客户端错误重试(参数错误、权限不足等不会因重试而成功), + * 仅对 5xx 服务端错误和网络错误重试。可通过 retryOnAllErrors: true 关闭此行为。 + */ + +/** withRetry 配置选项 */ +export interface RetryOptions { + /** 最大重试次数(默认 3) */ + maxRetries?: number; + /** 基础延迟(毫秒,默认 1000) */ + baseDelay?: number; + /** 最大延迟(毫秒,默认 10000) */ + maxDelay?: number; + /** 日志标签 */ + label?: string; + /** 为 true 时对所有错误(含 4xx)重试 */ + retryOnAllErrors?: boolean; +} + +/** axios 风格的错误(带 response.status) */ +interface AxiosLikeError { + response?: { status?: number }; + message?: string; +} + +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxRetries = 3, + baseDelay = 1000, + maxDelay = 10000, + label = '', + retryOnAllErrors = false, + } = options; + + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + const axiosErr = err as AxiosLikeError; + + if (attempt === maxRetries) { + break; + } + + // 4xx 客户端错误不重试(除非显式要求) + const status = axiosErr?.response?.status; + if (!retryOnAllErrors && status && status >= 400 && status < 500) { + break; + } + + // 指数退避 + 随机抖动 + const delay = Math.min(baseDelay * Math.pow(2, attempt) + Math.random() * 500, maxDelay); + const tag = label ? `[${label}]` : ''; + console.warn( + `${tag} 第 ${attempt + 1} 次失败,${Math.round(delay)}ms 后重试: ${axiosErr?.message ?? err}`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} diff --git a/backend/src/utils/syncCompare.js b/backend/src/utils/syncCompare.ts similarity index 57% rename from backend/src/utils/syncCompare.js rename to backend/src/utils/syncCompare.ts index b37f855..069221b 100644 --- a/backend/src/utils/syncCompare.js +++ b/backend/src/utils/syncCompare.ts @@ -3,13 +3,32 @@ * 纯逻辑函数,从 sync.js 抽出以便单元测试。 */ +/** PingCode 远端工作项(仅声明比较依赖的字段,实际对象字段更多) */ +export interface RemoteItem { + updated_at?: string; + updatedAt?: string; + last_modified?: string; + title?: string; + description?: string | null; + [key: string]: unknown; +} + +/** 本地同步缓存行 */ +export interface SyncedRecord { + is_archived?: boolean; + remote_updated_at?: string | null; + title?: string; + description?: string | null; + [key: string]: unknown; +} + /** 提取 PingCode 对象的更新时间,用于增量同步判断 */ -export function remoteUpdatedAt(obj) { +export function remoteUpdatedAt(obj: RemoteItem | null | undefined): string | null { return obj?.updated_at || obj?.updatedAt || obj?.last_modified || null; } /** 规范化更新时间为可比较字符串(ISO 或时间戳字符串) */ -export function normUpdateKey(v) { +export function normUpdateKey(v: unknown): string { if (!v) return ''; return String(v); } @@ -20,12 +39,15 @@ export function normUpdateKey(v) { * 2. 远端有 updated_at 且与本地不同 → 需要更新 * 3. 远端无 updated_at 时,比较 title/description 内容是否变化 * - * @param {object} existing - 本地缓存行(含 is_archived, remote_updated_at, title, description) - * @param {object} remoteItem - PingCode 返回的对象 - * @param {string|null} remoteUpdated - 远端更新时间 - * @returns {boolean} + * @param existing - 本地缓存行(含 is_archived, remote_updated_at, title, description) + * @param remoteItem - PingCode 返回的对象 + * @param remoteUpdated - 远端更新时间 */ -export function needsUpdate(existing, remoteItem, remoteUpdated) { +export function needsUpdate( + existing: SyncedRecord | null | undefined, + remoteItem: RemoteItem | null | undefined, + remoteUpdated: string | null | undefined, +): boolean { // 归档项重新出现,强制更新以取消归档 if (existing?.is_archived) return true; diff --git a/backend/src/utils/workItem.js b/backend/src/utils/workItem.ts similarity index 78% rename from backend/src/utils/workItem.js rename to backend/src/utils/workItem.ts index 0a66bb0..4840518 100644 --- a/backend/src/utils/workItem.js +++ b/backend/src/utils/workItem.ts @@ -3,19 +3,27 @@ */ /** 工时单位配置:minute / hour / day(与 PingCode 企业设置一致) */ -function getWorkloadUnit() { +type WorkloadUnit = 'minute' | 'hour' | 'day'; + +function getWorkloadUnit(): WorkloadUnit { const unit = (process.env.PINGCODE_WORKLOAD_UNIT || 'hour').toLowerCase(); - return ['minute', 'hour', 'day'].includes(unit) ? unit : 'hour'; + return (['minute', 'hour', 'day'] as const).includes(unit as WorkloadUnit) + ? (unit as WorkloadUnit) + : 'hour'; } /** 每单位对应的小时数 */ -const UNIT_TO_HOURS = { minute: 1 / 60, hour: 1, day: 8 }; +const UNIT_TO_HOURS: Record = { + minute: 1 / 60, + hour: 1, + day: 8, +}; /** * 将「小时」转换为 PingCode estimated_workload 所需单位(配置项 PINGCODE_WORKLOAD_UNIT)。 * 默认 'hour'(向后兼容),可配置为 'minute' / 'day'。 */ -export function hoursToWorkload(hours) { +export function hoursToWorkload(hours: unknown): number | null { if (typeof hours !== 'number' || !isFinite(hours) || hours <= 0) return null; const unit = getWorkloadUnit(); const factor = UNIT_TO_HOURS[unit]; @@ -25,13 +33,20 @@ export function hoursToWorkload(hours) { /** * 将 PingCode estimated_workload(按配置单位)转换回「小时」,用于统计展示。 */ -export function workloadToHours(workload) { +export function workloadToHours(workload: unknown): number { if (typeof workload !== 'number' || !isFinite(workload) || workload <= 0) return 0; const unit = getWorkloadUnit(); const factor = UNIT_TO_HOURS[unit]; return Math.round(workload * factor * 1000) / 1000; } +/** extractWorkItemIds 返回的 ID 三元组 */ +export interface WorkItemIds { + typeId: string | null; + priorityId: string | null; + stateId: string | null; +} + /** * 从 PingCode 工作项对象中提取类型/优先级/状态 ID。 * 兼容两种返回结构: @@ -40,7 +55,7 @@ export function workloadToHours(workload) { * * 当字段为对象时仅取 .id,避免把整个对象当 ID 传入 Map 查找。 */ -export function extractWorkItemIds(item) { +export function extractWorkItemIds(item: Record | null | undefined): WorkItemIds { if (!item) return { typeId: null, priorityId: null, stateId: null }; const rawType = item.work_item_type_id || item.type_id || item.type; const rawPriority = item.priority_id || item.priority; @@ -57,7 +72,7 @@ export function extractWorkItemIds(item) { * 生成项目标识符(identifier) * 加入随机后缀避免同一时间并发创建项目时发生碰撞(PingCode 要求 identifier 企业内唯一)。 */ -export function generateProjectIdentifier(projectName) { +export function generateProjectIdentifier(projectName: string): string { const cleaned = projectName .replace(/[^\w\s一-龥]/g, '') .trim(); @@ -82,7 +97,7 @@ export function generateProjectIdentifier(projectName) { /** * 将 ISO 日期字符串或 Date 转换为 Unix 时间戳(秒) */ -export function toUnixTimestamp(dateInput) { +export function toUnixTimestamp(dateInput: string | number | Date | null | undefined): number | null { if (!dateInput) return null; if (typeof dateInput === 'number') { return dateInput > 9999999999 ? Math.floor(dateInput / 1000) : dateInput; @@ -98,7 +113,7 @@ const UUID_SHORT_RE = /^[0-9a-fA-F]{32}$/; const OBJECT_ID_RE = /^[0-9a-fA-F]{24}$/; /** 判断字符串是否为 UUID / ObjectId 形式(已经是可以直接使用的 ID,无需再映射) */ -export function isUuidLike(value) { +export function isUuidLike(value: unknown): boolean { if (typeof value !== 'string') return false; return UUID_RE.test(value) || OBJECT_ID_RE.test(value) || UUID_SHORT_RE.test(value); } @@ -106,7 +121,7 @@ export function isUuidLike(value) { /** * 解析 type_id:若已经是 UUID/ObjId 直接返回,否则按名称查映射表。 */ -export function resolveTypeId(typeId, typeNameMap) { +export function resolveTypeId(typeId: string | null | undefined, typeNameMap: Map): string { if (!typeId) return typeNameMap.get('story') || 'story'; if (isUuidLike(typeId)) return typeId; const mapped = typeNameMap.get(typeId.toLowerCase()); @@ -116,7 +131,11 @@ export function resolveTypeId(typeId, typeNameMap) { /** * 解析 priority_id:若已经是 UUID/ObjId 直接返回,否则按名称查映射表。 */ -export function resolvePriorityId(priorityId, priorityName, priorityNameMap) { +export function resolvePriorityId( + priorityId: string | null | undefined, + priorityName: string | null | undefined, + priorityNameMap: Map, +): string | null { if (priorityId && isUuidLike(priorityId)) return priorityId; if (priorityId) { const mapped = priorityNameMap.get(priorityId.toLowerCase()); From 556da11aa21192ad8a35cc320548e5108eded773 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:32:25 +0800 Subject: [PATCH 03/12] =?UTF-8?q?build(backend):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20migrate=20config=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts: AppConfig interface for the exported config object, typed loadEnv/pingcodeHostAuthority - validate.ts: validateRequiredConfig(): string[] Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Co-Authored-By: Claude Fable 5 --- backend/src/config/{index.js => index.ts} | 30 +++++++++++++++++-- .../src/config/{validate.js => validate.ts} | 8 ++--- 2 files changed, 31 insertions(+), 7 deletions(-) rename backend/src/config/{index.js => index.ts} (75%) rename backend/src/config/{validate.js => validate.ts} (88%) diff --git a/backend/src/config/index.js b/backend/src/config/index.ts similarity index 75% rename from backend/src/config/index.js rename to backend/src/config/index.ts index 8803dae..23e390a 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.ts @@ -8,7 +8,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * 根据 NODE_ENV 加载对应的环境配置文件 * 加载顺序:先加载 .env(基础),再加载 .env.{NODE_ENV}(覆盖) */ -function loadEnv() { +function loadEnv(): void { // 先加载基础 .env dotenv.config({ path: path.resolve(__dirname, '../../.env') }); @@ -22,7 +22,7 @@ function loadEnv() { loadEnv(); /** 从 PINGCODE_HOST(完整 URL)解析 host:port,供 OAuth 回调等无 scheme 的 domain 参数使用 */ -function pingcodeHostAuthority(hostUrl) { +function pingcodeHostAuthority(hostUrl: string): string { if (!hostUrl) return ''; try { return new URL(hostUrl).host; @@ -33,8 +33,32 @@ function pingcodeHostAuthority(hostUrl) { const pingcodeHost = process.env.PINGCODE_HOST || ''; +/** 统一导出的配置对象类型 */ +export interface AppConfig { + env: string; + port: number; + isDev: boolean; + cors: { origin: string[] }; + frontendUrl: string; + jwt: { secret: string }; + pingcode: { redirectUri: string; host: string; defaultDomain: string }; + seekdb: { + host: string; + port: number; + user: string; + password: string; + database: string; + retryCount: number; + retryIntervalMs: number; + /** 同步工作项时每批数量,2c2g 建议 20-30,4g+ 可调高 */ + syncWorkItemBatchSize: number; + /** 批次间延迟(毫秒),缓解 embedding 模型内存压力 */ + syncBatchDelayMs: number; + }; +} + /** 统一导出的配置对象 */ -export const appConfig = { +export const appConfig: AppConfig = { env: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '3000', 10), isDev: (process.env.NODE_ENV || 'development') === 'development', diff --git a/backend/src/config/validate.js b/backend/src/config/validate.ts similarity index 88% rename from backend/src/config/validate.js rename to backend/src/config/validate.ts index bbc90d6..bc0a5a2 100644 --- a/backend/src/config/validate.js +++ b/backend/src/config/validate.ts @@ -7,14 +7,14 @@ * * 开发环境对 PINGCODE_HOST 缺失等给出警告但不阻断启动。 * - * @returns {string[]} 错误消息数组,空数组表示校验通过 + * @returns 错误消息数组,空数组表示校验通过 */ import { appConfig } from './index.js'; const DEFAULT_JWT_SECRET = 'dev_secret_key'; -export function validateRequiredConfig() { - const errors = []; +export function validateRequiredConfig(): string[] { + const errors: string[] = []; const isProd = appConfig.env === 'production'; // JWT_SECRET 不能为默认值(开发环境也警告) @@ -28,7 +28,7 @@ export function validateRequiredConfig() { if (isProd && !process.env.ENCRYPTION_KEY) { errors.push( '生产环境必须设置 ENCRYPTION_KEY(敏感字段加密密钥),' + - '生成方式:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"' + '生成方式:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"', ); } From 795d97f251b310e7a308e59b67f2dc208e0b686b Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:38:57 +0800 Subject: [PATCH 04/12] =?UTF-8?q?build(backend):=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20migrate=20models=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 15 Sequelize model files (.js → .ts). Schema, hooks, and associations are unchanged — verified by 176/176 tests. Approach (deviation from plan, lower risk): keep sequelize.define() and annotate each export as ModelDefined instead of converting to `class extends Model` + .init(). Rationale: the plan's class-based pattern is a structural refactor that risks sync({alter:true})/hook/ association compatibility, and there is no live DB to validate against. The define + ModelDone pattern adds full attribute typing (typed findByPk/findAll/creation) while leaving model definitions byte-for-byte equivalent. - Each model exports a *Attributes interface + literal-union types for ENUM columns (ImportRecordStatus, ImportRecordItemStatus, ModelProvider) - User.ts / ModelConfig.ts: encrypt/decrypt hooks preserved verbatim; hook callbacks typed (instance: any) to avoid Sequelize hook-signature friction while keeping logic untouched - index.ts: pure rename, associations unchanged Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Co-Authored-By: Claude Fable 5 --- backend/src/models/ImportRecord.js | 67 ------------- backend/src/models/ImportRecord.ts | 52 ++++++++++ backend/src/models/ImportRecordItem.js | 113 --------------------- backend/src/models/ImportRecordItem.ts | 70 +++++++++++++ backend/src/models/ModelConfig.js | 96 ------------------ backend/src/models/ModelConfig.ts | 75 ++++++++++++++ backend/src/models/Permission.js | 39 -------- backend/src/models/Permission.ts | 26 +++++ backend/src/models/Role.js | 34 ------- backend/src/models/Role.ts | 24 +++++ backend/src/models/RolePermission.js | 20 ---- backend/src/models/RolePermission.ts | 21 ++++ backend/src/models/SyncedProject.js | 39 -------- backend/src/models/SyncedProject.ts | 31 ++++++ backend/src/models/SyncedWorkItem.js | 49 --------- backend/src/models/SyncedWorkItem.ts | 35 +++++++ backend/src/models/User.js | 116 ---------------------- backend/src/models/User.ts | 93 +++++++++++++++++ backend/src/models/UserRole.js | 20 ---- backend/src/models/UserRole.ts | 18 ++++ backend/src/models/WorkItemPriority.js | 27 ----- backend/src/models/WorkItemPriority.ts | 25 +++++ backend/src/models/WorkItemProperty.js | 42 -------- backend/src/models/WorkItemProperty.ts | 31 ++++++ backend/src/models/WorkItemState.js | 41 -------- backend/src/models/WorkItemState.ts | 31 ++++++ backend/src/models/WorkItemType.js | 33 ------ backend/src/models/WorkItemType.ts | 51 ++++++++++ backend/src/models/{index.js => index.ts} | 0 29 files changed, 583 insertions(+), 736 deletions(-) delete mode 100644 backend/src/models/ImportRecord.js create mode 100644 backend/src/models/ImportRecord.ts delete mode 100644 backend/src/models/ImportRecordItem.js create mode 100644 backend/src/models/ImportRecordItem.ts delete mode 100644 backend/src/models/ModelConfig.js create mode 100644 backend/src/models/ModelConfig.ts delete mode 100644 backend/src/models/Permission.js create mode 100644 backend/src/models/Permission.ts delete mode 100644 backend/src/models/Role.js create mode 100644 backend/src/models/Role.ts delete mode 100644 backend/src/models/RolePermission.js create mode 100644 backend/src/models/RolePermission.ts delete mode 100644 backend/src/models/SyncedProject.js create mode 100644 backend/src/models/SyncedProject.ts delete mode 100644 backend/src/models/SyncedWorkItem.js create mode 100644 backend/src/models/SyncedWorkItem.ts delete mode 100644 backend/src/models/User.js create mode 100644 backend/src/models/User.ts delete mode 100644 backend/src/models/UserRole.js create mode 100644 backend/src/models/UserRole.ts delete mode 100644 backend/src/models/WorkItemPriority.js create mode 100644 backend/src/models/WorkItemPriority.ts delete mode 100644 backend/src/models/WorkItemProperty.js create mode 100644 backend/src/models/WorkItemProperty.ts delete mode 100644 backend/src/models/WorkItemState.js create mode 100644 backend/src/models/WorkItemState.ts delete mode 100644 backend/src/models/WorkItemType.js create mode 100644 backend/src/models/WorkItemType.ts rename backend/src/models/{index.js => index.ts} (100%) diff --git a/backend/src/models/ImportRecord.js b/backend/src/models/ImportRecord.js deleted file mode 100644 index 76585f4..0000000 --- a/backend/src/models/ImportRecord.js +++ /dev/null @@ -1,67 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const ImportRecord = sequelize.define('ImportRecord', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - }, - file_name: { - type: DataTypes.STRING, - allowNull: false, - comment: '原始文件名', - }, - original_file_path: { - type: DataTypes.STRING, - allowNull: true, - comment: '原需求文档保存路径(如 uploads/demand/xxx-1234567890.txt)', - }, - requirements_count: { - type: DataTypes.INTEGER, - allowNull: false, - comment: '解析出的需求数量', - }, - projects_count: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '涉及的项目数量', - }, - target_project_id: { - type: DataTypes.STRING, - allowNull: true, - comment: '目标项目ID', - }, - target_project_name: { - type: DataTypes.STRING, - allowNull: true, - comment: '目标项目名称', - }, - imported_count: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '实际导入的工作项数量', - }, - failed_count: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '导入失败的工作项数量', - }, - status: { - type: DataTypes.ENUM('analyzed', 'importing', 'success', 'partial_success', 'failed'), - defaultValue: 'analyzed', - comment: '导入状态', - }, - error_message: { - type: DataTypes.TEXT, - allowNull: true, - comment: '错误信息', - }, -}, { - tableName: 'import_records', - timestamps: true, -}); diff --git a/backend/src/models/ImportRecord.ts b/backend/src/models/ImportRecord.ts new file mode 100644 index 0000000..1d21f66 --- /dev/null +++ b/backend/src/models/ImportRecord.ts @@ -0,0 +1,52 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export type ImportRecordStatus = + | 'analyzed' + | 'importing' + | 'success' + | 'partial_success' + | 'failed'; + +export interface ImportRecordAttributes { + id: string; + user_id: string; + file_name: string; + original_file_path?: string | null; + requirements_count: number; + projects_count?: number; + target_project_id?: string | null; + target_project_name?: string | null; + imported_count?: number; + failed_count?: number; + status?: ImportRecordStatus; + error_message?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export const ImportRecord: ModelDefined< + ImportRecordAttributes, + ImportRecordAttributes +> = sequelize.define( + 'ImportRecord', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false }, + file_name: { type: DataTypes.STRING, allowNull: false, comment: '原始文件名' }, + original_file_path: { type: DataTypes.STRING, allowNull: true, comment: '原需求文档保存路径(如 uploads/demand/xxx-1234567890.txt)' }, + requirements_count: { type: DataTypes.INTEGER, allowNull: false, comment: '解析出的需求数量' }, + projects_count: { type: DataTypes.INTEGER, defaultValue: 0, comment: '涉及的项目数量' }, + target_project_id: { type: DataTypes.STRING, allowNull: true, comment: '目标项目ID' }, + target_project_name: { type: DataTypes.STRING, allowNull: true, comment: '目标项目名称' }, + imported_count: { type: DataTypes.INTEGER, defaultValue: 0, comment: '实际导入的工作项数量' }, + failed_count: { type: DataTypes.INTEGER, defaultValue: 0, comment: '导入失败的工作项数量' }, + status: { + type: DataTypes.ENUM('analyzed', 'importing', 'success', 'partial_success', 'failed'), + defaultValue: 'analyzed', + comment: '导入状态', + }, + error_message: { type: DataTypes.TEXT, allowNull: true, comment: '错误信息' }, + }, + { tableName: 'import_records', timestamps: true }, +); diff --git a/backend/src/models/ImportRecordItem.js b/backend/src/models/ImportRecordItem.js deleted file mode 100644 index e9faa58..0000000 --- a/backend/src/models/ImportRecordItem.js +++ /dev/null @@ -1,113 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const ImportRecordItem = sequelize.define('ImportRecordItem', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - record_id: { - type: DataTypes.UUID, - allowNull: false, - comment: '关联的导入记录ID', - }, - title: { - type: DataTypes.STRING(500), - allowNull: false, - comment: '工作项标题', - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - comment: '工作项描述', - }, - project_name: { - type: DataTypes.STRING, - allowNull: true, - comment: '原始项目名称(AI识别)', - }, - type_id: { - type: DataTypes.STRING, - allowNull: true, - comment: '工作项类型ID', - }, - priority_id: { - type: DataTypes.STRING, - allowNull: true, - comment: '优先级ID', - }, - /** 优先级文本(High/Medium/Low),恢复时还原,不再硬编码 Medium */ - priority: { - type: DataTypes.STRING, - allowNull: true, - comment: '优先级文本(High/Medium/Low)', - }, - /** 预估工时(小时),恢复时还原 */ - estimated_hours: { - type: DataTypes.FLOAT, - allowNull: true, - comment: '预估工时(小时)', - }, - /** 计划开始时间(ISO 8601),恢复时还原 */ - start_at: { - type: DataTypes.STRING, - allowNull: true, - comment: '计划开始时间(ISO 8601)', - }, - /** 计划结束时间(ISO 8601) */ - end_at: { - type: DataTypes.STRING, - allowNull: true, - comment: '计划结束时间(ISO 8601)', - }, - /** 负责人姓名(从需求文档识别) */ - assignee_name: { - type: DataTypes.STRING, - allowNull: true, - comment: '负责人姓名(AI识别)', - }, - /** 负责人 ID(解析自成员列表或用户指定) */ - assignee_id: { - type: DataTypes.STRING, - allowNull: true, - comment: '负责人 ID(PingCode 用户 ID)', - }, - /** 解决方案建议(AI 核心输出之一,导入时拼入 description) */ - solution_suggestion: { - type: DataTypes.TEXT, - allowNull: true, - comment: 'AI 生成的解决方案建议', - }, - pingcode_id: { - type: DataTypes.STRING, - allowNull: true, - comment: '导入后的 PingCode 工作项ID', - }, - pingcode_identifier: { - type: DataTypes.STRING, - allowNull: true, - comment: '导入后的 PingCode 工作项标识符(如 WI-123)', - }, - status: { - type: DataTypes.ENUM('pending', 'success', 'failed', 'skipped'), - defaultValue: 'pending', - comment: '导入状态:pending-待导入, success-成功, failed-失败, skipped-跳过', - }, - error_message: { - type: DataTypes.TEXT, - allowNull: true, - comment: '导入失败的错误信息', - }, -}, { - tableName: 'import_record_items', - timestamps: true, - indexes: [ - { - fields: ['record_id'], - }, - { - fields: ['status'], - }, - ], -}); diff --git a/backend/src/models/ImportRecordItem.ts b/backend/src/models/ImportRecordItem.ts new file mode 100644 index 0000000..d0742f0 --- /dev/null +++ b/backend/src/models/ImportRecordItem.ts @@ -0,0 +1,70 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export type ImportRecordItemStatus = 'pending' | 'success' | 'failed' | 'skipped'; + +export interface ImportRecordItemAttributes { + id: string; + record_id: string; + title: string; + description?: string | null; + project_name?: string | null; + type_id?: string | null; + priority_id?: string | null; + /** 优先级文本(High/Medium/Low),恢复时还原,不再硬编码 Medium */ + priority?: string | null; + /** 预估工时(小时),恢复时还原 */ + estimated_hours?: number | null; + /** 计划开始时间(ISO 8601),恢复时还原 */ + start_at?: string | null; + /** 计划结束时间(ISO 8601) */ + end_at?: string | null; + /** 负责人姓名(从需求文档识别) */ + assignee_name?: string | null; + /** 负责人 ID(解析自成员列表或用户指定) */ + assignee_id?: string | null; + /** 解决方案建议(AI 核心输出之一,导入时拼入 description) */ + solution_suggestion?: string | null; + pingcode_id?: string | null; + pingcode_identifier?: string | null; + status?: ImportRecordItemStatus; + error_message?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export const ImportRecordItem: ModelDefined< + ImportRecordItemAttributes, + ImportRecordItemAttributes +> = sequelize.define( + 'ImportRecordItem', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + record_id: { type: DataTypes.UUID, allowNull: false, comment: '关联的导入记录ID' }, + title: { type: DataTypes.STRING(500), allowNull: false, comment: '工作项标题' }, + description: { type: DataTypes.TEXT, allowNull: true, comment: '工作项描述' }, + project_name: { type: DataTypes.STRING, allowNull: true, comment: '原始项目名称(AI识别)' }, + type_id: { type: DataTypes.STRING, allowNull: true, comment: '工作项类型ID' }, + priority_id: { type: DataTypes.STRING, allowNull: true, comment: '优先级ID' }, + priority: { type: DataTypes.STRING, allowNull: true, comment: '优先级文本(High/Medium/Low)' }, + estimated_hours: { type: DataTypes.FLOAT, allowNull: true, comment: '预估工时(小时)' }, + start_at: { type: DataTypes.STRING, allowNull: true, comment: '计划开始时间(ISO 8601)' }, + end_at: { type: DataTypes.STRING, allowNull: true, comment: '计划结束时间(ISO 8601)' }, + assignee_name: { type: DataTypes.STRING, allowNull: true, comment: '负责人姓名(AI识别)' }, + assignee_id: { type: DataTypes.STRING, allowNull: true, comment: '负责人 ID(PingCode 用户 ID)' }, + solution_suggestion: { type: DataTypes.TEXT, allowNull: true, comment: 'AI 生成的解决方案建议' }, + pingcode_id: { type: DataTypes.STRING, allowNull: true, comment: '导入后的 PingCode 工作项ID' }, + pingcode_identifier: { type: DataTypes.STRING, allowNull: true, comment: '导入后的 PingCode 工作项标识符(如 WI-123)' }, + status: { + type: DataTypes.ENUM('pending', 'success', 'failed', 'skipped'), + defaultValue: 'pending', + comment: '导入状态:pending-待导入, success-成功, failed-失败, skipped-跳过', + }, + error_message: { type: DataTypes.TEXT, allowNull: true, comment: '导入失败的错误信息' }, + }, + { + tableName: 'import_record_items', + timestamps: true, + indexes: [{ fields: ['record_id'] }, { fields: ['status'] }], + }, +); diff --git a/backend/src/models/ModelConfig.js b/backend/src/models/ModelConfig.js deleted file mode 100644 index 9ed0d3f..0000000 --- a/backend/src/models/ModelConfig.js +++ /dev/null @@ -1,96 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; -import { encrypt, decrypt } from '../utils/crypto.js'; - -/** 需要透明加解密的敏感字段 */ -const SENSITIVE_FIELDS = ['api_key']; - -export const ModelConfig = sequelize.define('ModelConfig', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - comment: '配置名称,如 "DeepSeek Chat", "GPT-4"', - }, - provider: { - type: DataTypes.ENUM('openai', 'anthropic'), - allowNull: false, - comment: 'API 提供商', - }, - api_key: { - type: DataTypes.TEXT, - allowNull: false, - comment: 'API 密钥', - }, - base_url: { - type: DataTypes.STRING, - allowNull: true, - comment: 'API 基础 URL', - }, - model: { - type: DataTypes.STRING, - allowNull: false, - comment: '模型名称,如 "deepseek-chat", "gpt-4"', - }, - temperature: { - type: DataTypes.FLOAT, - allowNull: true, - defaultValue: 0, - comment: '温度参数,控制随机性', - }, - max_tokens: { - type: DataTypes.INTEGER, - allowNull: true, - comment: '最大生成 tokens 数', - }, - is_default: { - type: DataTypes.BOOLEAN, - defaultValue: false, - comment: '是否为默认配置', - }, -}, { - tableName: 'model_configs', - timestamps: true, - indexes: [ - { - unique: true, - fields: ['user_id', 'name'], - }, - ], - hooks: { - beforeSave: (instance) => { - // 仅加密本次被修改(或新创建)的敏感字段 - for (const field of SENSITIVE_FIELDS) { - if (instance.changed(field)) { - const val = instance.getDataValue(field); - if (val !== null && val !== undefined && val !== '') { - instance.setDataValue(field, encrypt(val)); - } - } - } - }, - afterSave: (instance) => { - for (const field of SENSITIVE_FIELDS) { - const val = instance.getDataValue(field); - instance.setDataValue(field, decrypt(val)); - } - }, - afterFind: (instances) => { - const list = Array.isArray(instances) ? instances : (instances ? [instances] : []); - for (const instance of list) { - for (const field of SENSITIVE_FIELDS) { - const val = instance.getDataValue(field); - instance.setDataValue(field, decrypt(val)); - } - } - }, - }, -}); diff --git a/backend/src/models/ModelConfig.ts b/backend/src/models/ModelConfig.ts new file mode 100644 index 0000000..0af94e7 --- /dev/null +++ b/backend/src/models/ModelConfig.ts @@ -0,0 +1,75 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; +import { encrypt, decrypt } from '../utils/crypto.js'; + +export type ModelProvider = 'openai' | 'anthropic'; + +export interface ModelConfigAttributes { + id: string; + user_id: string; + name: string; + provider: ModelProvider; + api_key: string; + base_url?: string | null; + model: string; + temperature?: number | null; + max_tokens?: number | null; + is_default?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +/** 需要透明加解密的敏感字段 */ +const SENSITIVE_FIELDS = ['api_key']; + +export const ModelConfig: ModelDefined< + ModelConfigAttributes, + ModelConfigAttributes +> = sequelize.define( + 'ModelConfig', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false }, + name: { type: DataTypes.STRING, allowNull: false, comment: '配置名称,如 "DeepSeek Chat", "GPT-4"' }, + provider: { type: DataTypes.ENUM('openai', 'anthropic'), allowNull: false, comment: 'API 提供商' }, + api_key: { type: DataTypes.TEXT, allowNull: false, comment: 'API 密钥' }, + base_url: { type: DataTypes.STRING, allowNull: true, comment: 'API 基础 URL' }, + model: { type: DataTypes.STRING, allowNull: false, comment: '模型名称,如 "deepseek-chat", "gpt-4"' }, + temperature: { type: DataTypes.FLOAT, allowNull: true, defaultValue: 0, comment: '温度参数,控制随机性' }, + max_tokens: { type: DataTypes.INTEGER, allowNull: true, comment: '最大生成 tokens 数' }, + is_default: { type: DataTypes.BOOLEAN, defaultValue: false, comment: '是否为默认配置' }, + }, + { + tableName: 'model_configs', + timestamps: true, + indexes: [{ unique: true, fields: ['user_id', 'name'] }], + hooks: { + beforeSave: (instance: any) => { + // 仅加密本次被修改(或新创建)的敏感字段 + for (const field of SENSITIVE_FIELDS) { + if (instance.changed(field)) { + const val = instance.getDataValue(field); + if (val !== null && val !== undefined && val !== '') { + instance.setDataValue(field, encrypt(val)); + } + } + } + }, + afterSave: (instance: any) => { + for (const field of SENSITIVE_FIELDS) { + const val = instance.getDataValue(field); + instance.setDataValue(field, decrypt(val)); + } + }, + afterFind: (instances: any) => { + const list = Array.isArray(instances) ? instances : (instances ? [instances] : []); + for (const instance of list) { + for (const field of SENSITIVE_FIELDS) { + const val = instance.getDataValue(field); + instance.setDataValue(field, decrypt(val)); + } + } + }, + }, + }, +); diff --git a/backend/src/models/Permission.js b/backend/src/models/Permission.js deleted file mode 100644 index 1dc5b8c..0000000 --- a/backend/src/models/Permission.js +++ /dev/null @@ -1,39 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const Permission = sequelize.define('Permission', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - comment: '权限名称,如 users.manage, projects.view', - }, - display_name: { - type: DataTypes.STRING, - allowNull: false, - comment: '权限显示名称', - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - comment: '权限描述', - }, - resource: { - type: DataTypes.STRING, - allowNull: false, - comment: '资源类型,如 users, projects, work_items', - }, - action: { - type: DataTypes.STRING, - allowNull: false, - comment: '操作类型,如 view, create, update, delete, manage', - }, -}, { - tableName: 'permissions', - timestamps: true, -}); diff --git a/backend/src/models/Permission.ts b/backend/src/models/Permission.ts new file mode 100644 index 0000000..f01c367 --- /dev/null +++ b/backend/src/models/Permission.ts @@ -0,0 +1,26 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface PermissionAttributes { + id: string; + name: string; + display_name: string; + description?: string | null; + resource: string; + action: string; + createdAt?: Date; + updatedAt?: Date; +} + +export const Permission: ModelDefined = sequelize.define( + 'Permission', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false, unique: true, comment: '权限名称,如 users.manage, projects.view' }, + display_name: { type: DataTypes.STRING, allowNull: false, comment: '权限显示名称' }, + description: { type: DataTypes.TEXT, allowNull: true, comment: '权限描述' }, + resource: { type: DataTypes.STRING, allowNull: false, comment: '资源类型,如 users, projects, work_items' }, + action: { type: DataTypes.STRING, allowNull: false, comment: '操作类型,如 view, create, update, delete, manage' }, + }, + { tableName: 'permissions', timestamps: true }, +); diff --git a/backend/src/models/Role.js b/backend/src/models/Role.js deleted file mode 100644 index 4c09feb..0000000 --- a/backend/src/models/Role.js +++ /dev/null @@ -1,34 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const Role = sequelize.define('Role', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - unique: true, - comment: '角色名称,如 admin, user', - }, - display_name: { - type: DataTypes.STRING, - allowNull: false, - comment: '角色显示名称', - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - comment: '角色描述', - }, - is_system: { - type: DataTypes.BOOLEAN, - defaultValue: false, - comment: '是否为系统角色(不可删除)', - }, -}, { - tableName: 'roles', - timestamps: true, -}); diff --git a/backend/src/models/Role.ts b/backend/src/models/Role.ts new file mode 100644 index 0000000..d285b7f --- /dev/null +++ b/backend/src/models/Role.ts @@ -0,0 +1,24 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface RoleAttributes { + id: string; + name: string; + display_name: string; + description?: string | null; + is_system?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export const Role: ModelDefined = sequelize.define( + 'Role', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false, unique: true, comment: '角色名称,如 admin, user' }, + display_name: { type: DataTypes.STRING, allowNull: false, comment: '角色显示名称' }, + description: { type: DataTypes.TEXT, allowNull: true, comment: '角色描述' }, + is_system: { type: DataTypes.BOOLEAN, defaultValue: false, comment: '是否为系统角色(不可删除)' }, + }, + { tableName: 'roles', timestamps: true }, +); diff --git a/backend/src/models/RolePermission.js b/backend/src/models/RolePermission.js deleted file mode 100644 index a5a14b4..0000000 --- a/backend/src/models/RolePermission.js +++ /dev/null @@ -1,20 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const RolePermission = sequelize.define('RolePermission', { - role_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - comment: '角色ID', - }, - permission_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - comment: '权限ID', - }, -}, { - tableName: 'role_permissions', - timestamps: true, -}); diff --git a/backend/src/models/RolePermission.ts b/backend/src/models/RolePermission.ts new file mode 100644 index 0000000..4dcf840 --- /dev/null +++ b/backend/src/models/RolePermission.ts @@ -0,0 +1,21 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface RolePermissionAttributes { + role_id: string; + permission_id: string; + createdAt?: Date; + updatedAt?: Date; +} + +export const RolePermission: ModelDefined< + RolePermissionAttributes, + RolePermissionAttributes +> = sequelize.define( + 'RolePermission', + { + role_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '角色ID' }, + permission_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '权限ID' }, + }, + { tableName: 'role_permissions', timestamps: true }, +); diff --git a/backend/src/models/SyncedProject.js b/backend/src/models/SyncedProject.js deleted file mode 100644 index 04c7349..0000000 --- a/backend/src/models/SyncedProject.js +++ /dev/null @@ -1,39 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const SyncedProject = sequelize.define('SyncedProject', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - comment: 'PingCode 项目 ID', - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - }, - /** PingCode 侧最后更新时间,用于增量同步判断是否需要更新本地缓存 */ - remote_updated_at: { - type: DataTypes.STRING, - allowNull: true, - comment: 'PingCode 项目最后更新时间(ISO 字符串或时间戳)', - }, - /** 本地缓存是否已软删除(PingCode 侧已删除/归档) */ - is_archived: { - type: DataTypes.BOOLEAN, - defaultValue: false, - comment: 'PingCode 侧已删除/归档时标记为 true', - }, -}, { - tableName: 'synced_projects', - timestamps: true, -}); diff --git a/backend/src/models/SyncedProject.ts b/backend/src/models/SyncedProject.ts new file mode 100644 index 0000000..76f5c2b --- /dev/null +++ b/backend/src/models/SyncedProject.ts @@ -0,0 +1,31 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface SyncedProjectAttributes { + id: string; + user_id: string; + name: string; + description?: string | null; + /** PingCode 侧最后更新时间,用于增量同步判断是否需要更新本地缓存 */ + remote_updated_at?: string | null; + /** 本地缓存是否已软删除(PingCode 侧已删除/归档) */ + is_archived?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export const SyncedProject: ModelDefined< + SyncedProjectAttributes, + SyncedProjectAttributes +> = sequelize.define( + 'SyncedProject', + { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true, comment: 'PingCode 项目 ID' }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + description: { type: DataTypes.TEXT, allowNull: true }, + remote_updated_at: { type: DataTypes.STRING, allowNull: true, comment: 'PingCode 项目最后更新时间(ISO 字符串或时间戳)' }, + is_archived: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'PingCode 侧已删除/归档时标记为 true' }, + }, + { tableName: 'synced_projects', timestamps: true }, +); diff --git a/backend/src/models/SyncedWorkItem.js b/backend/src/models/SyncedWorkItem.js deleted file mode 100644 index c39057b..0000000 --- a/backend/src/models/SyncedWorkItem.js +++ /dev/null @@ -1,49 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const SyncedWorkItem = sequelize.define('SyncedWorkItem', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - comment: 'PingCode 工作项 ID', - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - description: { - type: DataTypes.TEXT, - allowNull: true, - comment: '工作项描述(用于列表展示与查重明细)', - }, - identifier: { - type: DataTypes.STRING, - allowNull: true, - comment: '工作项编号', - }, - /** PingCode 侧最后更新时间戳,用于增量同步判断是否需要更新本地缓存 */ - remote_updated_at: { - type: DataTypes.STRING, - allowNull: true, - comment: 'PingCode 工作项最后更新时间(ISO 字符串或时间戳)', - }, - /** 本地缓存是否已软删除(PingCode 侧已删除/归档) */ - is_archived: { - type: DataTypes.BOOLEAN, - defaultValue: false, - comment: 'PingCode 侧已删除/归档时标记为 true', - }, -}, { - tableName: 'synced_work_items', - timestamps: true, -}); diff --git a/backend/src/models/SyncedWorkItem.ts b/backend/src/models/SyncedWorkItem.ts new file mode 100644 index 0000000..f6d75c1 --- /dev/null +++ b/backend/src/models/SyncedWorkItem.ts @@ -0,0 +1,35 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface SyncedWorkItemAttributes { + id: string; + user_id: string; + project_id: string; + title: string; + description?: string | null; + identifier?: string | null; + /** PingCode 侧最后更新时间戳,用于增量同步判断是否需要更新本地缓存 */ + remote_updated_at?: string | null; + /** 本地缓存是否已软删除(PingCode 侧已删除/归档) */ + is_archived?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export const SyncedWorkItem: ModelDefined< + SyncedWorkItemAttributes, + SyncedWorkItemAttributes +> = sequelize.define( + 'SyncedWorkItem', + { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true, comment: 'PingCode 工作项 ID' }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + project_id: { type: DataTypes.STRING, allowNull: false }, + title: { type: DataTypes.STRING, allowNull: false }, + description: { type: DataTypes.TEXT, allowNull: true, comment: '工作项描述(用于列表展示与查重明细)' }, + identifier: { type: DataTypes.STRING, allowNull: true, comment: '工作项编号' }, + remote_updated_at: { type: DataTypes.STRING, allowNull: true, comment: 'PingCode 工作项最后更新时间(ISO 字符串或时间戳)' }, + is_archived: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'PingCode 侧已删除/归档时标记为 true' }, + }, + { tableName: 'synced_work_items', timestamps: true }, +); diff --git a/backend/src/models/User.js b/backend/src/models/User.js deleted file mode 100644 index 95c13b1..0000000 --- a/backend/src/models/User.js +++ /dev/null @@ -1,116 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; -import { encrypt, decrypt } from '../utils/crypto.js'; - -/** 需要透明加解密的敏感字段 */ -const SENSITIVE_FIELDS = ['pingcode_client_secret', 'access_token', 'refresh_token']; - -export const User = sequelize.define('User', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - username: { - type: DataTypes.STRING, - allowNull: false, - unique: true - }, - password_hash: { - type: DataTypes.STRING, - allowNull: false - }, - // PingCode Config - pingcode_client_id: { - type: DataTypes.STRING, - allowNull: true - }, - pingcode_client_secret: { - type: DataTypes.STRING, - allowNull: true - }, - /** OAuth2 grant: authorization_code(用户授权)| client_credentials(企业令牌) */ - pingcode_grant_type: { - type: DataTypes.STRING, - allowNull: false, - defaultValue: 'authorization_code' - }, - // PingCode Tokens - pingcode_uid: { - type: DataTypes.STRING, - allowNull: true, - // 不设 unique: true —— 允许同一个 PingCode 账号关联多个本地用户, - // 避免多用户绑定同一 PingCode 账号时唯一约束冲突。 - }, - access_token: { - type: DataTypes.TEXT, - allowNull: true - }, - refresh_token: { - type: DataTypes.TEXT, - allowNull: true - }, - expires_at: { - type: DataTypes.DATE, - allowNull: true - }, - domain: { - type: DataTypes.STRING, - allowNull: true - }, - // PingCode 用户信息(来自 GET /v1/myself) - pingcode_user_id: { - type: DataTypes.STRING, - allowNull: true, - comment: 'PingCode 用户 ID,用于 assignee_id' - }, - pingcode_user_name: { - type: DataTypes.STRING, - allowNull: true - }, - pingcode_display_name: { - type: DataTypes.STRING, - allowNull: true - }, - pingcode_email: { - type: DataTypes.STRING, - allowNull: true - }, - pingcode_avatar: { - type: DataTypes.STRING, - allowNull: true - } -}, { - tableName: 'user_auth', - timestamps: true, - // Force sync to update schema since we changed PK - // In prod, use migrations. Here, sequelize.sync({ alter: true }) in db.js handles it. - hooks: { - beforeSave: (instance) => { - // 仅加密本次被修改(或新创建)的敏感字段,避免每次 update 都重写全部密文列(防竞态) - for (const field of SENSITIVE_FIELDS) { - if (instance.changed(field)) { - const val = instance.getDataValue(field); - if (val !== null && val !== undefined && val !== '') { - instance.setDataValue(field, encrypt(val)); - } - } - } - }, - afterSave: (instance) => { - for (const field of SENSITIVE_FIELDS) { - const val = instance.getDataValue(field); - instance.setDataValue(field, decrypt(val)); - } - }, - afterFind: (instances) => { - const list = Array.isArray(instances) ? instances : (instances ? [instances] : []); - for (const instance of list) { - for (const field of SENSITIVE_FIELDS) { - const val = instance.getDataValue(field); - instance.setDataValue(field, decrypt(val)); - } - } - }, - }, -}); diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000..7c57f5f --- /dev/null +++ b/backend/src/models/User.ts @@ -0,0 +1,93 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; +import { encrypt, decrypt } from '../utils/crypto.js'; + +export interface UserAttributes { + id: string; + username: string; + password_hash: string; + pingcode_client_id?: string | null; + pingcode_client_secret?: string | null; + /** OAuth2 grant: authorization_code(用户授权)| client_credentials(企业令牌) */ + pingcode_grant_type?: string; + pingcode_uid?: string | null; + access_token?: string | null; + refresh_token?: string | null; + expires_at?: Date | null; + domain?: string | null; + pingcode_user_id?: string | null; + pingcode_user_name?: string | null; + pingcode_display_name?: string | null; + pingcode_email?: string | null; + pingcode_avatar?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +/** 需要透明加解密的敏感字段 */ +const SENSITIVE_FIELDS = ['pingcode_client_secret', 'access_token', 'refresh_token']; + +export const User: ModelDefined = sequelize.define( + 'User', + { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + username: { type: DataTypes.STRING, allowNull: false, unique: true }, + password_hash: { type: DataTypes.STRING, allowNull: false }, + pingcode_client_id: { type: DataTypes.STRING, allowNull: true }, + pingcode_client_secret: { type: DataTypes.STRING, allowNull: true }, + pingcode_grant_type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'authorization_code', + }, + pingcode_uid: { + type: DataTypes.STRING, + allowNull: true, + // 不设 unique: true —— 允许同一个 PingCode 账号关联多个本地用户, + // 避免多用户绑定同一 PingCode 账号时唯一约束冲突。 + }, + access_token: { type: DataTypes.TEXT, allowNull: true }, + refresh_token: { type: DataTypes.TEXT, allowNull: true }, + expires_at: { type: DataTypes.DATE, allowNull: true }, + domain: { type: DataTypes.STRING, allowNull: true }, + pingcode_user_id: { type: DataTypes.STRING, allowNull: true, comment: 'PingCode 用户 ID,用于 assignee_id' }, + pingcode_user_name: { type: DataTypes.STRING, allowNull: true }, + pingcode_display_name: { type: DataTypes.STRING, allowNull: true }, + pingcode_email: { type: DataTypes.STRING, allowNull: true }, + pingcode_avatar: { type: DataTypes.STRING, allowNull: true }, + }, + { + tableName: 'user_auth', + timestamps: true, + // Force sync to update schema since we changed PK + // In prod, use migrations. Here, sequelize.sync({ alter: true }) in db.js handles it. + hooks: { + beforeSave: (instance: any) => { + // 仅加密本次被修改(或新创建)的敏感字段,避免每次 update 都重写全部密文列(防竞态) + for (const field of SENSITIVE_FIELDS) { + if (instance.changed(field)) { + const val = instance.getDataValue(field); + if (val !== null && val !== undefined && val !== '') { + instance.setDataValue(field, encrypt(val)); + } + } + } + }, + afterSave: (instance: any) => { + for (const field of SENSITIVE_FIELDS) { + const val = instance.getDataValue(field); + instance.setDataValue(field, decrypt(val)); + } + }, + afterFind: (instances: any) => { + const list = Array.isArray(instances) ? instances : (instances ? [instances] : []); + for (const instance of list) { + for (const field of SENSITIVE_FIELDS) { + const val = instance.getDataValue(field); + instance.setDataValue(field, decrypt(val)); + } + } + }, + }, + }, +); diff --git a/backend/src/models/UserRole.js b/backend/src/models/UserRole.js deleted file mode 100644 index 46dee21..0000000 --- a/backend/src/models/UserRole.js +++ /dev/null @@ -1,20 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const UserRole = sequelize.define('UserRole', { - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - comment: '用户ID', - }, - role_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - comment: '角色ID', - }, -}, { - tableName: 'user_roles', - timestamps: true, -}); diff --git a/backend/src/models/UserRole.ts b/backend/src/models/UserRole.ts new file mode 100644 index 0000000..78c2f0b --- /dev/null +++ b/backend/src/models/UserRole.ts @@ -0,0 +1,18 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface UserRoleAttributes { + user_id: string; + role_id: string; + createdAt?: Date; + updatedAt?: Date; +} + +export const UserRole: ModelDefined = sequelize.define( + 'UserRole', + { + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '用户ID' }, + role_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '角色ID' }, + }, + { tableName: 'user_roles', timestamps: true }, +); diff --git a/backend/src/models/WorkItemPriority.js b/backend/src/models/WorkItemPriority.js deleted file mode 100644 index 68b4081..0000000 --- a/backend/src/models/WorkItemPriority.js +++ /dev/null @@ -1,27 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const WorkItemPriority = sequelize.define('WorkItemPriority', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, -}, { - tableName: 'work_item_priorities', - timestamps: true, -}); diff --git a/backend/src/models/WorkItemPriority.ts b/backend/src/models/WorkItemPriority.ts new file mode 100644 index 0000000..e5e8ce7 --- /dev/null +++ b/backend/src/models/WorkItemPriority.ts @@ -0,0 +1,25 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface WorkItemPriorityAttributes { + id: string; + project_id: string; + user_id: string; + name: string; + createdAt?: Date; + updatedAt?: Date; +} + +export const WorkItemPriority: ModelDefined< + WorkItemPriorityAttributes, + WorkItemPriorityAttributes +> = sequelize.define( + 'WorkItemPriority', + { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + }, + { tableName: 'work_item_priorities', timestamps: true }, +); diff --git a/backend/src/models/WorkItemProperty.js b/backend/src/models/WorkItemProperty.js deleted file mode 100644 index 8018d54..0000000 --- a/backend/src/models/WorkItemProperty.js +++ /dev/null @@ -1,42 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const WorkItemProperty = sequelize.define('WorkItemProperty', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - work_item_type_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - type: { - type: DataTypes.STRING, - allowNull: true, - comment: 'select / text / ...', - }, - options: { - type: DataTypes.JSON, - allowNull: true, - comment: '选项列表', - }, -}, { - tableName: 'work_item_properties', - timestamps: true, -}); diff --git a/backend/src/models/WorkItemProperty.ts b/backend/src/models/WorkItemProperty.ts new file mode 100644 index 0000000..627f459 --- /dev/null +++ b/backend/src/models/WorkItemProperty.ts @@ -0,0 +1,31 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface WorkItemPropertyAttributes { + id: string; + project_id: string; + work_item_type_id: string; + user_id: string; + name: string; + type?: string | null; + options?: unknown; + createdAt?: Date; + updatedAt?: Date; +} + +export const WorkItemProperty: ModelDefined< + WorkItemPropertyAttributes, + WorkItemPropertyAttributes +> = sequelize.define( + 'WorkItemProperty', + { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + work_item_type_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + type: { type: DataTypes.STRING, allowNull: true, comment: 'select / text / ...' }, + options: { type: DataTypes.JSON, allowNull: true, comment: '选项列表' }, + }, + { tableName: 'work_item_properties', timestamps: true }, +); diff --git a/backend/src/models/WorkItemState.js b/backend/src/models/WorkItemState.js deleted file mode 100644 index 94e8fc9..0000000 --- a/backend/src/models/WorkItemState.js +++ /dev/null @@ -1,41 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const WorkItemState = sequelize.define('WorkItemState', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - work_item_type_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - type: { - type: DataTypes.STRING, - allowNull: true, - comment: 'pending / doing / done', - }, - color: { - type: DataTypes.STRING, - allowNull: true, - }, -}, { - tableName: 'work_item_states', - timestamps: true, -}); diff --git a/backend/src/models/WorkItemState.ts b/backend/src/models/WorkItemState.ts new file mode 100644 index 0000000..5ce5054 --- /dev/null +++ b/backend/src/models/WorkItemState.ts @@ -0,0 +1,31 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +export interface WorkItemStateAttributes { + id: string; + project_id: string; + work_item_type_id: string; + user_id: string; + name: string; + type?: string | null; + color?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export const WorkItemState: ModelDefined< + WorkItemStateAttributes, + WorkItemStateAttributes +> = sequelize.define( + 'WorkItemState', + { + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + work_item_type_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + type: { type: DataTypes.STRING, allowNull: true, comment: 'pending / doing / done' }, + color: { type: DataTypes.STRING, allowNull: true }, + }, + { tableName: 'work_item_states', timestamps: true }, +); diff --git a/backend/src/models/WorkItemType.js b/backend/src/models/WorkItemType.js deleted file mode 100644 index 4908960..0000000 --- a/backend/src/models/WorkItemType.js +++ /dev/null @@ -1,33 +0,0 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../services/db.js'; - -export const WorkItemType = sequelize.define('WorkItemType', { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - comment: 'PingCode 类型 ID,如 epic, story, task', - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - group: { - type: DataTypes.STRING, - allowNull: true, - comment: 'requirement / task / bug', - }, -}, { - tableName: 'work_item_types', - timestamps: true, -}); diff --git a/backend/src/models/WorkItemType.ts b/backend/src/models/WorkItemType.ts new file mode 100644 index 0000000..eb6231c --- /dev/null +++ b/backend/src/models/WorkItemType.ts @@ -0,0 +1,51 @@ +import { DataTypes, ModelDefined } from 'sequelize'; +import { sequelize } from '../services/db.js'; + +/** WorkItemType 行属性(timestamps: true 自动追加 createdAt/updatedAt) */ +export interface WorkItemTypeAttributes { + id: string; + project_id: string; + user_id: string; + name: string; + group?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +export const WorkItemType: ModelDefined< + WorkItemTypeAttributes, + WorkItemTypeAttributes +> = sequelize.define( + 'WorkItemType', + { + id: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + comment: 'PingCode 类型 ID,如 epic, story, task', + }, + project_id: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + group: { + type: DataTypes.STRING, + allowNull: true, + comment: 'requirement / task / bug', + }, + }, + { + tableName: 'work_item_types', + timestamps: true, + }, +); diff --git a/backend/src/models/index.js b/backend/src/models/index.ts similarity index 100% rename from backend/src/models/index.js rename to backend/src/models/index.ts From 1a76bd76081d931aadcb5397393409a8a1d739e8 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:45:13 +0800 Subject: [PATCH 05/12] build(backend): rework Phase 3 models to class-based pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch all 14 models from sequelize.define() + ModelDefined to `class extends Model` + declare fields + .init(). Schema, hooks, associations, table names unchanged (176/176 tests green). Why: ModelDefined instances do NOT expose attributes as properties, so service/route code accessing model fields directly (config.name, user.id, record.title — pervasive) fails type-checking. The class + declare pattern provides both typed queries AND direct attribute access, matching Sequelize's official TS guidance. This is what the migration plan originally specified; the earlier ModelDefined attempt (795d97f) was a lower-risk deviation that turned out to be unworkable downstream. Verified: tsc --noEmit (0 errors) · pnpm test (176/176). statsAnalyzer (a service that reads ModelConfig fields directly) now type-checks. Co-Authored-By: Claude Fable 5 --- backend/src/models/ImportRecord.ts | 27 +++++++++---- backend/src/models/ImportRecordItem.ts | 32 ++++++++++++--- backend/src/models/ModelConfig.ts | 24 +++++++++--- backend/src/models/Permission.ts | 18 +++++++-- backend/src/models/Role.ts | 17 ++++++-- backend/src/models/RolePermission.ts | 17 ++++---- backend/src/models/SyncedProject.ts | 21 ++++++---- backend/src/models/SyncedWorkItem.ts | 23 +++++++---- backend/src/models/User.ts | 27 +++++++++++-- backend/src/models/UserRole.ts | 14 +++++-- backend/src/models/WorkItemPriority.ts | 19 +++++---- backend/src/models/WorkItemProperty.ts | 22 +++++++---- backend/src/models/WorkItemState.ts | 22 +++++++---- backend/src/models/WorkItemType.ts | 54 +++++++++----------------- 14 files changed, 225 insertions(+), 112 deletions(-) diff --git a/backend/src/models/ImportRecord.ts b/backend/src/models/ImportRecord.ts index 1d21f66..8354087 100644 --- a/backend/src/models/ImportRecord.ts +++ b/backend/src/models/ImportRecord.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export type ImportRecordStatus = @@ -25,11 +25,24 @@ export interface ImportRecordAttributes { updatedAt?: Date; } -export const ImportRecord: ModelDefined< - ImportRecordAttributes, - ImportRecordAttributes -> = sequelize.define( - 'ImportRecord', +export class ImportRecord extends Model { + declare id: string; + declare user_id: string; + declare file_name: string; + declare original_file_path?: string | null; + declare requirements_count: number; + declare projects_count?: number; + declare target_project_id?: string | null; + declare target_project_name?: string | null; + declare imported_count?: number; + declare failed_count?: number; + declare status?: ImportRecordStatus; + declare error_message?: string | null; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +ImportRecord.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, user_id: { type: DataTypes.UUID, allowNull: false }, @@ -48,5 +61,5 @@ export const ImportRecord: ModelDefined< }, error_message: { type: DataTypes.TEXT, allowNull: true, comment: '错误信息' }, }, - { tableName: 'import_records', timestamps: true }, + { sequelize, tableName: 'import_records', timestamps: true }, ); diff --git a/backend/src/models/ImportRecordItem.ts b/backend/src/models/ImportRecordItem.ts index d0742f0..0536cbc 100644 --- a/backend/src/models/ImportRecordItem.ts +++ b/backend/src/models/ImportRecordItem.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export type ImportRecordItemStatus = 'pending' | 'success' | 'failed' | 'skipped'; @@ -33,11 +33,30 @@ export interface ImportRecordItemAttributes { updatedAt?: Date; } -export const ImportRecordItem: ModelDefined< - ImportRecordItemAttributes, - ImportRecordItemAttributes -> = sequelize.define( - 'ImportRecordItem', +export class ImportRecordItem extends Model { + declare id: string; + declare record_id: string; + declare title: string; + declare description?: string | null; + declare project_name?: string | null; + declare type_id?: string | null; + declare priority_id?: string | null; + declare priority?: string | null; + declare estimated_hours?: number | null; + declare start_at?: string | null; + declare end_at?: string | null; + declare assignee_name?: string | null; + declare assignee_id?: string | null; + declare solution_suggestion?: string | null; + declare pingcode_id?: string | null; + declare pingcode_identifier?: string | null; + declare status?: ImportRecordItemStatus; + declare error_message?: string | null; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +ImportRecordItem.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, record_id: { type: DataTypes.UUID, allowNull: false, comment: '关联的导入记录ID' }, @@ -63,6 +82,7 @@ export const ImportRecordItem: ModelDefined< error_message: { type: DataTypes.TEXT, allowNull: true, comment: '导入失败的错误信息' }, }, { + sequelize, tableName: 'import_record_items', timestamps: true, indexes: [{ fields: ['record_id'] }, { fields: ['status'] }], diff --git a/backend/src/models/ModelConfig.ts b/backend/src/models/ModelConfig.ts index 0af94e7..b87f68e 100644 --- a/backend/src/models/ModelConfig.ts +++ b/backend/src/models/ModelConfig.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; import { encrypt, decrypt } from '../utils/crypto.js'; @@ -19,14 +19,25 @@ export interface ModelConfigAttributes { updatedAt?: Date; } +export class ModelConfig extends Model { + declare id: string; + declare user_id: string; + declare name: string; + declare provider: ModelProvider; + declare api_key: string; + declare base_url?: string | null; + declare model: string; + declare temperature?: number | null; + declare max_tokens?: number | null; + declare is_default?: boolean; + declare createdAt?: Date; + declare updatedAt?: Date; +} + /** 需要透明加解密的敏感字段 */ const SENSITIVE_FIELDS = ['api_key']; -export const ModelConfig: ModelDefined< - ModelConfigAttributes, - ModelConfigAttributes -> = sequelize.define( - 'ModelConfig', +ModelConfig.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, user_id: { type: DataTypes.UUID, allowNull: false }, @@ -40,6 +51,7 @@ export const ModelConfig: ModelDefined< is_default: { type: DataTypes.BOOLEAN, defaultValue: false, comment: '是否为默认配置' }, }, { + sequelize, tableName: 'model_configs', timestamps: true, indexes: [{ unique: true, fields: ['user_id', 'name'] }], diff --git a/backend/src/models/Permission.ts b/backend/src/models/Permission.ts index f01c367..f6baded 100644 --- a/backend/src/models/Permission.ts +++ b/backend/src/models/Permission.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface PermissionAttributes { @@ -12,8 +12,18 @@ export interface PermissionAttributes { updatedAt?: Date; } -export const Permission: ModelDefined = sequelize.define( - 'Permission', +export class Permission extends Model { + declare id: string; + declare name: string; + declare display_name: string; + declare description?: string | null; + declare resource: string; + declare action: string; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +Permission.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, name: { type: DataTypes.STRING, allowNull: false, unique: true, comment: '权限名称,如 users.manage, projects.view' }, @@ -22,5 +32,5 @@ export const Permission: ModelDefined = sequelize.define( - 'Role', +export class Role extends Model { + declare id: string; + declare name: string; + declare display_name: string; + declare description?: string | null; + declare is_system?: boolean; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +Role.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, name: { type: DataTypes.STRING, allowNull: false, unique: true, comment: '角色名称,如 admin, user' }, @@ -20,5 +29,5 @@ export const Role: ModelDefined = sequelize.defi description: { type: DataTypes.TEXT, allowNull: true, comment: '角色描述' }, is_system: { type: DataTypes.BOOLEAN, defaultValue: false, comment: '是否为系统角色(不可删除)' }, }, - { tableName: 'roles', timestamps: true }, + { sequelize, tableName: 'roles', timestamps: true }, ); diff --git a/backend/src/models/RolePermission.ts b/backend/src/models/RolePermission.ts index 4dcf840..50cdd6e 100644 --- a/backend/src/models/RolePermission.ts +++ b/backend/src/models/RolePermission.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface RolePermissionAttributes { @@ -8,14 +8,17 @@ export interface RolePermissionAttributes { updatedAt?: Date; } -export const RolePermission: ModelDefined< - RolePermissionAttributes, - RolePermissionAttributes -> = sequelize.define( - 'RolePermission', +export class RolePermission extends Model { + declare role_id: string; + declare permission_id: string; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +RolePermission.init( { role_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '角色ID' }, permission_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '权限ID' }, }, - { tableName: 'role_permissions', timestamps: true }, + { sequelize, tableName: 'role_permissions', timestamps: true }, ); diff --git a/backend/src/models/SyncedProject.ts b/backend/src/models/SyncedProject.ts index 76f5c2b..74379d3 100644 --- a/backend/src/models/SyncedProject.ts +++ b/backend/src/models/SyncedProject.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface SyncedProjectAttributes { @@ -14,11 +14,18 @@ export interface SyncedProjectAttributes { updatedAt?: Date; } -export const SyncedProject: ModelDefined< - SyncedProjectAttributes, - SyncedProjectAttributes -> = sequelize.define( - 'SyncedProject', +export class SyncedProject extends Model { + declare id: string; + declare user_id: string; + declare name: string; + declare description?: string | null; + declare remote_updated_at?: string | null; + declare is_archived?: boolean; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +SyncedProject.init( { id: { type: DataTypes.STRING, allowNull: false, primaryKey: true, comment: 'PingCode 项目 ID' }, user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, @@ -27,5 +34,5 @@ export const SyncedProject: ModelDefined< remote_updated_at: { type: DataTypes.STRING, allowNull: true, comment: 'PingCode 项目最后更新时间(ISO 字符串或时间戳)' }, is_archived: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'PingCode 侧已删除/归档时标记为 true' }, }, - { tableName: 'synced_projects', timestamps: true }, + { sequelize, tableName: 'synced_projects', timestamps: true }, ); diff --git a/backend/src/models/SyncedWorkItem.ts b/backend/src/models/SyncedWorkItem.ts index f6d75c1..67d92ca 100644 --- a/backend/src/models/SyncedWorkItem.ts +++ b/backend/src/models/SyncedWorkItem.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface SyncedWorkItemAttributes { @@ -16,11 +16,20 @@ export interface SyncedWorkItemAttributes { updatedAt?: Date; } -export const SyncedWorkItem: ModelDefined< - SyncedWorkItemAttributes, - SyncedWorkItemAttributes -> = sequelize.define( - 'SyncedWorkItem', +export class SyncedWorkItem extends Model { + declare id: string; + declare user_id: string; + declare project_id: string; + declare title: string; + declare description?: string | null; + declare identifier?: string | null; + declare remote_updated_at?: string | null; + declare is_archived?: boolean; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +SyncedWorkItem.init( { id: { type: DataTypes.STRING, allowNull: false, primaryKey: true, comment: 'PingCode 工作项 ID' }, user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, @@ -31,5 +40,5 @@ export const SyncedWorkItem: ModelDefined< remote_updated_at: { type: DataTypes.STRING, allowNull: true, comment: 'PingCode 工作项最后更新时间(ISO 字符串或时间戳)' }, is_archived: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'PingCode 侧已删除/归档时标记为 true' }, }, - { tableName: 'synced_work_items', timestamps: true }, + { sequelize, tableName: 'synced_work_items', timestamps: true }, ); diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index 7c57f5f..9eec700 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; import { encrypt, decrypt } from '../utils/crypto.js'; @@ -24,11 +24,31 @@ export interface UserAttributes { updatedAt?: Date; } +export class User extends Model { + declare id: string; + declare username: string; + declare password_hash: string; + declare pingcode_client_id?: string | null; + declare pingcode_client_secret?: string | null; + declare pingcode_grant_type?: string; + declare pingcode_uid?: string | null; + declare access_token?: string | null; + declare refresh_token?: string | null; + declare expires_at?: Date | null; + declare domain?: string | null; + declare pingcode_user_id?: string | null; + declare pingcode_user_name?: string | null; + declare pingcode_display_name?: string | null; + declare pingcode_email?: string | null; + declare pingcode_avatar?: string | null; + declare createdAt?: Date; + declare updatedAt?: Date; +} + /** 需要透明加解密的敏感字段 */ const SENSITIVE_FIELDS = ['pingcode_client_secret', 'access_token', 'refresh_token']; -export const User: ModelDefined = sequelize.define( - 'User', +User.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, username: { type: DataTypes.STRING, allowNull: false, unique: true }, @@ -57,6 +77,7 @@ export const User: ModelDefined = sequelize.defi pingcode_avatar: { type: DataTypes.STRING, allowNull: true }, }, { + sequelize, tableName: 'user_auth', timestamps: true, // Force sync to update schema since we changed PK diff --git a/backend/src/models/UserRole.ts b/backend/src/models/UserRole.ts index 78c2f0b..9d6b3c7 100644 --- a/backend/src/models/UserRole.ts +++ b/backend/src/models/UserRole.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface UserRoleAttributes { @@ -8,11 +8,17 @@ export interface UserRoleAttributes { updatedAt?: Date; } -export const UserRole: ModelDefined = sequelize.define( - 'UserRole', +export class UserRole extends Model { + declare user_id: string; + declare role_id: string; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +UserRole.init( { user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '用户ID' }, role_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true, comment: '角色ID' }, }, - { tableName: 'user_roles', timestamps: true }, + { sequelize, tableName: 'user_roles', timestamps: true }, ); diff --git a/backend/src/models/WorkItemPriority.ts b/backend/src/models/WorkItemPriority.ts index e5e8ce7..ab11af4 100644 --- a/backend/src/models/WorkItemPriority.ts +++ b/backend/src/models/WorkItemPriority.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface WorkItemPriorityAttributes { @@ -10,16 +10,21 @@ export interface WorkItemPriorityAttributes { updatedAt?: Date; } -export const WorkItemPriority: ModelDefined< - WorkItemPriorityAttributes, - WorkItemPriorityAttributes -> = sequelize.define( - 'WorkItemPriority', +export class WorkItemPriority extends Model { + declare id: string; + declare project_id: string; + declare user_id: string; + declare name: string; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +WorkItemPriority.init( { id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, name: { type: DataTypes.STRING, allowNull: false }, }, - { tableName: 'work_item_priorities', timestamps: true }, + { sequelize, tableName: 'work_item_priorities', timestamps: true }, ); diff --git a/backend/src/models/WorkItemProperty.ts b/backend/src/models/WorkItemProperty.ts index 627f459..0ba5056 100644 --- a/backend/src/models/WorkItemProperty.ts +++ b/backend/src/models/WorkItemProperty.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface WorkItemPropertyAttributes { @@ -13,11 +13,19 @@ export interface WorkItemPropertyAttributes { updatedAt?: Date; } -export const WorkItemProperty: ModelDefined< - WorkItemPropertyAttributes, - WorkItemPropertyAttributes -> = sequelize.define( - 'WorkItemProperty', +export class WorkItemProperty extends Model { + declare id: string; + declare project_id: string; + declare work_item_type_id: string; + declare user_id: string; + declare name: string; + declare type?: string | null; + declare options?: unknown; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +WorkItemProperty.init( { id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, @@ -27,5 +35,5 @@ export const WorkItemProperty: ModelDefined< type: { type: DataTypes.STRING, allowNull: true, comment: 'select / text / ...' }, options: { type: DataTypes.JSON, allowNull: true, comment: '选项列表' }, }, - { tableName: 'work_item_properties', timestamps: true }, + { sequelize, tableName: 'work_item_properties', timestamps: true }, ); diff --git a/backend/src/models/WorkItemState.ts b/backend/src/models/WorkItemState.ts index 5ce5054..85dfabf 100644 --- a/backend/src/models/WorkItemState.ts +++ b/backend/src/models/WorkItemState.ts @@ -1,4 +1,4 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; export interface WorkItemStateAttributes { @@ -13,11 +13,19 @@ export interface WorkItemStateAttributes { updatedAt?: Date; } -export const WorkItemState: ModelDefined< - WorkItemStateAttributes, - WorkItemStateAttributes -> = sequelize.define( - 'WorkItemState', +export class WorkItemState extends Model { + declare id: string; + declare project_id: string; + declare work_item_type_id: string; + declare user_id: string; + declare name: string; + declare type?: string | null; + declare color?: string | null; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +WorkItemState.init( { id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, @@ -27,5 +35,5 @@ export const WorkItemState: ModelDefined< type: { type: DataTypes.STRING, allowNull: true, comment: 'pending / doing / done' }, color: { type: DataTypes.STRING, allowNull: true }, }, - { tableName: 'work_item_states', timestamps: true }, + { sequelize, tableName: 'work_item_states', timestamps: true }, ); diff --git a/backend/src/models/WorkItemType.ts b/backend/src/models/WorkItemType.ts index eb6231c..adf8717 100644 --- a/backend/src/models/WorkItemType.ts +++ b/backend/src/models/WorkItemType.ts @@ -1,7 +1,6 @@ -import { DataTypes, ModelDefined } from 'sequelize'; +import { DataTypes, Model } from 'sequelize'; import { sequelize } from '../services/db.js'; -/** WorkItemType 行属性(timestamps: true 自动追加 createdAt/updatedAt) */ export interface WorkItemTypeAttributes { id: string; project_id: string; @@ -12,40 +11,23 @@ export interface WorkItemTypeAttributes { updatedAt?: Date; } -export const WorkItemType: ModelDefined< - WorkItemTypeAttributes, - WorkItemTypeAttributes -> = sequelize.define( - 'WorkItemType', - { - id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - comment: 'PingCode 类型 ID,如 epic, story, task', - }, - project_id: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - user_id: { - type: DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - group: { - type: DataTypes.STRING, - allowNull: true, - comment: 'requirement / task / bug', - }, - }, +export class WorkItemType extends Model { + declare id: string; + declare project_id: string; + declare user_id: string; + declare name: string; + declare group?: string | null; + declare createdAt?: Date; + declare updatedAt?: Date; +} + +WorkItemType.init( { - tableName: 'work_item_types', - timestamps: true, + id: { type: DataTypes.STRING, allowNull: false, primaryKey: true, comment: 'PingCode 类型 ID,如 epic, story, task' }, + project_id: { type: DataTypes.STRING, allowNull: false, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false, primaryKey: true }, + name: { type: DataTypes.STRING, allowNull: false }, + group: { type: DataTypes.STRING, allowNull: true, comment: 'requirement / task / bug' }, }, + { sequelize, tableName: 'work_item_types', timestamps: true }, ); From 718952995277bd0341ff9e280a1bd0e200660654 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:50:05 +0800 Subject: [PATCH 06/12] =?UTF-8?q?build(backend):=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20migrate=20services=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 10 service files (.js → .ts). Business logic unchanged. - db.ts: Sequelize + SeekdbClient typed; dynamic model imports resolve to index.ts; initDB/connectRelational/initDefault* return Promise - agent.ts: LLMConfig interface (covers both ModelConfig instances and frontend test configs); NormalizedWorkItem output type; dynamic @langchain/anthropic import preserved - pingcode.ts: axios API wrappers typed (string token/domain params); dynamic PingCode responses kept Promise; BatchCreateResult / BatchProgressItem interfaces; jwtDecode cast to Record for sub/uid - metadata.ts: MetadataCounts, PingCodeProject, Set existing-id accumulators - importHelper.ts: ImportItemInput / ImportContext; reuses typed ProjectMember from utils/assignee - statsAnalyzer.ts, uploadCleanup.ts, clearSyncedData.ts, auditLog.ts, parser.ts: parameter/return types, exported option/result interfaces Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Co-Authored-By: Claude Fable 5 --- backend/src/services/{agent.js => agent.ts} | 82 +++++----- .../src/services/{auditLog.js => auditLog.ts} | 54 ++++--- ...{clearSyncedData.js => clearSyncedData.ts} | 13 +- backend/src/services/{db.js => db.ts} | 47 +++--- .../{importHelper.js => importHelper.ts} | 60 ++++--- .../src/services/{metadata.js => metadata.ts} | 79 +++++---- backend/src/services/{parser.js => parser.ts} | 2 +- .../src/services/{pingcode.js => pingcode.ts} | 150 +++++++++--------- .../{statsAnalyzer.js => statsAnalyzer.ts} | 8 +- .../{uploadCleanup.js => uploadCleanup.ts} | 40 +++-- 10 files changed, 309 insertions(+), 226 deletions(-) rename backend/src/services/{agent.js => agent.ts} (72%) rename backend/src/services/{auditLog.js => auditLog.ts} (62%) rename backend/src/services/{clearSyncedData.js => clearSyncedData.ts} (84%) rename backend/src/services/{db.js => db.ts} (83%) rename backend/src/services/{importHelper.js => importHelper.ts} (64%) rename backend/src/services/{metadata.js => metadata.ts} (65%) rename backend/src/services/{parser.js => parser.ts} (82%) rename backend/src/services/{pingcode.js => pingcode.ts} (69%) rename backend/src/services/{statsAnalyzer.js => statsAnalyzer.ts} (90%) rename backend/src/services/{uploadCleanup.js => uploadCleanup.ts} (75%) diff --git a/backend/src/services/agent.js b/backend/src/services/agent.ts similarity index 72% rename from backend/src/services/agent.js rename to backend/src/services/agent.ts index 8e49fb2..5df0958 100644 --- a/backend/src/services/agent.js +++ b/backend/src/services/agent.ts @@ -10,12 +10,33 @@ const parser = new JsonOutputParser(); /** 需求分析 Prompt 模板 */ const analyzePrompt = PromptTemplate.fromTemplate(ANALYZE_REQUIREMENTS_PROMPT); +/** LLM 配置(模型实例 / 前端测试连接均满足此结构) */ +interface LLMConfig { + provider?: string | null; + api_key: string; + base_url?: string | null; + model: string; + temperature?: number | null; + max_tokens?: number | null; +} + +/** 规范化后的工作项结构 */ +export interface NormalizedWorkItem { + project_name: string; + title: string; + description: string; + priority: 'High' | 'Medium' | 'Low'; + estimated_hours: number; + start_at: string; + type_id: string; + assignee_name: string | null; + solution_suggestion: string; +} + /** * 根据 provider 创建 OpenAI 兼容的 LLM 实例 - * @param {object} config - 模型配置 - * @returns {ChatOpenAI} LLM 实例 */ -function createOpenAIModel(config) { +function createOpenAIModel(config: LLMConfig): ChatOpenAI { return new ChatOpenAI({ configuration: { baseURL: config.base_url || undefined, @@ -29,18 +50,16 @@ function createOpenAIModel(config) { /** * 根据 provider 创建 Anthropic 的 LLM 实例(动态加载依赖,未安装时抛出明确错误) - * @param {object} config - 模型配置 - * @returns {Promise} LLM 实例 */ -async function createAnthropicModel(config) { +async function createAnthropicModel(config: LLMConfig): Promise { let ChatAnthropic; try { const mod = await import('@langchain/anthropic'); ChatAnthropic = mod.ChatAnthropic; - } catch (err) { + } catch (err: any) { if (err.code === 'ERR_MODULE_NOT_FOUND' || err.message?.includes('Cannot find package')) { throw new Error( - '使用 Anthropic 模型需要先安装依赖,请在 backend 目录执行: pnpm add @langchain/anthropic' + '使用 Anthropic 模型需要先安装依赖,请在 backend 目录执行: pnpm add @langchain/anthropic', ); } throw err; @@ -57,10 +76,8 @@ async function createAnthropicModel(config) { /** * 获取 LLM 模型实例(仅从模型管理中获取) * 优先使用用户默认模型,若无则使用第一个配置 - * @param {string} userId - 用户ID - * @returns {Promise} LLM 模型实例 */ -async function getModelInstance(userId) { +async function getModelInstance(userId: string): Promise { // 1. 优先获取用户默认模型配置 let config = await ModelConfig.findOne({ where: { user_id: userId, is_default: true }, @@ -88,10 +105,9 @@ async function getModelInstance(userId) { /** * 测试模型连接是否可用 - * @param {object} config - 模型配置 { provider, api_key, base_url?, model, temperature?, max_tokens? } - * @throws {Error} 连接失败时抛出 + * @throws 连接失败时抛出 */ -export async function testModelConnection(config) { +export async function testModelConnection(config: LLMConfig): Promise { const model = config.provider === 'anthropic' ? await createAnthropicModel(config) : createOpenAIModel(config); @@ -100,28 +116,17 @@ export async function testModelConnection(config) { /** * 调用 LLM 分析需求文档并提取工作项 - * @param {string} text - 文档文本内容 - * @param {string} userId - 用户ID - * @returns {Promise} 工作项列表,每项包含: - * - project_name: 项目名称 - * - title: 工作项标题 - * - description: 详细描述 - * - priority: 优先级 (High/Medium/Low) - * - estimated_hours: 预估工时(小时) - * - start_at: 计划开始时间 (ISO 8601) - * - assignee_name: 负责人姓名(可选) - * - solution_suggestion: 解决方案建议 */ -export async function analyzeRequirements(text, userId) { +export async function analyzeRequirements(text: string, userId: string): Promise { const result = await analyzeRequirementsRaw(text, userId); return normalizeWorkItems(result); } /** 将 LLM 原始输出规范化为统一的工作项结构 */ -function normalizeWorkItems(result) { +function normalizeWorkItems(result: unknown): NormalizedWorkItem[] { const typeIdAllowList = ['story', 'task', 'bug', 'feature', 'epic']; const currentTime = new Date().toISOString(); - const workItems = Array.isArray(result) ? result : []; + const workItems: any[] = Array.isArray(result) ? result : []; return workItems.map((item) => ({ project_name: item.project_name || '未分类项目', title: item.title || '未命名工作项', @@ -136,7 +141,7 @@ function normalizeWorkItems(result) { } /** 底层调用:返回 LLM 原始解析结果(供非流式和流式复用) */ -async function analyzeRequirementsRaw(text, userId) { +async function analyzeRequirementsRaw(text: string, userId: string): Promise { const model = await getModelInstance(userId); const chain = analyzePrompt.pipe(model).pipe(parser); const currentTime = new Date().toISOString(); @@ -148,7 +153,7 @@ async function analyzeRequirementsRaw(text, userId) { format_instructions: parser.getFormatInstructions(), }); } catch (e) { - console.error('[Agent] 需求分析失败:', e.message); + console.error('[Agent] 需求分析失败:', (e as Error).message); throw e; } } @@ -159,17 +164,18 @@ async function analyzeRequirementsRaw(text, userId) { * LangChain 的 chain.stream() 配合 JsonOutputParser 会逐步 yield 已解析的部分结果。 * 通过 onProgress 回调将部分结果推送给前端(SSE),让用户在大文档分析时获得实时反馈。 * - * @param {string} text - 文档文本 - * @param {string} userId - 用户 ID - * @param {Function} [onProgress] - 回调 (partialItems: Array) => void,收到部分结果时触发 - * @returns {Promise} 最终完整的工作项列表 + * @param onProgress 回调 (partialItems) => void,收到部分结果时触发 */ -export async function analyzeRequirementsStream(text, userId, onProgress) { +export async function analyzeRequirementsStream( + text: string, + userId: string, + onProgress?: (partialItems: NormalizedWorkItem[]) => void, +): Promise { const model = await getModelInstance(userId); const chain = analyzePrompt.pipe(model).pipe(parser); const currentTime = new Date().toISOString(); - let lastResult = null; + let lastResult: unknown = null; try { const stream = await chain.stream({ text, @@ -185,7 +191,7 @@ export async function analyzeRequirementsStream(text, userId, onProgress) { } } } catch (e) { - console.error('[Agent] 流式需求分析失败:', e.message); + console.error('[Agent] 流式需求分析失败:', (e as Error).message); // 流式失败时回退到非流式调用 if (lastResult) { return normalizeWorkItems(lastResult); @@ -194,6 +200,6 @@ export async function analyzeRequirementsStream(text, userId, onProgress) { } // 流结束后用最终结果规范化(可能比最后一块更完整) - const finalRaw = lastResult || await analyzeRequirementsRaw(text, userId); + const finalRaw = lastResult || (await analyzeRequirementsRaw(text, userId)); return normalizeWorkItems(finalRaw); } diff --git a/backend/src/services/auditLog.js b/backend/src/services/auditLog.ts similarity index 62% rename from backend/src/services/auditLog.js rename to backend/src/services/auditLog.ts index 66662d0..3289b8c 100644 --- a/backend/src/services/auditLog.js +++ b/backend/src/services/auditLog.ts @@ -5,24 +5,45 @@ * 便于安全审查与问题追溯。 * * 采用轻量的内存 + 控制台输出方案(避免引入额外存储依赖)。 - * 生产环境可替换为持久化存储(如写入 DB 或发送到日志服务)。 - */ + * 生产环境可替换为持久化存储(如写入 DB 或发送到日志服务) */ const MAX_LOG_ENTRIES = 1000; -const auditEntries = []; + +export interface AuditEntry { + userId?: string; + username?: string; + /** 操作类型(如 'DELETE_USER'、'CLEAR_SYNC_DATA'、'UPDATE_ROLE') */ + action: string; + /** 操作目标(如 'user:xxx'、'sync-data') */ + resource?: string; + /** 附加详情 */ + detail?: Record; + result?: 'success' | 'failed'; +} + +export interface AuditRecord { + timestamp: string; + userId: string; + username: string; + action: string; + resource: string; + detail: Record; + result: 'success' | 'failed'; +} + +export interface AuditQueryFilters { + userId?: string; + action?: string; + limit?: number; +} + +const auditEntries: AuditRecord[] = []; /** * 记录一条审计日志。 - * @param {object} entry - * @param {string} entry.userId - 操作者 ID - * @param {string} entry.username - 操作者用户名 - * @param {string} entry.action - 操作类型(如 'DELETE_USER'、'CLEAR_SYNC_DATA'、'UPDATE_ROLE') - * @param {string} [entry.resource] - 操作目标(如 'user:xxx'、'sync-data') - * @param {object} [entry.detail] - 附加详情 - * @param {'success'|'failed'} [entry.result='success'] - 操作结果 */ -export function logAudit(entry) { - const record = { +export function logAudit(entry: AuditEntry): void { + const record: AuditRecord = { timestamp: new Date().toISOString(), userId: entry.userId || 'unknown', username: entry.username || 'unknown', @@ -42,19 +63,14 @@ export function logAudit(entry) { const level = record.result === 'failed' ? 'warn' : 'log'; console[level]( `[Audit] ${record.timestamp} | ${record.username}(${record.userId}) | ${record.action} | ${record.resource} | ${record.result}`, - record.detail && Object.keys(record.detail).length > 0 ? JSON.stringify(record.detail) : '' + record.detail && Object.keys(record.detail).length > 0 ? JSON.stringify(record.detail) : '', ); } /** * 查询审计日志(最新优先)。 - * @param {object} [filters] - * @param {string} [filters.userId] - 按用户过滤 - * @param {string} [filters.action] - 按操作类型过滤 - * @param {number} [filters.limit=100] - 返回条数 - * @returns {Array} 审计日志条目 */ -export function getAuditLogs(filters = {}) { +export function getAuditLogs(filters: AuditQueryFilters = {}): AuditRecord[] { const { userId, action, limit = 100 } = filters; let result = [...auditEntries].reverse(); // 最新优先 if (userId) result = result.filter((e) => e.userId === userId); diff --git a/backend/src/services/clearSyncedData.js b/backend/src/services/clearSyncedData.ts similarity index 84% rename from backend/src/services/clearSyncedData.js rename to backend/src/services/clearSyncedData.ts index 1ba45fb..f0cf89e 100644 --- a/backend/src/services/clearSyncedData.js +++ b/backend/src/services/clearSyncedData.ts @@ -11,7 +11,7 @@ import { const SEEK_DELETE_BATCH = 200; -async function deleteSeekVectors(collectionName, compositeIds) { +async function deleteSeekVectors(collectionName: string, compositeIds: string[]): Promise { if (!compositeIds.length) return; const coll = await seekdbClient.getCollection({ name: collectionName }); for (const batch of chunk(compositeIds, SEEK_DELETE_BATCH)) { @@ -19,11 +19,20 @@ async function deleteSeekVectors(collectionName, compositeIds) { } } +export interface ClearSyncedResult { + projects: number; + workItems: number; + types: number; + states: number; + properties: number; + priorities: number; +} + /** * 清除当前用户从 PingCode 同步到本地的数据(关系表 + SeekDB 向量),不含导入记录。 * 包括已标记归档(is_archived)的记录,即全量清除后下次同步可重建。 */ -export async function clearUserSyncedData(userId) { +export async function clearUserSyncedData(userId: string): Promise { const [projRows, wiRows] = await Promise.all([ SyncedProject.findAll({ where: { user_id: userId }, attributes: ['id'] }), SyncedWorkItem.findAll({ where: { user_id: userId }, attributes: ['id'] }), diff --git a/backend/src/services/db.js b/backend/src/services/db.ts similarity index 83% rename from backend/src/services/db.js rename to backend/src/services/db.ts index 131ceb5..a0d8530 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.ts @@ -6,25 +6,20 @@ import { appConfig } from '../config/index.js'; const { seekdb: dbConf } = appConfig; /** 关系数据库(OceanBase/MySQL 兼容) */ -export const sequelize = new Sequelize( - dbConf.database, - dbConf.user, - dbConf.password, - { - host: dbConf.host, - port: dbConf.port, - dialect: 'mysql', - logging: false, - pool: { - max: 5, - min: 1, - acquire: 30000, - idle: 10000, - /** 断线后自动剔除并重建连接,避免 SeekDB 重启后使用失效连接 */ - evict: 5000, - }, - } -); +export const sequelize = new Sequelize(dbConf.database, dbConf.user, dbConf.password, { + host: dbConf.host, + port: dbConf.port, + dialect: 'mysql', + logging: false, + pool: { + max: 5, + min: 1, + acquire: 30000, + idle: 10000, + /** 断线后自动剔除并重建连接,避免 SeekDB 重启后使用失效连接 */ + evict: 5000, + }, +}); /** 向量数据库客户端 */ export const seekdbClient = new SeekdbClient({ @@ -35,10 +30,10 @@ export const seekdbClient = new SeekdbClient({ database: dbConf.database, }); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** 连接关系数据库并同步模型 */ -async function connectRelational() { +async function connectRelational(): Promise { await sequelize.authenticate(); // 导入 models/index.js 以确保所有模型和关联关系都被注册 await import('../models/index.js'); @@ -58,7 +53,7 @@ async function connectRelational() { } /** 初始化默认角色 */ -async function initDefaultRoles() { +async function initDefaultRoles(): Promise { const { Role } = await import('../models/index.js'); const defaultRoles = [ @@ -88,7 +83,7 @@ async function initDefaultRoles() { } /** 初始化默认管理员账号(admin / qwe@123) */ -async function initDefaultAdmin() { +async function initDefaultAdmin(): Promise { const { User, Role, UserRole } = await import('../models/index.js'); const existingUser = await User.findOne({ where: { username: 'admin' } }); @@ -115,7 +110,7 @@ async function initDefaultAdmin() { } /** 初始化向量集合 */ -async function initCollections() { +async function initCollections(): Promise { const collections = ['projects', 'work_items']; for (const name of collections) { try { @@ -127,7 +122,7 @@ async function initCollections() { } /** 初始化数据库(带重试) */ -export async function initDB() { +export async function initDB(): Promise { const { retryCount, retryIntervalMs } = dbConf; for (let i = 0; i < retryCount; i++) { @@ -140,7 +135,7 @@ export async function initDB() { return; } catch (err) { if (i === retryCount - 1) { - console.error('[DB] 数据库连接失败,请确认 SeekDB 服务已启动。', err.message); + console.error('[DB] 数据库连接失败,请确认 SeekDB 服务已启动。', (err as Error).message); throw err; } console.warn(`[DB] 连接失败,${retryIntervalMs}ms 后重试(${i + 1}/${retryCount})`); diff --git a/backend/src/services/importHelper.js b/backend/src/services/importHelper.ts similarity index 64% rename from backend/src/services/importHelper.js rename to backend/src/services/importHelper.ts index be9db10..ec255ef 100644 --- a/backend/src/services/importHelper.js +++ b/backend/src/services/importHelper.ts @@ -7,27 +7,52 @@ * - 构建符合 PingCode API 的 payload(含工时单位转换、自定义属性、时间戳) */ import { getProjectMembers } from './pingcode.js'; -import { resolveAssigneeId } from '../utils/assignee.js'; +import { resolveAssigneeId, ProjectMember } from '../utils/assignee.js'; import { toUnixTimestamp, hoursToWorkload } from '../utils/workItem.js'; +/** 前端传入的工作项(字段可能部分缺失) */ +export interface ImportItemInput { + id: string; + title: string; + description?: string | null; + assignee_id?: string | null; + assignee_name?: string | null; + state_id?: string | null; + start_at?: string | null; + end_at?: string | null; + estimated_hours?: number | null; + solution_suggestion?: string | null; + properties?: Record | null; +} + +/** buildImportPayload 上下文 */ +export interface ImportContext { + targetProjectId: string; + resolvedTypeId: string; + resolvedPriorityId: string | null; + members?: ProjectMember[]; + pingcodeUserId?: string | null; +} + /** * 拉取项目成员并缓存 assignee_name → assignee_id 的映射结果。 * 为减少 PingCode API 调用,同一项目的成员列表只拉取一次。 * - * @param {string} accessToken - * @param {string} projectId - * @param {string} domain - * @param {Map} cache - 项目成员缓存(projectId → members[]) - * @returns {Promise} 成员列表(拉取失败时返回空数组) + * @returns 成员列表(拉取失败时返回空数组) */ -export async function getProjectMembersCached(accessToken, projectId, domain, cache) { - if (cache.has(projectId)) return cache.get(projectId); - let members = []; +export async function getProjectMembersCached( + accessToken: string, + projectId: string, + domain: string, + cache: Map, +): Promise { + if (cache.has(projectId)) return cache.get(projectId)!; + let members: ProjectMember[] = []; try { const res = await getProjectMembers(accessToken, projectId, domain); members = Array.isArray(res) ? res : (res?.values || []); } catch (e) { - console.warn(`[ImportHelper] 获取项目 ${projectId} 成员失败,负责人将回退到当前用户:`, e.message); + console.warn(`[ImportHelper] 获取项目 ${projectId} 成员失败,负责人将回退到当前用户:`, (e as Error).message); } cache.set(projectId, members); return members; @@ -37,7 +62,10 @@ export async function getProjectMembersCached(accessToken, projectId, domain, ca * 将 solution_suggestion 拼接到 description 末尾(P1-3.3)。 * 仅当 solution_suggestion 非空时追加。 */ -function appendSolutionSuggestion(description, solutionSuggestion) { +function appendSolutionSuggestion( + description: string | null | undefined, + solutionSuggestion?: string | null, +): string { const desc = description || ''; const suggestion = solutionSuggestion?.trim(); if (!suggestion) return desc; @@ -46,13 +74,9 @@ function appendSolutionSuggestion(description, solutionSuggestion) { } /** - * 构建单个工作项的 PingCode 创建 payload。 - * - * @param {object} item - 前端传入的工作项 - * @param {object} ctx - 上下文 { targetProjectId, resolvedTypeId, resolvedPriorityId, members, pingcodeUserId } - * @returns {object} PingCode work_items POST payload(含 _local_id 用于关联本地记录) + * 构建单个工作项的 PingCode 创建 payload(含 _local_id 用于关联本地记录)。 */ -export function buildImportPayload(item, ctx) { +export function buildImportPayload(item: ImportItemInput, ctx: ImportContext): Record { const { targetProjectId, resolvedTypeId, resolvedPriorityId, members, pingcodeUserId } = ctx; // 负责人解析:优先用显式 assignee_id,其次从 assignee_name 模糊匹配,最后回退当前用户 @@ -64,7 +88,7 @@ export function buildImportPayload(item, ctx) { assigneeId = pingcodeUserId; } - const payload = { + const payload: Record = { _local_id: item.id, project_id: targetProjectId, title: item.title, diff --git a/backend/src/services/metadata.js b/backend/src/services/metadata.ts similarity index 65% rename from backend/src/services/metadata.js rename to backend/src/services/metadata.ts index f7a0cd4..75163a5 100644 --- a/backend/src/services/metadata.js +++ b/backend/src/services/metadata.ts @@ -14,37 +14,53 @@ import { runWithConcurrency } from '../utils/array.js'; const CONCURRENCY_LIMIT = 3; +export interface MetadataCounts { + types: number; + states: number; + properties: number; + priorities: number; +} + +export interface PingCodeProject { + id: string; + name?: string; + [key: string]: unknown; +} + /** * 拉取并存储单个项目的全部元数据(types / states / properties / priorities) * 支持增量写入:已存在的记录会被跳过 */ export async function fetchAndStoreMetadataForProject( - userId, accessToken, domain, projectId, - existingTypeIds = new Set(), - existingStateIds = new Set(), - existingPropertyIds = new Set(), - existingPriorityIds = new Set() -) { - const counts = { types: 0, states: 0, properties: 0, priorities: 0 }; + userId: string, + accessToken: string, + domain: string, + projectId: string, + existingTypeIds: Set = new Set(), + existingStateIds: Set = new Set(), + existingPropertyIds: Set = new Set(), + existingPriorityIds: Set = new Set(), +): Promise { + const counts: MetadataCounts = { types: 0, states: 0, properties: 0, priorities: 0 }; const typesRes = await getWorkItemTypes(accessToken, projectId, domain); - const typeList = Array.isArray(typesRes) ? typesRes : (typesRes?.values || []); + const typeList: any[] = Array.isArray(typesRes) ? typesRes : (typesRes?.values || []); - const newTypes = typeList.filter(t => !existingTypeIds.has(`${projectId}:${t.id}`)); + const newTypes = typeList.filter((t) => !existingTypeIds.has(`${projectId}:${t.id}`)); if (newTypes.length > 0) { await WorkItemType.bulkCreate( - newTypes.map(t => ({ + newTypes.map((t) => ({ id: t.id, project_id: projectId, user_id: userId, name: t.name || t.id, group: t.group || '', })), - { ignoreDuplicates: true } + { ignoreDuplicates: true }, ); - newTypes.forEach(t => existingTypeIds.add(`${projectId}:${t.id}`)); + newTypes.forEach((t) => existingTypeIds.add(`${projectId}:${t.id}`)); counts.types = newTypes.length; } - const statesBatch = []; - const propsBatch = []; + const statesBatch: any[] = []; + const propsBatch: any[] = []; // P2-4.13: type 级别也限制并发,避免大量类型一次性打满 PingCode API 限流 await runWithConcurrency( @@ -53,8 +69,8 @@ export async function fetchAndStoreMetadataForProject( getWorkItemStates(accessToken, projectId, t.id, domain), getWorkItemProperties(accessToken, projectId, t.id, domain), ]); - const stateList = Array.isArray(statesRes) ? statesRes : (statesRes?.values || []); - const propList = Array.isArray(propsRes) ? propsRes : (propsRes?.values || []); + const stateList: any[] = Array.isArray(statesRes) ? statesRes : (statesRes?.values || []); + const propList: any[] = Array.isArray(propsRes) ? propsRes : (propsRes?.values || []); for (const s of stateList) { const key = `${projectId}:${t.id}:${s.id}`; @@ -77,7 +93,7 @@ export async function fetchAndStoreMetadataForProject( existingPropertyIds.add(key); } }), - CONCURRENCY_LIMIT + CONCURRENCY_LIMIT, ); if (statesBatch.length > 0) { @@ -90,16 +106,16 @@ export async function fetchAndStoreMetadataForProject( } const prioRes = await getWorkItemPriorities(accessToken, projectId, domain); - const prioList = Array.isArray(prioRes) ? prioRes : (prioRes?.values || []); - const newPrios = prioList.filter(p => !existingPriorityIds.has(`${projectId}:${p.id}`)); + const prioList: any[] = Array.isArray(prioRes) ? prioRes : (prioRes?.values || []); + const newPrios = prioList.filter((p) => !existingPriorityIds.has(`${projectId}:${p.id}`)); if (newPrios.length > 0) { await WorkItemPriority.bulkCreate( - newPrios.map(p => ({ + newPrios.map((p) => ({ id: p.id, project_id: projectId, user_id: userId, name: p.name || p.id, })), - { ignoreDuplicates: true } + { ignoreDuplicates: true }, ); - newPrios.forEach(p => existingPriorityIds.add(`${projectId}:${p.id}`)); + newPrios.forEach((p) => existingPriorityIds.add(`${projectId}:${p.id}`)); counts.priorities = newPrios.length; } @@ -109,7 +125,12 @@ export async function fetchAndStoreMetadataForProject( /** * 批量确保多个项目的元数据(增量同步) */ -export async function ensureMetadata(userId, accessToken, domain, projectList) { +export async function ensureMetadata( + userId: string, + accessToken: string, + domain: string, + projectList: PingCodeProject[], +): Promise { console.log('[Sync] 开始增量同步元数据...'); const [existingTypes, existingStates, existingProperties, existingPriorities] = await Promise.all([ @@ -119,16 +140,16 @@ export async function ensureMetadata(userId, accessToken, domain, projectList) { WorkItemPriority.findAll({ where: { user_id: userId }, attributes: ['id', 'project_id'] }), ]); - const existingTypeIds = new Set(existingTypes.map(t => `${t.project_id}:${t.id}`)); - const existingStateIds = new Set(existingStates.map(s => `${s.project_id}:${s.work_item_type_id}:${s.id}`)); - const existingPropertyIds = new Set(existingProperties.map(p => `${p.project_id}:${p.work_item_type_id}:${p.id}`)); - const existingPriorityIds = new Set(existingPriorities.map(p => `${p.project_id}:${p.id}`)); + const existingTypeIds = new Set(existingTypes.map((t) => `${t.project_id}:${t.id}`)); + const existingStateIds = new Set(existingStates.map((s) => `${s.project_id}:${s.work_item_type_id}:${s.id}`)); + const existingPropertyIds = new Set(existingProperties.map((p) => `${p.project_id}:${p.work_item_type_id}:${p.id}`)); + const existingPriorityIds = new Set(existingPriorities.map((p) => `${p.project_id}:${p.id}`)); const tasks = projectList.map((proj) => () => fetchAndStoreMetadataForProject( userId, accessToken, domain, proj.id, existingTypeIds, existingStateIds, existingPropertyIds, existingPriorityIds, - ) + ), ); const allCounts = await runWithConcurrency(tasks, CONCURRENCY_LIMIT); @@ -139,7 +160,7 @@ export async function ensureMetadata(userId, accessToken, domain, projectList) { properties: acc.properties + (c?.properties || 0), priorities: acc.priorities + (c?.priorities || 0), }), - { types: 0, states: 0, properties: 0, priorities: 0 } + { types: 0, states: 0, properties: 0, priorities: 0 }, ); console.log(`[Sync] 元数据增量同步完成:+${totals.types} types, +${totals.states} states, +${totals.properties} properties, +${totals.priorities} priorities`); diff --git a/backend/src/services/parser.js b/backend/src/services/parser.ts similarity index 82% rename from backend/src/services/parser.js rename to backend/src/services/parser.ts index 1ac5ba1..da9ce4e 100644 --- a/backend/src/services/parser.js +++ b/backend/src/services/parser.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises'; import mammoth from 'mammoth'; import path from 'path'; -export const parseFile = async (filePath, originalName) => { +export const parseFile = async (filePath: string, originalName: string): Promise => { const ext = path.extname(originalName).toLowerCase(); if (ext === '.txt' || ext === '.md') { return await fs.readFile(filePath, 'utf-8'); diff --git a/backend/src/services/pingcode.js b/backend/src/services/pingcode.ts similarity index 69% rename from backend/src/services/pingcode.js rename to backend/src/services/pingcode.ts index 2804dde..0a522c1 100644 --- a/backend/src/services/pingcode.js +++ b/backend/src/services/pingcode.ts @@ -13,10 +13,25 @@ export const PINGCODE_GRANT_CLIENT_CREDENTIALS = 'client_credentials'; const THIRTY_DAYS_SEC = 30 * 24 * 3600; const MAX_EXPIRES_IN_SEC = 40 * 24 * 3600; +/** 批量创建工作项进度回调的单条结果 */ +export interface BatchProgressItem { + title: string; + status: 'success' | 'failed'; + error?: unknown; +} + +/** 批量创建工作项结果 */ +export interface BatchCreateResult { + success: number; + failed: number; + errors: Array<{ local_id: unknown; item: unknown; error: unknown }>; + created: Array<{ local_id: unknown; pingcode_id?: string; pingcode_identifier?: string; title: unknown }>; +} + /** * 根据 token 接口返回计算 access_token 过期时间(expires_in 按秒解析,异常值回退/裁剪) */ -export function computeExpiresAtFromTokenResponse(tokenData) { +export function computeExpiresAtFromTokenResponse(tokenData: { expires_in?: unknown }): Date { let sec = Number(tokenData?.expires_in); if (!Number.isFinite(sec) || sec <= 0) { return new Date(Date.now() + THIRTY_DAYS_SEC * 1000); @@ -29,7 +44,7 @@ export function computeExpiresAtFromTokenResponse(tokenData) { /* ---- 域名与 API 地址处理 ---- */ -function normalizeHost(domain) { +function normalizeHost(domain?: string | null): string { if (!domain) return pcConf.host; const isSaaS = !domain.includes('.') || domain.endsWith('.pingcode.com'); @@ -39,14 +54,14 @@ function normalizeHost(domain) { return `https://${domain}`; } -function getApiBase(domain) { +function getApiBase(domain?: string | null): string { return `${normalizeHost(domain)}/v1`; } /* ---- OAuth ---- */ /** 生成 PingCode OAuth 授权 URL */ -export function getAuthUrl(clientId, state) { +export function getAuthUrl(clientId: string, state?: string): string { const url = new URL('/oauth2/authorize', pcConf.host); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', clientId); @@ -59,7 +74,7 @@ export function getAuthUrl(clientId, state) { } /** 通过授权码获取令牌 */ -export async function getToken(code, clientId, clientSecret) { +export async function getToken(code: string, clientId: string, clientSecret: string): Promise { const url = new URL('/v1/auth/token', pcConf.host); url.searchParams.set('grant_type', 'authorization_code'); url.searchParams.set('client_id', clientId); @@ -72,12 +87,12 @@ export async function getToken(code, clientId, clientSecret) { const res = await axios.get(url.toString()); return res.data; }, - { maxRetries: 2, label: 'PingCode:getToken' } + { maxRetries: 2, label: 'PingCode:getToken' }, ); } /** 使用 refresh_token 刷新 access_token */ -export async function refreshAccessToken(refreshToken, clientId, clientSecret) { +export async function refreshAccessToken(refreshToken: string, clientId: string, clientSecret: string): Promise { const url = new URL('/v1/auth/token', pcConf.host); url.searchParams.set('grant_type', 'refresh_token'); url.searchParams.set('client_id', clientId); @@ -89,12 +104,12 @@ export async function refreshAccessToken(refreshToken, clientId, clientSecret) { const res = await axios.get(url.toString()); return res.data; }, - { maxRetries: 2, label: 'PingCode:refreshToken' } + { maxRetries: 2, label: 'PingCode:refreshToken' }, ); } /** 企业令牌:client_credentials */ -export async function getEnterpriseToken(clientId, clientSecret) { +export async function getEnterpriseToken(clientId: string, clientSecret: string): Promise { const url = new URL('/v1/auth/token', pcConf.host); url.searchParams.set('grant_type', PINGCODE_GRANT_CLIENT_CREDENTIALS); url.searchParams.set('client_id', clientId); @@ -105,14 +120,14 @@ export async function getEnterpriseToken(clientId, clientSecret) { const res = await axios.get(url.toString()); return res.data; }, - { maxRetries: 2, label: 'PingCode:getEnterpriseToken' } + { maxRetries: 2, label: 'PingCode:getEnterpriseToken' }, ); } /* ---- 项目与工作项 ---- */ /** 获取用户可访问的项目列表 */ -export async function getProjects(token, domain) { +export async function getProjects(token: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { @@ -121,7 +136,7 @@ export async function getProjects(token, domain) { }); return res.data; }, - { maxRetries: 2, label: 'PingCode:getProjects' } + { maxRetries: 2, label: 'PingCode:getProjects' }, ); } @@ -134,24 +149,24 @@ export async function getProjects(token, domain) { * - 主停止条件:本页返回条数 < page_size * - 安全限制:最多拉取 100 页,避免分页参数异常导致死循环 */ -export async function getWorkItems(token, projectId, domain) { +export async function getWorkItems(token: string, projectId: string, domain?: string): Promise { const apiBase = getApiBase(domain); const pageSize = 100; const MAX_PAGES = 100; - let allItems = []; + let allItems: any[] = []; let pageIndex = 0; let hasMore = true; while (hasMore && pageIndex < MAX_PAGES) { - const data = await withRetry( + const data: any = await withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_items?project_id=${projectId}&page_size=${pageSize}&page_index=${pageIndex}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: `PingCode:getWorkItems(page=${pageIndex})` } + { maxRetries: 2, label: `PingCode:getWorkItems(page=${pageIndex})` }, ); const items = Array.isArray(data) ? data : (data?.values || []); @@ -171,7 +186,7 @@ export async function getWorkItems(token, projectId, domain) { if (pageIndex >= MAX_PAGES) { console.warn( - `[PingCode] getWorkItems 达到最大分页限制 ${MAX_PAGES}(项目 ${projectId}),可能存在超过 ${MAX_PAGES * pageSize} 条工作项被截断` + `[PingCode] getWorkItems 达到最大分页限制 ${MAX_PAGES}(项目 ${projectId}),可能存在超过 ${MAX_PAGES * pageSize} 条工作项被截断`, ); } @@ -179,17 +194,17 @@ export async function getWorkItems(token, projectId, domain) { } /** 获取工作项类型列表 */ -export async function getWorkItemTypes(token, projectId, domain) { +export async function getWorkItemTypes(token: string, projectId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_item/types?project_id=${encodeURIComponent(projectId)}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getWorkItemTypes' } + { maxRetries: 2, label: 'PingCode:getWorkItemTypes' }, ); } @@ -197,78 +212,69 @@ export async function getWorkItemTypes(token, projectId, domain) { * 获取项目成员列表(用于将 assignee_name 解析为 assignee_id) * PingCode API: GET /v1/project/projects/{projectId}/members */ -export async function getProjectMembers(token, projectId, domain) { +export async function getProjectMembers(token: string, projectId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/projects/${encodeURIComponent(projectId)}/members`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getProjectMembers' } + { maxRetries: 2, label: 'PingCode:getProjectMembers' }, ); } /** 获取工作项状态列表 */ -export async function getWorkItemStates(token, projectId, workItemTypeId, domain) { +export async function getWorkItemStates(token: string, projectId: string, workItemTypeId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_item/states?project_id=${encodeURIComponent(projectId)}&work_item_type_id=${encodeURIComponent(workItemTypeId)}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getWorkItemStates' } + { maxRetries: 2, label: 'PingCode:getWorkItemStates' }, ); } /** 获取工作项属性列表 */ -export async function getWorkItemProperties(token, projectId, workItemTypeId, domain) { +export async function getWorkItemProperties(token: string, projectId: string, workItemTypeId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_item/properties?project_id=${encodeURIComponent(projectId)}&work_item_type_id=${encodeURIComponent(workItemTypeId)}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getWorkItemProperties' } + { maxRetries: 2, label: 'PingCode:getWorkItemProperties' }, ); } /** 获取工作项优先级列表 */ -export async function getWorkItemPriorities(token, projectId, domain) { +export async function getWorkItemPriorities(token: string, projectId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_item/priorities?project_id=${encodeURIComponent(projectId)}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getWorkItemPriorities' } + { maxRetries: 2, label: 'PingCode:getWorkItemPriorities' }, ); } /** * 创建项目 - * @param {string} token - 访问令牌 - * @param {object} projectData - 项目数据 - * @param {string} projectData.name - 项目名称(必填) - * @param {string} projectData.type - 项目类型(必填): kanban, scrum, waterfall, hybrid - * @param {string} projectData.identifier - 项目标识(必填): 大写字母/数字/下划线/连接线,不超过15字符 - * @param {string} [projectData.description] - 项目描述 - * @param {string} [projectData.assignee_id] - 项目负责人 ID - * @param {string} domain - 域名 - * @returns {Promise} 创建的项目信息 */ -export async function createProject(token, projectData, domain) { +export async function createProject(token: string, projectData: Record, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { @@ -277,16 +283,16 @@ export async function createProject(token, projectData, domain) { }); return res.data; }, - { maxRetries: 2, label: 'PingCode:createProject' } + { maxRetries: 2, label: 'PingCode:createProject' }, ); } /** * 根据名称查找项目(精确匹配或模糊匹配) */ -export async function findProjectByName(token, projectName, domain) { +export async function findProjectByName(token: string, projectName: string, domain?: string): Promise { const projectsRes = await getProjects(token, domain); - const projectList = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); + const projectList: any[] = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); // 精确匹配 const exactMatch = projectList.find((p) => p.name === projectName); @@ -294,48 +300,43 @@ export async function findProjectByName(token, projectName, domain) { // 模糊匹配(忽略大小写) const fuzzyMatch = projectList.find( - (p) => p.name.toLowerCase() === projectName.toLowerCase() + (p) => p.name.toLowerCase() === projectName.toLowerCase(), ); return fuzzyMatch || null; } /** * 更新单个工作项(P3-5.2) - * @param {string} token - 访问令牌 - * @param {string} workItemId - 工作项 ID - * @param {object} updateData - 需要更新的字段(如 title/description/state_id/priority_id/assignee_id 等) - * @param {string} domain - 域名 - * @returns {Promise} 更新后的工作项 */ -export async function updateWorkItem(token, workItemId, updateData, domain) { +export async function updateWorkItem(token: string, workItemId: string, updateData: Record, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.patch( `${apiBase}/project/work_items/${encodeURIComponent(workItemId)}`, updateData, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:updateWorkItem' } + { maxRetries: 2, label: 'PingCode:updateWorkItem' }, ); } /** * 获取单个工作项详情(P3-5.2) */ -export async function getWorkItemDetail(token, workItemId, domain) { +export async function getWorkItemDetail(token: string, workItemId: string, domain?: string): Promise { const apiBase = getApiBase(domain); return withRetry( async () => { const res = await axios.get( `${apiBase}/project/work_items/${encodeURIComponent(workItemId)}`, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); return res.data; }, - { maxRetries: 2, label: 'PingCode:getWorkItemDetail' } + { maxRetries: 2, label: 'PingCode:getWorkItemDetail' }, ); } @@ -344,17 +345,16 @@ export async function getWorkItemDetail(token, workItemId, domain) { * * 并发度由 PINGCODE_IMPORT_CONCURRENCY 控制(默认 3,可在 API 限流范围内提速)。 * 进度回调仍按完成顺序触发,current 为累计已完成数量。 - * - * @param {string} token - PingCode access token - * @param {Array} items - 工作项数据数组,每项需包含 _local_id 用于关联本地记录 - * @param {string} domain - PingCode 域名 - * @param {Function} [onProgress] - 进度回调 (current, total, item) => void - * @returns {Object} 包含 success, failed, errors, created 的结果对象 */ -export async function createWorkItemsBatch(token, items, domain, onProgress) { +export async function createWorkItemsBatch( + token: string, + items: any[], + domain: string, + onProgress?: (current: number, total: number, item: BatchProgressItem) => void, +): Promise { const apiBase = getApiBase(domain); const concurrency = Math.max(1, parseInt(process.env.PINGCODE_IMPORT_CONCURRENCY || '3', 10)); - const results = { + const results: BatchCreateResult = { success: 0, failed: 0, errors: [], @@ -369,7 +369,7 @@ export async function createWorkItemsBatch(token, items, domain, onProgress) { const { _local_id, ...pingcodeItem } = item; try { - let createdItem = null; + let createdItem: any = null; await withRetry( async () => { const response = await axios.post(`${apiBase}/project/work_items`, pingcodeItem, { @@ -377,7 +377,7 @@ export async function createWorkItemsBatch(token, items, domain, onProgress) { }); createdItem = response.data; }, - { maxRetries: 2, label: 'PingCode:createWorkItem' } + { maxRetries: 2, label: 'PingCode:createWorkItem' }, ); results.success++; results.created.push({ @@ -389,7 +389,7 @@ export async function createWorkItemsBatch(token, items, domain, onProgress) { if (onProgress) { onProgress(++completed, total, { title: pingcodeItem.title, status: 'success' }); } - } catch (e) { + } catch (e: any) { results.failed++; const errorDetail = e.response?.data?.message || e.response?.data?.error || e.message; results.errors.push({ @@ -411,17 +411,17 @@ export async function createWorkItemsBatch(token, items, domain, onProgress) { /* ---- 用户信息 ---- */ /** 从 JWT 中解析用户 ID */ -export function getUserIdFromToken(token) { +export function getUserIdFromToken(token: string): string | null { try { - const decoded = jwtDecode(token); - return decoded.sub || decoded.uid || decoded.user_id; + const decoded = jwtDecode(token) as Record; + return (decoded.sub || decoded.uid || decoded.user_id) as string | null; } catch { return null; } } /** 通过 API 获取用户信息(directory/users/me) */ -export async function fetchUserInfo(token, domain) { +export async function fetchUserInfo(token: string, domain?: string): Promise { try { const apiBase = getApiBase(domain); const res = await axios.get(`${apiBase}/directory/users/me`, { @@ -434,7 +434,7 @@ export async function fetchUserInfo(token, domain) { } /** 获取当前登录用户信息(GET /v1/myself) */ -export async function getMyself(token, domain) { +export async function getMyself(token: string, domain?: string): Promise { try { const apiBase = getApiBase(domain); const res = await axios.get(`${apiBase}/myself`, { @@ -442,7 +442,7 @@ export async function getMyself(token, domain) { }); return res.data; } catch (e) { - console.warn('[PingCode] getMyself failed:', e.message); + console.warn('[PingCode] getMyself failed:', (e as Error).message); return null; } } diff --git a/backend/src/services/statsAnalyzer.js b/backend/src/services/statsAnalyzer.ts similarity index 90% rename from backend/src/services/statsAnalyzer.js rename to backend/src/services/statsAnalyzer.ts index 58a0e4c..c4402e8 100644 --- a/backend/src/services/statsAnalyzer.js +++ b/backend/src/services/statsAnalyzer.ts @@ -7,7 +7,7 @@ import { ModelConfig } from '../models/index.js'; const statsPrompt = PromptTemplate.fromTemplate(ANALYZE_STATS_PROMPT); const stringParser = new StringOutputParser(); -function createOpenAIModel(config) { +function createOpenAIModel(config: any) { return new ChatOpenAI({ configuration: { baseURL: config.base_url || undefined, @@ -19,7 +19,7 @@ function createOpenAIModel(config) { }); } -async function createAnthropicModel(config) { +async function createAnthropicModel(config: any) { const mod = await import('@langchain/anthropic'); return new mod.ChatAnthropic({ anthropicApiKey: config.api_key, @@ -30,7 +30,7 @@ async function createAnthropicModel(config) { }); } -async function getModelInstance(userId) { +async function getModelInstance(userId: string): Promise { let config = await ModelConfig.findOne({ where: { user_id: userId, is_default: true }, }); @@ -55,7 +55,7 @@ async function getModelInstance(userId) { /** * 使用 AI 分析项目统计数据,生成 Markdown 格式的分析报告 */ -export async function analyzeProjectStats(statsData, userId) { +export async function analyzeProjectStats(statsData: any, userId: string): Promise { const model = await getModelInstance(userId); const chain = statsPrompt.pipe(model).pipe(stringParser); diff --git a/backend/src/services/uploadCleanup.js b/backend/src/services/uploadCleanup.ts similarity index 75% rename from backend/src/services/uploadCleanup.js rename to backend/src/services/uploadCleanup.ts index ceb5416..b264a41 100644 --- a/backend/src/services/uploadCleanup.js +++ b/backend/src/services/uploadCleanup.ts @@ -22,26 +22,37 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEMAND_DIR = path.resolve(__dirname, '../../uploads/demand'); const DEFAULT_TTL_HOURS = 30 * 24; // 30 天 +export interface CleanupResult { + deleted: number; + skipped: number; + errors: number; +} + +export interface CleanupOptions { + /** 文件保留时长(小时) */ + ttlHours?: number; + /** 当前时间(供测试注入) */ + now?: Date; + /** 目录路径(供测试注入) */ + dir?: string; +} + /** * 清理过期的 demand 文件。 - * @param {object} [options] - * @param {number} [options.ttlHours] - 文件保留时长(小时) - * @param {Date} [options.now] - 当前时间(供测试注入) - * @returns {Promise<{deleted: number, skipped: number, errors: number}>} */ -export async function cleanupDemandFiles(options = {}) { +export async function cleanupDemandFiles(options: CleanupOptions = {}): Promise { const ttlHours = options.ttlHours ?? (Number(process.env.DEMAND_FILE_TTL_HOURS) || DEFAULT_TTL_HOURS); const now = options.now || new Date(); const dir = options.dir || DEMAND_DIR; const ttlMs = ttlHours * 3600 * 1000; - const result = { deleted: 0, skipped: 0, errors: 0 }; + const result: CleanupResult = { deleted: 0, skipped: 0, errors: 0 }; - let files; + let files: string[]; try { files = await fs.readdir(dir); } catch (e) { - if (e.code === 'ENOENT') return result; // 目录不存在,无需清理 + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return result; // 目录不存在,无需清理 throw e; } @@ -54,7 +65,7 @@ export async function cleanupDemandFiles(options = {}) { referencedRecords .map((r) => r.original_file_path) .filter(Boolean) - .map((p) => path.normalize(p)) + .map((p) => path.normalize(p as string)), ); for (const file of files) { @@ -72,7 +83,7 @@ export async function cleanupDemandFiles(options = {}) { } else { result.skipped++; } - } catch (e) { + } catch { // 文件可能已被删除或无权限,记录错误继续 result.errors++; } @@ -85,10 +96,11 @@ export async function cleanupDemandFiles(options = {}) { * 启动定时清理任务(每小时执行一次)。 * 在服务启动时调用,返回定时器引用以便测试或优雅关闭。 */ -export function startCleanupSchedule(intervalMs = 60 * 60 * 1000) { +export function startCleanupSchedule( + intervalMs = 60 * 60 * 1000, +): { initialTimer: NodeJS.Timeout; timer: NodeJS.Timeout } { // 首次延迟 5 分钟执行,避免启动时立即触发 IO const initialDelay = 5 * 60 * 1000; - let timer; const runOnce = async () => { try { @@ -97,12 +109,12 @@ export function startCleanupSchedule(intervalMs = 60 * 60 * 1000) { console.log(`[UploadCleanup] 清理 ${result.deleted} 个过期需求文档`); } } catch (e) { - console.error('[UploadCleanup] 清理失败:', e.message); + console.error('[UploadCleanup] 清理失败:', (e as Error).message); } }; const initialTimer = setTimeout(runOnce, initialDelay); - timer = setInterval(runOnce, intervalMs); + const timer = setInterval(runOnce, intervalMs); return { initialTimer, timer }; } From 89ad14afe28be1ea31e5edfccb19f571bf9ccde8 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 12:58:48 +0800 Subject: [PATCH 07/12] =?UTF-8?q?build(backend):=20Phase=205=20=E2=80=94?= =?UTF-8?q?=20migrate=20middleware=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 6 middleware files (.js → .ts). Logic unchanged. - auth.ts: JwtPayload cast, sliding-refresh logic typed; Sequelize include association (ur.Role) accessed via cast - permission.ts: requireAdmin / requirePermission typed - tokenRefresh.ts: grant-type helpers take User; ensureFreshToken typed - errorHandler.ts: HttpError interface, createError(): HttpError - logger.ts, rateLimit.ts: typed handlers / inferred limiters req.user typing — deviation from plan: the plan prescribed a global `declare module 'express-serve-static-core'` augmentation, but this project's @types/express does NOT install express-serve-static-core as a resolvable module, so a `declare module` creates an empty module that clobbers express's native Request (every property vanishes). Also, a module-form .d.ts (top-level import) is not auto-loaded by tsconfig include. Fix: a plain exported AuthedRequest interface (src/types/authRequest.ts) that extends express's Request. Under strict:false, handler param bivariance lets it register as a RequestHandler. Routes (Phase 6) will reuse AuthedRequest for req.user. Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Co-Authored-By: Claude Fable 5 --- backend/src/middleware/{auth.js => auth.ts} | 28 +++++++--- .../{errorHandler.js => errorHandler.ts} | 17 ++++-- .../src/middleware/{logger.js => logger.ts} | 4 +- .../{permission.js => permission.ts} | 36 ++++++++----- .../middleware/{rateLimit.js => rateLimit.ts} | 0 .../{tokenRefresh.js => tokenRefresh.ts} | 54 +++++++++++-------- backend/src/types/authRequest.ts | 18 +++++++ 7 files changed, 110 insertions(+), 47 deletions(-) rename backend/src/middleware/{auth.js => auth.ts} (70%) rename backend/src/middleware/{errorHandler.js => errorHandler.ts} (61%) rename backend/src/middleware/{logger.js => logger.ts} (74%) rename backend/src/middleware/{permission.js => permission.ts} (54%) rename backend/src/middleware/{rateLimit.js => rateLimit.ts} (100%) rename backend/src/middleware/{tokenRefresh.js => tokenRefresh.ts} (73%) create mode 100644 backend/src/types/authRequest.ts diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.ts similarity index 70% rename from backend/src/middleware/auth.js rename to backend/src/middleware/auth.ts index 961c0d6..dd67d77 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.ts @@ -1,6 +1,8 @@ import jwt from 'jsonwebtoken'; import { User, Role, UserRole } from '../models/index.js'; import { appConfig } from '../config/index.js'; +import type { Response, NextFunction } from 'express'; +import type { AuthedRequest } from '../types/authRequest.js'; /** 当 token 剩余有效期不足 25% 时,自动签发新 token 通过响应头下发(滑动续期 P2-4.3) */ const REFRESH_THRESHOLD = 0.25; // 剩余 < 25% 时续期 @@ -9,6 +11,14 @@ const MAX_SESSION_LIFETIME_SEC = 30 * 24 * 3600; /** 单次 token 有效期 */ const TOKEN_TTL = '7d'; +/** requireAuth 解码后的 JWT 载荷 */ +interface AuthTokenPayload { + id: string; + exp?: number; + iat?: number; + first_iat?: number; +} + /** * JWT 认证中间件 * 从 Authorization header 提取 Bearer token 并验证。 @@ -17,19 +27,21 @@ const TOKEN_TTL = '7d'; * * 安全约束:自首次签发(first_iat)起超过 30 天后不再续期,强制重新认证。 */ -export const requireAuth = async (req, res, next) => { +export const requireAuth = async (req: AuthedRequest, res: Response, next: NextFunction): Promise => { const authHeader = req.headers['authorization']; - const token = authHeader?.split(' ')[1]; + const token = (authHeader as string | undefined)?.split(' ')[1]; if (!token) { - return res.status(401).json({ success: false, error: '未提供认证令牌' }); + res.status(401).json({ success: false, error: '未提供认证令牌' }); + return; } try { - const decoded = jwt.verify(token, appConfig.jwt.secret); + const decoded = jwt.verify(token, appConfig.jwt.secret) as AuthTokenPayload; const user = await User.findByPk(decoded.id); if (!user) { - return res.status(401).json({ success: false, error: '用户不存在' }); + res.status(401).json({ success: false, error: '用户不存在' }); + return; } // 获取用户角色 @@ -38,7 +50,7 @@ export const requireAuth = async (req, res, next) => { include: [Role], }); - const roles = userRoles.map(ur => ur.Role.name); + const roles = userRoles.map((ur) => (ur as UserRole & { Role?: Role }).Role?.name).filter((n): n is string => !!n); const isAdmin = roles.includes('admin'); req.user = user; @@ -60,7 +72,7 @@ export const requireAuth = async (req, res, next) => { const refreshedToken = jwt.sign( { id: user.id, username: user.username, roles, isAdmin, first_iat: firstIat }, appConfig.jwt.secret, - { expiresIn: TOKEN_TTL } + { expiresIn: TOKEN_TTL }, ); res.setHeader('X-Refreshed-Token', refreshedToken); } @@ -68,6 +80,6 @@ export const requireAuth = async (req, res, next) => { next(); } catch { - return res.status(401).json({ success: false, error: '认证令牌无效或已过期' }); + res.status(401).json({ success: false, error: '认证令牌无效或已过期' }); } }; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.ts similarity index 61% rename from backend/src/middleware/errorHandler.js rename to backend/src/middleware/errorHandler.ts index 9ca4807..95da6ee 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.ts @@ -1,10 +1,21 @@ +import type { Request, Response, NextFunction } from 'express'; import { appConfig } from '../config/index.js'; +/** 带状态码的错误 */ +export interface HttpError extends Error { + status: number; +} + /** * 统一错误处理中间件 * 放在所有路由之后注册 */ -export const errorHandler = (err, req, res, _next) => { +export const errorHandler = ( + err: HttpError & Record, + req: Request, + res: Response, + _next: NextFunction, +): void => { const status = err.status || 500; const message = err.message || '服务器内部错误'; @@ -30,8 +41,8 @@ export const errorHandler = (err, req, res, _next) => { /** * 创建一个带状态码的错误 */ -export function createError(message, status = 500) { - const err = new Error(message); +export function createError(message: string, status = 500): HttpError { + const err = new Error(message) as HttpError; err.status = status; return err; } diff --git a/backend/src/middleware/logger.js b/backend/src/middleware/logger.ts similarity index 74% rename from backend/src/middleware/logger.js rename to backend/src/middleware/logger.ts index c94832f..47020aa 100644 --- a/backend/src/middleware/logger.js +++ b/backend/src/middleware/logger.ts @@ -1,8 +1,10 @@ +import type { Request, Response, NextFunction } from 'express'; + /** * 请求日志中间件 * 记录请求方法、路径、状态码、耗时 */ -export const requestLogger = (req, res, next) => { +export const requestLogger = (req: Request, res: Response, next: NextFunction): void => { const start = Date.now(); const { method, originalUrl } = req; diff --git a/backend/src/middleware/permission.js b/backend/src/middleware/permission.ts similarity index 54% rename from backend/src/middleware/permission.js rename to backend/src/middleware/permission.ts index 9c7e57d..584eefe 100644 --- a/backend/src/middleware/permission.js +++ b/backend/src/middleware/permission.ts @@ -1,16 +1,20 @@ +import type { Response, NextFunction } from 'express'; +import type { AuthedRequest } from '../types/authRequest.js'; import { Role, UserRole, Permission, RolePermission } from '../models/index.js'; /** * 检查用户是否为 admin 的中间件 */ -export async function requireAdmin(req, res, next) { +export async function requireAdmin(req: AuthedRequest, res: Response, next: NextFunction): Promise { try { if (!req.user) { - return res.status(401).json({ success: false, error: '未登录' }); + res.status(401).json({ success: false, error: '未登录' }); + return; } if (req.isAdmin) { - return next(); + next(); + return; } const userRoles = await UserRole.findAll({ @@ -19,7 +23,8 @@ export async function requireAdmin(req, res, next) { }); if (userRoles.length === 0) { - return res.status(403).json({ success: false, error: '需要管理员权限' }); + res.status(403).json({ success: false, error: '需要管理员权限' }); + return; } req.isAdmin = true; @@ -31,17 +36,19 @@ export async function requireAdmin(req, res, next) { /** * 检查用户是否有指定权限的中间件 - * @param {string} permissionName - 权限名称,格式为 "resource.action",如 "users.manage" + * @param permissionName - 权限名称,格式为 "resource.action",如 "users.manage" */ -export function requirePermission(permissionName) { - return async (req, res, next) => { +export function requirePermission(permissionName: string) { + return async (req: AuthedRequest, res: Response, next: NextFunction): Promise => { try { if (!req.user) { - return res.status(401).json({ success: false, error: '未登录' }); + res.status(401).json({ success: false, error: '未登录' }); + return; } if (req.isAdmin) { - return next(); + next(); + return; } const adminRole = await UserRole.findOne({ @@ -51,7 +58,8 @@ export function requirePermission(permissionName) { if (adminRole) { req.isAdmin = true; - return next(); + next(); + return; } const userRoles = await UserRole.findAll({ @@ -60,10 +68,11 @@ export function requirePermission(permissionName) { }); if (userRoles.length === 0) { - return res.status(403).json({ success: false, error: `缺少权限: ${permissionName}` }); + res.status(403).json({ success: false, error: `缺少权限: ${permissionName}` }); + return; } - const roleIds = userRoles.map(ur => ur.role_id); + const roleIds = userRoles.map((ur) => ur.role_id); const rolePermission = await RolePermission.findOne({ where: { role_id: roleIds }, include: [{ @@ -73,7 +82,8 @@ export function requirePermission(permissionName) { }); if (!rolePermission) { - return res.status(403).json({ success: false, error: `缺少权限: ${permissionName}` }); + res.status(403).json({ success: false, error: `缺少权限: ${permissionName}` }); + return; } next(); diff --git a/backend/src/middleware/rateLimit.js b/backend/src/middleware/rateLimit.ts similarity index 100% rename from backend/src/middleware/rateLimit.js rename to backend/src/middleware/rateLimit.ts diff --git a/backend/src/middleware/tokenRefresh.js b/backend/src/middleware/tokenRefresh.ts similarity index 73% rename from backend/src/middleware/tokenRefresh.js rename to backend/src/middleware/tokenRefresh.ts index a8099a5..a2e4035 100644 --- a/backend/src/middleware/tokenRefresh.js +++ b/backend/src/middleware/tokenRefresh.ts @@ -1,3 +1,5 @@ +import type { Response, NextFunction } from 'express'; +import type { AuthedRequest } from '../types/authRequest.js'; import { refreshAccessToken, getEnterpriseToken, @@ -5,18 +7,19 @@ import { PINGCODE_GRANT_AUTHORIZATION_CODE, PINGCODE_GRANT_CLIENT_CREDENTIALS, } from '../services/pingcode.js'; +import type { User } from '../models/index.js'; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; -function grantTypeOf(user) { +function grantTypeOf(user: User): string { return user.pingcode_grant_type || PINGCODE_GRANT_AUTHORIZATION_CODE; } -async function refreshUserOAuthToken(user) { - const tokenData = await refreshAccessToken( - user.refresh_token, - user.pingcode_client_id, - user.pingcode_client_secret +async function refreshUserOAuthToken(user: User): Promise { + const tokenData: any = await refreshAccessToken( + user.refresh_token!, + user.pingcode_client_id!, + user.pingcode_client_secret!, ); const { access_token, refresh_token, expires_in } = tokenData; await user.update({ @@ -26,10 +29,10 @@ async function refreshUserOAuthToken(user) { }); } -async function refreshEnterpriseToken(user) { - const tokenData = await getEnterpriseToken( - user.pingcode_client_id, - user.pingcode_client_secret +async function refreshEnterpriseToken(user: User): Promise { + const tokenData: any = await getEnterpriseToken( + user.pingcode_client_id!, + user.pingcode_client_secret!, ); const { access_token: accessToken } = tokenData; if (!accessToken) { @@ -49,73 +52,80 @@ async function refreshEnterpriseToken(user) { * 刷新失败时返回明确的 401(带 code: 'PINGCODE_AUTH_EXPIRED'),引导前端在保持本地登录态的前提下 * 提示用户重新连接 PingCode,而非静默放行导致下游报错链路长且不友好。 */ -export async function ensureFreshToken(req, res, next) { +export async function ensureFreshToken(req: AuthedRequest, res: Response, next: NextFunction): Promise { try { const user = req.user; if (!user || !user.access_token || !user.expires_at) { - return next(); + next(); + return; } const expiresAt = new Date(user.expires_at).getTime(); const now = Date.now(); if (now < expiresAt - TOKEN_REFRESH_BUFFER_MS) { - return next(); + next(); + return; } const mode = grantTypeOf(user); if (mode === PINGCODE_GRANT_CLIENT_CREDENTIALS) { if (!user.pingcode_client_id || !user.pingcode_client_secret) { - return res.status(401).json({ + res.status(401).json({ success: false, code: 'PINGCODE_AUTH_EXPIRED', error: 'PingCode 企业凭证不完整,请重新配置 Client ID 与 Secret', }); + return; } console.log(`[TokenRefresh] 企业令牌即将过期,正在为用户 ${user.id} 重新获取...`); try { await refreshEnterpriseToken(user); } catch (refreshErr) { - console.error('[TokenRefresh] 企业令牌获取失败:', refreshErr.message); - return res.status(401).json({ + console.error('[TokenRefresh] 企业令牌获取失败:', (refreshErr as Error).message); + res.status(401).json({ success: false, code: 'PINGCODE_AUTH_EXPIRED', error: 'PingCode 企业授权已过期或凭证无效,请重新配置', }); + return; } await user.reload(); req.user = user; console.log(`[TokenRefresh] 用户 ${user.id} 企业令牌已更新`); - return next(); + next(); + return; } if (!user.refresh_token || !user.pingcode_client_id || !user.pingcode_client_secret) { - return res.status(401).json({ + res.status(401).json({ success: false, code: 'PINGCODE_AUTH_EXPIRED', error: 'PingCode 授权已过期且缺少刷新凭证,请重新连接 PingCode', }); + return; } console.log(`[TokenRefresh] Token 即将过期,正在为用户 ${user.id} 刷新...`); try { await refreshUserOAuthToken(user); } catch (refreshErr) { - console.error('[TokenRefresh] Token 刷新失败:', refreshErr.message); - return res.status(401).json({ + console.error('[TokenRefresh] Token 刷新失败:', (refreshErr as Error).message); + res.status(401).json({ success: false, code: 'PINGCODE_AUTH_EXPIRED', error: 'PingCode 授权已过期,请重新连接 PingCode', }); + return; } await user.reload(); req.user = user; console.log(`[TokenRefresh] 用户 ${user.id} Token 刷新成功`); next(); } catch (e) { - console.error('[TokenRefresh] 中间件异常:', e.message); - return res.status(401).json({ + console.error('[TokenRefresh] 中间件异常:', (e as Error).message); + res.status(401).json({ success: false, code: 'PINGCODE_AUTH_EXPIRED', error: 'PingCode 授权状态异常,请重新连接', diff --git a/backend/src/types/authRequest.ts b/backend/src/types/authRequest.ts new file mode 100644 index 0000000..1fa7c2f --- /dev/null +++ b/backend/src/types/authRequest.ts @@ -0,0 +1,18 @@ +import type { Request } from 'express'; +import type { User } from '../models/User.js'; + +/** + * 带 auth 上下文的 Express Request。 + * + * requireAuth 中间件注入 user / userRoles / isAdmin。 + * + * 采用显式接口而非全局 `declare module` 增强:本项目的 @types/express + * 未独立安装 express-serve-static-core,全局增强会意外创建空模块、破坏 + * express 原生类型;显式接口可移植且无此副作用。strict:false 下函数参数 + * 双变,`(req: AuthedRequest) => ...` 可作为 RequestHandler 注册。 + */ +export interface AuthedRequest extends Request { + user?: User; + userRoles?: string[]; + isAdmin?: boolean; +} From 6384e08aa0e208bae14190df1be23a9f178ef5c8 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 13:20:20 +0800 Subject: [PATCH 08/12] =?UTF-8?q?build(backend):=20Phase=206=20=E2=80=94?= =?UTF-8?q?=20migrate=20routes=20layer=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 12 route files (.js → .ts). Behavior unchanged; 176/176 tests green (including records/workItems whose tests mock express + workItem utils). - All authed handlers take AuthedRequest (req.user/req.isAdmin); plain routes use Request. Express 5 types req.params.* as string|string[] and req.body-sourced values surface as unknown in some positions — applied targeted `as string`/`as any[]` casts where needed. - Dynamic Sequelize where objects typed Record / any to allow Op-prefixed (symbol) keys and literal-union value friction. - Sequelize include associations (ur.Role, rp.Permission) accessed via cast. - workItems.ts: ImportResults interface, ImportRecordStatus-typed finalStatus, Map grouping; seekdb query results kept any. Supporting tweaks (routes revealed these): - utils/syncCompare: drop [key:string] index sig from RemoteItem/ SyncedRecord so SyncedProject/SyncedWorkItem class instances are structurally assignable to SyncedRecord - services/auditLog: AuditEntry/Record.detail loosened Record<…> → object (ClearSyncedResult has no index signature) - services/pingcode: BatchCreateResult.errors/created members unknown → any (flow into Sequelize where clauses) Verified: tsc --noEmit (0 errors) · pnpm test (176/176). Co-Authored-By: Claude Fable 5 --- backend/src/routes/{analyze.js => analyze.ts} | 54 +++--- backend/src/routes/{auth.js => auth.ts} | 42 +++-- backend/src/routes/{config.js => config.ts} | 13 +- .../src/routes/{localAuth.js => localAuth.ts} | 13 +- .../src/routes/{metadata.js => metadata.ts} | 80 +++++---- backend/src/routes/{models.js => models.ts} | 46 ++--- backend/src/routes/{records.js => records.ts} | 75 ++++---- backend/src/routes/{roles.js => roles.ts} | 26 +-- backend/src/routes/{stats.js => stats.ts} | 128 ++++++++------ backend/src/routes/{sync.js => sync.ts} | 71 ++++---- backend/src/routes/{users.js => users.ts} | 50 +++--- .../src/routes/{workItems.js => workItems.ts} | 166 ++++++++++-------- backend/src/services/auditLog.ts | 4 +- backend/src/services/pingcode.ts | 4 +- backend/src/utils/syncCompare.ts | 4 +- 15 files changed, 422 insertions(+), 354 deletions(-) rename backend/src/routes/{analyze.js => analyze.ts} (88%) rename backend/src/routes/{auth.js => auth.ts} (79%) rename backend/src/routes/{config.js => config.ts} (80%) rename backend/src/routes/{localAuth.js => localAuth.ts} (86%) rename backend/src/routes/{metadata.js => metadata.ts} (58%) rename backend/src/routes/{models.js => models.ts} (81%) rename backend/src/routes/{records.js => records.ts} (77%) rename backend/src/routes/{roles.js => roles.ts} (77%) rename backend/src/routes/{stats.js => stats.ts} (71%) rename backend/src/routes/{sync.js => sync.ts} (85%) rename backend/src/routes/{users.js => users.ts} (79%) rename backend/src/routes/{workItems.js => workItems.ts} (81%) diff --git a/backend/src/routes/analyze.js b/backend/src/routes/analyze.ts similarity index 88% rename from backend/src/routes/analyze.js rename to backend/src/routes/analyze.ts index 03447a3..035e032 100644 --- a/backend/src/routes/analyze.js +++ b/backend/src/routes/analyze.ts @@ -1,10 +1,12 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import multer from 'multer'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from 'uuid'; import { requireAuth } from '../middleware/auth.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { parseFile } from '../services/parser.js'; import { analyzeRequirements, analyzeRequirementsStream } from '../services/agent.js'; import { ImportRecord, ImportRecordItem } from '../models/index.js'; @@ -31,12 +33,12 @@ const uploadMulti = multer({ * 解码文件名(处理 UTF-8 编码的中文文件名) * Multer 使用 busboy 解析 multipart,originalname 可能是 Latin1 编码 */ -function decodeFileName(originalname) { +function decodeFileName(originalname: string): string { try { // 尝试将 Latin1 编码的字符串转换为 UTF-8 const decoded = Buffer.from(originalname, 'latin1').toString('utf8'); // 检查解码后是否为有效的 UTF-8 字符串 - if (decoded && !decoded.includes('\ufffd')) { + if (decoded && !decoded.includes('�')) { return decoded; } return originalname; @@ -48,12 +50,12 @@ function decodeFileName(originalname) { /** * 生成安全的文件名(移除不允许的字符) */ -function sanitizeFileName(fileName) { +function sanitizeFileName(fileName: string): string { return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); } /** 上传文件并分析需求 */ -router.post('/analyze', requireAuth, upload.single('file'), async (req, res, next) => { +router.post('/analyze', requireAuth, upload.single('file'), async (req: AuthedRequest, res: Response, next: NextFunction) => { if (!req.file) { return res.status(400).json({ success: false, error: '请上传文件' }); } @@ -76,21 +78,21 @@ router.post('/analyze', requireAuth, upload.single('file'), async (req, res, nex await fs.mkdir(DEMAND_DIR, { recursive: true }); await fs.writeFile(demandFilePath, text, 'utf-8'); - const requirements = await analyzeRequirements(text, req.user.id); + const requirements = await analyzeRequirements(text, req.user!.id); // 为每条需求添加唯一 ID const data = requirements.map((item) => ({ ...item, id: uuidv4(), status: 'new', - })); + })) as any[]; // 创建导入记录 - const uniqueProjectNames = [...new Set(requirements.map(r => r.project_name))]; + const uniqueProjectNames = [...new Set(requirements.map((r) => r.project_name))]; const recordId = uuidv4(); const record = await ImportRecord.create({ id: recordId, - user_id: req.user.id, + user_id: req.user!.id, file_name: fileName, original_file_path: demandFilePath, requirements_count: requirements.length, @@ -117,7 +119,7 @@ router.post('/analyze', requireAuth, upload.single('file'), async (req, res, nex solution_suggestion: item.solution_suggestion || '', status: 'pending', })); - await ImportRecordItem.bulkCreate(recordItems); + await ImportRecordItem.bulkCreate(recordItems as any); } // 在响应中返回记录ID @@ -133,7 +135,7 @@ router.post('/analyze', requireAuth, upload.single('file'), async (req, res, nex * 流式分析需求文档(P3-5.8)。 * 通过 SSE 推送分析进度和部分结果,大文档分析时前端可实时展示已提取的工作项。 */ -router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, res) => { +router.post('/analyze-stream', requireAuth, upload.single('file'), async (req: AuthedRequest, res: Response) => { if (!req.file) { return res.status(400).json({ success: false, error: '请上传文件' }); } @@ -144,10 +146,10 @@ router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, r res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', }); - function sendEvent(event, data) { + function sendEvent(event: string, data: any): void { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } @@ -164,7 +166,7 @@ router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, r sendEvent('progress', { stage: 'analyzing', message: '正在分析需求文档...' }); - const requirements = await analyzeRequirementsStream(text, req.user.id, (partial) => { + const requirements = await analyzeRequirementsStream(text, req.user!.id, (partial) => { sendEvent('progress', { stage: 'extracting', message: `已提取 ${partial.length} 个工作项...`, @@ -176,13 +178,13 @@ router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, r ...item, id: uuidv4(), status: 'new', - })); + })) as any[]; const uniqueProjectNames = [...new Set(requirements.map((r) => r.project_name))]; const recordId = uuidv4(); const record = await ImportRecord.create({ id: recordId, - user_id: req.user.id, + user_id: req.user!.id, file_name: fileName, original_file_path: demandFilePath, requirements_count: requirements.length, @@ -208,12 +210,12 @@ router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, r solution_suggestion: item.solution_suggestion || '', status: 'pending', })); - await ImportRecordItem.bulkCreate(recordItems); + await ImportRecordItem.bulkCreate(recordItems as any); } sendEvent('complete', { data, record_id: record.id }); res.end(); - } catch (e) { + } catch (e: any) { await fs.unlink(filePath).catch(() => {}); sendEvent('error', { message: e.message }); res.end(); @@ -224,20 +226,20 @@ router.post('/analyze-stream', requireAuth, upload.single('file'), async (req, r * 多文件批量分析(P3-5.7)。 * 接收多个文件,合并解析后统一分析,适合批量上传多个需求文档合并处理的场景。 */ -router.post('/analyze-multi', requireAuth, (req, res, next) => { - uploadMulti(req, res, async (multerErr) => { +router.post('/analyze-multi', requireAuth, (req: AuthedRequest, res: Response, next: NextFunction) => { + uploadMulti(req, res, async (multerErr: any) => { if (multerErr) { return res.status(400).json({ success: false, error: multerErr.message }); } - const files = req.files; + const files = req.files as Express.Multer.File[] | undefined; if (!files || files.length === 0) { return res.status(400).json({ success: false, error: '请上传至少一个文件' }); } try { // 逐个解析文件并合并文本 - const parts = []; - const fileNames = []; + const parts: string[] = []; + const fileNames: string[] = []; for (const file of files) { const decodedName = decodeFileName(file.originalname); const text = await parseFile(file.path, decodedName); @@ -254,18 +256,18 @@ router.post('/analyze-multi', requireAuth, (req, res, next) => { await fs.mkdir(DEMAND_DIR, { recursive: true }); await fs.writeFile(demandFilePath, combinedText, 'utf-8'); - const requirements = await analyzeRequirements(combinedText, req.user.id); + const requirements = await analyzeRequirements(combinedText, req.user!.id); const data = requirements.map((item) => ({ ...item, id: uuidv4(), status: 'new', - })); + })) as any[]; const uniqueProjectNames = [...new Set(requirements.map((r) => r.project_name))]; const recordId = uuidv4(); const record = await ImportRecord.create({ id: recordId, - user_id: req.user.id, + user_id: req.user!.id, file_name: fileNames.join(', '), original_file_path: demandFilePath, requirements_count: requirements.length, @@ -290,7 +292,7 @@ router.post('/analyze-multi', requireAuth, (req, res, next) => { solution_suggestion: item.solution_suggestion || '', status: 'pending', })); - await ImportRecordItem.bulkCreate(recordItems); + await ImportRecordItem.bulkCreate(recordItems as any); } res.json({ success: true, data, record_id: record.id }); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.ts similarity index 79% rename from backend/src/routes/auth.js rename to backend/src/routes/auth.ts index 6abf8f7..2375930 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.ts @@ -1,4 +1,5 @@ import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { getAuthUrl, @@ -12,13 +13,14 @@ import { import { User } from '../models/User.js'; import { requireAuth } from '../middleware/auth.js'; import { appConfig } from '../config/index.js'; +import type { AuthedRequest } from '../types/authRequest.js'; const router = express.Router(); /* ---- 工具函数 ---- */ /** 解析 Cookie 字符串 */ -function parseCookies(cookieHeader) { +function parseCookies(cookieHeader?: string): Record { if (!cookieHeader) return {}; return cookieHeader.split(';').reduce((acc, part) => { const [rawKey, ...rest] = part.trim().split('='); @@ -26,27 +28,32 @@ function parseCookies(cookieHeader) { const value = decodeURIComponent(rest.join('=') || ''); if (key) acc[key] = value; return acc; - }, {}); + }, {} as Record); +} + +/** state JWT 载荷 */ +interface StatePayload { + userId?: string; } /** 通过 state 参数解析用户(state JWT 必须有效且与 cookie 中的 oauth_user_id 一致) */ -async function getUserFromState(state, expectedUserId) { +async function getUserFromState(state: unknown, expectedUserId: string): Promise { if (!state || !expectedUserId) return null; try { - const decoded = jwt.verify(state, appConfig.jwt.secret); + const decoded = jwt.verify(state as string, appConfig.jwt.secret) as StatePayload; // state 中的 userId 必须与 cookie 中的 oauth_user_id 一致(防 CSRF / 篡改攻击) if (decoded.userId !== expectedUserId) return null; - return await User.findByPk(decoded.userId); + return await User.findByPk(decoded.userId!); } catch { return null; } } /** 通过 Cookie 获取用户(辅助 state 解析,不做独立兜底) */ -async function getUserFromOAuthCookies(req) { +async function getUserFromOAuthCookies(req: Request): Promise { const cookies = parseCookies(req.headers.cookie); const userId = cookies.oauth_user_id; - const state = cookies.oauth_state || req.query.state; + const state = cookies.oauth_state || (req.query.state as string | undefined); if (!userId) return null; return await getUserFromState(state, userId); } @@ -57,8 +64,8 @@ async function getUserFromOAuthCookies(req) { * 为当前登录用户生成 PingCode 授权 URL(需要登录态)。 * 移除了原先的公开 /auth/login 入口——任意访客不应能以他人身份发起 OAuth。 */ -router.get('/login-url', requireAuth, (req, res) => { - const user = req.user; +router.get('/login-url', requireAuth, (req: AuthedRequest, res: Response) => { + const user = req.user!; if (user.pingcode_grant_type === PINGCODE_GRANT_CLIENT_CREDENTIALS) { return res.status(400).json({ success: false, error: '当前为企业授权模式,请使用「连接 PingCode」获取企业令牌,勿使用浏览器 OAuth。' }); } @@ -78,13 +85,14 @@ router.get('/login-url', requireAuth, (req, res) => { * OAuth 回调:严格依赖 state JWT + cookie 定位用户,不再使用「取第一个配置了 client_id 的用户」兜底, * 避免多用户部署下越权绑定。 */ -router.get('/callback', async (req, res, next) => { - const { code, state } = req.query; +router.get('/callback', async (req: Request, res: Response, next: NextFunction) => { + const code = req.query.code as string | undefined; + const state = req.query.state; if (!code) return res.status(400).send('缺少授权码'); try { // 严格通过 state JWT + cookie 定位用户,不做公开兜底 - let user = null; + let user: User | null = null; if (state) { // 优先用 query 中的 state(PingCode 回传),但需与 cookie 中的 oauth_user_id 匹配 const cookies = parseCookies(req.headers.cookie); @@ -103,12 +111,12 @@ router.get('/callback', async (req, res, next) => { return res.status(400).send('该用户缺少 PingCode 凭证'); } - const tokenData = await getToken(code, user.pingcode_client_id, user.pingcode_client_secret); + const tokenData: any = await getToken(code, user.pingcode_client_id, user.pingcode_client_secret); const { access_token, refresh_token, expires_in } = tokenData; // 获取 PingCode 用户 ID let pingcodeUid = getUserIdFromToken(access_token); - let domain = req.query.domain || appConfig.pingcode.defaultDomain; + const domain = (req.query.domain as string | undefined) || appConfig.pingcode.defaultDomain; if (!pingcodeUid) { const userInfo = await fetchUserInfo(access_token, domain); @@ -134,9 +142,9 @@ router.get('/callback', async (req, res, next) => { }); /** 企业授权:用 client_credentials 换取 access_token(需已保存凭证与模式) */ -router.post('/pingcode/enterprise-token', requireAuth, async (req, res, next) => { +router.post('/pingcode/enterprise-token', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const user = req.user; + const user = req.user!; if (user.pingcode_grant_type !== PINGCODE_GRANT_CLIENT_CREDENTIALS) { return res.status(400).json({ success: false, @@ -147,7 +155,7 @@ router.post('/pingcode/enterprise-token', requireAuth, async (req, res, next) => return res.status(400).json({ success: false, error: '请先配置 Client ID 与 Client Secret' }); } - const tokenData = await getEnterpriseToken(user.pingcode_client_id, user.pingcode_client_secret); + const tokenData: any = await getEnterpriseToken(user.pingcode_client_id, user.pingcode_client_secret); const { access_token: accessToken } = tokenData; if (!accessToken) { return res.status(502).json({ success: false, error: 'PingCode 未返回 access_token' }); diff --git a/backend/src/routes/config.js b/backend/src/routes/config.ts similarity index 80% rename from backend/src/routes/config.js rename to backend/src/routes/config.ts index 92f8317..9f1578b 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.ts @@ -1,6 +1,9 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { success } from '../utils/response.js'; +import type { AuthedRequest } from '../types/authRequest.js'; +import type { User } from '../models/User.js'; import { PINGCODE_GRANT_AUTHORIZATION_CODE, PINGCODE_GRANT_CLIENT_CREDENTIALS, @@ -13,7 +16,7 @@ const ALLOWED_GRANT_TYPES = new Set([ PINGCODE_GRANT_CLIENT_CREDENTIALS, ]); -function clearPingcodeConnection(user) { +function clearPingcodeConnection(user: User): void { user.access_token = null; user.refresh_token = null; user.expires_at = null; @@ -26,14 +29,14 @@ function clearPingcodeConnection(user) { } /** 保存 PingCode 配置 */ -router.post('/config', requireAuth, async (req, res, next) => { +router.post('/config', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { const { client_id, client_secret, grant_type: bodyGrantType } = req.body; if (!client_id || !client_secret) { return res.status(400).json({ success: false, error: 'Client ID 和 Client Secret 不能为空' }); } try { - const user = req.user; + const user = req.user!; const prevGrant = user.pingcode_grant_type || PINGCODE_GRANT_AUTHORIZATION_CODE; const nextGrant = bodyGrantType ?? prevGrant; @@ -60,9 +63,9 @@ router.post('/config', requireAuth, async (req, res, next) => { }); /** 获取配置信息(含连接状态) */ -router.get('/config', requireAuth, async (req, res, next) => { +router.get('/config', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const user = req.user; + const user = req.user!; res.json({ client_id: user.pingcode_client_id || '', has_secret: !!user.pingcode_client_secret, diff --git a/backend/src/routes/localAuth.js b/backend/src/routes/localAuth.ts similarity index 86% rename from backend/src/routes/localAuth.js rename to backend/src/routes/localAuth.ts index fda0f0e..55c750a 100644 --- a/backend/src/routes/localAuth.js +++ b/backend/src/routes/localAuth.ts @@ -1,4 +1,5 @@ import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { User, Role, UserRole } from '../models/index.js'; @@ -10,7 +11,7 @@ import { loginLimiter, registerLimiter } from '../middleware/rateLimit.js'; const router = express.Router(); /** 注册(限流防批量注册) */ -router.post('/register', registerLimiter, async (req, res, next) => { +router.post('/register', registerLimiter, async (req: Request, res: Response, next: NextFunction) => { const { username, password, email } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: '用户名和密码不能为空' }); @@ -34,7 +35,7 @@ router.post('/register', registerLimiter, async (req, res, next) => { } res.json(success({ id: user.id, username: user.username }, '注册成功')); - } catch (e) { + } catch (e: any) { if (e.name === 'SequelizeUniqueConstraintError') { return res.status(400).json({ success: false, error: '用户名已存在' }); } @@ -43,7 +44,7 @@ router.post('/register', registerLimiter, async (req, res, next) => { }); /** 登录(限流防暴力破解) */ -router.post('/login', loginLimiter, async (req, res, next) => { +router.post('/login', loginLimiter, async (req: Request, res: Response, next: NextFunction) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: '用户名和密码不能为空' }); @@ -66,14 +67,14 @@ router.post('/login', loginLimiter, async (req, res, next) => { include: [Role], }); - const roles = userRoles.map(ur => ur.Role.name); + const roles = userRoles.map((ur) => (ur as UserRole & { Role: Role }).Role.name); const isAdmin = roles.includes('admin'); const token = jwt.sign( // first_iat 标记会话首次签发时间,用于滑动续期时计算绝对最大有效期 { id: user.id, username: user.username, roles, isAdmin, first_iat: Math.floor(Date.now() / 1000) }, appConfig.jwt.secret, - { expiresIn: '7d' } + { expiresIn: '7d' }, ); res.json({ @@ -84,7 +85,7 @@ router.post('/login', loginLimiter, async (req, res, next) => { username: user.username, roles, isAdmin, - } + }, }); } catch (e) { next(e); diff --git a/backend/src/routes/metadata.js b/backend/src/routes/metadata.ts similarity index 58% rename from backend/src/routes/metadata.js rename to backend/src/routes/metadata.ts index a929a03..d4f9a06 100644 --- a/backend/src/routes/metadata.js +++ b/backend/src/routes/metadata.ts @@ -1,6 +1,8 @@ import express from 'express'; -import { requireAuth } from '../middleware/auth.js'; +import type { Response, NextFunction } from 'express'; import { Op } from 'sequelize'; +import { requireAuth } from '../middleware/auth.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { WorkItemType, WorkItemState, @@ -14,36 +16,40 @@ import { success } from '../utils/response.js'; const router = express.Router(); // 获取项目名称映射的辅助函数 -async function getProjectNameMap(userId, projectIds) { +async function getProjectNameMap(userId: string, projectIds: string[]): Promise> { if (!projectIds || projectIds.length === 0) return new Map(); const projects = await SyncedProject.findAll({ where: { user_id: userId, - id: { [Op.in]: projectIds } + id: { [Op.in]: projectIds }, }, - attributes: ['id', 'name'] + attributes: ['id', 'name'], }); - return new Map(projects.map(p => [p.id, p.name])); + return new Map(projects.map((p) => [p.id, p.name])); } // 为结果添加项目名称 -async function enrichWithProjectName(userId, items, projectIdField = 'project_id') { - const projectIds = [...new Set(items.map(item => item[projectIdField]))]; +async function enrichWithProjectName( + userId: string, + items: any[], + projectIdField = 'project_id', +): Promise { + const projectIds = [...new Set(items.map((item) => item[projectIdField]))]; const nameMap = await getProjectNameMap(userId, projectIds); - return items.map(item => ({ + return items.map((item) => ({ ...item.toJSON(), - project_name: nameMap.get(item[projectIdField]) || item[projectIdField] + project_name: nameMap.get(item[projectIdField]) || item[projectIdField], })); } -router.get('/types', requireAuth, async (req, res, next) => { +router.get('/types', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; - const projectId = req.query.project_id; - const where = { user_id: userId }; + const userId = req.user!.id; + const projectId = req.query.project_id as string | undefined; + const where: Record = { user_id: userId }; if (projectId) where.project_id = projectId; const list = await WorkItemType.findAll({ where, order: [['id', 'ASC']] }); @@ -54,11 +60,11 @@ router.get('/types', requireAuth, async (req, res, next) => { } }); -router.get('/states', requireAuth, async (req, res, next) => { +router.get('/states', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; - const projectId = req.query.project_id; - const where = { user_id: userId }; + const userId = req.user!.id; + const projectId = req.query.project_id as string | undefined; + const where: Record = { user_id: userId }; if (projectId) where.project_id = projectId; const list = await WorkItemState.findAll({ where, order: [['id', 'ASC']] }); @@ -69,11 +75,11 @@ router.get('/states', requireAuth, async (req, res, next) => { } }); -router.get('/properties', requireAuth, async (req, res, next) => { +router.get('/properties', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; - const projectId = req.query.project_id; - const where = { user_id: userId }; + const userId = req.user!.id; + const projectId = req.query.project_id as string | undefined; + const where: Record = { user_id: userId }; if (projectId) where.project_id = projectId; const list = await WorkItemProperty.findAll({ where, order: [['id', 'ASC']] }); @@ -84,11 +90,11 @@ router.get('/properties', requireAuth, async (req, res, next) => { } }); -router.get('/priorities', requireAuth, async (req, res, next) => { +router.get('/priorities', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; - const projectId = req.query.project_id; - const where = { user_id: userId }; + const userId = req.user!.id; + const projectId = req.query.project_id as string | undefined; + const where: Record = { user_id: userId }; if (projectId) where.project_id = projectId; const list = await WorkItemPriority.findAll({ where, order: [['id', 'ASC']] }); @@ -99,10 +105,10 @@ router.get('/priorities', requireAuth, async (req, res, next) => { } }); -router.get('/projects', requireAuth, async (req, res, next) => { +router.get('/projects', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const list = await SyncedProject.findAll({ - where: { user_id: req.user.id, is_archived: false }, + where: { user_id: req.user!.id, is_archived: false }, order: [['name', 'ASC']], }); res.json(success(list)); @@ -111,11 +117,11 @@ router.get('/projects', requireAuth, async (req, res, next) => { } }); -router.get('/work-items', requireAuth, async (req, res, next) => { +router.get('/work-items', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; - const projectId = req.query.project_id; - const where = { user_id: userId, is_archived: false }; + const userId = req.user!.id; + const projectId = req.query.project_id as string | undefined; + const where: Record = { user_id: userId, is_archived: false }; if (projectId) where.project_id = projectId; const list = await SyncedWorkItem.findAll({ @@ -129,9 +135,9 @@ router.get('/work-items', requireAuth, async (req, res, next) => { } }); -router.get('/user-info', requireAuth, async (req, res, next) => { +router.get('/user-info', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const u = req.user; + const u = req.user!; res.json( success({ id: u.pingcode_user_id, @@ -139,7 +145,7 @@ router.get('/user-info', requireAuth, async (req, res, next) => { display_name: u.pingcode_display_name, email: u.pingcode_email, avatar: u.pingcode_avatar, - }) + }), ); } catch (e) { next(e); @@ -147,9 +153,9 @@ router.get('/user-info', requireAuth, async (req, res, next) => { }); /** 统计概览:项目数、工作项数、类型数、状态数 */ -router.get('/overview', requireAuth, async (req, res, next) => { +router.get('/overview', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const [projectsCount, workItemsCount, typesCount, statesCount] = await Promise.all([ SyncedProject.count({ where: { user_id: userId, is_archived: false } }), @@ -164,7 +170,7 @@ router.get('/overview', requireAuth, async (req, res, next) => { workItems: workItemsCount, types: typesCount, states: statesCount, - }) + }), ); } catch (e) { next(e); diff --git a/backend/src/routes/models.js b/backend/src/routes/models.ts similarity index 81% rename from backend/src/routes/models.js rename to backend/src/routes/models.ts index b2da293..36fa8c9 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.ts @@ -1,7 +1,9 @@ import crypto from 'crypto'; import express from 'express'; +import type { Response, NextFunction } from 'express'; import { Op } from 'sequelize'; import { requireAuth } from '../middleware/auth.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { ModelConfig } from '../models/index.js'; import { success } from '../utils/response.js'; import { testModelConnection } from '../services/agent.js'; @@ -10,17 +12,17 @@ import { maskSecret } from '../utils/crypto.js'; const router = express.Router(); /** 序列化模型配置:列表/详情接口中脱敏 api_key,仅返回掩码 */ -function serializeConfig(config) { +function serializeConfig(config: ModelConfig | null): Record | null { if (!config) return null; - const json = config.toJSON(); + const json = config.toJSON() as Record; json.api_key = maskSecret(json.api_key); return json; } /** 获取用户的所有模型配置 */ -router.get('/', requireAuth, async (req, res, next) => { +router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const configs = await ModelConfig.findAll({ where: { user_id: userId }, order: [['createdAt', 'DESC']], @@ -32,7 +34,7 @@ router.get('/', requireAuth, async (req, res, next) => { }); /** 测试模型连接(通过请求体传入配置,用于新增/编辑前测试) */ -router.post('/test', requireAuth, async (req, res, next) => { +router.post('/test', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const { provider, api_key, base_url, model } = req.body; @@ -52,16 +54,16 @@ router.post('/test', requireAuth, async (req, res, next) => { }); res.json(success(null, '连接成功')); - } catch (e) { + } catch (e: any) { const message = e.message || '连接失败'; return res.status(400).json({ success: false, error: message }); } }); /** 获取默认模型配置 */ -router.get('/default', requireAuth, async (req, res, next) => { +router.get('/default', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const config = await ModelConfig.findOne({ where: { user_id: userId, is_default: true }, }); @@ -82,9 +84,9 @@ router.get('/default', requireAuth, async (req, res, next) => { }); /** 获取单个模型配置 */ -router.get('/:id', requireAuth, async (req, res, next) => { +router.get('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const config = await ModelConfig.findOne({ where: { id: req.params.id, user_id: userId }, }); @@ -100,9 +102,9 @@ router.get('/:id', requireAuth, async (req, res, next) => { }); /** 测试模型连接 */ -router.post('/:id/test', requireAuth, async (req, res, next) => { +router.post('/:id/test', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const config = await ModelConfig.findOne({ where: { id: req.params.id, user_id: userId }, }); @@ -121,16 +123,16 @@ router.post('/:id/test', requireAuth, async (req, res, next) => { }); res.json(success(null, '连接成功')); - } catch (e) { + } catch (e: any) { const message = e.message || '连接失败'; return res.status(400).json({ success: false, error: message }); } }); /** 创建模型配置 */ -router.post('/', requireAuth, async (req, res, next) => { +router.post('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const { name, provider, api_key, base_url, model, temperature, max_tokens, is_default } = req.body; // 验证必填字段 @@ -147,7 +149,7 @@ router.post('/', requireAuth, async (req, res, next) => { if (is_default) { await ModelConfig.update( { is_default: false }, - { where: { user_id: userId } } + { where: { user_id: userId } }, ); } @@ -171,9 +173,9 @@ router.post('/', requireAuth, async (req, res, next) => { }); /** 更新模型配置 */ -router.put('/:id', requireAuth, async (req, res, next) => { +router.put('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const config = await ModelConfig.findOne({ where: { id: req.params.id, user_id: userId }, }); @@ -193,12 +195,12 @@ router.put('/:id', requireAuth, async (req, res, next) => { if (is_default) { await ModelConfig.update( { is_default: false }, - { where: { user_id: userId, id: { [Op.ne]: req.params.id } } } + { where: { user_id: userId, id: { [Op.ne]: req.params.id as string } } }, ); } // 如果 api_key 是脱敏值(含 ****),说明前端未修改密钥,跳过更新 - const updateFields = { + const updateFields: Record = { ...(name && { name }), ...(provider && { provider }), ...(base_url !== undefined && { base_url }), @@ -220,9 +222,9 @@ router.put('/:id', requireAuth, async (req, res, next) => { }); /** 删除模型配置(若删除的是默认模型,自动将最早创建的配置设为默认) */ -router.delete('/:id', requireAuth, async (req, res, next) => { +router.delete('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const config = await ModelConfig.findOne({ where: { id: req.params.id, user_id: userId }, }); diff --git a/backend/src/routes/records.js b/backend/src/routes/records.ts similarity index 77% rename from backend/src/routes/records.js rename to backend/src/routes/records.ts index 2a7d3f1..4c00381 100644 --- a/backend/src/routes/records.js +++ b/backend/src/routes/records.ts @@ -1,20 +1,21 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import fs from 'fs/promises'; import crypto from 'node:crypto'; import { requireAuth } from '../middleware/auth.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { ImportRecord, ImportRecordItem } from '../models/index.js'; import { success } from '../utils/response.js'; -import { Op } from 'sequelize'; const router = express.Router(); /** 获取用户的导入记录列表 */ -router.get('/', requireAuth, async (req, res, next) => { +router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const { page = 1, pageSize = 20, status } = req.query; - const where = { user_id: userId }; + const where: Record = { user_id: userId }; if (status) where.status = status; const offset = (Number(page) - 1) * Number(pageSize); @@ -38,11 +39,11 @@ router.get('/', requireAuth, async (req, res, next) => { }); /** 获取单个导入记录详情 */ -router.get('/:id', requireAuth, async (req, res, next) => { +router.get('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -56,9 +57,9 @@ router.get('/:id', requireAuth, async (req, res, next) => { }); /** 创建导入记录 */ -router.post('/', requireAuth, async (req, res, next) => { +router.post('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const { file_name, requirements_count, @@ -85,11 +86,11 @@ router.post('/', requireAuth, async (req, res, next) => { }); /** 更新导入记录状态 */ -router.put('/:id', requireAuth, async (req, res, next) => { +router.put('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -112,11 +113,11 @@ router.put('/:id', requireAuth, async (req, res, next) => { }); /** 删除导入记录 */ -router.delete('/:id', requireAuth, async (req, res, next) => { +router.delete('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -139,14 +140,14 @@ router.delete('/:id', requireAuth, async (req, res, next) => { }); /** 获取导入记录的工作项明细列表 */ -router.get('/:id/items', requireAuth, async (req, res, next) => { +router.get('/:id/items', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const { page = 1, pageSize = 50, status } = req.query; // 先验证记录是否属于当前用户 const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -154,7 +155,7 @@ router.get('/:id/items', requireAuth, async (req, res, next) => { } // 构建查询条件 - const where = { record_id: req.params.id }; + const where: Record = { record_id: req.params.id }; if (status) where.status = status; const offset = (Number(page) - 1) * Number(pageSize); @@ -178,13 +179,13 @@ router.get('/:id/items', requireAuth, async (req, res, next) => { }); /** 获取原需求文档内容 */ -router.get('/:id/content', requireAuth, async (req, res, next) => { +router.get('/:id/content', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; // 先验证记录是否属于当前用户 const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -192,9 +193,9 @@ router.get('/:id/content', requireAuth, async (req, res, next) => { } if (!record.original_file_path) { - return res.status(404).json({ - success: false, - error: '原需求文档不存在(可能是旧版本记录)' + return res.status(404).json({ + success: false, + error: '原需求文档不存在(可能是旧版本记录)', }); } @@ -203,10 +204,10 @@ router.get('/:id/content', requireAuth, async (req, res, next) => { try { content = await fs.readFile(record.original_file_path, 'utf-8'); } catch (err) { - if (err.code === 'ENOENT') { - return res.status(404).json({ - success: false, - error: '原需求文档文件已被删除' + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return res.status(404).json({ + success: false, + error: '原需求文档文件已被删除', }); } throw err; @@ -223,11 +224,11 @@ router.get('/:id/content', requireAuth, async (req, res, next) => { }); /** 从导入记录恢复分析结果(返回工作项列表,用于继续编辑和导入) */ -router.get('/:id/restore', requireAuth, async (req, res, next) => { +router.get('/:id/restore', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -239,7 +240,7 @@ router.get('/:id/restore', requireAuth, async (req, res, next) => { order: [['createdAt', 'ASC']], }); - const requirements = items.map(item => ({ + const requirements = items.map((item) => ({ id: item.id, project_name: item.project_name || record.target_project_name || '', title: item.title, @@ -272,11 +273,11 @@ router.get('/:id/restore', requireAuth, async (req, res, next) => { * 导出导入记录的工作项为 CSV(P3-5.5)。 * 包含标题、描述、优先级、工时、负责人、状态等字段,便于协作和存档。 */ -router.get('/:id/export', requireAuth, async (req, res, next) => { +router.get('/:id/export', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.user.id; + const userId = req.user!.id; const record = await ImportRecord.findOne({ - where: { id: req.params.id, user_id: userId }, + where: { id: req.params.id as string, user_id: userId }, }); if (!record) { @@ -294,7 +295,7 @@ router.get('/:id/export', requireAuth, async (req, res, next) => { '描述', '解决方案建议', ]; - const escapeCsv = (val) => { + const escapeCsv = (val: unknown): string => { if (val === null || val === undefined) return ''; let str = String(val); // CSV 注入防护:以 = + - @ 开头的值在 Excel 中会被解释为公式, diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.ts similarity index 77% rename from backend/src/routes/roles.js rename to backend/src/routes/roles.ts index 98cf227..0d3ec48 100644 --- a/backend/src/routes/roles.js +++ b/backend/src/routes/roles.ts @@ -1,18 +1,20 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import crypto from 'node:crypto'; +import { Op } from 'sequelize'; import { requireAuth } from '../middleware/auth.js'; import { requireAdmin } from '../middleware/permission.js'; -import { Role, Permission, RolePermission, UserRole, User } from '../models/index.js'; +import type { AuthedRequest } from '../types/authRequest.js'; +import { Role, Permission, RolePermission, UserRole } from '../models/index.js'; import { sequelize } from '../services/db.js'; import { success } from '../utils/response.js'; -import { Op } from 'sequelize'; const router = express.Router(); // ===== 角色管理 ===== /** 获取所有角色 */ -router.get('/', requireAuth, async (req, res, next) => { +router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const roles = await Role.findAll({ order: [['createdAt', 'ASC']], @@ -24,7 +26,7 @@ router.get('/', requireAuth, async (req, res, next) => { }); /** 创建角色 */ -router.post('/', requireAuth, requireAdmin, async (req, res, next) => { +router.post('/', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const { name, display_name, description, permissions } = req.body; @@ -57,9 +59,9 @@ router.post('/', requireAuth, requireAdmin, async (req, res, next) => { }); /** 更新角色 */ -router.put('/:id', requireAuth, requireAdmin, async (req, res, next) => { +router.put('/:id', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const role = await Role.findByPk(req.params.id); + const role = await Role.findByPk(req.params.id as string); if (!role) { return res.status(404).json({ success: false, error: '角色不存在' }); @@ -97,9 +99,9 @@ router.put('/:id', requireAuth, requireAdmin, async (req, res, next) => { }); /** 删除角色 */ -router.delete('/:id', requireAuth, requireAdmin, async (req, res, next) => { +router.delete('/:id', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const role = await Role.findByPk(req.params.id); + const role = await Role.findByPk(req.params.id as string); if (!role) { return res.status(404).json({ success: false, error: '角色不存在' }); @@ -124,7 +126,7 @@ router.delete('/:id', requireAuth, requireAdmin, async (req, res, next) => { // ===== 权限管理 ===== /** 获取所有权限 */ -router.get('/permissions', requireAuth, async (req, res, next) => { +router.get('/permissions', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const permissions = await Permission.findAll({ order: [['resource', 'ASC'], ['action', 'ASC']], @@ -136,7 +138,7 @@ router.get('/permissions', requireAuth, async (req, res, next) => { }); /** 创建权限 */ -router.post('/permissions', requireAuth, requireAdmin, async (req, res, next) => { +router.post('/permissions', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const { name, display_name, description, resource, action } = req.body; @@ -160,14 +162,14 @@ router.post('/permissions', requireAuth, requireAdmin, async (req, res, next) => }); /** 获取角色的权限 */ -router.get('/:id/permissions', requireAuth, async (req, res, next) => { +router.get('/:id/permissions', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const rolePermissions = await RolePermission.findAll({ where: { role_id: req.params.id }, include: [Permission], }); - const permissions = rolePermissions.map(rp => rp.Permission); + const permissions = rolePermissions.map((rp) => (rp as RolePermission & { Permission: Permission }).Permission); res.json(success(permissions)); } catch (e) { next(e); diff --git a/backend/src/routes/stats.js b/backend/src/routes/stats.ts similarity index 71% rename from backend/src/routes/stats.js rename to backend/src/routes/stats.ts index e26e178..22083c7 100644 --- a/backend/src/routes/stats.js +++ b/backend/src/routes/stats.ts @@ -1,6 +1,9 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { ensureFreshToken } from '../middleware/tokenRefresh.js'; +import type { AuthedRequest } from '../types/authRequest.js'; +import type { User } from '../models/index.js'; import { getWorkItems } from '../services/pingcode.js'; import { WorkItemType, WorkItemState, WorkItemPriority, SyncedProject } from '../models/index.js'; import { success } from '../utils/response.js'; @@ -9,15 +12,20 @@ import { workloadToHours, extractWorkItemIds } from '../utils/workItem.js'; const router = express.Router(); +interface StatsCacheEntry { + data: any; + expireAt: number; +} + /** * 统计数据内存缓存(P3-5.4)。 * TTL 默认 5 分钟,避免大项目每次请求都拉全量工作项。 * key: `${userId}:${projectId}`,value: { data, expireAt } */ const STATS_CACHE_TTL_MS = parseInt(process.env.STATS_CACHE_TTL_MS || '300000', 10); // 5 分钟 -const statsCache = new Map(); +const statsCache = new Map(); -function getCachedStats(userId, projectId) { +function getCachedStats(userId: string, projectId: string): any { const key = `${userId}:${projectId}`; const entry = statsCache.get(key); if (entry && entry.expireAt > Date.now()) { @@ -27,7 +35,7 @@ function getCachedStats(userId, projectId) { return null; } -function setCachedStats(userId, projectId, data) { +function setCachedStats(userId: string, projectId: string, data: any): void { statsCache.set(`${userId}:${projectId}`, { data, expireAt: Date.now() + STATS_CACHE_TTL_MS, @@ -35,7 +43,7 @@ function setCachedStats(userId, projectId, data) { } /** 使指定项目的统计缓存失效(同步/导入后调用) */ -export function invalidateStatsCache(userId, projectId) { +export function invalidateStatsCache(userId: string, projectId?: string | null): void { if (projectId) { statsCache.delete(`${userId}:${projectId}`); } else { @@ -46,10 +54,17 @@ export function invalidateStatsCache(userId, projectId) { } } +/** 名称映射集合 */ +interface NameMaps { + typeMap: Map; + stateMap: Map; + priorityMap: Map; +} + /** * 从本地元数据表构建 ID -> 名称 的映射 */ -async function buildNameMaps(userId, projectId) { +async function buildNameMaps(userId: string, projectId: string): Promise { const [types, states, priorities] = await Promise.all([ WorkItemType.findAll({ where: { user_id: userId, project_id: projectId } }), WorkItemState.findAll({ where: { user_id: userId, project_id: projectId } }), @@ -57,24 +72,58 @@ async function buildNameMaps(userId, projectId) { ]); return { - typeMap: new Map(types.map(t => [t.id, t.name])), - stateMap: new Map(states.map(s => [s.id, { name: s.name, type: s.type }])), - priorityMap: new Map(priorities.map(p => [p.id, p.name])), + typeMap: new Map(types.map((t) => [t.id, t.name])), + stateMap: new Map(states.map((s) => [s.id, { name: s.name, type: s.type || '' }])), + priorityMap: new Map(priorities.map((p) => [p.id, p.name])), + }; +} + +function mapToSortedArray(map: Map, order: 'desc' | 'asc' = 'desc'): Array<{ name: string; value: number }> { + const arr = Array.from(map, ([name, value]) => ({ name, value })); + arr.sort((a, b) => (order === 'desc' ? b.value - a.value : a.value - b.value)); + return arr; +} + +function buildStateDistribution(stateMap: Map): { + groups: { todo: number; inProgress: number; done: number; other: number }; + details: Array<{ name: string; type: string; count: number }>; + total: number; + completionRate: number; +} { + const groups = { todo: 0, inProgress: 0, done: 0, other: 0 }; + const details: Array<{ name: string; type: string; count: number }> = []; + + for (const [key, count] of stateMap) { + const [name, type] = key.split('|'); + details.push({ name, type, count }); + + if (type === 'todo' || type === 'register') groups.todo += count; + else if (type === 'in_progress' || type === 'processing') groups.inProgress += count; + else if (type === 'done' || type === 'completed' || type === 'resolved') groups.done += count; + else groups.other += count; + } + + const total = groups.todo + groups.inProgress + groups.done + groups.other; + return { + groups, + details, + total, + completionRate: total > 0 ? Math.round((groups.done / total) * 100) : 0, }; } /** * 聚合工作项统计数据 */ -function aggregateWorkItems(items, nameMaps) { +function aggregateWorkItems(items: any[], nameMaps: NameMaps): any { const { typeMap, stateMap, priorityMap } = nameMaps; - const assigneeDistribution = new Map(); - const typeDistribution = new Map(); - const priorityDistribution = new Map(); - const stateDistribution = new Map(); - const assigneeWorkload = new Map(); - const typeWorkload = new Map(); + const assigneeDistribution = new Map(); + const typeDistribution = new Map(); + const priorityDistribution = new Map(); + const stateDistribution = new Map(); + const assigneeWorkload = new Map(); + const typeWorkload = new Map(); for (const item of items) { const assigneeName = item.assignees?.[0]?.name @@ -83,7 +132,7 @@ function aggregateWorkItems(items, nameMaps) { || item.assignee_id || '未分配'; const { typeId, priorityId, stateId } = extractWorkItemIds(item); - const typeName = typeMap.get(typeId) || '未知类型'; + const typeName = typeMap.get(typeId || '') || '未知类型'; const priorityName = (priorityId && priorityMap.get(priorityId)) || '未设置'; const stateInfo = stateId ? stateMap.get(stateId) : null; const stateName = stateInfo?.name || '未知状态'; @@ -120,40 +169,11 @@ function aggregateWorkItems(items, nameMaps) { }; } -function mapToSortedArray(map, order = 'desc') { - const arr = Array.from(map, ([name, value]) => ({ name, value })); - arr.sort((a, b) => order === 'desc' ? b.value - a.value : a.value - b.value); - return arr; -} - -function buildStateDistribution(stateMap) { - const groups = { todo: 0, inProgress: 0, done: 0, other: 0 }; - const details = []; - - for (const [key, count] of stateMap) { - const [name, type] = key.split('|'); - details.push({ name, type, count }); - - if (type === 'todo' || type === 'register') groups.todo += count; - else if (type === 'in_progress' || type === 'processing') groups.inProgress += count; - else if (type === 'done' || type === 'completed' || type === 'resolved') groups.done += count; - else groups.other += count; - } - - const total = groups.todo + groups.inProgress + groups.done + groups.other; - return { - groups, - details, - total, - completionRate: total > 0 ? Math.round((groups.done / total) * 100) : 0, - }; -} - /** * 获取项目统计数据的核心逻辑(供路由和 AI 分析复用)。 * 使用内存缓存(TTL 5 分钟)避免重复拉取全量工作项(P3-5.4)。 */ -export async function getProjectStatsData(user, projectId) { +export async function getProjectStatsData(user: User, projectId: string): Promise { const userId = user.id; // 命中缓存直接返回 @@ -171,7 +191,7 @@ export async function getProjectStatsData(user, projectId) { } const [items, nameMaps] = await Promise.all([ - getWorkItems(accessToken, projectId, domain), + getWorkItems(accessToken!, projectId, domain), buildNameMaps(userId, projectId), ]); @@ -189,28 +209,28 @@ export async function getProjectStatsData(user, projectId) { } /** GET /api/stats/:projectId - 获取项目统计数据 */ -router.get('/:projectId', requireAuth, ensureFreshToken, async (req, res, next) => { +router.get('/:projectId', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const data = await getProjectStatsData(req.user, req.params.projectId); + const data = await getProjectStatsData(req.user!, req.params.projectId as string); res.json(success(data)); - } catch (e) { + } catch (e: any) { if (e.status === 404) return res.status(404).json({ success: false, error: e.message }); next(e); } }); /** POST /api/stats/ai-analyze - AI 统计分析 */ -router.post('/ai-analyze', requireAuth, ensureFreshToken, async (req, res, next) => { +router.post('/ai-analyze', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const { projectId } = req.body; if (!projectId) { return res.status(400).json({ success: false, error: '缺少 projectId 参数' }); } - const statsData = await getProjectStatsData(req.user, projectId); - const report = await analyzeProjectStats(statsData, req.user.id); + const statsData = await getProjectStatsData(req.user!, projectId); + const report = await analyzeProjectStats(statsData, req.user!.id); res.json(success({ report, stats: statsData })); - } catch (e) { + } catch (e: any) { if (e.status === 404) return res.status(404).json({ success: false, error: e.message }); next(e); } diff --git a/backend/src/routes/sync.js b/backend/src/routes/sync.ts similarity index 85% rename from backend/src/routes/sync.js rename to backend/src/routes/sync.ts index 87c551d..f2ee624 100644 --- a/backend/src/routes/sync.js +++ b/backend/src/routes/sync.ts @@ -1,6 +1,8 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { ensureFreshToken } from '../middleware/tokenRefresh.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { getProjects, getWorkItems, getMyself } from '../services/pingcode.js'; import { seekdbClient } from '../services/db.js'; import { SyncedProject, SyncedWorkItem } from '../models/index.js'; @@ -16,7 +18,14 @@ import { logAudit } from '../services/auditLog.js'; const router = express.Router(); const { syncWorkItemBatchSize, syncBatchDelayMs } = appConfig.seekdb; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** 待写入向量库的工作项条目 */ +interface VectorItemEntry { + item: any; + projId: string; + isNew: boolean; +} /** * 增量同步 PingCode 项目和工作项到本地(关系表 + 向量库)。 @@ -26,8 +35,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); * - 更新:remote_updated_at 变化的项目/工作项,更新关系表并重新 upsert 向量 * - 软删除:PingCode 侧已不存在的本地记录,标记 is_archived = true 并从向量库移除 */ -router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) => { - const user = req.user; +router.post('/sync-data', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { + const user = req.user!; const userId = user.id; const accessToken = user.access_token; const domain = user.domain; @@ -49,7 +58,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) } const projectsRes = await getProjects(accessToken, domain); - const projectList = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); + const projectList: any[] = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); await ensureMetadata(userId, accessToken, domain, projectList); @@ -60,9 +69,9 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) const existingProjectMap = new Map(existingProjects.map((p) => [p.id, p])); const remoteProjectIds = new Set(projectList.map((p) => p.id)); - const newProjects = []; - const updatedProjectIds = []; // 需要重新 upsert 向量的项目 ID - const archivedProjectIds = []; // PingCode 侧已不存在的本地项目 + const newProjects: any[] = []; + const updatedProjectIds: string[] = []; // 需要重新 upsert 向量的项目 ID + const archivedProjectIds: string[] = []; // PingCode 侧已不存在的本地项目 for (const p of projectList) { const existing = existingProjectMap.get(p.id); @@ -91,12 +100,12 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) name: p.name, description: p.description || null, remote_updated_at: remoteUpdatedAt(p) || null, - })) + })), ); await projectColl.upsert({ ids: newProjects.map((p) => `${userId}_${p.id}`), documents: newProjects.map( - (p) => `Project: ${p.name}\nDescription: ${p.description || ''}` + (p) => `Project: ${p.name}\nDescription: ${p.description || ''}`, ), metadatas: newProjects.map((p) => ({ id: p.id, name: p.name, user_id: userId })), }); @@ -113,13 +122,13 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) remote_updated_at: remoteUpdatedAt(p) || null, is_archived: false, }, - { where: { id: p.id, user_id: userId } } + { where: { id: p.id, user_id: userId } }, ); } await projectColl.upsert({ ids: toUpdate.map((p) => `${userId}_${p.id}`), documents: toUpdate.map( - (p) => `Project: ${p.name}\nDescription: ${p.description || ''}` + (p) => `Project: ${p.name}\nDescription: ${p.description || ''}`, ), metadatas: toUpdate.map((p) => ({ id: p.id, name: p.name, user_id: userId })), }); @@ -129,7 +138,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) if (archivedProjectIds.length > 0) { await SyncedProject.update( { is_archived: true }, - { where: { id: archivedProjectIds, user_id: userId } } + { where: { id: archivedProjectIds, user_id: userId } }, ); await projectColl.delete({ ids: archivedProjectIds.map((id) => `${userId}_${id}`), @@ -137,7 +146,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) } console.log( - `[Sync] 项目:+${newProjects.length} 新增,~${updatedProjectIds.length} 更新,×${archivedProjectIds.length} 归档` + `[Sync] 项目:+${newProjects.length} 新增,~${updatedProjectIds.length} 更新,×${archivedProjectIds.length} 归档`, ); /* ---- 工作项同步(新增 + 更新 + 软删除) ---- */ @@ -149,15 +158,15 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) let totalNewItems = 0; let totalUpdatedItems = 0; - const allRemoteWorkItemIds = new Set(); - const itemsToVector = []; // 需要重新 upsert 向量的工作项(新增 + 更新) + const allRemoteWorkItemIds = new Set(); + const itemsToVector: VectorItemEntry[] = []; // 需要重新 upsert 向量的工作项(新增 + 更新) // I3: 逐项目拉取,单个项目失败不影响其余项目的同步 for (const proj of projectList) { - let itemList; + let itemList: any[]; try { itemList = await getWorkItems(accessToken, proj.id, domain); - } catch (projErr) { + } catch (projErr: any) { console.warn(`[Sync] 项目 ${proj.id} 工作项拉取失败,跳过:`, projErr.message); continue; } @@ -194,7 +203,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) identifier: item.identifier || null, remote_updated_at: remoteUpdatedAt(item) || null, }; - }) + }), ); totalNewItems = newItems.length; } @@ -210,7 +219,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) remote_updated_at: remoteUpdatedAt(item) || null, is_archived: false, }, - { where: { id: item.id, user_id: userId } } + { where: { id: item.id, user_id: userId } }, ); } totalUpdatedItems = changedItems.length; @@ -223,7 +232,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) await workItemColl.upsert({ ids: batch.map((x) => `${userId}_${x.item.id}`), documents: batch.map( - (x) => `Title: ${x.item.title}\nDescription: ${x.item.description || ''}` + (x) => `Title: ${x.item.title}\nDescription: ${x.item.description || ''}`, ), metadatas: batch.map((x) => ({ id: x.item.id, @@ -237,7 +246,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) } // 软删除:本地有但 PingCode 侧已不存在的工作项 - const archivedWorkItemIds = []; + const archivedWorkItemIds: string[] = []; for (const existing of existingWorkItems) { if (!allRemoteWorkItemIds.has(existing.id) && !existing.is_archived) { archivedWorkItemIds.push(existing.id); @@ -246,11 +255,11 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) if (archivedWorkItemIds.length > 0) { await SyncedWorkItem.update( { is_archived: true }, - { where: { id: archivedWorkItemIds, user_id: userId } } + { where: { id: archivedWorkItemIds, user_id: userId } }, ); // 从向量库移除 const vecIds = chunk(archivedWorkItemIds, 200).map((batch) => - batch.map((id) => `${userId}_${id}`) + batch.map((id) => `${userId}_${id}`), ); for (const batchIds of vecIds) { await workItemColl.delete({ ids: batchIds }); @@ -259,7 +268,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) if (totalNewItems > 0 || totalUpdatedItems > 0 || archivedWorkItemIds.length > 0) { console.log( - `[Sync] 工作项:+${totalNewItems} 新增,~${totalUpdatedItems} 更新,×${archivedWorkItemIds.length} 归档` + `[Sync] 工作项:+${totalNewItems} 新增,~${totalUpdatedItems} 更新,×${archivedWorkItemIds.length} 归档`, ); } @@ -283,8 +292,8 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) archivedProjects: archivedProjectIds.length, archivedWorkItems: archivedWorkItemIds.length, }, - '同步完成' - ) + '同步完成', + ), ); } catch (e) { next(e); @@ -292,17 +301,17 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req, res, next) }); /** 清除当前用户从 PingCode 同步到本地的数据(不含导入记录) */ -router.delete('/sync-data', requireAuth, async (req, res, next) => { +router.delete('/sync-data', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const result = await clearUserSyncedData(req.user.id); + const result = await clearUserSyncedData(req.user!.id); logAudit({ - userId: req.user.id, - username: req.user.username, + userId: req.user!.id, + username: req.user!.username, action: 'CLEAR_SYNC_DATA', resource: 'sync-data', detail: result, }); - invalidateStatsCache(req.user.id); + invalidateStatsCache(req.user!.id); res.json(success(result, '已清除本地同步数据')); } catch (e) { next(e); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.ts similarity index 79% rename from backend/src/routes/users.js rename to backend/src/routes/users.ts index 92848e2..a08fb23 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.ts @@ -1,16 +1,18 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; +import { Op } from 'sequelize'; +import bcrypt from 'bcryptjs'; import { requireAuth } from '../middleware/auth.js'; import { requireAdmin } from '../middleware/permission.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { User, Role, UserRole } from '../models/index.js'; import { success } from '../utils/response.js'; import { logAudit } from '../services/auditLog.js'; -import { Op } from 'sequelize'; -import bcrypt from 'bcryptjs'; const router = express.Router(); /** 获取用户列表(仅 admin) */ -router.get('/', requireAuth, async (req, res, next) => { +router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { // 检查是否为 admin if (!req.isAdmin) { @@ -20,7 +22,7 @@ router.get('/', requireAuth, async (req, res, next) => { const { page = 1, pageSize = 20, search } = req.query; const offset = (Number(page) - 1) * Number(pageSize); - const where = {}; + const where: any = {}; if (search) { where[Op.or] = [ { username: { [Op.like]: `%${search}%` } }, @@ -47,10 +49,10 @@ router.get('/', requireAuth, async (req, res, next) => { }); /** 获取用户详情 */ -router.get('/:id', requireAuth, async (req, res, next) => { +router.get('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.params.id; - const currentUserId = req.user.id; + const userId = req.params.id as string; + const currentUserId = req.user!.id; // 只能查看自己或 admin 可以查看所有 if (userId !== currentUserId && !req.isAdmin) { @@ -71,11 +73,14 @@ router.get('/:id', requireAuth, async (req, res, next) => { include: [Role], }); - const roles = userRoles.map(ur => ({ - id: ur.Role.id, - name: ur.Role.name, - display_name: ur.Role.display_name, - })); + const roles = userRoles.map((ur) => { + const r = (ur as UserRole & { Role: Role }).Role; + return { + id: r.id, + name: r.name, + display_name: r.display_name, + }; + }); res.json(success({ ...user.toJSON(), roles })); } catch (e) { @@ -87,7 +92,7 @@ router.get('/:id', requireAuth, async (req, res, next) => { * 创建用户(仅 admin 可用,用于管理端创建账号)。 * 普通注册统一走 POST /auth/local/register(含限流)。 */ -router.post('/', requireAuth, requireAdmin, async (req, res, next) => { +router.post('/', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { const { username, password, email } = req.body; @@ -125,10 +130,10 @@ router.post('/', requireAuth, requireAdmin, async (req, res, next) => { }); /** 更新用户 */ -router.put('/:id', requireAuth, async (req, res, next) => { +router.put('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { - const userId = req.params.id; - const currentUserId = req.user.id; + const userId = req.params.id as string; + const currentUserId = req.user!.id; // 只能修改自己或 admin 可以修改所有 if (userId !== currentUserId && !req.isAdmin) { @@ -166,17 +171,17 @@ router.put('/:id', requireAuth, async (req, res, next) => { }); /** 删除用户(仅 admin) */ -router.delete('/:id', requireAuth, async (req, res, next) => { +router.delete('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { try { // 检查是否为 admin if (!req.isAdmin) { return res.status(403).json({ success: false, error: '无权限' }); } - const userId = req.params.id; + const userId = req.params.id as string; // 不能删除自己 - if (userId === req.user.id) { + if (userId === req.user!.id) { return res.status(400).json({ success: false, error: '不能删除自己' }); } @@ -185,16 +190,17 @@ router.delete('/:id', requireAuth, async (req, res, next) => { return res.status(404).json({ success: false, error: '用户不存在' }); } + const deletedUsername = user.username; await user.destroy(); // 删除用户时,同时删除角色关联 await UserRole.destroy({ where: { user_id: userId } }); logAudit({ - userId: req.user.id, - username: req.user.username, + userId: req.user!.id, + username: req.user!.username, action: 'DELETE_USER', resource: `user:${userId}`, - detail: { deletedUsername: user.username }, + detail: { deletedUsername }, }); res.json(success(null, '删除成功')); diff --git a/backend/src/routes/workItems.js b/backend/src/routes/workItems.ts similarity index 81% rename from backend/src/routes/workItems.js rename to backend/src/routes/workItems.ts index f57fa3d..eedd50b 100644 --- a/backend/src/routes/workItems.js +++ b/backend/src/routes/workItems.ts @@ -1,6 +1,8 @@ import express from 'express'; +import type { Response, NextFunction } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { ensureFreshToken } from '../middleware/tokenRefresh.js'; +import type { AuthedRequest } from '../types/authRequest.js'; import { seekdbClient } from '../services/db.js'; import { createWorkItemsBatch, @@ -9,6 +11,7 @@ import { updateWorkItem, } from '../services/pingcode.js'; import { ImportRecord, ImportRecordItem, WorkItemType, WorkItemPriority } from '../models/index.js'; +import type { ImportRecordStatus } from '../models/ImportRecord.js'; import { success } from '../utils/response.js'; import { generateProjectIdentifier, toUnixTimestamp, resolveTypeId, resolvePriorityId } from '../utils/workItem.js'; import { fetchAndStoreMetadataForProject } from '../services/metadata.js'; @@ -21,12 +24,12 @@ const router = express.Router(); * 根据项目元数据构建 type name -> type id 映射表 * 允许通过 name(如 "story")或 group(如 "story")查找真实 UUID */ -async function buildTypeNameMap(userId, projectId) { +async function buildTypeNameMap(userId: string, projectId: string): Promise> { const types = await WorkItemType.findAll({ where: { user_id: userId, project_id: projectId }, attributes: ['id', 'name', 'group'], }); - const map = new Map(); + const map = new Map(); for (const t of types) { if (t.name) map.set(t.name.toLowerCase(), t.id); if (t.group) map.set(t.group.toLowerCase(), t.id); @@ -38,13 +41,13 @@ async function buildTypeNameMap(userId, projectId) { * 根据项目元数据构建 priority name -> priority id 映射表 * 支持中英文匹配(如 "High" -> "高","Medium" -> "中","Low" -> "低") */ -async function buildPriorityNameMap(userId, projectId) { +async function buildPriorityNameMap(userId: string, projectId: string): Promise> { const priorities = await WorkItemPriority.findAll({ where: { user_id: userId, project_id: projectId }, attributes: ['id', 'name'], }); - const map = new Map(); - const aliasMap = { + const map = new Map(); + const aliasMap: Record = { high: ['高', '紧急', 'urgent', 'critical', 'high'], medium: ['中', '普通', 'normal', 'medium'], low: ['低', 'low', 'minor'], @@ -62,8 +65,16 @@ async function buildPriorityNameMap(userId, projectId) { return map; } +/** 导入结果聚合 */ +interface ImportResults { + success: number; + failed: number; + errors: any[]; + createdProjects: Array<{ id: string; name: string }>; +} + /** 匹配最相似的项目 */ -router.post('/match-project', requireAuth, async (req, res, next) => { +router.post('/match-project', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { const { requirements } = req.body; if (!requirements || !requirements.length) { return res.status(400).json({ success: false, error: '请提供需求列表' }); @@ -71,15 +82,15 @@ router.post('/match-project', requireAuth, async (req, res, next) => { try { const projectColl = await seekdbClient.getCollection({ name: 'projects' }); - const userId = req.user.id; - + const userId = req.user!.id; + // 提取所有唯一的项目名称 - const uniqueProjectNames = [...new Set(requirements.map(r => r.project_name))]; - + const uniqueProjectNames: string[] = [...new Set((requirements as any[]).map((r: any) => r.project_name))]; + // 为每个项目名称匹配最相似的 PingCode 项目 - const allMatches = []; + const allMatches: any[] = []; for (const projectName of uniqueProjectNames) { - const results = await projectColl.query({ + const results: any = await projectColl.query({ queryTexts: [projectName], where: { user_id: userId }, nResults: 3, @@ -108,7 +119,7 @@ router.post('/match-project', requireAuth, async (req, res, next) => { }); /** 检查重复工作项 */ -router.post('/check-duplicates', requireAuth, async (req, res, next) => { +router.post('/check-duplicates', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { const { items, projectId } = req.body; if (!items || !items.length || !projectId) { return res.status(400).json({ success: false, error: '参数不完整' }); @@ -116,14 +127,14 @@ router.post('/check-duplicates', requireAuth, async (req, res, next) => { try { const workItemColl = await seekdbClient.getCollection({ name: 'work_items' }); - const userId = req.user.id; + const userId = req.user!.id; // 查重相似度阈值(P3-5.14):低于此分数的匹配视为无重复,可通过 DUPLICATE_SIMILARITY_THRESHOLD 配置 const similarityThreshold = parseFloat(process.env.DUPLICATE_SIMILARITY_THRESHOLD || '0.75'); - const checkedItems = []; + const checkedItems: any[] = []; for (const item of items) { - const results = await workItemColl.query({ + const results: any = await workItemColl.query({ queryTexts: [item.title], where: { $and: [{ project_id: projectId }, { user_id: userId }] }, nResults: 1, @@ -158,60 +169,60 @@ router.post('/check-duplicates', requireAuth, async (req, res, next) => { /** * 批量导入工作项到 PingCode - * + * * 逻辑: * 1. 如果提供了 projectId,直接使用该项目 * 2. 如果未提供 projectId 或项目不存在,尝试按 project_name 查找或创建项目 * 3. 构建符合 PingCode API 的工作项数据(必填 type_id,时间戳格式等) */ -router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => { +router.post('/import', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { const { items, projectId, autoCreateProject = true } = req.body; if (!items || !items.length) { return res.status(400).json({ success: false, error: '请提供工作项列表' }); } - const { access_token, domain, pingcode_user_id } = req.user; + const { access_token, domain, pingcode_user_id } = req.user!; try { // 校验 record_id 归属当前用户 let recordBelongsToUser = false; if (req.body.record_id) { const record = await ImportRecord.findOne({ - where: { id: req.body.record_id, user_id: req.user.id }, + where: { id: req.body.record_id, user_id: req.user!.id }, attributes: ['id'], }); recordBelongsToUser = !!record; if (!record) { - console.warn(`[Import] record_id ${req.body.record_id} 不属于用户 ${req.user.id},将跳过记录更新`); + console.warn(`[Import] record_id ${req.body.record_id} 不属于用户 ${req.user!.id},将跳过记录更新`); } } // 获取现有项目列表用于匹配 const projectsRes = await getProjects(access_token, domain); - const existingProjects = Array.isArray(projectsRes) + const existingProjects: any[] = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); const projectMap = new Map(existingProjects.map((p) => [p.id, p])); const projectNameMap = new Map( - existingProjects.map((p) => [p.name.toLowerCase(), p]) + existingProjects.map((p) => [p.name.toLowerCase(), p]), ); // 按项目分组工作项 - const itemsByProject = new Map(); + const itemsByProject = new Map(); for (const item of items) { const key = projectId || item.project_name || '未分类项目'; if (!itemsByProject.has(key)) { itemsByProject.set(key, []); } - itemsByProject.get(key).push(item); + itemsByProject.get(key)!.push(item); } - const results = { success: 0, failed: 0, errors: [], createdProjects: [] }; + const results: ImportResults = { success: 0, failed: 0, errors: [], createdProjects: [] }; // 项目成员缓存(projectId → members[]),同一次导入内复用 - const membersCache = new Map(); + const membersCache = new Map(); for (const [projectKey, projectItems] of itemsByProject) { - let targetProjectId = null; + let targetProjectId: string | null = null; // 情况1:直接使用传入的 projectId if (projectId && projectMap.has(projectId)) { @@ -235,7 +246,7 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => description: `由需求分析工具自动创建`, assignee_id: pingcode_user_id || undefined, }, - domain + domain, ); targetProjectId = newProject.id; results.createdProjects.push({ @@ -246,10 +257,10 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => // 为新项目拉取并存储元数据,确保后续 type_id / priority_id 能正确解析 const metaCounts = await fetchAndStoreMetadataForProject( - req.user.id, access_token, domain, targetProjectId + req.user!.id, access_token, domain, targetProjectId, ); console.log(`[Import] 新项目元数据同步: +${metaCounts.types} types, +${metaCounts.priorities} priorities`); - } catch (createErr) { + } catch (createErr: any) { const errMsg = createErr.response?.data?.message || createErr.message; console.error(`[Import] 创建项目失败: ${projectKey}`, errMsg); // 项目创建失败,该分组的所有工作项都标记为失败 @@ -276,17 +287,17 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => } // 查询项目元数据,建立 name -> id 映射表 - const typeNameMap = await buildTypeNameMap(req.user.id, targetProjectId); - const priorityNameMap = await buildPriorityNameMap(req.user.id, targetProjectId); + const typeNameMap = await buildTypeNameMap(req.user!.id, targetProjectId!); + const priorityNameMap = await buildPriorityNameMap(req.user!.id, targetProjectId!); // 拉取项目成员用于负责人姓名解析(同一项目只拉一次) - const members = await getProjectMembersCached(access_token, targetProjectId, domain, membersCache); + const members = await getProjectMembersCached(access_token, targetProjectId!, domain, membersCache); // 构建 PingCode API 所需的工作项数据(公共逻辑集中在 importHelper.buildImportPayload) const pingcodeItems = projectItems.map((i) => { const resolvedTypeId = resolveTypeId(i.type_id, typeNameMap); const resolvedPriorityId = resolvePriorityId(i.priority_id, i.priority, priorityNameMap); return buildImportPayload(i, { - targetProjectId, + targetProjectId: targetProjectId!, resolvedTypeId, resolvedPriorityId, members, @@ -298,12 +309,12 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => const batchResult = await createWorkItemsBatch( access_token, pingcodeItems, - domain + domain, ); results.success += batchResult.success; results.failed += batchResult.failed; results.errors.push(...batchResult.errors); - + // 更新导入明细状态(如果提供了 record_id) if (req.body.record_id && recordBelongsToUser) { for (const created of batchResult.created || []) { @@ -314,13 +325,13 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => pingcode_id: created.pingcode_id, pingcode_identifier: created.pingcode_identifier, }, - { where: { id: created.local_id, record_id: req.body.record_id } } - ).catch(err => { + { where: { id: created.local_id, record_id: req.body.record_id } }, + ).catch((err: any) => { console.error('[Import] 更新明细状态失败:', err.message); }); } } - + for (const error of batchResult.errors || []) { if (error.local_id) { await ImportRecordItem.update( @@ -328,8 +339,8 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => status: 'failed', error_message: error.error, }, - { where: { id: error.local_id, record_id: req.body.record_id } } - ).catch(err => { + { where: { id: error.local_id, record_id: req.body.record_id } }, + ).catch((err: any) => { console.error('[Import] 更新明细状态失败:', err.message); }); } @@ -340,13 +351,13 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => // 更新导入记录(如果提供了 record_id 且归属当前用户) if (req.body.record_id && recordBelongsToUser) { try { - let finalStatus = 'success'; + let finalStatus: ImportRecordStatus = 'success'; if (results.failed > 0 && results.success > 0) { finalStatus = 'partial_success'; } else if (results.failed > 0 && results.success === 0) { finalStatus = 'failed'; } - + await ImportRecord.update( { imported_count: results.success, @@ -354,15 +365,15 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => status: finalStatus, error_message: results.errors.length > 0 ? JSON.stringify(results.errors) : null, }, - { where: { id: req.body.record_id, user_id: req.user.id } } + { where: { id: req.body.record_id, user_id: req.user!.id } }, ); - } catch (err) { + } catch (err: any) { console.error('[Import] 更新导入记录失败:', err.message); } } // 导入后清除统计缓存(数据已变更,A1) - invalidateStatsCache(req.user.id); + invalidateStatsCache(req.user!.id); res.json(success({ result: results }, '导入完成')); } catch (e) { @@ -371,7 +382,7 @@ router.post('/import', requireAuth, ensureFreshToken, async (req, res, next) => }); /** SSE 导入:实时推送导入进度 */ -router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => { +router.post('/import-stream', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response) => { const { items, projectId, autoCreateProject = true, record_id } = req.body; if (!items || !items.length) { return res.status(400).json({ success: false, error: '请提供工作项列表' }); @@ -380,48 +391,47 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', }); - function sendEvent(event, data) { + function sendEvent(event: string, data: any): void { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } - const { access_token, domain, pingcode_user_id } = req.user; + const { access_token, domain, pingcode_user_id } = req.user!; try { let recordBelongsToUser = false; if (record_id) { const record = await ImportRecord.findOne({ - where: { id: record_id, user_id: req.user.id }, + where: { id: record_id, user_id: req.user!.id }, attributes: ['id'], }); recordBelongsToUser = !!record; } const projectsRes = await getProjects(access_token, domain); - const existingProjects = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); + const existingProjects: any[] = Array.isArray(projectsRes) ? projectsRes : (projectsRes?.values || []); const projectMap = new Map(existingProjects.map((p) => [p.id, p])); const projectNameMap = new Map(existingProjects.map((p) => [p.name.toLowerCase(), p])); - const allItems = []; - const itemsByProject = new Map(); + const itemsByProject = new Map(); for (const item of items) { const key = projectId || item.project_name || '未分类项目'; if (!itemsByProject.has(key)) itemsByProject.set(key, []); - itemsByProject.get(key).push(item); + itemsByProject.get(key)!.push(item); } - const results = { success: 0, failed: 0, errors: [], createdProjects: [] }; - let totalItems = items.length; + const results: ImportResults = { success: 0, failed: 0, errors: [], createdProjects: [] }; + const totalItems = items.length; let processedItems = 0; // 项目成员缓存(projectId → members[]),同一次导入内复用 - const membersCache = new Map(); + const membersCache = new Map(); sendEvent('start', { total: totalItems }); for (const [projectKey, projectItems] of itemsByProject) { - let targetProjectId = null; + let targetProjectId: string | null = null; if (projectId && projectMap.has(projectId)) { targetProjectId = projectId; @@ -443,10 +453,10 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => sendEvent('project_created', { name: newProject.name }); const metaCounts = await fetchAndStoreMetadataForProject( - req.user.id, access_token, domain, targetProjectId + req.user!.id, access_token, domain, targetProjectId, ); console.log(`[Import-Stream] 新项目元数据同步: +${metaCounts.types} types, +${metaCounts.priorities} priorities`); - } catch (err) { + } catch (err: any) { const errMsg = err.response?.data?.message || err.message; for (const item of projectItems) { results.failed++; @@ -471,15 +481,15 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => } } - const typeNameMap = await buildTypeNameMap(req.user.id, targetProjectId); - const priorityNameMap = await buildPriorityNameMap(req.user.id, targetProjectId); - const members = await getProjectMembersCached(access_token, targetProjectId, domain, membersCache); + const typeNameMap = await buildTypeNameMap(req.user!.id, targetProjectId!); + const priorityNameMap = await buildPriorityNameMap(req.user!.id, targetProjectId!); + const members = await getProjectMembersCached(access_token, targetProjectId!, domain, membersCache); const pingcodeItems = projectItems.map((i) => { const resolvedTypeId = resolveTypeId(i.type_id, typeNameMap); const resolvedPriorityId = resolvePriorityId(i.priority_id, i.priority, priorityNameMap); return buildImportPayload(i, { - targetProjectId, + targetProjectId: targetProjectId!, resolvedTypeId, resolvedPriorityId, members, @@ -496,7 +506,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => title: itemInfo.title, status: itemInfo.status, error: itemInfo.error || null, }); - } + }, ); results.success += batchResult.success; results.failed += batchResult.failed; @@ -507,7 +517,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => if (created.local_id) { await ImportRecordItem.update( { status: 'success', pingcode_id: created.pingcode_id, pingcode_identifier: created.pingcode_identifier }, - { where: { id: created.local_id, record_id } } + { where: { id: created.local_id, record_id } }, ).catch(() => {}); } } @@ -515,7 +525,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => if (error.local_id) { await ImportRecordItem.update( { status: 'failed', error_message: error.error }, - { where: { id: error.local_id, record_id } } + { where: { id: error.local_id, record_id } }, ).catch(() => {}); } } @@ -523,7 +533,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => } if (record_id && recordBelongsToUser) { - let finalStatus = 'success'; + let finalStatus: ImportRecordStatus = 'success'; if (results.failed > 0 && results.success > 0) finalStatus = 'partial_success'; else if (results.failed > 0 && results.success === 0) finalStatus = 'failed'; @@ -533,16 +543,16 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => status: finalStatus, error_message: results.errors.length > 0 ? JSON.stringify(results.errors) : null, }, - { where: { id: record_id, user_id: req.user.id } } + { where: { id: record_id, user_id: req.user!.id } }, ).catch(() => {}); } // 导入后清除统计缓存(数据已变更,A1) - invalidateStatsCache(req.user.id); + invalidateStatsCache(req.user!.id); sendEvent('complete', { result: results }); res.end(); - } catch (e) { + } catch (e: any) { sendEvent('error', { message: e.message }); res.end(); } @@ -552,8 +562,8 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req, res) => * 更新单个 PingCode 工作项(P3-5.2:工作项更新/编辑能力)。 * 支持 PATCH 语义:仅传入需要更新的字段。 */ -router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req, res, next) => { - const { id } = req.params; +router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { + const { id } = req.params as { id: string }; if (!id) { return res.status(400).json({ success: false, error: '缺少工作项 ID' }); } @@ -562,7 +572,7 @@ router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req, res, 'title', 'description', 'state_id', 'priority_id', 'assignee_id', 'start_at', 'end_at', 'estimated_workload', 'parent_id', ]; - const updateData = {}; + const updateData: Record = {}; for (const field of allowedFields) { if (req.body[field] !== undefined) { updateData[field] = req.body[field]; @@ -582,10 +592,10 @@ router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req, res, } try { - const { access_token, domain } = req.user; + const { access_token, domain } = req.user!; const result = await updateWorkItem(access_token, id, updateData, domain); res.json(success(result, '更新成功')); - } catch (e) { + } catch (e: any) { const status = e.response?.status || 500; const message = e.response?.data?.message || e.message; res.status(status).json({ success: false, error: message }); diff --git a/backend/src/services/auditLog.ts b/backend/src/services/auditLog.ts index 3289b8c..d26053f 100644 --- a/backend/src/services/auditLog.ts +++ b/backend/src/services/auditLog.ts @@ -17,7 +17,7 @@ export interface AuditEntry { /** 操作目标(如 'user:xxx'、'sync-data') */ resource?: string; /** 附加详情 */ - detail?: Record; + detail?: object; result?: 'success' | 'failed'; } @@ -27,7 +27,7 @@ export interface AuditRecord { username: string; action: string; resource: string; - detail: Record; + detail: object; result: 'success' | 'failed'; } diff --git a/backend/src/services/pingcode.ts b/backend/src/services/pingcode.ts index 0a522c1..1b36510 100644 --- a/backend/src/services/pingcode.ts +++ b/backend/src/services/pingcode.ts @@ -24,8 +24,8 @@ export interface BatchProgressItem { export interface BatchCreateResult { success: number; failed: number; - errors: Array<{ local_id: unknown; item: unknown; error: unknown }>; - created: Array<{ local_id: unknown; pingcode_id?: string; pingcode_identifier?: string; title: unknown }>; + errors: Array<{ local_id: any; item: any; error: any }>; + created: Array<{ local_id: any; pingcode_id?: string; pingcode_identifier?: string; title: any }>; } /** diff --git a/backend/src/utils/syncCompare.ts b/backend/src/utils/syncCompare.ts index 069221b..2441a75 100644 --- a/backend/src/utils/syncCompare.ts +++ b/backend/src/utils/syncCompare.ts @@ -10,16 +10,14 @@ export interface RemoteItem { last_modified?: string; title?: string; description?: string | null; - [key: string]: unknown; } -/** 本地同步缓存行 */ +/** 本地同步缓存行(不加索引签名,以便 SyncedProject/SyncedWorkItem 类结构兼容) */ export interface SyncedRecord { is_archived?: boolean; remote_updated_at?: string | null; title?: string; description?: string | null; - [key: string]: unknown; } /** 提取 PingCode 对象的更新时间,用于增量同步判断 */ From 457f6594ae7107bbdf01349202fd162b560102e0 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 13:25:19 +0800 Subject: [PATCH 09/12] =?UTF-8?q?build(backend):=20Phase=207=20=E2=80=94?= =?UTF-8?q?=20migrate=20entry=20+=20prompts=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.ts: typed Express app; audit-logs/health inline routes (AuthedRequest / Request), dynamic checks object typed Record; SPA fallback handler typed; CORS callback contextually typed - index.ts: start()/shutdown(signal) typed - prompts/*.js → *.ts via git mv (pure string exports, no content change) - package.json: dev now `tsx watch src/index.ts` - Dockerfile: install full deps (need typescript) + `pnpm run build` step; CMD → node backend/dist/index.js (src/index.js no longer exists) All backend source (.js under src/) is now TypeScript; only test files remain .js (Phase 8). Verified: tsc --noEmit (0 errors) · pnpm test (176/176) · pnpm build (dist/index.js emitted). Co-Authored-By: Claude Fable 5 --- Dockerfile | 10 +- backend/package.json | 2 +- backend/src/app.ts | 132 ++++++++++++++++++ backend/src/index.ts | 49 +++++++ ...Requirements.js => analyzeRequirements.ts} | 0 .../{analyzeStats.js => analyzeStats.ts} | 0 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 backend/src/app.ts create mode 100644 backend/src/index.ts rename backend/src/prompts/{analyzeRequirements.js => analyzeRequirements.ts} (100%) rename backend/src/prompts/{analyzeStats.js => analyzeStats.ts} (100%) diff --git a/Dockerfile b/Dockerfile index 8942a3a..0777748 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,11 @@ WORKDIR /app # 启用 pnpm(与项目包管理一致) RUN corepack enable && corepack prepare pnpm@latest --activate -# 先复制后端代码(.dockerignore 已排除 node_modules),再安装依赖 +# 先复制后端代码(.dockerignore 已排除 node_modules),安装全部依赖(含 typescript/tsx 以便编译) COPY backend/ ./backend/ -RUN cd backend && pnpm install --prod +RUN cd backend && pnpm install +# 编译 TypeScript -> dist/(生产运行 dist/index.js) +RUN cd backend && pnpm run build # 再复制前端代码,安装依赖并构建(同源部署时 API 使用相对路径) COPY frontend/ ./frontend/ @@ -26,5 +28,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))" -# 从项目根 /app 启动后端(后端通过 __dirname 解析 backend/.env 路径) -CMD ["node", "backend/src/index.js"] +# 从项目根 /app 启动后端(运行 tsc 编译产物 dist/index.js,后端通过 __dirname 解析 backend/.env 路径) +CMD ["node", "backend/dist/index.js"] diff --git a/backend/package.json b/backend/package.json index d893a3d..077bcd8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "cross-env NODE_ENV=production node dist/index.js", - "dev": "cross-env NODE_ENV=development tsx watch src/index.js", + "dev": "cross-env NODE_ENV=development tsx watch src/index.ts", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..b0b2601 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,132 @@ +import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import cors from 'cors'; +import { appConfig } from './config/index.js'; +import { sequelize, seekdbClient } from './services/db.js'; +import { requireAuth } from './middleware/auth.js'; +import { requireAdmin } from './middleware/permission.js'; +import { requestLogger } from './middleware/logger.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { success } from './utils/response.js'; +import type { AuthedRequest } from './types/authRequest.js'; +import authRoutes from './routes/auth.js'; +import localAuthRoutes from './routes/localAuth.js'; +import configRoutes from './routes/config.js'; +import syncRoutes from './routes/sync.js'; +import analyzeRoutes from './routes/analyze.js'; +import workItemRoutes from './routes/workItems.js'; +import metadataRoutes from './routes/metadata.js'; +import modelsRoutes from './routes/models.js'; +import recordsRoutes from './routes/records.js'; +import rolesRoutes from './routes/roles.js'; +import usersRoutes from './routes/users.js'; +import statsRoutes from './routes/stats.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); + +/* ---- 全局中间件 ---- */ +const allowedOrigins = Array.isArray(appConfig.cors.origin) + ? appConfig.cors.origin + : [appConfig.cors.origin]; + +app.use(cors({ + origin: (origin, callback) => { + // 允许同源请求(无 Origin header,如服务器端请求或同源浏览器请求) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(null, false); // 不允许的源:不设置 CORS 头,浏览器会拦截 + } + }, + credentials: true, + // 暴露滑动续期自定义响应头,供前端读取(P2-4.3) + exposedHeaders: ['X-Refreshed-Token'], +})); +// 显式限制请求体大小(默认 100kb 对大批量导入场景不足;5mb 兼顾大文档与 DoS 防护) +app.use(express.json({ limit: '5mb' })); +app.use(requestLogger); + +/* ---- 审计日志查询(仅 admin,P3-5.9) ---- */ +app.get('/api/audit-logs', requireAuth, requireAdmin, async (req: AuthedRequest, res: Response, next: NextFunction) => { + try { + const { getAuditLogs } = await import('./services/auditLog.js'); + const logs = getAuditLogs({ + userId: (req.query.userId as string | undefined) || undefined, + action: (req.query.action as string | undefined) || undefined, + limit: parseInt((req.query.limit as string) || '100', 10), + }); + res.json(success(logs)); + } catch (e) { + next(e); + } +}); + +/* ---- 健康检查(无需认证,供容器编排探针使用) ---- */ +app.get('/health', async (_req: Request, res: Response) => { + const checks: Record = {}; + let allOk = true; + + // 关系数据库连通性 + try { + await sequelize.authenticate(); + checks.database = 'ok'; + } catch { + checks.database = 'error'; + allOk = false; + } + + // 向量数据库连通性(SeekDB 复用同一 MySQL 协议连接,authenticate 通过即可认为向量库可用) + try { + const coll = await seekdbClient.getCollection({ name: 'projects' }).catch(() => null); + checks.vectorDB = coll ? 'ok' : 'degraded'; + } catch { + checks.vectorDB = 'degraded'; + } + + res.status(allOk ? 200 : 503).json({ + status: allOk ? 'ok' : 'error', + checks, + timestamp: new Date().toISOString(), + }); +}); + +/* ---- 路由 ---- */ +app.use('/auth', authRoutes); +app.use('/auth/local', localAuthRoutes); +app.use('/api', configRoutes); +app.use('/api', syncRoutes); +app.use('/api', analyzeRoutes); +app.use('/api', workItemRoutes); +app.use('/api/metadata', metadataRoutes); +app.use('/api/models', modelsRoutes); +app.use('/api/records', recordsRoutes); +app.use('/api/roles', rolesRoutes); +app.use('/api/users', usersRoutes); +app.use('/api/stats', statsRoutes); + +const publicDir = path.join(__dirname, '../public'); + +app.get('/', (_req: Request, res: Response) => { + if (appConfig.env === 'production' && fs.existsSync(publicDir)) { + return res.sendFile(path.join(publicDir, 'index.html')); + } + res.json({ message: 'PingCraft 后端服务运行中', env: appConfig.env }); +}); + +/* ---- 生产环境:托管前端静态资源与 SPA 回退(Docker 同源部署) ---- */ +if (appConfig.env === 'production' && fs.existsSync(publicDir)) { + app.use(express.static(publicDir)); + app.get('*', (req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith('/api') || req.path.startsWith('/auth')) return next(); + res.sendFile(path.join(publicDir, 'index.html')); + }); +} + +/* ---- 统一错误处理(必须在路由之后) ---- */ +app.use(errorHandler); + +export default app; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..13a874d --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,49 @@ +import app from './app.js'; +import { appConfig } from './config/index.js'; +import { initDB } from './services/db.js'; +import { validateRequiredConfig } from './config/validate.js'; +import { startCleanupSchedule } from './services/uploadCleanup.js'; + +/* ---- 启动:先校验配置,再完成数据库初始化,最后监听端口 ---- */ +async function start(): Promise { + // 1. 校验必填环境变量(生产环境严格要求 ENCRYPTION_KEY / JWT_SECRET 等) + const configErrors = validateRequiredConfig(); + if (configErrors.length > 0) { + console.error('[App] 配置校验失败:'); + configErrors.forEach((e) => console.error(` - ${e}`)); + process.exit(1); + } + + // 2. 数据库初始化 + try { + await initDB(); + } catch { + console.error('[App] 数据库初始化失败,服务无法启动'); + process.exit(1); + } + + // 3. 启动上传文件定时清理(P2-4.9) + const cleanupHandles = startCleanupSchedule(); + + // 4. 启动 HTTP 服务 + const server = app.listen(appConfig.port, () => { + console.log(`[${appConfig.env}] 服务运行在端口 ${appConfig.port}`); + }); + + // 5. 优雅关闭:清理定时器和连接(P2 审查 I3) + function shutdown(signal: string): void { + console.log(`[App] 收到 ${signal},正在关闭...`); + if (cleanupHandles?.timer) clearInterval(cleanupHandles.timer); + if (cleanupHandles?.initialTimer) clearTimeout(cleanupHandles.initialTimer); + server.close(() => { + console.log('[App] HTTP 服务已关闭'); + process.exit(0); + }); + // 5s 超时强制退出 + setTimeout(() => process.exit(1), 5000).unref(); + } + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +start(); diff --git a/backend/src/prompts/analyzeRequirements.js b/backend/src/prompts/analyzeRequirements.ts similarity index 100% rename from backend/src/prompts/analyzeRequirements.js rename to backend/src/prompts/analyzeRequirements.ts diff --git a/backend/src/prompts/analyzeStats.js b/backend/src/prompts/analyzeStats.ts similarity index 100% rename from backend/src/prompts/analyzeStats.js rename to backend/src/prompts/analyzeStats.ts From d223991705323b32f128b036c67e710f1e30d1b5 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 13:28:38 +0800 Subject: [PATCH 10/12] =?UTF-8?q?build(backend):=20Phase=208=20=E2=80=94?= =?UTF-8?q?=20migrate=20test=20files=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git mv all 22 *.test.js → *.test.ts and setup.js → setup.ts. Test bodies unchanged (vitest globals + vi.mock factories are type-agnostic under esbuild transform). tsconfig continues to exclude **/__tests__/**, so tests are run by vitest (not type-checked by tsc) — this keeps the rich vi.mock patterns (mock express router, mock utils) working without friction. Enabling test type-checking is a possible follow-up under Phase 9 strict mode. vitest.config.ts setupFiles: setup.js → setup.ts. Entire backend (src source + tests) is now .ts. Verified: tsc --noEmit (0 errors) · pnpm test (176/176) · zero .js files under backend/src. Co-Authored-By: Claude Fable 5 --- backend/src/__tests__/{setup.js => setup.ts} | 0 backend/src/app.js | 130 ------------------ .../{validate.test.js => validate.test.ts} | 0 backend/src/index.js | 49 ------- .../__tests__/{auth.test.js => auth.test.ts} | 0 ...orHandler.test.js => errorHandler.test.ts} | 0 .../{logger.test.js => logger.test.ts} | 0 ...{permission.test.js => permission.test.ts} | 0 .../{localAuth.test.js => localAuth.test.ts} | 0 .../{records.test.js => records.test.ts} | 0 .../{workItems.test.js => workItems.test.ts} | 0 .../{auditLog.test.js => auditLog.test.ts} | 0 ...ortHelper.test.js => importHelper.test.ts} | 0 .../{parser.test.js => parser.test.ts} | 0 .../{pingcode.test.js => pingcode.test.ts} | 0 ...Analyzer.test.js => statsAnalyzer.test.ts} | 0 ...dCleanup.test.js => uploadCleanup.test.ts} | 0 .../{array.test.js => array.test.ts} | 0 ...rency.test.js => arrayConcurrency.test.ts} | 0 .../{assignee.test.js => assignee.test.ts} | 0 .../{crypto.test.js => crypto.test.ts} | 0 .../{response.test.js => response.test.ts} | 0 .../{retry.test.js => retry.test.ts} | 0 ...yncCompare.test.js => syncCompare.test.ts} | 0 .../{workItem.test.js => workItem.test.ts} | 0 backend/vitest.config.ts | 2 +- 26 files changed, 1 insertion(+), 180 deletions(-) rename backend/src/__tests__/{setup.js => setup.ts} (100%) delete mode 100644 backend/src/app.js rename backend/src/config/__tests__/{validate.test.js => validate.test.ts} (100%) delete mode 100644 backend/src/index.js rename backend/src/middleware/__tests__/{auth.test.js => auth.test.ts} (100%) rename backend/src/middleware/__tests__/{errorHandler.test.js => errorHandler.test.ts} (100%) rename backend/src/middleware/__tests__/{logger.test.js => logger.test.ts} (100%) rename backend/src/middleware/__tests__/{permission.test.js => permission.test.ts} (100%) rename backend/src/routes/__tests__/{localAuth.test.js => localAuth.test.ts} (100%) rename backend/src/routes/__tests__/{records.test.js => records.test.ts} (100%) rename backend/src/routes/__tests__/{workItems.test.js => workItems.test.ts} (100%) rename backend/src/services/__tests__/{auditLog.test.js => auditLog.test.ts} (100%) rename backend/src/services/__tests__/{importHelper.test.js => importHelper.test.ts} (100%) rename backend/src/services/__tests__/{parser.test.js => parser.test.ts} (100%) rename backend/src/services/__tests__/{pingcode.test.js => pingcode.test.ts} (100%) rename backend/src/services/__tests__/{statsAnalyzer.test.js => statsAnalyzer.test.ts} (100%) rename backend/src/services/__tests__/{uploadCleanup.test.js => uploadCleanup.test.ts} (100%) rename backend/src/utils/__tests__/{array.test.js => array.test.ts} (100%) rename backend/src/utils/__tests__/{arrayConcurrency.test.js => arrayConcurrency.test.ts} (100%) rename backend/src/utils/__tests__/{assignee.test.js => assignee.test.ts} (100%) rename backend/src/utils/__tests__/{crypto.test.js => crypto.test.ts} (100%) rename backend/src/utils/__tests__/{response.test.js => response.test.ts} (100%) rename backend/src/utils/__tests__/{retry.test.js => retry.test.ts} (100%) rename backend/src/utils/__tests__/{syncCompare.test.js => syncCompare.test.ts} (100%) rename backend/src/utils/__tests__/{workItem.test.js => workItem.test.ts} (100%) diff --git a/backend/src/__tests__/setup.js b/backend/src/__tests__/setup.ts similarity index 100% rename from backend/src/__tests__/setup.js rename to backend/src/__tests__/setup.ts diff --git a/backend/src/app.js b/backend/src/app.js deleted file mode 100644 index f606cf8..0000000 --- a/backend/src/app.js +++ /dev/null @@ -1,130 +0,0 @@ -import express from 'express'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; -import cors from 'cors'; -import { appConfig } from './config/index.js'; -import { sequelize, seekdbClient } from './services/db.js'; -import { requireAuth } from './middleware/auth.js'; -import { requireAdmin } from './middleware/permission.js'; -import { requestLogger } from './middleware/logger.js'; -import { errorHandler } from './middleware/errorHandler.js'; -import { success } from './utils/response.js'; -import authRoutes from './routes/auth.js'; -import localAuthRoutes from './routes/localAuth.js'; -import configRoutes from './routes/config.js'; -import syncRoutes from './routes/sync.js'; -import analyzeRoutes from './routes/analyze.js'; -import workItemRoutes from './routes/workItems.js'; -import metadataRoutes from './routes/metadata.js'; -import modelsRoutes from './routes/models.js'; -import recordsRoutes from './routes/records.js'; -import rolesRoutes from './routes/roles.js'; -import usersRoutes from './routes/users.js'; -import statsRoutes from './routes/stats.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const app = express(); - -/* ---- 全局中间件 ---- */ -const allowedOrigins = Array.isArray(appConfig.cors.origin) - ? appConfig.cors.origin - : [appConfig.cors.origin]; - -app.use(cors({ - origin: (origin, callback) => { - // 允许同源请求(无 Origin header,如服务器端请求或同源浏览器请求) - if (!origin || allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(null, false); // 不允许的源:不设置 CORS 头,浏览器会拦截 - } - }, - credentials: true, - // 暴露滑动续期自定义响应头,供前端读取(P2-4.3) - exposedHeaders: ['X-Refreshed-Token'], -})); -// 显式限制请求体大小(默认 100kb 对大批量导入场景不足;5mb 兼顾大文档与 DoS 防护) -app.use(express.json({ limit: '5mb' })); -app.use(requestLogger); - -/* ---- 审计日志查询(仅 admin,P3-5.9) ---- */ -app.get('/api/audit-logs', requireAuth, requireAdmin, async (req, res, next) => { - try { - const { getAuditLogs } = await import('./services/auditLog.js'); - const logs = getAuditLogs({ - userId: req.query.userId || undefined, - action: req.query.action || undefined, - limit: parseInt(req.query.limit || '100', 10), - }); - res.json(success(logs)); - } catch (e) { - next(e); - } -}); - -/* ---- 健康检查(无需认证,供容器编排探针使用) ---- */ -app.get('/health', async (_req, res) => { - const checks = {}; - let allOk = true; - - // 关系数据库连通性 - try { - await sequelize.authenticate(); - checks.database = 'ok'; - } catch (e) { - checks.database = 'error'; - allOk = false; - } - - // 向量数据库连通性(SeekDB 复用同一 MySQL 协议连接,authenticate 通过即可认为向量库可用) - try { - const coll = await seekdbClient.getCollection({ name: 'projects' }).catch(() => null); - checks.vectorDB = coll ? 'ok' : 'degraded'; - } catch { - checks.vectorDB = 'degraded'; - } - - res.status(allOk ? 200 : 503).json({ - status: allOk ? 'ok' : 'error', - checks, - timestamp: new Date().toISOString(), - }); -}); - -/* ---- 路由 ---- */ -app.use('/auth', authRoutes); -app.use('/auth/local', localAuthRoutes); -app.use('/api', configRoutes); -app.use('/api', syncRoutes); -app.use('/api', analyzeRoutes); -app.use('/api', workItemRoutes); -app.use('/api/metadata', metadataRoutes); -app.use('/api/models', modelsRoutes); -app.use('/api/records', recordsRoutes); -app.use('/api/roles', rolesRoutes); -app.use('/api/users', usersRoutes); -app.use('/api/stats', statsRoutes); - -const publicDir = path.join(__dirname, '../public'); - -app.get('/', (_req, res) => { - if (appConfig.env === 'production' && fs.existsSync(publicDir)) { - return res.sendFile(path.join(publicDir, 'index.html')); - } - res.json({ message: 'PingCraft 后端服务运行中', env: appConfig.env }); -}); - -/* ---- 生产环境:托管前端静态资源与 SPA 回退(Docker 同源部署) ---- */ -if (appConfig.env === 'production' && fs.existsSync(publicDir)) { - app.use(express.static(publicDir)); - app.get('*', (req, res, next) => { - if (req.path.startsWith('/api') || req.path.startsWith('/auth')) return next(); - res.sendFile(path.join(publicDir, 'index.html')); - }); -} - -/* ---- 统一错误处理(必须在路由之后) ---- */ -app.use(errorHandler); - -export default app; diff --git a/backend/src/config/__tests__/validate.test.js b/backend/src/config/__tests__/validate.test.ts similarity index 100% rename from backend/src/config/__tests__/validate.test.js rename to backend/src/config/__tests__/validate.test.ts diff --git a/backend/src/index.js b/backend/src/index.js deleted file mode 100644 index 43c851e..0000000 --- a/backend/src/index.js +++ /dev/null @@ -1,49 +0,0 @@ -import app from './app.js'; -import { appConfig } from './config/index.js'; -import { initDB } from './services/db.js'; -import { validateRequiredConfig } from './config/validate.js'; -import { startCleanupSchedule } from './services/uploadCleanup.js'; - -/* ---- 启动:先校验配置,再完成数据库初始化,最后监听端口 ---- */ -async function start() { - // 1. 校验必填环境变量(生产环境严格要求 ENCRYPTION_KEY / JWT_SECRET 等) - const configErrors = validateRequiredConfig(); - if (configErrors.length > 0) { - console.error('[App] 配置校验失败:'); - configErrors.forEach((e) => console.error(` - ${e}`)); - process.exit(1); - } - - // 2. 数据库初始化 - try { - await initDB(); - } catch (err) { - console.error('[App] 数据库初始化失败,服务无法启动'); - process.exit(1); - } - - // 3. 启动上传文件定时清理(P2-4.9) - const cleanupHandles = startCleanupSchedule(); - - // 4. 启动 HTTP 服务 - const server = app.listen(appConfig.port, () => { - console.log(`[${appConfig.env}] 服务运行在端口 ${appConfig.port}`); - }); - - // 5. 优雅关闭:清理定时器和连接(P2 审查 I3) - function shutdown(signal) { - console.log(`[App] 收到 ${signal},正在关闭...`); - if (cleanupHandles?.timer) clearInterval(cleanupHandles.timer); - if (cleanupHandles?.initialTimer) clearTimeout(cleanupHandles.initialTimer); - server.close(() => { - console.log('[App] HTTP 服务已关闭'); - process.exit(0); - }); - // 5s 超时强制退出 - setTimeout(() => process.exit(1), 5000).unref(); - } - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); -} - -start(); diff --git a/backend/src/middleware/__tests__/auth.test.js b/backend/src/middleware/__tests__/auth.test.ts similarity index 100% rename from backend/src/middleware/__tests__/auth.test.js rename to backend/src/middleware/__tests__/auth.test.ts diff --git a/backend/src/middleware/__tests__/errorHandler.test.js b/backend/src/middleware/__tests__/errorHandler.test.ts similarity index 100% rename from backend/src/middleware/__tests__/errorHandler.test.js rename to backend/src/middleware/__tests__/errorHandler.test.ts diff --git a/backend/src/middleware/__tests__/logger.test.js b/backend/src/middleware/__tests__/logger.test.ts similarity index 100% rename from backend/src/middleware/__tests__/logger.test.js rename to backend/src/middleware/__tests__/logger.test.ts diff --git a/backend/src/middleware/__tests__/permission.test.js b/backend/src/middleware/__tests__/permission.test.ts similarity index 100% rename from backend/src/middleware/__tests__/permission.test.js rename to backend/src/middleware/__tests__/permission.test.ts diff --git a/backend/src/routes/__tests__/localAuth.test.js b/backend/src/routes/__tests__/localAuth.test.ts similarity index 100% rename from backend/src/routes/__tests__/localAuth.test.js rename to backend/src/routes/__tests__/localAuth.test.ts diff --git a/backend/src/routes/__tests__/records.test.js b/backend/src/routes/__tests__/records.test.ts similarity index 100% rename from backend/src/routes/__tests__/records.test.js rename to backend/src/routes/__tests__/records.test.ts diff --git a/backend/src/routes/__tests__/workItems.test.js b/backend/src/routes/__tests__/workItems.test.ts similarity index 100% rename from backend/src/routes/__tests__/workItems.test.js rename to backend/src/routes/__tests__/workItems.test.ts diff --git a/backend/src/services/__tests__/auditLog.test.js b/backend/src/services/__tests__/auditLog.test.ts similarity index 100% rename from backend/src/services/__tests__/auditLog.test.js rename to backend/src/services/__tests__/auditLog.test.ts diff --git a/backend/src/services/__tests__/importHelper.test.js b/backend/src/services/__tests__/importHelper.test.ts similarity index 100% rename from backend/src/services/__tests__/importHelper.test.js rename to backend/src/services/__tests__/importHelper.test.ts diff --git a/backend/src/services/__tests__/parser.test.js b/backend/src/services/__tests__/parser.test.ts similarity index 100% rename from backend/src/services/__tests__/parser.test.js rename to backend/src/services/__tests__/parser.test.ts diff --git a/backend/src/services/__tests__/pingcode.test.js b/backend/src/services/__tests__/pingcode.test.ts similarity index 100% rename from backend/src/services/__tests__/pingcode.test.js rename to backend/src/services/__tests__/pingcode.test.ts diff --git a/backend/src/services/__tests__/statsAnalyzer.test.js b/backend/src/services/__tests__/statsAnalyzer.test.ts similarity index 100% rename from backend/src/services/__tests__/statsAnalyzer.test.js rename to backend/src/services/__tests__/statsAnalyzer.test.ts diff --git a/backend/src/services/__tests__/uploadCleanup.test.js b/backend/src/services/__tests__/uploadCleanup.test.ts similarity index 100% rename from backend/src/services/__tests__/uploadCleanup.test.js rename to backend/src/services/__tests__/uploadCleanup.test.ts diff --git a/backend/src/utils/__tests__/array.test.js b/backend/src/utils/__tests__/array.test.ts similarity index 100% rename from backend/src/utils/__tests__/array.test.js rename to backend/src/utils/__tests__/array.test.ts diff --git a/backend/src/utils/__tests__/arrayConcurrency.test.js b/backend/src/utils/__tests__/arrayConcurrency.test.ts similarity index 100% rename from backend/src/utils/__tests__/arrayConcurrency.test.js rename to backend/src/utils/__tests__/arrayConcurrency.test.ts diff --git a/backend/src/utils/__tests__/assignee.test.js b/backend/src/utils/__tests__/assignee.test.ts similarity index 100% rename from backend/src/utils/__tests__/assignee.test.js rename to backend/src/utils/__tests__/assignee.test.ts diff --git a/backend/src/utils/__tests__/crypto.test.js b/backend/src/utils/__tests__/crypto.test.ts similarity index 100% rename from backend/src/utils/__tests__/crypto.test.js rename to backend/src/utils/__tests__/crypto.test.ts diff --git a/backend/src/utils/__tests__/response.test.js b/backend/src/utils/__tests__/response.test.ts similarity index 100% rename from backend/src/utils/__tests__/response.test.js rename to backend/src/utils/__tests__/response.test.ts diff --git a/backend/src/utils/__tests__/retry.test.js b/backend/src/utils/__tests__/retry.test.ts similarity index 100% rename from backend/src/utils/__tests__/retry.test.js rename to backend/src/utils/__tests__/retry.test.ts diff --git a/backend/src/utils/__tests__/syncCompare.test.js b/backend/src/utils/__tests__/syncCompare.test.ts similarity index 100% rename from backend/src/utils/__tests__/syncCompare.test.js rename to backend/src/utils/__tests__/syncCompare.test.ts diff --git a/backend/src/utils/__tests__/workItem.test.js b/backend/src/utils/__tests__/workItem.test.ts similarity index 100% rename from backend/src/utils/__tests__/workItem.test.js rename to backend/src/utils/__tests__/workItem.test.ts diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 599429a..5354dfb 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, include: ['src/**/__tests__/**/*.test.{js,ts}'], - setupFiles: ['src/__tests__/setup.js'], + setupFiles: ['src/__tests__/setup.ts'], coverage: { provider: 'v8', reportsDirectory: 'coverage', From 09afd1f9f920bf1b58d08c499a3f58da716fa7c2 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 13:39:33 +0800 Subject: [PATCH 11/12] =?UTF-8?q?build(backend):=20Phase=209=20=E2=80=94?= =?UTF-8?q?=20enable=20strict=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig: strict:true + strictNullChecks/noImplicitAny/noUnusedLocals/ noUnusedParameters true. noImplicitReturns kept false — Express route handlers legitimately mix `return res.json()` early-exit with fall-through paths, and forcing consistency there adds churn with no behavioral value. Fixed the 28 surfaced errors (the other 40 were noImplicitReturns): - null safety: assert non-null on user.access_token/domain/pingcode_user_id in routes behind ensureFreshToken (sync/stats/workItems destructures); targetProjectId! at fetchAndStore calls; domain ?? undefined for optional pingcode domain params - unused: dropped unused Op import; _-prefixed unused req/next/params - Sequelize CreationAttributes strictness: Model.create/findOrCreate args for Role/User cast as any (auto-generated id field friction under strict) Verified: tsc --noEmit --strict (0 errors) · pnpm test (176/176) · pnpm build (dist/ emitted). Backend TypeScript migration complete: all 77 source+test files migrated, strict type-checking on, full test suite green. Co-Authored-By: Claude Fable 5 --- backend/src/routes/localAuth.ts | 2 +- backend/src/routes/models.ts | 4 ++-- backend/src/routes/roles.ts | 5 ++--- backend/src/routes/stats.ts | 2 +- backend/src/routes/sync.ts | 2 +- backend/src/routes/users.ts | 2 +- backend/src/routes/workItems.ts | 19 ++++++++++++------- backend/src/services/db.ts | 4 ++-- backend/tsconfig.json | 10 +++++----- 9 files changed, 27 insertions(+), 23 deletions(-) diff --git a/backend/src/routes/localAuth.ts b/backend/src/routes/localAuth.ts index 55c750a..6f99c86 100644 --- a/backend/src/routes/localAuth.ts +++ b/backend/src/routes/localAuth.ts @@ -23,7 +23,7 @@ router.post('/register', registerLimiter, async (req: Request, res: Response, ne username, password_hash: hashedPassword, pingcode_email: email, - }); + } as any); // 为新用户分配默认角色 const defaultRole = await Role.findOne({ where: { name: 'user' } }); diff --git a/backend/src/routes/models.ts b/backend/src/routes/models.ts index 36fa8c9..146e3d0 100644 --- a/backend/src/routes/models.ts +++ b/backend/src/routes/models.ts @@ -34,7 +34,7 @@ router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: Nex }); /** 测试模型连接(通过请求体传入配置,用于新增/编辑前测试) */ -router.post('/test', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { +router.post('/test', requireAuth, async (req: AuthedRequest, res: Response, _next: NextFunction) => { try { const { provider, api_key, base_url, model } = req.body; @@ -102,7 +102,7 @@ router.get('/:id', requireAuth, async (req: AuthedRequest, res: Response, next: }); /** 测试模型连接 */ -router.post('/:id/test', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { +router.post('/:id/test', requireAuth, async (req: AuthedRequest, res: Response, _next: NextFunction) => { try { const userId = req.user!.id; const config = await ModelConfig.findOne({ diff --git a/backend/src/routes/roles.ts b/backend/src/routes/roles.ts index 0d3ec48..8529b92 100644 --- a/backend/src/routes/roles.ts +++ b/backend/src/routes/roles.ts @@ -1,7 +1,6 @@ import express from 'express'; import type { Response, NextFunction } from 'express'; import crypto from 'node:crypto'; -import { Op } from 'sequelize'; import { requireAuth } from '../middleware/auth.js'; import { requireAdmin } from '../middleware/permission.js'; import type { AuthedRequest } from '../types/authRequest.js'; @@ -14,7 +13,7 @@ const router = express.Router(); // ===== 角色管理 ===== /** 获取所有角色 */ -router.get('/', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { +router.get('/', requireAuth, async (_req: AuthedRequest, res: Response, next: NextFunction) => { try { const roles = await Role.findAll({ order: [['createdAt', 'ASC']], @@ -126,7 +125,7 @@ router.delete('/:id', requireAuth, requireAdmin, async (req: AuthedRequest, res: // ===== 权限管理 ===== /** 获取所有权限 */ -router.get('/permissions', requireAuth, async (req: AuthedRequest, res: Response, next: NextFunction) => { +router.get('/permissions', requireAuth, async (_req: AuthedRequest, res: Response, next: NextFunction) => { try { const permissions = await Permission.findAll({ order: [['resource', 'ASC'], ['action', 'ASC']], diff --git a/backend/src/routes/stats.ts b/backend/src/routes/stats.ts index 22083c7..b7a3c86 100644 --- a/backend/src/routes/stats.ts +++ b/backend/src/routes/stats.ts @@ -181,7 +181,7 @@ export async function getProjectStatsData(user: User, projectId: string): Promis if (cached) return cached; const accessToken = user.access_token; - const domain = user.domain; + const domain = user.domain ?? undefined; const project = await SyncedProject.findOne({ where: { id: projectId, user_id: userId }, diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts index f2ee624..616e701 100644 --- a/backend/src/routes/sync.ts +++ b/backend/src/routes/sync.ts @@ -39,7 +39,7 @@ router.post('/sync-data', requireAuth, ensureFreshToken, async (req: AuthedReque const user = req.user!; const userId = user.id; const accessToken = user.access_token; - const domain = user.domain; + const domain = user.domain!; if (!accessToken) { return res.status(400).json({ success: false, error: '未连接 PingCode,请先完成授权' }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index a08fb23..6a41065 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -112,7 +112,7 @@ router.post('/', requireAuth, requireAdmin, async (req: AuthedRequest, res: Resp username, password_hash, pingcode_email: email, - }); + } as any); // 为新用户分配默认角色(user 角色) const defaultRole = await Role.findOne({ where: { name: 'user' } }); diff --git a/backend/src/routes/workItems.ts b/backend/src/routes/workItems.ts index eedd50b..b0d84ae 100644 --- a/backend/src/routes/workItems.ts +++ b/backend/src/routes/workItems.ts @@ -181,7 +181,9 @@ router.post('/import', requireAuth, ensureFreshToken, async (req: AuthedRequest, return res.status(400).json({ success: false, error: '请提供工作项列表' }); } - const { access_token, domain, pingcode_user_id } = req.user!; + const access_token = req.user!.access_token!; + const domain = req.user!.domain!; + const pingcode_user_id = req.user!.pingcode_user_id!; try { // 校验 record_id 归属当前用户 @@ -257,7 +259,7 @@ router.post('/import', requireAuth, ensureFreshToken, async (req: AuthedRequest, // 为新项目拉取并存储元数据,确保后续 type_id / priority_id 能正确解析 const metaCounts = await fetchAndStoreMetadataForProject( - req.user!.id, access_token, domain, targetProjectId, + req.user!.id, access_token, domain, targetProjectId!, ); console.log(`[Import] 新项目元数据同步: +${metaCounts.types} types, +${metaCounts.priorities} priorities`); } catch (createErr: any) { @@ -398,7 +400,9 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req: AuthedR res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } - const { access_token, domain, pingcode_user_id } = req.user!; + const access_token = req.user!.access_token!; + const domain = req.user!.domain!; + const pingcode_user_id = req.user!.pingcode_user_id!; try { let recordBelongsToUser = false; @@ -453,7 +457,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req: AuthedR sendEvent('project_created', { name: newProject.name }); const metaCounts = await fetchAndStoreMetadataForProject( - req.user!.id, access_token, domain, targetProjectId, + req.user!.id, access_token, domain, targetProjectId!, ); console.log(`[Import-Stream] 新项目元数据同步: +${metaCounts.types} types, +${metaCounts.priorities} priorities`); } catch (err: any) { @@ -499,7 +503,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req: AuthedR const batchResult = await createWorkItemsBatch( access_token, pingcodeItems, domain, - (current, total, itemInfo) => { + (_current, _total, itemInfo) => { processedItems++; sendEvent('progress', { current: processedItems, total: totalItems, @@ -562,7 +566,7 @@ router.post('/import-stream', requireAuth, ensureFreshToken, async (req: AuthedR * 更新单个 PingCode 工作项(P3-5.2:工作项更新/编辑能力)。 * 支持 PATCH 语义:仅传入需要更新的字段。 */ -router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, next: NextFunction) => { +router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req: AuthedRequest, res: Response, _next: NextFunction) => { const { id } = req.params as { id: string }; if (!id) { return res.status(400).json({ success: false, error: '缺少工作项 ID' }); @@ -592,7 +596,8 @@ router.patch('/work-items/:id', requireAuth, ensureFreshToken, async (req: Authe } try { - const { access_token, domain } = req.user!; + const access_token = req.user!.access_token!; + const domain = req.user!.domain!; const result = await updateWorkItem(access_token, id, updateData, domain); res.json(success(result, '更新成功')); } catch (e: any) { diff --git a/backend/src/services/db.ts b/backend/src/services/db.ts index a0d8530..1dcf468 100644 --- a/backend/src/services/db.ts +++ b/backend/src/services/db.ts @@ -74,7 +74,7 @@ async function initDefaultRoles(): Promise { for (const roleData of defaultRoles) { const [role, created] = await Role.findOrCreate({ where: { name: roleData.name }, - defaults: roleData, + defaults: roleData as any, }); if (created) { console.log(`[DB] 创建默认角色: ${role.display_name}`); @@ -101,7 +101,7 @@ async function initDefaultAdmin(): Promise { const user = await User.create({ username: 'admin', password_hash: passwordHash, - }); + } as any); await UserRole.create({ user_id: user.id, role_id: adminRole.id, diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2986001..aa88c12 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -18,11 +18,11 @@ "removeComments": false, // 严格度(初始宽松,Phase 9 收紧) - "strict": false, - "noImplicitAny": false, - "strictNullChecks": false, - "noUnusedLocals": false, - "noUnusedParameters": false, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noImplicitReturns": false, // 互操作 From 06193ca468c784f317b8b92e9848ed919117f794 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 11 Jul 2026 18:49:55 +0800 Subject: [PATCH 12/12] chore: add HANDOFF.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 304ea1c..84e9408 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ docs/ doc/ uploads/ backend/uploads/ +HANDOFF.md # Environment variables (may contain secrets) .env