feat: 完成 A 群組 — 專案骨架與可重啟的服務
- packages/shared:TypeScript 專案參考建置鏈路、統一日誌模組(Asia/Taipei 格式) - apps/api:最小 NestJS 服務,GET /health - apps/web:最小 Next.js(App Router + Tailwind v4)首頁,套用品牌色票與亮暗模式 - scripts/dev-restart.mjs:讀 .dev-pids 以 process group 訊號安全停止舊行程、背景啟動 api/web 並輪詢健康檢查 - scripts/smoke:npm run smoke -- <群組代號> 冒煙測試框架,A 群組測試涵蓋 /health 與首頁內容 - 本機 port 3000 被既有服務佔用,web 開發埠改為 3100(已記錄於 todo.md) npm run restart && npm run smoke -- A 皆通過(A-V)。
This commit is contained in:
@@ -10,3 +10,4 @@ build/
|
|||||||
*.db-journal
|
*.db-journal
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@kokorone/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "心音後端服務(NestJS)",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"start": "node dist/main.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.29",
|
||||||
|
"@nestjs/core": "^11.1.29",
|
||||||
|
"@nestjs/platform-express": "^11.1.29",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthController } from "./health/health.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get } from "@nestjs/common";
|
||||||
|
|
||||||
|
const START_TIME = Date.now();
|
||||||
|
|
||||||
|
@Controller("health")
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
version: process.env.npm_package_version ?? "0.1.0",
|
||||||
|
uptime: Math.floor((Date.now() - START_TIME) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { log } from "@kokorone/shared";
|
||||||
|
import { AppModule } from "./app.module.js";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, { logger: false });
|
||||||
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
|
await app.listen(port);
|
||||||
|
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../../packages/shared" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-heartbeat-pink: #ff7e9d;
|
||||||
|
--color-dusk-purple: #8c7ae6;
|
||||||
|
--background: #fff8fa;
|
||||||
|
--foreground: #3a3242;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-heartbeat-pink: var(--color-heartbeat-pink);
|
||||||
|
--color-dusk-purple: var(--color-dusk-purple);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #1a1430;
|
||||||
|
--foreground: #fff8fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "心音 Kokorone",
|
||||||
|
description: "心音(Kokorone)— 戀愛陪伴系統",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="zh-Hant"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
|
||||||
|
<h1 className="text-4xl font-bold tracking-wide">
|
||||||
|
心音 <span className="text-[var(--color-heartbeat-pink)]">Kokorone</span>
|
||||||
|
</h1>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 400 100"
|
||||||
|
className="w-full max-w-md text-[var(--color-heartbeat-pink)]"
|
||||||
|
role="img"
|
||||||
|
aria-label="心跳波形"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points="0,50 60,50 80,50 95,15 110,85 125,50 160,50 180,50 195,25 210,75 225,50 400,50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
agentRules: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@kokorone/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.3.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Generated
+8344
File diff suppressed because it is too large
Load Diff
@@ -14,5 +14,9 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@kokorone/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "api/web/mobile 三端共用型別與工具",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./result.js";
|
||||||
|
export * from "./log.js";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type LogLevel = "INF" | "WRN" | "ERR" | "TRC" | "DBG";
|
||||||
|
|
||||||
|
const TAIPEI_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Taipei",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatTaipeiTimestamp(date: Date = new Date()): string {
|
||||||
|
const parts = TAIPEI_FORMATTER.formatToParts(date);
|
||||||
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00";
|
||||||
|
return `${get("year")}/${get("month")}/${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(stage: string, level: LogLevel, message: string): void {
|
||||||
|
const line = `[${formatTaipeiTimestamp()}][${stage}][${level}]: ${message}`;
|
||||||
|
if (level === "ERR") {
|
||||||
|
console.error(line);
|
||||||
|
} else if (level === "WRN") {
|
||||||
|
console.warn(line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
|
|
||||||
|
export function ok<T>(value: T): Result<T, never> {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err<E>(error: E): Result<never, E> {
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { readFile, writeFile, rm, mkdir } from "node:fs/promises";
|
||||||
|
import { existsSync, openSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { log } from "../packages/shared/dist/index.js";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const PID_FILE = path.join(ROOT, ".dev-pids");
|
||||||
|
const LOG_DIR = path.join(ROOT, ".dev-logs");
|
||||||
|
|
||||||
|
const API_PORT = process.env.PORT_API ?? "3001";
|
||||||
|
const WEB_PORT = process.env.PORT_WEB ?? "3100";
|
||||||
|
const HEALTH_TIMEOUT_MS = 60_000;
|
||||||
|
const POLL_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
function isAlive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 啟動時使用 detached: true,子行程會成為新 process group 的 leader(pgid === pid)。
|
||||||
|
// next dev 之類的指令會再往下多層 fork(npx → sh -c → next-server),只送 SIGTERM 給
|
||||||
|
// 最外層 pid 可能殺不到孫行程、留下孤兒繼續佔用埠號,因此一律用 -pid 對整個 process group 送訊號。
|
||||||
|
function isGroupAlive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopExisting() {
|
||||||
|
if (!existsSync(PID_FILE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = await readFile(PID_FILE, "utf8");
|
||||||
|
let pids;
|
||||||
|
try {
|
||||||
|
pids = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
log("重啟", "WRN", ".dev-pids 內容無法解析,略過停止舊行程");
|
||||||
|
await rm(PID_FILE, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [name, pid] of Object.entries(pids)) {
|
||||||
|
if (typeof pid !== "number" || !isGroupAlive(pid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log("重啟", "INF", `停止舊行程 ${name}(pid=${pid})`);
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// 行程可能已結束
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deadline = Date.now() + 5_000;
|
||||||
|
for (const pid of Object.values(pids)) {
|
||||||
|
while (typeof pid === "number" && isGroupAlive(pid) && Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
if (typeof pid === "number" && isGroupAlive(pid)) {
|
||||||
|
log("重啟", "WRN", `舊行程 pid=${pid} 未在時限內結束,強制終止`);
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// 行程可能已結束
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rm(PID_FILE, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTypeScript() {
|
||||||
|
log("重啟", "INF", "建置 TypeScript 專案(tsc -b)");
|
||||||
|
const result = spawnSync("npx", ["tsc", "-b"], { cwd: ROOT, stdio: "inherit" });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
log("重啟", "ERR", "tsc -b 建置失敗");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogFd(name) {
|
||||||
|
await mkdir(LOG_DIR, { recursive: true });
|
||||||
|
return openSync(path.join(LOG_DIR, `${name}.log`), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApi() {
|
||||||
|
const fd = await openLogFd("api");
|
||||||
|
const child = spawn("node", ["apps/api/dist/main.js"], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, PORT: API_PORT },
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", fd, fd],
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
log("重啟", "INF", `啟動 api(pid=${child.pid},port=${API_PORT})`);
|
||||||
|
return child.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWeb() {
|
||||||
|
const fd = await openLogFd("web");
|
||||||
|
const child = spawn("npx", ["next", "dev", "-p", WEB_PORT], {
|
||||||
|
cwd: path.join(ROOT, "apps/web"),
|
||||||
|
env: { ...process.env, PORT: WEB_PORT },
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", fd, fd],
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
log("重啟", "INF", `啟動 web(pid=${child.pid},port=${WEB_PORT})`);
|
||||||
|
return child.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilOk(name, pid, check, deadline) {
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isAlive(pid)) {
|
||||||
|
log("重啟", "ERR", `${name} 對應的行程(pid=${pid})已提前結束,請查看 .dev-logs`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await check()) {
|
||||||
|
log("重啟", "INF", `${name} 健康檢查通過`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 服務尚未就緒,繼續輪詢
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
log("重啟", "ERR", `${name} 健康檢查逾時(${HEALTH_TIMEOUT_MS}ms)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await stopExisting();
|
||||||
|
buildTypeScript();
|
||||||
|
|
||||||
|
const apiPid = await startApi();
|
||||||
|
const webPid = await startWeb();
|
||||||
|
await writeFile(PID_FILE, JSON.stringify({ api: apiPid, web: webPid }, null, 2));
|
||||||
|
|
||||||
|
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
||||||
|
|
||||||
|
const apiOk = await pollUntilOk(
|
||||||
|
"api /health",
|
||||||
|
apiPid,
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`http://localhost:${API_PORT}/health`);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const body = await res.json();
|
||||||
|
return body.status === "ok";
|
||||||
|
},
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
|
||||||
|
const webOk = await pollUntilOk(
|
||||||
|
"web 首頁",
|
||||||
|
webPid,
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`http://localhost:${WEB_PORT}/`);
|
||||||
|
return res.ok;
|
||||||
|
},
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiOk || !webOk) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("重啟", "INF", "api 與 web 皆已就緒");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const API_PORT = process.env.PORT_API ?? "3001";
|
||||||
|
const WEB_PORT = process.env.PORT_WEB ?? "3100";
|
||||||
|
|
||||||
|
export default async function smokeA() {
|
||||||
|
const healthRes = await fetch(`http://localhost:${API_PORT}/health`);
|
||||||
|
if (healthRes.status !== 200) {
|
||||||
|
throw new Error(`GET /health 回傳 ${healthRes.status},預期 200`);
|
||||||
|
}
|
||||||
|
const healthBody = await healthRes.json();
|
||||||
|
if (healthBody.status !== "ok") {
|
||||||
|
throw new Error("GET /health 回應內容缺少 status: ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeRes = await fetch(`http://localhost:${WEB_PORT}/`);
|
||||||
|
if (homeRes.status !== 200) {
|
||||||
|
throw new Error(`web 首頁回傳 ${homeRes.status},預期 200`);
|
||||||
|
}
|
||||||
|
const homeHtml = await homeRes.text();
|
||||||
|
if (!homeHtml.includes("心音")) {
|
||||||
|
throw new Error("web 首頁內容缺少「心音」字樣");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from "node:path";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { log } from "../../packages/shared/dist/index.js";
|
||||||
|
|
||||||
|
const SMOKE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function listGroupFiles() {
|
||||||
|
const files = await readdir(SMOKE_DIR);
|
||||||
|
return files.filter((f) => f.endsWith(".mjs") && f !== "run.mjs").sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOne(file) {
|
||||||
|
const group = file.replace(/\.mjs$/, "");
|
||||||
|
const modUrl = pathToFileURL(path.join(SMOKE_DIR, file)).href;
|
||||||
|
log("冒煙測試", "INF", `開始執行 ${group} 群組冒煙測試`);
|
||||||
|
try {
|
||||||
|
const mod = await import(modUrl);
|
||||||
|
if (typeof mod.default !== "function") {
|
||||||
|
throw new Error(`${file} 未匯出預設測試函式`);
|
||||||
|
}
|
||||||
|
await mod.default();
|
||||||
|
log("冒煙測試", "INF", `${group} 群組冒煙測試全綠`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
log("冒煙測試", "ERR", `${group} 群組冒煙測試失敗:${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const arg = process.argv[2];
|
||||||
|
if (!arg) {
|
||||||
|
log("冒煙測試", "ERR", "請指定群組代號,例如:npm run smoke -- A(或 all 執行全部群組)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let files;
|
||||||
|
if (arg === "all") {
|
||||||
|
files = await listGroupFiles();
|
||||||
|
if (files.length === 0) {
|
||||||
|
log("冒煙測試", "WRN", "尚無任何冒煙測試檔案");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const file = `${arg}.mjs`;
|
||||||
|
if (!existsSync(path.join(SMOKE_DIR, file))) {
|
||||||
|
log("冒煙測試", "ERR", `找不到群組 ${arg} 的冒煙測試檔案(scripts/smoke/${file})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
files = [file];
|
||||||
|
}
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
for (const file of files) {
|
||||||
|
const ok = await runOne(file);
|
||||||
|
allOk = allOk && ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(allOk ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -111,14 +111,16 @@ flowchart TB
|
|||||||
|
|
||||||
### A. 專案骨架與可重啟的服務
|
### A. 專案骨架與可重啟的服務
|
||||||
|
|
||||||
- [ ] **A-1 建立 monorepo 根目錄(XS)**:於 `/home/h3285/jsc/jiantw83/Kokorone` 建立 `package.json`(npm workspaces:`apps/*`、`packages/*`)、`.gitignore`、`.editorconfig`、`README.md`(一句話說明專案),執行 `git init` 並完成首次 commit。驗收:根目錄 `npm run` 可列出腳本、`git log` 有一筆提交。依據:技術選型「單一 monorepo 三端共用邏輯」;因本機無 pnpm,先用 npm workspaces 並在 README 註記 R-1 會遷移。
|
- [x] **A-1 建立 monorepo 根目錄(XS)**:於 `/home/h3285/jsc/jiantw83/Kokorone` 建立 `package.json`(npm workspaces:`apps/*`、`packages/*`)、`.gitignore`、`.editorconfig`、`README.md`(一句話說明專案),執行 `git init` 並完成首次 commit。驗收:根目錄 `npm run` 可列出腳本、`git log` 有一筆提交。依據:技術選型「單一 monorepo 三端共用邏輯」;因本機無 pnpm,先用 npm workspaces 並在 README 註記 R-1 會遷移。
|
||||||
- [ ] **A-2 TypeScript 基礎設定(XS)**:建立根 `tsconfig.base.json`(`strict: true`),各 workspace 以 `extends` 繼承;建立 `packages/shared` 空套件並匯出一個型別驗證編譯鏈路。驗收:`npx tsc -b` 零錯誤。依據:技術選型「TypeScript:三端共用型別」。
|
- [x] **A-2 TypeScript 基礎設定(XS)**:建立根 `tsconfig.base.json`(`strict: true`),各 workspace 以 `extends` 繼承;建立 `packages/shared` 空套件並匯出一個型別驗證編譯鏈路。驗收:`npx tsc -b` 零錯誤。依據:技術選型「TypeScript:三端共用型別」。
|
||||||
- [ ] **A-3 統一日誌模組(S)**:於 `packages/shared/src/log.ts` 實作 `[yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息` 格式輸出(時區 Asia/Taipei,等級限 INF/WRN/ERR/TRC/DBG,一行一則),api 與 web 的伺服端輸出一律走此模組。驗收:啟動 api 時輸出符合格式的啟動訊息。
|
- [x] **A-3 統一日誌模組(S)**:於 `packages/shared/src/log.ts` 實作 `[yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息` 格式輸出(時區 Asia/Taipei,等級限 INF/WRN/ERR/TRC/DBG,一行一則),api 與 web 的伺服端輸出一律走此模組。驗收:啟動 api 時輸出符合格式的啟動訊息。
|
||||||
- [ ] **A-4 建立 apps/api 最小 NestJS 服務(S)**:實作 `GET /health` 回 `{ status: 'ok', version, uptime }`,連接埠讀 `PORT`(預設 3001)。驗收:`curl localhost:3001/health` 回 200 且含 `status: ok`。依據:技術選型「後端 NestJS + Socket.IO」。
|
- [x] **A-4 建立 apps/api 最小 NestJS 服務(S)**:實作 `GET /health` 回 `{ status: 'ok', version, uptime }`,連接埠讀 `PORT`(預設 3001)。驗收:`curl localhost:3001/health` 回 200 且含 `status: ok`。依據:技術選型「後端 NestJS + Socket.IO」。
|
||||||
- [ ] **A-5 建立 apps/web 最小 Next.js 頁面(S)**:App Router + Tailwind,首頁顯示「心音 Kokorone」標題與心跳波形 SVG,套用色票 心動粉 `#FF7E9D`/暮空紫 `#8C7AE6`/晨霧白 `#FFF8FA`/夜空藍黑 `#1A1430`/墨字 `#3A3242`,並以 CSS 變數實作亮/暗模式(暗色模式為一級公民)。驗收:`http://localhost:3000` 顯示標題與波形,切換系統深色模式配色正確。依據:主視覺與資產§色彩、§視覺母題。
|
- [x] **A-5 建立 apps/web 最小 Next.js 頁面(S)**:App Router + Tailwind,首頁顯示「心音 Kokorone」標題與心跳波形 SVG,套用色票 心動粉 `#FF7E9D`/暮空紫 `#8C7AE6`/晨霧白 `#FFF8FA`/夜空藍黑 `#1A1430`/墨字 `#3A3242`,並以 CSS 變數實作亮/暗模式(暗色模式為一級公民)。驗收:`http://localhost:3000` 顯示標題與波形,切換系統深色模式配色正確。依據:主視覺與資產§色彩、§視覺母題。
|
||||||
- [ ] **A-6 重啟與健康檢查機制(M)**:實作 `scripts/dev-restart.mjs`——讀 `.dev-pids` 停掉舊行程 → 背景啟動 api 與 web → 輪詢 `GET /health` 與 web 首頁直到皆回 200(逾時 60 秒則以非零狀態結束並輸出 ERR log);`package.json` 加上 `npm run restart` 指向它。驗收:連續執行兩次 `npm run restart` 不會殘留重複行程且皆成功。依據:使用者要求「每完成一個階段就重新啟動服務」。
|
- [x] **A-6 重啟與健康檢查機制(M)**:實作 `scripts/dev-restart.mjs`——讀 `.dev-pids` 停掉舊行程 → 背景啟動 api 與 web → 輪詢 `GET /health` 與 web 首頁直到皆回 200(逾時 60 秒則以非零狀態結束並輸出 ERR log);`package.json` 加上 `npm run restart` 指向它。驗收:連續執行兩次 `npm run restart` 不會殘留重複行程且皆成功。依據:使用者要求「每完成一個階段就重新啟動服務」。
|
||||||
- [ ] **A-7 冒煙測試框架(M)**:實作 `scripts/smoke/run.mjs`,以 `npm run smoke -- <群組代號>` 執行 `scripts/smoke/<群組代號>.mjs`;建立 `scripts/smoke/A.mjs`(檢查 `/health` 回 200、web 首頁含「心音」字樣),失敗以非零狀態結束。驗收:`npm run smoke -- A` 全綠。
|
- [x] **A-7 冒煙測試框架(M)**:實作 `scripts/smoke/run.mjs`,以 `npm run smoke -- <群組代號>` 執行 `scripts/smoke/<群組代號>.mjs`;建立 `scripts/smoke/A.mjs`(檢查 `/health` 回 200、web 首頁含「心音」字樣),失敗以非零狀態結束。驗收:`npm run smoke -- A` 全綠。
|
||||||
- [ ] **A-V 階段驗證(XS)**:執行 `npm run restart && npm run smoke -- A`,兩者皆通過才算本階段完成;失敗則修到通過為止,不得跳到 B。
|
- [x] **A-V 階段驗證(XS)**:執行 `npm run restart && npm run smoke -- A`,兩者皆通過才算本階段完成;失敗則修到通過為止,不得跳到 B。
|
||||||
|
|
||||||
|
> **實作記錄(A 群組)**:本機 port 3000 已被既有非本專案服務佔用,web 開發埠改為 **3100**(`PORT_WEB`,可覆寫),api 維持 **3001**(`PORT_API`)。後續所有群組提及 `localhost:3000` 之處一律改讀 `localhost:3100`。
|
||||||
|
|
||||||
### B. 資料層與核心領域模型
|
### B. 資料層與核心領域模型
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"composite": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "packages/shared" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user