chore: restore initial vtuber source snapshot

This commit is contained in:
ogt
2026-07-03 00:36:01 +08:00
commit 17f0c8c8ff
85 changed files with 7677 additions and 0 deletions

2
apps/api/.env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=4000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vtuber

8
apps/api/.eslintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"]
}

15
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20
WORKDIR /app
COPY package.json ./
COPY tsconfig.base.json ./
COPY packages ./packages
COPY apps/api ./apps/api
RUN npm install
RUN npm run db:generate -w packages/db
RUN npm run build:api
ENV NODE_ENV=production
EXPOSE 4000
CMD ["npm","run","start","-w","apps/api"]

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": ["**/*.json"],
"watchAssets": true
}
}

35
apps/api/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@vtuber/api",
"private": true,
"version": "0.1.0",
"scripts": {
"start": "node dist/main.js",
"build": "tsc -p tsconfig.build.json",
"start:dev": "ts-node -r tsconfig-paths/register src/main.ts",
"lint": "eslint '{src,test}/**/*.ts' --fix",
"test": "echo \"No tests yet\""
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@vtuber/db": "*",
"@prisma/client": "^5.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.1",
"tsconfig-paths": "^4.2.0",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,494 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Patch,
Query,
} from '@nestjs/common';
import { AppService } from './app.service';
type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
type LiveRoomStatus = 'PLANNING' | 'LIVE' | 'FINISHED' | 'CANCELLED';
type TeamRole =
| 'HOST'
| 'PRODUCER'
| 'DIRECTOR'
| 'EDITOR'
| 'SUPPORT'
| 'WAREHOUSE'
| 'MARKETING'
| 'OPERATOR';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('health')
health() {
return this.appService.health();
}
@Get('products')
async listProducts() {
return this.appService.listProducts();
}
@Get('products/:id')
async getProduct(@Param('id') id: string) {
const product = await this.appService.getProduct(id);
if (!product) {
throw new BadRequestException('找不到商品');
}
return product;
}
@Get('live-rooms')
async listLiveRooms() {
return this.appService.listLiveRooms();
}
@Get('live-rooms/:id/messages')
async listLiveMessages(
@Param('id') id: string,
@Query('limit') limit?: string,
) {
const parsedLimit = Number(limit ?? 30);
const validLimit = Number.isNaN(parsedLimit) ? 30 : parsedLimit;
return this.appService.listLiveMessages(id, validLimit);
}
@Get('live-rooms/:id/operations')
async getLiveRoomOperations(@Param('id') id: string) {
return this.appService.getLiveRoomOperations(id);
}
@Post('live-rooms/:id/messages')
async postLiveMessage(
@Param('id') id: string,
@Body()
body: {
userName?: string;
message?: string;
productVariantId?: string;
},
) {
const userName = body.userName?.trim() ?? '';
const message = body.message?.trim() ?? '';
if (!userName || !message) {
throw new BadRequestException('userName 與 message 是必要欄位');
}
return this.appService.postLiveMessage({
liveRoomId: id,
userName,
message,
productVariantId: body.productVariantId?.trim(),
});
}
@Get('admin/team-members')
async listTeamMembers() {
return this.appService.listTeamMembers();
}
@Post('admin/team-members')
async createTeamMember(
@Body()
body: {
displayName?: string;
role?: TeamRole;
nickname?: string;
phone?: string;
email?: string;
notes?: string;
},
) {
const displayName = body.displayName?.trim();
if (!displayName || !body.role) {
throw new BadRequestException('displayName 與 role 是必要欄位');
}
const result = await this.appService.createTeamMember({
displayName,
role: body.role,
nickname: body.nickname?.trim(),
phone: body.phone?.trim(),
email: body.email?.trim(),
notes: body.notes?.trim(),
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('admin/team-members/:id')
async deleteTeamMember(@Param('id') id: string) {
return this.appService.removeTeamMember(id);
}
@Get('admin/products')
async listAdminProducts() {
return this.appService.listAdminProducts();
}
@Post('admin/products')
async createProduct(
@Body()
body: {
name: string;
slug: string;
description?: string;
imageUrl?: string;
basePrice: number;
liveRoomId?: string | null;
variants?: Array<{
sku: string;
name: string;
size?: string | null;
price: number;
stock: number;
}>;
},
) {
if (!body?.name?.trim() || !body?.slug?.trim()) {
throw new BadRequestException('name/slug/basePrice 為必要欄位');
}
const basePrice = Number(body.basePrice);
if (Number.isNaN(basePrice) || basePrice < 0) {
throw new BadRequestException('basePrice 必須是非負數');
}
return this.appService.createProduct({
name: body.name,
slug: body.slug,
description: body.description,
imageUrl: body.imageUrl,
basePrice,
liveRoomId: body.liveRoomId ?? null,
variants: body.variants,
});
}
@Put('admin/products/:id')
async updateProduct(
@Param('id') id: string,
@Body()
body: {
name?: string;
slug?: string;
description?: string | null;
imageUrl?: string | null;
basePrice?: number;
isActive?: boolean;
liveRoomId?: string | null;
},
) {
return this.appService.updateProduct(id, body);
}
@Delete('admin/products/:id')
async deleteProduct(@Param('id') id: string) {
return this.appService.deleteProduct(id);
}
@Get('admin/orders')
async listAdminOrders() {
return this.appService.listAdminOrders();
}
@Patch('admin/orders/:id/status')
async updateOrderStatus(
@Param('id') id: string,
@Body() body: { status: string },
) {
const normalizedStatus = body.status?.toUpperCase();
if (
!normalizedStatus ||
!(['PENDING', 'PAID', 'SHIPPED', 'CANCELLED'] as const).includes(
normalizedStatus as OrderStatus,
)
) {
throw new BadRequestException('status 僅能為 PENDING/PAID/SHIPPED/CANCELLED');
}
return this.appService.updateOrderStatus(id, normalizedStatus as OrderStatus);
}
@Get('admin/inventories')
async listInventories() {
return this.appService.listInventories();
}
@Put('admin/inventories/:variantId')
async updateInventory(
@Param('variantId') variantId: string,
@Body() body: { quantity: number },
) {
const quantity = Number(body.quantity);
if (Number.isNaN(quantity)) {
throw new BadRequestException('quantity 必須為數字');
}
const result = await this.appService.updateInventory(variantId, quantity);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Get('admin/live-rooms/:id/operations')
async getLiveRoomOperationsAdmin(@Param('id') id: string) {
return this.appService.getLiveRoomOperations(id);
}
@Post('admin/live-rooms/:id/operations/assignments')
async createLiveRoomAssignment(
@Param('id') id: string,
@Body()
body: {
teamMemberId?: string;
role?: TeamRole;
isPrimary?: boolean;
shiftStartAt?: string;
shiftEndAt?: string;
note?: string;
},
) {
if (!body.teamMemberId || !body.role) {
throw new BadRequestException('teamMemberId 與 role 是必要欄位');
}
const result = await this.appService.createLiveRoomAssignment(id, {
teamMemberId: body.teamMemberId,
role: body.role,
isPrimary: !!body.isPrimary,
shiftStartAt: body.shiftStartAt,
shiftEndAt: body.shiftEndAt,
note: body.note?.trim(),
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('admin/live-rooms/:id/operations/assignments/:assignmentId')
async removeLiveRoomAssignment(
@Param('id') liveRoomId: string,
@Param('assignmentId') assignmentId: string,
) {
return this.appService.removeLiveRoomAssignment(liveRoomId, assignmentId);
}
@Patch('admin/live-rooms/:id/status')
async updateLiveRoomStatus(
@Param('id') id: string,
@Body() body: { status?: LiveRoomStatus },
) {
if (!body.status) {
throw new BadRequestException('status 是必要欄位');
}
const result = await this.appService.updateLiveRoomStatus(id, body.status);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('admin/live-rooms/:id/operations/scripts')
async createLiveRoomScript(
@Param('id') id: string,
@Body()
body: {
sequence?: number;
cue?: string;
title?: string;
content?: string;
ownerRole?: TeamRole;
targetProductId?: string;
},
) {
const payload = {
sequence: Number(body.sequence ?? 0),
cue: body.cue?.trim(),
title: body.title?.trim() ?? '',
content: body.content?.trim() ?? '',
ownerRole: body.ownerRole,
targetProductId: body.targetProductId?.trim(),
};
if (!payload.title || !payload.content) {
throw new BadRequestException('title/content 是必要欄位');
}
if (Number.isNaN(payload.sequence) || payload.sequence <= 0) {
throw new BadRequestException('sequence 必須是正整數');
}
const result = await this.appService.createLiveRoomScript(id, payload);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Patch('admin/live-ops/scripts/:id')
async updateLiveRoomScript(
@Param('id') id: string,
@Body()
body: {
sequence?: number;
cue?: string;
title?: string;
content?: string;
ownerRole?: TeamRole;
targetProductId?: string;
isDone?: boolean;
},
) {
const result = await this.appService.updateLiveRoomScript(id, {
sequence: body.sequence,
cue: body.cue?.trim(),
title: body.title?.trim(),
content: body.content?.trim(),
ownerRole: body.ownerRole,
targetProductId: body.targetProductId?.trim(),
isDone: body.isDone,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('admin/live-rooms/:id/operations/checklists')
async createLiveRoomChecklistItem(
@Param('id') id: string,
@Body()
body: {
title?: string;
ownerRole?: TeamRole;
description?: string;
isRequired?: boolean;
note?: string;
},
) {
const title = body.title?.trim() ?? '';
const ownerRole = body.ownerRole;
if (!title || !ownerRole) {
throw new BadRequestException('title/ownerRole 是必要欄位');
}
const payload = {
title,
description: body.description?.trim(),
isRequired: body.isRequired ?? true,
note: body.note?.trim(),
ownerRole,
};
const result = await this.appService.createLiveRoomChecklistItem(id, payload);
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Patch('admin/live-ops/checklists/:id')
async updateLiveRoomChecklistItem(
@Param('id') id: string,
@Body() body: { isDone?: boolean },
) {
const result = await this.appService.updateLiveRoomChecklistItem(id, {
isDone: body.isDone,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Get('cart')
async getCart(@Query('sessionId') sessionId: string) {
return this.appService.getCartBySession(sessionId);
}
@Post('cart/items')
async addCartItem(
@Body()
body: {
sessionId?: string;
productVariantId?: string;
quantity?: number;
},
) {
if (!body?.sessionId) {
throw new BadRequestException('sessionId 是必要參數');
}
if (!body?.productVariantId) {
throw new BadRequestException('productVariantId 是必要參數');
}
const quantity = Number(body.quantity ?? 1);
if (Number.isNaN(quantity) || quantity <= 0) {
throw new BadRequestException('quantity 需為正整數');
}
const result = await this.appService.addCartItem({
sessionId: body.sessionId,
productVariantId: body.productVariantId,
quantity,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Delete('cart/items/:id')
async removeCartItem(@Param('id') id: string) {
return this.appService.removeCartItem(id);
}
@Post('checkout')
async checkout(@Body() body: { sessionId?: string; liveRoomId?: string }) {
if (!body?.sessionId) {
throw new BadRequestException('sessionId 是必要參數');
}
const result = await this.appService.createMockCheckout({
sessionId: body.sessionId,
liveRoomId: body.liveRoomId,
});
if (!result.ok) {
throw new BadRequestException(result.message);
}
return result;
}
@Post('ai/product-chat')
async productChat(
@Body()
body: {
userMessage?: string;
currentProductId?: string;
liveRoomId?: string;
},
) {
if (!body?.userMessage?.trim()) {
throw new BadRequestException('userMessage 是必要參數');
}
const result = await this.appService.answerProductChat({
userMessage: body.userMessage,
currentProductId: body.currentProductId,
liveRoomId: body.liveRoomId,
});
return result;
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DatabaseModule } from './database/database.module';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), DatabaseModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

1219
apps/api/src/app.service.ts Normal file

File diff suppressed because it is too large Load Diff

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 DatabaseModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

14
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const port = process.env.PORT ? Number(process.env.PORT) : 4000;
await app.listen(port);
Logger.log(`VTuber API running on http://localhost:${port}/api`, 'Bootstrap');
}
bootstrap();

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "**/*spec.ts"]
}

19
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["es2022", "dom"],
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true
},
"include": ["src/**/*.ts", "src/**/*.js"]
}