Merge pull request #6 from Yaosanqi137/feature/p2-api-task-attachment

Feature/p2 api task attachment
This commit is contained in:
Yaosanqi137
2026-04-05 02:15:40 +08:00
committed by GitHub
51 changed files with 13116 additions and 42 deletions
+66
View File
@@ -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."
+59
View File
@@ -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."
+59
View File
@@ -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."
+46
View File
@@ -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
View File
@@ -2,3 +2,5 @@ develop.md
node_modules/
.turbo/
.idea/
.eslintcache
/.husky/_
+1
View File
@@ -0,0 +1 @@
pnpm lint:staged
+2
View File
@@ -0,0 +1,2 @@
pnpm typecheck
pnpm test
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
"*.{js,mjs,cjs,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yml,yaml}": ["prettier --write"]
};
+7
View File
@@ -0,0 +1,7 @@
node_modules
.turbo
.idea
dist
build
coverage
*.png
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100
}
+69
View File
@@ -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 缓存、临时导出文件)。
- 不要随意修改与当前任务无关的历史代码。
- 如发现仓库出现非本人预期改动,先暂停并和维护者确认。
+27
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
node_modules
# 环境变量文件不纳入版本控制
.env
/generated/prisma
dist
prisma.config.js
prisma.config.js.map
View File
+11
View File
@@ -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" }]
}
};
+58
View File
@@ -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"
}
}
+13
View File
@@ -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"]
}
});
+403
View File
@@ -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")
}
+20
View File
@@ -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;
}
+120
View File
@@ -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
};
}
}
+32
View File
@@ -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 {}
+211
View File
@@ -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
};
}
}
+11
View File
@@ -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
};
}
}
+19
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
+28
View File
@@ -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();
}
}
+64
View File
@@ -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;
}
+65
View File
@@ -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[];
}
+71
View File
@@ -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;
}
}
+11
View File
@@ -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 {}
+390
View File
@@ -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()
};
}
}
+464
View File
@@ -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);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
+10
View File
@@ -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"]
}
+9
View File
@@ -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"]
}
+29
View File
@@ -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
View File
@@ -7,9 +7,11 @@
"test": "turbo run test",
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "turbo run lint",
"lint": "turbo run lint && eslint .",
"typecheck": "turbo run typecheck",
"format": "turbo run format"
"format": "prettier --write .",
"lint:staged": "lint-staged",
"prepare": "husky"
},
"keywords": [],
"author": "",
@@ -17,6 +19,12 @@
"private": true,
"packageManager": "pnpm@9.15.2",
"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"
}
}
+10019 -40
View File
File diff suppressed because it is too large Load Diff