diff --git a/apps/server/prisma/migrations/20260708020000_v24_member_domain_fields/migration.sql b/apps/server/prisma/migrations/20260708020000_v24_member_domain_fields/migration.sql new file mode 100644 index 0000000..65bb641 --- /dev/null +++ b/apps/server/prisma/migrations/20260708020000_v24_member_domain_fields/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "username" TEXT, + ADD COLUMN IF NOT EXISTS "department_id" TEXT, + ADD COLUMN IF NOT EXISTS "role_id" TEXT NOT NULL DEFAULT 'member', + ADD COLUMN IF NOT EXISTS "phone" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "password" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_username_key" ON "users"("username"); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 599b404..005491c 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -8,12 +8,18 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - name String - avatar String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(cuid()) + email String @unique + name String + avatar String? + username String? @unique + departmentId String? @map("department_id") + roleId String @default("member") @map("role_id") + phone String @default("") + password String @default("") + isSystem Boolean @default(false) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") createdTasks Task[] @relation("TaskCreator") assignedTasks Task[] @relation("TaskAssignee") diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index f496c80..bf16a90 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -10,6 +10,10 @@ import { VersionPlanModule } from './modules/version-plan/version-plan.module'; import { DevTaskModule } from './modules/dev-task/dev-task.module'; import { TestCaseModule } from './modules/test-case/test-case.module'; import { BugModule } from './modules/bug/bug.module'; +import { MemberModule } from './modules/member/member.module'; +import { TaskCategoryModule } from './modules/task-category/task-category.module'; +import { TaskWorklogModule } from './modules/task-worklog/task-worklog.module'; +import { OvertimeModule } from './modules/overtime/overtime.module'; import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; @@ -27,6 +31,10 @@ import { HealthModule } from './modules/health/health.module'; DevTaskModule, TestCaseModule, BugModule, + MemberModule, + TaskCategoryModule, + TaskWorklogModule, + OvertimeModule, RequirementModule, ConfigModule, DataModule, diff --git a/apps/server/src/modules/member/dto/create-member.dto.ts b/apps/server/src/modules/member/dto/create-member.dto.ts new file mode 100644 index 0000000..0784ddc --- /dev/null +++ b/apps/server/src/modules/member/dto/create-member.dto.ts @@ -0,0 +1,34 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateMemberDto { + @IsString() + name!: string; + + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + departmentId?: string; + + @IsString() + @IsOptional() + roleId?: string; + + @IsString() + @IsOptional() + phone?: string; + + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + password?: string; + + @IsBoolean() + @IsOptional() + isSystem?: boolean; +} diff --git a/apps/server/src/modules/member/dto/update-member.dto.ts b/apps/server/src/modules/member/dto/update-member.dto.ts new file mode 100644 index 0000000..eeb4fa2 --- /dev/null +++ b/apps/server/src/modules/member/dto/update-member.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateMemberDto } from './create-member.dto'; + +export class UpdateMemberDto extends PartialType(CreateMemberDto) {} diff --git a/apps/server/src/modules/member/member.controller.ts b/apps/server/src/modules/member/member.controller.ts new file mode 100644 index 0000000..88c18f4 --- /dev/null +++ b/apps/server/src/modules/member/member.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; +import { MemberService } from './member.service'; + +@Controller('members') +export class MemberController { + constructor(private readonly memberService: MemberService) {} + + @Get() + findAll() { + return this.memberService.findAll(); + } + + @Post() + create(@Body() dto: CreateMemberDto) { + return this.memberService.create(dto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateMemberDto) { + return this.memberService.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.memberService.remove(id); + } +} diff --git a/apps/server/src/modules/member/member.module.ts b/apps/server/src/modules/member/member.module.ts new file mode 100644 index 0000000..924e68b --- /dev/null +++ b/apps/server/src/modules/member/member.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MemberController } from './member.controller'; +import { MemberService } from './member.service'; + +@Module({ + controllers: [MemberController], + providers: [MemberService], + exports: [MemberService], +}) +export class MemberModule {} diff --git a/apps/server/src/modules/member/member.service.spec.ts b/apps/server/src/modules/member/member.service.spec.ts new file mode 100644 index 0000000..4eabc09 --- /dev/null +++ b/apps/server/src/modules/member/member.service.spec.ts @@ -0,0 +1,94 @@ +import { BadRequestException } from '@nestjs/common'; +import { MemberService } from './member.service'; + +describe('MemberService domain writes', () => { + const makeService = () => { + const prisma = { + user: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + return { prisma, service: new MemberService(prisma as any) }; + }; + + it('creates members with UI role and department fields in users', async () => { + const { prisma, service } = makeService(); + prisma.user.create.mockResolvedValue({ + id: 'member-1', + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + isSystem: false, + createdAt: new Date('2026-07-08T00:00:00.000Z'), + }); + + await service.create({ + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + }), + }); + }); + + it('protects the built-in admin from deletion and privileged field edits', async () => { + const { prisma, service } = makeService(); + prisma.user.update.mockResolvedValue({ + id: 'm-8', + name: '超级管理员', + username: 'admin', + roleId: 'role-admin', + departmentId: '', + phone: '13200132009', + email: 'admin@example.com', + password: 'Ftb12345', + isSystem: true, + createdAt: new Date('2026-07-08T00:00:00.000Z'), + }); + + await expect(service.remove('m-8')).rejects.toBeInstanceOf(BadRequestException); + + await service.update('m-8', { + name: '误改', + username: 'other', + roleId: 'role-viewer', + departmentId: 'dept-1', + phone: '13200132009', + }); + + expect(prisma.user.delete).not.toHaveBeenCalled(); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'm-8' }, + data: expect.not.objectContaining({ + name: '误改', + username: 'other', + roleId: 'role-viewer', + departmentId: 'dept-1', + }), + }); + expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ phone: '13200132009', isSystem: true }), + })); + }); +}); diff --git a/apps/server/src/modules/member/member.service.ts b/apps/server/src/modules/member/member.service.ts new file mode 100644 index 0000000..7333228 --- /dev/null +++ b/apps/server/src/modules/member/member.service.ts @@ -0,0 +1,95 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; + +const SYSTEM_ADMIN_MEMBER_ID = 'm-8'; +const SYSTEM_ADMIN_NAME = '超级管理员'; +const SYSTEM_ADMIN_USERNAME = 'admin'; +const SYSTEM_ADMIN_ROLE_ID = 'role-admin'; + +@Injectable() +export class MemberService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + const rows = await (this.prisma.user as any).findMany({ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }] }); + return rows.map(toMember); + } + + async create(dto: CreateMemberDto) { + const data = toUserData(dto); + const row = await (this.prisma.user as any).create({ data }); + return toMember(row); + } + + async update(id: string, dto: UpdateMemberDto) { + const data = id === SYSTEM_ADMIN_MEMBER_ID + ? { ...sanitizeSystemAdminPatch(dto), isSystem: true } + : toUserData(dto, { partial: true }); + const row = await (this.prisma.user as any).update({ where: { id }, data }); + return toMember(row); + } + + async remove(id: string) { + if (id === SYSTEM_ADMIN_MEMBER_ID) { + throw new BadRequestException('系统内置超级管理员不可删除'); + } + await (this.prisma.user as any).delete({ where: { id } }); + return { deleted: true }; + } +} + +function sanitizeSystemAdminPatch(dto: UpdateMemberDto) { + const data = toUserData(dto, { partial: true }); + delete (data as any).name; + delete (data as any).username; + delete (data as any).departmentId; + delete (data as any).roleId; + delete (data as any).email; + return data; +} + +function toUserData(dto: Partial, options: { partial?: boolean } = {}) { + const id = (dto as any).id as string | undefined; + const email = dto.email?.trim() || (options.partial ? undefined : `${id || dto.username || Date.now()}@local.ftb`); + return { + ...(id !== undefined && { id }), + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.username !== undefined && { username: emptyToNull(dto.username) }), + ...(dto.departmentId !== undefined && { departmentId: emptyToNull(dto.departmentId) }), + ...(dto.roleId !== undefined && { roleId: dto.roleId || 'member' }), + ...(dto.phone !== undefined && { phone: dto.phone ?? '' }), + ...(email !== undefined && { email }), + ...(dto.password !== undefined && { password: dto.password ?? '' }), + ...(dto.isSystem !== undefined && { isSystem: dto.isSystem }), + }; +} + +function toMember(row: any) { + return { + id: row.id, + name: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_NAME : row.name, + username: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_USERNAME : row.username ?? undefined, + departmentId: row.id === SYSTEM_ADMIN_MEMBER_ID ? '' : row.departmentId ?? '', + roleId: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_ROLE_ID : row.roleId ?? 'member', + phone: row.phone ?? '', + email: row.email?.endsWith('@local.ftb') ? '' : row.email ?? '', + password: row.password ?? '', + createdAt: toIsoDate(row.createdAt), + isSystem: Boolean(row.isSystem) || row.id === SYSTEM_ADMIN_MEMBER_ID, + }; +} + +function emptyToNull(value: string | null | undefined): string | null { + if (value === null) return null; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function toIsoDate(value: unknown): string { + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value === 'string' && value) return value.slice(0, 10); + return new Date().toISOString().slice(0, 10); +} diff --git a/apps/server/src/modules/overtime/dto/create-overtime.dto.ts b/apps/server/src/modules/overtime/dto/create-overtime.dto.ts new file mode 100644 index 0000000..6ad5309 --- /dev/null +++ b/apps/server/src/modules/overtime/dto/create-overtime.dto.ts @@ -0,0 +1,34 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateOvertimeDto { + @IsString() + @IsOptional() + productId?: string; + + @IsString() + @IsOptional() + projectId?: string; + + @IsString() + @IsOptional() + versionId?: string; + + @IsString() + person!: string; + + @IsString() + startTime!: string; + + @IsString() + endTime!: string; + + @IsOptional() + duration?: number; + + @IsString() + reasonId!: string; + + @IsString() + @IsOptional() + remark?: string; +} diff --git a/apps/server/src/modules/overtime/dto/update-overtime.dto.ts b/apps/server/src/modules/overtime/dto/update-overtime.dto.ts new file mode 100644 index 0000000..08d5da0 --- /dev/null +++ b/apps/server/src/modules/overtime/dto/update-overtime.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateOvertimeDto } from './create-overtime.dto'; + +export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {} diff --git a/apps/server/src/modules/overtime/overtime.controller.ts b/apps/server/src/modules/overtime/overtime.controller.ts new file mode 100644 index 0000000..f142442 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateOvertimeDto } from './dto/create-overtime.dto'; +import { UpdateOvertimeDto } from './dto/update-overtime.dto'; +import { OvertimeService } from './overtime.service'; + +@Controller('overtime') +export class OvertimeController { + constructor(private readonly overtimeService: OvertimeService) {} + + @Get() + findAll() { + return this.overtimeService.findAll(); + } + + @Post() + create(@Body() dto: CreateOvertimeDto) { + return this.overtimeService.create(dto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) { + return this.overtimeService.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.overtimeService.remove(id); + } +} diff --git a/apps/server/src/modules/overtime/overtime.module.ts b/apps/server/src/modules/overtime/overtime.module.ts new file mode 100644 index 0000000..0351b3f --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { OvertimeController } from './overtime.controller'; +import { OvertimeService } from './overtime.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [OvertimeController], + providers: [OvertimeService], + exports: [OvertimeService], +}) +export class OvertimeModule {} diff --git a/apps/server/src/modules/overtime/overtime.service.spec.ts b/apps/server/src/modules/overtime/overtime.service.spec.ts new file mode 100644 index 0000000..df0a5e8 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.service.spec.ts @@ -0,0 +1,37 @@ +import { OvertimeService } from './overtime.service'; + +describe('OvertimeService domain writes', () => { + it('writes overtime records to relation table', async () => { + const prisma = { + overtimeRecord: { + create: jest.fn().mockResolvedValue({ id: 'ot-1' }), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + const service = new OvertimeService(prisma as any); + + await service.create({ + projectId: 'project-1', + versionId: 'version-1', + person: 'member-1', + startTime: '2026-07-08T19:00:00.000Z', + endTime: '2026-07-08T21:00:00.000Z', + duration: 2, + reasonId: 'reason-4', + }); + + expect(prisma.overtimeRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + projectId: 'project-1', + versionId: 'version-1', + userId: 'member-1', + reason: 'reason-4', + startAt: new Date('2026-07-08T19:00:00.000Z'), + endAt: new Date('2026-07-08T21:00:00.000Z'), + hours: 2, + }), + }); + }); +}); diff --git a/apps/server/src/modules/overtime/overtime.service.ts b/apps/server/src/modules/overtime/overtime.service.ts new file mode 100644 index 0000000..1b1d8c4 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateOvertimeDto } from './dto/create-overtime.dto'; +import { UpdateOvertimeDto } from './dto/update-overtime.dto'; + +@Injectable() +export class OvertimeService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity?: WorkActivityService, + ) {} + + async create(dto: CreateOvertimeDto) { + const item = await this.prisma.overtimeRecord.create({ + data: { + productId: dto.productId || null, + projectId: dto.projectId || null, + versionId: dto.versionId || null, + userId: dto.person, + reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId, + startAt: new Date(dto.startTime), + endAt: new Date(dto.endTime), + hours: dto.duration ?? 0, + }, + }); + if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId); + return item; + } + + findAll() { + return this.prisma.overtimeRecord.findMany({ orderBy: { createdAt: 'desc' } }); + } + + update(id: string, dto: UpdateOvertimeDto) { + return this.prisma.overtimeRecord.updateMany({ where: { id }, data: toData(dto) }); + } + + async remove(id: string) { + await this.prisma.overtimeRecord.deleteMany({ where: { id } }); + return { deleted: true }; + } +} + +function toData(dto: UpdateOvertimeDto) { + return { + ...(dto.productId !== undefined && { productId: dto.productId || null }), + ...(dto.projectId !== undefined && { projectId: dto.projectId || null }), + ...(dto.versionId !== undefined && { versionId: dto.versionId || null }), + ...(dto.person !== undefined && { userId: dto.person }), + ...(dto.reasonId !== undefined && { reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId }), + ...(dto.startTime !== undefined && { startAt: new Date(dto.startTime) }), + ...(dto.endTime !== undefined && { endAt: new Date(dto.endTime) }), + ...(dto.duration !== undefined && { hours: dto.duration ?? 0 }), + }; +} diff --git a/apps/server/src/modules/task-category/dto/create-task-category.dto.ts b/apps/server/src/modules/task-category/dto/create-task-category.dto.ts new file mode 100644 index 0000000..2b26b46 --- /dev/null +++ b/apps/server/src/modules/task-category/dto/create-task-category.dto.ts @@ -0,0 +1,24 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateTaskCategoryDto { + @IsString() + name!: string; + + @IsString() + group!: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsOptional() + sortOrder?: number; + + @IsBoolean() + @IsOptional() + isSystem?: boolean; +} diff --git a/apps/server/src/modules/task-category/dto/update-task-category.dto.ts b/apps/server/src/modules/task-category/dto/update-task-category.dto.ts new file mode 100644 index 0000000..0f83383 --- /dev/null +++ b/apps/server/src/modules/task-category/dto/update-task-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTaskCategoryDto } from './create-task-category.dto'; + +export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {} diff --git a/apps/server/src/modules/task-category/task-category.controller.ts b/apps/server/src/modules/task-category/task-category.controller.ts new file mode 100644 index 0000000..efaa60f --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateTaskCategoryDto } from './dto/create-task-category.dto'; +import { UpdateTaskCategoryDto } from './dto/update-task-category.dto'; +import { TaskCategoryService } from './task-category.service'; + +@Controller('task-categories') +export class TaskCategoryController { + constructor(private readonly taskCategoryService: TaskCategoryService) {} + + @Get() + findAll() { + return this.taskCategoryService.findAll(); + } + + @Post() + create(@Body() dto: CreateTaskCategoryDto) { + return this.taskCategoryService.create(dto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) { + return this.taskCategoryService.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.taskCategoryService.remove(id); + } +} diff --git a/apps/server/src/modules/task-category/task-category.module.ts b/apps/server/src/modules/task-category/task-category.module.ts new file mode 100644 index 0000000..57c5df3 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TaskCategoryController } from './task-category.controller'; +import { TaskCategoryService } from './task-category.service'; + +@Module({ + controllers: [TaskCategoryController], + providers: [TaskCategoryService], + exports: [TaskCategoryService], +}) +export class TaskCategoryModule {} diff --git a/apps/server/src/modules/task-category/task-category.service.spec.ts b/apps/server/src/modules/task-category/task-category.service.spec.ts new file mode 100644 index 0000000..d4e7ab2 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.service.spec.ts @@ -0,0 +1,41 @@ +import { TaskCategoryService } from './task-category.service'; + +describe('TaskCategoryService domain writes', () => { + const makeService = () => { + const prisma = { + taskCategory: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + return { prisma, service: new TaskCategoryService(prisma as any) }; + }; + + it('creates task categories with uniqueness scoped by group and name', async () => { + const { prisma, service } = makeService(); + prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' }); + + await service.create({ name: '前端开发', group: 'development', code: 'frontend_development' }); + + expect(prisma.taskCategory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: '前端开发', + group: 'development', + code: 'frontend_development', + isSystem: false, + }), + }); + }); + + it('does not delete system categories', async () => { + const { prisma, service } = makeService(); + prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-system', isSystem: true }]); + + const result = await service.remove('cat-system'); + + expect(result).toEqual({ deleted: false }); + expect(prisma.taskCategory.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/task-category/task-category.service.ts b/apps/server/src/modules/task-category/task-category.service.ts new file mode 100644 index 0000000..861e013 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateTaskCategoryDto } from './dto/create-task-category.dto'; +import { UpdateTaskCategoryDto } from './dto/update-task-category.dto'; + +@Injectable() +export class TaskCategoryService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }); + } + + create(dto: CreateTaskCategoryDto) { + return this.prisma.taskCategory.create({ + data: { + name: dto.name, + group: dto.group, + code: dto.code, + isSystem: dto.isSystem ?? false, + }, + }); + } + + update(id: string, dto: UpdateTaskCategoryDto) { + return this.prisma.taskCategory.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.group !== undefined && { group: dto.group }), + ...(dto.code !== undefined && { code: dto.code }), + ...(dto.isSystem !== undefined && { isSystem: dto.isSystem }), + }, + }); + } + + async remove(id: string) { + const [target] = await this.prisma.taskCategory.findMany({ where: { id }, take: 1 }); + if (!target || target.isSystem) return { deleted: false }; + await this.prisma.taskCategory.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts b/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts new file mode 100644 index 0000000..dc54016 --- /dev/null +++ b/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts @@ -0,0 +1,35 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateTaskWorklogDto { + @IsString() + taskId!: string; + + @IsString() + userId!: string; + + @IsString() + date!: string; + + @IsOptional() + hours?: number; + + @IsString() + @IsOptional() + workContent?: string; + + @IsString() + @IsOptional() + sourceType?: string; + + @IsString() + @IsOptional() + versionId?: string; + + @IsString() + @IsOptional() + productId?: string; + + @IsString() + @IsOptional() + projectId?: string; +} diff --git a/apps/server/src/modules/task-worklog/task-worklog.controller.ts b/apps/server/src/modules/task-worklog/task-worklog.controller.ts new file mode 100644 index 0000000..c6876d8 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto'; +import { TaskWorklogService } from './task-worklog.service'; + +@Controller('task-worklogs') +export class TaskWorklogController { + constructor(private readonly taskWorklogService: TaskWorklogService) {} + + @Get() + findAll() { + return this.taskWorklogService.findAll(); + } + + @Post() + create(@Body() dto: CreateTaskWorklogDto) { + return this.taskWorklogService.create(dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.taskWorklogService.remove(id); + } +} diff --git a/apps/server/src/modules/task-worklog/task-worklog.module.ts b/apps/server/src/modules/task-worklog/task-worklog.module.ts new file mode 100644 index 0000000..db27151 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { TaskWorklogController } from './task-worklog.controller'; +import { TaskWorklogService } from './task-worklog.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [TaskWorklogController], + providers: [TaskWorklogService], + exports: [TaskWorklogService], +}) +export class TaskWorklogModule {} diff --git a/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts b/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts new file mode 100644 index 0000000..e9ce36f --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts @@ -0,0 +1,36 @@ +import { TaskWorklogService } from './task-worklog.service'; + +describe('TaskWorklogService append writes', () => { + it('appends task worklogs as relational evidence rows', async () => { + const prisma = { + taskWorklog: { + create: jest.fn().mockResolvedValue({ id: 'wl-1' }), + deleteMany: jest.fn(), + findMany: jest.fn(), + }, + }; + const service = new TaskWorklogService(prisma as any); + + await service.create({ + taskId: 'task-1', + userId: 'member-1', + date: '2026-07-08', + hours: 2, + workContent: '补充日报', + versionId: 'version-1', + }); + + expect(prisma.taskWorklog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + sourceType: 'dev_task', + sourceId: 'task-1', + sourceVersionId: 'version-1', + versionId: 'version-1', + userId: 'member-1', + workDate: new Date('2026-07-08T00:00:00.000Z'), + hours: 2, + content: '补充日报', + }), + }); + }); +}); diff --git a/apps/server/src/modules/task-worklog/task-worklog.service.ts b/apps/server/src/modules/task-worklog/task-worklog.service.ts new file mode 100644 index 0000000..e310d14 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto'; + +@Injectable() +export class TaskWorklogService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity?: WorkActivityService, + ) {} + + async create(dto: CreateTaskWorklogDto) { + const item = await this.prisma.taskWorklog.create({ + data: { + versionId: dto.versionId ?? null, + productId: dto.productId ?? null, + projectId: dto.projectId ?? null, + userId: dto.userId, + sourceType: dto.sourceType ?? 'dev_task', + sourceId: dto.taskId, + sourceVersionId: dto.versionId ?? null, + workDate: parseDateOnly(dto.date), + hours: dto.hours ?? 0, + content: dto.workContent ?? '', + }, + }); + if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId); + return item; + } + + findAll() { + return this.prisma.taskWorklog.findMany({ orderBy: { createdAt: 'desc' } }); + } + + async remove(id: string) { + await this.prisma.taskWorklog.deleteMany({ where: { id } }); + return { deleted: true }; + } +} + +function parseDateOnly(value: string): Date { + return new Date(`${value.slice(0, 10)}T00:00:00.000Z`); +} diff --git a/apps/server/src/modules/work-activity/work-activity.controller.ts b/apps/server/src/modules/work-activity/work-activity.controller.ts new file mode 100644 index 0000000..98d16b2 --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service'; + +@Controller('work-activities') +export class WorkActivityController { + constructor(private readonly workActivityService: WorkActivityService) {} + + @Get() + findAll() { + return this.workActivityService.findAll(); + } + + @Post() + create(@Body() dto: WorkActivityRecordInput) { + return this.workActivityService.record(dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.workActivityService.remove(id); + } +} diff --git a/apps/server/src/modules/work-activity/work-activity.module.ts b/apps/server/src/modules/work-activity/work-activity.module.ts index b29b964..ab70dee 100644 --- a/apps/server/src/modules/work-activity/work-activity.module.ts +++ b/apps/server/src/modules/work-activity/work-activity.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; +import { WorkActivityController } from './work-activity.controller'; import { WorkActivityService } from './work-activity.service'; @Module({ + controllers: [WorkActivityController], providers: [WorkActivityService], exports: [WorkActivityService], }) diff --git a/apps/server/src/modules/work-activity/work-activity.service.ts b/apps/server/src/modules/work-activity/work-activity.service.ts index 5589a40..42305ec 100644 --- a/apps/server/src/modules/work-activity/work-activity.service.ts +++ b/apps/server/src/modules/work-activity/work-activity.service.ts @@ -22,6 +22,10 @@ export interface WorkActivityRecordInput { export class WorkActivityService { constructor(private readonly prisma: PrismaService) {} + findAll() { + return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } }); + } + async record(input: WorkActivityRecordInput) { const versionId = input.versionId ?? input.sourceVersionId ?? null; const metadata = { @@ -54,6 +58,11 @@ export class WorkActivityService { return activity; } + async remove(id: string) { + await this.prisma.workActivity.deleteMany({ where: { id } }); + return { deleted: true }; + } + async markXiaobaoSummaryDirty(versionId: string) { const riskSignature = `dirty:${versionId}`; await this.prisma.xiaobaoRiskSummary.upsert({ diff --git a/apps/web/lib/domain-api.ts b/apps/web/lib/domain-api.ts index d9b0b11..5a98d3a 100644 --- a/apps/web/lib/domain-api.ts +++ b/apps/web/lib/domain-api.ts @@ -2,10 +2,14 @@ import { api } from './api'; import type { Bug, BugSeverity, BugStatus } from './bug'; import type { DevTask, DevTaskStatus, Reference } from './dev-task'; import type { Priority } from './derive'; +import type { Member } from './members'; +import type { OvertimeRecord } from './overtime'; import type { Requirement, RequirementStatus, SourceType } from './requirement'; +import type { TaskCategory } from './task-category'; +import type { TaskWorklog } from './task-worklog'; import type { TestCase, TestCaseStatus } from './test-case'; import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan'; -import type { WorkActivity, WorkActivityCategory, WorkActivitySourceType } from './work-activity'; +import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity'; export interface RootProject { id: string; @@ -254,6 +258,51 @@ interface DomainWorkActivityRow { occurredAt?: string | Date | null; } +interface DomainMemberRow { + id: string; + name: string; + username?: string | null; + departmentId?: string | null; + roleId?: string | null; + phone?: string | null; + email?: string | null; + password?: string | null; + isSystem?: boolean | null; + createdAt?: string | Date | null; +} + +interface DomainTaskCategoryRow { + id: string; + name: string; + code?: string | null; + group: string; + isSystem?: boolean | null; + createdAt?: string | Date | null; +} + +interface DomainTaskWorklogRow { + id: string; + userId?: string | null; + sourceId: string; + workDate?: string | Date | null; + hours?: number | null; + content?: string | null; + createdAt?: string | Date | null; +} + +interface DomainOvertimeRow { + id: string; + productId?: string | null; + projectId?: string | null; + versionId?: string | null; + userId?: string | null; + reason?: string | null; + startAt?: string | Date | null; + endAt?: string | Date | null; + hours?: number | null; + createdAt?: string | Date | null; +} + interface DomainMutationResponse { item: T; activities?: DomainWorkActivityRow[]; @@ -436,6 +485,83 @@ export async function deleteBugByVersionId(versionId: string, bugId: string): Pr await api.delete(`/versions/${versionId}/bugs/${bugId}`); } +export async function listMembersDomain(): Promise { + const rows = await api.get('/members'); + return rows.map(normalizeMember); +} + +export async function createMemberDomain(data: Partial, _existing?: Member[]): Promise { + return normalizeMember(await api.post('/members', toMemberPayload(data))); +} + +export async function updateMemberDomain(memberId: string, data: Partial): Promise { + return normalizeMember(await api.patch(`/members/${memberId}`, toMemberPayload(data))); +} + +export async function deleteMemberDomain(memberId: string): Promise { + await api.delete(`/members/${memberId}`); +} + +export async function listTaskCategoriesDomain(): Promise { + const rows = await api.get('/task-categories'); + return rows.map((row, index) => normalizeTaskCategory(row, index)); +} + +export async function createTaskCategoryDomain(data: Partial): Promise { + return normalizeTaskCategory(await api.post('/task-categories', toTaskCategoryPayload(data)), 0); +} + +export async function updateTaskCategoryDomain(categoryId: string, data: Partial): Promise { + return normalizeTaskCategory(await api.patch(`/task-categories/${categoryId}`, toTaskCategoryPayload(data)), 0); +} + +export async function deleteTaskCategoryDomain(categoryId: string): Promise { + await api.delete(`/task-categories/${categoryId}`); +} + +export async function listTaskWorklogsDomain(): Promise { + const rows = await api.get('/task-worklogs'); + return rows.map(normalizeTaskWorklog); +} + +export async function createTaskWorklogDomain(data: TaskWorklog): Promise { + return normalizeTaskWorklog(await api.post('/task-worklogs', toTaskWorklogPayload(data))); +} + +export async function deleteTaskWorklogDomain(worklogId: string): Promise { + await api.delete(`/task-worklogs/${worklogId}`); +} + +export async function listOvertimeRecordsDomain(): Promise { + const rows = await api.get('/overtime'); + return rows.map(normalizeOvertimeRecord); +} + +export async function createOvertimeRecordDomain(data: OvertimeRecord): Promise { + return normalizeOvertimeRecord(await api.post('/overtime', toOvertimePayload(data))); +} + +export async function updateOvertimeRecordDomain(recordId: string, data: Partial): Promise { + await api.patch(`/overtime/${recordId}`, toOvertimePayload(data)); +} + +export async function deleteOvertimeRecordDomain(recordId: string): Promise { + await api.delete(`/overtime/${recordId}`); +} + +export async function listWorkActivitiesDomain(): Promise { + const rows = await api.get('/work-activities'); + return rows.map(normalizeWorkActivity); +} + +export async function createWorkActivityDomain(data: WorkActivity | WorkActivityDraft): Promise { + return normalizeWorkActivity(await api.post('/work-activities', toWorkActivityPayload(data))); +} + +export async function deleteWorkActivityDomain(activityId: string): Promise { + await api.delete(`/work-activities/${activityId}`); +} + function normalizeProductRoot(product: RootProduct): RootProduct { return { ...product, @@ -753,6 +879,126 @@ function toBugSeverity(value: string | null | undefined): BugSeverity { return 'minor'; } +function toMemberPayload(data: Partial) { + return { + ...(data.id !== undefined && { id: data.id }), + ...(data.name !== undefined && { name: data.name }), + ...(data.username !== undefined && { username: data.username }), + ...(data.departmentId !== undefined && { departmentId: data.departmentId }), + ...(data.roleId !== undefined && { roleId: data.roleId }), + ...(data.phone !== undefined && { phone: data.phone }), + ...(data.email !== undefined && { email: data.email }), + ...(data.password !== undefined && { password: data.password }), + ...(data.isSystem !== undefined && { isSystem: data.isSystem }), + }; +} + +function normalizeMember(row: DomainMemberRow): Member { + return { + id: row.id, + name: row.name, + username: row.username ?? undefined, + departmentId: row.departmentId ?? '', + roleId: row.roleId ?? 'member', + phone: row.phone ?? '', + email: row.email ?? '', + password: row.password ?? '', + createdAt: isoString(row.createdAt).slice(0, 10), + isSystem: Boolean(row.isSystem), + }; +} + +function toTaskCategoryPayload(data: Partial) { + return { + ...(data.name !== undefined && { name: data.name }), + ...(data.group !== undefined && { group: data.group }), + ...(data.code !== undefined && { code: data.code }), + ...(data.isSystem !== undefined && { isSystem: data.isSystem }), + }; +} + +function normalizeTaskCategory(row: DomainTaskCategoryRow, index: number): TaskCategory { + return { + id: row.id, + code: row.code ?? row.name, + name: row.name, + group: toCategoryGroup(row.group), + sortOrder: index + 1, + isSystem: Boolean(row.isSystem), + }; +} + +function toCategoryGroup(value: string): TaskCategory['group'] { + if (value === 'development' || value === 'testing' || value === 'implementation') return value; + return 'other'; +} + +function toTaskWorklogPayload(data: TaskWorklog) { + return { + taskId: data.taskId, + userId: data.userId, + date: data.date, + hours: data.hours, + workContent: data.workContent, + }; +} + +function normalizeTaskWorklog(row: DomainTaskWorklogRow): TaskWorklog { + return { + id: row.id, + taskId: row.sourceId, + userId: row.userId ?? '', + date: isoString(row.workDate).slice(0, 10), + hours: row.hours ?? 0, + workContent: row.content ?? '', + createdAt: isoString(row.createdAt), + }; +} + +function toOvertimePayload(data: Partial) { + return { + ...(data.productId !== undefined && { productId: data.productId }), + ...(data.projectId !== undefined && { projectId: data.projectId }), + ...(data.versionId !== undefined && { versionId: data.versionId }), + ...(data.person !== undefined && { person: data.person }), + ...(data.startTime !== undefined && { startTime: data.startTime }), + ...(data.endTime !== undefined && { endTime: data.endTime }), + ...(data.duration !== undefined && { duration: data.duration }), + ...(data.reasonId !== undefined && { reasonId: data.reasonId }), + ...(data.remark !== undefined && { remark: data.remark }), + }; +} + +function normalizeOvertimeRecord(row: DomainOvertimeRow): OvertimeRecord { + const [reasonId, ...remarkParts] = (row.reason ?? '').split(':'); + return { + id: row.id, + projectId: row.projectId ?? '', + versionId: row.versionId ?? undefined, + person: row.userId ?? '', + startTime: isoString(row.startAt), + endTime: isoString(row.endAt), + duration: row.hours ?? 0, + reasonId, + remark: remarkParts.join(':') || undefined, + createdAt: isoString(row.createdAt).slice(0, 10), + }; +} + +function toWorkActivityPayload(data: WorkActivity | WorkActivityDraft) { + return { + actorId: data.actorId, + sourceType: data.sourceType, + sourceId: data.sourceId, + action: data.action, + title: data.title, + category: data.category, + summary: data.summary, + metadata: data.metadata, + occurredAt: data.occurredAt, + }; +} + function normalizeMutation( response: DomainMutationResponse, mapper: (row: Row) => Item, diff --git a/apps/web/lib/overtime.ts b/apps/web/lib/overtime.ts index 7b022d0..d501962 100644 --- a/apps/web/lib/overtime.ts +++ b/apps/web/lib/overtime.ts @@ -5,6 +5,7 @@ import { hasPermission } from './permissions'; export interface OvertimeRecord { id: string; + productId?: string; projectId: string; versionId?: string; requirementId?: string; diff --git a/apps/web/lib/v245-domain-source.test.ts b/apps/web/lib/v245-domain-source.test.ts new file mode 100644 index 0000000..a5fbb4b --- /dev/null +++ b/apps/web/lib/v245-domain-source.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +function store(name: string) { + return readFileSync(join(process.cwd(), `stores/${name}`), 'utf8'); +} + +function storeMethodBody(text: string, name: string) { + const implementationStart = text.indexOf('export const'); + assert.notEqual(implementationStart, -1, 'missing store implementation'); + const start = text.indexOf(` ${name}:`, implementationStart); + assert.notEqual(start, -1, `missing store method ${name}`); + let depth = 0; + let sawFirstBrace = false; + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + if (char === '{') { + depth += 1; + sawFirstBrace = true; + } + if (char === '}') { + depth -= 1; + if (sawFirstBrace && depth === 0) return text.slice(start, i + 1); + } + } + throw new Error(`could not extract store method ${name}`); +} + +test('member and dictionary stores use domain APIs as primary writes', () => { + const members = store('useMemberStore.ts'); + const categories = store('useTaskCategoryStore.ts'); + + assert.match(members, /from '@\/lib\/domain-api'/); + assert.match(storeMethodBody(members, 'createMember'), /createMemberDomain\(member,/); + assert.match(storeMethodBody(members, 'updateMember'), /updateMemberDomain\(id,/); + assert.match(storeMethodBody(members, 'deleteMember'), /deleteMemberDomain\(id\)/); + + assert.match(categories, /from '@\/lib\/domain-api'/); + assert.match(storeMethodBody(categories, 'addCategory'), /createTaskCategoryDomain\(item\)/); + assert.match(storeMethodBody(categories, 'updateCategory'), /updateTaskCategoryDomain\(id,/); + assert.match(storeMethodBody(categories, 'deleteCategory'), /deleteTaskCategoryDomain\(id\)/); +}); + +test('evidence stores use append-style domain APIs as primary writes', () => { + const worklogs = store('useTaskWorklogStore.ts'); + const overtime = store('useOvertimeStore.ts'); + const activities = store('useWorkActivityStore.ts'); + + assert.match(storeMethodBody(worklogs, 'addWorklog'), /createTaskWorklogDomain\(item\)/); + assert.match(storeMethodBody(overtime, 'createRecord'), /createOvertimeRecordDomain\(record\)/); + assert.match(storeMethodBody(activities, 'addActivity'), /createWorkActivityDomain\(item\)/); + + assert.doesNotMatch(storeMethodBody(worklogs, 'addWorklog'), /saveServerData\('task-worklogs'/); + assert.doesNotMatch(storeMethodBody(overtime, 'createRecord'), /saveServerData\('overtime'/); + assert.doesNotMatch(storeMethodBody(activities, 'addActivity'), /saveServerData\('work-activities'/); +}); diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts index 9549ec4..731067a 100644 --- a/apps/web/stores/useMemberStore.ts +++ b/apps/web/stores/useMemberStore.ts @@ -9,6 +9,12 @@ import { ensureSystemAdminMember, sanitizeSystemAdminPatch, } from '@/lib/member-system'; +import { + createMemberDomain, + deleteMemberDomain, + listMembersDomain, + updateMemberDomain, +} from '@/lib/domain-api'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions'; import { mergePresetRolePermissions } from '@/lib/role-permission-migration'; @@ -72,7 +78,10 @@ export const useMemberStore = create((set, get) => ({ fetchMembers: async (options) => { if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const [cached, domainMembers] = await Promise.all([ + loadStored(), + listMembersDomain().catch(() => null), + ]); if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return; lastMembersFetchAt = Date.now(); if (cached) { @@ -93,7 +102,8 @@ export const useMemberStore = create((set, get) => ({ permissionMerged.changed || roles.length !== rolesWithNewDefaults.length || roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index])); - const ensured = ensureSystemAdminMember(cached.members); + const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : cached.members; + const ensured = ensureSystemAdminMember(sourceMembers); const usernameEnsured = ensureMemberUsernames(ensured.members); const next = { departments: cached.departments, @@ -104,7 +114,7 @@ export const useMemberStore = create((set, get) => ({ set({ ...next, loaded: true }); if (ensured.changed || usernameEnsured.changed || rolesChanged) void saveStored(next).catch(() => {}); } else { - set({ loaded: true }); + set({ members: domainMembers && domainMembers.length > 0 ? domainMembers : get().members, loaded: true }); } }, @@ -166,7 +176,16 @@ export const useMemberStore = create((set, get) => ({ const members = [...previous, member]; set({ members }); scheduleSaveWithOptimisticRollback({ - save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }), + save: async () => { + try { + const created = await createMemberDomain(member, previous); + set({ + members: get().members.map((item) => (item.id === member.id ? { ...member, ...created } : item)), + }); + } catch { + await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); + } + }, expected: members, getCurrent: () => get().members, rollback: () => set({ members: previous }), @@ -179,10 +198,14 @@ export const useMemberStore = create((set, get) => ({ const ensured = ensureSystemAdminMember(updatedMembers); set({ members: ensured.members }); try { - await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule }); + await updateMemberDomain(id, safeData); } catch (error) { - set({ members: previousMembers }); - throw error; + try { + await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule }); + } catch { + set({ members: previousMembers }); + throw error; + } } }, deleteMember: (id) => { @@ -191,7 +214,13 @@ export const useMemberStore = create((set, get) => ({ const members = previous.filter((m) => m.id !== id); set({ members }); scheduleSaveWithOptimisticRollback({ - save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }), + save: async () => { + try { + await deleteMemberDomain(id); + } catch { + await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }); + } + }, expected: members, getCurrent: () => get().members, rollback: () => set({ members: previous }), diff --git a/apps/web/stores/useOvertimeStore.ts b/apps/web/stores/useOvertimeStore.ts index d9d5ed3..0d50a90 100644 --- a/apps/web/stores/useOvertimeStore.ts +++ b/apps/web/stores/useOvertimeStore.ts @@ -3,6 +3,12 @@ import { create } from 'zustand'; import type { OvertimeRecord } from '@/lib/overtime'; import type { DictItem } from '@/lib/requirement'; import { calcDuration } from '@/lib/overtime'; +import { + createOvertimeRecordDomain, + deleteOvertimeRecordDomain, + listOvertimeRecordsDomain, + updateOvertimeRecordDomain, +} from '@/lib/domain-api'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; @@ -52,10 +58,17 @@ export const useOvertimeStore = create((set, get) => ({ fetchRecords: async (options) => { if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const [cached, domainRecords] = await Promise.all([ + loadStored(), + listOvertimeRecordsDomain().catch(() => null), + ]); if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return; lastOvertimeFetchAt = Date.now(); - set({ records: cached?.records ?? MOCK_RECORDS, reasons: cached?.reasons ?? PRESET_REASONS, loaded: true }); + set({ + records: domainRecords && domainRecords.length > 0 ? domainRecords : cached?.records ?? MOCK_RECORDS, + reasons: cached?.reasons ?? PRESET_REASONS, + loaded: true, + }); }, createRecord: (data) => { @@ -70,7 +83,17 @@ export const useOvertimeStore = create((set, get) => ({ const records = [...previous, record]; set({ records, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('overtime', { records, reasons: get().reasons }), + save: async () => { + try { + const created = await createOvertimeRecordDomain(record); + set({ + records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)), + loaded: true, + }); + } catch { + await saveOvertimeFallback(records, get().reasons); + } + }, expected: records, getCurrent: () => get().records, rollback: () => set({ records: previous, loaded: true }), @@ -89,7 +112,13 @@ export const useOvertimeStore = create((set, get) => ({ }); set({ records, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('overtime', { records, reasons: get().reasons }), + save: async () => { + try { + await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {}); + } catch { + await saveOvertimeFallback(records, get().reasons); + } + }, expected: records, getCurrent: () => get().records, rollback: () => set({ records: previous, loaded: true }), @@ -101,7 +130,13 @@ export const useOvertimeStore = create((set, get) => ({ const records = previous.filter((r) => r.id !== id); set({ records, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('overtime', { records, reasons: get().reasons }), + save: async () => { + try { + await deleteOvertimeRecordDomain(id); + } catch { + await saveOvertimeFallback(records, get().reasons); + } + }, expected: records, getCurrent: () => get().records, rollback: () => set({ records: previous, loaded: true }), @@ -145,3 +180,7 @@ export const useOvertimeStore = create((set, get) => ({ }); }, })); + +async function saveOvertimeFallback(records: OvertimeRecord[], reasons: DictItem[]) { + await saveServerData('overtime', { records, reasons }); +} diff --git a/apps/web/stores/useTaskCategoryStore.ts b/apps/web/stores/useTaskCategoryStore.ts index 58e4c14..e4cd731 100644 --- a/apps/web/stores/useTaskCategoryStore.ts +++ b/apps/web/stores/useTaskCategoryStore.ts @@ -2,6 +2,12 @@ import { create } from 'zustand'; import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category'; +import { + createTaskCategoryDomain, + deleteTaskCategoryDomain, + listTaskCategoriesDomain, + updateTaskCategoryDomain, +} from '@/lib/domain-api'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; @@ -30,7 +36,7 @@ export const useTaskCategoryStore = create((set, get) => ({ fetchCategories: async (options) => { if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const cached = await listTaskCategoriesDomain().catch(loadStored); if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return; lastCategoriesFetchAt = Date.now(); set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true }); @@ -50,7 +56,17 @@ export const useTaskCategoryStore = create((set, get) => ({ const updated = [...list, item]; set({ categories: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-categories', updated), + save: async () => { + try { + const created = await createTaskCategoryDomain(item); + set({ + categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)), + loaded: true, + }); + } catch { + await saveTaskCategoriesFallback(updated); + } + }, expected: updated, getCurrent: () => get().categories, rollback: () => set({ categories: list, loaded: true }), @@ -63,7 +79,13 @@ export const useTaskCategoryStore = create((set, get) => ({ if (result.created) { set({ categories: result.categories, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-categories', result.categories), + save: async () => { + try { + await createTaskCategoryDomain(result.category); + } catch { + await saveTaskCategoriesFallback(result.categories); + } + }, expected: result.categories, getCurrent: () => get().categories, rollback: () => set({ categories: previous, loaded: true }), @@ -77,7 +99,13 @@ export const useTaskCategoryStore = create((set, get) => ({ const updated = previous.map((c) => (c.id === id ? { ...c, ...data } : c)); set({ categories: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-categories', updated), + save: async () => { + try { + await updateTaskCategoryDomain(id, data); + } catch { + await saveTaskCategoriesFallback(updated); + } + }, expected: updated, getCurrent: () => get().categories, rollback: () => set({ categories: previous, loaded: true }), @@ -91,7 +119,13 @@ export const useTaskCategoryStore = create((set, get) => ({ const updated = previous.filter((c) => c.id !== id); set({ categories: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-categories', updated), + save: async () => { + try { + await deleteTaskCategoryDomain(id); + } catch { + await saveTaskCategoriesFallback(updated); + } + }, expected: updated, getCurrent: () => get().categories, rollback: () => set({ categories: previous, loaded: true }), @@ -99,3 +133,7 @@ export const useTaskCategoryStore = create((set, get) => ({ return true; }, })); + +async function saveTaskCategoriesFallback(categories: TaskCategory[]) { + await saveServerData('task-categories', categories); +} diff --git a/apps/web/stores/useTaskWorklogStore.ts b/apps/web/stores/useTaskWorklogStore.ts index 39383ca..e81e63d 100644 --- a/apps/web/stores/useTaskWorklogStore.ts +++ b/apps/web/stores/useTaskWorklogStore.ts @@ -1,6 +1,11 @@ 'use client'; import { create } from 'zustand'; import type { TaskWorklog } from '@/lib/task-worklog'; +import { + createTaskWorklogDomain, + deleteTaskWorklogDomain, + listTaskWorklogsDomain, +} from '@/lib/domain-api'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; @@ -28,7 +33,7 @@ export const useTaskWorklogStore = create((set, get) => ({ fetchWorklogs: async (options) => { if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const cached = await listTaskWorklogsDomain().catch(loadStored); if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return; lastWorklogsFetchAt = Date.now(); set({ worklogs: cached ?? [], loaded: true }); @@ -44,7 +49,17 @@ export const useTaskWorklogStore = create((set, get) => ({ const updated = [...previous, item]; set({ worklogs: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-worklogs', updated), + save: async () => { + try { + const created = await createTaskWorklogDomain(item); + set({ + worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)), + loaded: true, + }); + } catch { + await saveTaskWorklogsFallback(updated); + } + }, expected: updated, getCurrent: () => get().worklogs, rollback: () => set({ worklogs: previous, loaded: true }), @@ -56,7 +71,13 @@ export const useTaskWorklogStore = create((set, get) => ({ const updated = previous.filter((w) => w.id !== id); set({ worklogs: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('task-worklogs', updated), + save: async () => { + try { + await deleteTaskWorklogDomain(id); + } catch { + await saveTaskWorklogsFallback(updated); + } + }, expected: updated, getCurrent: () => get().worklogs, rollback: () => set({ worklogs: previous, loaded: true }), @@ -69,3 +90,7 @@ export const useTaskWorklogStore = create((set, get) => ({ .reduce((sum, w) => sum + w.hours, 0); }, })); + +async function saveTaskWorklogsFallback(worklogs: TaskWorklog[]) { + await saveServerData('task-worklogs', worklogs); +} diff --git a/apps/web/stores/useWorkActivityStore.ts b/apps/web/stores/useWorkActivityStore.ts index f98deab..34526c8 100644 --- a/apps/web/stores/useWorkActivityStore.ts +++ b/apps/web/stores/useWorkActivityStore.ts @@ -3,6 +3,12 @@ import { create } from 'zustand'; import { formatLocalDate } from '@/lib/format'; import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity'; import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory'; +import { + createWorkActivityDomain, + deleteWorkActivityDomain, + listWorkActivitiesDomain, +} from '@/lib/domain-api'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; interface WorkActivityState { @@ -52,13 +58,14 @@ export const useWorkActivityStore = create((set, get) => ({ fetchActivities: async (options) => { if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const cached = await listWorkActivitiesDomain().catch(loadStored); if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return; lastActivitiesFetchAt = Date.now(); set({ activities: cached ?? [], loaded: true }); }, addActivity: (data) => { + const previous = get().activities; const now = new Date(); const item: WorkActivity = { ...data, @@ -66,9 +73,24 @@ export const useWorkActivityStore = create((set, get) => ({ date: data.date ?? formatLocalDate(now), occurredAt: data.occurredAt ?? now.toISOString(), }; - const updated = [...get().activities, item]; + const updated = [...previous, item]; set({ activities: updated, loaded: true }); - saveStored(updated, (activities) => set({ activities, loaded: true })); + scheduleSaveWithOptimisticRollback({ + save: async () => { + try { + const created = await createWorkActivityDomain(item); + set({ + activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)), + loaded: true, + }); + } catch { + await saveStored(updated, (activities) => set({ activities, loaded: true })); + } + }, + expected: updated, + getCurrent: () => get().activities, + rollback: () => set({ activities: previous, loaded: true }), + }); return item; }, @@ -77,8 +99,20 @@ export const useWorkActivityStore = create((set, get) => ({ }, deleteActivity: (id) => { - const updated = get().activities.filter((activity) => activity.id !== id); + const previous = get().activities; + const updated = previous.filter((activity) => activity.id !== id); set({ activities: updated, loaded: true }); - saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false }); + scheduleSaveWithOptimisticRollback({ + save: async () => { + try { + await deleteWorkActivityDomain(id); + } catch { + await saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false }); + } + }, + expected: updated, + getCurrent: () => get().activities, + rollback: () => set({ activities: previous, loaded: true }), + }); }, })); diff --git a/docs/architecture.md b/docs/architecture.md index 004d94c..b787f6c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,8 +33,8 @@ Requirement Version TestCase | 前端 | Next.js 14 (App Router) | TypeScript + 客户端组件为主 | | UI | Tailwind CSS + Shadcn/ui | 紧凑信息密度、现代风格 | | 状态 | Zustand | 每个领域一个 store | -| 持久化 | PostgreSQL AppData + 关系表快读/同步(V2.3) | AppData 仍是兼容窗口内主写入;V2.2/V2.3 关系表用于热路径快读和写后同步 | -| 后端 | NestJS + Prisma + PostgreSQL(V2.3) | Product/Requirement 有领域 CRUD;其他领域仍在从 AppData 向领域 API 迁移 | +| 持久化 | PostgreSQL 关系表领域主写 + AppData 兼容兜底(V2.4) | 高增长领域直接写关系表;AppData 仅用于历史兼容、迁移兜底和少量配置项 | +| 后端 | NestJS + Prisma + PostgreSQL(V2.4) | Product/Project/Version/Requirement/VersionPlan/DevTask/TestCase/Bug/Member/TaskCategory/TaskWorklog/Overtime/WorkActivity 均有领域 CRUD | | AI | Anthropic SDK(V3 远景) | 健康度/风险预警/排期建议 | ## 模块结构 @@ -114,21 +114,26 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完 ## 数据持久化 -**当前 V2.3 分层:** AppData 兼容写入 + 关系表快读/同步 +**当前 V2.4 分层:** 领域 CRUD 主写 + AppData 兼容/迁移兜底 -兼容写入层仍使用通用服务端文档表 `app_data`: +领域主写层已经覆盖主要业务实体: +- 根数据:Product、Project、Version 直接写领域 API,`products-overview` 只作为兼容读取/兜底。 +- 需求池:Requirement 直接按 `productId` 分区键写 `requirements`,列表/search/filter/sort 使用服务端分页。 +- 版本详情:VersionPlan、DevTask、TestCase、Bug 直接按 `versionId` 分区键写关系表,并继续标脏 Xiaobao 摘要和写入工作活动证据。 +- 字典/成员/证据:Member 写 `users` 的成员身份字段;TaskCategory 写 `task_categories`;TaskWorklog、OvertimeRecord、WorkActivity 保持追加/证据型关系表写入。 + +兼容层仍保留通用服务端文档表 `app_data`: - 后端:`apps/server/src/modules/data/` 提供 `GET/PUT /api/v1/data/:key` - 数据库:Prisma `AppData` 模型,表名 `app_data`,`key` 为主键,`value` 为 JSONB - 一致性:`GET` 返回 `updatedAt` 派生的 `version`;前端保存时带上最近读取的 `version`,后端用 `key + updatedAt` 原子更新,版本不匹配返回 `409 APP_DATA_CONFLICT` -- 前端:各 Zustand store 保持现有数据形状,通过 `apps/web/lib/server-data.ts` 读写服务端 -- 覆盖范围:产品/项目/版本树、需求池、调研/产品方案/UI 计划、开发任务、测试用例、Bug、成员/角色/部门、任务类型、任务工时日志、加班记录 +- 前端:各 Zustand store 保持现有 UI 数据形状,优先调用 `apps/web/lib/domain-api.ts`;领域 API 不可用时才通过 `apps/web/lib/server-data.ts` 读取或回退保存 AppData。 +- 仍留在 AppData 兼容配置中的内容:部门、角色、密码规则、加班原因等尚未拆出独立 RBAC/配置表的低频配置。 - 浏览器仅保留登录会话(`ftb_auth_session` / `ftb_auth_persist`),不再作为业务数据主存储 -关系表层已经包含 V2.2/V2.3 能力: +关系表层包含 V2.2-V2.4 能力: - V2.2:高增长业务表使用分区表,并提供版本详情、需求池、工作台和小宝预警的快读 API。 - V2.3:AppData 保存成功后触发关系表同步,让快读路径保持新鲜;同步失败只记日志,不阻塞用户保存。 - -尚未完成的是 V2.4 领域 CRUD 迁移:Project、Version、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime 等主写入仍未完整切到领域 API。迁移前不要恢复业务 localStorage 缓存,避免线上部署后出现多端数据分叉。 +- V2.4:领域 CRUD 成为主写入路径,AppData 写桥保留给历史数据和回滚兜底。不要恢复业务 localStorage 缓存,避免线上部署后出现多端数据分叉。 ## 生产部署层(2026-07-01) @@ -286,8 +291,9 @@ The server also has lightweight observability for this phase: a global API timin Current source-of-truth boundary: -- Product and Requirement have domain CRUD modules. -- Project, Version, VersionPlan, DevTask, TestCase, Bug, Member, TaskCategory, TaskWorklog, Overtime, and WorkActivity relation models exist for V2.2/V2.3 mapping and fast reads, but their frontend write paths still mostly go through AppData stores. -- `products-overview` remains the primary document for the product/project/version tree until Project and Version write APIs replace it. -- V2.2 read APIs and V2.3 relation sync are compatibility infrastructure, not proof that every relation model already has a public CRUD API. -- `packages/shared` still contains early Requirement/Task status enums. Before switching frontend writes to domain APIs, align shared enums with the current workflow statuses in this document. +- Product, Project, Version, Requirement, VersionPlan, DevTask, TestCase, Bug, Member, TaskCategory, TaskWorklog, OvertimeRecord, and WorkActivity now have public domain CRUD/write APIs. +- Frontend stores use domain APIs as the primary mutation path. AppData reads and saves remain only as compatibility fallback while old deployments or partially migrated data are drained. +- `products-overview` is no longer the product/project/version tree source of truth; it remains a compatibility document for fallback reads and rollback. +- V2.2 read APIs and V2.3 relation sync remain compatibility infrastructure for fast reads, historical AppData imports, and rollback. They are no longer the main proof of data freshness for domains that now write relation tables directly. +- `packages/shared` status contracts have been aligned with the current workflow statuses before the V2.4 write switch. +- Conservative V2.4.5 boundary: Member identity fields are stored on `users`; departments, roles, password rules, and overtime reasons remain AppData compatibility/config until a dedicated RBAC/config schema phase. diff --git a/docs/decisions.md b/docs/decisions.md index 84f349c..c5133ab 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -557,3 +557,24 @@ - 同步失败只记录日志,不阻塞 AppData 保存。慢 API 和慢 Prisma 查询先通过日志监控,后续再接 Prometheus/Grafana。 **理由**:这是从 AppData 兼容写入平滑过渡到领域 CRUD 的中间层。用户保存不能因为派生关系表暂时失败而丢失业务数据;同时,关系表保持跟随更新后,V2.2 快读路径才能真正承受大数据量。把同步服务独立出来,也能让后续领域 CRUD 逐步替换 AppData 时复用同一套映射和小宝 dirty 策略。 + +## 44. V2.4 领域 CRUD 成为主写入路径,AppData 退为兼容兜底 + +**问题**:V2.2/V2.3 让高增长页面优先读关系表,但前端主写仍长期停留在 AppData 时,会形成“AppData 写入 + 关系表同步”的双层事实链。数据量继续增长后,需求池分页搜索、版本详情、与我相关和小宝预警仍会受 AppData 同步时效、整文档写入和双源理解成本影响。 + +**决策**: +- V2.4.0 先统一共享状态契约,避免领域 API 切换时把旧状态机重新带回系统。 +- V2.4.1-V2.4.4 将 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug 切为领域 API 主写。 +- V2.4.5 将 Member、TaskCategory、TaskWorklog、OvertimeRecord、WorkActivity 切为领域 API 主写。 +- 需求池列表/search/filter/sort 走服务端分页,避免加载全量 AppData 文档。 +- 版本详情实体按 `versionId` 分区键写入;Requirement 按 `productId` 分区键写入;追加型证据表保留追加语义,不从 AppData 快照反向删除历史。 +- `/workspace` 和 `/xiaobao-warning` 继续走关系表聚合/快读。领域写成功后通过工作活动和小宝 dirty 标记维持证据链。 +- AppData 保留为兼容读取、失败回退、历史迁移和少量配置承载,不再作为已迁移领域的事实源。 +- 成员迁移采用保守边界:`users` 存成员身份字段;部门、角色、密码规则暂不在本阶段发明完整 RBAC 表,仍作为 AppData 兼容配置。 +- 加班原因同样暂留 AppData 配置;加班记录本身写 `overtime_records`。 + +**理由**: +- 领域 CRUD 直接写关系表后,读写路径对齐,分页、筛选、聚合和风险预警不再依赖 AppData 同步是否及时。 +- 分区键进入每次领域写入,能维持 V2.2 分区表设计的查询边界。 +- AppData fallback 让迁移可回滚、可兼容旧数据,但不再制造长期双事实源。 +- RBAC/配置表会影响权限模型和管理流程,单独成阶段更安全;V2.4.5 只收口当前高频业务写入,避免为了“全收口”临时设计不稳的权限 schema。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 48bea1a..a03c8e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,21 +1,31 @@ # 开发路线图 -## 当前阶段:V2.3 — 关系表写入与预计算闭环 +## 当前阶段:V2.4 — 领域 CRUD 主写迁移完成 -V2.3 在 V2.2 快读路径之后补上写入闭环:前端仍保留现有 AppData Store 写入形状,但 AppData 保存成功后会同步关系表、刷新/标脏小宝风险摘要,并记录慢 API 与慢 Prisma 查询。领域 CRUD 仍是后续阶段,当前重点是让版本详情、需求池、与我相关和小宝预警在大数据量下持续命中关系表快读。 +V2.4 将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.2 快读 API 和 V2.3 AppData 写后同步继续保留,但它们现在是兼容基础设施,不再是已迁移领域的数据新鲜度主链路。 ### 当前状态快照(2026-07-08) - 项目已经不是早期骨架。前端业务功能已覆盖产品、项目、版本详情、需求池、工作台、成员/角色/任务类型、加班、小宝预警和 AI 配置等主要管理端路由。 -- 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab;渲染重的路径优先接入 V2.2 关系表快读,并保留 AppData fallback。 -- 后端已落地 Product、Requirement 领域 CRUD,DataModule AppData 乐观锁,V2.2 快读 API,V2.3 AppData 写后同步关系表,AI Provider 抽象和健康版本接口。 -- Prisma schema 已包含 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、WorkActivity、Xiaobao、AiLog、AppData 等关系模型;高增长表的分区 migration 已落地。 -- 主写入源仍处在兼容窗口:多数前端 store 继续通过 `apps/web/lib/server-data.ts` 的 `loadServerData` / `saveServerData` 写 AppData;`useProductStore` 仍以 `products-overview` 文档作为产品/项目/版本树主写入。 -- Project、Version、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime 等领域写 API 尚未完整替代 AppData Store。若后续称为 V2.4,应理解为“领域 CRUD 迁移阶段”,不是 V2.3 已完成内容。 -- `packages/shared` 中仍保留早期枚举口径;切换领域 API 时需要统一为当前前端业务状态机。 +- 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab;渲染重的路径优先接入关系表快读,并保留 AppData fallback。 +- 后端已落地 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime、WorkActivity 领域 CRUD/write API。 +- Prisma schema 已包含 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、WorkActivity、TaskWorklog、Overtime、Xiaobao、AiLog、AppData 等关系模型;高增长表的分区 migration 已落地。 +- 主写入源已经切到领域 API:前端 store 优先调用 `apps/web/lib/domain-api.ts`,AppData 只保留兼容读取、失败回退和少量配置。 +- 需求池已切到服务端分页、搜索、筛选、排序,不再要求加载全量 AppData 文档。 +- `packages/shared` 状态契约已统一为当前业务状态机。 +- V2.4.5 保守边界:成员身份写 `users`;部门、角色、密码规则、加班原因暂留 AppData 配置,等待后续 RBAC/配置表阶段。 ### 已完成(按时间倒序) +**2026-07-08** +- V2.4.0 completed shared domain status contract alignment for Requirement, VersionPlan, DevTask, TestCase, Bug, and Version. +- V2.4.1 switched Product / Project / Version root mutations to domain APIs and left `products-overview` as compatibility fallback. +- V2.4.2 switched Requirement writes to relation-table CRUD and added server-side pagination, search, filters, sorting, and cursor support for the requirement pool. +- V2.4.3 switched VersionPlan and DevTask writes to version-scoped domain APIs, with work activity evidence and Xiaobao dirty marking. +- V2.4.4 switched TestCase and Bug writes to version-scoped domain APIs, preserving round-copy and bug workflow behavior. +- V2.4.5 switched Member, TaskCategory, TaskWorklog, OvertimeRecord, and WorkActivity writes to domain APIs. AppData remains only for compatibility fallback and low-frequency config such as departments, roles, password rules, and overtime reasons. +- Added focused source-contract tests proving migrated frontend stores use domain APIs as primary writes rather than AppData document saves. + **2026-07-06** - Added production CI/CD flow: GitHub Actions builds `web` and `server` Docker images, pushes immutable commit-SHA tags to GHCR, deploys by SSH, pulls images on the server, runs `pnpm --filter server db:deploy`, restarts Compose, and verifies `/api/v1/health/version`. - Added runtime version metadata: backend `GET /api/v1/health/version`, Docker build args/env, and a frontend refresh banner when browser assets are older than the server runtime. @@ -50,6 +60,11 @@ V2.3 在 V2.2 快读路径之后补上写入闭环:前端仍保留现有 AppDa - 新增 `pnpm deploy:verify` 校验生产部署文件完整性 - 新增 `docs/deployment.md`,覆盖本地开发、云服务器部署、升级、备份和排查流程 +**2026-06-26** +- Workspace daily report upgraded from manual worklog summary to mixed activity aggregation. +- Added `work-activities` AppData key and a typed activity factory for VersionPlan, DevTask, TestCase, and Bug actions. +- `/workspace` daily report now groups delivery/progress/creation/risk/progress-note records and flags in-progress work that needs today's progress update. + **2026-06-24** - 新增 NestJS `DataModule` + Prisma `AppData`,提供 `GET/PUT /api/v1/data/:key` - 产品/项目/版本树、需求池、调研/产品方案/UI、开发任务、测试用例、Bug 改为服务端持久化 @@ -97,14 +112,14 @@ V2.3 在 V2.2 快读路径之后补上写入闭环:前端仍保留现有 AppDa ## V2 — 后端接入 -NestJS + Prisma + PostgreSQL 已接入到 V2.3。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状,避免浏览器清站点数据导致业务数据丢失;第二阶段已建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步。下一步才是逐领域启用写 API,让前端 store 从 AppData 主写入迁移到领域 CRUD。 +NestJS + Prisma + PostgreSQL 已推进到 V2.4。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状,避免浏览器清站点数据导致业务数据丢失;第二阶段建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步;第三阶段 V2.4 已逐领域启用写 API,让前端 store 从 AppData 主写入迁移到领域 CRUD 主写。 ### 关键任务 1. **服务端文档层**:`app_data` + `/api/v1/data/:key`(第一阶段已实现) 2. **localStorage → API 切换**:业务主数据不再写浏览器(第一阶段已实现) 3. **运行 Prisma 同步/迁移**:本地和服务器数据库都需要创建 `app_data` 与 V2.2/V2.3 关系表 -4. **关系化拆表**:把稳定模块从 JSONB 拆成 Product/Project/Version/Task 等领域表(关系模型和同步桥已落地,领域写 API 仍待迁移) +4. **关系化拆表**:把稳定模块从 JSONB 拆成 Product/Project/Version/Task 等领域表(关系模型、同步桥和 V2.4 领域写 API 已落地) 5. **认证**:NextAuth.js + JWT 6. **权限**:RBAC(Owner/Admin/Member/Viewer),按项目/版本级别 7. **版本规则引擎收敛**:VersionPlan 完成条件、关联需求候选、TaskCategory 语义码、TestCase.categoryId 统一收束到规则层 @@ -113,17 +128,18 @@ NestJS + Prisma + PostgreSQL 已接入到 V2.3。第一阶段用 `app_data` JSON 当前不做本地导入导出。清站点数据后浏览器旧数据无法恢复,后续新增数据直接写入 PostgreSQL。若以后需要迁移旧浏览器数据,再单独做管理员导入工具。 -## V2.4 — 领域 CRUD 迁移(下一阶段) +## V2.4 — 领域 CRUD 迁移(已完成) -目标是让关系表从“快读 + AppData 同步副本”逐步升级为主写入路径。迁移顺序应优先选择写入频率高、实体边界清晰、已经在 V2.2 mapper 中稳定的领域: +目标是让关系表从“快读 + AppData 同步副本”升级为主写入路径。V2.4 按以下顺序完成: -1. Project / Version:替代 `products-overview` 中的项目和版本主写入,保留产品树兼容读取。 -2. VersionPlan / DevTask / TestCase / Bug:按 `versionId` 分区键提供领域写 API,写入后继续复用现有小宝 dirty 策略和工作活动记录。 -3. Member / TaskCategory:替代 `members`、`task-categories` AppData 文档,统一权限、人员和任务类型字典来源。 -4. TaskWorklog / Overtime / WorkActivity:保留追加型写入语义,避免从当前 AppData 快照反向删除历史证据。 -5. 前端 store 分批切换:每切一个领域,都要保留兼容读取和回滚路径,直到 AppData 对应 key 不再是事实源。 +1. V2.4.0:统一 `packages/shared` 状态枚举与当前前端业务口径。 +2. V2.4.1:Project / Version 替代 `products-overview` 中的项目和版本主写入,产品树文档保留兼容读取。 +3. V2.4.2:Requirement 按 `productId` 分区键主写,并支持需求池服务端分页、搜索、筛选、排序。 +4. V2.4.3:VersionPlan / DevTask 按 `versionId` 分区键主写,写入后继续复用小宝 dirty 策略和工作活动记录。 +5. V2.4.4:TestCase / Bug 按 `versionId` 分区键主写,保留测试轮次和缺陷闭环。 +6. V2.4.5:Member / TaskCategory / TaskWorklog / Overtime / WorkActivity 主写关系表,证据型数据保留追加语义。 -V2.4 开始前必须先统一 `packages/shared` 的状态枚举与当前前端业务口径,避免领域 API 切换时把旧的 `draft/reviewing/approved` 或 `todo/in_review/done/closed` 状态重新带回系统。 +V2.4 完成后的兼容边界:AppData 不再是上述领域的事实源,只用于 fallback、历史迁移和少量配置。部门、角色、密码规则、加班原因仍作为兼容配置保留,后续由 RBAC/配置表阶段单独收口。 ## V3 — AI Agent 集成 @@ -140,14 +156,14 @@ V2.4 开始前必须先统一 `packages/shared` 的状态枚举与当前前端 - 编辑后自动清除 aiDraft 标记 - agent-spec.md / glossary.md 文档落地 - 约定:原型链接 = 产品方案 (VersionPlan type=product) 已完成计划的 resultUrl,不在 Version 上独立存储 +- V2.4 已完成 DevTask/TestCase/Bug 的 `versionId` 版本主归属和领域主写,AI 写入可直接走版本级领域 API。 **待实现**: 1. 后端 `AiGateway` + `PrototypeDecomposeService`(NestJS module) 2. 前端「AI 拆解任务和用例」按钮(产品方案 Tab) 3. 对账报告组件(弹窗呈现:完美对应 / 需求未见原型 / 无需求ID分组 / 含糊) 4. 用户确认后批量创建 DevTask + TestCase 草案 -5. DevTask 增加 `versionId`,`requirementId` 改为可选,兼容旧数据通过需求反查版本 -6. AiLog 表(调用记录、token 计量、用时) +5. AiLog 表(调用记录、token 计量、用时) **MVP 范围限制**: - 不自动分配 assignee(留给用户在草案上手填) @@ -193,10 +209,6 @@ V2.4 开始前必须先统一 `packages/shared` 的状态枚举与当前前端 |------|------| | V1 业务流程打磨 | 进行中 | | V1 朋友试用反馈 | 持续中 | -| V2 后端接入 | 进行中(V2.3 AppData 写桥 + 关系表快读/同步已实现,V2.4 领域 CRUD 迁移待推进) | +| V2 后端接入 | 进行中(V2.4 领域 CRUD 主写迁移已完成;RBAC/认证仍待后续阶段) | | V3 AI 集成 | 等 V2 数据沉淀 | | 公开发布 | TBD | -**2026-06-26** -- Workspace daily report upgraded from manual worklog summary to mixed activity aggregation. -- Added `work-activities` AppData key and a typed activity factory for VersionPlan, DevTask, TestCase, and Bug actions. -- `/workspace` daily report now groups delivery/progress/creation/risk/progress-note records and flags in-progress work that needs today's progress update. diff --git a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md index 4b6e7e9..7f76de7 100644 --- a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md +++ b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md @@ -225,12 +225,12 @@ - Produces: domain APIs for members, task categories, task worklogs, overtime records, and activity records. - Consumes: existing AppData keys only for compatibility import/fallback during the transition. -- [ ] Add failing backend tests for member CRUD, protected built-in admin behavior, and role/department fields used by the UI. -- [ ] Add failing backend tests for task category CRUD and uniqueness by `(group, name)`. -- [ ] Add failing backend tests for append-style TaskWorklog, OvertimeRecord, and WorkActivity writes. -- [ ] Add failing frontend tests proving dictionary/member/evidence stores no longer save AppData as the main write path. -- [ ] Implement the remaining domain modules. -- [ ] Switch stores to domain writes, preserving fallback reads and current UI data shapes. -- [ ] Update architecture, decisions, workflow, and roadmap to mark V2.4.0-V2.4.5 complete and clarify AppData is now compatibility/migration fallback. -- [ ] Run full gates: `pnpm type-check`, `pnpm test`, `pnpm build`, `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm --filter server exec prisma validate --schema prisma/schema.prisma`, `pnpm deploy:verify`. -- [ ] Commit: `feat(v2.4): 完成领域主写迁移`. +- [x] Add failing backend tests for member CRUD, protected built-in admin behavior, and role/department fields used by the UI. +- [x] Add failing backend tests for task category CRUD and uniqueness by `(group, name)`. +- [x] Add failing backend tests for append-style TaskWorklog, OvertimeRecord, and WorkActivity writes. +- [x] Add failing frontend tests proving dictionary/member/evidence stores no longer save AppData as the main write path. +- [x] Implement the remaining domain modules. +- [x] Switch stores to domain writes, preserving fallback reads and current UI data shapes. +- [x] Update architecture, decisions, workflow, and roadmap to mark V2.4.0-V2.4.5 complete and clarify AppData is now compatibility/migration fallback. +- [x] Run full gates: `pnpm type-check`, `pnpm test`, `pnpm build`, `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm --filter server exec prisma validate --schema prisma/schema.prisma`, `pnpm deploy:verify`. +- [x] Commit: `feat(v2.4): 完成领域主写迁移`. diff --git a/docs/workflow.md b/docs/workflow.md index 293647f..1ffbd55 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -142,6 +142,31 @@ - 后端只在 `key + updatedAt(version)` 匹配时更新;如果其他用户已经先保存,返回 `409 APP_DATA_CONFLICT`,响应包含当前服务端 `currentVersion` 和 `currentValue`。 - 收到 `ServerDataConflictError` 时,不要自动重试覆盖。当前处理策略是阻止静默覆盖,后续 UI 冲突合并能力再单独补。 +## V2.4 领域写入流程 + +已迁移领域的 store 默认写入顺序: + +1. 本地状态先做乐观更新,保持 UI 响应速度。 +2. 优先调用 `apps/web/lib/domain-api.ts` 中的领域 API。 +3. 领域 API 成功后,用服务端返回行替换本地临时行;若返回 `activities`,直接合并工作活动证据。 +4. 领域 API 不可用或旧环境未部署时,才使用 `loadServerData` / `saveServerData` 做 AppData 兼容兜底。 +5. 不新增长期双写逻辑;AppData fallback 只用于兼容、迁移和回滚,不作为已迁移领域的事实源。 + +当前领域主写范围: + +- Product / Project / Version:根数据主写,`products-overview` 仅兼容。 +- Requirement:按 `productId` 写入,需求池列表走服务端分页、搜索、筛选、排序。 +- VersionPlan / DevTask / TestCase / Bug:按 `versionId` 写入,并维护工作活动和小宝 dirty 标记。 +- Member / TaskCategory:成员身份字段和任务类型字典主写关系表。 +- TaskWorklog / OvertimeRecord / WorkActivity:证据型数据主写关系表,保持追加语义。 + +仍在 AppData 兼容配置中的内容: + +- 部门、角色、密码规则。 +- 加班原因。 + +新增领域 store 时,先写 source contract 测试证明主写不是 `saveServerData('')`,再实现领域 API 和 fallback。 + ## 与我相关(Workspace)数据流 ```