Merge pull request #6 from Yaosanqi137/feature/p2-api-task-attachment
Feature/p2 api task attachment
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
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."
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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."
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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."
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
@@ -2,3 +2,5 @@ develop.md
|
|||||||
node_modules/
|
node_modules/
|
||||||
.turbo/
|
.turbo/
|
||||||
.idea/
|
.idea/
|
||||||
|
.eslintcache
|
||||||
|
/.husky/_
|
||||||
|
|||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm lint:staged
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
"*.{js,mjs,cjs,ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||||
|
"*.{json,md,yml,yaml}": ["prettier --write"]
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.turbo
|
||||||
|
.idea
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 贡献指南(Contributing)
|
||||||
|
|
||||||
|
本文档定义 TodoList 仓库的协作规范,所有贡献者提交代码前请先阅读。
|
||||||
|
|
||||||
|
## 1. 分支模型
|
||||||
|
|
||||||
|
- 长期分支:
|
||||||
|
- `main`:生产稳定分支
|
||||||
|
- `develop`:开发集成分支
|
||||||
|
- 功能分支:
|
||||||
|
- 命名:`feature/<phase>-<name>`
|
||||||
|
- 示例:`feature/p1-code-quality-hooks`
|
||||||
|
- 其他分支:
|
||||||
|
- `release/<version>`
|
||||||
|
- `hotfix/<issue-id>-<short-desc>`
|
||||||
|
|
||||||
|
## 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 缓存、临时导出文件)。
|
||||||
|
- 不要随意修改与当前任务无关的历史代码。
|
||||||
|
- 如发现仓库出现非本人预期改动,先暂停并和维护者确认。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/todolist?schema=public"
|
||||||
|
AUTH_ACCESS_SECRET="dev-access-secret"
|
||||||
|
AUTH_ACCESS_EXPIRES_IN_SECONDS="900"
|
||||||
|
AUTH_REFRESH_EXPIRES_IN_SECONDS="2592000"
|
||||||
|
AUTH_EMAIL_CODE_TTL_SECONDS="300"
|
||||||
|
AUTH_TOTP_ISSUER="TodoList"
|
||||||
|
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_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_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_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"
|
||||||
|
S3_FORCE_PATH_STYLE="true"
|
||||||
|
S3_PRESIGN_EXPIRES_SECONDS="900"
|
||||||
|
S3_PUBLIC_BASE_URL="http://127.0.0.1:9000"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
# 环境变量文件不纳入版本控制
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
|
dist
|
||||||
|
prisma.config.js
|
||||||
|
prisma.config.js.map
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
rootDir: ".",
|
||||||
|
testEnvironment: "node",
|
||||||
|
clearMocks: true,
|
||||||
|
testMatch: ["<rootDir>/test/**/*.spec.ts"],
|
||||||
|
moduleFileExtensions: ["ts", "js", "json"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.(t|j)s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@todolist/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TodoList API service",
|
||||||
|
"scripts": {
|
||||||
|
"prisma:generate": "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",
|
||||||
|
"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/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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// 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"]
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// 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 @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?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerUserId])
|
||||||
|
@@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?
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { AttachmentModule } from "./attachment/attachment.module";
|
||||||
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { TaskModule } from "./task/task.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ".env"
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
TaskModule,
|
||||||
|
AttachmentModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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<PresignAttachmentResponse> {
|
||||||
|
return this.attachmentService.presignAttachment(this.resolveUserId(userIdHeader), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("complete")
|
||||||
|
async completeAttachment(
|
||||||
|
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
||||||
|
@Body() body: CompleteAttachmentDto
|
||||||
|
): Promise<AttachmentResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 {}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Injectable, 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 { 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: {
|
||||||
|
"Content-Type": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async presignAttachment(
|
||||||
|
userId: string,
|
||||||
|
body: PresignAttachmentDto
|
||||||
|
): Promise<PresignAttachmentResponse> {
|
||||||
|
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(userId, body.fileName);
|
||||||
|
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
|
||||||
|
const expiresInSeconds = this.getPresignExpiresInSeconds();
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: objectKey,
|
||||||
|
ContentType: body.mimeType,
|
||||||
|
ContentLength: body.fileSize
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
||||||
|
"Content-Type": body.mimeType
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeAttachment(
|
||||||
|
userId: string,
|
||||||
|
body: CompleteAttachmentDto
|
||||||
|
): Promise<AttachmentResponse> {
|
||||||
|
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: objectUrl,
|
||||||
|
mimeType: body.mimeType,
|
||||||
|
fileName: body.fileName,
|
||||||
|
fileSize: body.fileSize,
|
||||||
|
width: body.width ?? null,
|
||||||
|
height: body.height ?? null,
|
||||||
|
durationMs: body.durationMs ?? null,
|
||||||
|
checksum: body.checksum ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: attachment.id,
|
||||||
|
taskId: attachment.taskId,
|
||||||
|
type: attachment.type,
|
||||||
|
url: attachment.url,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
fileSize: attachment.fileSize,
|
||||||
|
width: attachment.width,
|
||||||
|
height: attachment.height,
|
||||||
|
durationMs: attachment.durationMs,
|
||||||
|
checksum: attachment.checksum,
|
||||||
|
createdAt: attachment.createdAt.toISOString(),
|
||||||
|
updatedAt: attachment.updatedAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getS3Client(): S3Client {
|
||||||
|
if (this.s3Client) {
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = this.configService.get<string>("S3_ENDPOINT") ?? "http://127.0.0.1:9000";
|
||||||
|
const region = this.configService.get<string>("S3_REGION") ?? "us-east-1";
|
||||||
|
const forcePathStyle =
|
||||||
|
this.configService.get<string>("S3_FORCE_PATH_STYLE")?.toLowerCase() !== "false";
|
||||||
|
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.configService.get<string>("S3_ACCESS_KEY_ID") ?? "minioadmin",
|
||||||
|
secretAccessKey: this.configService.get<string>("S3_SECRET_ACCESS_KEY") ?? "minioadmin"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultBucket(): string {
|
||||||
|
return this.configService.get<string>("S3_BUCKET") ?? "todolist";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPresignExpiresInSeconds(): number {
|
||||||
|
const configValue = Number(this.configService.get<string>("S3_PRESIGN_EXPIRES_SECONDS") ?? 900);
|
||||||
|
if (!Number.isFinite(configValue) || configValue <= 0) {
|
||||||
|
return 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(configValue, 604800);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateObjectKey(userId: string, fileName: string): string {
|
||||||
|
const safeFileName = fileName.replace(/[^\w.-]+/g, "_");
|
||||||
|
const datePrefix = new Date().toISOString().slice(0, 10);
|
||||||
|
return `${userId}/${datePrefix}/${randomUUID()}-${safeFileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveObjectUrl(bucket: string, objectKey: string): string {
|
||||||
|
const publicBaseUrl = this.configService.get<string>("S3_PUBLIC_BASE_URL");
|
||||||
|
if (publicBaseUrl) {
|
||||||
|
return `${publicBaseUrl.replace(/\/+$/, "")}/${bucket}/${objectKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = this.configService.get<string>("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 async ensureTaskOwnership(userId: string, taskId: string): Promise<void> {
|
||||||
|
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<PrismaService, "user"> = this.prismaService
|
||||||
|
): Promise<QuotaInfo> {
|
||||||
|
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("存储配额不足");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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; debugCode: string }> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 { 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<string>("AUTH_ACCESS_SECRET") ?? "dev-access-secret",
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: expiresInSeconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, GithubStrategy, QqStrategy, WechatStrategy]
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
type EmailCodeEntry = {
|
||||||
|
code: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RefreshTokenEntry = {
|
||||||
|
userId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
revokedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TwoFactorEntry = {
|
||||||
|
secret: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthTokenResult = {
|
||||||
|
accessToken: string;
|
||||||
|
tokenType: "Bearer";
|
||||||
|
expiresInSeconds: number;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshExpiresInSeconds: number;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly emailCodeStore = new Map<string, EmailCodeEntry>();
|
||||||
|
private readonly userStoreByEmail = new Map<string, AuthUser>();
|
||||||
|
private readonly userStoreById = new Map<string, AuthUser>();
|
||||||
|
private readonly refreshTokenStore = new Map<string, RefreshTokenEntry>();
|
||||||
|
private readonly twoFactorStore = new Map<string, TwoFactorEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly jwtService: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendEmailCode(
|
||||||
|
email: string
|
||||||
|
): Promise<{ success: boolean; expiresInSeconds: number; debugCode: string }> {
|
||||||
|
const ttlSeconds = Number(this.configService.get("AUTH_EMAIL_CODE_TTL_SECONDS") ?? 300);
|
||||||
|
const code = this.generateCode();
|
||||||
|
const expiresAt = Date.now() + ttlSeconds * 1000;
|
||||||
|
|
||||||
|
this.emailCodeStore.set(email.toLowerCase(), { code, expiresAt });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresInSeconds: ttlSeconds,
|
||||||
|
debugCode: code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithEmailCode(email: string, code: string): Promise<AuthTokenResult> {
|
||||||
|
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 = this.getOrCreateUser(lowerEmail);
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(refreshToken: string): Promise<AuthTokenResult> {
|
||||||
|
const entry = this.refreshTokenStore.get(refreshToken);
|
||||||
|
if (!entry) {
|
||||||
|
throw new UnauthorizedException("刷新令牌不存在");
|
||||||
|
}
|
||||||
|
if (entry.revokedAt) {
|
||||||
|
throw new UnauthorizedException("刷新令牌已注销");
|
||||||
|
}
|
||||||
|
if (entry.expiresAt < Date.now()) {
|
||||||
|
this.refreshTokenStore.delete(refreshToken);
|
||||||
|
throw new UnauthorizedException("刷新令牌已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.userStoreById.get(entry.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.revokedAt = Date.now();
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeRefreshToken(refreshToken: string): Promise<{ success: boolean }> {
|
||||||
|
const entry = this.refreshTokenStore.get(refreshToken);
|
||||||
|
if (!entry) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.revokedAt = Date.now();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrollTwoFactor(
|
||||||
|
email: string
|
||||||
|
): Promise<{ userId: string; secret: string; otpauthUrl: string; enabled: boolean }> {
|
||||||
|
const user = this.getOrCreateUser(email.toLowerCase());
|
||||||
|
const secret = authenticator.generateSecret();
|
||||||
|
const issuer = this.configService.get<string>("AUTH_TOTP_ISSUER") ?? "TodoList";
|
||||||
|
const otpauthUrl = authenticator.keyuri(user.email, issuer, secret);
|
||||||
|
|
||||||
|
this.twoFactorStore.set(user.id, {
|
||||||
|
secret,
|
||||||
|
enabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
secret,
|
||||||
|
otpauthUrl,
|
||||||
|
enabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyTwoFactor(
|
||||||
|
email: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: boolean; enabled: boolean }> {
|
||||||
|
const user = this.getOrCreateUser(email.toLowerCase());
|
||||||
|
const entry = this.twoFactorStore.get(user.id);
|
||||||
|
if (!entry) {
|
||||||
|
throw new UnauthorizedException("尚未启用两步验证");
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = authenticator.check(token, entry.secret);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedException("两步验证码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.enabled = true;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateUser(email: string): AuthUser {
|
||||||
|
const existingUser = this.userStoreByEmail.get(email);
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
email
|
||||||
|
};
|
||||||
|
this.userStoreByEmail.set(email, newUser);
|
||||||
|
this.userStoreById.set(newUser.id, newUser);
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateCode(): string {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueTokens(user: AuthUser): Promise<AuthTokenResult> {
|
||||||
|
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()}`;
|
||||||
|
|
||||||
|
this.refreshTokenStore.set(refreshToken, {
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: Date.now() + refreshExpiresInSeconds * 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
expiresInSeconds: accessExpiresInSeconds,
|
||||||
|
refreshToken,
|
||||||
|
refreshExpiresInSeconds,
|
||||||
|
user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsEmail, IsString, Length, Matches } from "class-validator";
|
||||||
|
|
||||||
|
export class EmailLoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/)
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsEmail } from "class-validator";
|
||||||
|
|
||||||
|
export class SendEmailCodeDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsEmail } from "class-validator";
|
||||||
|
|
||||||
|
export class TwoFactorEnrollDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsEmail, IsString, Length, Matches } from "class-validator";
|
||||||
|
|
||||||
|
export class TwoFactorVerifyDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/)
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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<string>("OAUTH_GITHUB_CLIENT_ID") ?? "github-client-id",
|
||||||
|
clientSecret:
|
||||||
|
configService.get<string>("OAUTH_GITHUB_CLIENT_SECRET") ?? "github-client-secret",
|
||||||
|
callbackURL:
|
||||||
|
configService.get<string>("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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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<string>("OAUTH_QQ_AUTH_URL") ?? "https://graph.qq.com/oauth2.0/authorize",
|
||||||
|
tokenURL:
|
||||||
|
configService.get<string>("OAUTH_QQ_TOKEN_URL") ?? "https://graph.qq.com/oauth2.0/token",
|
||||||
|
clientID: configService.get<string>("OAUTH_QQ_CLIENT_ID") ?? "qq-client-id",
|
||||||
|
clientSecret: configService.get<string>("OAUTH_QQ_CLIENT_SECRET") ?? "qq-client-secret",
|
||||||
|
callbackURL:
|
||||||
|
configService.get<string>("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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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<string>("OAUTH_WECHAT_AUTH_URL") ??
|
||||||
|
"https://open.weixin.qq.com/connect/qrconnect",
|
||||||
|
tokenURL:
|
||||||
|
configService.get<string>("OAUTH_WECHAT_TOKEN_URL") ??
|
||||||
|
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||||
|
clientID: configService.get<string>("OAUTH_WECHAT_CLIENT_ID") ?? "wechat-client-id",
|
||||||
|
clientSecret:
|
||||||
|
configService.get<string>("OAUTH_WECHAT_CLIENT_SECRET") ?? "wechat-client-secret",
|
||||||
|
callbackURL:
|
||||||
|
configService.get<string>("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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.listen(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "./prisma.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService]
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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<string>("DATABASE_URL");
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("缺少数据库连接配置 DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
super({
|
||||||
|
adapter: new PrismaPg({
|
||||||
|
connectionString
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
@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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
@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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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<ListTasksResponse> {
|
||||||
|
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<TaskResponse> {
|
||||||
|
return this.taskService.getTaskById(this.resolveUserId(userIdHeader), taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createTask(
|
||||||
|
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
||||||
|
@Body() body: CreateTaskDto
|
||||||
|
): Promise<TaskResponse> {
|
||||||
|
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<TaskResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 {}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.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) {}
|
||||||
|
|
||||||
|
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = this.buildWhereInput(userId, query);
|
||||||
|
const orderBy = this.buildOrderByInput(query);
|
||||||
|
|
||||||
|
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) => this.serializeTask(item)),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaskById(userId: string, taskId: string): Promise<TaskResponse> {
|
||||||
|
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<TaskResponse> {
|
||||||
|
const tagNames = this.normalizeTagNames(body.tagNames);
|
||||||
|
const nextStatus = body.status ?? TaskStatus.TODO;
|
||||||
|
const contentJson =
|
||||||
|
body.contentJson !== undefined ? (body.contentJson as Prisma.InputJsonValue) : undefined;
|
||||||
|
|
||||||
|
const task = await this.prismaService.$transaction(async (tx) => {
|
||||||
|
const createdTask = await tx.task.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: body.title,
|
||||||
|
contentJson,
|
||||||
|
contentText: body.contentText ?? null,
|
||||||
|
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<TaskResponse> {
|
||||||
|
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 = body.title;
|
||||||
|
}
|
||||||
|
if (body.contentJson !== undefined) {
|
||||||
|
data.contentJson = body.contentJson as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
if (body.contentText !== undefined) {
|
||||||
|
data.contentText = 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): 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 (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<string>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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) => ({
|
||||||
|
taskId,
|
||||||
|
tagId: tag.id
|
||||||
|
})),
|
||||||
|
skipDuplicates: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeTask(task: TaskEntity): TaskResponse {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
contentJson: task.contentJson,
|
||||||
|
contentText: 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) => taskTag.tag.name),
|
||||||
|
createdAt: task.createdAt.toISOString(),
|
||||||
|
updatedAt: task.updatedAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { PrismaService } from "../src/prisma/prisma.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<T>(runner: (tx: InMemoryPrismaService) => Promise<T>): Promise<T> {
|
||||||
|
return runner(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService }
|
||||||
|
]
|
||||||
|
}).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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "../../packages/tsconfig/nest-app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "generated/prisma/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["**/node_modules/**", "**/.turbo/**", "**/dist/**", "**/build/**"]
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.cjs"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "commonjs",
|
||||||
|
globals: {
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
+10
-2
@@ -7,9 +7,11 @@
|
|||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"dev": "turbo run dev --parallel",
|
"dev": "turbo run dev --parallel",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint && eslint .",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"format": "turbo run format"
|
"format": "prettier --write .",
|
||||||
|
"lint:staged": "lint-staged",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -17,6 +19,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@9.15.2",
|
"packageManager": "pnpm@9.15.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"turbo": "^2.9.3"
|
"turbo": "^2.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+10019
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user