From 4fb551106a601455c0ecd042aafcac3ffbf584b5 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 <99163721+Yaosanqi137@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:48:54 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"=E5=AE=9E=E7=8E=B0=20AI=20=E4=B8=89?= =?UTF-8?q?=E8=B7=AF=E6=B8=A0=E9=81=93=E8=B7=AF=E7=94=B1=E3=80=81Copilot?= =?UTF-8?q?=20=E4=B8=8A=E4=B8=8B=E6=96=87=E4=B8=8E=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/api-docker-image.yml | 66 - .github/workflows/deploy-admin.yml | 59 - .github/workflows/deploy-web.yml | 59 - .github/workflows/pr-quality.yml | 46 - .gitignore | 6 +- .husky/pre-commit | 1 - .husky/pre-push | 2 - .lintstagedrc.cjs | 4 - .prettierignore | 7 - .prettierrc.json | 6 - CONTRIBUTING.md | 69 - README.md | 125 +- apps/admin/.gitkeep | 0 apps/api/.env.example | 73 - apps/api/.gitignore | 8 - apps/api/jest.config.cjs | 11 - apps/api/package.json | 61 - apps/api/prisma.config.ts | 13 - apps/api/prisma/schema.prisma | 408 - apps/api/scripts/reencrypt-sensitive-data.ts | 418 - .../src/ai/ai-provider-registry.service.ts | 28 - apps/api/src/ai/ai-rate-limit.service.ts | 123 - apps/api/src/ai/ai.controller.ts | 74 - apps/api/src/ai/ai.module.ts | 21 - apps/api/src/ai/ai.service.ts | 988 -- apps/api/src/ai/ai.types.ts | 61 - apps/api/src/ai/dto/ai-chat.dto.ts | 60 - .../ai/dto/list-ai-usage-logs-query.dto.ts | 48 - .../ai/dto/upsert-ai-provider-binding.dto.ts | 47 - apps/api/src/ai/providers/astrbot.provider.ts | 284 - .../providers/openai-compatible.provider.ts | 300 - apps/api/src/app.module.ts | 27 - .../src/attachment/attachment.controller.ts | 38 - apps/api/src/attachment/attachment.module.ts | 11 - apps/api/src/attachment/attachment.service.ts | 335 - .../attachment/dto/complete-attachment.dto.ts | 89 - .../attachment/dto/presign-attachment.dto.ts | 35 - apps/api/src/auth/auth-mail.service.ts | 131 - apps/api/src/auth/auth.controller.ts | 120 - apps/api/src/auth/auth.module.ts | 33 - apps/api/src/auth/auth.service.ts | 288 - apps/api/src/auth/dto/email-login.dto.ts | 11 - apps/api/src/auth/dto/refresh-token.dto.ts | 7 - apps/api/src/auth/dto/send-email-code.dto.ts | 6 - .../api/src/auth/dto/two-factor-enroll.dto.ts | 6 - .../api/src/auth/dto/two-factor-verify.dto.ts | 11 - .../src/auth/strategies/github.strategy.ts | 32 - apps/api/src/auth/strategies/qq.strategy.ts | 33 - .../src/auth/strategies/wechat.strategy.ts | 36 - apps/api/src/main.ts | 31 - apps/api/src/prisma/prisma.module.ts | 9 - apps/api/src/prisma/prisma.service.ts | 28 - .../src/security/data-encryption.service.ts | 155 - apps/api/src/security/security.module.ts | 9 - apps/api/src/sync/dto/sync-pull.dto.ts | 16 - apps/api/src/sync/dto/sync-push.dto.ts | 62 - apps/api/src/sync/sync.controller.ts | 34 - apps/api/src/sync/sync.module.ts | 11 - apps/api/src/sync/sync.service.ts | 309 - apps/api/src/task/dto/create-task.dto.ts | 64 - apps/api/src/task/dto/list-tasks-query.dto.ts | 92 - apps/api/src/task/dto/update-task.dto.ts | 65 - apps/api/src/task/task.controller.ts | 71 - apps/api/src/task/task.module.ts | 11 - apps/api/src/task/task.service.ts | 458 - apps/api/test/ai.spec.ts | 1250 -- apps/api/test/astrbot-provider.spec.ts | 73 - apps/api/test/auth.spec.ts | 355 - .../test/openai-compatible-provider.spec.ts | 80 - apps/api/test/sync-push.spec.ts | 439 - apps/api/test/task.spec.ts | 481 - apps/api/tsconfig.build.json | 5 - apps/api/tsconfig.json | 10 - apps/api/tsconfig.spec.json | 9 - apps/web/.gitignore | 24 - apps/web/README.md | 57 - apps/web/components.json | 25 - apps/web/eslint.config.js | 23 - apps/web/index.html | 14 - apps/web/package.json | 51 - apps/web/postcss.config.js | 6 - apps/web/public/favicon.png | Bin 203435 -> 0 bytes apps/web/public/favicon.svg | 1 - apps/web/public/icons.svg | 24 - apps/web/src/App.css | 184 - apps/web/src/App.tsx | 386 - apps/web/src/assets/hero.png | Bin 44919 -> 0 bytes apps/web/src/assets/react.svg | 1 - apps/web/src/assets/vite.svg | 1 - apps/web/src/components/ai/ai-shared.ts | 72 - .../editor/resizable-media-node-view.tsx | 283 - apps/web/src/components/task-rich-editor.tsx | 608 - apps/web/src/components/ui/button.tsx | 59 - apps/web/src/extensions/resizable-image.tsx | 42 - apps/web/src/extensions/resizable-video.tsx | 99 - apps/web/src/extensions/resizable-youtube.tsx | 32 - apps/web/src/hooks/use-sync-engine.ts | 296 - apps/web/src/index.css | 77 - apps/web/src/lib/utils.ts | 6 - apps/web/src/main.tsx | 16 - apps/web/src/pages/ai-chat-page.tsx | 539 - apps/web/src/pages/email-login-page.tsx | 175 - apps/web/src/pages/oauth-callback-page.tsx | 68 - apps/web/src/pages/placeholder-page.tsx | 26 - apps/web/src/pages/settings-page.tsx | 549 - apps/web/src/pages/todo-shell-page.tsx | 953 - apps/web/src/services/ai-api.ts | 198 - apps/web/src/services/auth-api.ts | 98 - apps/web/src/services/local-ai-chat-repo.ts | 98 - apps/web/src/services/local-crypto.ts | 126 - apps/web/src/services/local-db.ts | 148 - .../web/src/services/local-sensitive-codec.ts | 150 - apps/web/src/services/local-sync-repo.ts | 178 - .../web/src/services/local-task-draft-repo.ts | 38 - apps/web/src/services/local-task-repo.ts | 225 - apps/web/src/services/session-storage.ts | 67 - apps/web/src/services/storage-quota.ts | 53 - apps/web/src/services/sync-api.ts | 159 - apps/web/src/services/sync-merge.ts | 269 - apps/web/src/services/sync-worker.ts | 142 - apps/web/src/services/theme-storage.ts | 29 - apps/web/tailwind.config.js | 43 - apps/web/tsconfig.app.json | 32 - apps/web/tsconfig.json | 10 - apps/web/tsconfig.node.json | 26 - apps/web/vite.config.ts | 13 - docs/architecture/ADR-template.md | 62 - eslint.config.mjs | 29 - package.json | 30 - packages/eslint-config/base.cjs | 16 - packages/eslint-config/package.json | 11 - packages/sdk/.gitkeep | 0 packages/shared-types/.gitkeep | 0 packages/tsconfig/base.json | 17 - packages/tsconfig/nest-app.json | 13 - packages/tsconfig/package.json | 12 - packages/tsconfig/react-app.json | 9 - packages/ui/.gitkeep | 0 pnpm-lock.yaml | 14349 ---------------- pnpm-workspace.yaml | 3 - turbo.json | 28 - 141 files changed, 18 insertions(+), 30212 deletions(-) delete mode 100644 .github/workflows/api-docker-image.yml delete mode 100644 .github/workflows/deploy-admin.yml delete mode 100644 .github/workflows/deploy-web.yml delete mode 100644 .github/workflows/pr-quality.yml delete mode 100755 .husky/pre-commit delete mode 100755 .husky/pre-push delete mode 100644 .lintstagedrc.cjs delete mode 100644 .prettierignore delete mode 100644 .prettierrc.json delete mode 100644 CONTRIBUTING.md delete mode 100644 apps/admin/.gitkeep delete mode 100644 apps/api/.env.example delete mode 100644 apps/api/.gitignore delete mode 100644 apps/api/jest.config.cjs delete mode 100644 apps/api/package.json delete mode 100644 apps/api/prisma.config.ts delete mode 100644 apps/api/prisma/schema.prisma delete mode 100644 apps/api/scripts/reencrypt-sensitive-data.ts delete mode 100644 apps/api/src/ai/ai-provider-registry.service.ts delete mode 100644 apps/api/src/ai/ai-rate-limit.service.ts delete mode 100644 apps/api/src/ai/ai.controller.ts delete mode 100644 apps/api/src/ai/ai.module.ts delete mode 100644 apps/api/src/ai/ai.service.ts delete mode 100644 apps/api/src/ai/ai.types.ts delete mode 100644 apps/api/src/ai/dto/ai-chat.dto.ts delete mode 100644 apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts delete mode 100644 apps/api/src/ai/dto/upsert-ai-provider-binding.dto.ts delete mode 100644 apps/api/src/ai/providers/astrbot.provider.ts delete mode 100644 apps/api/src/ai/providers/openai-compatible.provider.ts delete mode 100644 apps/api/src/app.module.ts delete mode 100644 apps/api/src/attachment/attachment.controller.ts delete mode 100644 apps/api/src/attachment/attachment.module.ts delete mode 100644 apps/api/src/attachment/attachment.service.ts delete mode 100644 apps/api/src/attachment/dto/complete-attachment.dto.ts delete mode 100644 apps/api/src/attachment/dto/presign-attachment.dto.ts delete mode 100644 apps/api/src/auth/auth-mail.service.ts delete mode 100644 apps/api/src/auth/auth.controller.ts delete mode 100644 apps/api/src/auth/auth.module.ts delete mode 100644 apps/api/src/auth/auth.service.ts delete mode 100644 apps/api/src/auth/dto/email-login.dto.ts delete mode 100644 apps/api/src/auth/dto/refresh-token.dto.ts delete mode 100644 apps/api/src/auth/dto/send-email-code.dto.ts delete mode 100644 apps/api/src/auth/dto/two-factor-enroll.dto.ts delete mode 100644 apps/api/src/auth/dto/two-factor-verify.dto.ts delete mode 100644 apps/api/src/auth/strategies/github.strategy.ts delete mode 100644 apps/api/src/auth/strategies/qq.strategy.ts delete mode 100644 apps/api/src/auth/strategies/wechat.strategy.ts delete mode 100644 apps/api/src/main.ts delete mode 100644 apps/api/src/prisma/prisma.module.ts delete mode 100644 apps/api/src/prisma/prisma.service.ts delete mode 100644 apps/api/src/security/data-encryption.service.ts delete mode 100644 apps/api/src/security/security.module.ts delete mode 100644 apps/api/src/sync/dto/sync-pull.dto.ts delete mode 100644 apps/api/src/sync/dto/sync-push.dto.ts delete mode 100644 apps/api/src/sync/sync.controller.ts delete mode 100644 apps/api/src/sync/sync.module.ts delete mode 100644 apps/api/src/sync/sync.service.ts delete mode 100644 apps/api/src/task/dto/create-task.dto.ts delete mode 100644 apps/api/src/task/dto/list-tasks-query.dto.ts delete mode 100644 apps/api/src/task/dto/update-task.dto.ts delete mode 100644 apps/api/src/task/task.controller.ts delete mode 100644 apps/api/src/task/task.module.ts delete mode 100644 apps/api/src/task/task.service.ts delete mode 100644 apps/api/test/ai.spec.ts delete mode 100644 apps/api/test/astrbot-provider.spec.ts delete mode 100644 apps/api/test/auth.spec.ts delete mode 100644 apps/api/test/openai-compatible-provider.spec.ts delete mode 100644 apps/api/test/sync-push.spec.ts delete mode 100644 apps/api/test/task.spec.ts delete mode 100644 apps/api/tsconfig.build.json delete mode 100644 apps/api/tsconfig.json delete mode 100644 apps/api/tsconfig.spec.json delete mode 100644 apps/web/.gitignore delete mode 100644 apps/web/README.md delete mode 100644 apps/web/components.json delete mode 100644 apps/web/eslint.config.js delete mode 100644 apps/web/index.html delete mode 100644 apps/web/package.json delete mode 100644 apps/web/postcss.config.js delete mode 100644 apps/web/public/favicon.png delete mode 100644 apps/web/public/favicon.svg delete mode 100644 apps/web/public/icons.svg delete mode 100644 apps/web/src/App.css delete mode 100644 apps/web/src/App.tsx delete mode 100644 apps/web/src/assets/hero.png delete mode 100644 apps/web/src/assets/react.svg delete mode 100644 apps/web/src/assets/vite.svg delete mode 100644 apps/web/src/components/ai/ai-shared.ts delete mode 100644 apps/web/src/components/editor/resizable-media-node-view.tsx delete mode 100644 apps/web/src/components/task-rich-editor.tsx delete mode 100644 apps/web/src/components/ui/button.tsx delete mode 100644 apps/web/src/extensions/resizable-image.tsx delete mode 100644 apps/web/src/extensions/resizable-video.tsx delete mode 100644 apps/web/src/extensions/resizable-youtube.tsx delete mode 100644 apps/web/src/hooks/use-sync-engine.ts delete mode 100644 apps/web/src/index.css delete mode 100644 apps/web/src/lib/utils.ts delete mode 100644 apps/web/src/main.tsx delete mode 100644 apps/web/src/pages/ai-chat-page.tsx delete mode 100644 apps/web/src/pages/email-login-page.tsx delete mode 100644 apps/web/src/pages/oauth-callback-page.tsx delete mode 100644 apps/web/src/pages/placeholder-page.tsx delete mode 100644 apps/web/src/pages/settings-page.tsx delete mode 100644 apps/web/src/pages/todo-shell-page.tsx delete mode 100644 apps/web/src/services/ai-api.ts delete mode 100644 apps/web/src/services/auth-api.ts delete mode 100644 apps/web/src/services/local-ai-chat-repo.ts delete mode 100644 apps/web/src/services/local-crypto.ts delete mode 100644 apps/web/src/services/local-db.ts delete mode 100644 apps/web/src/services/local-sensitive-codec.ts delete mode 100644 apps/web/src/services/local-sync-repo.ts delete mode 100644 apps/web/src/services/local-task-draft-repo.ts delete mode 100644 apps/web/src/services/local-task-repo.ts delete mode 100644 apps/web/src/services/session-storage.ts delete mode 100644 apps/web/src/services/storage-quota.ts delete mode 100644 apps/web/src/services/sync-api.ts delete mode 100644 apps/web/src/services/sync-merge.ts delete mode 100644 apps/web/src/services/sync-worker.ts delete mode 100644 apps/web/src/services/theme-storage.ts delete mode 100644 apps/web/tailwind.config.js delete mode 100644 apps/web/tsconfig.app.json delete mode 100644 apps/web/tsconfig.json delete mode 100644 apps/web/tsconfig.node.json delete mode 100644 apps/web/vite.config.ts delete mode 100644 docs/architecture/ADR-template.md delete mode 100644 eslint.config.mjs delete mode 100644 package.json delete mode 100644 packages/eslint-config/base.cjs delete mode 100644 packages/eslint-config/package.json delete mode 100644 packages/sdk/.gitkeep delete mode 100644 packages/shared-types/.gitkeep delete mode 100644 packages/tsconfig/base.json delete mode 100644 packages/tsconfig/nest-app.json delete mode 100644 packages/tsconfig/package.json delete mode 100644 packages/tsconfig/react-app.json delete mode 100644 packages/ui/.gitkeep delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml delete mode 100644 turbo.json diff --git a/.github/workflows/api-docker-image.yml b/.github/workflows/api-docker-image.yml deleted file mode 100644 index 685084a..0000000 --- a/.github/workflows/api-docker-image.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: API Docker Image - -on: - pull_request: - branches: [main, develop] - paths: - - "apps/api/**" - - ".github/workflows/api-docker-image.yml" - push: - branches: [main] - paths: - - "apps/api/**" - - ".github/workflows/api-docker-image.yml" - workflow_dispatch: - -concurrency: - group: api-docker-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-publish: - name: Build API Docker Image - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check Dockerfile - id: dockerfile - run: | - if [ -f apps/api/Dockerfile ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Setup Docker Buildx - if: steps.dockerfile.outputs.exists == 'true' - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - if: steps.dockerfile.outputs.exists == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build (PR/manual) or Build and Push (main) - if: steps.dockerfile.outputs.exists == 'true' - uses: docker/build-push-action@v6 - with: - context: ./apps/api - file: ./apps/api/Dockerfile - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - tags: | - ghcr.io/${{ github.repository }}/api:${{ github.sha }} - ghcr.io/${{ github.repository }}/api:latest - - - name: Skip notice - if: steps.dockerfile.outputs.exists != 'true' - run: echo "apps/api/Dockerfile not found, skip docker build." diff --git a/.github/workflows/deploy-admin.yml b/.github/workflows/deploy-admin.yml deleted file mode 100644 index 4c1f691..0000000 --- a/.github/workflows/deploy-admin.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Deploy Admin - -on: - push: - branches: [main] - paths: - - "apps/admin/**" - - "packages/shared-types/**" - - "packages/ui/**" - - ".github/workflows/deploy-admin.yml" - workflow_dispatch: - -concurrency: - group: deploy-admin-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build Admin - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build workspace - run: pnpm run build - - deploy: - name: Deploy Admin (Template) - runs-on: ubuntu-latest - needs: build - - steps: - - name: Trigger deployment webhook - env: - ADMIN_DEPLOY_WEBHOOK_URL: ${{ secrets.ADMIN_DEPLOY_WEBHOOK_URL }} - run: | - if [ -z "$ADMIN_DEPLOY_WEBHOOK_URL" ]; then - echo "ADMIN_DEPLOY_WEBHOOK_URL is not configured. Skipping deploy." - exit 0 - fi - - curl -X POST "$ADMIN_DEPLOY_WEBHOOK_URL" - echo "Admin deployment webhook triggered." diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml deleted file mode 100644 index a55a8be..0000000 --- a/.github/workflows/deploy-web.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Deploy Web - -on: - push: - branches: [main] - paths: - - "apps/web/**" - - "packages/shared-types/**" - - "packages/ui/**" - - ".github/workflows/deploy-web.yml" - workflow_dispatch: - -concurrency: - group: deploy-web-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build Web - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build workspace - run: pnpm run build - - deploy: - name: Deploy Web (Template) - runs-on: ubuntu-latest - needs: build - - steps: - - name: Trigger deployment webhook - env: - WEB_DEPLOY_WEBHOOK_URL: ${{ secrets.WEB_DEPLOY_WEBHOOK_URL }} - run: | - if [ -z "$WEB_DEPLOY_WEBHOOK_URL" ]; then - echo "WEB_DEPLOY_WEBHOOK_URL is not configured. Skipping deploy." - exit 0 - fi - - curl -X POST "$WEB_DEPLOY_WEBHOOK_URL" - echo "Web deployment webhook triggered." diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml deleted file mode 100644 index 2dbdb43..0000000 --- a/.github/workflows/pr-quality.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: PR Quality - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: [main, develop] - -concurrency: - group: pr-quality-${{ github.ref }} - cancel-in-progress: true - -jobs: - quality: - name: Lint, Typecheck, Test, Build - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.15.2 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm run lint - - - name: Typecheck - run: pnpm run typecheck - - - name: Test - run: pnpm run test - - - name: Build - run: pnpm run build diff --git a/.gitignore b/.gitignore index ce34796..5fcdc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,2 @@ develop.md -node_modules/ -.turbo/ -.idea/ -.eslintcache -/.husky/_ +.idea/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index f9241ee..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm lint:staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 4226722..0000000 --- a/.husky/pre-push +++ /dev/null @@ -1,2 +0,0 @@ -pnpm typecheck -pnpm test diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs deleted file mode 100644 index b7ddb53..0000000 --- a/.lintstagedrc.cjs +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - "*.{js,mjs,cjs,ts,tsx}": ["eslint --fix", "prettier --write"], - "*.{json,md,yml,yaml}": ["prettier --write"] -}; diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 1613f18..0000000 --- a/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.turbo -.idea -dist -build -coverage -*.png diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 8a0f27e..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "trailingComma": "none", - "printWidth": 100 -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 728d44f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,69 +0,0 @@ -# 贡献指南(Contributing) - -本文档定义 TodoList 仓库的协作规范,所有贡献者提交代码前请先阅读。 - -## 1. 分支模型 - -- 长期分支: - - `main`:生产稳定分支 - - `develop`:开发集成分支 -- 功能分支: - - 命名:`feature/-` - - 示例:`feature/p1-code-quality-hooks` -- 其他分支: - - `release/` - - `hotfix/-` - -## 2. 提交流程 - -1. 从目标基线分支切出功能分支。 -2. 每完成一个小功能,提交一个最小 commit。 -3. 完成后推送分支并创建 PR。 -4. 通过 Code Review 后再合并到目标分支。 - -## 3. Commit 规范 - -- 使用 Conventional Commits: - - `feat(scope): ...` - - `fix(scope): ...` - - `chore(scope): ...` - - `docs(scope): ...` - - `test(scope): ...` - - `ci(scope): ...` -- 要求: - - commit 粒度最小化,不要把多个不相关改动塞进一个提交。 - - commit 必须可回滚、可解释。 - - 默认使用 GPG 签名提交:`git commit -S`。 - -## 4. PR 规范 - -- PR 标题简明描述变更目标。 -- PR 描述至少包含: - - 变更概述 - - 具体改动 - - 测试结果 - - 风险评估 - - 回滚方案 -- 一个 PR 只解决一类问题,避免“超大 PR”。 - -## 5. 代码质量检查 - -提交前建议至少执行: - -```bash -pnpm install -pnpm run lint -pnpm run typecheck -pnpm run test -``` - -说明: - -- `pre-commit` 会自动执行 `lint-staged`。 -- `pre-push` 会自动执行 `typecheck + test`。 - -## 6. 变更边界要求 - -- 不要提交无关文件(例如本地 IDE 缓存、临时导出文件)。 -- 不要随意修改与当前任务无关的历史代码。 -- 如发现仓库出现非本人预期改动,先暂停并和维护者确认。 diff --git a/README.md b/README.md index 98c8577..29367df 100644 --- a/README.md +++ b/README.md @@ -71,23 +71,23 @@ > 状态说明:`[x]` 已完成,`[ ]` 进行中/未开始(请随开发进度更新) -| 顺序 | 功能实现项(用户视角) | 你会看到的效果 | 状态 | -| ---- | ---------------------------------- | --------------------------------------- | ---- | -| 1 | 明确产品能力与交互流程 | 确认 TodoList 的核心使用方式与页面路径 | [x] | -| 2 | 实现基础登录(邮箱验证码) | 可以注册/登录并进入主页面 | [x] | -| 3 | 实现任务基础能力(增删改查) | 可以创建、编辑、删除、完成任务 | [x] | -| 4 | 实现富文本与媒体内容 | 任务详情可插入图片、视频、链接等内容 | [x] | -| 5 | 实现本地离线存储(Dexie) | 无网时仍可打开并编辑任务 | [ ] | -| 6 | 实现云端同步与冲突处理 | 恢复网络后自动同步,冲突按规则合并 | [ ] | -| 7 | 实现提醒系统(邮件) | DDL 临近时收到邮件提醒 | [ ] | -| 8 | 实现 AI 问答(用户自带 Key) | 可直接用自己的 AI API Key 获取建议 | [ ] | -| 9 | 实现 Astrbot Provider 接入 | 可复用 Astrbot 内配置的 AI 提供商 | [ ] | -| 10 | 实现公共 AI 通道(可开关) | 管理员开启后,用户可直接使用站点公共 AI | [ ] | -| 11 | 实现 Astrbot Skill 对接 | 可通过 QQ 机器人添加/修改任务与获取建议 | [ ] | -| 12 | 实现完整账号安全(2FA + OAuth) | 支持 2FA、QQ/微信/GitHub 登录 | [ ] | -| 13 | 实现 PWA 安装与离线体验优化 | 支持“添加到桌面”,像本地 App 一样使用 | [ ] | -| 14 | 实现管理后台(配额/日志/系统配置) | 管理员可管理用户配额、站点信息、日志 | [ ] | -| 15 | 上线前安全与性能收尾 | 使用更稳定、更安全,核心链路可观测 | [ ] | +| 顺序 | 功能实现项(用户视角) | 你会看到的效果 | 状态 | +|---|---|---|---| +| 1 | 明确产品能力与交互流程 | 确认 TodoList 的核心使用方式与页面路径 | [x] | +| 2 | 实现基础登录(邮箱验证码) | 可以注册/登录并进入主页面 | [ ] | +| 3 | 实现任务基础能力(增删改查) | 可以创建、编辑、删除、完成任务 | [ ] | +| 4 | 实现富文本与媒体内容 | 任务详情可插入图片、视频、链接等内容 | [ ] | +| 5 | 实现本地离线存储(Dexie) | 无网时仍可打开并编辑任务 | [ ] | +| 6 | 实现云端同步与冲突处理 | 恢复网络后自动同步,冲突按规则合并 | [ ] | +| 7 | 实现提醒系统(邮件) | DDL 临近时收到邮件提醒 | [ ] | +| 8 | 实现 AI 问答(用户自带 Key) | 可直接用自己的 AI API Key 获取建议 | [ ] | +| 9 | 实现 Astrbot Provider 接入 | 可复用 Astrbot 内配置的 AI 提供商 | [ ] | +| 10 | 实现公共 AI 通道(可开关) | 管理员开启后,用户可直接使用站点公共 AI | [ ] | +| 11 | 实现 Astrbot Skill 对接 | 可通过 QQ 机器人添加/修改任务与获取建议 | [ ] | +| 12 | 实现完整账号安全(2FA + OAuth) | 支持 2FA、QQ/微信/GitHub 登录 | [ ] | +| 13 | 实现 PWA 安装与离线体验优化 | 支持“添加到桌面”,像本地 App 一样使用 | [ ] | +| 14 | 实现管理后台(配额/日志/系统配置) | 管理员可管理用户配额、站点信息、日志 | [ ] | +| 15 | 上线前安全与性能收尾 | 使用更稳定、更安全,核心链路可观测 | [ ] | --- @@ -151,97 +151,6 @@ TodoList/ --- -## 部署与使用 - -### 1. 环境要求 - -- Node.js `20.x` -- pnpm `9.15.2` -- PostgreSQL `14+`(本地或远程都可) -- 可选:MinIO / S3(附件上传功能使用) - -### 2. 安装依赖 - -```bash -pnpm install -``` - -### 3. 后端环境变量配置 - -1. 复制环境变量示例文件: - -```bash -cp apps/api/.env.example apps/api/.env -# PowerShell: -# Copy-Item apps/api/.env.example apps/api/.env -``` - -2. 至少修改以下配置: - -- `DATABASE_URL`:你的 PostgreSQL 连接串 -- `AUTH_ACCESS_SECRET`:生产环境请改为高强度随机值 -- `MAIL_SMTP_*`:邮件服务器配置(验证码/提醒邮件) -- `OAUTH_*`:第三方登录配置(未接入可先保留示例值) -- `S3_*`:对象存储配置(未启用附件可后续再配) - -### 4. 初始化数据库 - -```bash -pnpm --filter @todolist/api exec prisma db push -``` - -### 5. 本地开发启动 - -1. 启动后端(默认端口 `3000`): - -```bash -pnpm --filter @todolist/api start:dev -``` - -2. 启动前端(默认端口 `5173`): - -```bash -pnpm --filter web dev -``` - -3. 若前端需连接非默认后端地址,可设置: - -```bash -VITE_API_BASE_URL=http://localhost:3000 -``` - -### 6. 生产构建与运行 - -1. 构建: - -```bash -pnpm run build -``` - -2. 运行 API(需先构建): - -```bash -pnpm --filter @todolist/api start -``` - -3. 发布 Web: - -- `apps/web/dist` 为静态资源产物,建议使用 Nginx/静态托管服务发布。 - -### 7. CI/CD 说明(当前仓库) - -- PR 质量检查:`.github/workflows/pr-quality.yml` -- Web 部署模板:`.github/workflows/deploy-web.yml` -- Admin 部署模板:`.github/workflows/deploy-admin.yml` -- API 镜像构建:`.github/workflows/api-docker-image.yml` - -说明: - -- Web/Admin 工作流通过 Webhook 触发真实部署,需在仓库 Secrets 配置: - - `WEB_DEPLOY_WEBHOOK_URL` - - `ADMIN_DEPLOY_WEBHOOK_URL` -- API 镜像工作流仅在存在 `apps/api/Dockerfile` 时执行镜像构建与推送。 - ## License 本项目遵循 [GNUv3](./LICENSE)。 diff --git a/apps/admin/.gitkeep b/apps/admin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/.env.example b/apps/api/.env.example deleted file mode 100644 index c8045bf..0000000 --- a/apps/api/.env.example +++ /dev/null @@ -1,73 +0,0 @@ -# ----------------------------------------------------------------------------- -# TodoList API 环境变量示例 -# 用法: -# 1) 复制为 apps/api/.env -# 2) 按实际环境替换值(尤其是密钥、密码、令牌) -# ----------------------------------------------------------------------------- - -# [数据库] PostgreSQL 连接串 -# 格式:postgresql://:@:/?schema=public -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public" - -# [鉴权] Access Token 签名密钥(生产环境必须使用高强度随机值) -AUTH_ACCESS_SECRET="dev-access-secret" -# [鉴权] Access Token 有效期(秒),默认 15 分钟 -AUTH_ACCESS_EXPIRES_IN_SECONDS="900" -# [鉴权] Refresh Token 有效期(秒),默认 30 天 -AUTH_REFRESH_EXPIRES_IN_SECONDS="2592000" -# [鉴权] 邮箱验证码有效期(秒),默认 5 分钟 -AUTH_EMAIL_CODE_TTL_SECONDS="300" -# [2FA] TOTP 签发方名称(会显示在验证器 App 中) -AUTH_TOTP_ISSUER="TodoList" - -# [OAuth - GitHub] 第三方登录配置 -OAUTH_GITHUB_CLIENT_ID="github-client-id" -OAUTH_GITHUB_CLIENT_SECRET="github-client-secret" -OAUTH_GITHUB_CALLBACK_URL="http://localhost:3000/auth/oauth/github/callback" - -# [OAuth - QQ] 第三方登录配置 -OAUTH_QQ_CLIENT_ID="qq-client-id" -OAUTH_QQ_CLIENT_SECRET="qq-client-secret" -OAUTH_QQ_CALLBACK_URL="http://localhost:3000/auth/oauth/qq/callback" -OAUTH_QQ_AUTH_URL="https://graph.qq.com/oauth2.0/authorize" -OAUTH_QQ_TOKEN_URL="https://graph.qq.com/oauth2.0/token" - -# [OAuth - 微信] 第三方登录配置 -OAUTH_WECHAT_CLIENT_ID="wechat-client-id" -OAUTH_WECHAT_CLIENT_SECRET="wechat-client-secret" -OAUTH_WECHAT_CALLBACK_URL="http://localhost:3000/auth/oauth/wechat/callback" -OAUTH_WECHAT_AUTH_URL="https://open.weixin.qq.com/connect/qrconnect" -OAUTH_WECHAT_TOKEN_URL="https://api.weixin.qq.com/sns/oauth2/access_token" - -# [对象存储] S3/MinIO 配置(附件上传) -# 本地开发可使用 MinIO,生产可切换到云厂商 S3 兼容服务 -S3_ENDPOINT="http://127.0.0.1:9000" -S3_REGION="us-east-1" -S3_BUCKET="todolist" -S3_ACCESS_KEY_ID="minioadmin" -S3_SECRET_ACCESS_KEY="minioadmin" -# MinIO 常用 true;AWS S3 常用 false -S3_FORCE_PATH_STYLE="true" -# 预签名上传 URL 的有效期(秒) -S3_PRESIGN_EXPIRES_SECONDS="900" -# 对外访问附件的基础地址(用于拼接公开 URL) -S3_PUBLIC_BASE_URL="http://127.0.0.1:9000" - -# [邮件] SMTP 配置(验证码/DDL 提醒邮件) -MAIL_SMTP_HOST="smtp.example.com" -MAIL_SMTP_PORT="465" -# 465 通常为 true(SSL),587 通常为 false(STARTTLS) -MAIL_SMTP_SECURE="true" -MAIL_SMTP_USER="no-reply@example.com" -MAIL_SMTP_PASS="replace-with-smtp-password" -# 发件人显示名称与地址 -MAIL_FROM_NAME="TodoList" -MAIL_FROM_ADDRESS="no-reply@example.com" - -# [数据加密] 服务端敏感数据加密主密钥 -# 用于加密 AI 配置、任务内容、同步 payload、附件元数据等数据库字段 -# 请使用高强度随机字符串,生产环境务必单独保管 -DATA_ENCRYPTION_SECRET="replace-with-a-long-random-secret" - -# [对象存储加密] 服务端对象加密策略,默认使用 AES256;如需关闭可填写 NONE -S3_SERVER_SIDE_ENCRYPTION="AES256" diff --git a/apps/api/.gitignore b/apps/api/.gitignore deleted file mode 100644 index 13b7b20..0000000 --- a/apps/api/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -# 环境变量文件不纳入版本控制 -.env - -/generated/prisma -dist -prisma.config.js -prisma.config.js.map diff --git a/apps/api/jest.config.cjs b/apps/api/jest.config.cjs deleted file mode 100644 index 8194a11..0000000 --- a/apps/api/jest.config.cjs +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - rootDir: ".", - testEnvironment: "node", - clearMocks: true, - testMatch: ["/test/**/*.spec.ts"], - moduleFileExtensions: ["ts", "js", "json"], - transform: { - "^.+\\.(t|j)s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }] - } -}; diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index cac4457..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@todolist/api", - "version": "0.1.0", - "description": "TodoList API service", - "scripts": { - "prisma:generate": "node -e \"require('node:fs').rmSync('generated/prisma', { recursive: true, force: true })\" && prisma generate", - "prisma:format": "prisma format", - "prisma:validate": "prisma validate", - "prebuild": "pnpm run prisma:generate", - "pretypecheck": "pnpm run prisma:generate", - "pretest": "pnpm run prisma:generate", - "data:reencrypt": "node -e \"require('node:fs').rmSync('.tmp-compile', { recursive: true, force: true })\" && tsc -p tsconfig.json --outDir .tmp-compile --noEmit false && node .tmp-compile/scripts/reencrypt-sensitive-data.js && node -e \"require('node:fs').rmSync('.tmp-compile', { recursive: true, force: true })\"", - "start": "node dist/main.js", - "start:dev": "ts-node-dev --respawn --transpile-only src/main.ts", - "build": "tsc -p tsconfig.build.json", - "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "jest --config jest.config.cjs --runInBand" - }, - "license": "GPL-3.0-or-later", - "devDependencies": { - "@nestjs/testing": "^11.1.18", - "@types/jest": "^30.0.0", - "@types/node": "^25.5.2", - "@types/nodemailer": "^8.0.0", - "@types/passport-github2": "^1.2.9", - "@types/passport-oauth2": "^1.8.0", - "@types/supertest": "^7.2.0", - "dotenv": "^16.6.1", - "jest": "^30.3.0", - "prisma": "^7.6.0", - "supertest": "^7.2.2", - "ts-jest": "^29.4.9", - "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", - "typescript": "^5.9.3" - }, - "private": true, - "dependencies": { - "@aws-sdk/client-s3": "^3.1024.0", - "@aws-sdk/s3-request-presigner": "^3.1024.0", - "@nestjs/common": "^11.1.18", - "@nestjs/config": "^4.0.3", - "@nestjs/core": "^11.1.18", - "@nestjs/jwt": "^11.0.2", - "@nestjs/passport": "^11.0.5", - "@nestjs/platform-express": "^11.1.18", - "@otplib/preset-default": "^12.0.1", - "@prisma/adapter-pg": "^7.6.0", - "@prisma/client": "^7.6.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.15.1", - "nodemailer": "^8.0.4", - "otplib": "^13.4.0", - "passport": "^0.7.0", - "passport-github2": "^0.1.12", - "passport-oauth2": "^1.8.0", - "pg": "^8.20.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2" - } -} diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts deleted file mode 100644 index d2c3b82..0000000 --- a/apps/api/prisma.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Prisma CLI 配置(TodoList) -import "dotenv/config"; -import { defineConfig } from "prisma/config"; - -export default defineConfig({ - schema: "prisma/schema.prisma", - migrations: { - path: "prisma/migrations" - }, - datasource: { - url: process.env["DATABASE_URL"] - } -}); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma deleted file mode 100644 index b6cfa31..0000000 --- a/apps/api/prisma/schema.prisma +++ /dev/null @@ -1,408 +0,0 @@ -// Prisma 数据模型定义(TodoList) - -generator client { - provider = "prisma-client" - output = "../generated/prisma" -} - -datasource db { - provider = "postgresql" -} - -enum UserStatus { - ACTIVE - DISABLED - BANNED -} - -enum AuthProvider { - EMAIL - GITHUB - QQ - WECHAT -} - -enum TaskPriority { - LOW - MEDIUM - HIGH - URGENT -} - -enum TaskStatus { - TODO - IN_PROGRESS - DONE - ARCHIVED -} - -enum AttachmentType { - IMAGE - VIDEO - FILE - LINK -} - -enum AiChannel { - USER_KEY - ASTRBOT - PUBLIC_POOL -} - -enum NotificationChannel { - EMAIL - WEB_PUSH -} - -enum NotificationStatus { - PENDING - SENT - FAILED - CANCELED -} - -model User { - id String @id @default(cuid()) - email String - emailHash String? @unique - nickname String? - avatarUrl String? - status UserStatus @default(ACTIVE) - defaultStorageQuotaMb Int @default(100) - usedStorageBytes BigInt @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - identities AuthIdentity[] - refreshTokens RefreshToken[] - security UserSecurity? - tasks Task[] - tags Tag[] - attachments Attachment[] - taskActivityLogs TaskActivityLog[] - syncOperations SyncOperation[] - syncCursors SyncCursor[] - taskTombstones TaskTombstone[] - aiProviderBindings AiProviderBinding[] - aiUsageLogs AiUsageLog[] - notificationRules NotificationRule[] - notificationJobs NotificationJob[] - createdAdminTokens AdminToken[] - auditLogs AuditLog[] - - @@map("users") -} - -model AuthIdentity { - id String @id @default(cuid()) - userId String - provider AuthProvider - providerUserId String - email String? - emailHash String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerUserId]) - @@index([emailHash]) - @@index([userId]) - @@map("auth_identities") -} - -model UserSecurity { - id String @id @default(cuid()) - userId String @unique - twoFactorEnabled Boolean @default(false) - twoFactorSecret String? - recoveryCodes String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("user_security") -} - -model RefreshToken { - id String @id @default(cuid()) - userId String - tokenHash String @unique - deviceId String? - expiresAt DateTime - revokedAt DateTime? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, expiresAt]) - @@map("refresh_tokens") -} - -model Task { - id String @id @default(cuid()) - userId String - title String - contentJson Json? - contentText String? - priority TaskPriority @default(MEDIUM) - status TaskStatus @default(TODO) - ddl DateTime? - completedAt DateTime? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - taskTags TaskTag[] - attachments Attachment[] - activityLogs TaskActivityLog[] - notificationJobs NotificationJob[] - notificationRules NotificationRule[] - - @@index([userId, status]) - @@index([userId, ddl]) - @@map("tasks") -} - -model Tag { - id String @id @default(cuid()) - userId String - name String - color String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - taskTags TaskTag[] - - @@unique([userId, name]) - @@index([userId]) - @@map("tags") -} - -model TaskTag { - taskId String - tagId String - createdAt DateTime @default(now()) - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) - - @@id([taskId, tagId]) - @@index([tagId]) - @@map("task_tags") -} - -model Attachment { - id String @id @default(cuid()) - userId String - taskId String? - type AttachmentType - url String - mimeType String? - fileName String? - fileSize Int - width Int? - height Int? - durationMs Int? - checksum String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) - - @@index([userId]) - @@index([taskId]) - @@map("attachments") -} - -model TaskActivityLog { - id String @id @default(cuid()) - userId String - taskId String - action String - payload Json? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) - - @@index([taskId, createdAt]) - @@index([userId, createdAt]) - @@map("task_activity_logs") -} - -model SyncOperation { - id String @id @default(cuid()) - opId String @unique - userId String - deviceId String - entityType String - entityId String - action String - payload Json? - clientTs DateTime - serverTs DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, deviceId, serverTs]) - @@index([userId, entityType, entityId]) - @@map("sync_operations") -} - -model SyncCursor { - id String @id @default(cuid()) - userId String - deviceId String - lastPulledAt DateTime? - lastOperationServerTs DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([userId, deviceId]) - @@map("sync_cursors") -} - -model TaskTombstone { - id String @id @default(cuid()) - taskId String @unique - userId String - deletedAt DateTime @default(now()) - deleteOpId String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, deletedAt]) - @@map("task_tombstones") -} - -model AiProviderBinding { - id String @id @default(cuid()) - userId String - channel AiChannel - providerName String - model String? - configId String? - configName String? - encryptedApiKey String? - endpoint String? - isDefault Boolean @default(false) - isEnabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, isEnabled]) - @@map("ai_provider_bindings") -} - -model AiPublicPoolConfig { - id String @id @default(cuid()) - enabled Boolean @default(false) - providerName String? - model String? - encryptedApiKey String? - endpoint String? - rpmLimit Int @default(60) - dailyTokenLimit Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("ai_public_pool_config") -} - -model AiUsageLog { - id String @id @default(cuid()) - userId String? - channel AiChannel - providerName String? - model String? - promptTokens Int @default(0) - completionTokens Int @default(0) - totalTokens Int @default(0) - latencyMs Int? - success Boolean @default(true) - errorCode String? - createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - - @@index([userId, createdAt]) - @@index([channel, createdAt]) - @@map("ai_usage_logs") -} - -model NotificationRule { - id String @id @default(cuid()) - userId String - taskId String? - channel NotificationChannel @default(EMAIL) - advanceMinutes Int @default(60) - enabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) - jobs NotificationJob[] - - @@index([userId, enabled]) - @@index([taskId]) - @@map("notification_rules") -} - -model NotificationJob { - id String @id @default(cuid()) - userId String - taskId String? - ruleId String? - channel NotificationChannel - scheduledAt DateTime - sentAt DateTime? - status NotificationStatus @default(PENDING) - retryCount Int @default(0) - errorMessage String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull) - rule NotificationRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull) - - @@index([status, scheduledAt]) - @@index([userId, createdAt]) - @@map("notification_jobs") -} - -model SystemSetting { - id String @id @default(cuid()) - key String @unique - value Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("system_settings") -} - -model AdminToken { - id String @id @default(cuid()) - tokenHash String @unique - name String - expiresAt DateTime - lastUsedAt DateTime? - revokedAt DateTime? - createdAt DateTime @default(now()) - createdByUserId String? - createdByUser User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) - - @@index([expiresAt]) - @@map("admin_tokens") -} - -model AuditLog { - id String @id @default(cuid()) - actorUserId String? - action String - targetType String - targetId String? - meta Json? - ip String? - userAgent String? - createdAt DateTime @default(now()) - actorUser User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull) - - @@index([action, createdAt]) - @@index([actorUserId, createdAt]) - @@map("audit_logs") -} diff --git a/apps/api/scripts/reencrypt-sensitive-data.ts b/apps/api/scripts/reencrypt-sensitive-data.ts deleted file mode 100644 index af39197..0000000 --- a/apps/api/scripts/reencrypt-sensitive-data.ts +++ /dev/null @@ -1,418 +0,0 @@ -import "dotenv/config"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { ConfigService } from "@nestjs/config"; -import { Prisma, PrismaClient } from "../generated/prisma/client"; -import { DataEncryptionService } from "../src/security/data-encryption.service"; - -type MigrationCounter = Record< - | "users" - | "authIdentities" - | "aiBindings" - | "publicPools" - | "aiUsageLogs" - | "tasks" - | "attachments" - | "syncOperations", - number ->; - -function createEncryptionService(): DataEncryptionService { - const configService = { - get: (key: string) => process.env[key] - } as ConfigService; - - return new DataEncryptionService(configService); -} - -function encryptStringIfNeeded( - value: string | null, - dataEncryptionService: DataEncryptionService -): string | null | undefined { - if (value === null || dataEncryptionService.isEncryptedString(value)) { - return undefined; - } - - return dataEncryptionService.encryptString(value) ?? null; -} - -function assignRequiredEncryptedString, K extends keyof T>( - target: T, - key: K, - value: string | null | undefined -): void { - if (typeof value === "string") { - target[key] = value as T[K]; - } -} - -function assignOptionalEncryptedString, K extends keyof T>( - target: T, - key: K, - value: string | null | undefined -): void { - if (value !== undefined) { - target[key] = value as T[K]; - } -} - -function encryptJsonIfNeeded( - value: Prisma.JsonValue | null, - dataEncryptionService: DataEncryptionService -): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined { - if (value === null) { - return undefined; - } - - if (typeof value === "string" && dataEncryptionService.isEncryptedString(value)) { - return undefined; - } - - return (dataEncryptionService.encryptJson(value as Prisma.InputJsonValue) ?? Prisma.JsonNull) as - | Prisma.InputJsonValue - | Prisma.NullableJsonNullValueInput; -} - -function resolvePlainString( - value: string | null, - dataEncryptionService: DataEncryptionService -): string | null { - if (value === null) { - return null; - } - - return dataEncryptionService.isEncryptedString(value) - ? (dataEncryptionService.decryptString(value) ?? null) - : value; -} - -async function main(): Promise { - if (!process.env["DATABASE_URL"]) { - throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移"); - } - - if (!process.env["DATA_ENCRYPTION_SECRET"]) { - throw new Error("缺少 DATA_ENCRYPTION_SECRET,无法执行敏感数据迁移"); - } - - const prisma = new PrismaClient({ - adapter: new PrismaPg({ - connectionString: process.env["DATABASE_URL"] - }) - }); - const dataEncryptionService = createEncryptionService(); - const counter: MigrationCounter = { - users: 0, - authIdentities: 0, - aiBindings: 0, - publicPools: 0, - aiUsageLogs: 0, - tasks: 0, - attachments: 0, - syncOperations: 0 - }; - - try { - const users = await prisma.user.findMany({ - select: { - id: true, - email: true, - emailHash: true, - nickname: true, - avatarUrl: true - } - }); - - for (const user of users) { - const normalizedEmail = resolvePlainString(user.email, dataEncryptionService)?.toLowerCase(); - if (!normalizedEmail) { - continue; - } - const nextEmailHash = dataEncryptionService.createLookupHash("user.email", normalizedEmail); - const data: Prisma.UserUpdateInput = {}; - const email = encryptStringIfNeeded(user.email, dataEncryptionService); - const nickname = encryptStringIfNeeded(user.nickname, dataEncryptionService); - const avatarUrl = encryptStringIfNeeded(user.avatarUrl, dataEncryptionService); - - assignRequiredEncryptedString(data, "email", email); - if (user.emailHash !== nextEmailHash) { - data.emailHash = nextEmailHash; - } - assignOptionalEncryptedString(data, "nickname", nickname); - assignOptionalEncryptedString(data, "avatarUrl", avatarUrl); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.user.update({ - where: { - id: user.id - }, - data - }); - counter.users += 1; - } - - const authIdentities = await prisma.authIdentity.findMany({ - select: { - id: true, - email: true, - emailHash: true - } - }); - - for (const authIdentity of authIdentities) { - const data: Prisma.AuthIdentityUpdateInput = {}; - const email = encryptStringIfNeeded(authIdentity.email, dataEncryptionService); - const normalizedIdentityEmail = resolvePlainString(authIdentity.email, dataEncryptionService); - const nextEmailHash = - normalizedIdentityEmail === null - ? null - : dataEncryptionService.createLookupHash( - "auth_identity.email", - normalizedIdentityEmail.toLowerCase() - ); - - assignOptionalEncryptedString(data, "email", email); - if (authIdentity.emailHash !== nextEmailHash) { - data.emailHash = nextEmailHash; - } - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.authIdentity.update({ - where: { - id: authIdentity.id - }, - data - }); - counter.authIdentities += 1; - } - - const aiBindings = await prisma.aiProviderBinding.findMany({ - select: { - id: true, - providerName: true, - model: true, - configId: true, - configName: true, - endpoint: true, - encryptedApiKey: true - } - }); - - for (const binding of aiBindings) { - const data: Prisma.AiProviderBindingUpdateInput = {}; - const providerName = encryptStringIfNeeded(binding.providerName, dataEncryptionService); - const model = encryptStringIfNeeded(binding.model, dataEncryptionService); - const configId = encryptStringIfNeeded(binding.configId, dataEncryptionService); - const configName = encryptStringIfNeeded(binding.configName, dataEncryptionService); - const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService); - const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService); - - assignRequiredEncryptedString(data, "providerName", providerName); - assignOptionalEncryptedString(data, "model", model); - assignOptionalEncryptedString(data, "configId", configId); - assignOptionalEncryptedString(data, "configName", configName); - assignOptionalEncryptedString(data, "endpoint", endpoint); - assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.aiProviderBinding.update({ - where: { - id: binding.id - }, - data - }); - counter.aiBindings += 1; - } - - const publicPools = await prisma.aiPublicPoolConfig.findMany({ - select: { - id: true, - providerName: true, - model: true, - endpoint: true, - encryptedApiKey: true - } - }); - - for (const publicPool of publicPools) { - const data: Prisma.AiPublicPoolConfigUpdateInput = {}; - const providerName = encryptStringIfNeeded(publicPool.providerName, dataEncryptionService); - const model = encryptStringIfNeeded(publicPool.model, dataEncryptionService); - const endpoint = encryptStringIfNeeded(publicPool.endpoint, dataEncryptionService); - const encryptedApiKey = encryptStringIfNeeded( - publicPool.encryptedApiKey, - dataEncryptionService - ); - - assignOptionalEncryptedString(data, "providerName", providerName); - assignOptionalEncryptedString(data, "model", model); - assignOptionalEncryptedString(data, "endpoint", endpoint); - assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.aiPublicPoolConfig.update({ - where: { - id: publicPool.id - }, - data - }); - counter.publicPools += 1; - } - - const aiUsageLogs = await prisma.aiUsageLog.findMany({ - select: { - id: true, - providerName: true, - model: true - } - }); - - for (const aiUsageLog of aiUsageLogs) { - const data: Prisma.AiUsageLogUpdateInput = {}; - const providerName = encryptStringIfNeeded(aiUsageLog.providerName, dataEncryptionService); - const model = encryptStringIfNeeded(aiUsageLog.model, dataEncryptionService); - - assignOptionalEncryptedString(data, "providerName", providerName); - assignOptionalEncryptedString(data, "model", model); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.aiUsageLog.update({ - where: { - id: aiUsageLog.id - }, - data - }); - counter.aiUsageLogs += 1; - } - - const tasks = await prisma.task.findMany({ - select: { - id: true, - title: true, - contentJson: true, - contentText: true - } - }); - - for (const task of tasks) { - const data: Prisma.TaskUpdateInput = {}; - const title = encryptStringIfNeeded(task.title, dataEncryptionService); - const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService); - const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService); - - assignRequiredEncryptedString(data, "title", title); - if (contentJson !== undefined) { - data.contentJson = contentJson; - } - assignOptionalEncryptedString(data, "contentText", contentText); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.task.update({ - where: { - id: task.id - }, - data - }); - counter.tasks += 1; - } - - const attachments = await prisma.attachment.findMany({ - select: { - id: true, - url: true, - fileName: true, - checksum: true - } - }); - - for (const attachment of attachments) { - const data: Prisma.AttachmentUpdateInput = {}; - const url = encryptStringIfNeeded(attachment.url, dataEncryptionService); - const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService); - const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService); - - assignRequiredEncryptedString(data, "url", url); - assignOptionalEncryptedString(data, "fileName", fileName); - assignOptionalEncryptedString(data, "checksum", checksum); - - if (Object.keys(data).length === 0) { - continue; - } - - await prisma.attachment.update({ - where: { - id: attachment.id - }, - data - }); - counter.attachments += 1; - } - - const syncOperations = await prisma.syncOperation.findMany({ - select: { - id: true, - payload: true - } - }); - - for (const operation of syncOperations) { - if (operation.payload === null) { - continue; - } - - let nextPayload: string | null = null; - if (typeof operation.payload === "string") { - if (dataEncryptionService.isEncryptedString(operation.payload)) { - continue; - } - - nextPayload = dataEncryptionService.encryptString(operation.payload) ?? null; - } else { - nextPayload = - dataEncryptionService.encryptString(JSON.stringify(operation.payload)) ?? null; - } - - if (nextPayload === null) { - continue; - } - - await prisma.syncOperation.update({ - where: { - id: operation.id - }, - data: { - payload: nextPayload - } - }); - counter.syncOperations += 1; - } - - console.log("敏感数据迁移完成"); - console.log(JSON.stringify(counter, null, 2)); - } finally { - await prisma.$disconnect(); - } -} - -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : "未知错误"; - console.error(`敏感数据迁移失败:${message}`); - process.exitCode = 1; -}); diff --git a/apps/api/src/ai/ai-provider-registry.service.ts b/apps/api/src/ai/ai-provider-registry.service.ts deleted file mode 100644 index 54387a9..0000000 --- a/apps/api/src/ai/ai-provider-registry.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { AiChannel } from "../../generated/prisma/client"; -import { AstrbotProvider } from "./providers/astrbot.provider"; -import { OpenAiCompatibleProvider } from "./providers/openai-compatible.provider"; -import { AiChannelExecutor } from "./ai.types"; - -@Injectable() -export class AiProviderRegistryService { - private readonly executors = new Map(); - - constructor( - openAiCompatibleProvider: OpenAiCompatibleProvider, - astrbotProvider: AstrbotProvider - ) { - this.executors.set(AiChannel.USER_KEY, openAiCompatibleProvider); - this.executors.set(AiChannel.PUBLIC_POOL, openAiCompatibleProvider); - this.executors.set(AiChannel.ASTRBOT, astrbotProvider); - } - - getExecutor(channel: AiChannel): AiChannelExecutor { - const executor = this.executors.get(channel); - if (!executor) { - throw new Error(`未找到 ${channel} 对应的 AI 通道执行器`); - } - - return executor; - } -} diff --git a/apps/api/src/ai/ai-rate-limit.service.ts b/apps/api/src/ai/ai-rate-limit.service.ts deleted file mode 100644 index e84b2de..0000000 --- a/apps/api/src/ai/ai-rate-limit.service.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; - -type AiRateLimitBucket = { - count: number; - resetAt: number; -}; - -export type AiRateLimitResult = - | { - allowed: true; - } - | { - allowed: false; - reason: "USER" | "IP"; - retryAfterMs: number; - limit: number; - windowMs: number; - }; - -@Injectable() -export class AiRateLimitService { - private readonly userBuckets = new Map(); - private readonly ipBuckets = new Map(); - private readonly windowMs: number; - private readonly userLimit: number; - private readonly ipLimit: number; - - constructor(private readonly configService: ConfigService) { - this.windowMs = this.readPositiveInt("AI_RATE_LIMIT_WINDOW_MS", 60_000); - this.userLimit = this.readPositiveInt("AI_RATE_LIMIT_USER_MAX", 20); - this.ipLimit = this.readPositiveInt("AI_RATE_LIMIT_IP_MAX", 60); - } - - consume(userId: string, clientIp: string | null): AiRateLimitResult { - const now = Date.now(); - const userBucket = this.getBucket(this.userBuckets, userId, now); - if (userBucket.count >= this.userLimit) { - return { - allowed: false, - reason: "USER", - retryAfterMs: Math.max(0, userBucket.resetAt - now), - limit: this.userLimit, - windowMs: this.windowMs - }; - } - - const normalizedIp = this.normalizeIp(clientIp); - const ipBucket = normalizedIp ? this.getBucket(this.ipBuckets, normalizedIp, now) : null; - if (ipBucket && ipBucket.count >= this.ipLimit) { - return { - allowed: false, - reason: "IP", - retryAfterMs: Math.max(0, ipBucket.resetAt - now), - limit: this.ipLimit, - windowMs: this.windowMs - }; - } - - userBucket.count += 1; - if (ipBucket) { - ipBucket.count += 1; - } - - this.cleanupExpiredBuckets(this.userBuckets, now); - this.cleanupExpiredBuckets(this.ipBuckets, now); - - return { - allowed: true - }; - } - - private getBucket( - buckets: Map, - key: string, - now: number - ): AiRateLimitBucket { - const currentBucket = buckets.get(key); - if (!currentBucket || now >= currentBucket.resetAt) { - const nextBucket: AiRateLimitBucket = { - count: 0, - resetAt: now + this.windowMs - }; - buckets.set(key, nextBucket); - return nextBucket; - } - - return currentBucket; - } - - private cleanupExpiredBuckets(buckets: Map, now: number): void { - if (buckets.size <= 256) { - return; - } - - for (const [key, bucket] of buckets.entries()) { - if (now >= bucket.resetAt) { - buckets.delete(key); - } - } - } - - private normalizeIp(clientIp: string | null): string | null { - if (!clientIp) { - return null; - } - - const normalizedIp = clientIp.trim(); - return normalizedIp.length > 0 ? normalizedIp : null; - } - - private readPositiveInt(key: string, fallbackValue: number): number { - const rawValue = this.configService.get(key); - const parsedValue = - typeof rawValue === "number" ? rawValue : Number.parseInt(String(rawValue ?? ""), 10); - - if (!Number.isFinite(parsedValue) || parsedValue <= 0) { - return fallbackValue; - } - - return parsedValue; - } -} diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts deleted file mode 100644 index 8756e08..0000000 --- a/apps/api/src/ai/ai.controller.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - Body, - Controller, - Get, - Headers, - Ip, - Post, - Query, - UnauthorizedException -} from "@nestjs/common"; -import { AiChatDto } from "./dto/ai-chat.dto"; -import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto"; -import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto"; -import { - AiChatResponse, - AiService, - ListAiBindingsResponse, - ListAiUsageLogsResponse, - TestAiBindingResponse -} from "./ai.service"; - -@Controller("ai") -export class AiController { - constructor(private readonly aiService: AiService) {} - - @Get("bindings") - async listBindings( - @Headers("x-user-id") userIdHeader: string | string[] | undefined - ): Promise { - return this.aiService.listBindings(this.resolveUserId(userIdHeader)); - } - - @Get("usage-logs") - async listUsageLogs( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Query() query: ListAiUsageLogsQueryDto - ): Promise { - return this.aiService.listUsageLogs(this.resolveUserId(userIdHeader), query); - } - - @Post("bindings") - async upsertBinding( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: UpsertAiProviderBindingDto - ) { - return this.aiService.upsertBinding(this.resolveUserId(userIdHeader), body); - } - - @Post("bindings/test") - async testBinding( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: UpsertAiProviderBindingDto - ): Promise { - return this.aiService.testBinding(this.resolveUserId(userIdHeader), body); - } - - @Post("chat") - async chat( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Ip() clientIp: string, - @Body() body: AiChatDto - ): Promise { - return this.aiService.chat(this.resolveUserId(userIdHeader), body, clientIp); - } - - private resolveUserId(userIdHeader: string | string[] | undefined): string { - const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader; - if (!userId) { - throw new UnauthorizedException("缺少用户上下文"); - } - - return userId; - } -} diff --git a/apps/api/src/ai/ai.module.ts b/apps/api/src/ai/ai.module.ts deleted file mode 100644 index a17544a..0000000 --- a/apps/api/src/ai/ai.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Module } from "@nestjs/common"; -import { PrismaModule } from "../prisma/prisma.module"; -import { AiRateLimitService } from "./ai-rate-limit.service"; -import { AiController } from "./ai.controller"; -import { AiProviderRegistryService } from "./ai-provider-registry.service"; -import { AiService } from "./ai.service"; -import { AstrbotProvider } from "./providers/astrbot.provider"; -import { OpenAiCompatibleProvider } from "./providers/openai-compatible.provider"; - -@Module({ - imports: [PrismaModule], - controllers: [AiController], - providers: [ - AiService, - AiRateLimitService, - AiProviderRegistryService, - OpenAiCompatibleProvider, - AstrbotProvider - ] -}) -export class AiModule {} diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts deleted file mode 100644 index 25cbf61..0000000 --- a/apps/api/src/ai/ai.service.ts +++ /dev/null @@ -1,988 +0,0 @@ -import { - BadGatewayException, - BadRequestException, - HttpException, - HttpStatus, - Injectable, - Logger -} from "@nestjs/common"; -import { - AiChannel, - AiUsageLog, - AiProviderBinding, - AiPublicPoolConfig, - Prisma, - TaskPriority, - TaskStatus -} from "../../generated/prisma/client"; -import { PrismaService } from "../prisma/prisma.service"; -import { DataEncryptionService } from "../security/data-encryption.service"; -import { AiRateLimitService } from "./ai-rate-limit.service"; -import { AiProviderRegistryService } from "./ai-provider-registry.service"; -import { AiChatDto } from "./dto/ai-chat.dto"; -import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto"; -import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto"; -import { - AiResolvedRouteCandidate, - AiRouteAttempt, - AiRouteFailureError, - AiUsageMetrics -} from "./ai.types"; - -type AiBindingSummary = { - id: string; - channel: AiChannel; - providerName: string; - model: string | null; - configId: string | null; - configName: string | null; - endpoint: string | null; - isEnabled: boolean; - hasApiKey: boolean; - maskedApiKey: string | null; - updatedAt: string; -}; - -type AiRoutePlanEntry = - | { - kind: "candidate"; - candidate: AiResolvedRouteCandidate; - } - | { - kind: "skip"; - attempt: AiRouteAttempt; - }; - -export type ListAiBindingsResponse = { - routeOrder: AiChannel[]; - bindings: AiBindingSummary[]; - publicPool: { - enabled: boolean; - providerName: string | null; - model: string | null; - hasApiKey: boolean; - } | null; -}; - -type AiUsageLogSummary = { - id: string; - channel: AiChannel; - providerName: string | null; - model: string | null; - promptTokens: number; - completionTokens: number; - totalTokens: number; - latencyMs: number | null; - success: boolean; - errorCode: string | null; - createdAt: string; -}; - -type AiContextTaskItem = { - id: string; - title: string; - priority: TaskPriority; - status: TaskStatus; - ddl: Date | null; - contentText: string | null; - updatedAt: Date; -}; - -export type ListAiUsageLogsResponse = { - items: AiUsageLogSummary[]; - page: number; - pageSize: number; - total: number; -}; - -export type AiChatResponse = { - channel: AiChannel; - providerName: string; - model: string | null; - content: string; - sessionId: string | null; - attempts: AiRouteAttempt[]; -}; - -export type TestAiBindingResponse = - | { - success: true; - channel: AiChannel; - providerName: string; - model: string | null; - contentPreview: string; - } - | { - success: false; - channel: AiChannel; - providerName: string; - model: string | null; - code: string; - message: string; - }; - -@Injectable() -export class AiService { - private readonly logger = new Logger(AiService.name); - private readonly maxContextTasks = 6; - private readonly maxContextContentLength = 80; - - constructor( - private readonly prismaService: PrismaService, - private readonly aiProviderRegistryService: AiProviderRegistryService, - private readonly dataEncryptionService: DataEncryptionService, - private readonly aiRateLimitService: AiRateLimitService - ) {} - - async listBindings(userId: string): Promise { - const [bindings, publicPool] = await Promise.all([ - this.prismaService.aiProviderBinding.findMany({ - where: { - userId - }, - orderBy: [{ updatedAt: "desc" }] - }), - this.prismaService.aiPublicPoolConfig.findFirst({ - orderBy: { - updatedAt: "desc" - } - }) - ]); - - const latestBindings = this.pickLatestBindingsByChannel(bindings); - - return { - routeOrder: [AiChannel.USER_KEY, AiChannel.ASTRBOT, AiChannel.PUBLIC_POOL], - bindings: latestBindings.map((binding) => this.serializeBinding(binding)), - publicPool: publicPool - ? { - enabled: publicPool.enabled, - providerName: this.readDecryptedString(publicPool.providerName), - model: this.readDecryptedString(publicPool.model), - hasApiKey: Boolean(publicPool.encryptedApiKey) - } - : null - }; - } - - async listUsageLogs( - userId: string, - query: ListAiUsageLogsQueryDto - ): Promise { - const page = query.page ?? 1; - const pageSize = query.pageSize ?? 20; - const skip = (page - 1) * pageSize; - const where: Prisma.AiUsageLogWhereInput = { - userId - }; - - if (query.channel) { - where.channel = query.channel; - } - - if (query.success !== undefined) { - where.success = query.success; - } - - const [items, total] = await Promise.all([ - this.prismaService.aiUsageLog.findMany({ - where, - orderBy: { - createdAt: "desc" - }, - skip, - take: pageSize - }), - this.prismaService.aiUsageLog.count({ - where - }) - ]); - - return { - items: items.map((item) => this.serializeUsageLog(item)), - page, - pageSize, - total - }; - } - - async upsertBinding(userId: string, dto: UpsertAiProviderBindingDto): Promise { - if (dto.channel === AiChannel.PUBLIC_POOL) { - throw new BadRequestException("公共 AI 通道只能由管理员配置"); - } - - this.validateBindingInput(dto); - - const result = await this.prismaService.$transaction(async (tx) => { - const existingBinding = await tx.aiProviderBinding.findFirst({ - where: { - userId, - channel: dto.channel - }, - orderBy: { - updatedAt: "desc" - } - }); - - if (!existingBinding) { - return tx.aiProviderBinding.create({ - data: { - userId, - channel: dto.channel, - providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)), - model: this.encryptOptionalString(dto.model), - configId: this.encryptOptionalString(dto.configId), - configName: this.encryptOptionalString(dto.configName), - endpoint: this.encryptOptionalString(dto.endpoint), - encryptedApiKey: this.encryptOptionalString(dto.apiKey), - isEnabled: dto.isEnabled ?? true - } - }); - } - - const updateData: Prisma.AiProviderBindingUpdateInput = { - channel: dto.channel, - providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)), - model: this.encryptOptionalString(dto.model), - configId: this.encryptOptionalString(dto.configId), - configName: this.encryptOptionalString(dto.configName), - isEnabled: dto.isEnabled ?? existingBinding.isEnabled - }; - - if (dto.endpoint !== undefined) { - updateData.endpoint = this.encryptOptionalString(dto.endpoint); - } - - if (dto.apiKey !== undefined) { - updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey); - } - - return tx.aiProviderBinding.update({ - where: { - id: existingBinding.id - }, - data: updateData - }); - }); - - return this.serializeBinding(result); - } - - async testBinding( - userId: string, - dto: UpsertAiProviderBindingDto - ): Promise { - if (dto.channel === AiChannel.PUBLIC_POOL) { - throw new BadRequestException("公共 AI 通道不能由用户自行测试"); - } - - const candidate = await this.buildTestCandidate(userId, dto); - const executor = this.aiProviderRegistryService.getExecutor(candidate.channel); - - try { - const result = await executor.execute(candidate, { - userId, - message: "请只回复“连接成功”,不要添加其他内容。", - sessionId: null - }); - - return { - success: true, - channel: result.channel, - providerName: result.providerName, - model: result.model, - contentPreview: this.limitPreviewText(result.content) - }; - } catch (error) { - if (error instanceof AiRouteFailureError) { - return { - success: false, - channel: error.channel, - providerName: error.providerName, - model: candidate.model, - code: error.code, - message: error.message - }; - } - - if (error instanceof Error) { - return { - success: false, - channel: candidate.channel, - providerName: candidate.providerName, - model: candidate.model, - code: "UNKNOWN_ERROR", - message: error.message - }; - } - - return { - success: false, - channel: candidate.channel, - providerName: candidate.providerName, - model: candidate.model, - code: "UNKNOWN_ERROR", - message: "未知错误" - }; - } - } - - async chat( - userId: string, - dto: AiChatDto, - clientIp: string | null = null - ): Promise { - const rateLimitResult = this.aiRateLimitService.consume(userId, clientIp); - if (!rateLimitResult.allowed) { - throw new HttpException( - { - message: "AI 请求过于频繁,请稍后再试", - code: "AI_RATE_LIMITED", - dimension: rateLimitResult.reason === "USER" ? "user" : "ip", - retryAfterMs: rateLimitResult.retryAfterMs, - limit: rateLimitResult.limit, - windowMs: rateLimitResult.windowMs - }, - HttpStatus.TOO_MANY_REQUESTS - ); - } - - const attempts: AiRouteAttempt[] = []; - const plan = await this.buildRoutePlan(userId, dto.channel ?? null); - const promptMessage = await this.buildPromptMessage(userId, dto.message, dto.localTasks ?? []); - - for (const entry of plan) { - if (entry.kind === "skip") { - attempts.push(entry.attempt); - continue; - } - - const executor = this.aiProviderRegistryService.getExecutor(entry.candidate.channel); - const startedAt = Date.now(); - - try { - const result = await executor.execute(entry.candidate, { - userId, - message: promptMessage, - sessionId: dto.sessionId ?? null - }); - const latencyMs = Date.now() - startedAt; - - attempts.push({ - channel: result.channel, - providerName: result.providerName, - model: result.model, - status: "success", - reasonCode: null, - reasonMessage: null - }); - await this.recordUsageLog({ - userId, - channel: result.channel, - providerName: result.providerName, - model: result.model, - usage: result.usage, - latencyMs, - success: true, - errorCode: null - }); - - return { - channel: result.channel, - providerName: result.providerName, - model: result.model, - content: result.content, - sessionId: result.sessionId, - attempts - }; - } catch (error) { - const latencyMs = Date.now() - startedAt; - const failureAttempt = this.toFailureAttempt(entry.candidate, error); - attempts.push(failureAttempt); - await this.recordUsageLog({ - userId, - channel: failureAttempt.channel, - providerName: failureAttempt.providerName, - model: failureAttempt.model, - usage: null, - latencyMs, - success: false, - errorCode: failureAttempt.reasonCode - }); - this.logger.warn( - `AI 通道降级:channel=${failureAttempt.channel} provider=${failureAttempt.providerName ?? "unknown"} code=${failureAttempt.reasonCode ?? "UNKNOWN"} message=${failureAttempt.reasonMessage ?? "unknown"}` - ); - } - } - - throw new BadGatewayException({ - message: "当前没有可用的 AI 通道,请稍后重试", - attempts - }); - } - - private async buildRoutePlan( - userId: string, - selectedChannel: AiChannel | null - ): Promise { - const plan: AiRoutePlanEntry[] = []; - const targetChannels = selectedChannel - ? [selectedChannel] - : [AiChannel.USER_KEY, AiChannel.ASTRBOT, AiChannel.PUBLIC_POOL]; - - for (const channel of targetChannels) { - if (channel === AiChannel.PUBLIC_POOL) { - const publicPool = await this.findEnabledPublicPool(); - if (publicPool) { - plan.push({ - kind: "candidate", - candidate: this.toPublicPoolCandidate(publicPool) - }); - } else { - plan.push({ - kind: "skip", - attempt: { - channel: AiChannel.PUBLIC_POOL, - providerName: null, - model: null, - status: "skipped", - reasonCode: "PUBLIC_POOL_DISABLED", - reasonMessage: "公共 AI 通道未开启" - } - }); - } - continue; - } - - const binding = await this.findPreferredBinding(userId, channel); - if (binding) { - plan.push({ - kind: "candidate", - candidate: this.toBindingCandidate(binding) - }); - continue; - } - - plan.push({ - kind: "skip", - attempt: { - channel, - providerName: null, - model: null, - status: "skipped", - reasonCode: "CHANNEL_NOT_CONFIGURED", - reasonMessage: - channel === AiChannel.USER_KEY - ? "当前用户未配置可用的自备 Key 通道" - : "当前用户未配置可用的 AstrBot 通道" - } - }); - } - - return plan; - } - - private async findPreferredBinding( - userId: string, - channel: AiChannel - ): Promise { - return this.prismaService.aiProviderBinding.findFirst({ - where: { - userId, - channel, - isEnabled: true - }, - orderBy: { - updatedAt: "desc" - } - }); - } - - private async findEnabledPublicPool(): Promise { - return this.prismaService.aiPublicPoolConfig.findFirst({ - where: { - enabled: true - }, - orderBy: { - updatedAt: "desc" - } - }); - } - - private async buildTestCandidate( - userId: string, - dto: UpsertAiProviderBindingDto - ): Promise { - const existingBinding = await this.prismaService.aiProviderBinding.findFirst({ - where: { - userId, - channel: dto.channel - }, - orderBy: { - updatedAt: "desc" - } - }); - - const mergedDto: UpsertAiProviderBindingDto = { - channel: dto.channel, - providerName: - dto.providerName ?? this.readDecryptedString(existingBinding?.providerName ?? null) ?? "", - model: dto.model ?? this.readDecryptedString(existingBinding?.model ?? null) ?? undefined, - configId: - dto.configId ?? this.readDecryptedString(existingBinding?.configId ?? null) ?? undefined, - configName: - dto.configName ?? - this.readDecryptedString(existingBinding?.configName ?? null) ?? - undefined, - endpoint: - dto.endpoint ?? this.readDecryptedString(existingBinding?.endpoint ?? null) ?? undefined, - apiKey: - dto.apiKey ?? - this.readDecryptedString(existingBinding?.encryptedApiKey ?? null) ?? - undefined, - isEnabled: dto.isEnabled ?? existingBinding?.isEnabled ?? true - }; - - this.validateBindingInput(mergedDto); - - return { - channel: mergedDto.channel, - source: existingBinding ? "binding" : "binding", - sourceId: existingBinding?.id ?? null, - providerName: this.normalizeProviderName(mergedDto.providerName), - model: this.normalizeOptionalString(mergedDto.model), - configId: this.normalizeOptionalString(mergedDto.configId), - configName: this.normalizeOptionalString(mergedDto.configName), - endpoint: this.normalizeOptionalString(mergedDto.endpoint), - apiKey: this.normalizeOptionalString(mergedDto.apiKey) - }; - } - - private toBindingCandidate(binding: AiProviderBinding): AiResolvedRouteCandidate { - return { - channel: binding.channel, - source: "binding", - sourceId: binding.id, - providerName: this.readDecryptedString(binding.providerName) ?? "", - model: this.readDecryptedString(binding.model), - configId: this.readDecryptedString(binding.configId), - configName: this.readDecryptedString(binding.configName), - endpoint: this.readDecryptedString(binding.endpoint), - apiKey: this.readDecryptedString(binding.encryptedApiKey) - }; - } - - private toPublicPoolCandidate(publicPool: AiPublicPoolConfig): AiResolvedRouteCandidate { - return { - channel: AiChannel.PUBLIC_POOL, - source: "public_pool", - sourceId: publicPool.id, - providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool", - model: this.readDecryptedString(publicPool.model), - configId: null, - configName: null, - endpoint: this.readDecryptedString(publicPool.endpoint), - apiKey: this.readDecryptedString(publicPool.encryptedApiKey) - }; - } - - private serializeBinding(binding: AiProviderBinding): AiBindingSummary { - const decryptedProviderName = this.readDecryptedString(binding.providerName) ?? ""; - const decryptedModel = this.readDecryptedString(binding.model); - const decryptedConfigId = this.readDecryptedString(binding.configId); - const decryptedConfigName = this.readDecryptedString(binding.configName); - const decryptedEndpoint = this.readDecryptedString(binding.endpoint); - const decryptedApiKey = this.readDecryptedString(binding.encryptedApiKey); - - return { - id: binding.id, - channel: binding.channel, - providerName: decryptedProviderName, - model: decryptedModel, - configId: decryptedConfigId, - configName: decryptedConfigName, - endpoint: decryptedEndpoint, - isEnabled: binding.isEnabled, - hasApiKey: Boolean(binding.encryptedApiKey), - maskedApiKey: this.maskSecret(decryptedApiKey), - updatedAt: binding.updatedAt.toISOString() - }; - } - - private pickLatestBindingsByChannel(bindings: AiProviderBinding[]): AiProviderBinding[] { - const bindingMap = new Map(); - - for (const binding of bindings) { - if (!bindingMap.has(binding.channel)) { - bindingMap.set(binding.channel, binding); - } - } - - return [AiChannel.USER_KEY, AiChannel.ASTRBOT] - .map((channel) => bindingMap.get(channel) ?? null) - .filter((binding): binding is AiProviderBinding => binding !== null); - } - - private serializeUsageLog(log: AiUsageLog): AiUsageLogSummary { - return { - id: log.id, - channel: log.channel, - providerName: this.readDecryptedString(log.providerName), - model: this.readDecryptedString(log.model), - promptTokens: log.promptTokens, - completionTokens: log.completionTokens, - totalTokens: log.totalTokens, - latencyMs: log.latencyMs, - success: log.success, - errorCode: log.errorCode, - createdAt: log.createdAt.toISOString() - }; - } - - private async buildPromptMessage( - userId: string, - userMessage: string, - localTasks: NonNullable - ): Promise { - const taskSummary = await this.buildTaskContextSummary(userId, localTasks); - if (!taskSummary) { - return userMessage; - } - - return [ - "你是 TodoList 的 AI 助手,需要结合用户当前待办提供任务统筹建议。", - "以下是系统整理的未完成任务摘要:", - taskSummary, - "请优先根据这些任务的紧急度、截止时间和执行顺序回答,并给出明确可执行的建议。", - `用户当前问题:${userMessage}` - ].join("\n\n"); - } - - private async buildTaskContextSummary( - userId: string, - localTasks: NonNullable - ): Promise { - const tasks = await this.prismaService.task.findMany({ - where: { - userId, - status: { - in: [TaskStatus.TODO, TaskStatus.IN_PROGRESS] - } - }, - select: { - id: true, - title: true, - priority: true, - status: true, - ddl: true, - contentText: true, - updatedAt: true - }, - take: 20 - }); - - const sortedTasks = this.sortContextTasks(this.mergeContextTasks(tasks, localTasks)); - if (sortedTasks.length === 0) { - return null; - } - - const visibleTasks = sortedTasks.slice(0, this.maxContextTasks); - const lines = visibleTasks.map((task, index) => { - const parts = [ - `${index + 1}. ${task.title}`, - `优先级:${this.getPriorityLabel(task.priority)}`, - `状态:${this.getStatusLabel(task.status)}`, - `DDL:${task.ddl ? task.ddl.toISOString() : "未设置"}` - ]; - - const contentSnippet = this.getContentSnippet(task.contentText); - if (contentSnippet) { - parts.push(`内容摘要:${contentSnippet}`); - } - - return parts.join(" | "); - }); - - const omittedCount = sortedTasks.length - visibleTasks.length; - if (omittedCount > 0) { - lines.push(`另有 ${omittedCount} 条任务已省略。`); - } - - return [`共 ${sortedTasks.length} 条未完成任务。`, ...lines].join("\n"); - } - - private mergeContextTasks( - databaseTasks: Array<{ - id: string; - title: string; - priority: TaskPriority; - status: TaskStatus; - ddl: Date | null; - contentText: string | null; - updatedAt: Date; - }>, - localTasks: NonNullable - ): AiContextTaskItem[] { - const taskMap = new Map(); - - for (const task of databaseTasks) { - taskMap.set(task.id, { - id: task.id, - title: this.readDecryptedString(task.title) ?? "未命名任务", - priority: task.priority, - status: task.status, - ddl: task.ddl, - contentText: this.readDecryptedString(task.contentText), - updatedAt: task.updatedAt - }); - } - - for (const task of localTasks) { - if (task.status !== TaskStatus.TODO && task.status !== TaskStatus.IN_PROGRESS) { - continue; - } - - const currentTask = taskMap.get(task.id); - const nextTask: AiContextTaskItem = { - id: task.id, - title: task.title.trim().length > 0 ? task.title.trim() : "未命名任务", - priority: task.priority, - status: task.status, - ddl: typeof task.ddlAt === "number" ? new Date(task.ddlAt) : null, - contentText: - typeof task.contentText === "string" && task.contentText.trim().length > 0 - ? task.contentText - : null, - updatedAt: new Date(task.updatedAt) - }; - - if (!currentTask || nextTask.updatedAt.getTime() >= currentTask.updatedAt.getTime()) { - taskMap.set(task.id, nextTask); - } - } - - return [...taskMap.values()].filter( - (task) => task.status === TaskStatus.TODO || task.status === TaskStatus.IN_PROGRESS - ); - } - - private sortContextTasks(tasks: AiContextTaskItem[]): AiContextTaskItem[] { - return [...tasks].sort((left, right) => { - const priorityDiff = - this.getPriorityWeight(right.priority) - this.getPriorityWeight(left.priority); - if (priorityDiff !== 0) { - return priorityDiff; - } - - const leftDdl = left.ddl?.getTime() ?? Number.POSITIVE_INFINITY; - const rightDdl = right.ddl?.getTime() ?? Number.POSITIVE_INFINITY; - if (leftDdl !== rightDdl) { - return leftDdl - rightDdl; - } - - return right.updatedAt.getTime() - left.updatedAt.getTime(); - }); - } - - private toFailureAttempt(candidate: AiResolvedRouteCandidate, error: unknown): AiRouteAttempt { - if (error instanceof AiRouteFailureError) { - return { - channel: error.channel, - providerName: error.providerName, - model: candidate.model, - status: "failed", - reasonCode: error.code, - reasonMessage: error.message - }; - } - - if (error instanceof Error) { - return { - channel: candidate.channel, - providerName: candidate.providerName, - model: candidate.model, - status: "failed", - reasonCode: "UNKNOWN_ERROR", - reasonMessage: error.message - }; - } - - return { - channel: candidate.channel, - providerName: candidate.providerName, - model: candidate.model, - status: "failed", - reasonCode: "UNKNOWN_ERROR", - reasonMessage: "未知错误" - }; - } - - private normalizeOptionalString(value: string | undefined): string | null { - if (value === undefined) { - return null; - } - - const normalizedValue = value.trim(); - return normalizedValue.length > 0 ? normalizedValue : null; - } - - private normalizeProviderName(value: string | undefined): string { - return this.normalizeOptionalString(value) ?? ""; - } - - private encryptOptionalString(value: string | undefined): string | null | undefined { - const normalizedValue = this.normalizeOptionalString(value); - return this.dataEncryptionService.encryptString(normalizedValue); - } - - private encryptRequiredString(value: string): string { - const encryptedValue = this.dataEncryptionService.encryptString(value); - if (!encryptedValue) { - throw new BadRequestException("敏感配置加密失败"); - } - - return encryptedValue; - } - - private readDecryptedString(value: string | null): string | null { - const decryptedValue = this.dataEncryptionService.decryptString(value); - return typeof decryptedValue === "string" ? decryptedValue : null; - } - - private validateBindingInput(dto: UpsertAiProviderBindingDto): void { - const providerName = this.normalizeOptionalString(dto.providerName); - const configId = this.normalizeOptionalString(dto.configId); - const configName = this.normalizeOptionalString(dto.configName); - - if (dto.channel === AiChannel.ASTRBOT) { - if (!providerName && !configId && !configName) { - throw new BadRequestException( - "AstrBot 通道至少需要 providerName、configId、configName 三者之一" - ); - } - return; - } - - if (!providerName) { - throw new BadRequestException("当前通道必须提供 providerName"); - } - } - - private maskSecret(secret: string | null): string | null { - if (!secret) { - return null; - } - - if (secret.length <= 6) { - return "*".repeat(secret.length); - } - - return `${secret.slice(0, 4)}***${secret.slice(-2)}`; - } - - private limitPreviewText(content: string): string { - const normalizedContent = content.replace(/\s+/g, " ").trim(); - if (normalizedContent.length <= 60) { - return normalizedContent; - } - - return `${normalizedContent.slice(0, 60)}...`; - } - - private getPriorityWeight(priority: TaskPriority): number { - switch (priority) { - case TaskPriority.URGENT: - return 4; - case TaskPriority.HIGH: - return 3; - case TaskPriority.MEDIUM: - return 2; - case TaskPriority.LOW: - return 1; - default: - return 0; - } - } - - private getPriorityLabel(priority: TaskPriority): string { - switch (priority) { - case TaskPriority.URGENT: - return "紧急"; - case TaskPriority.HIGH: - return "高"; - case TaskPriority.MEDIUM: - return "中"; - case TaskPriority.LOW: - return "低"; - default: - return String(priority); - } - } - - private getStatusLabel(status: TaskStatus): string { - switch (status) { - case TaskStatus.TODO: - return "待开始"; - case TaskStatus.IN_PROGRESS: - return "进行中"; - case TaskStatus.DONE: - return "已完成"; - case TaskStatus.ARCHIVED: - return "已归档"; - default: - return String(status); - } - } - - private getContentSnippet(contentText: string | null): string | null { - if (!contentText) { - return null; - } - - const normalizedContent = contentText.replace(/\s+/g, " ").trim(); - if (normalizedContent.length === 0) { - return null; - } - - if (normalizedContent.length <= this.maxContextContentLength) { - return normalizedContent; - } - - return `${normalizedContent.slice(0, this.maxContextContentLength)}...`; - } - - private async recordUsageLog(input: { - userId: string; - channel: AiChannel; - providerName: string | null; - model: string | null; - usage: AiUsageMetrics | null; - latencyMs: number; - success: boolean; - errorCode: string | null; - }): Promise { - try { - await this.prismaService.aiUsageLog.create({ - data: { - userId: input.userId, - channel: input.channel, - providerName: - input.providerName === null - ? null - : this.dataEncryptionService.encryptString(input.providerName), - model: - input.model === null ? null : this.dataEncryptionService.encryptString(input.model), - promptTokens: input.usage?.promptTokens ?? 0, - completionTokens: input.usage?.completionTokens ?? 0, - totalTokens: input.usage?.totalTokens ?? 0, - latencyMs: input.latencyMs, - success: input.success, - errorCode: input.errorCode - } - }); - } catch (error) { - const message = error instanceof Error ? error.message : "未知错误"; - this.logger.warn(`写入 AI 使用日志失败:${message}`); - } - } -} diff --git a/apps/api/src/ai/ai.types.ts b/apps/api/src/ai/ai.types.ts deleted file mode 100644 index ccb8088..0000000 --- a/apps/api/src/ai/ai.types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AiChannel } from "../../generated/prisma/client"; - -export type AiResolvedRouteCandidate = { - channel: AiChannel; - source: "binding" | "public_pool"; - sourceId: string | null; - providerName: string; - model: string | null; - configId: string | null; - configName: string | null; - endpoint: string | null; - apiKey: string | null; -}; - -export type AiChatInput = { - userId: string; - message: string; - sessionId: string | null; -}; - -export type AiChatResult = { - channel: AiChannel; - providerName: string; - model: string | null; - content: string; - sessionId: string | null; - usage: AiUsageMetrics | null; - raw: unknown; -}; - -export type AiUsageMetrics = { - promptTokens: number; - completionTokens: number; - totalTokens: number; -}; - -export type AiRouteAttempt = { - channel: AiChannel; - providerName: string | null; - model: string | null; - status: "skipped" | "failed" | "success"; - reasonCode: string | null; - reasonMessage: string | null; -}; - -export class AiRouteFailureError extends Error { - constructor( - public readonly channel: AiChannel, - public readonly providerName: string, - public readonly code: string, - message: string - ) { - super(message); - this.name = "AiRouteFailureError"; - Object.setPrototypeOf(this, new.target.prototype); - } -} - -export interface AiChannelExecutor { - execute(candidate: AiResolvedRouteCandidate, input: AiChatInput): Promise; -} diff --git a/apps/api/src/ai/dto/ai-chat.dto.ts b/apps/api/src/ai/dto/ai-chat.dto.ts deleted file mode 100644 index e2613ae..0000000 --- a/apps/api/src/ai/dto/ai-chat.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Type } from "class-transformer"; -import { - IsArray, - IsEnum, - IsInt, - IsOptional, - IsString, - MinLength, - ValidateNested -} from "class-validator"; -import { AiChannel } from "../../../generated/prisma/client"; -import { TaskPriority, TaskStatus } from "../../../generated/prisma/client"; - -export class LocalTaskContextItemDto { - @IsString() - @MinLength(1) - id!: string; - - @IsString() - @MinLength(1) - title!: string; - - @IsEnum(TaskPriority) - priority!: TaskPriority; - - @IsEnum(TaskStatus) - status!: TaskStatus; - - @IsOptional() - @IsInt() - ddlAt?: number | null; - - @IsOptional() - @IsString() - contentText?: string | null; - - @IsInt() - updatedAt!: number; -} - -export class AiChatDto { - @IsString() - @MinLength(1) - message!: string; - - @IsOptional() - @IsString() - @MinLength(1) - sessionId?: string; - - @IsOptional() - @IsEnum(AiChannel) - channel?: AiChannel; - - @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => LocalTaskContextItemDto) - localTasks?: LocalTaskContextItemDto[]; -} diff --git a/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts b/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts deleted file mode 100644 index 49aa2e0..0000000 --- a/apps/api/src/ai/dto/list-ai-usage-logs-query.dto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Transform, Type } from "class-transformer"; -import { IsBoolean, IsEnum, IsInt, IsOptional, Max, Min } from "class-validator"; -import { AiChannel } from "../../../generated/prisma/client"; - -function normalizeBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - - if (typeof value !== "string") { - return undefined; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") { - return true; - } - - if (normalized === "false" || normalized === "0") { - return false; - } - - return undefined; -} - -export class ListAiUsageLogsQueryDto { - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - page?: number; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(100) - pageSize?: number; - - @IsOptional() - @IsEnum(AiChannel) - channel?: AiChannel; - - @Transform(({ value }) => normalizeBoolean(value)) - @IsOptional() - @IsBoolean() - success?: boolean; -} diff --git a/apps/api/src/ai/dto/upsert-ai-provider-binding.dto.ts b/apps/api/src/ai/dto/upsert-ai-provider-binding.dto.ts deleted file mode 100644 index b821bcc..0000000 --- a/apps/api/src/ai/dto/upsert-ai-provider-binding.dto.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AiChannel } from "../../../generated/prisma/client"; -import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, MinLength } from "class-validator"; - -export class UpsertAiProviderBindingDto { - @IsEnum(AiChannel) - channel!: AiChannel; - - @IsOptional() - @IsString() - @MinLength(1) - providerName?: string; - - @IsOptional() - @IsString() - @MinLength(1) - model?: string; - - @IsOptional() - @IsString() - @MinLength(1) - configId?: string; - - @IsOptional() - @IsString() - @MinLength(1) - configName?: string; - - @IsOptional() - @IsUrl( - { - require_tld: false - }, - { - message: "endpoint \u5fc5\u987b\u662f\u5408\u6cd5\u7684 URL" - } - ) - endpoint?: string; - - @IsOptional() - @IsString() - @MinLength(1) - apiKey?: string; - - @IsOptional() - @IsBoolean() - isEnabled?: boolean; -} diff --git a/apps/api/src/ai/providers/astrbot.provider.ts b/apps/api/src/ai/providers/astrbot.provider.ts deleted file mode 100644 index 3d28cbb..0000000 --- a/apps/api/src/ai/providers/astrbot.provider.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { - AiChannelExecutor, - AiChatInput, - AiChatResult, - AiResolvedRouteCandidate, - AiRouteFailureError -} from "../ai.types"; - -@Injectable() -export class AstrbotProvider implements AiChannelExecutor { - async execute(candidate: AiResolvedRouteCandidate, input: AiChatInput): Promise { - const routeLabel = - candidate.providerName || candidate.configName || candidate.configId || "astrbot"; - - if (!candidate.endpoint) { - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - "MISSING_ENDPOINT", - "缺少 AstrBot 服务地址配置" - ); - } - - if (!candidate.apiKey) { - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - "MISSING_API_KEY", - "缺少 AstrBot API Key 配置" - ); - } - - const requestUrl = this.buildRequestUrl(candidate.endpoint); - - let response: Response; - try { - response = await fetch(requestUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${candidate.apiKey}` - }, - body: JSON.stringify({ - username: input.userId, - session_id: input.sessionId ?? undefined, - message: input.message, - enable_streaming: false, - selected_model: candidate.model ?? undefined - }), - signal: AbortSignal.timeout(30000) - }); - } catch (error) { - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - "UPSTREAM_UNREACHABLE", - this.toErrorMessage(error, "AstrBot 服务请求失败") - ); - } - - if (!response.ok) { - const rawText = await response.text(); - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - `UPSTREAM_HTTP_${response.status}`, - this.extractHttpErrorMessage(rawText, response.status) - ); - } - - const events = await this.readSseEvents(response); - let content = ""; - let sessionId = input.sessionId; - - for (const event of events) { - const type = this.readString(event["type"]); - if (type === "session_id") { - sessionId = this.readString(event["session_id"]) ?? sessionId; - continue; - } - - if (type === "error") { - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - this.readString(event["code"]) ?? "ASTRBOT_ERROR", - this.readString(event["data"]) ?? "AstrBot 返回错误" - ); - } - - if (type !== "plain") { - continue; - } - - const chainType = this.readString(event["chain_type"]); - if ( - chainType === "reasoning" || - chainType === "tool_call" || - chainType === "tool_call_result" - ) { - continue; - } - - const data = this.readString(event["data"]); - if (!data) { - continue; - } - - if (event["streaming"] === true) { - content += data; - continue; - } - - content = data; - } - - if (!content.trim()) { - throw new AiRouteFailureError( - candidate.channel, - routeLabel, - "EMPTY_RESPONSE", - "AstrBot 没有返回有效内容" - ); - } - - return { - channel: candidate.channel, - providerName: routeLabel, - model: candidate.model, - content, - sessionId, - usage: this.extractUsage(events), - raw: events - }; - } - - private buildRequestUrl(endpoint: string): string { - const normalizedEndpoint = endpoint.replace(/\/+$/, ""); - if (normalizedEndpoint.endsWith("/api/v1/chat")) { - return normalizedEndpoint; - } - if (normalizedEndpoint.endsWith("/api/v1")) { - return `${normalizedEndpoint}/chat`; - } - if (normalizedEndpoint.endsWith("/api")) { - return `${normalizedEndpoint}/v1/chat`; - } - return `${normalizedEndpoint}/api/v1/chat`; - } - - private parseSseEvents(rawText: string): Array> { - return rawText - .split(/\r?\n\r?\n/) - .map((block) => - block - .split(/\r?\n/) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trim()) - .join("\n") - ) - .filter((payload) => payload.length > 0) - .map((payload) => { - try { - return JSON.parse(payload) as Record; - } catch { - return null; - } - }) - .filter((item): item is Record => item !== null); - } - - private async readSseEvents(response: Response): Promise>> { - if (!response.body) { - return this.parseSseEvents(await response.text()); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const events: Array> = []; - let buffer = ""; - let reachedEndEvent = false; - - try { - while (!reachedEndEvent) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - const segments = buffer.split(/\r?\n\r?\n/); - buffer = segments.pop() ?? ""; - - for (const segment of segments) { - const parsedEvents = this.parseSseEvents(segment); - for (const event of parsedEvents) { - events.push(event); - if (this.readString(event["type"]) === "end") { - reachedEndEvent = true; - break; - } - } - - if (reachedEndEvent) { - break; - } - } - } - - const tail = `${buffer}${decoder.decode()}`; - if (tail.trim().length > 0) { - events.push(...this.parseSseEvents(tail)); - } - } finally { - await reader.cancel(); - } - - return events; - } - - private extractHttpErrorMessage(rawText: string, statusCode: number): string { - try { - const payload = JSON.parse(rawText) as Record; - if (typeof payload["message"] === "string") { - return payload["message"]; - } - if (typeof payload["data"] === "string") { - return payload["data"]; - } - } catch { - return `AstrBot 服务调用失败,状态码 ${statusCode}`; - } - - return `AstrBot 服务调用失败,状态码 ${statusCode}`; - } - - private readString(value: unknown): string | null { - return typeof value === "string" ? value : null; - } - - private toErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error && error.message) { - return error.message; - } - - return fallback; - } - - private extractUsage(events: Array>): AiChatResult["usage"] { - for (const event of events) { - if (this.readString(event["type"]) !== "agent_stats") { - continue; - } - - const data = this.asRecord(event["data"]); - const tokenUsage = this.asRecord(data?.["token_usage"]); - if (!tokenUsage) { - continue; - } - - const promptTokens = - (this.readNumber(tokenUsage["input_other"]) ?? 0) + - (this.readNumber(tokenUsage["input_cached"]) ?? 0); - const completionTokens = this.readNumber(tokenUsage["output"]) ?? 0; - - return { - promptTokens, - completionTokens, - totalTokens: promptTokens + completionTokens - }; - } - - return null; - } - - private asRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null ? (value as Record) : null; - } - - private readNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; - } -} diff --git a/apps/api/src/ai/providers/openai-compatible.provider.ts b/apps/api/src/ai/providers/openai-compatible.provider.ts deleted file mode 100644 index 1ba4eff..0000000 --- a/apps/api/src/ai/providers/openai-compatible.provider.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { - AiChannelExecutor, - AiChatInput, - AiChatResult, - AiResolvedRouteCandidate, - AiRouteFailureError -} from "../ai.types"; - -@Injectable() -export class OpenAiCompatibleProvider implements AiChannelExecutor { - async execute(candidate: AiResolvedRouteCandidate, input: AiChatInput): Promise { - if (!candidate.endpoint) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "MISSING_ENDPOINT", - "缺少 AI 服务地址配置" - ); - } - - if (!candidate.apiKey) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "MISSING_API_KEY", - "缺少 AI 服务密钥配置" - ); - } - - if (!candidate.model) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "MISSING_MODEL", - "缺少 AI 模型配置" - ); - } - - const requestUrl = this.buildRequestUrl(candidate.endpoint); - - let response: Response; - try { - response = await fetch(requestUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${candidate.apiKey}` - }, - body: JSON.stringify({ - model: candidate.model, - messages: [ - { - role: "user", - content: input.message - } - ], - stream: false - }), - signal: AbortSignal.timeout(30000) - }); - } catch (error) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "UPSTREAM_UNREACHABLE", - this.toErrorMessage(error, "AI 服务请求失败") - ); - } - - let payload: unknown; - try { - payload = await response.json(); - } catch (error) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "INVALID_RESPONSE", - this.toErrorMessage(error, "AI 服务返回了无法解析的数据") - ); - } - - if (!response.ok) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - `UPSTREAM_HTTP_${response.status}`, - this.extractErrorMessage(payload, `AI 服务调用失败,状态码 ${response.status}`) - ); - } - - const content = this.extractAssistantText(payload); - if (!content.trim()) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName, - "EMPTY_RESPONSE", - "AI 服务没有返回有效内容" - ); - } - - return { - channel: candidate.channel, - providerName: candidate.providerName, - model: this.extractModel(payload) ?? candidate.model, - content, - sessionId: input.sessionId, - usage: this.extractUsage(payload), - raw: payload - }; - } - - private buildRequestUrl(endpoint: string): string { - const normalizedEndpoint = endpoint.replace(/\/+$/, ""); - if (normalizedEndpoint.endsWith("/chat/completions")) { - return normalizedEndpoint; - } - if (normalizedEndpoint.endsWith("/v1")) { - return `${normalizedEndpoint}/chat/completions`; - } - return `${normalizedEndpoint}/v1/chat/completions`; - } - - private extractAssistantText(payload: unknown): string { - const chatCompletionText = this.extractChatCompletionText(payload); - if (chatCompletionText) { - return chatCompletionText; - } - - const responsesText = this.extractResponsesApiText(payload); - if (responsesText) { - return responsesText; - } - - return ""; - } - - private extractChatCompletionText(payload: unknown): string { - if (!this.isRecord(payload)) { - return ""; - } - - const choices = payload["choices"]; - if (!Array.isArray(choices) || choices.length === 0) { - return ""; - } - - const firstChoice = choices[0]; - if (!this.isRecord(firstChoice)) { - return ""; - } - - const message = firstChoice["message"]; - if (this.isRecord(message)) { - const messageContent = this.extractMessageContent(message["content"]); - if (messageContent) { - return messageContent; - } - } - - if (typeof firstChoice["text"] === "string") { - return firstChoice["text"]; - } - - return ""; - } - - private extractResponsesApiText(payload: unknown): string { - if (!this.isRecord(payload)) { - return ""; - } - - if (typeof payload["output_text"] === "string") { - return payload["output_text"]; - } - - const output = payload["output"]; - if (!Array.isArray(output)) { - return ""; - } - - return output - .map((item) => { - if (!this.isRecord(item)) { - return ""; - } - - if (typeof item["text"] === "string") { - return item["text"]; - } - - return this.extractMessageContent(item["content"]); - }) - .filter((item) => item.length > 0) - .join("\n") - .trim(); - } - - private extractMessageContent(content: unknown): string { - if (typeof content === "string") { - return content; - } - - if (!Array.isArray(content)) { - return ""; - } - - return content - .map((item) => this.extractContentPartText(item)) - .filter((item) => item.length > 0) - .join("\n") - .trim(); - } - - private extractContentPartText(item: unknown): string { - if (!this.isRecord(item)) { - return ""; - } - - if (typeof item["text"] === "string") { - return item["text"]; - } - - if (this.isRecord(item["text"]) && typeof item["text"]["value"] === "string") { - return item["text"]["value"]; - } - - if (typeof item["content"] === "string") { - return item["content"]; - } - - if (this.isRecord(item["content"]) && typeof item["content"]["text"] === "string") { - return item["content"]["text"]; - } - - return ""; - } - - private extractModel(payload: unknown): string | null { - if (!this.isRecord(payload) || typeof payload["model"] !== "string") { - return null; - } - - return payload["model"]; - } - - private extractUsage(payload: unknown): AiChatResult["usage"] { - if (!this.isRecord(payload)) { - return null; - } - - const usage = payload["usage"]; - if (!this.isRecord(usage)) { - return null; - } - - const promptTokens = this.readNumber(usage["prompt_tokens"]); - const completionTokens = this.readNumber(usage["completion_tokens"]); - const totalTokens = this.readNumber(usage["total_tokens"]); - - if (promptTokens === null && completionTokens === null && totalTokens === null) { - return null; - } - - return { - promptTokens: promptTokens ?? 0, - completionTokens: completionTokens ?? 0, - totalTokens: totalTokens ?? (promptTokens ?? 0) + (completionTokens ?? 0) - }; - } - - private extractErrorMessage(payload: unknown, fallback: string): string { - if (!this.isRecord(payload)) { - return fallback; - } - - const error = payload["error"]; - if (!this.isRecord(error) || typeof error["message"] !== "string") { - return fallback; - } - - return error["message"]; - } - - private isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; - } - - private toErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error && error.message) { - return error.message; - } - - return fallback; - } - - private readNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; - } -} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts deleted file mode 100644 index aae9297..0000000 --- a/apps/api/src/app.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; -import { resolve } from "node:path"; -import { AiModule } from "./ai/ai.module"; -import { AttachmentModule } from "./attachment/attachment.module"; -import { AuthModule } from "./auth/auth.module"; -import { PrismaModule } from "./prisma/prisma.module"; -import { SecurityModule } from "./security/security.module"; -import { SyncModule } from "./sync/sync.module"; -import { TaskModule } from "./task/task.module"; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: [resolve(__dirname, "../.env"), ".env"] - }), - PrismaModule, - SecurityModule, - AuthModule, - TaskModule, - AttachmentModule, - SyncModule, - AiModule - ] -}) -export class AppModule {} diff --git a/apps/api/src/attachment/attachment.controller.ts b/apps/api/src/attachment/attachment.controller.ts deleted file mode 100644 index 6baec6e..0000000 --- a/apps/api/src/attachment/attachment.controller.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Body, Controller, Headers, Post, UnauthorizedException } from "@nestjs/common"; -import { - AttachmentResponse, - AttachmentService, - PresignAttachmentResponse -} from "./attachment.service"; -import { CompleteAttachmentDto } from "./dto/complete-attachment.dto"; -import { PresignAttachmentDto } from "./dto/presign-attachment.dto"; - -@Controller("attachments") -export class AttachmentController { - constructor(private readonly attachmentService: AttachmentService) {} - - @Post("presign") - async presignAttachment( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: PresignAttachmentDto - ): Promise { - return this.attachmentService.presignAttachment(this.resolveUserId(userIdHeader), body); - } - - @Post("complete") - async completeAttachment( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: CompleteAttachmentDto - ): Promise { - return this.attachmentService.completeAttachment(this.resolveUserId(userIdHeader), body); - } - - private resolveUserId(userIdHeader: string | string[] | undefined): string { - const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader; - if (!userId) { - throw new UnauthorizedException("缺少用户上下文"); - } - - return userId; - } -} diff --git a/apps/api/src/attachment/attachment.module.ts b/apps/api/src/attachment/attachment.module.ts deleted file mode 100644 index cd8dfe3..0000000 --- a/apps/api/src/attachment/attachment.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { PrismaModule } from "../prisma/prisma.module"; -import { AttachmentController } from "./attachment.controller"; -import { AttachmentService } from "./attachment.service"; - -@Module({ - imports: [PrismaModule], - controllers: [AttachmentController], - providers: [AttachmentService] -}) -export class AttachmentModule {} diff --git a/apps/api/src/attachment/attachment.service.ts b/apps/api/src/attachment/attachment.service.ts deleted file mode 100644 index cb15f7e..0000000 --- a/apps/api/src/attachment/attachment.service.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - Injectable, - InternalServerErrorException, - NotFoundException, - PayloadTooLargeException -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { AttachmentType } from "../../generated/prisma/client"; -import { PrismaService } from "../prisma/prisma.service"; -import { DataEncryptionService } from "../security/data-encryption.service"; -import { CompleteAttachmentDto } from "./dto/complete-attachment.dto"; -import { PresignAttachmentDto } from "./dto/presign-attachment.dto"; - -type QuotaInfo = { - totalBytes: bigint; - usedBytes: bigint; -}; - -export type PresignAttachmentResponse = { - method: "PUT"; - uploadUrl: string; - bucket: string; - objectKey: string; - objectUrl: string; - expiresInSeconds: number; - quota: { - totalBytes: string; - usedBytes: string; - remainingBytes: string; - }; - headers: Record; -}; - -export type AttachmentResponse = { - id: string; - taskId: string | null; - type: AttachmentType; - url: string; - mimeType: string | null; - fileName: string | null; - fileSize: number; - width: number | null; - height: number | null; - durationMs: number | null; - checksum: string | null; - createdAt: string; - updatedAt: string; -}; - -@Injectable() -export class AttachmentService { - private s3Client: S3Client | null = null; - - constructor( - private readonly configService: ConfigService, - private readonly prismaService: PrismaService, - private readonly dataEncryptionService: DataEncryptionService - ) {} - - async presignAttachment( - userId: string, - body: PresignAttachmentDto - ): Promise { - const quotaInfo = await this.getQuotaSnapshot(userId); - this.assertQuotaAvailable(quotaInfo.totalBytes, quotaInfo.usedBytes, body.fileSize); - - if (body.taskId) { - await this.ensureTaskOwnership(userId, body.taskId); - } - - const bucket = this.getDefaultBucket(); - const objectKey = this.generateObjectKey(body.fileName); - const objectUrl = this.resolveObjectUrl(bucket, objectKey); - const expiresInSeconds = this.getPresignExpiresInSeconds(); - const serverSideEncryption = this.getServerSideEncryptionMode(); - - const command = new PutObjectCommand({ - Bucket: bucket, - Key: objectKey, - ContentType: body.mimeType, - ContentLength: body.fileSize, - ServerSideEncryption: serverSideEncryption - }); - - const uploadUrl = await getSignedUrl(this.getS3Client(), command, { - expiresIn: expiresInSeconds - }); - - return { - method: "PUT", - uploadUrl, - bucket, - objectKey, - objectUrl, - expiresInSeconds, - quota: { - totalBytes: quotaInfo.totalBytes.toString(), - usedBytes: quotaInfo.usedBytes.toString(), - remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString() - }, - headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption) - }; - } - - async completeAttachment( - userId: string, - body: CompleteAttachmentDto - ): Promise { - if (body.taskId) { - await this.ensureTaskOwnership(userId, body.taskId); - } - - const bucket = body.bucket ?? this.getDefaultBucket(); - const objectUrl = this.resolveObjectUrl(bucket, body.objectKey); - - const attachment = await this.prismaService.$transaction(async (tx) => { - const quotaInfo = await this.getQuotaSnapshot(userId, tx); - this.assertQuotaAvailable(quotaInfo.totalBytes, quotaInfo.usedBytes, body.fileSize); - - const uploadBytes = BigInt(body.fileSize); - const maxUsedBeforeUpload = quotaInfo.totalBytes - uploadBytes; - const updatedUser = await tx.user.updateMany({ - where: { - id: userId, - usedStorageBytes: { - lte: maxUsedBeforeUpload - } - }, - data: { - usedStorageBytes: { - increment: uploadBytes - } - } - }); - if (updatedUser.count === 0) { - throw new PayloadTooLargeException("存储配额不足"); - } - - return tx.attachment.create({ - data: { - userId, - taskId: body.taskId ?? null, - type: body.type ?? this.resolveAttachmentType(body.mimeType), - url: this.encryptRequiredString(objectUrl), - mimeType: body.mimeType, - fileName: this.encryptNullableString(body.fileName), - fileSize: body.fileSize, - width: body.width ?? null, - height: body.height ?? null, - durationMs: body.durationMs ?? null, - checksum: this.encryptNullableString(body.checksum) - } - }); - }); - - return { - id: attachment.id, - taskId: attachment.taskId, - type: attachment.type, - url: this.readDecryptedString(attachment.url) ?? objectUrl, - mimeType: attachment.mimeType, - fileName: this.readDecryptedString(attachment.fileName), - fileSize: attachment.fileSize, - width: attachment.width, - height: attachment.height, - durationMs: attachment.durationMs, - checksum: this.readDecryptedString(attachment.checksum), - createdAt: attachment.createdAt.toISOString(), - updatedAt: attachment.updatedAt.toISOString() - }; - } - - private getS3Client(): S3Client { - if (this.s3Client) { - return this.s3Client; - } - - const endpoint = this.configService.get("S3_ENDPOINT") ?? "http://127.0.0.1:9000"; - const region = this.configService.get("S3_REGION") ?? "us-east-1"; - const forcePathStyle = - this.configService.get("S3_FORCE_PATH_STYLE")?.toLowerCase() !== "false"; - - this.s3Client = new S3Client({ - endpoint, - region, - forcePathStyle, - credentials: { - accessKeyId: this.configService.get("S3_ACCESS_KEY_ID") ?? "minioadmin", - secretAccessKey: this.configService.get("S3_SECRET_ACCESS_KEY") ?? "minioadmin" - } - }); - - return this.s3Client; - } - - private getDefaultBucket(): string { - return this.configService.get("S3_BUCKET") ?? "todolist"; - } - - private getPresignExpiresInSeconds(): number { - const configValue = Number(this.configService.get("S3_PRESIGN_EXPIRES_SECONDS") ?? 900); - if (!Number.isFinite(configValue) || configValue <= 0) { - return 900; - } - - return Math.min(configValue, 604800); - } - - private generateObjectKey(fileName: string): string { - const datePrefix = new Date().toISOString().slice(0, 10); - return `attachments/${datePrefix}/${randomUUID()}${this.extractFileExtension(fileName)}`; - } - - private resolveObjectUrl(bucket: string, objectKey: string): string { - const publicBaseUrl = this.configService.get("S3_PUBLIC_BASE_URL"); - if (publicBaseUrl) { - return `${publicBaseUrl.replace(/\/+$/, "")}/${bucket}/${objectKey}`; - } - - const endpoint = this.configService.get("S3_ENDPOINT") ?? "http://127.0.0.1:9000"; - return `${endpoint.replace(/\/+$/, "")}/${bucket}/${objectKey}`; - } - - private resolveAttachmentType(mimeType: string): AttachmentType { - if (mimeType.startsWith("image/")) { - return AttachmentType.IMAGE; - } - - if (mimeType.startsWith("video/")) { - return AttachmentType.VIDEO; - } - - return AttachmentType.FILE; - } - - private buildUploadHeaders( - mimeType: string, - serverSideEncryption: "AES256" | undefined - ): Record { - const headers: Record = { - "Content-Type": mimeType - }; - - if (serverSideEncryption) { - headers["x-amz-server-side-encryption"] = serverSideEncryption; - } - - return headers; - } - - private getServerSideEncryptionMode(): "AES256" | undefined { - const configValue = - this.configService.get("S3_SERVER_SIDE_ENCRYPTION")?.trim().toUpperCase() ?? "AES256"; - - if (configValue === "NONE" || configValue === "DISABLED") { - return undefined; - } - - return "AES256"; - } - - private extractFileExtension(fileName: string): string { - const match = /\.[a-zA-Z0-9]{1,16}$/.exec(fileName); - return match?.[0]?.toLowerCase() ?? ""; - } - - private async ensureTaskOwnership(userId: string, taskId: string): Promise { - const task = await this.prismaService.task.findFirst({ - where: { - id: taskId, - userId - }, - select: { - id: true - } - }); - - if (!task) { - throw new NotFoundException("任务不存在"); - } - } - - private async getQuotaSnapshot( - userId: string, - tx: Pick = this.prismaService - ): Promise { - const user = await tx.user.findUnique({ - where: { - id: userId - }, - select: { - id: true, - defaultStorageQuotaMb: true, - usedStorageBytes: true - } - }); - - if (!user) { - throw new NotFoundException("用户不存在"); - } - - return { - totalBytes: BigInt(user.defaultStorageQuotaMb) * 1024n * 1024n, - usedBytes: user.usedStorageBytes - }; - } - - private assertQuotaAvailable(totalBytes: bigint, usedBytes: bigint, fileSize: number): void { - const uploadBytes = BigInt(fileSize); - if (uploadBytes > totalBytes || usedBytes + uploadBytes > totalBytes) { - throw new PayloadTooLargeException("存储配额不足"); - } - } - - private encryptRequiredString(value: string): string { - const encryptedValue = this.dataEncryptionService.encryptString(value); - if (!encryptedValue) { - throw new InternalServerErrorException("附件元数据加密失败"); - } - - return encryptedValue; - } - - private encryptNullableString(value: string | null | undefined): string | null | undefined { - return this.dataEncryptionService.encryptString(value); - } - - private readDecryptedString(value: string | null): string | null { - const decryptedValue = this.dataEncryptionService.decryptString(value); - return typeof decryptedValue === "string" ? decryptedValue : null; - } -} diff --git a/apps/api/src/attachment/dto/complete-attachment.dto.ts b/apps/api/src/attachment/dto/complete-attachment.dto.ts deleted file mode 100644 index 2318e3b..0000000 --- a/apps/api/src/attachment/dto/complete-attachment.dto.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Transform, Type } from "class-transformer"; -import { - IsEnum, - IsInt, - IsOptional, - IsString, - Max, - MaxLength, - Min, - MinLength -} from "class-validator"; -import { AttachmentType } from "../../../generated/prisma/client"; - -function normalizeString(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - - return value.trim(); -} - -export class CompleteAttachmentDto { - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(255) - objectKey!: string; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(100) - bucket?: string; - - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(255) - fileName!: string; - - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(255) - mimeType!: string; - - @Type(() => Number) - @IsInt() - @Min(1) - @Max(1073741824) - fileSize!: number; - - @IsOptional() - @IsEnum(AttachmentType) - type?: AttachmentType; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(255) - taskId?: string; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(128) - checksum?: string; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(100000) - width?: number; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(100000) - height?: number; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(86400000) - durationMs?: number; -} diff --git a/apps/api/src/attachment/dto/presign-attachment.dto.ts b/apps/api/src/attachment/dto/presign-attachment.dto.ts deleted file mode 100644 index 9eda1a9..0000000 --- a/apps/api/src/attachment/dto/presign-attachment.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Transform } from "class-transformer"; -import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator"; - -function normalizeString(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - - return value.trim(); -} - -export class PresignAttachmentDto { - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(255) - fileName!: string; - - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(255) - mimeType!: string; - - @IsInt() - @Min(1) - @Max(1073741824) - fileSize!: number; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(255) - taskId?: string; -} diff --git a/apps/api/src/auth/auth-mail.service.ts b/apps/api/src/auth/auth-mail.service.ts deleted file mode 100644 index b0944bb..0000000 --- a/apps/api/src/auth/auth-mail.service.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Injectable, - InternalServerErrorException, - Logger, - ServiceUnavailableException -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { createTransport, type Transporter } from "nodemailer"; - -type MailRuntimeConfig = { - host: string; - port: number; - secure: boolean; - user: string; - pass: string; - fromName: string; - fromAddress: string; -}; - -@Injectable() -export class AuthMailService { - private readonly logger = new Logger(AuthMailService.name); - private cachedConfig: MailRuntimeConfig | null = null; - private transporter: Transporter | null = null; - - constructor(private readonly configService: ConfigService) {} - - async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise { - const config = this.getRuntimeConfig(); - const transporter = this.getTransporter(config); - - try { - await transporter.sendMail({ - from: this.resolveFromField(config), - to: email, - subject: "TodoList 登录验证码", - text: `你的验证码是 ${code},${ttlSeconds} 秒内有效。`, - html: `

你的验证码是 ${code},${ttlSeconds} 秒内有效。

` - }); - } catch (error) { - this.logger.error( - `验证码邮件发送失败: ${email}`, - error instanceof Error ? error.stack : undefined - ); - throw new ServiceUnavailableException("验证码邮件发送失败,请稍后重试"); - } - } - - private getTransporter(config: MailRuntimeConfig): Transporter { - if (this.transporter) { - return this.transporter; - } - - this.transporter = createTransport({ - host: config.host, - port: config.port, - secure: config.secure, - auth: { - user: config.user, - pass: config.pass - } - }); - - return this.transporter; - } - - private getRuntimeConfig(): MailRuntimeConfig { - if (this.cachedConfig) { - return this.cachedConfig; - } - - const host = this.getRequiredString("MAIL_SMTP_HOST"); - const port = this.getRequiredNumber("MAIL_SMTP_PORT"); - const secure = this.getBoolean("MAIL_SMTP_SECURE", port === 465); - const user = this.getRequiredString("MAIL_SMTP_USER"); - const pass = this.getRequiredString("MAIL_SMTP_PASS"); - const fromName = this.configService.get("MAIL_FROM_NAME")?.trim() || "TodoList"; - const fromAddress = this.configService.get("MAIL_FROM_ADDRESS")?.trim() || user; - - const config: MailRuntimeConfig = { - host, - port, - secure, - user, - pass, - fromName, - fromAddress - }; - - this.cachedConfig = config; - return config; - } - - private getRequiredString(key: string): string { - const value = this.configService.get(key)?.trim(); - if (!value) { - throw new InternalServerErrorException(`邮件配置缺失: ${key}`); - } - - return value; - } - - private getRequiredNumber(key: string): number { - const rawValue = this.configService.get(key)?.trim(); - if (!rawValue) { - throw new InternalServerErrorException(`邮件配置缺失: ${key}`); - } - - const parsedValue = Number(rawValue); - if (!Number.isFinite(parsedValue)) { - throw new InternalServerErrorException(`邮件配置格式错误: ${key}`); - } - - return parsedValue; - } - - private getBoolean(key: string, fallback: boolean): boolean { - const rawValue = this.configService.get(key); - if (!rawValue) { - return fallback; - } - - const normalizedValue = rawValue.trim().toLowerCase(); - return normalizedValue === "true" || normalizedValue === "1"; - } - - private resolveFromField(config: MailRuntimeConfig): string { - const sanitizedName = config.fromName.replace(/"/g, ""); - return `"${sanitizedName}" <${config.fromAddress}>`; - } -} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts deleted file mode 100644 index 707399a..0000000 --- a/apps/api/src/auth/auth.controller.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common"; -import { AuthGuard } from "@nestjs/passport"; -import { AuthService } from "./auth.service"; -import { EmailLoginDto } from "./dto/email-login.dto"; -import { RefreshTokenDto } from "./dto/refresh-token.dto"; -import { SendEmailCodeDto } from "./dto/send-email-code.dto"; -import { TwoFactorEnrollDto } from "./dto/two-factor-enroll.dto"; -import { TwoFactorVerifyDto } from "./dto/two-factor-verify.dto"; - -@Controller("auth") -export class AuthController { - constructor(private readonly authService: AuthService) {} - - @Post("email/send-code") - async sendEmailCode( - @Body() body: SendEmailCodeDto - ): Promise<{ success: boolean; expiresInSeconds: number }> { - return this.authService.sendEmailCode(body.email); - } - - @Post("email/login") - async loginWithEmailCode(@Body() body: EmailLoginDto): Promise<{ - accessToken: string; - tokenType: "Bearer"; - expiresInSeconds: number; - refreshToken: string; - refreshExpiresInSeconds: number; - user: { id: string; email: string }; - }> { - return this.authService.loginWithEmailCode(body.email, body.code); - } - - @Post("token/refresh") - async refreshTokens(@Body() body: RefreshTokenDto): Promise<{ - accessToken: string; - tokenType: "Bearer"; - expiresInSeconds: number; - refreshToken: string; - refreshExpiresInSeconds: number; - user: { id: string; email: string }; - }> { - return this.authService.refreshTokens(body.refreshToken); - } - - @Post("token/revoke") - async revokeRefreshToken(@Body() body: RefreshTokenDto): Promise<{ success: boolean }> { - return this.authService.revokeRefreshToken(body.refreshToken); - } - - @Post("2fa/enroll") - async enrollTwoFactor(@Body() body: TwoFactorEnrollDto): Promise<{ - userId: string; - secret: string; - otpauthUrl: string; - enabled: boolean; - }> { - return this.authService.enrollTwoFactor(body.email); - } - - @Post("2fa/verify") - async verifyTwoFactor( - @Body() body: TwoFactorVerifyDto - ): Promise<{ success: boolean; enabled: boolean }> { - return this.authService.verifyTwoFactor(body.email, body.token); - } - - @Get("oauth/github") - @UseGuards(AuthGuard("github")) - githubLogin(): void {} - - @Get("oauth/github/callback") - @UseGuards(AuthGuard("github")) - githubCallback(@Req() req: { user: unknown }): { - success: boolean; - provider: "github"; - profile: unknown; - } { - return { - success: true, - provider: "github", - profile: req.user - }; - } - - @Get("oauth/qq") - @UseGuards(AuthGuard("qq")) - qqLogin(): void {} - - @Get("oauth/qq/callback") - @UseGuards(AuthGuard("qq")) - qqCallback(@Req() req: { user: unknown }): { - success: boolean; - provider: "qq"; - profile: unknown; - } { - return { - success: true, - provider: "qq", - profile: req.user - }; - } - - @Get("oauth/wechat") - @UseGuards(AuthGuard("wechat")) - wechatLogin(): void {} - - @Get("oauth/wechat/callback") - @UseGuards(AuthGuard("wechat")) - wechatCallback(@Req() req: { user: unknown }): { - success: boolean; - provider: "wechat"; - profile: unknown; - } { - return { - success: true, - provider: "wechat", - profile: req.user - }; - } -} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts deleted file mode 100644 index ede59b7..0000000 --- a/apps/api/src/auth/auth.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { JwtModule } from "@nestjs/jwt"; -import { PassportModule } from "@nestjs/passport"; -import { AuthController } from "./auth.controller"; -import { AuthMailService } from "./auth-mail.service"; -import { AuthService } from "./auth.service"; -import { GithubStrategy } from "./strategies/github.strategy"; -import { QqStrategy } from "./strategies/qq.strategy"; -import { WechatStrategy } from "./strategies/wechat.strategy"; - -@Module({ - imports: [ - ConfigModule, - PassportModule.register({ session: false }), - JwtModule.registerAsync({ - inject: [ConfigService], - useFactory: (configService: ConfigService) => { - const expiresInSeconds = Number(configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900); - - return { - secret: configService.get("AUTH_ACCESS_SECRET") ?? "dev-access-secret", - signOptions: { - expiresIn: expiresInSeconds - } - }; - } - }) - ], - controllers: [AuthController], - providers: [AuthService, AuthMailService, GithubStrategy, QqStrategy, WechatStrategy] -}) -export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts deleted file mode 100644 index 8554a5c..0000000 --- a/apps/api/src/auth/auth.service.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { JwtService } from "@nestjs/jwt"; -import { randomUUID } from "node:crypto"; -import { authenticator } from "@otplib/preset-default"; -import { AuthMailService } from "./auth-mail.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { DataEncryptionService } from "../security/data-encryption.service"; - -type EmailCodeEntry = { - code: string; - expiresAt: number; -}; - -type AuthUser = { - id: string; - email: string; -}; - -type AuthTokenResult = { - accessToken: string; - tokenType: "Bearer"; - expiresInSeconds: number; - refreshToken: string; - refreshExpiresInSeconds: number; - user: AuthUser; -}; - -@Injectable() -export class AuthService { - private readonly emailCodeStore = new Map(); - - constructor( - private readonly configService: ConfigService, - private readonly jwtService: JwtService, - private readonly authMailService: AuthMailService, - private readonly prismaService: PrismaService, - private readonly dataEncryptionService: DataEncryptionService - ) {} - - async sendEmailCode(email: string): Promise<{ success: boolean; expiresInSeconds: number }> { - const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300); - const code = this.generateCode(); - const expiresAt = Date.now() + ttlSeconds * 1000; - const normalizedEmail = email.toLowerCase(); - - await this.authMailService.sendLoginCode(normalizedEmail, code, ttlSeconds); - this.emailCodeStore.set(normalizedEmail, { code, expiresAt }); - - return { - success: true, - expiresInSeconds: ttlSeconds - }; - } - - async loginWithEmailCode(email: string, code: string): Promise { - const lowerEmail = email.toLowerCase(); - const codeEntry = this.emailCodeStore.get(lowerEmail); - - if (!codeEntry) { - throw new UnauthorizedException("验证码不存在或已失效"); - } - - if (codeEntry.expiresAt < Date.now()) { - this.emailCodeStore.delete(lowerEmail); - throw new UnauthorizedException("验证码已过期"); - } - - if (codeEntry.code !== code) { - throw new UnauthorizedException("验证码错误"); - } - - this.emailCodeStore.delete(lowerEmail); - - const user = await this.getOrCreateUser(lowerEmail); - return this.issueTokens(user); - } - - async refreshTokens(refreshToken: string): Promise { - const entry = await this.prismaService.refreshToken.findUnique({ - where: { - tokenHash: refreshToken - }, - include: { - user: { - select: { - id: true, - email: true - } - } - } - }); - - if (!entry) { - throw new UnauthorizedException("刷新令牌不存在"); - } - - if (entry.revokedAt) { - throw new UnauthorizedException("刷新令牌已注销"); - } - - if (entry.expiresAt.getTime() < Date.now()) { - await this.prismaService.refreshToken.update({ - where: { - id: entry.id - }, - data: { - revokedAt: new Date() - } - }); - throw new UnauthorizedException("刷新令牌已过期"); - } - - await this.prismaService.refreshToken.update({ - where: { - id: entry.id - }, - data: { - revokedAt: new Date() - } - }); - - return this.issueTokens({ - id: entry.user.id, - email: this.readRequiredEmail(entry.user.email) - }); - } - - async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> { - await this.prismaService.refreshToken.updateMany({ - where: { - tokenHash: refreshToken, - revokedAt: null - }, - data: { - revokedAt: new Date() - } - }); - - return { success: true }; - } - - async enrollTwoFactor( - email: string - ): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> { - const user = await this.getOrCreateUser(email.toLowerCase()); - const secret = authenticator.generateSecret(); - const issuer = this.configService.get("AUTH_TOTP_ISSUER") ?? "TodoList"; - const otpauthUrl = authenticator.keyuri(user.email, issuer, secret); - - await this.prismaService.userSecurity.upsert({ - where: { - userId: user.id - }, - update: { - twoFactorSecret: secret, - twoFactorEnabled: false - }, - create: { - userId: user.id, - twoFactorSecret: secret, - twoFactorEnabled: false - } - }); - - return { - userId: user.id, - secret, - otpauthUrl, - enabled: false - }; - } - - async verifyTwoFactor( - email: string, - token: string - ): Promise<{ success: boolean; enabled: boolean }> { - const user = await this.getOrCreateUser(email.toLowerCase()); - const security = await this.prismaService.userSecurity.findUnique({ - where: { - userId: user.id - }, - select: { - twoFactorSecret: true - } - }); - - if (!security?.twoFactorSecret) { - throw new UnauthorizedException("尚未启用两步验证"); - } - - const valid = authenticator.check(token, security.twoFactorSecret); - if (!valid) { - throw new UnauthorizedException("两步验证码错误"); - } - - await this.prismaService.userSecurity.update({ - where: { - userId: user.id - }, - data: { - twoFactorEnabled: true - } - }); - - return { - success: true, - enabled: true - }; - } - - private async getOrCreateUser(email: string): Promise { - const normalizedEmail = email.toLowerCase(); - const emailHash = this.dataEncryptionService.createLookupHash("user.email", normalizedEmail); - const user = await this.prismaService.user.upsert({ - where: { - emailHash - }, - update: {}, - create: { - email: this.encryptRequiredString(normalizedEmail), - emailHash - }, - select: { - id: true, - email: true - } - }); - - return { - id: user.id, - email: this.readRequiredEmail(user.email) - }; - } - - private generateCode(): string { - return String(Math.floor(100000 + Math.random() * 900000)); - } - - private async issueTokens(user: AuthUser): Promise { - const accessExpiresInSeconds = Number( - this.configService.get("AUTH_ACCESS_EXPIRES_IN_SECONDS") ?? 900 - ); - const refreshExpiresInSeconds = Number( - this.configService.get("AUTH_REFRESH_EXPIRES_IN_SECONDS") ?? 2592000 - ); - const accessToken = await this.jwtService.signAsync({ - sub: user.id, - email: user.email - }); - const refreshToken = `${randomUUID()}${randomUUID()}`; - - await this.prismaService.refreshToken.create({ - data: { - userId: user.id, - tokenHash: refreshToken, - expiresAt: new Date(Date.now() + refreshExpiresInSeconds * 1000) - } - }); - - return { - accessToken, - tokenType: "Bearer", - expiresInSeconds: accessExpiresInSeconds, - refreshToken, - refreshExpiresInSeconds, - user - }; - } - - private encryptRequiredString(value: string): string { - const encryptedValue = this.dataEncryptionService.encryptString(value); - if (!encryptedValue) { - throw new UnauthorizedException("用户敏感字段加密失败"); - } - - return encryptedValue; - } - - private readRequiredEmail(value: string): string { - const decryptedValue = this.dataEncryptionService.decryptString(value); - if (typeof decryptedValue !== "string" || decryptedValue.length === 0) { - throw new UnauthorizedException("用户邮箱解密失败"); - } - - return decryptedValue; - } -} diff --git a/apps/api/src/auth/dto/email-login.dto.ts b/apps/api/src/auth/dto/email-login.dto.ts deleted file mode 100644 index 32b1511..0000000 --- a/apps/api/src/auth/dto/email-login.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsEmail, IsString, Length, Matches } from "class-validator"; - -export class EmailLoginDto { - @IsEmail() - email!: string; - - @IsString() - @Length(6, 6) - @Matches(/^\d{6}$/) - code!: string; -} diff --git a/apps/api/src/auth/dto/refresh-token.dto.ts b/apps/api/src/auth/dto/refresh-token.dto.ts deleted file mode 100644 index 2d91b3c..0000000 --- a/apps/api/src/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsString, MinLength } from "class-validator"; - -export class RefreshTokenDto { - @IsString() - @MinLength(20) - refreshToken!: string; -} diff --git a/apps/api/src/auth/dto/send-email-code.dto.ts b/apps/api/src/auth/dto/send-email-code.dto.ts deleted file mode 100644 index 998b618..0000000 --- a/apps/api/src/auth/dto/send-email-code.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsEmail } from "class-validator"; - -export class SendEmailCodeDto { - @IsEmail() - email!: string; -} diff --git a/apps/api/src/auth/dto/two-factor-enroll.dto.ts b/apps/api/src/auth/dto/two-factor-enroll.dto.ts deleted file mode 100644 index 2dea4ef..0000000 --- a/apps/api/src/auth/dto/two-factor-enroll.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsEmail } from "class-validator"; - -export class TwoFactorEnrollDto { - @IsEmail() - email!: string; -} diff --git a/apps/api/src/auth/dto/two-factor-verify.dto.ts b/apps/api/src/auth/dto/two-factor-verify.dto.ts deleted file mode 100644 index f9317bd..0000000 --- a/apps/api/src/auth/dto/two-factor-verify.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsEmail, IsString, Length, Matches } from "class-validator"; - -export class TwoFactorVerifyDto { - @IsEmail() - email!: string; - - @IsString() - @Length(6, 6) - @Matches(/^\d{6}$/) - token!: string; -} diff --git a/apps/api/src/auth/strategies/github.strategy.ts b/apps/api/src/auth/strategies/github.strategy.ts deleted file mode 100644 index 3b3133d..0000000 --- a/apps/api/src/auth/strategies/github.strategy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PassportStrategy } from "@nestjs/passport"; -import { Profile, Strategy } from "passport-github2"; - -@Injectable() -export class GithubStrategy extends PassportStrategy(Strategy, "github") { - constructor(configService: ConfigService) { - super({ - clientID: configService.get("OAUTH_GITHUB_CLIENT_ID") ?? "github-client-id", - clientSecret: - configService.get("OAUTH_GITHUB_CLIENT_SECRET") ?? "github-client-secret", - callbackURL: - configService.get("OAUTH_GITHUB_CALLBACK_URL") ?? - "http://localhost:3000/auth/oauth/github/callback", - scope: ["user:email"] - }); - } - - async validate( - accessToken: string, - refreshToken: string, - profile: Profile - ): Promise<{ provider: "github"; accessToken: string; refreshToken: string; profile: Profile }> { - return { - provider: "github", - accessToken, - refreshToken, - profile - }; - } -} diff --git a/apps/api/src/auth/strategies/qq.strategy.ts b/apps/api/src/auth/strategies/qq.strategy.ts deleted file mode 100644 index 5191151..0000000 --- a/apps/api/src/auth/strategies/qq.strategy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PassportStrategy } from "@nestjs/passport"; -import { Strategy } from "passport-oauth2"; - -@Injectable() -export class QqStrategy extends PassportStrategy(Strategy, "qq") { - constructor(configService: ConfigService) { - super({ - authorizationURL: - configService.get("OAUTH_QQ_AUTH_URL") ?? "https://graph.qq.com/oauth2.0/authorize", - tokenURL: - configService.get("OAUTH_QQ_TOKEN_URL") ?? "https://graph.qq.com/oauth2.0/token", - clientID: configService.get("OAUTH_QQ_CLIENT_ID") ?? "qq-client-id", - clientSecret: configService.get("OAUTH_QQ_CLIENT_SECRET") ?? "qq-client-secret", - callbackURL: - configService.get("OAUTH_QQ_CALLBACK_URL") ?? - "http://localhost:3000/auth/oauth/qq/callback", - scope: ["get_user_info"] - }); - } - - async validate( - accessToken: string, - refreshToken: string - ): Promise<{ provider: "qq"; accessToken: string; refreshToken: string }> { - return { - provider: "qq", - accessToken, - refreshToken - }; - } -} diff --git a/apps/api/src/auth/strategies/wechat.strategy.ts b/apps/api/src/auth/strategies/wechat.strategy.ts deleted file mode 100644 index 1e4343b..0000000 --- a/apps/api/src/auth/strategies/wechat.strategy.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PassportStrategy } from "@nestjs/passport"; -import { Strategy } from "passport-oauth2"; - -@Injectable() -export class WechatStrategy extends PassportStrategy(Strategy, "wechat") { - constructor(configService: ConfigService) { - super({ - authorizationURL: - configService.get("OAUTH_WECHAT_AUTH_URL") ?? - "https://open.weixin.qq.com/connect/qrconnect", - tokenURL: - configService.get("OAUTH_WECHAT_TOKEN_URL") ?? - "https://api.weixin.qq.com/sns/oauth2/access_token", - clientID: configService.get("OAUTH_WECHAT_CLIENT_ID") ?? "wechat-client-id", - clientSecret: - configService.get("OAUTH_WECHAT_CLIENT_SECRET") ?? "wechat-client-secret", - callbackURL: - configService.get("OAUTH_WECHAT_CALLBACK_URL") ?? - "http://localhost:3000/auth/oauth/wechat/callback", - scope: ["snsapi_login"] - }); - } - - async validate( - accessToken: string, - refreshToken: string - ): Promise<{ provider: "wechat"; accessToken: string; refreshToken: string }> { - return { - provider: "wechat", - accessToken, - refreshToken - }; - } -} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts deleted file mode 100644 index 6a4f0ca..0000000 --- a/apps/api/src/main.ts +++ /dev/null @@ -1,31 +0,0 @@ -import "reflect-metadata"; -import { ValidationPipe } from "@nestjs/common"; -import { NestFactory } from "@nestjs/core"; -import type { NestExpressApplication } from "@nestjs/platform-express"; -import { AppModule } from "./app.module"; - -async function bootstrap(): Promise { - const app = await NestFactory.create(AppModule); - const bodyLimit = process.env.API_BODY_LIMIT ?? "8mb"; - - app.useBodyParser("json", { limit: bodyLimit }); - app.useBodyParser("urlencoded", { - extended: true, - limit: bodyLimit - }); - app.enableCors({ - origin: true, - credentials: true - }); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true - }) - ); - - await app.listen(3000); -} - -void bootstrap(); diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts deleted file mode 100644 index 7a94e73..0000000 --- a/apps/api/src/prisma/prisma.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from "@nestjs/common"; -import { PrismaService } from "./prisma.service"; - -@Global() -@Module({ - providers: [PrismaService], - exports: [PrismaService] -}) -export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts deleted file mode 100644 index 27e6013..0000000 --- a/apps/api/src/prisma/prisma.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { PrismaClient } from "../../generated/prisma/client"; - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - constructor(configService: ConfigService) { - const connectionString = configService.get("DATABASE_URL"); - if (!connectionString) { - throw new Error("缺少数据库连接配置 DATABASE_URL"); - } - - super({ - adapter: new PrismaPg({ - connectionString - }) - }); - } - - async onModuleInit(): Promise { - await this.$connect(); - } - - async onModuleDestroy(): Promise { - await this.$disconnect(); - } -} diff --git a/apps/api/src/security/data-encryption.service.ts b/apps/api/src/security/data-encryption.service.ts deleted file mode 100644 index ece7ceb..0000000 --- a/apps/api/src/security/data-encryption.service.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Injectable, InternalServerErrorException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Prisma } from "../../generated/prisma/client"; -import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; - -const ENCRYPTION_PREFIX = "encv1"; -const ENCRYPTION_ALGORITHM = "aes-256-gcm"; -const ENCRYPTION_IV_LENGTH = 12; - -@Injectable() -export class DataEncryptionService { - constructor(private readonly configService: ConfigService) {} - - isConfigured(): boolean { - return Boolean(this.configService.get("DATA_ENCRYPTION_SECRET")); - } - - isEncryptedString(value: string): boolean { - return value.startsWith(`${ENCRYPTION_PREFIX}:`); - } - - encryptString(value: string | null | undefined): string | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null) { - return null; - } - - const key = this.resolveKey(); - const iv = randomBytes(ENCRYPTION_IV_LENGTH); - const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv); - const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); - const authTag = cipher.getAuthTag(); - - return [ - ENCRYPTION_PREFIX, - iv.toString("base64url"), - authTag.toString("base64url"), - encrypted.toString("base64url") - ].join(":"); - } - - decryptString(value: string | null | undefined): string | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null || !this.isEncryptedPayload(value)) { - return value; - } - - const [prefix, ivText, authTagText, encryptedText] = value.split(":"); - if (prefix !== ENCRYPTION_PREFIX || !ivText || !authTagText || encryptedText === undefined) { - throw new InternalServerErrorException("加密数据格式无效"); - } - - try { - const key = this.resolveKey(); - const decipher = createDecipheriv( - ENCRYPTION_ALGORITHM, - key, - Buffer.from(ivText, "base64url") - ); - decipher.setAuthTag(Buffer.from(authTagText, "base64url")); - const decrypted = Buffer.concat([ - decipher.update(Buffer.from(encryptedText, "base64url")), - decipher.final() - ]); - - return decrypted.toString("utf8"); - } catch { - throw new InternalServerErrorException("加密数据解密失败"); - } - } - - encryptJson( - value: Prisma.InputJsonValue | null | undefined - ): Prisma.InputJsonValue | null | undefined { - if (value === undefined) { - return undefined; - } - - if (value === null) { - return null; - } - - return this.encryptString(JSON.stringify(value)); - } - - decryptJson(value: Prisma.JsonValue | null): Prisma.JsonValue | null { - if (value === null) { - return null; - } - - if (typeof value !== "string" || !this.isEncryptedPayload(value)) { - return value; - } - - const decrypted = this.decryptString(value); - if (typeof decrypted !== "string") { - throw new InternalServerErrorException("加密数据解密失败"); - } - - try { - return JSON.parse(decrypted) as Prisma.JsonValue; - } catch { - throw new InternalServerErrorException("加密 JSON 数据损坏"); - } - } - - decryptPayload(value: Prisma.JsonValue | null): string | null { - if (value === null) { - return null; - } - - if (typeof value === "string") { - return this.decryptString(value) ?? null; - } - - return JSON.stringify(value); - } - - createLookupHash(scope: string, value: string): string { - const normalizedScope = scope.trim().toLowerCase(); - if (!normalizedScope) { - throw new InternalServerErrorException("缺少盲索引作用域"); - } - - const secret = this.configService.get("DATA_ENCRYPTION_SECRET"); - if (!secret) { - throw new InternalServerErrorException("服务端未配置 DATA_ENCRYPTION_SECRET,无法生成盲索引"); - } - - return createHmac("sha256", `lookup:${normalizedScope}:${secret}`) - .update(value, "utf8") - .digest("hex"); - } - - private isEncryptedPayload(value: string): boolean { - return this.isEncryptedString(value); - } - - private resolveKey(): Buffer { - const secret = this.configService.get("DATA_ENCRYPTION_SECRET"); - if (!secret) { - throw new InternalServerErrorException( - "服务端未配置 DATA_ENCRYPTION_SECRET,无法写入加密数据" - ); - } - - return createHash("sha256").update(secret, "utf8").digest(); - } -} diff --git a/apps/api/src/security/security.module.ts b/apps/api/src/security/security.module.ts deleted file mode 100644 index 8373141..0000000 --- a/apps/api/src/security/security.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from "@nestjs/common"; -import { DataEncryptionService } from "./data-encryption.service"; - -@Global() -@Module({ - providers: [DataEncryptionService], - exports: [DataEncryptionService] -}) -export class SecurityModule {} diff --git a/apps/api/src/sync/dto/sync-pull.dto.ts b/apps/api/src/sync/dto/sync-pull.dto.ts deleted file mode 100644 index c5c99fb..0000000 --- a/apps/api/src/sync/dto/sync-pull.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from "class-transformer"; -import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; - -export class SyncPullQueryDto { - @IsOptional() - @IsString() - @MaxLength(512) - cursor?: string; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(200) - limit?: number; -} diff --git a/apps/api/src/sync/dto/sync-push.dto.ts b/apps/api/src/sync/dto/sync-push.dto.ts deleted file mode 100644 index 2e43da4..0000000 --- a/apps/api/src/sync/dto/sync-push.dto.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Type } from "class-transformer"; -import { - ArrayMaxSize, - ArrayMinSize, - IsArray, - IsEnum, - IsInt, - IsOptional, - IsString, - MaxLength, - Min, - ValidateNested -} from "class-validator"; - -export enum SyncEntityTypeDto { - TASK = "TASK" -} - -export enum SyncActionTypeDto { - CREATE = "CREATE", - UPDATE = "UPDATE", - DELETE = "DELETE" -} - -export class SyncPushOperationDto { - @IsString() - @MaxLength(64) - opId!: string; - - @IsString() - @MaxLength(64) - entityId!: string; - - @IsEnum(SyncEntityTypeDto) - entityType!: SyncEntityTypeDto; - - @IsEnum(SyncActionTypeDto) - action!: SyncActionTypeDto; - - @IsOptional() - @IsString() - @MaxLength(5000000) - payload?: string; - - @Type(() => Number) - @IsInt() - @Min(0) - clientTs!: number; - - @IsString() - @MaxLength(128) - deviceId!: string; -} - -export class SyncPushDto { - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(200) - @ValidateNested({ each: true }) - @Type(() => SyncPushOperationDto) - operations!: SyncPushOperationDto[]; -} diff --git a/apps/api/src/sync/sync.controller.ts b/apps/api/src/sync/sync.controller.ts deleted file mode 100644 index 72ccfac..0000000 --- a/apps/api/src/sync/sync.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Body, Controller, Get, Headers, Post, Query, UnauthorizedException } from "@nestjs/common"; -import { SyncPullQueryDto } from "./dto/sync-pull.dto"; -import { SyncPushDto } from "./dto/sync-push.dto"; -import { SyncPullResponse, SyncPushResponse, SyncService } from "./sync.service"; - -@Controller("sync") -export class SyncController { - constructor(private readonly syncService: SyncService) {} - - @Get("pull") - async pullOperations( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Query() query: SyncPullQueryDto - ): Promise { - return this.syncService.pullOperations(this.resolveUserId(userIdHeader), query); - } - - @Post("push") - async pushOperations( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: SyncPushDto - ): Promise { - return this.syncService.pushOperations(this.resolveUserId(userIdHeader), body); - } - - private resolveUserId(userIdHeader: string | string[] | undefined): string { - const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader; - if (!userId) { - throw new UnauthorizedException("缺少用户上下文"); - } - - return userId; - } -} diff --git a/apps/api/src/sync/sync.module.ts b/apps/api/src/sync/sync.module.ts deleted file mode 100644 index 65f1492..0000000 --- a/apps/api/src/sync/sync.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { PrismaModule } from "../prisma/prisma.module"; -import { SyncController } from "./sync.controller"; -import { SyncService } from "./sync.service"; - -@Module({ - imports: [PrismaModule], - controllers: [SyncController], - providers: [SyncService] -}) -export class SyncModule {} diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts deleted file mode 100644 index cfd0f49..0000000 --- a/apps/api/src/sync/sync.service.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; -import { Prisma } from "../../generated/prisma/client"; -import { PrismaService } from "../prisma/prisma.service"; -import { DataEncryptionService } from "../security/data-encryption.service"; -import { SyncPullQueryDto } from "./dto/sync-pull.dto"; -import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto"; - -export type SyncPushItemStatus = "accepted" | "duplicate" | "failed"; - -export type SyncPushItemResult = { - opId: string; - status: SyncPushItemStatus; - serverTs: string | null; - reason: string | null; -}; - -export type SyncPushResponse = { - acceptedCount: number; - duplicateCount: number; - failedCount: number; - results: SyncPushItemResult[]; -}; - -type ExistingOperationRecord = { - opId: string; - serverTs: Date; -}; - -type SyncPullCursorState = { - serverTs: string; - opId: string; -}; - -type SyncPullOperationRecord = { - opId: string; - entityId: string; - entityType: string; - action: string; - payload: Prisma.JsonValue | null; - clientTs: Date; - deviceId: string; - serverTs: Date; -}; - -export type SyncPullItem = { - opId: string; - entityId: string; - entityType: string; - action: string; - payload: string | null; - clientTs: number; - deviceId: string; - serverTs: string; -}; - -export type SyncPullResponse = { - items: SyncPullItem[]; - nextCursor: string | null; - hasMore: boolean; -}; - -@Injectable() -export class SyncService { - constructor( - private readonly prismaService: PrismaService, - private readonly dataEncryptionService: DataEncryptionService - ) {} - - async pullOperations(userId: string, query: SyncPullQueryDto): Promise { - const limit = query.limit ?? 100; - const cursor = this.parseCursor(query.cursor); - - const operations = (await this.prismaService.syncOperation.findMany({ - where: this.buildPullWhereInput(userId, cursor), - orderBy: [{ serverTs: "asc" }, { opId: "asc" }], - take: limit + 1, - select: { - opId: true, - entityId: true, - entityType: true, - action: true, - payload: true, - clientTs: true, - deviceId: true, - serverTs: true - } - })) as SyncPullOperationRecord[]; - - const hasMore = operations.length > limit; - const pageItems = hasMore ? operations.slice(0, limit) : operations; - const lastOperation = pageItems.at(-1); - - return { - items: pageItems.map((operation) => this.serializePullItem(operation)), - nextCursor: lastOperation - ? this.encodeCursor({ - serverTs: lastOperation.serverTs.toISOString(), - opId: lastOperation.opId - }) - : (query.cursor ?? null), - hasMore - }; - } - - async pushOperations(userId: string, body: SyncPushDto): Promise { - const existingOperations = await this.loadExistingOperations(userId, body.operations); - const results: SyncPushItemResult[] = []; - const seenOperationIds = new Set(); - const acceptedOperationServerTs = new Map(); - - for (const operation of body.operations) { - if (seenOperationIds.has(operation.opId)) { - results.push({ - opId: operation.opId, - status: "duplicate", - serverTs: acceptedOperationServerTs.get(operation.opId) ?? null, - reason: "same_batch_duplicate" - }); - continue; - } - - seenOperationIds.add(operation.opId); - - const existingOperation = existingOperations.get(operation.opId); - if (existingOperation) { - results.push({ - opId: operation.opId, - status: "duplicate", - serverTs: existingOperation.serverTs.toISOString(), - reason: "already_synced" - }); - continue; - } - - try { - const createdOperation = await this.prismaService.syncOperation.create({ - data: { - opId: operation.opId, - userId, - deviceId: operation.deviceId, - entityType: operation.entityType, - entityId: operation.entityId, - action: operation.action, - payload: this.dataEncryptionService.encryptString(operation.payload) ?? undefined, - clientTs: new Date(operation.clientTs) - }, - select: { - opId: true, - serverTs: true - } - }); - - const serverTs = createdOperation.serverTs.toISOString(); - acceptedOperationServerTs.set(createdOperation.opId, serverTs); - results.push({ - opId: createdOperation.opId, - status: "accepted", - serverTs, - reason: null - }); - } catch (error) { - if (this.isDuplicateOpIdError(error)) { - results.push({ - opId: operation.opId, - status: "duplicate", - serverTs: null, - reason: "already_synced" - }); - continue; - } - - results.push({ - opId: operation.opId, - status: "failed", - serverTs: null, - reason: "persist_failed" - }); - } - } - - return { - acceptedCount: results.filter((item) => item.status === "accepted").length, - duplicateCount: results.filter((item) => item.status === "duplicate").length, - failedCount: results.filter((item) => item.status === "failed").length, - results - }; - } - - private async loadExistingOperations( - userId: string, - operations: SyncPushOperationDto[] - ): Promise> { - const opIds = Array.from(new Set(operations.map((operation) => operation.opId))); - - const existingOperations = (await this.prismaService.syncOperation.findMany({ - where: { - userId, - opId: { - in: opIds - } - }, - select: { - opId: true, - serverTs: true - } - })) as ExistingOperationRecord[]; - - return new Map( - existingOperations.map((operation): [string, ExistingOperationRecord] => [ - operation.opId, - operation - ]) - ); - } - - private buildPullWhereInput( - userId: string, - cursor: SyncPullCursorState | null - ): Prisma.SyncOperationWhereInput { - if (!cursor) { - return { userId }; - } - - const cursorDate = new Date(cursor.serverTs); - - return { - userId, - // 同一毫秒内可能有多条操作,必须使用 opId 作为二级游标来保证稳定分页。 - OR: [ - { - serverTs: { - gt: cursorDate - } - }, - { - serverTs: cursorDate, - opId: { - gt: cursor.opId - } - } - ] - }; - } - - private serializePullItem(operation: SyncPullOperationRecord): SyncPullItem { - return { - opId: operation.opId, - entityId: operation.entityId, - entityType: operation.entityType, - action: operation.action, - payload: this.serializePayload(operation.payload), - clientTs: operation.clientTs.getTime(), - deviceId: operation.deviceId, - serverTs: operation.serverTs.toISOString() - }; - } - - private serializePayload(payload: Prisma.JsonValue | null): string | null { - return this.dataEncryptionService.decryptPayload(payload); - } - - private parseCursor(cursor: string | undefined): SyncPullCursorState | null { - if (!cursor) { - return null; - } - - let decodedCursor: unknown; - try { - decodedCursor = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); - } catch { - throw new BadRequestException("Invalid sync cursor"); - } - - if (typeof decodedCursor !== "object" || decodedCursor === null) { - throw new BadRequestException("Invalid sync cursor"); - } - - const cursorRecord = decodedCursor as { - serverTs?: unknown; - opId?: unknown; - }; - - if ( - typeof cursorRecord.serverTs !== "string" || - typeof cursorRecord.opId !== "string" || - Number.isNaN(Date.parse(cursorRecord.serverTs)) || - cursorRecord.opId.trim().length === 0 - ) { - throw new BadRequestException("Invalid sync cursor"); - } - - return { - serverTs: cursorRecord.serverTs, - opId: cursorRecord.opId - }; - } - - private encodeCursor(cursor: SyncPullCursorState): string { - return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); - } - - private isDuplicateOpIdError(error: unknown): boolean { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { - return false; - } - - return error.code === "P2002"; - } -} diff --git a/apps/api/src/task/dto/create-task.dto.ts b/apps/api/src/task/dto/create-task.dto.ts deleted file mode 100644 index 69c2000..0000000 --- a/apps/api/src/task/dto/create-task.dto.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Transform } from "class-transformer"; -import { - IsArray, - IsDateString, - IsEnum, - IsObject, - IsOptional, - IsString, - MaxLength, - MinLength -} from "class-validator"; -import { TaskPriority, TaskStatus } from "../../../generated/prisma/client"; - -function normalizeString(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - - return value.trim(); -} - -export class CreateTaskDto { - @Transform(({ value }) => normalizeString(value)) - @IsString() - @MinLength(1) - @MaxLength(120) - title!: string; - - @IsOptional() - @IsObject() - contentJson?: Record; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(20000) - contentText?: string; - - @IsOptional() - @IsEnum(TaskPriority) - priority?: TaskPriority; - - @IsOptional() - @IsEnum(TaskStatus) - status?: TaskStatus; - - @IsOptional() - @IsDateString() - ddl?: string; - - @Transform(({ value }) => { - if (!Array.isArray(value)) { - return value; - } - - return value.map((item) => normalizeString(item)); - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(30, { each: true }) - tagNames?: string[]; -} diff --git a/apps/api/src/task/dto/list-tasks-query.dto.ts b/apps/api/src/task/dto/list-tasks-query.dto.ts deleted file mode 100644 index baa5afb..0000000 --- a/apps/api/src/task/dto/list-tasks-query.dto.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Transform, Type } from "class-transformer"; -import { IsArray, IsEnum, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; -import { TaskPriority, TaskStatus } from "../../../generated/prisma/client"; - -export enum TaskSortBy { - CREATED_AT = "createdAt", - UPDATED_AT = "updatedAt", - DDL = "ddl" -} - -export enum TaskSortOrder { - ASC = "asc", - DESC = "desc" -} - -function normalizeString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - - const normalized = value.trim(); - if (!normalized) { - return undefined; - } - - return normalized; -} - -export class ListTasksQueryDto { - @IsOptional() - @IsEnum(TaskStatus) - status?: TaskStatus; - - @IsOptional() - @IsEnum(TaskPriority) - priority?: TaskPriority; - - @Transform(({ value }) => { - if (value === undefined || value === null || value === "") { - return undefined; - } - - if (Array.isArray(value)) { - const normalized = value - .map((item) => normalizeString(item)) - .filter((item): item is string => item !== undefined); - return normalized.length > 0 ? normalized : undefined; - } - - if (typeof value === "string") { - const normalized = value - .split(",") - .map((item) => normalizeString(item)) - .filter((item): item is string => item !== undefined); - return normalized.length > 0 ? normalized : undefined; - } - - return undefined; - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - @MaxLength(30, { each: true }) - tags?: string[]; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(120) - keyword?: string; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - page?: number; - - @Type(() => Number) - @IsOptional() - @IsInt() - @Min(1) - @Max(100) - pageSize?: number; - - @IsOptional() - @IsEnum(TaskSortBy) - sortBy?: TaskSortBy; - - @IsOptional() - @IsEnum(TaskSortOrder) - sortOrder?: TaskSortOrder; -} diff --git a/apps/api/src/task/dto/update-task.dto.ts b/apps/api/src/task/dto/update-task.dto.ts deleted file mode 100644 index 23b8894..0000000 --- a/apps/api/src/task/dto/update-task.dto.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Transform } from "class-transformer"; -import { - IsArray, - IsDateString, - IsEnum, - IsObject, - IsOptional, - IsString, - MaxLength, - MinLength -} from "class-validator"; -import { TaskPriority, TaskStatus } from "../../../generated/prisma/client"; - -function normalizeString(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - - return value.trim(); -} - -export class UpdateTaskDto { - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MinLength(1) - @MaxLength(120) - title?: string; - - @IsOptional() - @IsObject() - contentJson?: Record; - - @Transform(({ value }) => normalizeString(value)) - @IsOptional() - @IsString() - @MaxLength(20000) - contentText?: string; - - @IsOptional() - @IsEnum(TaskPriority) - priority?: TaskPriority; - - @IsOptional() - @IsEnum(TaskStatus) - status?: TaskStatus; - - @IsOptional() - @IsDateString() - ddl?: string; - - @Transform(({ value }) => { - if (!Array.isArray(value)) { - return value; - } - - return value.map((item) => normalizeString(item)); - }) - @IsOptional() - @IsArray() - @IsString({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(30, { each: true }) - tagNames?: string[]; -} diff --git a/apps/api/src/task/task.controller.ts b/apps/api/src/task/task.controller.ts deleted file mode 100644 index 33b9710..0000000 --- a/apps/api/src/task/task.controller.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Headers, - Param, - Patch, - Post, - Query, - UnauthorizedException -} from "@nestjs/common"; -import { CreateTaskDto } from "./dto/create-task.dto"; -import { ListTasksQueryDto } from "./dto/list-tasks-query.dto"; -import { UpdateTaskDto } from "./dto/update-task.dto"; -import { ListTasksResponse, TaskResponse, TaskService } from "./task.service"; - -@Controller("tasks") -export class TaskController { - constructor(private readonly taskService: TaskService) {} - - @Get() - async listTasks( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Query() query: ListTasksQueryDto - ): Promise { - return this.taskService.listTasks(this.resolveUserId(userIdHeader), query); - } - - @Get(":taskId") - async getTaskById( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Param("taskId") taskId: string - ): Promise { - return this.taskService.getTaskById(this.resolveUserId(userIdHeader), taskId); - } - - @Post() - async createTask( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Body() body: CreateTaskDto - ): Promise { - return this.taskService.createTask(this.resolveUserId(userIdHeader), body); - } - - @Patch(":taskId") - async updateTask( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Param("taskId") taskId: string, - @Body() body: UpdateTaskDto - ): Promise { - return this.taskService.updateTask(this.resolveUserId(userIdHeader), taskId, body); - } - - @Delete(":taskId") - async deleteTask( - @Headers("x-user-id") userIdHeader: string | string[] | undefined, - @Param("taskId") taskId: string - ): Promise<{ success: boolean }> { - return this.taskService.deleteTask(this.resolveUserId(userIdHeader), taskId); - } - - private resolveUserId(userIdHeader: string | string[] | undefined): string { - const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader; - if (!userId) { - throw new UnauthorizedException("缺少用户上下文"); - } - - return userId; - } -} diff --git a/apps/api/src/task/task.module.ts b/apps/api/src/task/task.module.ts deleted file mode 100644 index 8226093..0000000 --- a/apps/api/src/task/task.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { PrismaModule } from "../prisma/prisma.module"; -import { TaskController } from "./task.controller"; -import { TaskService } from "./task.service"; - -@Module({ - imports: [PrismaModule], - controllers: [TaskController], - providers: [TaskService] -}) -export class TaskModule {} diff --git a/apps/api/src/task/task.service.ts b/apps/api/src/task/task.service.ts deleted file mode 100644 index deb1f76..0000000 --- a/apps/api/src/task/task.service.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; -import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client"; -import { PrismaService } from "../prisma/prisma.service"; -import { DataEncryptionService } from "../security/data-encryption.service"; -import { CreateTaskDto } from "./dto/create-task.dto"; -import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto"; -import { UpdateTaskDto } from "./dto/update-task.dto"; - -type TaskEntity = Prisma.TaskGetPayload<{ - include: { - taskTags: { - include: { - tag: { - select: { - name: true; - }; - }; - }; - }; - }; -}>; - -export type TaskResponse = { - id: string; - title: string; - contentJson: unknown | null; - contentText: string | null; - priority: TaskPriority; - status: TaskStatus; - ddl: string | null; - completedAt: string | null; - version: number; - tags: string[]; - createdAt: string; - updatedAt: string; -}; - -export type ListTasksResponse = { - items: TaskResponse[]; - page: number; - pageSize: number; - total: number; -}; - -@Injectable() -export class TaskService { - constructor( - private readonly prismaService: PrismaService, - private readonly dataEncryptionService: DataEncryptionService - ) {} - - async listTasks(userId: string, query: ListTasksQueryDto): Promise { - const page = query.page ?? 1; - const pageSize = query.pageSize ?? 20; - const skip = (page - 1) * pageSize; - const keyword = query.keyword?.trim() ?? ""; - - const where = this.buildWhereInput(userId, query, keyword.length === 0); - const orderBy = this.buildOrderByInput(query); - - if (keyword.length > 0) { - const items = await this.prismaService.task.findMany({ - where, - orderBy, - include: { - taskTags: { - include: { - tag: { - select: { - name: true - } - } - } - } - } - }); - - const serializedItems = items.map((item: TaskEntity) => this.serializeTask(item)); - const filteredItems = serializedItems.filter((item) => this.matchesKeyword(item, keyword)); - - return { - items: filteredItems.slice(skip, skip + pageSize), - page, - pageSize, - total: filteredItems.length - }; - } - - const [items, total] = await Promise.all([ - this.prismaService.task.findMany({ - where, - orderBy, - skip, - take: pageSize, - include: { - taskTags: { - include: { - tag: { - select: { - name: true - } - } - } - } - } - }), - this.prismaService.task.count({ where }) - ]); - - return { - items: items.map((item: TaskEntity) => this.serializeTask(item)), - page, - pageSize, - total - }; - } - - async getTaskById(userId: string, taskId: string): Promise { - const task = await this.prismaService.task.findFirst({ - where: { - id: taskId, - userId - }, - include: { - taskTags: { - include: { - tag: { - select: { - name: true - } - } - } - } - } - }); - - if (!task) { - throw new NotFoundException("任务不存在"); - } - - return this.serializeTask(task); - } - - async createTask(userId: string, body: CreateTaskDto): Promise { - const tagNames = this.normalizeTagNames(body.tagNames); - const nextStatus = body.status ?? TaskStatus.TODO; - const contentJson = - body.contentJson !== undefined - ? ((this.dataEncryptionService.encryptJson(body.contentJson as Prisma.InputJsonValue) ?? - Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput) - : undefined; - - const task = await this.prismaService.$transaction(async (tx) => { - const createdTask = await tx.task.create({ - data: { - userId, - title: this.encryptRequiredString(body.title), - contentJson, - contentText: this.encryptNullableString(body.contentText), - priority: body.priority ?? TaskPriority.MEDIUM, - status: nextStatus, - ddl: body.ddl ? new Date(body.ddl) : null, - completedAt: nextStatus === TaskStatus.DONE ? new Date() : null - } - }); - - await this.replaceTaskTags(tx, userId, createdTask.id, tagNames); - - return tx.task.findUniqueOrThrow({ - where: { id: createdTask.id }, - include: { - taskTags: { - include: { - tag: { - select: { - name: true - } - } - } - } - } - }); - }); - - return this.serializeTask(task); - } - - async updateTask(userId: string, taskId: string, body: UpdateTaskDto): Promise { - const currentTask = await this.prismaService.task.findFirst({ - where: { - id: taskId, - userId - }, - select: { - id: true, - status: true - } - }); - - if (!currentTask) { - throw new NotFoundException("任务不存在"); - } - - const data: Prisma.TaskUpdateInput = { - version: { - increment: 1 - } - }; - - if (body.title !== undefined) { - data.title = this.encryptRequiredString(body.title); - } - if (body.contentJson !== undefined) { - data.contentJson = (this.dataEncryptionService.encryptJson( - body.contentJson as Prisma.InputJsonValue - ) ?? Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput; - } - if (body.contentText !== undefined) { - data.contentText = this.encryptNullableString(body.contentText); - } - if (body.priority !== undefined) { - data.priority = body.priority; - } - if (body.status !== undefined) { - data.status = body.status; - if (body.status === TaskStatus.DONE && currentTask.status !== TaskStatus.DONE) { - data.completedAt = new Date(); - } else if (body.status !== TaskStatus.DONE) { - data.completedAt = null; - } - } - if (body.ddl !== undefined) { - data.ddl = body.ddl ? new Date(body.ddl) : null; - } - - const shouldReplaceTags = body.tagNames !== undefined; - const nextTagNames = this.normalizeTagNames(body.tagNames); - - const task = await this.prismaService.$transaction(async (tx) => { - await tx.task.update({ - where: { id: taskId }, - data - }); - - if (shouldReplaceTags) { - await this.replaceTaskTags(tx, userId, taskId, nextTagNames); - } - - return tx.task.findUniqueOrThrow({ - where: { id: taskId }, - include: { - taskTags: { - include: { - tag: { - select: { - name: true - } - } - } - } - } - }); - }); - - return this.serializeTask(task); - } - - async deleteTask(userId: string, taskId: string): Promise<{ success: boolean }> { - const deleted = await this.prismaService.task.deleteMany({ - where: { - id: taskId, - userId - } - }); - - if (deleted.count === 0) { - throw new NotFoundException("任务不存在"); - } - - return { success: true }; - } - - private buildWhereInput( - userId: string, - query: ListTasksQueryDto, - includeKeyword: boolean - ): Prisma.TaskWhereInput { - const where: Prisma.TaskWhereInput = { - userId - }; - - if (query.status !== undefined) { - where.status = query.status; - } - - if (query.priority !== undefined) { - where.priority = query.priority; - } - - if (query.tags !== undefined && query.tags.length > 0) { - where.taskTags = { - some: { - tag: { - name: { - in: query.tags - } - } - } - }; - } - - if (includeKeyword && query.keyword !== undefined && query.keyword.length > 0) { - where.OR = [ - { - title: { - contains: query.keyword, - mode: "insensitive" - } - }, - { - contentText: { - contains: query.keyword, - mode: "insensitive" - } - } - ]; - } - - return where; - } - - private buildOrderByInput(query: ListTasksQueryDto): Prisma.TaskOrderByWithRelationInput { - const order: Prisma.SortOrder = - query.sortOrder === TaskSortOrder.ASC ? Prisma.SortOrder.asc : Prisma.SortOrder.desc; - - if (query.sortBy === TaskSortBy.CREATED_AT) { - return { createdAt: order }; - } - - if (query.sortBy === TaskSortBy.DDL) { - return { ddl: order }; - } - - return { updatedAt: order }; - } - - private normalizeTagNames(tagNames: string[] | undefined): string[] { - if (!tagNames) { - return []; - } - - const result: string[] = []; - const uniqueNames = new Set(); - - for (const rawTagName of tagNames) { - const normalized = rawTagName.trim(); - if (!normalized) { - continue; - } - - const uniqueKey = normalized.toLocaleLowerCase(); - if (uniqueNames.has(uniqueKey)) { - continue; - } - - uniqueNames.add(uniqueKey); - result.push(normalized); - } - - return result; - } - - private async replaceTaskTags( - tx: Prisma.TransactionClient, - userId: string, - taskId: string, - tagNames: string[] - ): Promise { - await tx.taskTag.deleteMany({ - where: { - taskId - } - }); - - if (tagNames.length === 0) { - return; - } - - const tags = await Promise.all( - tagNames.map((name) => - tx.tag.upsert({ - where: { - userId_name: { - userId, - name - } - }, - update: {}, - create: { - userId, - name - } - }) - ) - ); - - await tx.taskTag.createMany({ - data: tags.map((tag: { id: string }) => ({ - taskId, - tagId: tag.id - })), - skipDuplicates: true - }); - } - - private serializeTask(task: TaskEntity): TaskResponse { - return { - id: task.id, - title: this.readDecryptedString(task.title) ?? "未命名任务", - contentJson: this.dataEncryptionService.decryptJson(task.contentJson), - contentText: this.readDecryptedString(task.contentText), - priority: task.priority, - status: task.status, - ddl: task.ddl?.toISOString() ?? null, - completedAt: task.completedAt?.toISOString() ?? null, - version: task.version, - tags: task.taskTags.map((taskTag: { tag: { name: string } }) => taskTag.tag.name), - createdAt: task.createdAt.toISOString(), - updatedAt: task.updatedAt.toISOString() - }; - } - - private encryptRequiredString(value: string): string { - const encryptedValue = this.dataEncryptionService.encryptString(value); - if (!encryptedValue) { - throw new InternalServerErrorException("任务字段加密失败"); - } - - return encryptedValue; - } - - private encryptNullableString(value: string | null | undefined): string | null | undefined { - return this.dataEncryptionService.encryptString(value); - } - - private readDecryptedString(value: string | null): string | null { - const decryptedValue = this.dataEncryptionService.decryptString(value); - return typeof decryptedValue === "string" ? decryptedValue : null; - } - - private matchesKeyword(task: TaskResponse, keyword: string): boolean { - const lowerKeyword = keyword.toLocaleLowerCase(); - return ( - task.title.toLocaleLowerCase().includes(lowerKeyword) || - task.contentText?.toLocaleLowerCase().includes(lowerKeyword) === true - ); - } -} diff --git a/apps/api/test/ai.spec.ts b/apps/api/test/ai.spec.ts deleted file mode 100644 index 7dc70e1..0000000 --- a/apps/api/test/ai.spec.ts +++ /dev/null @@ -1,1250 +0,0 @@ -import request from "supertest"; -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Test, TestingModule } from "@nestjs/testing"; -import { - AiChannel, - AiUsageLog, - AiProviderBinding, - AiPublicPoolConfig, - TaskPriority, - TaskStatus -} from "../generated/prisma/client"; -import { AiController } from "../src/ai/ai.controller"; -import { AiProviderRegistryService } from "../src/ai/ai-provider-registry.service"; -import { AiRateLimitService } from "../src/ai/ai-rate-limit.service"; -import { AiService } from "../src/ai/ai.service"; -import { - AiChatInput, - AiChannelExecutor, - AiResolvedRouteCandidate, - AiRouteFailureError -} from "../src/ai/ai.types"; -import { PrismaService } from "../src/prisma/prisma.service"; -import { DataEncryptionService } from "../src/security/data-encryption.service"; - -type AiUsageLogRecord = { - id: string; - userId: string | null; - channel: AiChannel; - providerName: string | null; - model: string | null; - promptTokens: number; - completionTokens: number; - totalTokens: number; - latencyMs: number | null; - success: boolean; - errorCode: string | null; - createdAt: Date; -}; - -type AiTaskRecord = { - id: string; - userId: string; - title: string; - priority: TaskPriority; - status: TaskStatus; - ddl: Date | null; - contentText: string | null; - updatedAt: Date; -}; - -class InMemoryAiPrismaService { - private bindingIdSequence = 1; - private publicPoolIdSequence = 1; - private usageLogIdSequence = 1; - private bindings: AiProviderBinding[] = []; - private publicPools: AiPublicPoolConfig[] = []; - private usageLogs: AiUsageLogRecord[] = []; - private tasks: AiTaskRecord[] = []; - - readonly aiProviderBinding = { - findMany: async (args: { - where: { - userId: string; - }; - }) => { - return this.bindings - .filter((binding) => binding.userId === args.where.userId) - .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()); - }, - - findFirst: async (args: { - where: { - id?: string; - userId?: string; - channel?: AiChannel; - isEnabled?: boolean; - }; - }) => { - return ( - this.bindings - .filter((binding) => { - if (args.where.id !== undefined && binding.id !== args.where.id) { - return false; - } - if (args.where.userId !== undefined && binding.userId !== args.where.userId) { - return false; - } - if (args.where.channel !== undefined && binding.channel !== args.where.channel) { - return false; - } - if (args.where.isEnabled !== undefined && binding.isEnabled !== args.where.isEnabled) { - return false; - } - return true; - }) - .sort((left, right) => { - if (left.isDefault !== right.isDefault) { - return Number(right.isDefault) - Number(left.isDefault); - } - return right.updatedAt.getTime() - left.updatedAt.getTime(); - })[0] ?? null - ); - }, - - create: async (args: { - data: { - userId: string; - channel: AiChannel; - providerName: string; - model: string | null; - configId: string | null; - configName: string | null; - endpoint: string | null; - encryptedApiKey: string | null; - isDefault: boolean; - isEnabled: boolean; - }; - }) => { - const now = new Date(); - const binding: AiProviderBinding = { - id: `binding_${this.bindingIdSequence++}`, - userId: args.data.userId, - channel: args.data.channel, - providerName: args.data.providerName, - model: args.data.model, - configId: args.data.configId, - configName: args.data.configName, - encryptedApiKey: args.data.encryptedApiKey, - endpoint: args.data.endpoint, - isDefault: args.data.isDefault, - isEnabled: args.data.isEnabled, - createdAt: now, - updatedAt: now - }; - - this.bindings.push(binding); - return binding; - }, - - update: async (args: { - where: { - id: string; - }; - data: Partial; - }) => { - const binding = this.bindings.find((item) => item.id === args.where.id); - if (!binding) { - throw new Error("binding not found"); - } - - Object.assign(binding, args.data, { updatedAt: new Date() }); - return binding; - }, - - updateMany: async (args: { - where: { - userId?: string; - channel?: AiChannel; - id?: { - not: string; - }; - }; - data: { - isDefault?: boolean; - }; - }) => { - let count = 0; - for (const binding of this.bindings) { - if (args.where.userId !== undefined && binding.userId !== args.where.userId) { - continue; - } - if (args.where.channel !== undefined && binding.channel !== args.where.channel) { - continue; - } - if (args.where.id?.not !== undefined && binding.id === args.where.id.not) { - continue; - } - - if (args.data.isDefault !== undefined) { - binding.isDefault = args.data.isDefault; - binding.updatedAt = new Date(); - } - count += 1; - } - - return { count }; - } - }; - - readonly aiPublicPoolConfig = { - findFirst: async (args?: { - where?: { - enabled?: boolean; - }; - }) => { - const items = this.publicPools - .filter((item) => - args?.where?.enabled === undefined ? true : item.enabled === args.where.enabled - ) - .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()); - - return items[0] ?? null; - } - }; - - readonly aiUsageLog = { - create: async (args: { data: Omit }) => { - const usageLog: AiUsageLogRecord = { - id: `usage_log_${this.usageLogIdSequence++}`, - createdAt: new Date(), - ...args.data - }; - - this.usageLogs.push(usageLog); - return usageLog; - }, - - findMany: async (args: { - where?: { - userId?: string; - channel?: AiChannel; - success?: boolean; - }; - orderBy?: { - createdAt: "asc" | "desc"; - }; - skip?: number; - take?: number; - }) => { - const filteredLogs = this.filterUsageLogs(args.where); - const sortedLogs = [...filteredLogs].sort((left, right) => { - const direction = args.orderBy?.createdAt === "asc" ? 1 : -1; - return (left.createdAt.getTime() - right.createdAt.getTime()) * direction; - }); - const start = args.skip ?? 0; - const end = args.take === undefined ? undefined : start + args.take; - return sortedLogs.slice(start, end); - }, - - count: async (args?: { - where?: { - userId?: string; - channel?: AiChannel; - success?: boolean; - }; - }) => { - return this.filterUsageLogs(args?.where).length; - } - }; - - readonly task = { - findMany: async (args: { - where: { - userId: string; - status: { - in: TaskStatus[]; - }; - }; - take?: number; - }) => { - const filteredTasks = this.tasks.filter( - (task) => task.userId === args.where.userId && args.where.status.in.includes(task.status) - ); - - return filteredTasks.slice(0, args.take ?? filteredTasks.length).map((task) => ({ - id: task.id, - title: task.title, - priority: task.priority, - status: task.status, - ddl: task.ddl, - contentText: task.contentText, - updatedAt: task.updatedAt - })); - } - }; - - async $transaction(callback: (tx: InMemoryAiPrismaService) => Promise): Promise { - return callback(this); - } - - seedBinding(binding: Omit): void { - const now = new Date(); - this.bindings.push({ - ...binding, - createdAt: now, - updatedAt: now - }); - } - - seedPublicPool(publicPool: Omit): void { - const now = new Date(); - this.publicPools.push({ - id: `pool_${this.publicPoolIdSequence++}`, - createdAt: now, - updatedAt: now, - ...publicPool - }); - } - - getUsageLogs(): AiUsageLogRecord[] { - return [...this.usageLogs]; - } - - getBindings(): AiProviderBinding[] { - return [...this.bindings]; - } - - seedTask(task: AiTaskRecord): void { - this.tasks.push(task); - } - - seedUsageLog(log: Omit & { id?: string }): void { - this.usageLogs.push({ - id: log.id ?? `usage_log_${this.usageLogIdSequence++}`, - ...log - }); - } - - private filterUsageLogs(where?: { - userId?: string; - channel?: AiChannel; - success?: boolean; - }): AiUsageLogRecord[] { - return this.usageLogs.filter((log) => { - if (where?.userId !== undefined && log.userId !== where.userId) { - return false; - } - if (where?.channel !== undefined && log.channel !== where.channel) { - return false; - } - if (where?.success !== undefined && log.success !== where.success) { - return false; - } - - return true; - }); - } -} - -class StaticExecutor implements AiChannelExecutor { - readonly inputs: Array<{ - candidate: AiResolvedRouteCandidate; - message: string; - }> = []; - - constructor( - private readonly resolver: (channel: AiChannel) => { - content?: string; - code?: string; - message?: string; - } - ) {} - - async execute(candidate: AiResolvedRouteCandidate, input: AiChatInput) { - this.inputs.push({ - candidate, - message: input.message - }); - - const result = this.resolver(candidate.channel); - if (result.code) { - throw new AiRouteFailureError( - candidate.channel, - candidate.providerName || candidate.configName || candidate.configId || "unknown", - result.code, - result.message ?? "执行失败" - ); - } - - return { - channel: candidate.channel, - providerName: candidate.providerName || candidate.configName || candidate.configId || "", - model: candidate.model, - content: result.content ?? "", - sessionId: "session_ai", - usage: { - promptTokens: 12, - completionTokens: 8, - totalTokens: 20 - }, - raw: null - }; - } -} - -describe("AiController (integration)", () => { - let app: INestApplication; - let prismaService: InMemoryAiPrismaService; - let astrbotExecutor: StaticExecutor; - let openAiExecutor: StaticExecutor; - - beforeEach(async () => { - prismaService = new InMemoryAiPrismaService(); - - openAiExecutor = new StaticExecutor((channel) => - channel === AiChannel.USER_KEY - ? { - code: "UPSTREAM_UNREACHABLE", - message: "用户自备 Key 渠道暂时不可用" - } - : { - content: "公共 AI 已接管" - } - ); - astrbotExecutor = new StaticExecutor(() => ({ - content: "AstrBot 已接管" - })); - - const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [AiController], - providers: [ - AiService, - AiRateLimitService, - DataEncryptionService, - { - provide: PrismaService, - useValue: prismaService - }, - { - provide: ConfigService, - useValue: { - get: (key: string) => { - if (key === "DATA_ENCRYPTION_SECRET") { - return "test-data-encryption-secret"; - } - if (key === "AI_RATE_LIMIT_WINDOW_MS") { - return 60_000; - } - if (key === "AI_RATE_LIMIT_USER_MAX") { - return 2; - } - if (key === "AI_RATE_LIMIT_IP_MAX") { - return 3; - } - - return undefined; - } - } - }, - { - provide: AiProviderRegistryService, - useValue: { - getExecutor: (channel: AiChannel) => - channel === AiChannel.ASTRBOT ? astrbotExecutor : openAiExecutor - } - } - ] - }).compile(); - - app = moduleRef.createNestApplication(); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true - }) - ); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it("should create and list ai bindings", async () => { - await request(app.getHttpServer()) - .post("/ai/bindings") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.ASTRBOT, - providerName: "astrbot-main", - model: "deepseek-chat", - configId: "default", - endpoint: "http://127.0.0.1:6185", - apiKey: "abk_secret_1234", - isEnabled: true - }) - .expect(201); - - const response = await request(app.getHttpServer()) - .get("/ai/bindings") - .set("x-user-id", "user_1") - .expect(200); - - expect(response.body.routeOrder).toEqual([ - AiChannel.USER_KEY, - AiChannel.ASTRBOT, - AiChannel.PUBLIC_POOL - ]); - expect(response.body.bindings).toHaveLength(1); - expect(response.body.bindings[0]).toMatchObject({ - channel: AiChannel.ASTRBOT, - providerName: "astrbot-main", - model: "deepseek-chat", - configId: "default", - configName: null, - hasApiKey: true, - maskedApiKey: "abk_***34", - isEnabled: true - }); - - const storedBinding = prismaService.getBindings()[0]; - expect(storedBinding?.providerName).not.toBe("astrbot-main"); - expect(storedBinding?.endpoint).not.toBe("http://127.0.0.1:6185"); - expect(storedBinding?.encryptedApiKey).not.toBe("abk_secret_1234"); - }); - - it("should hide public pool endpoint from user bindings response", async () => { - prismaService.seedPublicPool({ - enabled: true, - providerName: "public-openai", - model: "gpt-4o-mini", - encryptedApiKey: "sk-public", - endpoint: "https://internal.example.com/v1", - rpmLimit: 60, - dailyTokenLimit: 100000 - }); - - const response = await request(app.getHttpServer()) - .get("/ai/bindings") - .set("x-user-id", "user_1") - .expect(200); - - expect(response.body.publicPool).toEqual({ - enabled: true, - providerName: "public-openai", - model: "gpt-4o-mini", - hasApiKey: true - }); - }); - - it("should upsert one binding per user channel", async () => { - await request(app.getHttpServer()) - .post("/ai/bindings") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - endpoint: "https://api.example.com", - apiKey: "sk-first", - isEnabled: true - }) - .expect(201); - - await request(app.getHttpServer()) - .post("/ai/bindings") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.USER_KEY, - providerName: "google", - model: "gemini-2.5-flash", - endpoint: "https://generativelanguage.googleapis.com", - apiKey: "sk-second", - isEnabled: false - }) - .expect(201); - - const response = await request(app.getHttpServer()) - .get("/ai/bindings") - .set("x-user-id", "user_1") - .expect(200); - - expect(response.body.bindings).toEqual([ - expect.objectContaining({ - channel: AiChannel.USER_KEY, - providerName: "google", - model: "gemini-2.5-flash", - endpoint: "https://generativelanguage.googleapis.com", - isEnabled: false, - maskedApiKey: "sk-s***nd" - }) - ]); - }); - - it("should fallback from user key to astrbot", async () => { - prismaService.seedBinding({ - id: "binding_user_key", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - configId: null, - configName: null, - encryptedApiKey: "sk-user", - endpoint: "https://api.example.com", - isDefault: true, - isEnabled: true - }); - prismaService.seedBinding({ - id: "binding_astrbot", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "帮我安排今天的任务" - }) - .expect(201); - - expect(response.body.channel).toBe(AiChannel.ASTRBOT); - expect(response.body.content).toBe("AstrBot 已接管"); - expect(response.body.attempts).toEqual([ - { - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - status: "failed", - reasonCode: "UPSTREAM_UNREACHABLE", - reasonMessage: "用户自备 Key 渠道暂时不可用" - }, - { - channel: AiChannel.ASTRBOT, - providerName: "default", - model: null, - status: "success", - reasonCode: null, - reasonMessage: null - } - ]); - expect(prismaService.getUsageLogs()).toEqual([ - expect.objectContaining({ - id: expect.any(String), - userId: "user_1", - channel: AiChannel.USER_KEY, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - latencyMs: expect.any(Number), - success: false, - errorCode: "UPSTREAM_UNREACHABLE", - createdAt: expect.any(Date) - }), - expect.objectContaining({ - id: expect.any(String), - userId: "user_1", - channel: AiChannel.ASTRBOT, - promptTokens: 12, - completionTokens: 8, - totalTokens: 20, - latencyMs: expect.any(Number), - success: true, - errorCode: null, - createdAt: expect.any(Date) - }) - ]); - expect(prismaService.getUsageLogs()[0]?.providerName).not.toBe("openai"); - expect(prismaService.getUsageLogs()[0]?.model).not.toBe("gpt-4o-mini"); - }); - - it("should allow astrbot binding with config id only", async () => { - const response = await request(app.getHttpServer()) - .post("/ai/bindings") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.ASTRBOT, - configId: "default", - endpoint: "http://127.0.0.1:6185", - apiKey: "abk_secret_1234", - isEnabled: true - }) - .expect(201); - - expect(response.body).toMatchObject({ - channel: AiChannel.ASTRBOT, - providerName: "", - configId: "default", - configName: null, - isEnabled: true - }); - }); - - it("should test binding with stored secret when api key is omitted", async () => { - prismaService.seedBinding({ - id: "binding_user_key_test_existing_secret", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-4.1", - configId: null, - configName: null, - encryptedApiKey: "sk-existing", - endpoint: "https://api.example.com", - isDefault: false, - isEnabled: true - }); - - const executeSpy = jest.spyOn(openAiExecutor, "execute").mockResolvedValue({ - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-4.1", - content: "连接成功", - sessionId: "session_binding_test", - usage: { - promptTokens: 1, - completionTokens: 1, - totalTokens: 2 - }, - raw: null - }); - - const response = await request(app.getHttpServer()) - .post("/ai/bindings/test") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-4.1", - endpoint: "https://api.example.com" - }) - .expect(201); - - expect(response.body).toEqual({ - success: true, - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-4.1", - contentPreview: "连接成功" - }); - expect(executeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-4.1", - endpoint: "https://api.example.com", - apiKey: "sk-existing" - }), - expect.objectContaining({ - userId: "user_1" - }) - ); - }); - - it("should return structured failure result when binding test fails", async () => { - prismaService.seedBinding({ - id: "binding_user_key_test_failure", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-5.4", - configId: null, - configName: null, - encryptedApiKey: "sk-existing", - endpoint: "https://api.example.com", - isDefault: false, - isEnabled: true - }); - - const response = await request(app.getHttpServer()) - .post("/ai/bindings/test") - .set("x-user-id", "user_1") - .send({ - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-5.4", - endpoint: "https://api.example.com" - }) - .expect(201); - - expect(response.body).toEqual({ - success: false, - channel: AiChannel.USER_KEY, - providerName: "airouter", - model: "gpt-5.4", - code: "UPSTREAM_UNREACHABLE", - message: "用户自备 Key 渠道暂时不可用" - }); - }); - - it("should use selected channel without automatic fallback", async () => { - prismaService.seedBinding({ - id: "binding_user_key_selected", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - configId: null, - configName: null, - encryptedApiKey: "sk-user", - endpoint: "https://api.example.com", - isDefault: false, - isEnabled: true - }); - prismaService.seedBinding({ - id: "binding_astrbot_selected", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: false, - isEnabled: true - }); - - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "只使用自备渠道", - channel: AiChannel.USER_KEY - }) - .expect(502); - - expect(response.body.attempts).toEqual([ - { - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - status: "failed", - reasonCode: "UPSTREAM_UNREACHABLE", - reasonMessage: "用户自备 Key 渠道暂时不可用" - } - ]); - }); - - it("should inject unfinished task summary into ai prompt", async () => { - prismaService.seedBinding({ - id: "binding_astrbot_context", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - prismaService.seedTask({ - id: "task_weekly_report", - userId: "user_1", - title: "今晚提交周报", - priority: TaskPriority.URGENT, - status: TaskStatus.IN_PROGRESS, - ddl: new Date("2026-04-06T12:00:00.000Z"), - contentText: "需要汇总 AI 路由、AstrBot 接入和同步模块进度", - updatedAt: new Date("2026-04-06T08:00:00.000Z") - }); - prismaService.seedTask({ - id: "task_done_item", - userId: "user_1", - title: "整理已完成事项", - priority: TaskPriority.LOW, - status: TaskStatus.DONE, - ddl: null, - contentText: "这条任务不应该出现在上下文里", - updatedAt: new Date("2026-04-06T07:00:00.000Z") - }); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "帮我安排今天剩余任务" - }) - .expect(201); - - expect(astrbotExecutor.inputs).toHaveLength(1); - expect(astrbotExecutor.inputs[0]?.message).toContain("以下是系统整理的未完成任务摘要"); - expect(astrbotExecutor.inputs[0]?.message).toContain("今晚提交周报"); - expect(astrbotExecutor.inputs[0]?.message).toContain("优先级:紧急"); - expect(astrbotExecutor.inputs[0]?.message).not.toContain("整理已完成事项"); - expect(astrbotExecutor.inputs[0]?.message).toContain("用户当前问题:帮我安排今天剩余任务"); - }); - - it("should inject local unfinished tasks into ai prompt when database is empty", async () => { - prismaService.seedBinding({ - id: "binding_user_key_local_context", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - configId: null, - configName: null, - encryptedApiKey: "sk-user", - endpoint: "https://api.example.com", - isDefault: true, - isEnabled: true - }); - - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "结合我的 TodoList 帮我排优先级", - channel: AiChannel.USER_KEY, - localTasks: [ - { - id: "local_task_1", - title: "准备明天答辩材料", - priority: TaskPriority.URGENT, - status: TaskStatus.IN_PROGRESS, - ddlAt: new Date("2026-04-07T13:00:00.000Z").getTime(), - contentText: "需要补齐演示文稿和总结页", - updatedAt: new Date("2026-04-07T09:00:00.000Z").getTime() - } - ] - }) - .expect(502); - - expect(response.body.attempts).toEqual([ - { - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - status: "failed", - reasonCode: "UPSTREAM_UNREACHABLE", - reasonMessage: "用户自备 Key 渠道暂时不可用" - } - ]); - expect(openAiExecutor.inputs).toHaveLength(1); - expect(openAiExecutor.inputs[0]?.message).toContain("准备明天答辩材料"); - expect(openAiExecutor.inputs[0]?.message).toContain("优先级:紧急"); - expect(openAiExecutor.inputs[0]?.message).toContain("内容摘要:需要补齐演示文稿和总结页"); - expect(openAiExecutor.inputs[0]?.message).toContain( - "用户当前问题:结合我的 TodoList 帮我排优先级" - ); - expect(astrbotExecutor.inputs).toHaveLength(0); - }); - - it("should prefer newer local task snapshot over older database task", async () => { - prismaService.seedBinding({ - id: "binding_astrbot_local_override", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - prismaService.seedTask({ - id: "task_same_id", - userId: "user_1", - title: "旧标题", - priority: TaskPriority.LOW, - status: TaskStatus.TODO, - ddl: new Date("2026-04-08T10:00:00.000Z"), - contentText: "旧内容", - updatedAt: new Date("2026-04-07T08:00:00.000Z") - }); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "看看我最新要做什么", - channel: AiChannel.ASTRBOT, - localTasks: [ - { - id: "task_same_id", - title: "新标题", - priority: TaskPriority.HIGH, - status: TaskStatus.IN_PROGRESS, - ddlAt: new Date("2026-04-07T15:00:00.000Z").getTime(), - contentText: "新内容", - updatedAt: new Date("2026-04-07T12:00:00.000Z").getTime() - } - ] - }) - .expect(201); - - expect(astrbotExecutor.inputs.at(-1)?.message).toContain("新标题"); - expect(astrbotExecutor.inputs.at(-1)?.message).toContain("优先级:高"); - expect(astrbotExecutor.inputs.at(-1)?.message).toContain("内容摘要:新内容"); - expect(astrbotExecutor.inputs.at(-1)?.message).not.toContain("旧标题"); - expect(astrbotExecutor.inputs.at(-1)?.message).not.toContain("旧内容"); - }); - - it("should return skipped attempts when no channel is available", async () => { - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .send({ - message: "帮我总结今天的安排" - }) - .expect(502); - - expect(response.body.message).toBe("当前没有可用的 AI 通道,请稍后重试"); - expect(response.body.attempts).toEqual([ - { - channel: AiChannel.USER_KEY, - providerName: null, - model: null, - status: "skipped", - reasonCode: "CHANNEL_NOT_CONFIGURED", - reasonMessage: "当前用户未配置可用的自备 Key 通道" - }, - { - channel: AiChannel.ASTRBOT, - providerName: null, - model: null, - status: "skipped", - reasonCode: "CHANNEL_NOT_CONFIGURED", - reasonMessage: "当前用户未配置可用的 AstrBot 通道" - }, - { - channel: AiChannel.PUBLIC_POOL, - providerName: null, - model: null, - status: "skipped", - reasonCode: "PUBLIC_POOL_DISABLED", - reasonMessage: "公共 AI 通道未开启" - } - ]); - expect(prismaService.getUsageLogs()).toEqual([]); - }); - - it("should rate limit ai chat by user in the same window", async () => { - prismaService.seedBinding({ - id: "binding_astrbot_rate_limit_user", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .set("x-forwarded-for", "203.0.113.10") - .send({ - message: "第一条" - }) - .expect(201); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .set("x-forwarded-for", "203.0.113.10") - .send({ - message: "第二条" - }) - .expect(201); - - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .set("x-forwarded-for", "203.0.113.10") - .send({ - message: "第三条" - }) - .expect(429); - - expect(response.body).toMatchObject({ - message: "AI 请求过于频繁,请稍后再试", - code: "AI_RATE_LIMITED", - dimension: "user", - limit: 2, - windowMs: 60000 - }); - expect(response.body.retryAfterMs).toEqual(expect.any(Number)); - expect(astrbotExecutor.inputs).toHaveLength(2); - expect(prismaService.getUsageLogs()).toHaveLength(2); - }); - - it("should rate limit ai chat by ip across different users", async () => { - prismaService.seedBinding({ - id: "binding_astrbot_rate_limit_ip_user_1", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - prismaService.seedBinding({ - id: "binding_astrbot_rate_limit_ip_user_2", - userId: "user_2", - channel: AiChannel.ASTRBOT, - providerName: "", - model: null, - configId: "default", - configName: null, - encryptedApiKey: "abk_astrbot", - endpoint: "http://127.0.0.1:6185", - isDefault: true, - isEnabled: true - }); - - const sharedIp = "198.51.100.7"; - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .set("x-forwarded-for", sharedIp) - .send({ - message: "用户一第一条" - }) - .expect(201); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_2") - .set("x-forwarded-for", sharedIp) - .send({ - message: "用户二第一条" - }) - .expect(201); - - await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_1") - .set("x-forwarded-for", sharedIp) - .send({ - message: "用户一第二条" - }) - .expect(201); - - const response = await request(app.getHttpServer()) - .post("/ai/chat") - .set("x-user-id", "user_2") - .set("x-forwarded-for", sharedIp) - .send({ - message: "用户二第二条" - }) - .expect(429); - - expect(response.body).toMatchObject({ - message: "AI 请求过于频繁,请稍后再试", - code: "AI_RATE_LIMITED", - dimension: "ip", - limit: 3, - windowMs: 60000 - }); - expect(response.body.retryAfterMs).toEqual(expect.any(Number)); - expect(astrbotExecutor.inputs).toHaveLength(3); - expect(prismaService.getUsageLogs()).toHaveLength(3); - }); - - it("should list usage logs with pagination and filters", async () => { - prismaService.seedUsageLog({ - id: "usage_log_1", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "default", - model: "deepseek-chat", - promptTokens: 10, - completionTokens: 6, - totalTokens: 16, - latencyMs: 120, - success: true, - errorCode: null, - createdAt: new Date("2026-04-06T08:00:00.000Z") - }); - prismaService.seedUsageLog({ - id: "usage_log_2", - userId: "user_1", - channel: AiChannel.ASTRBOT, - providerName: "default", - model: "deepseek-chat", - promptTokens: 14, - completionTokens: 9, - totalTokens: 23, - latencyMs: 100, - success: true, - errorCode: null, - createdAt: new Date("2026-04-06T09:00:00.000Z") - }); - prismaService.seedUsageLog({ - id: "usage_log_3", - userId: "user_1", - channel: AiChannel.USER_KEY, - providerName: "openai", - model: "gpt-4o-mini", - promptTokens: 20, - completionTokens: 12, - totalTokens: 32, - latencyMs: 210, - success: false, - errorCode: "UPSTREAM_UNREACHABLE", - createdAt: new Date("2026-04-06T10:00:00.000Z") - }); - prismaService.seedUsageLog({ - id: "usage_log_4", - userId: "user_2", - channel: AiChannel.ASTRBOT, - providerName: "default", - model: "deepseek-chat", - promptTokens: 18, - completionTokens: 11, - totalTokens: 29, - latencyMs: 90, - success: true, - errorCode: null, - createdAt: new Date("2026-04-06T11:00:00.000Z") - }); - - const response = await request(app.getHttpServer()) - .get("/ai/usage-logs") - .set("x-user-id", "user_1") - .query({ - page: 2, - pageSize: 1, - channel: AiChannel.ASTRBOT, - success: true - }) - .expect(200); - - expect(response.body).toEqual({ - items: [ - { - id: "usage_log_1", - channel: AiChannel.ASTRBOT, - providerName: "default", - model: "deepseek-chat", - promptTokens: 10, - completionTokens: 6, - totalTokens: 16, - latencyMs: 120, - success: true, - errorCode: null, - createdAt: "2026-04-06T08:00:00.000Z" - } - ], - page: 2, - pageSize: 1, - total: 2 - }); - }); -}); diff --git a/apps/api/test/astrbot-provider.spec.ts b/apps/api/test/astrbot-provider.spec.ts deleted file mode 100644 index 3ccecc9..0000000 --- a/apps/api/test/astrbot-provider.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { AiChannel } from "../generated/prisma/client"; -import { AstrbotProvider } from "../src/ai/providers/astrbot.provider"; - -describe("AstrbotProvider", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("should not forward binding label fields as astrbot selection parameters", async () => { - const provider = new AstrbotProvider(); - const fetchMock = jest.fn(async (_input: unknown, init?: RequestInit) => { - expect(init?.method).toBe("POST"); - const payload = JSON.parse(String(init?.body ?? "{}")) as Record; - - expect(payload).toMatchObject({ - username: "user_1", - session_id: "session_1", - message: "你好", - enable_streaming: false, - selected_model: "deepseek-chat" - }); - expect(payload).not.toHaveProperty("selected_provider"); - expect(payload).not.toHaveProperty("config_id"); - expect(payload).not.toHaveProperty("config_name"); - - return new Response( - [ - 'data: {"type":"session_id","session_id":"session_1"}', - "", - 'data: {"type":"plain","data":"收到","streaming":false,"chain_type":null}', - "", - 'data: {"type":"end","data":"","streaming":false}', - "" - ].join("\n"), - { - status: 200, - headers: { - "content-type": "text/event-stream" - } - } - ); - }); - - global.fetch = fetchMock as typeof global.fetch; - - const result = await provider.execute( - { - channel: AiChannel.ASTRBOT, - source: "binding", - sourceId: "binding_1", - providerName: "astrbot-main", - model: "deepseek-chat", - configId: "default", - configName: "默认配置", - endpoint: "http://127.0.0.1:6185", - apiKey: "abk_secret" - }, - { - userId: "user_1", - message: "你好", - sessionId: "session_1" - } - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.content).toBe("收到"); - expect(result.sessionId).toBe("session_1"); - expect(result.providerName).toBe("astrbot-main"); - }); -}); diff --git a/apps/api/test/auth.spec.ts b/apps/api/test/auth.spec.ts deleted file mode 100644 index 8f1f1ae..0000000 --- a/apps/api/test/auth.spec.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { UnauthorizedException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { JwtService } from "@nestjs/jwt"; -import { Test, TestingModule } from "@nestjs/testing"; -import { AuthMailService } from "../src/auth/auth-mail.service"; -import { AuthService } from "../src/auth/auth.service"; -import { PrismaService } from "../src/prisma/prisma.service"; -import { DataEncryptionService } from "../src/security/data-encryption.service"; - -type UserRecord = { - id: string; - email: string; - emailHash: string; - nickname: string | null; - avatarUrl: string | null; -}; - -type RefreshTokenRecord = { - id: string; - userId: string; - tokenHash: string; - expiresAt: Date; - revokedAt: Date | null; - createdAt: Date; -}; - -type UserSecurityRecord = { - userId: string; - twoFactorEnabled: boolean; - twoFactorSecret: string | null; -}; - -class InMemoryAuthPrismaService { - private userIdSequence = 1; - private refreshTokenIdSequence = 1; - private users: UserRecord[] = []; - private refreshTokens: RefreshTokenRecord[] = []; - private userSecurities: UserSecurityRecord[] = []; - - readonly user = { - upsert: async (args: { - where: { - emailHash: string; - }; - update: Record; - create: { - email: string; - emailHash: string; - }; - select: { - id: true; - email: true; - }; - }) => { - const existingUser = this.users.find((user) => user.emailHash === args.where.emailHash); - if (existingUser) { - return { - id: existingUser.id, - email: existingUser.email - }; - } - - const createdUser: UserRecord = { - id: `user_${this.userIdSequence++}`, - email: args.create.email, - emailHash: args.create.emailHash, - nickname: null, - avatarUrl: null - }; - this.users.push(createdUser); - - return { - id: createdUser.id, - email: createdUser.email - }; - } - }; - - readonly refreshToken = { - create: async (args: { - data: { - userId: string; - tokenHash: string; - expiresAt: Date; - }; - }) => { - const refreshToken: RefreshTokenRecord = { - id: `refresh_${this.refreshTokenIdSequence++}`, - userId: args.data.userId, - tokenHash: args.data.tokenHash, - expiresAt: args.data.expiresAt, - revokedAt: null, - createdAt: new Date() - }; - this.refreshTokens.push(refreshToken); - return refreshToken; - }, - - findUnique: async (args: { - where: { - tokenHash: string; - }; - include: { - user: { - select: { - id: true; - email: true; - }; - }; - }; - }) => { - const refreshToken = this.refreshTokens.find( - (item) => item.tokenHash === args.where.tokenHash - ); - if (!refreshToken) { - return null; - } - - const user = this.users.find((item) => item.id === refreshToken.userId); - if (!user) { - throw new Error("user not found"); - } - - return { - ...refreshToken, - user: { - id: user.id, - email: user.email - } - }; - }, - - update: async (args: { - where: { - id: string; - }; - data: { - revokedAt: Date; - }; - }) => { - const refreshToken = this.refreshTokens.find((item) => item.id === args.where.id); - if (!refreshToken) { - throw new Error("refresh token not found"); - } - - refreshToken.revokedAt = args.data.revokedAt; - return refreshToken; - }, - - updateMany: async (args: { - where: { - tokenHash: string; - revokedAt: null; - }; - data: { - revokedAt: Date; - }; - }) => { - let count = 0; - for (const refreshToken of this.refreshTokens) { - if (refreshToken.tokenHash !== args.where.tokenHash || refreshToken.revokedAt !== null) { - continue; - } - - refreshToken.revokedAt = args.data.revokedAt; - count += 1; - } - - return { count }; - } - }; - - readonly userSecurity = { - upsert: async (args: { - where: { - userId: string; - }; - update: { - twoFactorSecret: string; - twoFactorEnabled: boolean; - }; - create: { - userId: string; - twoFactorSecret: string; - twoFactorEnabled: boolean; - }; - }) => { - const existingSecurity = this.userSecurities.find( - (item) => item.userId === args.where.userId - ); - if (existingSecurity) { - existingSecurity.twoFactorSecret = args.update.twoFactorSecret; - existingSecurity.twoFactorEnabled = args.update.twoFactorEnabled; - return existingSecurity; - } - - const createdSecurity: UserSecurityRecord = { - userId: args.create.userId, - twoFactorSecret: args.create.twoFactorSecret, - twoFactorEnabled: args.create.twoFactorEnabled - }; - this.userSecurities.push(createdSecurity); - return createdSecurity; - }, - - findUnique: async (args: { - where: { - userId: string; - }; - select: { - twoFactorSecret: true; - }; - }) => { - const security = this.userSecurities.find((item) => item.userId === args.where.userId); - if (!security) { - return null; - } - - return { - twoFactorSecret: security.twoFactorSecret - }; - }, - - update: async (args: { - where: { - userId: string; - }; - data: { - twoFactorEnabled: boolean; - }; - }) => { - const security = this.userSecurities.find((item) => item.userId === args.where.userId); - if (!security) { - throw new Error("user security not found"); - } - - security.twoFactorEnabled = args.data.twoFactorEnabled; - return security; - } - }; - - getUsers(): UserRecord[] { - return [...this.users]; - } -} - -class MockAuthMailService { - readonly sentMessages: Array<{ - email: string; - code: string; - ttlSeconds: number; - }> = []; - - async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise { - this.sentMessages.push({ - email, - code, - ttlSeconds - }); - } -} - -describe("AuthService", () => { - let authService: AuthService; - let prismaService: InMemoryAuthPrismaService; - let authMailService: MockAuthMailService; - - beforeEach(async () => { - prismaService = new InMemoryAuthPrismaService(); - authMailService = new MockAuthMailService(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - DataEncryptionService, - { - provide: PrismaService, - useValue: prismaService - }, - { - provide: AuthMailService, - useValue: authMailService - }, - { - provide: JwtService, - useValue: { - signAsync: async (payload: Record) => - `signed-${String(payload["sub"])}-${String(payload["email"])}` - } - }, - { - provide: ConfigService, - useValue: { - get: (key: string) => { - switch (key) { - case "AUTH_EMAIL_CODE_TTL_SECONDS": - return "300"; - case "AUTH_ACCESS_EXPIRES_IN_SECONDS": - return "900"; - case "AUTH_REFRESH_EXPIRES_IN_SECONDS": - return "2592000"; - case "AUTH_TOTP_ISSUER": - return "TodoList"; - case "DATA_ENCRYPTION_SECRET": - return "test-data-encryption-secret"; - default: - return undefined; - } - } - } - } - ] - }).compile(); - - authService = moduleRef.get(AuthService); - }); - - it("should encrypt user email in database while keeping login flow available", async () => { - await authService.sendEmailCode("User@Example.com"); - expect(authMailService.sentMessages).toHaveLength(1); - expect(authMailService.sentMessages[0]?.email).toBe("user@example.com"); - - const loginResult = await authService.loginWithEmailCode( - "USER@example.com", - authMailService.sentMessages[0]?.code ?? "" - ); - - expect(loginResult.user.email).toBe("user@example.com"); - expect(loginResult.accessToken).toContain("user@example.com"); - - const storedUser = prismaService.getUsers()[0]; - expect(storedUser?.email).not.toBe("user@example.com"); - expect(storedUser?.emailHash).toMatch(/^[a-f0-9]{64}$/); - }); - - it("should decrypt user email when refreshing token", async () => { - await authService.sendEmailCode("refresh@example.com"); - const loginResult = await authService.loginWithEmailCode( - "refresh@example.com", - authMailService.sentMessages[0]?.code ?? "" - ); - - const refreshResult = await authService.refreshTokens(loginResult.refreshToken); - expect(refreshResult.user.email).toBe("refresh@example.com"); - expect(refreshResult.accessToken).toContain("refresh@example.com"); - }); - - it("should reject invalid verification code", async () => { - await authService.sendEmailCode("invalid@example.com"); - - await expect( - authService.loginWithEmailCode("invalid@example.com", "000000") - ).rejects.toBeInstanceOf(UnauthorizedException); - }); -}); diff --git a/apps/api/test/openai-compatible-provider.spec.ts b/apps/api/test/openai-compatible-provider.spec.ts deleted file mode 100644 index 7654669..0000000 --- a/apps/api/test/openai-compatible-provider.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { AiChannel } from "../generated/prisma/client"; -import { OpenAiCompatibleProvider } from "../src/ai/providers/openai-compatible.provider"; - -describe("OpenAiCompatibleProvider", () => { - const originalFetch = global.fetch; - - afterEach(() => { - global.fetch = originalFetch; - jest.restoreAllMocks(); - }); - - it("should read text from responses style payload when chat content is empty", async () => { - const provider = new OpenAiCompatibleProvider(); - const fetchMock = jest.fn(async (_input: unknown, init?: RequestInit) => { - expect(init?.method).toBe("POST"); - - return new Response( - JSON.stringify({ - id: "resp_123", - object: "response", - model: "gpt-5.4", - output: [ - { - id: "msg_123", - type: "message", - role: "assistant", - content: [ - { - type: "output_text", - text: "今天优先先完成截止时间最近的任务。" - } - ] - } - ], - usage: { - prompt_tokens: 15, - completion_tokens: 9, - total_tokens: 24 - } - }), - { - status: 200, - headers: { - "content-type": "application/json" - } - } - ); - }); - - global.fetch = fetchMock as typeof global.fetch; - - const result = await provider.execute( - { - channel: AiChannel.USER_KEY, - source: "binding", - sourceId: "binding_user_key_1", - providerName: "airouter", - model: "gpt-5.4", - configId: null, - configName: null, - endpoint: "https://api.airouter.io/v1", - apiKey: "sk_test" - }, - { - userId: "user_1", - message: "帮我安排今天的任务", - sessionId: null - } - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.content).toBe("今天优先先完成截止时间最近的任务。"); - expect(result.model).toBe("gpt-5.4"); - expect(result.usage).toEqual({ - promptTokens: 15, - completionTokens: 9, - totalTokens: 24 - }); - }); -}); diff --git a/apps/api/test/sync-push.spec.ts b/apps/api/test/sync-push.spec.ts deleted file mode 100644 index 3c75f9b..0000000 --- a/apps/api/test/sync-push.spec.ts +++ /dev/null @@ -1,439 +0,0 @@ -import request from "supertest"; -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Test, TestingModule } from "@nestjs/testing"; -import { PrismaService } from "../src/prisma/prisma.service"; -import { DataEncryptionService } from "../src/security/data-encryption.service"; -import { SyncController } from "../src/sync/sync.controller"; -import { SyncService } from "../src/sync/sync.service"; - -type SyncOperationRecord = { - id: string; - opId: string; - userId: string; - deviceId: string; - entityType: string; - entityId: string; - action: string; - payload: string | null; - clientTs: Date; - serverTs: Date; -}; - -type SyncOperationSelect = { - opId?: true; - entityId?: true; - entityType?: true; - action?: true; - payload?: true; - clientTs?: true; - deviceId?: true; - serverTs?: true; -}; - -type SyncOperationFindManyArgs = { - where: { - userId: string; - opId?: { - in: string[]; - }; - OR?: Array< - | { - serverTs: { - gt: Date; - }; - } - | { - serverTs: Date; - opId: { - gt: string; - }; - } - >; - }; - select: SyncOperationSelect; - orderBy?: Array<{ - serverTs?: "asc" | "desc"; - opId?: "asc" | "desc"; - }>; - take?: number; -}; - -type SyncOperationCreateArgs = { - data: { - opId: string; - userId: string; - deviceId: string; - entityType: string; - entityId: string; - action: string; - payload?: string; - clientTs: Date; - }; - select: { - opId: true; - serverTs: true; - }; -}; - -class InMemoryPrismaService { - private syncOperationIdSequence = 1; - private syncOperations: SyncOperationRecord[] = []; - - readonly syncOperation = { - findMany: async (args: SyncOperationFindManyArgs) => { - let items = this.syncOperations.filter((item) => item.userId === args.where.userId); - - if (args.where.opId?.in) { - items = items.filter((item) => args.where.opId?.in.includes(item.opId)); - } - - if (args.where.OR && args.where.OR.length > 0) { - items = items.filter((item) => - args.where.OR?.some((condition) => { - if ("gt" in condition.serverTs) { - return item.serverTs.getTime() > condition.serverTs.gt.getTime(); - } - - if ("opId" in condition) { - return ( - item.serverTs.getTime() === condition.serverTs.getTime() && - item.opId > condition.opId.gt - ); - } - - return false; - }) - ); - } - - if (args.orderBy && args.orderBy.length > 0) { - items = [...items].sort((left, right) => { - for (const orderRule of args.orderBy ?? []) { - if (orderRule.serverTs) { - const diff = left.serverTs.getTime() - right.serverTs.getTime(); - if (diff !== 0) { - return orderRule.serverTs === "asc" ? diff : -diff; - } - } - - if (orderRule.opId) { - const diff = left.opId.localeCompare(right.opId); - if (diff !== 0) { - return orderRule.opId === "asc" ? diff : -diff; - } - } - } - - return 0; - }); - } - - const limitedItems = args.take ? items.slice(0, args.take) : items; - - return limitedItems.map((item) => this.pickSelectedFields(item, args.select)); - }, - - create: async (args: SyncOperationCreateArgs) => { - const createdOperation: SyncOperationRecord = { - id: `sync_${this.syncOperationIdSequence++}`, - opId: args.data.opId, - userId: args.data.userId, - deviceId: args.data.deviceId, - entityType: args.data.entityType, - entityId: args.data.entityId, - action: args.data.action, - payload: args.data.payload ?? null, - clientTs: args.data.clientTs, - serverTs: new Date() - }; - - this.syncOperations.push(createdOperation); - - return { - opId: createdOperation.opId, - serverTs: createdOperation.serverTs - }; - } - }; - - getOperationCount(): number { - return this.syncOperations.length; - } - - getRawOperationById(opId: string): SyncOperationRecord | undefined { - return this.syncOperations.find((operation) => operation.opId === opId); - } - - seedOperations(records: Array>): void { - for (const record of records) { - this.syncOperations.push({ - ...record, - id: `sync_${this.syncOperationIdSequence++}` - }); - } - } - - private pickSelectedFields( - item: SyncOperationRecord, - select: SyncOperationSelect - ): Partial { - const result: Record = {}; - - for (const key of Object.keys(select) as Array) { - if (!select[key]) { - continue; - } - - const recordKey = key as keyof SyncOperationRecord; - result[recordKey] = item[recordKey]; - } - - return result as Partial; - } -} - -describe("SyncController (integration)", () => { - let app: INestApplication; - let prismaService: InMemoryPrismaService; - - beforeAll(async () => { - prismaService = new InMemoryPrismaService(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [SyncController], - providers: [ - SyncService, - DataEncryptionService, - { provide: PrismaService, useValue: prismaService }, - { - provide: ConfigService, - useValue: { - get: (key: string) => - key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined - } - } - ] - }).compile(); - - app = moduleRef.createNestApplication(); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true - }) - ); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it("should accept operations once and mark repeated push as duplicate", async () => { - const payload = { - operations: [ - { - opId: "op-create-1", - entityType: "TASK", - entityId: "task-1", - action: "CREATE", - payload: '{"title":"任务一"}', - clientTs: 1712419200000, - deviceId: "device-a" - }, - { - opId: "op-update-1", - entityType: "TASK", - entityId: "task-1", - action: "UPDATE", - payload: '{"title":"任务一-更新"}', - clientTs: 1712419201000, - deviceId: "device-a" - } - ] - }; - - const firstResponse = await request(app.getHttpServer()) - .post("/sync/push") - .set("x-user-id", "user-1") - .send(payload) - .expect(201); - - expect(firstResponse.body.acceptedCount).toBe(2); - expect(firstResponse.body.duplicateCount).toBe(0); - expect(firstResponse.body.failedCount).toBe(0); - expect(firstResponse.body.results).toEqual([ - expect.objectContaining({ - opId: "op-create-1", - status: "accepted" - }), - expect.objectContaining({ - opId: "op-update-1", - status: "accepted" - }) - ]); - expect(prismaService.getOperationCount()).toBe(2); - expect(prismaService.getRawOperationById("op-create-1")?.payload).not.toBe( - '{"title":"浠诲姟涓€"}' - ); - - const secondResponse = await request(app.getHttpServer()) - .post("/sync/push") - .set("x-user-id", "user-1") - .send(payload) - .expect(201); - - expect(secondResponse.body.acceptedCount).toBe(0); - expect(secondResponse.body.duplicateCount).toBe(2); - expect(secondResponse.body.failedCount).toBe(0); - expect(secondResponse.body.results).toEqual([ - expect.objectContaining({ - opId: "op-create-1", - status: "duplicate", - reason: "already_synced" - }), - expect.objectContaining({ - opId: "op-update-1", - status: "duplicate", - reason: "already_synced" - }) - ]); - expect(prismaService.getOperationCount()).toBe(2); - }); - - it("should mark duplicated op ids in the same batch as duplicate", async () => { - const response = await request(app.getHttpServer()) - .post("/sync/push") - .set("x-user-id", "user-2") - .send({ - operations: [ - { - opId: "op-dup-1", - entityType: "TASK", - entityId: "task-2", - action: "CREATE", - payload: '{"title":"任务二"}', - clientTs: 1712419300000, - deviceId: "device-b" - }, - { - opId: "op-dup-1", - entityType: "TASK", - entityId: "task-2", - action: "UPDATE", - payload: '{"title":"任务二-重复"}', - clientTs: 1712419301000, - deviceId: "device-b" - } - ] - }) - .expect(201); - - expect(response.body.acceptedCount).toBe(1); - expect(response.body.duplicateCount).toBe(1); - expect(response.body.failedCount).toBe(0); - expect(response.body.results[0]).toEqual( - expect.objectContaining({ - opId: "op-dup-1", - status: "accepted" - }) - ); - expect(response.body.results[1]).toEqual( - expect.objectContaining({ - opId: "op-dup-1", - status: "duplicate", - reason: "same_batch_duplicate" - }) - ); - expect(prismaService.getOperationCount()).toBe(3); - }); - - it("should pull operations incrementally with a stable cursor", async () => { - prismaService.seedOperations([ - { - opId: "pull-op-1", - userId: "user-pull", - deviceId: "device-c", - entityType: "TASK", - entityId: "task-10", - action: "CREATE", - payload: '{"title":"任务甲"}', - clientTs: new Date("2026-04-06T10:00:00.000Z"), - serverTs: new Date("2026-04-06T10:10:00.000Z") - }, - { - opId: "pull-op-2", - userId: "user-pull", - deviceId: "device-c", - entityType: "TASK", - entityId: "task-10", - action: "UPDATE", - payload: '{"title":"任务甲-更新"}', - clientTs: new Date("2026-04-06T10:01:00.000Z"), - serverTs: new Date("2026-04-06T10:10:00.000Z") - }, - { - opId: "pull-op-3", - userId: "user-pull", - deviceId: "device-c", - entityType: "TASK", - entityId: "task-11", - action: "CREATE", - payload: '{"title":"任务乙"}', - clientTs: new Date("2026-04-06T10:02:00.000Z"), - serverTs: new Date("2026-04-06T10:11:00.000Z") - }, - { - opId: "pull-op-other-user", - userId: "user-other", - deviceId: "device-z", - entityType: "TASK", - entityId: "task-99", - action: "CREATE", - payload: '{"title":"其他用户任务"}', - clientTs: new Date("2026-04-06T10:03:00.000Z"), - serverTs: new Date("2026-04-06T10:12:00.000Z") - } - ]); - - const firstResponse = await request(app.getHttpServer()) - .get("/sync/pull") - .set("x-user-id", "user-pull") - .query({ limit: 2 }) - .expect(200); - - expect(firstResponse.body.items.map((item: { opId: string }) => item.opId)).toEqual([ - "pull-op-1", - "pull-op-2" - ]); - expect(firstResponse.body.hasMore).toBe(true); - expect(firstResponse.body.nextCursor).toEqual(expect.any(String)); - - const secondResponse = await request(app.getHttpServer()) - .get("/sync/pull") - .set("x-user-id", "user-pull") - .query({ - limit: 2, - cursor: firstResponse.body.nextCursor - }) - .expect(200); - - expect(secondResponse.body.items.map((item: { opId: string }) => item.opId)).toEqual([ - "pull-op-3" - ]); - expect(secondResponse.body.hasMore).toBe(false); - expect(secondResponse.body.nextCursor).toEqual(expect.any(String)); - }); - - it("should reject invalid cursor payload", async () => { - await request(app.getHttpServer()) - .get("/sync/pull") - .set("x-user-id", "user-invalid-cursor") - .query({ - cursor: "not-a-valid-cursor" - }) - .expect(400); - }); -}); diff --git a/apps/api/test/task.spec.ts b/apps/api/test/task.spec.ts deleted file mode 100644 index 98b25cd..0000000 --- a/apps/api/test/task.spec.ts +++ /dev/null @@ -1,481 +0,0 @@ -import request from "supertest"; -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Test, TestingModule } from "@nestjs/testing"; -import { PrismaService } from "../src/prisma/prisma.service"; -import { DataEncryptionService } from "../src/security/data-encryption.service"; -import { TaskController } from "../src/task/task.controller"; -import { TaskService } from "../src/task/task.service"; -import { TaskPriority, TaskStatus } from "../generated/prisma/client"; - -type TaskRecord = { - id: string; - userId: string; - title: string; - contentJson: unknown | null; - contentText: string | null; - priority: TaskPriority; - status: TaskStatus; - ddl: Date | null; - completedAt: Date | null; - version: number; - createdAt: Date; - updatedAt: Date; -}; - -type TagRecord = { - id: string; - userId: string; - name: string; -}; - -type TaskTagRecord = { - taskId: string; - tagId: string; -}; - -type ListWhereInput = { - userId?: string; - status?: TaskStatus; - priority?: TaskPriority; - taskTags?: { - some: { - tag: { - name: { - in: string[]; - }; - }; - }; - }; - OR?: Array<{ - title?: { - contains: string; - mode?: "insensitive"; - }; - contentText?: { - contains: string; - mode?: "insensitive"; - }; - }>; -}; - -class InMemoryPrismaService { - private taskIdSequence = 1; - private tagIdSequence = 1; - private tasks: TaskRecord[] = []; - private tags: TagRecord[] = []; - private taskTags: TaskTagRecord[] = []; - - readonly task = { - findMany: async (args: { - where?: ListWhereInput; - orderBy?: { createdAt?: "asc" | "desc"; updatedAt?: "asc" | "desc"; ddl?: "asc" | "desc" }; - skip?: number; - take?: number; - }) => { - const where = args.where; - const skip = args.skip ?? 0; - const take = args.take ?? 20; - let filtered = [...this.tasks]; - - if (where?.userId) { - filtered = filtered.filter((task) => task.userId === where.userId); - } - if (where?.status) { - filtered = filtered.filter((task) => task.status === where.status); - } - if (where?.priority) { - filtered = filtered.filter((task) => task.priority === where.priority); - } - if (where?.taskTags?.some.tag.name.in) { - const expectedTags = new Set(where.taskTags.some.tag.name.in); - filtered = filtered.filter((task) => { - const taskTagNames = this.getTaskTagNames(task.id); - return taskTagNames.some((tagName) => expectedTags.has(tagName)); - }); - } - if (where?.OR && where.OR.length > 0) { - filtered = filtered.filter((task) => - where.OR!.some((orCondition) => { - if (orCondition.title?.contains) { - return task.title.toLowerCase().includes(orCondition.title.contains.toLowerCase()); - } - if (orCondition.contentText?.contains) { - return ( - task.contentText - ?.toLowerCase() - .includes(orCondition.contentText.contains.toLowerCase()) ?? false - ); - } - return false; - }) - ); - } - - if (args.orderBy) { - const [orderField, orderDirection] = Object.entries(args.orderBy)[0] as [ - "createdAt" | "updatedAt" | "ddl", - "asc" | "desc" - ]; - filtered.sort((left, right) => { - const leftValue = left[orderField]; - const rightValue = right[orderField]; - - if (leftValue === null && rightValue === null) { - return 0; - } - if (leftValue === null) { - return 1; - } - if (rightValue === null) { - return -1; - } - - const diff = leftValue.getTime() - rightValue.getTime(); - return orderDirection === "asc" ? diff : -diff; - }); - } - - return filtered.slice(skip, skip + take).map((task) => this.toTaskWithTags(task)); - }, - - count: async (args: { where?: ListWhereInput }) => { - const results = await this.task.findMany({ - where: args.where, - skip: 0, - take: Number.MAX_SAFE_INTEGER - }); - return results.length; - }, - - findFirst: async (args: { - where: { - id?: string; - userId?: string; - }; - select?: { - id?: boolean; - status?: boolean; - }; - }) => { - const task = this.tasks.find( - (item) => - (args.where.id === undefined || item.id === args.where.id) && - (args.where.userId === undefined || item.userId === args.where.userId) - ); - if (!task) { - return null; - } - - if (args.select) { - return { - id: args.select.id ? task.id : undefined, - status: args.select.status ? task.status : undefined - }; - } - - return this.toTaskWithTags(task); - }, - - create: async (args: { - data: { - userId: string; - title: string; - contentJson?: unknown; - contentText: string | null; - priority: TaskPriority; - status: TaskStatus; - ddl: Date | null; - completedAt: Date | null; - }; - }) => { - const now = new Date(); - const task: TaskRecord = { - id: `task_${this.taskIdSequence++}`, - userId: args.data.userId, - title: args.data.title, - contentJson: args.data.contentJson ?? null, - contentText: args.data.contentText, - priority: args.data.priority, - status: args.data.status, - ddl: args.data.ddl, - completedAt: args.data.completedAt, - version: 1, - createdAt: now, - updatedAt: now - }; - this.tasks.push(task); - return task; - }, - - update: async (args: { - where: { - id: string; - }; - data: { - title?: string; - contentJson?: unknown; - contentText?: string | null; - priority?: TaskPriority; - status?: TaskStatus; - ddl?: Date | null; - completedAt?: Date | null; - version?: { - increment: number; - }; - }; - }) => { - const task = this.tasks.find((item) => item.id === args.where.id); - if (!task) { - throw new Error("task not found"); - } - - if (args.data.title !== undefined) { - task.title = args.data.title; - } - if (args.data.contentJson !== undefined) { - task.contentJson = args.data.contentJson; - } - if (args.data.contentText !== undefined) { - task.contentText = args.data.contentText; - } - if (args.data.priority !== undefined) { - task.priority = args.data.priority; - } - if (args.data.status !== undefined) { - task.status = args.data.status; - } - if (args.data.ddl !== undefined) { - task.ddl = args.data.ddl; - } - if (args.data.completedAt !== undefined) { - task.completedAt = args.data.completedAt; - } - if (args.data.version !== undefined) { - task.version += args.data.version.increment; - } - task.updatedAt = new Date(); - - return task; - }, - - deleteMany: async (args: { - where: { - id: string; - userId: string; - }; - }) => { - const beforeCount = this.tasks.length; - this.tasks = this.tasks.filter( - (task) => !(task.id === args.where.id && task.userId === args.where.userId) - ); - this.taskTags = this.taskTags.filter((taskTag) => taskTag.taskId !== args.where.id); - return { - count: beforeCount - this.tasks.length - }; - }, - - findUniqueOrThrow: async (args: { - where: { - id: string; - }; - }) => { - const task = this.tasks.find((item) => item.id === args.where.id); - if (!task) { - throw new Error("task not found"); - } - - return this.toTaskWithTags(task); - } - }; - - readonly tag = { - upsert: async (args: { - where: { - userId_name: { - userId: string; - name: string; - }; - }; - create: { - userId: string; - name: string; - }; - }) => { - const existing = this.tags.find( - (tag) => - tag.userId === args.where.userId_name.userId && tag.name === args.where.userId_name.name - ); - if (existing) { - return existing; - } - - const createdTag: TagRecord = { - id: `tag_${this.tagIdSequence++}`, - userId: args.create.userId, - name: args.create.name - }; - this.tags.push(createdTag); - return createdTag; - } - }; - - readonly taskTag = { - deleteMany: async (args: { - where: { - taskId: string; - }; - }) => { - const beforeCount = this.taskTags.length; - this.taskTags = this.taskTags.filter((taskTag) => taskTag.taskId !== args.where.taskId); - return { - count: beforeCount - this.taskTags.length - }; - }, - - createMany: async (args: { - data: Array<{ - taskId: string; - tagId: string; - }>; - }) => { - for (const row of args.data) { - const existing = this.taskTags.find( - (taskTag) => taskTag.taskId === row.taskId && taskTag.tagId === row.tagId - ); - if (!existing) { - this.taskTags.push(row); - } - } - return { - count: args.data.length - }; - } - }; - - async $transaction(runner: (tx: InMemoryPrismaService) => Promise): Promise { - return runner(this); - } - - getRawTaskById(taskId: string): TaskRecord | undefined { - return this.tasks.find((task) => task.id === taskId); - } - - private toTaskWithTags( - task: TaskRecord - ): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } { - return { - ...task, - taskTags: this.taskTags - .filter((taskTag) => taskTag.taskId === task.id) - .map((taskTag) => this.tags.find((tag) => tag.id === taskTag.tagId)) - .filter((tag): tag is TagRecord => tag !== undefined) - .map((tag) => ({ - tag: { - name: tag.name - } - })) - }; - } - - private getTaskTagNames(taskId: string): string[] { - return this.taskTags - .filter((taskTag) => taskTag.taskId === taskId) - .map((taskTag) => this.tags.find((tag) => tag.id === taskTag.tagId)) - .filter((tag): tag is TagRecord => tag !== undefined) - .map((tag) => tag.name); - } -} - -describe("TaskController (integration)", () => { - let app: INestApplication; - const prismaService = new InMemoryPrismaService(); - - beforeAll(async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ - controllers: [TaskController], - providers: [ - TaskService, - DataEncryptionService, - { provide: PrismaService, useValue: prismaService as unknown as PrismaService }, - { - provide: ConfigService, - useValue: { - get: (key: string) => - key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined - } - } - ] - }).compile(); - - app = moduleRef.createNestApplication(); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true - }) - ); - - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it("should create, query, update and delete a task", async () => { - const createResponse = await request(app.getHttpServer()) - .post("/tasks") - .set("x-user-id", "user_1") - .send({ - title: "准备周会", - contentText: "整理本周进度", - priority: "HIGH", - tagNames: ["工作", "会议"] - }) - .expect(201); - - expect(createResponse.body.id).toBeDefined(); - expect(createResponse.body.tags).toEqual(["工作", "会议"]); - const taskId = createResponse.body.id as string; - const rawCreatedTask = prismaService.getRawTaskById(taskId); - expect(rawCreatedTask?.title).not.toBe("准备周会"); - expect(rawCreatedTask?.contentText).not.toBe("整理本周进度"); - - const listResponse = await request(app.getHttpServer()) - .get("/tasks") - .set("x-user-id", "user_1") - .query({ tags: "会议" }) - .expect(200); - - expect(listResponse.body.total).toBe(1); - expect(listResponse.body.items[0].id).toBe(taskId); - - const updateResponse = await request(app.getHttpServer()) - .patch(`/tasks/${taskId}`) - .set("x-user-id", "user_1") - .send({ - status: "DONE" - }) - .expect(200); - - expect(updateResponse.body.status).toBe("DONE"); - expect(updateResponse.body.completedAt).toBeTruthy(); - expect(updateResponse.body.version).toBe(2); - - await request(app.getHttpServer()) - .delete(`/tasks/${taskId}`) - .set("x-user-id", "user_1") - .expect(200) - .expect({ - success: true - }); - - const listAfterDeleteResponse = await request(app.getHttpServer()) - .get("/tasks") - .set("x-user-id", "user_1") - .expect(200); - expect(listAfterDeleteResponse.body.total).toBe(0); - }); -}); diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json deleted file mode 100644 index 10b2c04..0000000 --- a/apps/api/tsconfig.build.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "**/*.spec.ts"] -} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index f196337..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "../../packages/tsconfig/nest-app.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "dist" - }, - "include": ["src/**/*.ts", "scripts/**/*.ts", "generated/prisma/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json deleted file mode 100644 index e83c972..0000000 --- a/apps/api/tsconfig.spec.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node", "jest"] - }, - "include": ["src/**/*.ts", "generated/prisma/**/*.ts", "test/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/apps/web/.gitignore b/apps/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/apps/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/apps/web/README.md b/apps/web/README.md deleted file mode 100644 index 15e3b1d..0000000 --- a/apps/web/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# TodoList Web 前端 - -这是 TodoList 的用户端前端应用(SPA + PWA),基于 `React + TypeScript + Vite`。 - -## 技术栈 - -- React -- TypeScript -- Vite -- Tailwind CSS -- shadcn/ui - -## 本地开发 - -在仓库根目录执行: - -```bash -pnpm install -pnpm --filter web dev -``` - -默认开发地址: - -- `http://localhost:5173` - -## 后端接口地址 - -前端默认请求: - -- `http://localhost:3000` - -如需自定义,请在运行前设置环境变量: - -```bash -VITE_API_BASE_URL=http://localhost:3000 -``` - -## 构建与预览 - -```bash -pnpm --filter web build -pnpm --filter web preview -``` - -## 当前功能进度(阶段性) - -- 邮箱验证码登录页面 -- OAuth 回调页面 -- 会话本地缓存与启动恢复 -- 基础工作台页面骨架 - -## 目录说明 - -- `src/pages`:页面组件 -- `src/components`:通用 UI 组件 -- `src/services`:接口请求与会话处理 -- `src/lib`:工具函数 diff --git a/apps/web/components.json b/apps/web/components.json deleted file mode 100644 index f5ebd86..0000000 --- a/apps/web/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index 99368a6..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; -import { defineConfig, globalIgnores } from "eslint/config"; - -export default defineConfig([ - globalIgnores(["dist"]), - { - files: ["**/*.{ts,tsx}"], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser - } - } -]); diff --git a/apps/web/index.html b/apps/web/index.html deleted file mode 100644 index da4e35e..0000000 --- a/apps/web/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - TodoList - - -
- - - diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index efbfcf0..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@base-ui/react": "^1.3.0", - "@fontsource-variable/geist": "^5.2.8", - "@tiptap/core": "^3.22.2", - "@tiptap/extension-image": "^3.22.2", - "@tiptap/extension-link": "^3.22.2", - "@tiptap/extension-youtube": "^3.22.2", - "@tiptap/react": "^3.22.2", - "@tiptap/starter-kit": "^3.22.2", - "browser-image-compression": "^2.0.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dexie": "^4.4.2", - "dexie-react-hooks": "^4.4.0", - "lucide-react": "^1.7.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0", - "shadcn": "^4.1.2", - "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "@types/node": "^24.12.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "autoprefixer": "^10.4.27", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "postcss": "^8.5.8", - "tailwindcss": "^3.4.17", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" - } -} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js deleted file mode 100644 index ba80730..0000000 --- a/apps/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -}; diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png deleted file mode 100644 index 0f8c6089efc4dc90da9833d164669320dbf2e9d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 203435 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

P1^U3Tg+I!&Bk^gyO3AcM#)0MQ zV6+7!GdTrFG{84aZA(tdwgwjT=bhwIE|mc#GvZxXM+hEr0?-3GQ6V7IDA#}#RJcbz z@-yV`_U6~4ZrYhhb#iC$_yX9s1uD9^$$%><>{ zzq3#PvX3-tv4Ypz5@e%47hkbY;@gN!i9@+A+w5plXL_~p)fcYdmCwDlY#O}0gQI=m z-u}tFJkSMtV2iz)Xf!`!<7dYaPHpTW^Mlz4>nW$H-B~) z4{tt!G3qj5dj^X|`9~u@fV?Zu;7)$dGeT1mp_D9m+a*Bc#KP>B1Akx)&o0mmKna?& zl4(88nFEvy8&S^wsldmn(1$sCNN)>#`=53WVi4|89l0PZ9Tk!upSTq#ptg0ibon&GMGwR0N_t znoh|ha-V4(9_U7g-X8;OIR<)W1%f=R@gL{4Zy(4>pR7;QXS>smCaH2jlsEo?K8ek0 zmO^Ut63Izi*WXa5oVD1C$%2)VAw1hN6BhhRCN7oll83|wV-eAhB0nJ8Ce?GeQri5+ zIHNc(iv0j)}=#6+K=4;vhvlDR8+nV5s;0 z@5HqKu(^Ku@c}L!ZW58h5hr&~acdLwKl<67&Dy@Yz1L4WQs_;%M1hxV&X$gBr0+4o z+Dpm4QrspV zGs#$~tgHnVlaZ{@Lv8eRLI+kZ>{M3Gvko-)HrkpH16kiJBo20K$zoTBlVawXc!c+; zPOJNWYMa{(eVZ+s9|fRGbVbP&BdjHM+mai%ZDk*9GC)qwiCv(xb6st_*Zp)(yMdrL z=YApE6A4NX&5rjs%L5mmyM$LavjPV%T);=y&+v3-4FA_+Hf&4oJG%OypS0m`o+*g) zFW0Gkm{;!58!_L0!hw97Ea(1gx}u~dv-8Ue=tS=;Ek-`(fG>Pi z`&y}ePq!A3$2N!1`L(|!d2gTTVyA85$YsoMiXnJk{-NpAqVq5qV^o8b#A)x?a6@6x zjpr*MwJi*O=q1n2V2)ao4ZiM#da{TWTE(v=iP~>wwj8!bX9n+vZxvJKR)dQsUzR5S z7>VRWN`?X3jz}u2BgT}TlAjQZ!Effw8bA$X2|$X+`6jAHPM=tym!&gb+hsm7HkjOl z|BzpF!9K#yY4#=5wiNwj@_PNNe@bM0FdcZEEhIS6&_;X325U>_BsUp~HL9geQk4b7 zG`-8!#r%Zuqy~SYKL%hG=a7O%3RYprUO4t*DT!2wH)Mv`eJDv#cTP@_bMQC*Vpsb@ zu7Y}beaa>Axr%LgO_>j9*O=?ZOH3 zt`%Xg{`5amxzGMcoU^_Mp$qv~0q>&FC@DE+Au~zb(65+si<1jGksN5M&f+y6_vFRv zXGhB2YvC`Zw_Uu&QF8<4RNRbEfUyJuNt;U#%YV65X^N3Z=(yh+pb6YkI}R#KBYpCM zs7w819l{e1r8;q;89fT%SmIaZYe5*i_6av;11b>c1PCd0Zx%)57)jf33d?#u1Gx%S zG6&*j6sQSd9?b;5^dtJ`M38-fcnA4V1jn#d!+25#f?ba!bD=Cdtk^4CY;?2e1a6}i zXw61huHLTGPV|&$pNc5!mTcaWB8M%q9k^ZCtXJZt1fDum1Orh3JY^dFf_yx;JU+cL(W?5N7pP=0~cxphneU|O|WbL9^qG?G2g3)C#+3SLIbXajx|!L56S1; zN18u{&IkjQ60+&kh?J06;4^Hv`OZf;_gLqp`~^Dz*PNT8_Vi7ErnXza7k(A+{A%Ip z-)07;oxAkt0Q&F@_dhs9u@0wgR1N(!Lgxg&0dW3Y1hh7YGrc(j-bD;j=Aso7Bw|OY z7^vZNX~IkE*8y7zb4i}_)|F9p)nF)%k8CHXKx8HA3~|-c7*}0Cyg$tehX&)`w?>r! zP^m}}Kra@6S=iN?#49ogc>n{GE>4pOK&d{MbfYc-Z@LoKAkxV>%&F=EpgAn`c{ zKX)c|@I;6Gp}F3!TU*l8LM=zz3Csk2J`o$CbVa7A&tjiUG24E~Eo>zkA|NXMo#3bR zk!;PQU-BTj770g(A+4%VZ;?C@&>{0G4^T3%QN= zn0#-=f5MU!mIyg^5yrPdURDGNr@}p?@y=}&|6d%}X{efQoyMmSvXk5gTceg973^E- zIJcdyQC7}h61X&HTg&pf4#zkvzdD^w)zm5qIUQxWOH-UwXmLlQcT8pHDSyW#O##kk z0M?Y06QZ?6g&tD4Jj1DxXuUYjG^-g~A`(-BUdl6Y2C7bx4zBUXk|rM|k{&#?H6u<_ znviI#W3biKTB3o4)ogtm{mO0l`kzK{BHO5PlmyNQY=)NPPD!|pb80tFlOmnxKAANa zv&)|0LbFa<4Ic?x|Kv{z#3_k!nNwL_hVYA;A;o8cBnQ(qKDkfxom5PsO(4J;l;CPR z*(DStl@L?2Ijs19uuL+j?ynsagOv83vykn!hG^C*5L2^StaiMGN zJC&l~5%jRk3F|a!wp|O!`bb&cmKpA|u$={LNvtPOoX9Z2$men*66K~Q&Q8y8c;h}U zKi+Y=6V=?^%w+q=O|?B zW!hd2LZ?##*D%tfmBc_y$$uHUT-VjsX<&$yXawdD0lLMV*>SByWFW2vtkG}cdD@U8 zOq56`lRRA~3A4}y5I=&%JSq-OP`*_fl^i@qsRhO<&|3iKz^q`fsXd4CEZM0uCCyGu z=b28owt7*!Z&P1G0+=k1B@fE(ABmP!CITNVn+zi>0FkhU}bKrn*`;xQO7cIRR3ZKETt?HMnKTtN)S|z`e&rluxm?h8*I_ zA9xurKJ!LA|161;5Eo%5ppk3<9{nhsWL_DFtW9Y@9H@wA*DZa5{T>8>UMp<1^4lGu zs#M=-sdxoWAixO-`xW0Uu$*GG3;#_CM-bEp!XPnClOy|8Z6|PvPLmiF{f5*?v;v!X z#zRZM$kX-#z)qd@X?9ce2V^_s#KClDB|m)gL*VEPmri!F?V;F!1ArloM%g*w=e+>Z zW&|!hIftWr$2h$82%2yP+4l(Ryc=k;7uQSje>oC!B4*5D$6SKf3}`r^?*5rU8t@d({ASdT3xEi<4Y){EDG zRe{V43aw0sw6w-kOYvZ-%e?XOQjk#-5QK$S2sH6UU*kBoN1Vb9-sLpJrV^0h3BB5&*yDYu9wIzjp~z;7olDFW#L8FVaR;tbrZ zh{3dzydoSV(T)U&!770{sx7e%Ma4;%c{G}%%iOaD{h9(=`y~-Tkb(+|lhToVE5Ced z`5t^~7i>%it&l$}0|or!WHAYJo+jB2h2&_GBRGy0W}nJDx^D9cXDVhXB7oF{0035C zC@Fk?0@^S*b6wIlpYJhZc_~nxZ7Tj>)Hap3JD^6#NXD1t@K^RiAdFK(*bViTWz=wJ zxf82Kn!q4wvQ!c9a4)I6*+tYt$Qp*rq1oyD{)pApT)cD=w9@IkM{ z-Td1dcd_#jc=v}l8gC=<*5p|nKwY5yvQV-?N+MD`RnrqI4+*d9GFHf3x%s4%e&$h$ zj@A&H>c~%DTzK4t0abN;l4}llY!o4glu0U|M}Lr__<7<00SYx%Z=G6jK^EN$BOaM3 zmo-@k)0b&>VbS%a$j}Lzc?f(F2mb`tO*YyMF)RpZYSWxHvcQ&zv;s?TfnmDKP?HQn z;N4`>XiTz420B4$xVQyVZ-FrP7r;7)KD!lArK2anP`!VajTDLZzfD&Cv*WcZNhU&e z&DR~EJzh$(2PvCLI=H>)PtxZ;X%rd zr20wdKuWSvC92LV!d@uxOv#9#2W#g+Q~%|syX;!h;-9V`HuAvf_M`V8GJp_E_EnSV z)c(P>b9mv4*YLs@F5}8CU6=#^usdMb+mtzIvfih*Hf!#0{Ch9p@rO_G_W%45-v5I; ztxfWiW@v;y^Y6Rw-N73#U%~#tfTP_p@dYrp^O8)~fh7U+ynp+7`FL;MWAJb@`sWst zY$#~hz-K8GG0=6g<;cpoLxLUCCUJh$~T9}PC?m*dyX~-(5@$k&c=yn(j|dN**t6-zo$8Z5?`kb zOrksxfHj|U6cu1aWG1g{WK;(c(2^J&Wr!xbHb!5{4?F-MKEfn~SY@e#*UK3AjD|FmHL1bwcMf8R zlN{tyaG?>V_lITV8Kc4lSiyChd{>{$its5*dJ-?Cbk)24=8;3^+rqOh)V9gK*^dP} zpSG37O8_?<)F(Q#uPk*@=+E;dK6{oxkpU1v!xnHAGPcg<7!$NJ@6I>)FiOSRX+p3h zU7q3p@muM9^y+b@C7CndngPIY#CE8MEJ~*W)Y&x)Bf`S$&4B3Lj1UiY@WyYvh-;s^ zgcpDL3U)RDdAPJmy7x9?=iv-zJEvIgtjjdG9?oy3+^?O-=bw86XHQ4m`0hRQM@}%O zKX=~0jh8?F8ZKPh$J5aZG3j)QJOuC*gqO@=gnI5k~OgyP3 z45Pm6dHMiya$Gp8I&hO|jJgfDnxzluq!{mO0UoR!6&u6t0MGUOQK?efoG2lD~?$NF0wVEr^kR1wLx#@ z-Xgmhk07H?GggGsf)Sd2(~wa|pNl5I!pKV?lmZ-;BPnnunt(cu=Gm|%8#C$73{2^K z0&y)eYTm~3O!_gJTfh~>s3XrLSb0eS&FsJdl&PwTLSlz(i=4)11=O6BigS_xw%O`i znSUOI&H5P0)-+@hGayR3h~*|wFy&-6W8UQTX-E^!|Cp+4MHQ z-Ls9EpMYb*sG0&?^riDlc#aQ_u=nCQeEL6n6}Nx<0H;S|3XCv05=Ne$p5e~VZ{o#k zZ(!KWE=&nVg$d))q0BHwg>evU(d6KRxZKE2=^7WA9Qrf6^8dZF9ER4CgcGF9$#mn)bI_IQ4tD2 z6v9+V9_&p9`h@yR`7uC&OH$II4UoAd2KI39o*olWOiGo5_=3q9rwi?yUeq=$fzaS?({}QOdhKbwPB#lJcT&4??VIZ;KnLWx6>iLp{5Zr z(HB5Rtu&kLKoXSg&h1)oihdY8-7YDhIH)67U{$1qiYXwQVEpHR$aIn<6F6A9kb`l^ z&0g`E-U3!-Sm>s%qHE2B@-ywZOct4elJu{nmEw=G(jnQl+l}TfJ$OYX1DkySwW|ql ztOs5SXx;h6upvR7;p3%;fWj)gu%8ZBpv6}NjNcJ7h7|CpW7M3?8#W15Z8>S8O+xn)_{wrUwHwhm1Z$7}wPtS1ic);PgvnWMFan%|KfS9UpI6j@<%mQ3I z*~QcQhj@7V5!Lr^=`(a?bOAhL8`pUDbE)Xq<2n!dr>+DP;1^<4fINUMbs_1u&nkE$ zd1jsME_jaQW*2;{1pgp3U)HXax}^n(+lkzq1NultKb9FB=N!vdi0y4QKzt5rkn zt_nk!ZfwvMqyR%B6&umGt5Tqxk{2IIOUx>G)2;uIu@;{W8V2W2DDmT_@=(R>N_Z*e z8JNdOc_L70O16}X*_1l1t0jT##2x1dLOG%^4*HAdJ!N0 zE3e>SGg2R}2~IbG|H)>0-+co0=yA5U529##_WnL_oLBMQeB+Y*~9sceJ`@=L!QPQp$(ITZl1Vh_|!%7#)3NV2?_w8%| zxOf{wdvl(0R}#jpjf15Nn(~77BSb&4A}gbHYR9T*El){^63yZPuow&RS_K@yrtZyJ zL5P!p3aH9c>*R#2D?vi4be_7fkp~+@4RWB-m^1*71eJ76ARg@?lBg9KWVh93{0To? z3JDlRav($mGnO4%lEtX$Fs!z=u5N0^yMkati#n1 zZV$Vv^7bYiUonI4UJLxc^w(a&#a9o2i<^=9W~6;0XC9zDunD;qkb)SP{{ZeJ&z;lF zY{I=WeDvM>NEif^L<{K-h{K!rarO0U*uU5|0}?h>jfVYt@37^+YiBzcpW3Z|ivD0E zh#3_+Wlp!HJ^(^0)u2SRV%&&yM*(8V*!U1`(P#M-F^j{Wj9(nNGM%x^VP)GA0E2#^ zHi8FBLO{d^(K*FoxoDSpf@?QtS@HsQw8)Cm=og7R?g$KDF%_zay)@*24zA3bmCt=K z5NDtz>!^660=a^Q zzU+op$QqKYJ9T*zAhql5ClS{&XG8)fnYaX06~w_{0(9}Fj#!gDqN2Sf8P>pU!62E1 zi$Gd10XXzZ`eogOAGtLb+YK;B^3UEEh$s6>qb?yTc-eqm-P4-ey2LEA%%p7;`ihO#F6)C z4NPtMdk`XlNb-mc5kkEkQ4;|2C#tXHS9Q{e&5a6|9zwqKW7j9%1(;0gik z|4B{+Qr4$*%!MwhIL&7?H<6N0TdER5~*A)GC4aNaqI0HcW2&Lk997%xFtJCJc9pK^Vy!nqY0d2oR z=*i4bS%gg|>V4JZPZB^NCj?9H1<1(N9+AcqeN(JZTW25ggU$RL7R1Q5=?MU`O-eX@#T z>#fb9f1r=isk%-W2NJR@*vJ`aB(+>s`s;$4{<@82z>`Y5gU5iHXvnHF*-{H7fU=gX zN8he9_u-r*GN>m27V@)3xYoej^hYM&lgt#+Xe;9r$QD3bkiwvI(nN?ue%6g)rFtE@ z-QcPG>e-}a`mOVb|6_CD-%Rztwh8=~ful{}pI?oOq&E7UjK~OOK#S}2Y!WTk&uxPL z*-gbyfBStL-#?{mJ$|?>te@7_?e}iumCwD7E0=e0|Df4?6)IbX(fxMx?^4$_ILv<4>_Q%=t$f-DXEg6(+f2j#hb(6 zfh9Tbf>NntE$rB|ZWhpX9a`Tpv#?_9=%8Z~Osuocq=s>6YFcANR36ymHPX-xupvc- zS1AE4Y=~ShEu3?ppR2VDikQA6fRKV+L`J9)RbMqrT@{t>)Obld!}3<5Y^ruWv7{)i~81ZB~y&do-=xe{>(0uf2%- z=T8wEYLRR+>>{)}Z5ufEXv91J?!%N2`g0o)b{`_}^Z>R5U`x|vLTM0{=vVx?M=XDu zjg6G>iqoNnhlwwP6)iY|m>Fe?G=wg#p!K}FI0_kgk}N#)uRt7XCufza(*R{jK`*#S z4n{#49>}9Vm6&>593XlTYA}zeLf~oYo%mdtJaxe(fdyKI(MQjy)T9}R2E@!epU+|u zY73qe_^^;h+t3_*68*>{Z=Xgb!3h~d0ACm;vAsWI&yT-lq@&L zi7BtG8>B6XPe@;rZSs;?G(Ph*a1vqUHAhv_Ke13KY@%#4Ta2RUgk{u$NBXNtYWF}S z_Ph9PwrKvpj?X2i*r-Z`y38`NaKm(YA5AW150z*42CIkOg>65DB&&<|av}ooY~L$i zeIB3uYp>zjr>|h=n#_U!Y=@ty7o%UNbqF6&B8qh`HM+T7WZM~Z!0zKc+xyy}Gb{v+owWaNj3CXniZir)A?HBDT!@jh@If@ZBT;<`52+ z8Jyghg&{m=Szrb_uwTwiO$@_O@FD<++mO;8gJi753@KYmeS3x!fi@wp#|>noMdTACX@??o^8jf zlNEOpFd$Y#)kaWAL3$7@!9VmAj}Z=0g;qIhTFbceLQOb_w04#cal6-4H)d}X;#svw zmhIv@F~vQPkVtk2q(cHW*O;V0SM7&?rKxa1I&X6HX>JmT$oE9U(uB z*-XMuue7s8J3VYF>^|DV-5(v|yZ`N*?ZH20cbe>w8!%f8xrAL#w*;O% zIKs877x3Ywr>%SOHe>0_1wFShZT`Fc_VnbxU#T$_yU-)lV|7Rn(Hg>@N)EDuCENmsfk+-eaOVz2o71)(LQcYK^sFFm-IV9$ zTIB5>oo#GVYuND^kS%#h7OmVS-1^P029g7| z*`bhkNbk42Dx-l);73(+AC+GvJW!$)rHp`+Y*6B(o&8wZH0?bQkq(ULZZ`{>u=2=j zn<7otoOMEAJXTq?{%3C5J4sUFCc&qRg?I>{myoqB12Ev)HyaSJpBLYozD7dMn?%uM z6?K&AA6(nV7k}%MbKpOBeGkW*sq&-U(`Z+t4P6^xtmqjLOF?Xc`=&&Rd?i38DE^UBk zh_s`NMN@3L`)fsLCWZTG1+wV1<1KaIKrLRLdkpYces6s#P}5PCX7Ig|SG0bIl)Uo{ zoBL+*0<%$@hnPzb)Iq`mPL?#^d#@y)eZw(KLk+li+BW~)e%R>XK&a&Y!U8Jrow0gpUy2+xTfHD& zu+`c~NmBc($r7{w+aR0qp$0q~fIFPg^8x@k77O*P$QZGknUnP_P^?{&3x zvNRXE)ot75puv+{n@G}wopM-RF=&zETasrgme&PKhZE8H`gvg-aSWg752nq4mtVkx{Zm_p4^dK^ zdph9U!x3-)_pgBS%_eO?NvzYXx?uLqqb zr~rmF6#+uT#raX z3WhW_9>9nU^i3bGwt!;;J>RSfOyVy)t3SpZ}Cd# zTt2=@ZagBp(~|mucCQ7lFcma}Z9w}M@Rx!|bGvCy?VvfMx3EIZsd#hd@b z`}pur?)LugGDDi307CB;_|SWT(|Y6W4{+tR=jQ4C!~N4z4{j7!pYCFOa)L*6>mP)4 z%b17a?kR94^9uP;UuO4=BNl+e$68>Hh5HERP}jAjxW_C7uK6r1V+oez$>||a%>(D9 zDYK_tZ>Zsv2_QxyK?(;*Z55;_HwQOi2_-{}DS0B8oh<9$1!`@~x3aa$3_)WFLcDf8 zGp;;}=KkqO!K~&?njKac5bY1qz)odGP-p|OZS>WtIt%o92H_LPzRI-0-fb@xS zX*K2svitdz7@^sMQV3w*QUC-AUIvuHT#py*or-_np0Nq1I3Ci2BF^8Px%-+ckPH&#!&EI|x?|kQ@Ts{rz(4i@JRx!RFQ(K339^%o> zL!7^QwIu+W+iLCa9|5=DxrO5=$LMDc!-HCMR*^BSC4KD2oG$Q;4KdY$1qc2t z5ruoJ(WV(kolCJdON#rtjVETAQ}&hyE5y;Rftvy063{F&K~Eq#P&5|C5BlAgt?-i# zgEKO$^;v*D+KC?Wx~VvDspjdBG=;fz0v?6N6b*WMTH8Xc!D$aCXPzCb(o2EJXq)U= z?hcd;SQ@SJ26?kFwpJc(Ft0JI62K&oOfb`c2q?=#@3I!7L!Jr_`PHS1WHZ(T(@B91 z?6l1`oypQ)?EQ#7rhkJh6e!6~I8qVgK4RApr~wDt_;R_wJvL^K8i$SCnwVC%hueSu zQlJo%tiBXTRnY@ACDiuFQUr*^NK-rz%+i{8k_;FiK7s)uewWsrzjkWe{5Q;ZAt0H0 zG$AC*=BoqSjdff6+IZ8Dq$dFqejWa-DaT=eb`c>0RhthC&_%`=c94$7Wb;THesdzM zp%A0k6Bm$tmm)8G{yBWlhEP`$95B8NPb?*m39mTlm<^S8;G*z|+0a8CW9G4omzG#I|VV|qkBRg zsu1}bXG_%9S_^5WfQ+xUxkvz=7cuQ!;uGmxP7H}=Y45Gh+?WHg4%)AzRPh2lrN>EA zjDVcjGD7GNWi;n=@hc~7l*vm$Po6mmXZMSFKS4wY6alAp;ug0iTbHKo&u@N7J^wZmlJ7|VJCDBT3N0lqa;q2~Pn5|4y5~ERv$fG` zH3Y*WAzrDNGM-xB!Ls8AicCzZ?Gd%p)oFMCmwxNhcxf};zw^Q_jt|b39q{DO)udxK z5ABX!@(mj)5+U4o$Wg!}MYC6>lGB?1_|b^Be&>TF_)oRe>;l;r&_>Qm_?7J0bSngo zwb0V@oe%Ed)z7_w3&-d1bobbO*N%%vyEwXkh(~uHrg|EYHu@3fyHBK}o}@m^|D%{X zPeu=SC7i563;ThihTYBFeJrvx9Coz6Y??>}b=ba|y@oCBnyIIH#ZeR_C^0aS_PvJF zJh~3y#H;{`mOx+KSwX_#?B4<1s&MJVnQn2PkijKY_eKBzD!I*PDKcTVDwzbo=JC9x6-eom>3q`oBgo9c_d$J+y; z)V^Clb=PPdNo`Ze-`4u4;KFnZ^RB6Hdr_0aj>HUu(?6Gsb9Pxt#!8xS`n=yZdquye zgC5xuJT|8J!Ev5y2s#~i$Key4{o#-2&urUVekB=JDhIPLvtQF)u~*IXg~>~l18Bmi z0Dt$$<|Gb3^4^f;bKc$mH-8Bi-?)U+Dmf@qhDUahGV29(*mX+q*EXdzLwKt`@S2~rsW{PXU~%K!LG4BHV14p z-~`hV0d`AW&7hZN7KmY+wyTc3+P3RVKq6a;YdBz#A3QJeW*L}(`jI~$Q}F-W|M7o~ z7oUF(Pma$po{fp1Wm6tGv$t&yn2IxTi0RLjjI#?MBHdtgP z(m;pk9YMb8vIshPQ(nA>065)TIQ{JFIQ@V8Gw7WM!E^)GG~0zbwNGt2!D! z0)cz>L%Yq5`mw-;I-<7a67$`d6}|NG1$^m$_Zd9@#p~F)zJsIlXAanGP)Z|0Y4#la z0r7AgI%_^$_~1Zc@2OohS!QUR?`N&%JC}B z39=3VfIxr0!-QhlRZR}rg8YSMfCcxkt28r2pAipI^op+%KLt7In~tHylwA(~olz_l zc$VS7S4wSlEQH`=iemc+xsN;x5SLl#=tGL9HYXraf~QGX80^;4_{@d^a5j)f5d}^n zJ?&&@rM~{9Z{npFUdQ*}d>8i)AHihf(_RVe%o}FQ2mmWUU4g@&3dr&msvx;;D#JQ3 z=b*%ggY)ux0p)08(bb|hl7q`dquZJU3R03~BTh-d4~s2G24jfn*yvhlpY95X*_Dv5 z(c1rWIlifO5XfR#yEq?y6n|3`$-ICsO(_ z`AwYucfT8LXaNd?UL{*4aP49FceyydXRE!IUa^cYj(AOjQgs z)NO+OxqHxBP->wIoY?F->l#_MH5NqDR7XP2QVu->>gcMdS zztywsw>YaSRu{Cj+pwHkAv%O6bR}#2(Xs*niq?g=k@5RaJ=*+0zm*@wVO<@3br*u$M4Kfs^< z{Wo#)^ep;f%vTOMWIyESv4J_y%&_ygv?ZEUqjl5Q{g3YA%B$CKaA_m2&GN|Q<2}gX z$-GCvW`8vCKvU%+y-BbmgX|K8Ji-wnlKljhc9dJOPRJD971(kp3E(Cb#mD%fJ&dW@ z0<>T}%f9e5fI-vEiX5S3ZcCs6y>@WRK?bBs9qKaSXp98?RRm2C={Hh&p?V?2a3-+P zsDd5Y6AJ_4<)i~&-{X>*IeG+c(uzv=n@ z=MHyq=SL6lC;!dQ@bvz1lL07HR06gTLn1PMy1L}1Myj1Kncsf@7S0|Y)qO&&vtPc zMKFobu6!O$BW*oLGC3vgEl9HA!2)1b9FIEEx=_u`5l+U_FYJ^R@YvZ@mE^3McG4W5 z*Tz@BUqLNocu#p#j)15C+3|V6VDvJ0T)lJ+U;g}N^nd4Yv-W@FK_cWs!CN_C0TDR+ zLn-)7qrl}MLvX!StaLC;|6*o`A5dXQ9^Xf5yn9veQ9YKm!Y=@gB)bl#OE2HeRvbyf108l;FtP4%kThlL{Wc5d*M_gaO#5a@PSa7dcTj>eo#5xdlea8I#@K=Ha#3j* z6yB|FSMl3tu$r$~pfaDEMsG*wb}(*yN^jqHf3gO&C^&#U0Mk^MW-8g46)s_+$Uve@ z{bViCAxKlamJm&!mtMJuZ~a%liqHP$C$RhReLT5xhOc6qG26+R%3@^J{-^|KfIzXkL$Opl#%l7%qcxB;>AH_~a9evqG~&KKLD#{4O=y z9d(e9XC8`waUvj2rPs}M$fGF{2!zY;l7nEykd28ZFdhV(Ff@t0Ns2(SKK&@pFU-nH zxaz(e;X**ysx6Q2%pkPgqZ`A6y}ecrWN4r^VlRkt^MHssavjIAV9#39;wC=RpNEnom zDe(hWb0jJThWyG^L^6fUlL4oT2BBkF@x(ex60-r)2Q3Q@2lS>CUdH4jU|M>SCNd&xz zWIfHGWN9Y*hH+;t0oeyted~|}B9~?lRoNcE6Z!U`OGB-}6?RIa4kD=6~FFp0$wFr;=a7Wsl@1flq4x=YA*#>{=JL zc`3r4gd&BkTWIqh$oum*Yx zoTu?NRS2X+hQ8SPwC_mM(!MO$IJ2}(X(K(9!e&g^7-fD&`I02qLo^oMQl`vrJn{Rd z2M?ZpkFk$lVuRB1|FUUvb~=Txz4M&7*^_7T_Pq?B0=sy&D|BTW(o(gS5sB+n+U-p| z+E_kq&qj#cf)*&?YTSKc?HS!eBra;8K_KpK0P-`b_D?Tz8fSq~1Ja(6E=Us|E`Mj* z1Pa^17(4~PGX?x|B2}`1fHy(jV3l6uyD5BpaTENHPjL2o-`$MggrS-`GgM06(^Vt@khadA3-?c-PR8~>-T*OU8GX`9@{U41l&B5Q0YdZD8+wb9Yvn+9YGy2~;nx*Ku84GMttdQlrv)Vb=W|61OzJ!U6)r4b7CM72CM$=MgA&_CFL7_q0 zNY9B%O(0G8d(h3J5bGnY#Wh6-q;`=VrKe~7HgKY~`vx-0w0XQ0$0-z!(+eor#z;0! zmr?OdtMlz|{0$sFI>Nofhu(j~)f@;-Uy@ni5#KzUG4SX}sG%U(jYR-{wubwTAj{ti z^kR(uL8CdKdv6publmzvqr3K&wZ@b?w3Jz;Wn?Y>G7fim*8zf13QEfW%SLh~anX^I zg*LOo5tC*UknC{bH^dzKCBjz7;aVV?Ma1vAM@(6|P8+RAfljPT(uMjy0i((7ho1e< zBc6~WoL)%=XGt~P|5rhi^&UzFo1W3={UK%s-0dYsoNgb~odv&9V}?ZLM^2A!M@@4$ z**}M)O}W!Q_yNX0{7INE*Hq;cF=?*ew`)fEYClu zr~I9LbcUb)4}XR~`-2;>xlM-YCOEJ%jK*!V5bniVW=jyb9Q8C`o9ofzrQ6?!)BdV^ z4{`tIT?&k5XE7yjgmW-tt-wrjiq&_l!A=Nbs3}(fDfB^JXH&uYBffc=6h+_})+72_*|Uum~sCb~lHRK68-Z zBw;D0z0B>pwh7)L1F4HuqYd$FVG2&0S{rn-+y8jo+RCd^&S}Yj17UtX_40-@v?Kt| zL)dxaF)TaepO74gB&Vj3;(=I+RcW+>oYD|*WXCq#bYKwb6o^O8wjibit)z$8=%yar z8d*t25sa5lzjxPUXn_D{8bX?>+$aJ)|J3djpR5HUa6)Kf~$2_|u?CYKSAv5tcO%4Pp^`t}-W7A?eG5#OcwJOKMe1ywXoZ03bK70F8sXF8M)5t5yBv!vpN}L-1`e0c_I|2Oe*1L zRHe#XHPbp+p%9GOf^-?0lf^WmlRA&^*aWZHl7+7; zNQpA7&RNK)m714osXwp%KR(9Y`wyKbC5w$jf*b`Dbcv40;NTsppCFnwre8-ZJfF*T zm%wE~hO+vU42%JAW7zGGHv37VMUrJjP9|lf6@nf(3Dl|sNJ6DzVAYHZ$===F!z*3v*)XNGo(4C_l9{Vc0_iCZb*W;vmZ#MR9W z-1s~jU?Q@Sf&1VVyPq%FB170&Jj_HQ5Sr~4o^095PWe+3Iml{Tf-n=&oW8;2B4MCD z5}f|q8E|y4kJI-)!qM;iK~$0m2|^78`2h+X1T`Z~a^vvd{%~H&SY1mr!t#V*^Y6LM zl>fK>tFPmwuf2@Zmj^uEKU=WNX=7B0;9mz{xm4f(O~cObV$ zy!ktShIhX6L8-v_Un;r)qjwRMQ8X2I+gcjr=sAb`&P zSa4-#LW{jfZl}(2c>Amr4STWCxh6f)45a%M&Eb&U70%~tBQJYaO19Xu9fK2)0y1XQ zOZWVzjIMGlD@{{-H8Wm0pRFTGE`XO|FZnMA%5*6U{aN&-*^0`Wu9WA#H5Z(!+uHvJ zAKh|;^Z>0k;*(MzWb7Jr+g)Lrr#6>u5X3hJ;E^|@s5_#P2S+egzb(f3Xn-+Nk`jr< zm>kAIor9`1Gd2eSIX|`8ym>Zrr0yw12xVB{WgG+L7BuKyXm^G&Y}kV@#CPr+V6$~H z_GE8rqdr^5ZrA49FGoWt4gRe@pI=ak{h51%0jNJ%hF)zeo?t9Fk(Hg>9U>UoXAyVw z;Yof>d!{7AWfzG>ldw0Kyio$6BZy|+Y}Uh*KnFN#cTQ5Hiyom7rh4zIq&!fP8UEkC3Uja2W-=Ch@-P1qzWH~)j?16Ff+x?N;dD>U z3<&&{?9yln&}VqiHCv%Glrt85Y{{!JiMk9D7`b~oV7Mdr#lO6PpZ$|xAbebj4zmX0 zU5g&jCq&_=WNT&o_@4e0b|N zDp&+?@{}@`>2WLPViI)EL3TrpM8b4z;%)f4l=N52mMJ))rG=RW-Nk_8j=L>m{Cd=R4vl2}skymfTE%w?4VgWNk^ryeFi! zZQB71{LBEH@yxAL?C*9ZIVArM8xe08{sckmOAv;)RIC0Kx^t3}|A$>H>RI+loSfgs z$*l)?`j7t=^zbqHz4W{!TSzVUzs>&GKBb<>?Z(L4^e}+D!VzeCobNCH*5~oYw_XEY z-c0%L+j>7%Mv8p&xEN=VY}xI^Y*NU+Ygyy~5M@cMo#&Vp#z-=7AIwWe!hM(xqR14QlH6cV z;pPJqN!(rIGaW1zw!ygS^xG1L(JNHUW-;@Uigt+B4|>((77zoST{fm=bd}r+&SG=S z+!$7z%gO8yYJY752%JEw0v4VEE>H`84P4T64-mmVfCBWI89lKEy~saA5_J~|ETah7 z5#OC}oBx(|MqO^vQ?oe}r?o?LC%ajeY?l2FOQqOw2lt6Z&tu5L4heuA$sz2H1xc)F zbZ@X~_6TX4>{#YuCkC2oGTEFMFPxvB^7iz1|24+xVLv8GiD^>xZq&>+8&@!y{F!an zNm8VoG58>H00{#;X>2vlUptRq``cf|^)Fn<@wLsg^f`LC7EIG&yWATOd{_wy#|NY! z6oeziln{Y}F*(TB)Xz?P(9gYp_WzWvs;NwVm|U*;4twEbdk4Hj?Q<~TVf9nO78;*5K% z@g*QKCo&Ni)$uvnNJAKJSg|JGvK-sW?t%hHY6u~hRv-ssDTE`NnkE+tgL6H05|EVP zBlRUD05pLJC}NaaL9bdV5%Bbi0T1&W3eM*O95_47oY{|WfBkRZ^c48u#;pVv77(>H z8h~wbSXv689UbB34IVy{goB+90YKu`YDEJ091MID4BQ}_)w#^zMVIo4zq#m;iz?gB zQhAXXO<$7@DT@6FJSZ+=Urz9b!Fki2uoDY7ab23u)~m1waIjpO6R;r@Gbvy@b3n{c z1n@G(7#Q)B@vOesCdgLKlDDNpH>~B~s6-Ci%8&vDb& zCp@Je{H1!0?tv$(8Y8MqI{Ab3N2480*+Yo+xsN}GU;SIZg3F)UO!;3?ob3euDa^$~ zr>T7;2IwF65V;g6VB?Z$jzVpDStn@k3afve~hF1C%I{(QBq43WECj> z?{YGlglRB-k`Zy$G`h`6k%lmsWuZL;-i_gRFI#I%OXotikZ6T1IGJq(?aBu3^)>qr zXn@u1%OKfFB8t3C^J8)N!rBZ`o(&-4Qz^Pa^bU0@&a$1{4U@-U8Et11a7`i;D=v@( zM6Q(7WXOxXnEL2xT%lM4_oUPp=2r=z5TmyM;l&VhplRP5pTE*Z|I<_d+J(7ZxedE@ zF`}+D<=-KNc{*7`oW9XZtq)z@2$Cf%*(Q)PN$ZzsY#av|NwW;qE+DC$r*{YN9*1SU zGuDs!xd29UIE4_^+xAK*dV6m+r)VSS(4m5CrZl%GDC#QEIg?Q45Nn$nwncT=R;DUb z^CLY=A3)#`$;G5UC4r;n>CGf4&2kVQrG>0Lu?L+qn;#ci;~f&k_5|79(AF|76V@DD zOwvwoTYGh0nHC}4a33baX~W;yh$r9qV;uj{kEwo#mgy&ZwtrJifj|mhE*Hr*9POF^ zOUAC$kK$wBdL7^TFE{J`pS_C5*Txq7Q<0D_#WHkettFwApe6Y~0vsD%u_K-806Dp7 zo!e~Cv^KH-WCsu5d5rJ>_dmtay<;#%KA6?axB2q?FUOV&G3iI_M*`O3gUKNJm+qW1 zrP&13`He2y`FTivgPBG4RF0^+Ja6r*DMwXs}pP<_@ z021Klsu5~Q+Cz7;bXWbW^(XAa$xp=}B`r;8OQJb8HI1%QO+efa8hmD`5o2=X|BRve zw$;5|oNk8bM}PV@jx6|7TPFXl$E?Sy|LS`Y068Y)@J5I87Z6GzUz0?(R^lMt8tBL80RBI=Lo&Yfa!M27?%(A6NUIj$stAUYjO z!Uhbt9D`E8OPvHyeunr*;6%)-T9Oz2Wt~)c5N5G0fbk*xe+ULDIju0KkTmVXK*~mJ z2M0-?EU-k$ndDxxydrZ)4pfCnUJ&S}I*~Z|35{8V&wb*H^XT87 z`Zt$p!(rpU?XKs5wd}NBWHnlw1e~3%l3yxV4w?WY)K>T)qey<5rjWG7fw5sq13uzA z3?A+mquq|i_CWk_qG3saw$ZmLIiIy504fkQ`S}{7MzE}o?Lgx+Hy3E_+Q>Tv1JHS; zq`?DlyF&s-cca0}*dw($8PXhN8yfpEQWm=tZ@OCSBxEkGh(@~uXw_-hh;l#Kk#geH z%Fd#d5TDf(YKYfi`$SE`K3ZiP{i*v7f#_s^`9$y%{;Gb#=?G;hd$;fuiX1cYp zrc?xwHZb>DPQp8O#jpq9+|drsK03uu|G)3z)=%ytZNdcEz1t`oLdbX|S^Ho^O1T6F zR$dc+8BVP8TK!smNyTXG?7;k|w6Sp1Fp?B*Sds=EE?WBn4@eq@7#3uh)$tqqClrKT z;!SwdR$i5p3GHwmSg{c08Ke}h!8l>^pN{!SfMPqFkteuP@R(0b9SCAQnl_3duNQMr z566k-(i;A3G_epMxl?M9t(k?v8#HPyKsqsVi_!~GgbZv;njjW<7N)-brElSE+`$JQ z-2sp4t*ps!)_c=TJgdGb%^s>lPkA%=az7eqU0uVN;Xy4xb}=%#ZoF8~GDDsp&d-vu zI+8?|6D%pTHT}2U?;+#XpTtgocVH{g*6B6O!R6{_BYu7w5DoJVNnWIJ6zCQ1^92y5evvDySVXATLBHK#K zQ_8AT^skT5q(@J37=*{A-G(2ahe1Y74MIqKBoAXr9f)ir-&|&+qbc~yh4VQ6#YcGX zkG=yvI^j;dJ(S?){@czcdbkYfG%W+pO=e68y$ZXHU7C*z*Usas|Jj%E@>gHN@wEZR z=g!tlDA%XJXf`o4y(5!M{+t@fWh8&m6!obY?dzO7-htjY!%u$uZM^+^@1b8mX56f_ z89oL+@k7@J-m7FKwBV5D;1^A6B#2hquuQeG;r_tqa$lZ?`;0Gh=+~fcIM9oEHbP z2le>9`ibLNFb{sEF2O5?@2m>No!j3!cgdy@C{x zh$wuspFMoZmU0pgjZZxru?9f2yNYC@VVB^IO>OUnp`R7iECV~_(nXxyx`+F}{d>@d zkDp?3;ukaf2fl3*kUwf7Q`;H z8|m$LeWO{gJhE0Q(vM!_V4g&>iKvlh0}j)h`-1kgQ)RZE8l&94Yc=^S5$(Hi5OI`v<>24G`zFB1N>|vF9<;Y4D)TWqA!V9JVzj}T zS5-(q71T0L>7Zjt9{S?t_W~EnoTG*jGZX>@a(s%400&-7JOBS0#jZZc96@=)*F|3QTkwDoWC=`Yko2 ztvUpoJ-0rM7hQ3R;McyWJM*&dYz&EO2E>?3Hb)mMcOgW86B@PnhO==jwnn$@u$bLfCu-->6o<2e_Km?dd-99tLtj2gzK(9TwT|G@O?;suO{kMZDl{zVJ^ zfUG%m2~=T1i(bk4$yJk)bZt7%jsxP@v{a`l)y==lFJHo6`meuK_ zm0o8dCqWj#Q8`|0F2~)5EkgP!{HOX}UI?;S_)ZYfv-}(&dFsADGQ-X>D8q4^q4VU* zydOZF?3|tQbh_8nNFbE!Cn4y{H*R&k4;PXJAp%#2gOrL=ISMB8=>PRE%sc-z@t2A6 z?5vGM7k{(SdyKkb6erF*uXlqCgZ(wl6eu|a2;40|w9hmGQ8W7NVAh{FZCacF>7P1p z3o*$#DR?Y~9|>VR309h;NH`&#_h$}xLQx#e6Q&peFP~%EoVOJ7Y0EJLrsJ6XKtq6$ zq(y&W*JPUlr@@CZu9}=|**TWC*@=+uXL(riC`6jto6TZ6Q(N*7b_cf|pKXDW)&h*b zr7zoNL&?@=v(d6Kzc95YSMZnq&)>qO&pe06S2s2o?+}Z|>!C;iVdkvavMSAw z7M+K`7#q25VHyEuzSZOGU@0{P|NVyp?*8~b{`4RGG}FmTBq%H>LZtq5Sj7sf#$ju+ zBD_)KSr^sKwH2|?r5}&>{d*FiG0pD5Q=-gR0y2j$E5mfzb#rs!a;=1qvW1et1iY*Z z?E*T`+g{>JSYDd0@vH`)(<*nRxY=I1Wzxe(sM7rO}U7LUYwkx4)>*^>)u62n3V zC+m!>v3uFtD+tmG59bf&<%S3U;*asmrutAFdOIRCLrcy#$}ezITo6A+Fp{}=~C1`;|JQ1D>PbJ4K~;IorT z%vl%S9Ep6H#IhN!oi>zM7 z+F=6;w#jIu^)ZgAe7Bc@lSppcf19v7Wx|&Yz@u@HShch&G4NH5!LfN*pUFh++|0U6 zn%=pU7+cViLq}edw+MT>vpKLW`-rBDPSUL^PO&J&(AP&K+QU@jn8|7s%=YT9oNk;d0=%^o{pzYl{ z8qW_Pu=9Y9NIVjaCP<2$nx3y<=RLca+xC55%@bl~he)Mc7j341ag0UpU}XXU@eGoZVNkF-l2cS)~AJb7fN0aVOIG%(4c15WtQes0hRRZ^^q>4q2nM zx_SWSw|{lI?|;CJJ9l83^}y8tsf8t%Wvqk4cZPR#(Y_)x_y_*GjDo19G77rEF)(_MLc5AP^Um%{GI!>1(h*&ga`Ba}jo< z>;R)JA;iOZkem!D5l8_H3dw#8;V?SOK}cRpd&|oeM@Am_`xA^80qP8ekgPl?Hu`7z zrx6gk@2aVRq>s4mE0!7T`lvMen)V4y8#_$`9*m6-N%a|oqC3*r?k;xr5AfvY@8j_I zzeltz?elhBDSRa#Y4icquLc6RFLeH|&DL_OE`GNg!I%FxU&NRG<1b)*MsK>;Na;Fj&451kN?qIczEM6c;?Rh z!4XaKpe*o(e1qxMYIoZCyk#t`M&rYYbz-Ol`7oOtP2fV(piljLqT4?;Xe4^;IX-ws z-2Hnk18axxI{;EW#t;t;ZZC>{&l~eZ`wZstqhC{c_#29^hYb$Ki-?GEq2UWyNdJ9?s9_HACUx4!(ZQ!8czonypsy>bW zfAtH$fe&xp#nJHzDzKW69AE=83Gm_a41T2~K0yg7g{Tc9r;<&^aZ6fEX_L|WTdd;c z71=ylT+x=D9NGrV0M1s1cHAeuAhKMc%oL55NLIMt%&$L4hCtr}yz#|0xqKNrd=a2u z4jO~T+_CX1*_qLvl>kL-C19J$b{+da`s9gukX1Aa8T=FDvvfI zVA?gqf-HY~hrrNY2c66NbNpA_iSTOUuRGX1IFIA^Z{XhV{!4^AQqn`oqf4?&#>H)V z`6VO{4dAvaCRg$`4)%#1EHCu=^8fZr_~c*z*=z%>Teq8XJ>A8V+hUr+1{xmwbqreJL!pzkm7ygO zbwe8Wk+Y&o1rpdZy&zx}E-SJ7c8PH{bLPJK6s8uP{!LH)8+8XC+`JWjn#l`9uu*HH zSt>7@r)sTNHmqcL%6`@OQ;p4XFdr_XJIx#kc{m3OT`{CNKo3LljpbeMZ%^*4%T5+NlCP`MYyfh3H;si4f3c26 zQYl1(+o7V2P*BPRh#8OOi>#|>jcA)ixB4T}lXuBh(CmU!#0*T04;waSlaBy7YQAd( zEJw|WR{~AU&tW)s@ghz>yoFo;=%3>3(GyZf*$}6d0PXeSV7mMk<`pKp_EV@`jR5B5 zBwTfOe;42U+uy*a{)129^yN+PKd0VOiiypwJ9KI^h!Z2I&mJ%vWJ=Tey2}EnqKI~s z>_w2Vst4dfqhCP#>)5hRN3pD(3X)Ww-=|ol- zGi}kvIH zo-iC}?Lt{Fp{w8?kthWpb+A1hanDa{AZ_C5m_%f#{<9E5=LA`CYzm&etr)^u@Mp)+ zwf@Cut66DJFFVVUu+T8Yn_4tq_+ATf{B zqzS`!X&V4ik~7!WGP9tSBny`0X!iy3(QB?vn?%M+m=qYG(n`3}7wQGKvk~a#nB3zzm zYYuBW`XiwrpCX?#TnZ22d9el@{&FWFaR=Z;5(o!QC4MR0qF#_eNOO4u!*iD|;pEPJ z-29z?j?;&avOEe(uuF$1R}3@A^{e7Iz->rsluVF#-He#8p2uJMJHLUKzxE1FUfRXc zIiA^A0)sE+DYCAEq!Ow$2X$%uCWe#ux9&oltmELD(-BUVmmU9mhk{!_xPzbkkMFE? zgcyl3?W76Gb{xS}5*?lhNzlL0p=9@x0#Ddw{$eF08cDtqatItSIz67WdIC^zZS9iI z`!S#py_~?u`Rv!)+pzYt1N39%JS`2As%?f?nOD?}*W_d?Fe)W!(G_5y+8oS_m?-mQ zK(Jn|T`NYMN~sx>bS>DF__A?M^7-OEAcmBZ6uXd&)@XMc+BwzKUfzok>+;5vdS&gV zxTdn-{@P#1{@w+=^=I!{gXGx7z=7U@TNP-GVw8+qH9j2_3Vp_#%YlN?Jdk4pAqK=f zJ3!GlxcM~vnCxMpmn1FA)c3S$%`?sEzvU75#4IsRiAK(Lnb#oMvs{m$d4%rI1u)(V zP|U_Xzkb<5fJTwxz`CL!p2_O7ekGnvJ{DPu$0Qxrq=9*io=q28XfiOA1mtj=^WRO6 z%YDvP2MC#u6go5|!2xuNy7^MmpF(#rdL9+-pT7Woe1v=d{NLi_?!DHxB$*0|jLV45 z{G~b;n^t3w=p|WuJHiG1m>%%+t^eb%zvwR~lwn&PjtR4&1-saX70C&= z_*J~2QvFfm)(yZNMmxy|i5BmZY{QUB3V6r+D+o8RMmx%RbbJJD!Pokc6ywW%uOm)Rsb?YMI38ou)RU&H%1@3gglbhP_>(lSM*yfUr`%c}aC5(Ou}sym`_v27eO8&@kWoQj$-XsFNnShw3O87?Q24ol_bKA$L#?-Uncf)LAnqCw)=#{AT3;%}wy1 zAMkT^#A#mX3MPpbRZ9A5S!>yB9{m)(1@yd}B{YMP2Vb(%ewZ{IynZ*zh`MAWIx9QYSX0&;$ku5*XGgx+rM}pD2`-Ix(g`4)8PftQ{Bi6N7#Mu>`G*ouo;`^Ui^Fj@e+;A z=vU)SvTq?)EqN$7;2&a1hdB@Qv;SnnKxoNX(O-xRohejk9QS zBALa;Or}AyoD?Y;Gr1`7G$w%}V7uAX(2_h~J2xjNPuykqav&57wiY1WYmy#27;FU2 z^h#Ku`U{9?qnYVm^P_2#TBOG$0r{LN0(7I?xr6iA-OzmZU;bO%|KlH^cof`Ii~k1^ zMJ}s{hRwPy8vWUKj4`3_S6-Q(?)Sgi1pgQC=$hhezAaU{9wT8?cc^N*CQ34a=BvOO z2dqQhzhShuv^?4-Sty@qzB2Flzws2``};q{(Su`{HM0M+#Z+(h)N#Eo0`@N`=H7a_yq zk#!4VjW>ea+!L)!#%N-|@@@o^K1U!la2p%HdGixL$G|;NsO-@5_fgBU*-0~gk#Iz1I?cfwit)P*IPmaPzLun_ndeH7%uW@$gM-n` z6Wx;Oc1e%2P0pr>Tj-kL#+EdgMmYwuGmf!Ual%9S ztkVtpj06eYK@dwel<`Rujg+47OF9zyq^vCm^A$Lw-X{oKosP}F-6SB5@I8`)2X_F- zkRx-9bIItboK)v+BO_NLK*@jvGm@Rg4XD*snvIyn#8XA{Z)D!=x?@CdEbxAF5S~W= zdwb{c)-T?1E8&dF-nb?XGL{I06Id67QLuopTmTsGX4zHHFM#0M70HQ}I_4oJY#C~R zsvVn&aNT*#Qzu+iBk@9e`=8s!(5490b;Qd-^9*%+Y^I5Lxx;|zL1ft^gOS>b5qz-4 zAcRn5^yP28L~jeG1ESSdLDPN!({&S00=r>|-?ZyKW+ zzUFg7$StBFJW(@dg^-y2DJ6Fk-ve9%AiDC7b!4Z<@|Mf<7cSxHdpB_FpZ}ZSooIC@ zP!fqJ%+bClNk((6D-;kwHUL@{vsn<05!q{Bc@1CrPribKH!k7f<>}GBLd>Yki}jZH zcXwb3fp`U!m6_}zgq8xXkPj%0#@!?X$XVv|j{h5{`0+pd8UE~Fe?a;UdU-a@k~d|! zl@VmJ7eqkw>l(%ENioIv_x{H50jOK?X!C^uyDK`sv+9Nzwk?e zkcGoW6;qdd-W(D!ol1t4x|6DA3B%NA$437q=*4g}B~G!nE~u>4>3`wC+UUEwS^NL$ z=Y9I~tmBFppQ9W}yH zc=~Hh>-_{Yvx3CIRERcKE;~=sgNdLjSIcaJ22ZKo)?8Y1R--@9qxWlWX6WtOsA<%t z36aDd$bGbHuk~y9^GCtCewvAB^rz&3+A#n%dD}=vxgRW}@@yy4%T$R+jS+waA`eKd z&q~kC!N_;Q#D0)?U&GSQ0J=M)=wmu|h8_&3j# z+HW33C;R3)QrOcq8JNErjmjJ$?(Xt4p$|HICQXBWcO>Vv(ySP$h2(Vo!Jy$D0P{;P zLXDWfI8a+b(|ws^NPy|BxFc!;i5he>rSgn(QVdO-6NIrPngsLC|GnMK=>OgKoE`@r z5O31=nn&hryc$L!<+s&$B;zcZr;Vsfy6$o-!D}%Rar&3@thAWUBhOk4%lObHE6HoV zZW`#_db-GOxQiqZu+fp4S-G~FjVaF}7>Dt2mf#KK0EqxEt7F`*C&art;7RhG#Q`%n z0oatppkF5q*v)jtbZ3);%a!KT*x_z+|f{2FHOK75v=b zmangGM*d&>TVLM<|BIWEzxh4a&;IWty<$>I7yx8sNs=KQus`JlL&JBKtU69ZAe|je zJN^$21^0e>A3yl*pMm|MCIMcFPOT0&Xy(Z2XIrA=(eb?zt|fRWd5!`p@WlE!`%TRF z8AaOsWuO4(1WDKk5QEK-U(p3L`B@^L>8|?s5=t^cE~D{T+fE7vh!2-~43?J;5aes- z#h!DbmWTw+3iW~M0%w}}u7~>TfV0x9;i#nOz}Coj&`hX;JXB@j7C?k1L)4@}P@7t# zia(epB?id08v_Q7_Q6>afsQ$|m?JO8)yvP}+>oA&n80 z(dV#X%5ESWfR%L+JW4VQhv!iV5F<H*8pJ@nB0M^`aUisSB&5$rFe~q{-HPyQo{+@jdh*1f?44b4*!$ zPGq-3 zpFv9=V9>*tJ>jV1EPi){Q>&VMJFpUkaV|su%+~;Pfm7MGlyG@TvU7}b<+>m))7(YeMow~x^tnm-qL@)Clut428_uk5Nl*#{><0#;&ZRH`~In3c)K?%*1X5v z9Q+Loxvwf$wA4) zQ3h9+qk(`6BnL825T-S>!S(~RKbk?ybUokLG}428td2z(C7UO?XvU0?XYvOR>@!9t=dsyPJUigxg^Sod9dYBI{{bFt{3HczAy)+Gb;U~l4^n-s z;~W0*nsk0VtD;L9$e-zX7E~lh5I0pHkv8=Q(OK`g0F6X z<6<|t3T&3prKUC51r|@|&mT=Y{-59n|IeS`>HVXGi%d-kh44n8`~y5|=Bw=OcD$IC z6;#>SOoRCtO20}%GWRncJW&D#yN*_MJD&Zh;g8M#&7YpEE;RZc9ikH*^>%`NR=dCM ztR9Fp^e(Y9n&>soi=4GYn5Fob{MGs}dj!WN!pKE0fqhK!@|*?2A7;gUX*mXKxrvS% zF9zKJj2W=N#v5T6fB{fmK<$-aW9O{z_66+CS&8K7;IAXaMJbZ0z#P}qdG3#C^#7I3 z+W*blhraXQdl1ChOH+MZ9pd_rd;cH(Ydp34WhcwJ_;9z|5rG4$|ax{T6J+^%6twBNA6{nlm?kmcbR z5eH~+3dN~Eh8y2=GYhDb!h203ZQ6XDa|Dq#=;If@O)3R4}4>E~L~ z9;+a-%l77BqnzblZ9uj-uS|v z5yRs2j@x%hYv~6Q?ssn{f#6pqgyo+?e+wV2jJ!@!5}m2`^lG z&7b-g!7+?1`_=-A>T3^3Jmo|vD~VrIn*h=TE!82pR#Cu0}#S`!uAF!3D%nJ zldbyo>66~f$2F8>GV?yhx*gkq1Jnv)YLhn49)Y~dled*oh?-q1Sw880`7(BT^J}Af zycc!pa6!T+uAyqoYWtUK^{2{H=5MG4{mA*Xn2C_54lR@SD%&1YTqFyBgqcL}N%TeM_g z`Ztc6I8OCi z-dtDPV3Uuf3=Zy05+^8{uAut6LjmxuU-|duwg35<|6(sQf< zc8tn$Ro@{!EeE(GC)zJxZ=!g6Q*ZH z0ZjU^K%?ZK+T=3r-d*eg6gWwE(2lMp_^R`pj|fg|x=S4-fM6#Oih_&YwSk&C-9wx8 zG254%b}xFQC-yl&wacD6jzNEtyVXY|IL?gAK!KE8S$!$|Q#r&4yW)Zpm@Ias*xlL1 zrSli?=-v15{$}c*4Q%eV8DGX^!xi>_5G|RHyhK^_&b*aCGku+={lEB|Us^`~7nJ-z zyoFpYP(fntfBD|l-OauheWI{CA~yH%uC2eM#26eaR2Rl_{snHR*iKf znb5!sbB_)h)%ndm(N)F|Xf`n_XU`yMrIx-eIY3}@Sa}d6-jC9N5-A}O_CL_mR$)Gr zZ72M{zFR%$OXV6p>Pbzur+|Iw69>nn;CG%eYh^4L3y=bF`YcvrG>+vxHDRw@k8m-V z4Rhnud`PL{kh3J;NF;{v6Ai9xDV=I*WW{7%CxSe^qKWPV zauW1-JDcOnpZz*sy#6{q_~=$M(numqp$^FHhc_vLUhbhRp&0@*EYVD7v3 zC!iUYTO$b{kOG<}XlXo-$#JSJu~R#s8{I9TGVyq#pjvdi82i{BwByN)^~=5`2tQ#| zC2)4TFefA1it>t-!4Oaov5O4+Rmw#S_RyI2eg)Va17y$D*LHVyaBaHX@57sT_kaFp z&bxI@dA*ZTfyia3C;(2t3h?9>f6({b@bv4AufDzn|JN?y;dv@zxHXG?!H6VJ5S?29 zaHJbEl1Wvs(0qpDUPZRW-?NX#vY^aQDZ21LaOa1&@o)d$_Zpo_L=gX#e%C>-1WxQS zAAwEFnOulP!+RL$q_-4-s1Vxvv9@g>;uza{VjJ<<6|xzreJBqch4>5~PqbU7!UIY5 zz|PBzhQrDKJG&kePC#QILnH<5ZpE5;A}Ozk)-RYDct}afMd%WppP{`B2ogkS?D!6K z@uocL+lcglCRwOnC$$c!hlCWF2#^7h_w;GU;g@k zfWyNh+(b0Sol#vh! z5IsKPaP7F01)UbSkTOk=kcw zK1MCe9l>{i3=!Fq03k<}csh$4q_p2Q8#J=Q$;Lh4*=I5R<@L%j6S9;GdNMe|6Ab%q zZ7%0ye~$sD(P_!I=NF3hOo;$pDDTo=2pV<mlU!cB;)cFTVlvKxya}gYsFU1T+%LXX4CR)wFzVf-v=-=1=Nu3rf z+IP(jEbU6^Gk&Zao&?Vo2N(Yme|u9%R1)3ZPP1IX>oC|cXQCMd-~qy^(-L6)ZFD99 z>O@)p`3N90eymNSKHEqNjtGE^?+8HR_NG|^dXSLc(q|nb+{kGiwd3E~Gz^OMZ`x}D z+P?ss<|bK@0%8^fE~{!c{zcV;jr)iOAlA`p$e4wL^kzp6_K@1Qr0(sr%i&EHn}63Y zUxPk+f_HxRpW@`VVpBL9*C>_J2iZ{0RG3 zdTS`p2Bzf#vZow}wj2_O7xad-6iQ)n!JW~Gg4?wZBQ(<;gO_MnlPy_qb)X#hqJ%Tn zr&k4#XDp)e!NksPGAf%(aUeDt$>%(KW3p>Rv#LTN+(*;oB&z5WiQIm2Ycf_wFs)!~ z1c3R@%}oEM(f`9o$GCg=FaXD$v(dscB^nA|y6@U>hk2oD8v>cT1j>6-$>x7 zVd`$uLgFVny~v#PL?um{ci<#8TzAv=s~0X~=jasg{i}b2r+4p>tl&D0DcTHbu0Le2 z`2Vr?=gpU0*OeH!_RCBpazJ7tCXoaw4gd*GA}LWMMT(`;SawyrqoUhkm#baw?k~_E z!C$1KyV}wJb##Oy%H1WGY|T}cD7i#ZBr$UkKmr6YCUVZa+wZ-5_TFplbAM2U!w<;c zd+*+J_Sxgw`(V2a@Tvdk)41#X7jgN5A1}n^m~mIbvUq?02}iWR5H<}tnpBw z0P>3VoE=Jna$Gup0q1;&pZvu)@%q!xmW_>L3IsYsmLZVh1Er2B{)ry}e04}i`SiK_ z&f`n}<(F~cqxa*=J$8I%g0*nyksk3Y!}Q_QJph9J&DUt}B_j1TBF)dnNDm^Y^7Oz_C}VfKIfHt^aTZ&eZDRZyg3=hJ_XdI+9!DGT0$%%-q1;=6JJ z?b)+Kc-ZAI%&rIwo8;Zu$kAmL=2+nk+EW!uwkBJp#+I5b`4)Hu^3jS;_)mi38u`=G z`?_>SNt#Rkur>2}IJPjpIPxY)QD_{8rk~*~YtmbblwmlZ!Ps-FB=kKz+-U^^O95fT zOUODTTKL z()o+H=hR(z_1}LBuYBuoDF#lC|18-k{#Px`FJ7X2uj_&0)RMFe=k7g+U;P(f!95>) z0PkJ`?pWy>34u_3kRFO>YC}u$QMeewr+a4rQa}YE1>E&h>17>j@Mq;eM;iN&=P#ea zTi<&FfAuf_Mr>CFl{Fn=Ltk6Ev=A+AIK6!S;xsnn5vjPSd%z~)YcDk1HU1g5uw;-f}n$7b9AHGJzX-UzBOypRpqJ`!a>8DPP=g6lbR<}xs03;XXm665! z;q)?Q&IzQByf%Sfc{klyB#C}wJSjX=C7yw+^r~PAd3^M(0>@P$@O;iezK+F*%e53p z9z1Q5A;|CJb0oeUcGZ@K&O&S%PxFilDbz^Y7x8R{JaD)^VBAG4eB?myuG435?_KBd z)(bztOW*u!8pkyLt;atnf$}|G9hoD95W$)r5Ft?UfA>E5fBC=qD(-vg5xje!9T!84 zgmO@#!*;HVOi~3A#0bvKB>bbWVJ5w>42+Ab4R&TZ)_R5wUVZMqvBS-uU&GV?|L4a= zf6OmPaaAa?qSp8y+eMUQibt{sCFArHMqo!m&zAa_juW223op27cA!niN@fuT|^rT-H-X!$=Xh9T%QkGUoG||0b}36An}^Q5v;?p?!Jc#4Cp^ zWBB61KX!eIh_D_&pMh@(YuuX!uaok7NoJDy4(jzM!Z%xl@$xBxjg&0WB!8yA1tr>^ z3K@xZ|NqxNzn}f@rw~7V?e)q~V~hyi07sx-!5mH<&6eUlMs;3{A-yCR*Lem{g4BqV zq3=bPvyu}l;$qP>!DWpf{-JiH{Ng?M68B+6Gd`M;;eUz;f*81*B;c&q_yi zB+n~QaH$cg2+JmIHNwYlY>oVO$l?_AJ_4nh3|XA>(~?Q4k{ekwLd#UK%eXEfv?pVt zZ=YbSc!XXs0nRED%Mn0Q^5u7wdnb64++p@Jr%vJFd+x)vS6;=7fAl8^zbgo)ui*YqKD&!nwGia|xV-(zm?xNX_ zLzn}A+ZVP9wo8!?72R1sAbq#-*_3x3AMbk;&-{1K;k9R8YsshDt@JAXGZw>^G1+lO z^NCE#cJv+duAI3K`N>MdlZK7=T(gP<-&gbqlSY@~$E$BkNYIM4#lUddbUEji#BapE z+}51wdA5mTCgb)C`PO5B*ON_0KQmoP=M@Qb9?`ZQ5+!VEoAHbGzV(^MJ*)tzOIUM~ zUSDi4BZko=S6DV^FRsMMDKK@O`wj0}@3TVSV8SAI8Rt1LHKPU1!;w#a?DKfveUIV? zFTH|{FiEa05r<;?I2qxZ(jx)e&;sSQyzb&CUvr!)r1)6T=dX2PAzge%<3tiGq$D~3 zz(FVN9Tex|^KQ&$3((Vn7meGz7GXTxrT~J2J{-8_M=@LO2`XefQn5;&%*T51Uy0WD z{Zi`Kju_QKskwY5S3Y9aD-{OP7m)hl0&X;!Zlztt7i}OR>?4W0P7ZvmCE#clT)~zv)u~H@eLskZOlVfE&UdfU1 zyZ<*ViPgt1{2#uE2cCEcSMS>$XZ|Wf$riF6hc@1hYI&*Y!lQxP+#$zs8LM^9&>7#P z!Fr7kQWuA|$9?~I954UH%Xslme^g1e4PaH-3Vr!GHUev0g0aJ`K&FRFuODR9ApAS+ zQ1VOiZN%4WXcjq~gGu5g=47?aDdq=}u5W;DRUHW~=uv!Q-D1-OKGU-9M3Oh=mGQtQ z%f6l~s^JU1rJT{%X2+jJJo>Mv-mK>r7RdSvXKh6vdEBl3v-^97Y-hdAcne5nZ;UF^ zlSqd1SfU_$gbctc@gxBh0ykg{`quKAw_x)EhVlCKFZ}j#@Bb%1dkvcGf{U>Y(2ZrP zi}kEm9&Hs$BvlT?$h+ZI8p5D7)WWs_k~k%*AaHUDqk+_U?D&Mmd@>mUm>Z^w)r@p1 zEO;K<$X}J5-Jv_kwT+UM_~>ZPn>LmKceLa2b3K>_saH=1Xu(V`?fMD=p;6%E19RA_~oT>0UmQ8%z2OKWo>*M`E z^B3-Y5Vzla8{hxezmJ=TNBcSrgQj0=o=HU+w=1R$@rdHBS&f4r*XJ48Q@{Ob{K6ML zh#L=_#?772dT%c$5UC=$&C=4xA@&vz-N2gEhF1b!T`6=EI;plncl{Y=CI9p90B=0= zI-dDAFBISJA=|V?>xGz3oi%QW4}@>(P#AH{wA&EN5p z|0W6&IBGnSZ?|L0dk%ta;V`vlG}WSEY3GZ_3R4&2ne)JmvaQf!pUEr#I8OFiqxM??@4YF*t2lM0#%6dxjW4`=1y#xF z$GiSLc<;l<*?$%f7RzFKB{qA)ZrrU;0j~x=-fP}zAKe8`kRAi%)<#M@wLKhCGp}QJ zYiY7gIlY#IA`nWaq2JszuLN62oU9ya(LM5Y$$4$L(4FT<%;Is}<`~clKg4ztomJKf!tB5slQl_OeU4Rv#-a>(UNr2Z#+2% z7t(93Reql-0EvKEMUfT;`T9=_*(;M(>5EcDND^U4))a4pMLY;9Z% zL@$$IUipmJW3Xzii=I{dfBb7t;ECVrOy^kyV(e>ag#B(oWiZqw50< zb!|`oh018p@$XX*g8V0ViMX%ttehzUqinV6ZrGm;BRwcLByL60i}O~rpu?}n19yW8 z%SQSP-%^=6LB^@!5l(S#ARVZVYx1Dl z1+GJbT;dO66L~qsZFNpvF^LB*+=ILKy#L)_{Wacv>7^-ddG#)V7*NpM0RNukQ~`H*{oqsJ%wy>U-n9PhlkXUz}Xsi3LMwIl=vepHRneaQQePJe5b@WCwAm@K1my$K)k7Pc?FOYYqmuckAy$2 ze$NGeeb-n^y@x-rJb{-$6)4~kZVo|?(8E8u@KpNoedh%B!kqDJi984Vu^23&wW-Xw zSgD{fd8UKL_E3bkD%YnE^*yfUjcTk6A^CPBKj87lK7x;a;FEaqX0AWrBnfQJfSR{%tkAP|Z! z%R*qkj`83=`QK}2@Z-PuCSLx|cLKMb!ia(?}@o6({i&WeAGRoU%M*^CXqBE4QoC?;Ev+==WpT2E4_4+M5 z|F2)bkN)zB+Fjx@-SP;`y_hO#`rvW3kM9x|A;9U`G*br81&Ehs8d2u zkSQ9|{a>8>u3Zjoc07;_8j9M~6unwvt|D;&DV!DzY#z(?NP*Q9cFky|e6GH1s^TmY zptWda?JkI)yB-f(Q8(6IWmIAYs7jg$ecwNJg*FTJNizIZ3s-zn_ zi()D+s$`vJgXR|1m;4i+$1dD;H|{@u7O#HyyZF0re=A3p{^ibyl8r_H7xU>;lw{Jb z>B}yDoCQC}&;ITFz31`8|LjXR``A5r_k8FPQA^~QQ!N(n3BFK%>5VVq^}Nv+5`9)Z zu|GB4P(`5qM?DZVcIH;CyJ1b4gt!fRHnhk15eYlS=!iFPqc7Q)P&_3?;rw0L43 z2KIo8jE*tN6YDC9XeYd520*z>mDX39LI^HC#aB>z#u$GWQDlHtD5*3Sc+)CHoEhQS zN`jXYo9ZxeEW2(7fnI1B2IghAQm1-a+6VJ^?| zAd!ex4)Qj-RfV=9fb<$;#_^>YIB3hh*Z61reI=F`y~WB%N^aC<@Sawqm5@Hu$gafK zfyZS$WM|ctwHxYq3n-+e7V)i;-RNSu;$^t_GJ`=!2U`$lieu z{3!Jd_IYJ%aPK`nz!6NcjG||$z>X~xwSRP7rhraBH1Qt!98po%NDPWX-*DHdGkEOc zy}11HPw?V5|1#~xs}&RW$M`@MUL>--TKp#+`kb*01-3J1&*01d;%m77sYmh7_^tn; zRsOt>`b3;_Q4ls`8H!6z;*LtQ5&x+82VDf6zYx(_GA2Ff)pa{Q>FL6?9WKA{HvaPe z`PaC8<2LbHg=ZGeG{TBCbxw%3kCNkPl`>(L{Gils5N*T_w9@Cb@Yq_3NbE%EADH=# z!r60Fl|h`T1P3vj9eAP#Xr1F(GAcRo%1u_QBG=$;(fz;T$2Qz%4H{cq#tBfGt>XlM zJpfsRxQ4*9kV}W>gzZM92CKpIT7+OHROM~eV?*6VFas4*d{poTl#?rnH#@+JUAh@z93&bt;JI#~5lA4o=J?opXB@kurvX z3)90zf46>i4d4I&J%j6ST^oEO3mU6ob1?C_4Vi!@pV#V?2M)A0PO-J>p~hNKinkdn zx$}h4HXOtx{!e0Z){7SK75|gKjAI3!glV+BWqtK!;BvB2#dOL9j-kT$4M8<|15{X$ zb{RuhhW;vv+|IPa;4WV)0G4da{+%kIvBv)eNYQ48L>BjQEK(HAxZ)@Jq z-FFMj1h62#G6;D&RGl2Kd|y|_3>%3eiOsK%f9(_a;IIB7uHSzOxBLA+V4~+58Hq*E zF8SRh$OXi}co@yN9$Jriwnl|NjkVI`Z3Pe>+;wY*-Op~~>3{V!e)64Hs0D`mEMj-z zC%B}za`x@^%HrJWd-{W`UZBHe;q2nSlpNukF#;4np=*U56KOISiB|Yfcn+bf!hsbH znVlo-qsGbP42?-+_@DuKBPsMB zwqR48t{Jv-mz7BV#H|PJ9IT;uqc-fUs~yN{=2JBTcvhOmMaX#PC8}MqCKS@ItNT})&Rqi-e%%kHRF$gbFJ6}M2!As=8|f)^KOL% zNkj(5glZg5?^UV(Z}Bo3&h>j1B>OM7mpgd#>NPz5%1?0h=B;5vtiOnG;xkl1w2sTW zcRZh45I@E8g_g6!mWQOcGo`~{j6UxM*!%FPJVgO)9DG)eHuRn=JkG;7?mcrip1kxB zF78g_)ClZj(8Xt~{mF%F?&&Y(Z+@6hky<3s!`EBD3R^|#)} zcmL>rJSP7&bk;!*TSv}D*?Kjwu2<+7^vH}Mj}IS+LW6uaR&ow)Km02n!BhY7XL0Az zbGUXk?6`th+{h$~DM`=6x59IxP+8-jIBYTrj+)~1>PBI3C;E^q(tnZbt^|)RxHtaY zR~EeS!?*ClpZp;0Ym703IbGp2a_J-7)J(AeOppMM!#vu%K?dhT5L zpEwtw<0dWVr6vMoPyi6$)|Q2YejFRReR5v}JpAAM_L*O^#;5ZT#3wXi z24B9R?_2`b3?+eXQ$ayojqkzN;a~kCVT`@A6Zz_9)|b&3I_}y1?DC@@`ULK~|55z; z^UvejVQCrTngna%KO8%8Z&&sd9N3xkk-^y|t@Sl=fmQOI27cO!=)r_R6ev=g{hxFD zJ@l_U@P6F9{5GEc!S`_U_D$j~OP|IsC4MSsJPEtBQR8H|5pSx`Dg;ZfrO0)x+qZ7w zl^34hzpu;wZ1*IX?6%m0`lSyAcb^7C@r4iFrwG{3?N{V}^Z)jHIQyY{@zx~_+;Va4 zrVb*`;5hVuf)7{X^me>$=h?=9E^HsI2yVYI*5P&OZNnRX``Uir|F3ZU@Oyu=3G+by zP)AL3HN7(i2LD2GWS>~Aj8&cBeJ9Bb;1z?DmiU!l`erj;D~*mB!YDd+xiVHQwP_xc z_$9Nl%8q$sXOfVo4iig8ExIF6U*GN@#mZLZD+)eS^+rp2DYI4Vt=!g%=7wyCqnGaa zg4eP~5AYgCl7Dy&6Q1xXOFMuE}92xwZs_M%Pzvg?TW zx^&?Z{_}tGf5z?87w{J^zMz9!jGyiUE&@2IS+un=Ls-V+lWh$ePPAzjqkTUDvg&gg{x09Y z!@|E+vW^B$;qNkzQJI-WoHGdZ3xpcwi(!6_JEh6Am|lEBY;UmAa%vI^oioMC@R_7AYU>@i)DU zbi=;h#CJ&1c;IDe&p?5|SbeGO#L^R~wc-buiYr<;k=)+hNRFNG;%!a9o8F^E5oF!v zSff2(6QS)&-ZEpi3Nz*WFd%-}=GWLeVFvP@Y>~k;s?0bcMX7bBd<5A%u)i`&^;YbXDlO!3H$iT$JM9mFE8N7ys#(@W3Z4 z`LpjQd}geaA0@S%hzwB8I0nT5@w+8o%#dO91jWK31Nksxm$gJnFn9LWp4w~czW=Mo zhy0Auysle_?+H}iFd{f%H}F0^GHk*+jS*r7i|l7r#H=*UZR!*8lkrEV8J9-Q4sv$k zrQ&Qm`ET)m=#v+}(s8sz&8r;U?uq4I7!t>WK-xJEx$>CsVhK;{fg-@$>&Ed<9b#{u zb+mQu*D8B|jIk?bWPecn`_w0VW>@U+zCt*lQ(~wKGB{DlUc?wVl|~Zh{nv?!X+Ca7 ziI9&I^Jp)|SE-U6ClsC3$A$k0^ROQ_`cFP*Eg^evUt{>l3f_{XELn~BoUsI+02wUo zR zN-Gw;MhC0@R#FQ)mTc9k4lmbjZABo@BZG!tTvg{dyi7OVk7FJ09vJ7=XV5RyKXpV7 zSM5uNWvRehm#LSL#8I~&hVmdbWB@Whbn~s8HE!t29oQ<|hD`vM%fJ^8#EGd0E9eG3d6@XvvxD@l6YY7!%+~q<9v4?^lQ}8(!Zh|G)YF z{0(?3f^8YR6nkWQ=K&+LwXY3&vP-&x+M+&l(eH&p(Z|&Xl6ZSLTp}dnVzdt$j5V;% zSKC_5W5h23W88*2CQK8L)s(pGI!AgxH_v0fP@GWg^E*&00jYS76;r>ZqM>|xZLhz& z^D3Z{Sc@svCpPTjbRl5qf3UD)wb<7B#$a}y<wT1Vlef+)@k4nZ{KX!0!AJ+p!BKD;PTdbb+;)7^M5VI}RA+-lGWRYW!L8;B! zM`zAOGyd&%8%78!&dIJo(lHkg0#xWabOB&b$B*Upn#HU-EdOGWo_DYFvq{oU>k`j?ICuOAyG}%|IS@Hk5Bx=PvO)f7jX5gkR`t>efS0@ zNjwa@R|J?6Me)`X=QM6E@v4U7%UU}IFKgP%nAsmYcf)Y=^=o+k4__D$`H3;Y)?~ei zB{TFW2HD;vR*+-ESqoa!i9zz!b;#|QC++HBJ<-e;GiE}lae=ZCFij6^Tkh?{$kxod zxwV{cj)bQTSppCuu_JuE2aqb2#&^s{TN$eYYkp}LpJF@CW`VQeuP42#FoLiiM0Pp^ zdr~+(#O*7g$LxQQ9EzU<9Mrpia!fM|-&PU}6qF+&=aCq5Trjs)7;FVVPqx9G5qwv3 z0%Me;E(<-577Z{BCl(jM5$mmoB9E(qU=zJ+Zh726KrY5oYiGURF3Gw{K|D* zG)g)gfV%GCQ}PP1USzoAYgr$!2Az!!ibHV`_*3B}L0X^)0?k0_W92<;$Voc}$eD)| zt>D{zfPP~h9r)G@ryR;72dGi9Ne}S?@VLm*Nu2NmBkosPCE^SgjtvuwcOeDnXz6W% za2p1_B2l3AN=Ev=u&R+T0sz^im0yF8vPkmO6b#=%< zq3fK<6=pcvJt0QB!+&RwzxO{p(pcH4eVcIa^^SV9})k7V~dPA$7CzxD6Z?F zUY;$DY9T3dyy2h&+X=qLJ(B8A>;!p|t4?@UFZh6RNgc|k&nEgA=P0;$+$paEphUTq zcB=ba-+7NR>q0?anJ9O;?Ayn5DqpzLmhqB{a^RCHE>>MEhiprguq&fDIYA^cS=kto z=_ikxv%fgutzEC~bH!su@-b)|E37VsfI3}=g5bR`SFvnSdGGiVXtc2Mb@p!BAkVGz zj9{c-)!3u-WTp_15i|lJVTdjK&DKx ze&7q1s?AD?!nF7tT_t@vuOb*!4WxS%m@1@sVNhU9zCfR1-!FuDTh9SPuG^yxbb-0(Ih2Jpe zZtpsGzq{bAAH0bd|LjFb(jdN_A=gV+Q8XV8>8ez{O&CMxI`#rvN!FXB^vjYwXkyM4 zT6Q&FR2%7|%CCFpmRCb{#_N>Tbp-&8_m+vq81d_enusCgc8Y)HZSWUG1d6YPlQ89j zEFNRU5%cy3Cv6uQu^}7gf)Lh)Bto48%3T0if8gP8!LkuK#fDWbNWn@)M8-3Dy{O=o5d{?ra6+7R#J)g9jW?rZqwfAd#0 zW}`n(Ae`AGqk3hJ;a|Bb1?|so12h}4ikJQ>-D6H90akph0#-1Ulle)QpGg@MJ1AZy zRwbFOJrWm$|LV_uqfqC(6e}a@H~BHR7-Ln;OZ>qz+v^2OxTMgyfEn>_MCsOs;O{!iQ0;XiW%W2e6l*3&JfSuH7`()3;fJY{3;xYS1V+OZydnu*y*{4Hgr2&Up%AN z&_T_!fK?Hye}MvDyoJflwt|^gIx@l*hxUYiXTXdz0*=`3w@K3)yWae06iI`y&h$5Q z0u4u&-BaP^*^hkuF+BBqpTqLld0e{->ndRU7kBzIn>c2$)jeO;gX)M+x1{Km&^x{) z#|*)Su{0OMXI4!5F+@NuZhcK*H<=8oJ9ZAklL)r#lc<*EK zwOeO{=;rsOKU4+;5?(Vc4u%NylYlMgFc5OJ>g9o(9A}^>wgn%0{X`hQs&iMra-J!U zb5UdxfaYvw^f@$Gz_Zp&dtjT3up^p1qGlwF_^F}eBid!PJ?xPxB52_xO88ysxt}bLBI`u)wjdreLwYkpU0VpFXG+1 zOTM&(m#snC?C}9F-EH};6d&1-W1Z}RIcn1mW|N*OIjm#H>C?A?Q$N3n=lcUASZjA>u) zW_l&y>M!x>V0wVrSN{1oaQ~+s!`qik$W@Qnwpm{bB^IG?eQYfd!i-4QAiSdQE^@0H z-pdw2BNE=>KL6)0`+nd54LtuR&o``T7ydb8PdgXBCnj;l%U~}3o!Wb0<;=3vAufW) z7<@G@G#PJnOJ z!`W@wiLtTjK&LkVcRg+8L^O5;NmcUdR1-;uEU)IZ50s$t=W$%Iv42g~zo1j5#p7o- z9=yGU!!HRaIKlt)@PM@hh$i3QG2g8Scm(}22Verz;lScbwDHb-)JlbuTx+8$YFjL% zxSq)_ia6_t%;Ro|eB!H*!}Kr%R)X=6hifqb;*KTb;8VZ(S={%Lhj8^0-{uzy=xd!Y zepVa)jFb7+LoVaxMQu{a4ZX!ZpCjUrUANU*OJ-+X0p- z!<6udUtUG=7Jn?y_G=hCeAu+@R-srv?U1X&qB5uNvsxGEo{UV=7uT&#B?)!fG~UD^W^h6e{?f_R7|04#`bE2?9tn%q-zG~tNmzE1 z02ZgxKEZn}6@rqbi?G^@NI7I&Ctfp%WjU*{5{WvKxgi|4RxQ?Kt}^q7&}M>>Elao@-@x{oxvpJV%EmOjA@#%gNFtg&9o~@q*imx>~1>{H{O!-j&!9ENWc$-yRsH^d>HoT!9Yo6^a zQ79L1M(nW59)9ot#6S2H@X&c&$$kG&U)N2IHv6FZdR>g#$FKK1e$>Mbm?+kEUqMJH z8D$QiT;SN|)Zr(8mlyotk6*wGfBHglDV%S_WxwVl2Ghc(61p)_(o4S}a2n5`%kRf_ z^_%&eZMx!^b3lUowsCDE{v)_Y3=#L|YhhNJh0fxyfOw@D8X*WpH)3d^T9ymX8h z^_jBdTOyzUmt-a5ug=WQZyRH}I61g4K_}l_A;#FA^{MDMof@Y@<5>Bn%C_!ZCxWV*4j+@PQQN{M$ZYxI zW#ILve})(S?1h|gOa))Vv%CPBv1)u?mfBDE0uD?wRU;f6O3K(Zhz+_Jygdm6xERgJ z3&-zlAo2zF$rgfML0BsZsIlo|ZQI)H5YzZAPC)vBt_Ey%m$bU?7ZW0$FlJ$d!Y?kc zvj836v)GUjidb{vSaF6qDlICv^1fvY>Rk*Mqrr+FWgXY7WVO6WOd0VIj!o<>F zy_)kW5+{Rmm~97MZxPS3ud(c;m_;N=Y7R-Lw0t)50CPZ$zXYmC@<~&%6lPSBd3Z{b z(~1y4#ZNdG^PM~zm0rej0DZ+FPH2%r$t=KawBwmz>SGM*biQP#;lu&gVBN(M7c&_y zKh%*$$N+H~a{aSFij)LW3-?|UNY(>ORo^KHKo;!6m1kRRE5vgovWQ^g4}HY_;6--t z@iDqYQMw8pusZZtg^Q7HfPMw=M$m9llJM!EYT|0TwPzFc2HD6YYejD>sr7`GSHwrs z9lC$w>z~AZA9@H^?mdO&ludo7dSrvcbW}4rZ`~%~tvW{sgLMUfLEPCXGPZn!7fo#I zk#pA#H(xvawjSOo$yZiG*# zvA|m%UOH|7^{%&2ckt{wj;n1t5zv|F=g|C;#xA{aKWBR(3$dxc zSHh3j7oVbWGS&w!Mb0eWWjWM0d=^bG^7v`diC;1|>7}+}4zC?Azbk!fLe#)=Y{VHa z!LIb|1kQ^;8Pu9ng<#sDctbhb!r%wgzV1+DN6#1Cf}wXv(5F^867$eOpJ~d^?BX_|5o=YZDZsiZA+#`o#{R*3h2D ze<7Q(Hu4isn0+Hhl&U;&=4g}l3yNqHq1m5llf_?*Mz|OJ@6s1VU!`b%uk)i-FhS2< zM+Z8iYMfsuBt{ojZ2F5-Vs>lE395VOo>LvQ1hoLF0wq@^qQr~YXv$zy4GekcCbD`b@gwasqbO6E2MH~tr^1E! zn0a(=La}M;S;PvOI`S644Pun(p&KgIhMM#3V3UNg9flPz$WdKIP|Tk0thXNGiN?bPIr56%`oRz zjI(+i-<&XN{B&uvyvJ@Dr*>)GlOQ}rL*NNJ z59n6Fy<(h`%K-4vxi7V3Yj?1srthOoIH}Bp1037(QU(586+2kLVE`nbEJ6T_3s1Uz z05WPH%o?BlOvZTnJ11;|R@}YXuEfCd#u8Q4nq&voFW9i$o}HV4#U}aE2Xf^`F7BUNUl%+ z*5`2lM;^h|drsx={ll+U7c+_7_{a}Wk_1Ze%S*l->)!nk{kzlcmebV%iY*v0gDTRe z4{guh+&6ml8lL$#-#=4Mk2c04 zg{ZH1$)6YDnvOvG00KQ#?6~~vv)fXofwu_y>VUYX(FL`^XzGz-#j`n!mB;D}+KtBQ zILk$_((VV~n`1_t6;Es1zjet3tK>zGnCkobxPR=hrTs+FDNl zO0K?bF0Vwpe$Gr7jANC|8U1`SYXLOEWr1FiA7?mPvF;7lZ~LKs?MNW0Rf=(XtPJqo4Kv@zzcu+yy${+oiEK+R`}*2MmS0)1}zTMA2zTw?c^Chn}v_DhTAzX#t!UU990N& zlH$N3A8?ILcRvr$) z^T1)JI2etTk0HnEE2LLf-^d&lss#P|DtJyg`5QTRd>b@+{7W8%&lnB#TLDAtl*VuZ zG*=d)O9vin4l?IDP4qMt)pgP`Q__N_o;LuXb1zy7GR0>TBDYMCw*FsKL-kSw{v=Ef(E&6mbIIb0W&3kvdQ zQZ;orJ|{5DMWoWpNx25KPgK_Aq2uE+#t$7HehTW$zeVtm*kq{=Ct}7>BvS^*mLNmt zqBpV+Yzm6Buy+70dA6^|LB{1KAA^*IpeVoVK3E=TS+F?)RRW2$r%Gb~@3`;(8=uCh z_g%!*yGOy*cq6=ImcX$uTSu*bsiqEuj~mf+Y9_yW;bO>WW3DAJ#krN}`5T6JUw9MW z{D1#NVa>4XQD+m7d-7wYRJ!6FG7yg$`d^d90`>(~M9JrcZJiJbqYA&uc73apCrg?9 zvN>&~k#W5lidO(mT9N6;a<Ixj5?NSVc@eA8CXNmv-?t04-oDx$3sU7?axq zi0wa>g}w~NJxiR9E^P>zYzupt&orO>5jg!)VMpg*)e~;F$YQJQ===;N2i$S zI`s)Sl3}yb(a|>QHFA7Dnr>CSN%7MXNFsryRJlLKOIeu1N(^LrNS32e0ScHpE6p-1 zd5>8i@ldnKGM?vv)D9+wjM-!I7%1Ir9ro(W-cGzl47n(Z4a{VXz?fyzq*SJdsB&e@f5{Q+Uo`6D^!Ge+Mj)#^d6W@AqQUPruLmW^y&Dp|x zzg&Dnb=RCeJl7adLk?gnnny>O`FX`ov0lbjc3b>6AFQMopv6xx13vYepTh&6d_Ug4 zH`+Mx9$>_{TI08b!Tv}=CKnEK5KAxz#ql}qW5WzpbomC4-1qE0`Qw%S(*OUsU;2ON zTJ{GQQiERDtG2CN6EEig8l2lCa+%G=pVzFhtij^DD8SQKd;BLsXaXl6^;*hK{?Yx> z3Wi`Ukt_c)+xKQHqRUCw__+87#UdJR2Mx*gh1I+=YS{n!kZ;lgkBbo%U0Zk{HK!{6X+NuErWN}VL7>kbAkg((Ycg1`p~TH zX#CwS6=D4B_hk^7@` zzf8%Oa7hg!{#{7RzZxlvMw~h0QI4W|x>1YEUy5uoIiUG-OeQ>51oSh=evA(d8~2Hu{1o@#w)|>N)9x9oPhO#y}Z3oMeMc zt|Vc|AN!KlWXGuXeS<#^Fi?y+>fQog0ZiS7Tk-5dFL(0T#hRqQ2DmJpe z^GtSVCaX3}zgNg*DUdk8e!ev;TG`eT@b2q|JBPdd{n0bH_SQ8RM}Ojn1VPf~_&WkJ zjaA|55PK1+CF38oddU9T^-$8}%rD0-EX@YP=@fy2E3oESkf^NK7A*?UW-ZX2W*jx-svKLBFY3xfnLK#O8e=uI zAsA$lB^1KO(e&{7iLZSc_kH+bT)T8w`j1fzot(_eg0O2d#-q}ql@CZH6)rGiPM8)UC0igwNDG1{ z54PnG`nmeS7;21H02f4gEciql=7eKm%2*|`A%BKa?-x15+O=AL&&vp#$B`h&j6?KB zA+%%VGFbH2TO7n&BP0?2O{pC;k~k;=5FvW8k!-SE}g#p!vsBR(&nYh}^JFM^liuJM}1Md2%0 zkJchG@C9i6SE8swIrxar22pv~mO}_U%g6uv7bn2+z2d6tzGO)s$s(EBLNOtdc&v2E zN?fz2ZL+BT;}ZXht2M!#`ybvGen{Rp)>o}}k(O5)gHzmkz#IZU&mGMZNDNN}dNNdtyW(Hp1bB-~zd4#E|@VgbNZFr}h604b3! zbVV%D=5b`;El&egxMo62+Ma%AI9aT0k#daCS!{_=h<2=p|e^XH$_W#qv;U#y?^O1!Fm1 z9kt@0#urv}^@9^djKg{wDv|zmp7mPC4-fiT_DlcI{^8TO`u0^ZTMyg7(cu8C5ht5! z!WWH&y=A46^bn3A_ZKj-P*p<1PrIL?d^6>Bdhn3zc!=;{M2;+WfZ2@!@-w7dCdHfQ z=h?p;y~vL7Cmf+cdrXMPKI>rXK+eGFOvWRSa>Ltrn8!=~@olmvh3WYEUAK;E#jkj% ziGT~X6_zkhs<(){T{PTeD;t}DtDK7j4^kEh8cP(i{DA;1!xTlJz!*~2s$A@X+XlA* zkS*xPOmc8IjdvVlmuD%^;z;ogvyBlcP>s`p8o^BB6U+`TK~dPDK$;R^^n-8A#A_^0 zl2)S3ZP93XRjc0G&92@lk?v0bA1g1G6F%u{RZdI**Bc307DHJ-9Dbs|1Z>zb;7X>$ zs=z|r2dm^$VZ#V&3Y1JhgP-)L6&1n1reJwIxtUysR3)&IeB`X1Xy#2>f>p1PHuE$h zhUyeCr{FFp=rOs#l+3qiJn_{};r{*7|J8d=V>yNP;<&fKwe)2J1xFG_`bl}e>WZ&! zhO1lmq+fg9!#`X|jj?u#9W3*y#c=nP1uyQG{$KviOW3?PjkPNij`4xo=E)SYl1@1^ z65uxs2b_@Hy01Jac1kysKG;XB1z4xQw%Lv+Gho6%uObn`*CO>~=#hD?VhY3*`4q~n z@;eSU0$SP0BWW@}Il-hs*n+4l`+K#}m%Rm!jW7Q4G~gu9=);M{`#EfU)*#*BdZf z;a36%C1+9grXxU<5c0sVVH89WHQ7>CDfFtcM=I7SmR$O&hkyYqSzl5IJZmfdJqyh9 z_g=&&f9ngl^YCHmU+<#m!COj1FY$j`eR5KdJEq`bRS_W{oifJrG;hs}1cYiEV6YZl zH7N-YzPO+8zy94<@cf@WOWM=_61T7s_YOoRo*tM4w0NX{Y;Oq^RS9R!$ydQ~+J<#i zY=!{1*ebs;6lUfaOU=r8?4W;A0kZm)H=FBfLF=)$9YrwXviLbGo$6^+^c`FfT!$FVx?#`QXt>2EhP7KIN2@N(Dn*y}AKN zM4Xwk3vLjSGH3eh{sb&!R$t07iGe2&Zv!0zZjcR~(N-3uyB7v1c_PP>+n2B^#znjP;HG;qc#a7e(+I^IoI(jS4wrX|7P&&5Xvxg7{; zqR(20hN>B;4qp}u_F8c%z?oJlRwcl5CJ@o-jn82-6b`7K=3@WJ!&U#k_z14;m;Ucq zwUbvXx+eAci~tFmHj~Q5o#Bk8q>e}O36=nXZusM_`RaVZ;^F)54siDJf}ecrrQ=P1 zYJ~YE%en|_xW+%*gAyeBW8Z!#Aux|w4iG^*{uS-CH(?YfG$}YnBB|N!x|-u##_KVG zZM!>=OYzP!qRHZgk*Nds!U42xu1#MXxglcAijO$E1@ke>)+POhoLpRu&r%S3tw2Qo zseqt9=46i%0Pe+L>4+Sc?8B4YVcyhLwoSW?^UKm~S+sXW4aL-`7_>sH!E$7}^HU^P zrPg0 zOh&7Wd*odnsawuhen|lwHlRu|{S+tE{Z_i3ezGKXHYzYE?wrN2CTA6Dm9y3l%K)48 z)|5R7>xu-gJe909W-L9*wvw+K1bI&&zN-&O!9kF7-pN_3AYc-Tm##o%Xuwo{Y*efm zAi+7gNfsJ|yPREx%~t*(<_gAUr>m>DEp5++IlmkO>C2E>8#olck>_nJcuSIFww|37&F4`>x?y3jb6B6xc>6n_}l;PZvg88 z`J|$93(q(#{>p)-6&89lwY$q|mH<2XUD;tlS6(I(UA`pUl;!7SNAWKXLWTeYqw9R> zA^3)fzRHugeOp2!7)W3#Jx;ub<3>Z~Cz1jqcH=MfV{$G6 zU;)fBh>Qj{6&UL*zk(o&Hq0)81jhh^QUNmIyN}g|T9JyRUBUykXJ|4+(PuWxr-k>U zL7jkAbziSdq5{S|Lj00sw#(2hv9S6^Ei3-YKm9uH`oR6Ta&EUN!~8OEGN=xiK8L=e zs8&8UULDA(ILPP-=M4=sVn#84_kQW$UpZX$|NZ0A|Iy8aMXSFK@Jv-WN!_#U*?%3B z@$*$99>`oVt#Zj1N|YY_vp#CIh`K-$TeqXMtz!n&^WrYYQd_(#;PZJ) zIYT!(*{b7PLT~WPATSFEKI!Oms<@E+>_|?xG|1Nhi4r zvSwqh@xN}D?)ntSh{WVdlwG(+Q0OsLT$6>z>!)H?)&^cdU8>g+03GaYV~j4}j%TP9 z!1HTXDhS9Y5P{8wgxLnon%zk?J(A&(Kk5;)3c{$(AmR4)8JI<53&;4_(0SQ|F9zGx zH}rauJZjP?XAQ?Y4%=>nV2x%Zgvdd*kL*A1S7H7K z2rwYqcQqzY4Je~n!$N=(j5I&DFtRqVBwRRTkCk%hqHpZP_+ z?}_&xulhgS>p!+%RA8L^vROlebn%;?4N=hPf|Z>>k5l7#Z}%6e@0Nv7h@un_jV+C_m$-N9YEviN3Y%*U%e-k1A@Xx$Ivj!@gs3AaZ72@&DuQ^{-WF5nAl#Bh>^bP%s4pOS2eFX z%SC%$qS!K65b1)tgM#BdUd9e9uA83<^mWWtp6&#Y3Oi2p!iC3-RREi_6c`MHL&uC) z0jPZm%{PL~+9yr~Ti_ejENF%o7YoHQsw5BXnPWv!GxF^uK&03mC9VQ0=GqWfNRO^D zoXlc*mb-u>tJ%oWw5$wrBH*~m1W9~GqU4La5;_dv>tor!V~DUV)s-bXSIRuQ5E=~W z-U_d-^?4pA@hQTpR5-%otwm*9`wvo8G~ z$Sywd(%_6_9(r{e62wq^^v+k`t3QEBXFEhv(1>Jzv|u>oy8+z#*%kax|IMFcYc;Fa z_o3Gc*%61n!5SPj+30*xt6{I87kk3BJnjkL9VgSBNQqmcRB>blXxZ9|mMKr1n4*0p zpxEwO$d-;i^Ddi{e}>;Xuiw9bEv)A?&B|bUhjK6&n+5Nm5 zVfCj)F(>6k`mv!3N{xRiN|Rs>!;=9|9YTB}=y#l(uf&L41Vjdv6%7W0t^Fz|u$X360C?F}rBy&F8@;TbPA5#TjFA-vKnTvWKt0fMyS*6U77%ol74OAPKMbRrGW&LgA;YZmcV<; zw0fSWj*YqV0@}2pq^;t%7rLm8h0MK1R!x1&xc2uGuA849GPg*?N=`^JvXx)$J zY~#X4W)^ci=k`Dn^CMtA`D>rXgP;5WE}z>VHI2>2Rf)!(cqFInKJVpUl3+G92h5dEuQ-}~ zIef%vB#wx2v?8zK1rq;y&6U0j10tgjg;w!X?OgPM;rlhd6xQ@wh`%c@T40c)F} zFpho~fWTzA#z_+pcr{@z^hobs`euF-GYQfq7kJ^oB!oe@O+4`=UT<6sbk|{D$i7vC z$E%(OXl)P{mIkgso*lFt!rQS>s#jyp z{yVnXSsLGD#KEC8?tJ#dDk3)A-1vPsM)DXHJ_J0;yM$&rd&s>pPVm0DoWV_sjsR-K zLk*I)UU+qxM)T%Bqlgh;T{6FnVIYN!b27D5ef~tz+pLG|n@%xWBsg(VmB1V&!x+;{ zd?uojXI+lEx}U+bu5sA_Yb)L6>?-7OpArQU3SHPP@EvL$O$pLKgEzZ7a02+U6RmNx zAf|nffA*vJ$k#qKZump`fDX0&;zYlGFX>DEkUi`gw#-FY(+whX1OJ@PB`J^}u|=W@ z7bE-s&cAgVZ~yRhJojhMWHQ}W4kT`-_;+exThXs*qIRdaHlz4pUqF~pM2_8 z1-EX;nyK|7c2_C`hZo8vj?(L(%#1O9g|kn3ry{Ks1Ei+S$cE*QE?kz>CCvJ^O8^ZC zTZ5k5tR=u=$}558#)3LDC%6Qn48$_FZDg?!G6f+AlWPKaNk4r?$1o$_yKF!@>_UnJ zaS;h8bSL|H^r{&~cSNFt(n$6r9s)dP*K?6`lE=F3YP8}{`*|IlZM-2@G>|RpN|x8vf?L{i~s> z0sd1=56oIerub)lQfo?>c{%{xThup9%J?^yBhE4ZT>cZE5|7lD{gM+Cu#}ScpL@VT}&W zu|v4Hz(-XSj#S3T(8{2%=Faq564LROG@gN7wzVVYKDM5)d^iBfOcC0~WV)E-4?0#9 zrEwJ_i_8{T0p6U?TW}iM1)0Hk&^M)H#Lc7~a>&uG{9x!OS6#r#UW0RO?&2Dege`_x zcub5thq{7cDKB|lyjFbJ1&{uX>dQJR8GQUqF#^)6F9mA)NAk<#dHu6ycgsKEnNQB3 zhCKOGwRA0_JOElRytDNEZZiQ(n-E6$p5mXFfAZHpi~BzGFy4CL%zC%a<3&juiI!Go zYW%)`&-?JU5tqZ4NP!MWlISctCmq6x(AknVgyCXi{d{Ww<;;HR|A+tXIlS@XpMztm zuMp+afZ5Q-cUWEoFJ?|CUyW7lfxE^JgZ}Pq?4(f02G5Y8#G67zSPgXRD+5*^*SxW8J9uC2=X34~oMv71McN9We~!o3&q#6S2fZoKdO z@rFN#n>le|p8hDj5=L~uq@)lA?R=)*ELso_R8T`B7iq`v5$(4MT1A6{Z2Pm*k1yUb^2^6C! z;H?rj{Ii@v^wkwUe~+6cm4)Y5I+CDc>1GPta@7nNqi>^o4hzzQi>_6XPN3$bb-h5- z3A8DoKmqMQ{A5T9#0Zd>?F>Zq(KmsnNB!a7!d%uxr5Q9p-XTyN5>`oX(AO9${$#ve zw@Gs#(}1g`f8uhqTL!o-&!l?%@ z;_BV29o8ayR{-BVtnoy9JOf*Kdr0}D1Dm`Nc9AQDju;pjgvF6ZkM-QS8;7O;t9a%Q zzq>x^j~Y^_SK+5@DOs{p_-^Wg*jQJofc4>4<6dLQm(n+z@grp9|IvGnG$xH!7vbE9_53bj_4c7n{;H843^L-5Ik6U-dZPtS-p> zR)!|rgwA^AmzfSy@!b%7V5d*dE%uG}Ym*R9mT_vNR^gb&jz@8^&3Ebo>jgN&8wXD) z%LZ9zO4fAHzc*IGIX%Ocjt9G3?VAP@jTthnN38CL?x7W{pjcewWE*$VAu=yF53{WS zn}N&)fi*B@{kUFX&NK~}Zq@2MTdd?{5|K7!$o z{K_YA|A!yJ)k|k^XIHT-Gq*8p+myi8Ovfe{Zom|8%bYZV-_Y>F@d)B6eI|6kW0hV` z?^S#D%5A*(Cr{($@BAH*08Ko2G2l?JEfI{`pw~|*o79D6A@iZ_LiSzRS6a=}p^Djx ze+*xBY)yWW{^At_-S?zG^fnsu)587^WX&41B589%)R`*M@2V(deuAgKoF(Pum#o4_ zf{pQu&z6CjTxb$jyu_Fb7ahHS=7(WlY+I}*Iwts>Hgn|9YaKN);rQ1gJN~eo8vk0) z49p8C)Co zA2LONu6kOkfC_pX+$cLUSk~;r#*L0lKtl!%>gV`Awhp+Iili3w$h=Q1zGA{e}p@S_{p;x&6O1J4Mc$ZRA3$zYJ}l?icI(zss-`D^ zpuc}Ve+{_)cW>d@KYdz~ZbDLqjfo6A_R2r+8}qo*YSJ%*^-5dgIQ*?p1)_scye3nf{pi6`3e z>zJH&J0{Z3&(0d-vXO#T0AUm$YeCSi9*_3J>AD}R309q6lBl>Urh8lM5AG65-)~?NCj4b7c37)8*o^`J{*o*-wfNeE^mk)dR{$MDA z()7Yf<3#N&Z3CF5C&Jd$O(j8>;71l#OUe{Lp|b*S!C(CMVo`a~BIk);`wZ^+$Rl|7 z+^%F4oi!Vd7?ABj%zoh1AK@pq6}c7vsn*Gsq`*u~C42g<#6NKNjs48$wQG3hkG^;O z%|A$vOmYOO;b-Fr6;g*8R6I^))elc}9L)x$K1^Fffv1Lw{}JFh^QFJq*T>jZ%sI&* z+NJP!KyU^WKcVlnp{I;43|GP|e0K3m)j>~CvHBdC!K4fGQfb7zh@9{)VkmjZK%2`>1coLB+-%>X7cMr>K;7rIAlW9V z-8Yg8kixhNAOz+7WE=dY;+rjPglpWlVYtfJ=TCJc{s^&czy6QAH~P^1;CB*q`y#q z%sg@?l6>rjPIM~@p`)P)PQ<)yt&DL*}ix*yGB%%XjeBj}O23 ze_HrLw3Wm|_R6Hkg~s?ycB{Bc7tNfIg)HeCaFPY&XMLp#$;0N0W6SFe;9mn!#5(x` z`9b(=6d`3NDsWO5)l+aAdQBa4J*G?!EnF)$OVr>E;7J<$b?_YWEb*j2sIRBY4k_w- zvX?%jUvlsbJE8!h=bOPUU*|CAvic>`@mp-yqH<{zP_YtF#nu>R=(=T=X`Fb~3c#)c z&*7?wbf$22q{Bo5_DdsO96s;bQs(5u0SrqiFP6VH_j`yw3!|VLw_1#VHZs-#mYpf^ zhK6lJc^io>#*=4_mqw<~?8NaM8$ILAWcJ^(!1St%tL!~^Q5xPU5J)d+K-qQ~e<~0m zP3j7GHPfQ8$HCKgo!Kw_{}SxJ^SFFBC+yj# z6#fC_?7kWA;XjI{MJM=P=vtsEG){itP|h&X#`HR8slJuog&R0t`1f!BUw@3cIBF?@ zjEV8Mt1LO7;GHHynK6#ek(?N_rgk)L7D*BRn~>g^+{($3Y_JvaE^jf!VXTGp$CFu0 zKh%Vsp)v}C_e!eZm`2CW%&aIqp|Q*>k)Blg5aC-{Y{LW#NPb@%pd0?jxq+9d}m$7O3G2T^tCaTZ;!KMBY^@glUf7?S6#-2-jsEvVfvk5 zg1oV21L<7GLG75`)`fndekGA8GP$QL1zH70BwKzQZ>(~p>kE%WZz_bMm`1kn5dn&El!#s!?}EV?=!v2^BAWdU#nxc>xC|r{A~ESNpdMLv-C@L9uJyEdY3G?79=t6 z}IDfa?E!n5Q9bj(tBCN*KapI!`SetsP<{@Z79 z?d_|GRRJG7h|i-D&_Pl>PJ$7M%x4v8)XGRnrXvPk9GWc7bM~5@lY+rBc;2ETu!z%5 z9OIf_>Iog(T9Ey!ZkAJ=4;%GUJ%InZVNn=I|Xi)eK-3(P_u0OnXMpujn z)1{XhFr4ss30G96&KGC_9qcM-^*CRs7r<5_;UfRmUNQh^-E0!TdJdCAY2jcgcU++! z|MF^0U#R`~1eTh0iLlH8G3Pd;Ea z@vB=r#87B9jhC68W>=oSy*>Q5?wMM)cPTi^WBCG@y6}xw3&_KLsjKC8W~wU$$c2sq z_(U<>0VH6{VGb~W9Zv47cY#g7T7^r+77te!R64PgOO?A;R3y&89d0+JQ)grSDQjiw-EmIHyb8fE40AkWa}mMM}e zw%-Pr9?snN?)c6BpMAfCtMH#!il{geyP=W{DE#B{RudVjf5A?mqfs+MCh_>C2`Ui} zAxHB7bK=X!wE~PCDvcDm>qqZRj{UTdbS<7YX`?%CMY69mRa4kVEb?whk33^SI0{Y< z9y66oiVJ4VnaO@#LAkMk>^kU~l^3fHkuF@S9C13fB&lA?cn+RJoFG?e#6@qq5(IF7 zRS^+7s{9fD5#1tQ3oX{yPMwQgm$XtU9|Dzk@={x&n3M8LS=o*g=FXDoGOwv3ps%?4 zfN^+jyCA|!CkHcy;%`9;V`I6vIlA#mKpQiX2Ri_Jp)oin1sC8JzugKhD>=$RCz#pn zvw0}&M*zh2FBcCH6wCvN&XJ#(QHJiUu39#pS(&)+7;9HP1(&3;>TVnv&TA1d0$@f- zEj@sM*HXshd*PxkV4Gv1sVWsx%ZQsvA@txiZpv$OLCoimoD2{!qthJxBgm(%1e86{ z&Uo;$krcvR52wEN&wdxDAGl|~^uH_AI`nk#1=Ttep)nP*nfGCjp=(Y=RkVPTDeJ%V z$x+1$5?mVRyQp{H1aAHO3ZD6+@8bG9*Gl|l$;1-AZmK|uA!5@ zqQ&Aowp>r)Gmp$it_(FPlow9OBQ_|my7oAUc(xfFMxw$}@t^WK`bx3d2{|*((<=`^Ae9u6J&(!O_s3 zW~cV%y!-MU{OB*A$4lS&5qMIPD^(;@sx={u!Ila~?sQW8Lvbf3Og(6aJ>w=>9joFY zQ@qM^_%5p!l*OhZq$S<;kf;6A&p?h>!^^Qurra*$sA;k#Hc+v6z&b4+m3PIkI0j|N zg)Ab2&9ir+q>MxPj+wW%uYbh{7h+Nr#~8jfF67wUH{|i&EZg!U+DQ{aYuo;g=2mqL zKqC`sL|?n%Ku5=!{O0*nE3U|n?>n0Wv@I%5vou4)5n6&Y+D~`l7p{4((Ux&qlX6O3 z&?^Q)CfhtNufh9NRkNeAfX2WVf!07hP134s*V!0<-qBL;_q-=3D^A$JRTv?Kq-}4JXH|8-v5Tj~sXzG(3F0?WbiwqZwWa+%5?;@egx={byahsp&ZOB8*F_nlG;#v~;v zGc9@YK&^EKyGZ3e87rK@>AQt@L-|N>9KbE4gs{yYBd%krg{`F#YkVoEu)ZHt9+<^M z57Lq3CVD9&1)6Ijl!R;=B#_{T&WJZ;=;4_>c#fzA1Z!dd7C0b`j%_B)b@`YNjdX-s ziSYD&YwWGb#*o)2WOf`yk(Uo>P78SmU>%dIYaFhYDj%QrvsXHYA$1j zHk-3D4w{&0@Zk`xK@Z+Y?CzR^gEvjfM4617j@*vdARL&U)Jj)%afULy>L#l>k#{|2 zAw=gLBOi*9Jdbyo!Hn1ODQ*VQP!f;pqDB~&g9(zCMLSem3jOt6k`qi<}51Zc1PdeIuxKltg7;$vU^EUui7 zrGKo6Y@rLx!tD?L#a7J%wP@?60RB*p|#XZk`Ox$;+ojkZd&Zl6nR4Q;HrZB-n! zcrBSKc}E{nw^t58!(Eob%(l(|}dC5)rK6F|r zD9YJaeehCcyoJOv>u<@VgIr{4Dp-pbkV9w~BUc$Rk)Xf`;UEwmYkAL7e}JqrT7DOI+w)_#(0eAy0jqs>s>Sq)&_S0voirDiS*Rh1op5_de~L z)vBw9Jfq)^1CaB|3OVB}*+GdT1>rONJzOaQKMIaegi%J|nSKN*FRgEjXfs318z<&S zdb1Sb7_0|p$B(43tL2nzIRe#`;-FX0^d`zwxYVp^E9VG`(3sYItzxAN=4tW@Y?gnj zW|P_~gGoXxkWFW|opvEUdeHWnO06s)Y9jC+bAW5OOU!%%5Yw5xiUEE=NiK=TF;@w> z$k}?;%2rFPJ6h`NMnJkVa%S9v7%hk;Qv^q@#T3P;TG6N2VLA+GAYV9q`p7ST68As; zDBiqqX3$QhiahWtoYuT#)y~;oNIG0pm(&DtBva)LK2`;*&e%bPZ1|^jv5y!2oqhKX zUi|a#v~zzJjOp`QTPce{?lpHN$9}xg6 zKQJWY34Ap|tmp(m6COmIjbebz3?R!E}b<Jc{3X0wq z64|nfICvIW0ZINbLpN091g-?FS|>%)M2und9SLP?t7RbuD<;K-)}WwCY4OfJ%Hu^J z2Z+}N4!UlR20C4gIm|YKDFF*4NCWl=RyiEUB3ZWmz$|?%84e)eWpHL=E&|(yNAW4> zqv7-5F=xdkfY9JyDHNEG@oen%C6BYrCta{*8~|1NNx!M@JV@yjrNXY4aLyu8`aeD` z&~*$WFsX}qeCWM^(&J}&!?{gS58*3fi{yn8sWIL=f3^uvc;Wz)E`JFH0=6?2nX8?;_XnHW+sF->Y zAs1s>D3(`jEE67I92wsa?>jU4USL&$-p6kAhcD(tq04v8arwnq`j7Tq+oV?jY+5Fl znB+eD4Uj%zJ^sTfMgLx*NBXM*j%Xa)xr`E96vA)DVjW*ykbTu^%P`{KqK%dAVFD^y zlu5!NKbyLF`KPmf$qz_pIfg|aS3MTor0$G(NUY!l2xNL;Sq^^}pW}bH|JW+jWqBqh z6OiJbJ{ET6cWNgky2QV$ep)U~+jxsBDprnigx$0FAks}g@j6{qXk4|gL1K+T6{Upj z3m66!;&A2|^hvazFTGU3<9Y%V>;TQ?i8o4E6ZyewSgL*$qeHYW{qKJRr|!Q8?~JAYWBe}*;z>eJc2;MOgJA_9a)<`#b%!wv=Y}Q%v5m<24 zmQ|ZRPTVUZb;m_5@g}k*A=Qtu%wBp=|1wf=IcMgHpqqRDkq6N70Gq6m80Dmu^V1C8 zGu-rYpo;>SfV+ub1TX|+O9me8M{fk@`pG1jBL&bVBLlc zqMsEgYDdA4IQ!YXmx_$=)NJwq^w)ci?5?xxY$fp3X=tZ-A@<(!~cZ z;L-yZaqmNyaQ^-a`)!F+2X1id&JH(kIc{CqC;Z#KpS6#DbfYCf48G#$REA3AH;1lv zX=I$;uN<5^dm8sX_8=a3?0&reqYvSoH?H92{SxtE%RsblRiK1G6+?;SsM!D-02|{- zC_Z(v+F+NwXR?Fp>DX;?(x;(vi_>VRL>Ztd?p1fkJhVH%U;2OQ8(+k!`!3?zj^fWK z&JsMK@eeBA8Yl@v#aV(z%}$2TQNjpkat3(sOXAd~#ZiF#q20ynd!Kpz3cmCEe~rFv zQ7Z?8ap0|lqtX%2i13BuAA+T!Cx=n6gA~6GkYfO2nL086T9gA7d|qFvVS0;kQ%{%R z$(37e{kTz3TX|byspmOa%@bKbGyvY3(U%pr!m}XAw9Q4Ek&GcQ7Y|+&zSO}e4ss>W zkv@4e$>ND~E?V7A&a3`B^GM1|HrxpE!bd>*n~bUIOFtqTuw(JWHhAlL#VFoV7s&_P zTIB;k`bX;O@H&lQ98Rq16eH;>LtJk=`fPfh&7wd6kvN;955^9A(|j96UJBeAqt6g( zmSG#Y%SkpQ-7r+ARX|=EW7$H6>c7A?5I^_bt(In-RxC0O9Odvn>$U4tW`N zeiyiTW50iY%d3boA5UwnZG-h9mWW9wK!c8L5e)15jaw`2!;koth~gk8gFfr zNr;$PyvGAb#_|V*zod-O-o>udDkT*nWfn&b8O4Z0m*0WHG6*(A{If6bYy)sZOcbbc zaawH{V_B!Hk#)jS^46*km>V={{1S%OE12P*Is@LvMQCTsN_-k)GM)i<7r5rt3(?wo04^k z(?B{DYI9NmB2ChxlT!&~v$f5Id+))Mzy1Z>x^(XNu%Fnz;DRmmX)7)c8PkL@EQUoIFo#lM#hBNh)A{$2a&TX^PAzYClEL?=0!{MzG& zjdX6oi-4Xh`skSjK(${(7aoPd*V0I~ofznn4pN(tmcQTU_gcVNGzl z8xk5p(VDQqrpAq3#`5rrvY$*q23_08D_LZ_CA}PXybkv`*_5qSS zCL;aF`41m=B-j4%o&DCroBNfAcXns+(EX?J@z0&ZrTcz{A3gi({@fyZ_NBoi@5M@ zc`>KaB*>vKK|Ye<;b~hhF)&Ruq!92E3*BiMo4Th@TZ%vY*TpJTgpn>%XB)hQ9NoMI zNb*09Ic5*hmN5^gqAYwN{4!Rut^Bc%Z_WdIwX^aqh_yAzKR!d6&tPaqJu%{Q; z3cw)(6I3+~<3?a^6bGdeZ^K*WY7>4Kri^-n914m7sNj{0VZ?cB*_(d(lWdD>!7Di2 z5ES$zf3_sxS`{`GOuFQUOpXtIV4wJZ?d$l!6Ho1z`S%Iu4dB`}0tqAeKr^5t(Kg1R z&0>eAR-ghNB>+{$Mi+cJ>3yBSIwZSd9siK)UwzB=s{v>5=r2Beye{GC|M^F_cJ*d? zv>CbwMjTLlCNCVsTo{h0ghIIEq9Em1Pj>yP?g8;DiZf6#5wF9Ae;@wkr*QS`nQ`G? zt?V$L@M)yt!7FZdyPB~(*c>tA6HWx|c#uj~o`{>eNj(rN);U|LUf3`Fzxkt|;kiHm z-YSn^hkW|H`?4hv9!DlM zqwyKrC)*p|4?pIpBvtg3$^47A?xUnzc3o-vru+O+v9aQRx3j2@h>`Cc@F-146|K=EiF&f z2~Nv0IA~eQYX;5TVo!r-1^t!FCDWj_(yprMM5JR7G$ckn2 zEoq7&^w~(5<@}{f`0_vckMV(zJ+)uzzZY-630!@5bT?w&il-SAR*l87<+=r5ZI2x6 zXuGI^{Q9Z%vVvjlX`4O-Faz;0X}o^=4DP@0B0lkj58=6Qzl1B7uTx;ouLNtKfjaj# zhjOAw%)W$698ZxLSR*yquvz>b7@XG1hu4WUIoVzZPksH1uzSwoD$#f5Iot@YGfr-6 z74Pd(vTyuU5EDYX93rqMpEZ#Y0)Pq1N6WGguBR{DaNK_LDxTiY{${|V^D^x+%@zA#vv0LN_TD73&+HRQYIq);(8<> z@xKFwrF)HSHEQ};Vn_LK*96@PgP5G-DNW_Ig|x;G>Dm9<@`& zS;Q^cH-mt56>dnrwR&W-He$bl-QiCzOKz2Qq$jfL;p6GEXYu40 ze+8fVm0!b!2Oh=M{ZjusuOI~tM;@fdJ~C)5mv%8uh7QZat284k<^5I=#gA3eNc*ED z|Ikm2?zpkv7=P{ZDZFpLO#aDV{xH7#%@=X)%1w=V=fa8s31Uc|NtW=T6JT=-XABfR zAr>;WLBxH5nzQU-n4#ZKeD#-b-{X(rt-H>kzc^XO!<7i{)wx3)pq^(FalEqmNbXFghGSld>duvRmxMI1T9gI63RNXO)i zwB{vKXl*!D@dz)MCg3-*nTTIJ1Pvn{56oNnG3d$_(by_%+c>BBXwJMP8MAT$u0#tw zlxQubU<&#yW{lLx{e@YZ0&BH}C2FJ-dY=&kSwzUiSdF4#cE~7R#tk5vcsT=Q=V|86 zQI0u>frR)BFeuq9_i>ab2c+1jK0eU62b5IHSyS=Aoun1Ps3p%l%*ey?=rS zAAKA*Z(hKwKeGL@`m*kc?g{`?M-fQi)Js5bWxoXR+?J`&35-Ai)ks7~pE_J@yenmx zb5L3en)(|$KbZXWcXxR3;<8JY^{WLmYe`tNVofG_wNgg3;yxk419YIASd+YYnwp*#rx61;nsjvdHDp%i zrPMx<(cf?sjmNg^cmjphxgx0PE?vM>z4KB^UAbIxh?80cEN^@0GWS@1q#3Ay3{I&XyZaIo=N_9k;_tAboXf7UqjTjKzCoxK~M`O4Sv zsjvJx&fN6?Uf<97Z(heSndTMU3nw{9v1pX&8Ax)>!=Gie3w^MwYRe3+wPv$5srvwB zce$xZf{}NNtrod`+mA)S`!1fxC%^dNeG%{jT)lFG+9843cDn*I)0l|&@~s`_sGyl# z%2&GPl>rS)jh9t^T4@^XpZv<_asH79@iVlt${5&xc(3!Z?b-p62ZXUT8? z*$XEdW&vX5zm?oTKDmGu;`z%Z&W@$mNLU%4+o-Vls$!fU&uh{(^*4&i(6^^=0DXzQ zP=V|W3?GPsgS7^5PBrGwTEUe%WBVJWV`l-u!kD2a(dugu-$KYRiUQ7mrz3@CfKw*{ z{c!#vmGVlp&fljEtruXh@PmhDgWKmG!#EjBU{z5^O_3#HD z$5(#ue~0(|!bfoJ^4)m-C!Y5s3TAk9hzpkF(}nfHM@DG_W#^b$Zx<^vQJB)b$YaSe z^l{`u0*8vQA$<79ojd#OfKdcI`T589MZk}6M&srxdkI8$V=?tUSese#MrVQ_O zdD5@@3aH;0fkpuL;b(sz{ngLl@|n|EjtKx&csfUwx43S;(b^`P(LPpXsK5a0Ud-T= zKZVZL*$$fT074)vy*K2gcMp&H{TY7nSI?$N`9>(l_u^*^8Lmd?EcjLTTnEf}hGpzJ z!$w}BqT+nz1;gLh4{<}XS$cqrV=rOA7s{@(TLGJCg3(#`sxPC&n($?zjnX}edVEA~ zV!Q`F=kp@6PsMyFDRVtI*)fb3x(haBK^4Y8Io^N3OL7zejx%06N2i z?;2;z#>m6p@>a~_klZ`Lg~#I68A>LB=I4=!BU8tYkOnRW4quOVgN@Unh*b%|=XZ4y!q-LChm}V4^0=cxsT87X{qjo3e{zNa*$$c9Dr1!%;Z8stJl8nK zf@bt{>K8r~{o}RVSyuus+;?%m67cx(mc>VqG|Uld>eu29TyP|S(0No)3cUsdiGO&H zB!Y4E@xH;Y{ipv3r}jm_6*_mcyNCQ}dJNX=n0&ZR%?=4PlO`~Q6zP6_Qtq$~KS_`z zOWGDmV(?*vpcK#NZtg$->ee6=$jKx6#j1j!DxS`Zpbe zjyd@+{VBgNpc7OJd%Z36dFYhGCOxf9CeptVkLq6S&%GQI{4221_SZEh8km- z<_gBe;8&7U%Hu$nVHFJXt7f(RIKSyBA9=$XF7;tT_ON;B)8B(HtC^`5#Yv;f<{KuAnsGZl_M140>tfNuDrx4=y`^=@?2Gk z#JmxZEq0!=F8UKOdeEg(W>N4y)`bD}S!L*pL zjbj_MIU{W3n%05-V)L97amJX}no@kMTnt|ZuM``#DJ(k8d0Cbbt(MDToOLg0T~y9W zBFWM+RrVi>Gj?F3;wR^t8@FIZ8HdWURTrh~ssXgGC=g8s?n@xIjzyFT>q*Bw0cbEz z*FZRf02_iCMe5U}aM7)juJisPU#0*nCo)++=Hojlgo@X--IJgH5s1Z>X4QAmuw z*u_|Do5P(0FP}PvM?dt?ejDI6UjD)B$RG^{nH1vP)dp~K^Lm0@-Eb6p zP5^9~D2#GYB!-LE7uz! zH=0V;nIRQowM>|P#e2pa-{l)X<+I_x6-82j*oGe#_gX~KdH}Sjp=}V7k127y2B)Eq z`fWT-ia-<4+)8xtYGf2DYa9CMCJ16$QIoT7W{*ML6dYovFNE{U#>+dX@bO}->mDIT z@a$V-unqgLTHw}pS!|urDk(kXFk&r5^uw)y4Yc(-SlvpTIlwY0PF&7P*m9x!zeJ7u$!^y>R*+}1Z7 zq6n)Kr9bB}TG}3bsl#~-8f<`{A20O#!Z&^!pZNT*?d86NS6|%Eh;I|SBawtio@mZ7 z7Ks%y4(mqZ%=q1=^ zS8@69m|3td23SQ5frOd$yLCN4^`Hc56%T924d2PwTsiv0SHFn+_Ot&t=yzJ;lc?y( zK}il9;}l?c-PW`vYM8oYk0BmFyvutW6-wuYs}##&<-`5{&tAEMXaDW@@yc^A3H=1} zJXA>}x7tE>C!6${R!|LJ9nVKNy!4KwTatE+AwTN5CBPcZ_Y4vH5k!<`&GW4+p|vR!Zn8TMEp9%!N}3UDuRS1egwAHHYFD(HIb?Q>6;y&)Ck#pRV?^1jfgey;e!spQ~1WY|F9EVo9s`HfQVEIr! zdD@#MIWW2Uf>|bvVz7e|Gx1(dMEl-O{(=H%)8;utVKqg?n!>APif6DYR#;#qBu}7< zGl>i~mnB=F4@2PPZ47}vPFCy~AtNer7q~MZML!eHk4GCYi&-%*H|FdrvLPw`aPE;0 zei&c*-Twp+|H8-c_Rr4X^6N-YjlgE3m0S>8w3;VvRt`DrIBxN)0OaX&89d>RLDL8L zjb94{iv@CFi<<4O{5a~NBu;mu^Ylq8BssP{;+3${+O0s~RT~yLXVD11<{psvKmNCrZ!R+7;utiM)X;oZ z4VFB)Fh=;r0xu++SE0_?!`i3#%!SLb{X&=&!@5tCono?}%!l5MZSN=D4_EDV9*3DJ<-Y+yYO?rjAD4^c+E zNb`-P^og&09+w__81F3Bf{x+LQg@uNj&a)`Y!v)7k(`eYda+-BOfIAwwmpA#@*%$e zMI@EsRr@zx^(*zI`Rzvl_Tys%um)RUbN6v7uvRUUs&Qr%sXFBkg!Pmsn2VR`*)S=*D~g!_NlM!Vs4w zr}uiz=Y2B~2J?dj8E0iU3}|VbTIMQRt%hBXE>vBc@}DWq8ZY4+rp8L-4jgIEQz$;d z35|drsYqf6C5|=SsUEyJ-+F!|W0E{n6Re)UbT7X0JO2qj^s&$4`a2i##;fa%aY_cr z!}W~$aZhQ2kHZ6aqHXHR2wSo7GU7`blx;FVy#rr4yF4qK##JJY@?hNB#0Vr z?~8!T*LS%8(fjep2QK5)m){<4XV5%i$x_S=V?|=M$=FUK8|C@rl@yNx1-Zj!0^+_f zIsWYLbGUl??D2m82?)st$revpgpp4Gg?vNyoe>=)euw_Tw-<1L4LLk%0RoQh$Rx0c?>yogL+~rtz;6 zoJwiqYw{tE#X4o%IiVU~bJYUS$sa-Vt&Kgunm)`Z2Cezp7$jmX5Qo)BJQUsr%OR7~ z$&x49Em%gZcg79*^GxbkxI!y)gU?FHLn|$JrAIN2ps;M(`o;EI@jB)@Q(qQmb&#*Q z%2S}xL9u(sI96>|^}FaeK$xM?D1EZm>o=l>Lf{}>`R0UU{#$L5& zw?o673T>Ubm9L=`lI;agvG^C*o6H8-CjUT{0PoP?6Mf~(fTaFfor4_Hf2W86pvnM_DvM^)G{ zTQF!lm?RSLkfd`kSf#egzr5)#x>AT1{Sidog0aSTkkMDL%O^7$IBCB1uHoT(@4}Ox z{Q$o6mp?cX$e;&M2|A-Y3ox=?&Z^2ZVYd>*N~vUH7R&JXr$3GlefE>MVyAJ($xc>2 zQZp`#?6~rnvv*x(IlSt|L&367Ad)ks;Urb4So$;Tl?lcSrN*5!bfkB<>CYSc+5fYD zHfI0Uw>{;v9&Il44?&aTm;HTV|93JA#DwV5Bc=*xL8Oa5+!o)JXZsED}&G637_W zl9wbd{KtQJkVl&e4RSbFg|oBEJ2tS)I<@tCf(x~!LO1XTef0w%2Vfb74YbJeO#qTg zc9T!XjqBI((hE=H*3HAsRBq$$i@ULxhRC2rlKv$aI!W#1BOY?@(p?k>s@?jpbQ^u0 ze@~9!o9s3AoU{P)ro6jGN<70t?>q#w+C;F{63&>V8@J(gi&Ffd}Rh1(+lb z+l#jaHck`YDQm`GC9kKx_A5Ab=^k8NAUBJN#1`=)c;p3;VMp)xV<4wJ`bFq%Ftp_Z z$U)6~JyM#PX0z4I(`Z5^X}_Vz8uI5GZd*pWPPIhlaJuzp~T+-G6Xhsw0|^6uYO z{i|To)f2S^sPLg|WkZ_qPXG*_QB)rLXZ%~kzG}mIEuD$R#2q5%Eu(D|kRD`dBCrsU zw_1q)IC}z$ODg)31I}RC9n+Q(>nQXn4@hI1yA*mZag`_F6JXa44!_`4UL!56>ryZdGBFW{a> zF5%YI(|G$8;Px%=A}YtBuEAOcs8^8jOnvQQEf|1&hB^6LMxM;Ro7$2+pLi4-00O!U z?#ASnS_uj^u(bW#VfP#R#B^=H74X52Jq-T|`KqC0IkqQG2-I^n7KhWe3<%ixtP&k= z`g7l7kKhVU(YUM#S`8}ZM$=kVNRQZf!q~CxuE4xTpIEAKL=PGrM!vWvwe{AIaW8^s zf1q>z@-4h^yy?%2q_^nTd`x75BxK{b3cp&oj~{>TTa%y&#-#-;#giUJt%xk!cu9@7 z^LyDUzV}AyC1f9Ctd?PEMr@_~WuYx0fM)9CJJXoSIW|{Xn|v9=L3Ze#7cv(V&f z3Sv|S-7ZV2U_i!Z6sb80a>gMOIT&8!x|}SK+`-qys_ZMRe$1fTc5?j}$M!gv#I3~6 z)nvl==q7rJ!-ToD`cW9VzO7169qzxMI&;@U|8)lG(MRa+E0~k7aKV7?>(3VW^aK!b ze78G+rY9@AOt2>e5d-E%AZBIk?Yy@!hf-{iOYB6+uqJz>>Mv;2vU~t|H~y;6aPH_k zSaIl6(E>C{H}AELk|1lQSi~|G<`NeiTr1BvZ(hfbp7}0*`s3$u>&|67_zU;p(nG-6 z^LOFawfzk24uN_%tJQny!~Y%ttUlJ2zR_la95P|XgqK~`es%H~{A6EF7(wU&g1epW zXM2KdR-&*3LOdntON+*gYXIo9l7KF^MjXA!_0t4fDIU;U^5 z2zNjB7~Wpkf2^=sKufr%K(5uN478$AI8$HJ7n)4U`+K}t7Md9L?$+NK8<`x*urB}}{;!pr z2B@P0&w#9Fw+=uy%AH``O4+D@$!q$;)U=2&$#;T zTexuF8NBcDi#T=aF5Gw**qUVwl|ZW7;LjjI*!ykq$xOv;PWl?RB^?gIq~4=qP;iIF zGHYW4&;k?dg-wZumgf#%7vJIh1;bDO?nij}$3H??t7cvR1iqYfFvi%?Sd>iSn2{{& z3fS&=;*($fB7X6gKZmz=ckTE7X&)J`{z7Opttc7ALR<9qQqTg*iB$C#zN@WiZ` z1ZDvMNx9qbPtq~lCdQQH@4_kSF|SVgqVJ|OZ{0ckD!_2xL-*p@zyA08)sj17C=CWf z={Z$?{6Gak78*V$HQ@!vr~Q5Ix4(u{?|bC9_pfnC+bq_}&@2`69&E&M7FZPPJF-{F z!Cp)k&x-N22tf1k@Ba7xiW3j)8h%&Ne)}^D63YFjnzjT3{PZ^4W+jX3W zfls}A0~-YP20)P@MbbK$)>&>du`SD%osqOhJi7a$FLKlO-M=Kip)dNjdvx5LIHwVh zIAdfavSLvZWy_K%i6F(JK>)-VB!GRu#y+je_r7aZ)$`Px@3*&-1$_HmhdFCj)vS7| z=3H~FbqnwR=uKg$W)9Zd)NdBNvLHxW(T#uWAO9A9_A9@J5BzF9=4!y4_*&7i)=3i& zq?9r&vVF3Y?4OcVjKbq;ja;(E>GT7_0z&N$nO2=$0|Klh9_I=59SZ26#KI`3ySFxaeqhC48}+hm&Qdl% zYCFPr{20`ib?_T}ox?w-*L25;8I@r_$Qn*X$xKMWu2%FJ;Zh zyg)#>@zkGm5=!TwBp@i{{p7*Nw8Hn|@85mvFYrJAm;Z14`2FwU^S|&AH-3s3bo$E% zT@CVh7_0|>joqCkld^J=yTu6;7JS&CwXf-^MPa#e*y>u&zKo{;@>96+X1mdLu7Zz# zBbo1A3{dakQ}~s?|MyOhR*o)8oeJZu+l5!^;=qmKuq9|b`@-}1^56bH;FGJ*TulAj zc#5I;b<$Vq&{E+xK8+l*Q{(Cqqnyj1r%$`|OU>e$^XO}`(n&u)^uMoYK7R1WasMZG z@ZGQd#lR9v@K$@X$|r5;n)Ey+|0ugrPpgyccTua4I~Z%6Wa#Py$(Z^A(4SyPP$ys9 z&zJwFd?})~6ntbKPi0K>;=)_urEk6xpf|@ISn^Yo)`Dd0#qm5s-LPucTB*{bgW$0A zLvca$?IrgPfveZM7apSl%a(5$G_VmGs6U%+?bo)(JtPL^-}t9&nCbQ@eNVmb4Owev zWraiUB-h0Zz)SyWjCK-Wg1G~Y8Yk|792uO79C}MxR;E2Fn~*FH)Coz;0iMQ{3{rS; zs@sB2N7!grTh!mq4`QUfUeq&BE0JdOCsKjNJNN+e}i zj5-}yj2;8nF97-eJ-qU*KgVak_<4NhOD`UcXRHf=ZZsv8dR|33n}OBXBZ#+fkfH~& zi&3V{9Ob@K$>lX~m|k=op?@!XM+8CL9$d_dy!gVki`kKj+b6HShGt~5oitWfI$GJ= zar?@}=>PM-`pfv>;bG_AR}Nq0`Q710w7rcu7`zYheFSoPYgiD$>Cl2JJE)!UD+UM_ z^Z0hM>mT03_kQ>5r_cSGP#o=aEC42dXL}Z{2<|AiCB4+pHccz=j2*WzwTs#3PIM<+ z8Kuc*;q&OZFU}Od8A*QjF}m+mpC11`7_Fr;Ttav3dhw~hgELPSsJ*HeFty81xU|?- zx}>Xcu}$UU48i&h-OSy(A9LX4Si}(?r4uWtsHBeiLJaRQKKmqFx1EetV6UbAt`6CK z9H`)yfr1H&LzSY90@Nw{fLBIkRh9}V=NV|~>KX(`#)n#2FXYIQH2C`fG#65uMK#Gr zFLs0T$n-j5j4JV=Aig&8Q^^c{@SB<5C?y6lg8wPwjO-P<2qwy&0-m)zn`En7HDs{l z;11}h#eK9TEup3d4fpSTf`9p6|5JSPkN+1u`%`!D{7XgvoI;-dPLCEjHKfVwa4-Ut z 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 diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/apps/web/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/apps/web/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/App.css b/apps/web/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/apps/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index dd51c3b..0000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { useEffect, useState } from "react"; -import type { LucideIcon } from "lucide-react"; -import { - Bell, - ChevronLeft, - ChevronRight, - LayoutDashboard, - ListTodo, - LogOut, - Menu, - Moon, - Settings, - Sparkles, - Sun, - X -} from "lucide-react"; -import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { AiChatPage } from "@/pages/ai-chat-page"; -import { EmailLoginPage } from "@/pages/email-login-page"; -import { OAuthCallbackPage } from "@/pages/oauth-callback-page"; -import { PlaceholderPage } from "@/pages/placeholder-page"; -import { SettingsPage } from "@/pages/settings-page"; -import { TodoShellPage } from "@/pages/todo-shell-page"; -import { revokeRefreshToken, type EmailLoginResult } from "@/services/auth-api"; -import { - clearSession, - loadSession, - saveSession, - type WebSession -} from "@/services/session-storage"; -import { - applyThemeMode, - loadThemeMode, - saveThemeMode, - type ThemeMode -} from "@/services/theme-storage"; - -type SidebarItem = { - key: string; - label: string; - icon: LucideIcon; - path: string; -}; - -const SIDEBAR_ITEMS: SidebarItem[] = [ - { key: "dashboard", label: "概览面板", icon: LayoutDashboard, path: "/dashboard" }, - { key: "todo", label: "待办事项", icon: ListTodo, path: "/todo" }, - { key: "ai", label: "AI 助手", icon: Sparkles, path: "/ai" }, - { key: "notice", label: "提醒中心", icon: Bell, path: "/notice" }, - { key: "settings", label: "系统设置", icon: Settings, path: "/settings" } -]; - -const READY_SIDEBAR_KEYS = new Set(["todo", "ai", "settings"]); - -function toWebSession(payload: EmailLoginResult): WebSession { - return { - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - user: { - id: payload.user.id, - email: payload.user.email - } - }; -} - -function App() { - const [session, setSession] = useState(() => loadSession()); - const [loggingOut, setLoggingOut] = useState(false); - const [themeMode, setThemeMode] = useState(() => loadThemeMode()); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - - const isAuthPage = - location.pathname === "/login/email" || location.pathname.startsWith("/auth/callback/"); - - useEffect(() => { - applyThemeMode(themeMode); - saveThemeMode(themeMode); - }, [themeMode]); - - async function handleLogout(): Promise { - if (!session || loggingOut) { - return; - } - - try { - setLoggingOut(true); - await revokeRefreshToken(session.refreshToken); - } catch { - // 无论接口成功与否,都要清理本地会话,避免页面卡在登录态。 - } finally { - clearSession(); - setSession(null); - setLoggingOut(false); - setMobileSidebarOpen(false); - navigate("/login/email", { replace: true }); - } - } - - function handleToggleTheme(): void { - setThemeMode((currentTheme) => (currentTheme === "dark" ? "light" : "dark")); - } - - function handleLoginSuccess(payload: EmailLoginResult): void { - const nextSession = toWebSession(payload); - saveSession(nextSession); - setSession(nextSession); - setMobileSidebarOpen(false); - navigate("/todo", { replace: true }); - } - - function handleBootstrapSession(nextSession: WebSession): void { - setSession(nextSession); - setMobileSidebarOpen(false); - } - - function renderSidebarContent(options: { collapsed: boolean; mobile: boolean }) { - const { collapsed, mobile } = options; - - return ( -

- {mobile ? ( -
- -
- ) : null} - -
- -
- -
- - - -
-
- ); - } - - if (isAuthPage) { - return ( -
-
-
- - } - /> - } - /> - } - /> - -
-
-
- ); - } - - return ( -
-
-
-
- - TodoList - TodoList -
- - {session ? session.user.email : "未登录"} - -
-
- - {mobileSidebarOpen ? ( - - - -
-
-
- - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - } - /> - -
-
-
-
- - ); -} - -export default App; diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png deleted file mode 100644 index cc51a3d20ad4bc961b596a6adfd686685cd84bb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44919 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 JPDHLkV1myZcL)Fg diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/apps/web/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/apps/web/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/apps/web/src/components/ai/ai-shared.ts b/apps/web/src/components/ai/ai-shared.ts deleted file mode 100644 index c0fa0e0..0000000 --- a/apps/web/src/components/ai/ai-shared.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { UpsertWebAiBindingInput, WebAiBindingSummary, WebAiChannel } from "@/services/ai-api"; - -export type AiBindingFormState = { - providerName: string; - model: string; - endpoint: string; - apiKey: string; - configId: string; - configName: string; - isEnabled: boolean; -}; - -export const CHANNEL_ORDER: WebAiChannel[] = ["USER_KEY", "ASTRBOT", "PUBLIC_POOL"]; - -export const CHANNEL_META: Record< - WebAiChannel, - { - title: string; - description: string; - accentClassName: string; - } -> = { - USER_KEY: { - title: "自备厂商", - description: "用户自行接入 OpenAI-Compatible 服务", - accentClassName: "from-sky-500/15 via-transparent to-sky-500/5" - }, - ASTRBOT: { - title: "AstrBot", - description: "复用你在 AstrBot 中维护的模型配置", - accentClassName: "from-amber-500/15 via-transparent to-amber-500/5" - }, - PUBLIC_POOL: { - title: "公共 AI", - description: "使用管理员开放的站点公共通道", - accentClassName: "from-emerald-500/15 via-transparent to-emerald-500/5" - } -}; - -export function createAiBindingFormState(binding?: WebAiBindingSummary | null): AiBindingFormState { - return { - providerName: binding?.providerName ?? "", - model: binding?.model ?? "", - endpoint: binding?.endpoint ?? "", - apiKey: "", - configId: binding?.configId ?? "", - configName: binding?.configName ?? "", - isEnabled: binding?.isEnabled ?? true - }; -} - -export function trimAiOptionalValue(value: string): string | undefined { - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function buildAiBindingPayload( - channel: Exclude, - formState: AiBindingFormState, - currentBinding: WebAiBindingSummary | null -): UpsertWebAiBindingInput { - return { - channel, - providerName: trimAiOptionalValue(formState.providerName), - model: trimAiOptionalValue(formState.model), - endpoint: trimAiOptionalValue(formState.endpoint), - configId: trimAiOptionalValue(formState.configId), - configName: trimAiOptionalValue(formState.configName), - apiKey: trimAiOptionalValue(formState.apiKey) ?? undefined, - isEnabled: formState.isEnabled ?? currentBinding?.isEnabled ?? true - }; -} diff --git a/apps/web/src/components/editor/resizable-media-node-view.tsx b/apps/web/src/components/editor/resizable-media-node-view.tsx deleted file mode 100644 index e0c0874..0000000 --- a/apps/web/src/components/editor/resizable-media-node-view.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; -import { cn } from "@/lib/utils"; - -type MediaAlign = "left" | "center" | "right"; -type MediaKind = "image" | "video" | "youtube"; -type ResizeSide = "left" | "right"; - -type ResizableMediaNodeViewProps = NodeViewProps & { - mediaKind: MediaKind; -}; - -type HandleDescriptor = { - key: string; - side: ResizeSide; - className: string; -}; - -const HANDLE_DESCRIPTORS: HandleDescriptor[] = [ - { - key: "top-left", - side: "left", - className: "-left-1.5 -top-1.5 cursor-ew-resize" - }, - { - key: "bottom-left", - side: "left", - className: "-bottom-1.5 -left-1.5 cursor-ew-resize" - }, - { - key: "top-right", - side: "right", - className: "-right-1.5 -top-1.5 cursor-ew-resize" - }, - { - key: "bottom-right", - side: "right", - className: "-bottom-1.5 -right-1.5 cursor-ew-resize" - } -]; - -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -function readWidthPercent(value: unknown): number { - const numericValue = typeof value === "number" ? value : Number(value); - - if (Number.isNaN(numericValue)) { - return 100; - } - - return clamp(numericValue, 25, 100); -} - -function readAlign(value: unknown): MediaAlign { - if (value === "left" || value === "right" || value === "center") { - return value; - } - - return "center"; -} - -function resolveAlignClass(align: MediaAlign): string { - if (align === "left") { - return "mr-auto"; - } - - if (align === "right") { - return "ml-auto"; - } - - return "mx-auto"; -} - -function isStringValue(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -export function ResizableMediaNodeView({ - editor, - getPos, - mediaKind, - node, - selected, - updateAttributes -}: ResizableMediaNodeViewProps) { - const [isResizing, setIsResizing] = useState(false); - const mediaFrameRef = useRef(null); - const cleanupResizeRef = useRef<(() => void) | null>(null); - - const widthPercent = readWidthPercent(node.attrs.widthPercent); - const align = readAlign(node.attrs.align); - const src = isStringValue(node.attrs.src) ? node.attrs.src : ""; - const alt = isStringValue(node.attrs.alt) ? node.attrs.alt : ""; - const title = isStringValue(node.attrs.title) ? node.attrs.title : ""; - const showControls = selected || isResizing; - - useEffect(() => { - return () => { - cleanupResizeRef.current?.(); - }; - }, []); - - function selectCurrentNode(): void { - const position = getPos(); - - if (typeof position !== "number") { - return; - } - - editor.chain().focus().setNodeSelection(position).run(); - } - - function applyAlign(nextAlign: MediaAlign): void { - selectCurrentNode(); - updateAttributes({ align: nextAlign }); - } - - function startResize(side: ResizeSide) { - return (event: React.PointerEvent): void => { - event.preventDefault(); - event.stopPropagation(); - - selectCurrentNode(); - - const mediaFrame = mediaFrameRef.current; - const editorRoot = mediaFrame?.closest(".ProseMirror") as HTMLElement | null; - - if (!mediaFrame || !editorRoot) { - return; - } - - const startX = event.clientX; - const startWidth = mediaFrame.getBoundingClientRect().width; - const maxWidth = Math.max(editorRoot.clientWidth - 24, 240); - - const handlePointerMove = (moveEvent: PointerEvent): void => { - const delta = moveEvent.clientX - startX; - const resizedWidth = side === "right" ? startWidth + delta : startWidth - delta; - const nextWidth = clamp(resizedWidth, 180, maxWidth); - const nextWidthPercent = clamp((nextWidth / maxWidth) * 100, 25, 100); - - updateAttributes({ - widthPercent: Math.round(nextWidthPercent) - }); - }; - - const handlePointerUp = (): void => { - cleanupResizeRef.current?.(); - cleanupResizeRef.current = null; - setIsResizing(false); - }; - - cleanupResizeRef.current = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - setIsResizing(true); - }; - } - - function renderMediaContent() { - if (mediaKind === "image") { - return ( - {alt} - ); - } - - if (mediaKind === "youtube") { - return ( -
-