Przeglądaj źródła

feat(backend): 拆分模块,在doc中新增后端目录结构规范

ice-static 4 miesięcy temu
rodzic
commit
367637f6a3

+ 0 - 1
.gitignore

@@ -1,5 +1,4 @@
 node_modules/
-.env
 *.local
 *.log
 package-lock.json

+ 4 - 6
backend/.env.example

@@ -1,9 +1,7 @@
-PORT=3000
 NODE_ENV=development
-DASHSCOPE_API_KEY=your_api_key_here
-DASHSCOPE_APP_ID=your_app_id_here
-
 
-应用ID:87ab39e9dc0c4aed8597dc0f6a35e04a
-API Key:sk-aaa67885fdb3450185943bd3eb79e64e
+MOBILE_CHAT_PORT=3201
+PC_PORT=3101
 
+DASHSCOPE_API_KEY=your_api_key_here
+DASHSCOPE_APP_ID=your_app_id_here

+ 15 - 0
backend/src/apps/mobile/chat/app.ts

@@ -0,0 +1,15 @@
+import express from 'express';
+import cors from 'cors';
+import type { Express } from 'express';
+import { mobileChatApiRouter } from './routes/chat.routes.js';
+
+export function createMobileChatApp(): Express {
+  const app = express();
+
+  app.use(cors());
+  app.use(express.json());
+
+  app.use('/api', mobileChatApiRouter);
+
+  return app;
+}

+ 141 - 0
backend/src/apps/mobile/chat/controllers/chat.controller.ts

@@ -0,0 +1,141 @@
+import type { Request, Response } from 'express';
+
+interface ChatRequestBody {
+  prompt: string;
+  sessionId?: string;
+}
+
+interface DashScopeRequestBody {
+  input: {
+    prompt: string;
+    session_id?: string;
+  };
+  parameters: {
+    incremental_output: boolean;
+    has_thoughts: boolean;
+  };
+  debug: Record<string, unknown>;
+}
+
+interface DashScopeErrorResponse {
+  message?: string;
+}
+
+export async function postChat(req: Request, res: Response) {
+  const { prompt, sessionId } = req.body as ChatRequestBody;
+
+  if (!prompt) {
+    res.status(400).json({ error: { message: 'Prompt is required' } });
+    return;
+  }
+
+  const apiKey = process.env.DASHSCOPE_API_KEY;
+  const appId = process.env.DASHSCOPE_APP_ID;
+
+  if (!apiKey || !appId) {
+    res.status(500).json({ error: { message: 'API configuration missing' } });
+    return;
+  }
+
+  const apiUrl = `https://dashscope.aliyuncs.com/api/v1/apps/${appId}/completion`;
+
+  const requestBody: DashScopeRequestBody = {
+    input: { prompt },
+    parameters: {
+      incremental_output: true,
+      has_thoughts: true
+    },
+    debug: {}
+  };
+
+  if (sessionId) {
+    requestBody.input.session_id = sessionId;
+  }
+
+  try {
+    const response = await fetch(apiUrl, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${apiKey}`,
+        'Content-Type': 'application/json',
+        'X-DashScope-SSE': 'enable'
+      },
+      body: JSON.stringify(requestBody)
+    });
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({})) as DashScopeErrorResponse;
+      res
+        .status(response.status)
+        .json({ error: { message: errorData.message || `Request failed: ${response.status}` } });
+      return;
+    }
+
+    res.setHeader('Content-Type', 'text/event-stream');
+    res.setHeader('Cache-Control', 'no-cache');
+    res.setHeader('Connection', 'keep-alive');
+
+    const reader = response.body?.getReader();
+    const decoder = new TextDecoder();
+
+    if (!reader) {
+      res.status(500).json({ error: { message: 'Failed to read response stream' } });
+      return;
+    }
+
+    let buffer = '';
+
+    const readStream = async () => {
+      try {
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) {
+            if (buffer) {
+              const lines = buffer.split('\n');
+              for (const line of lines) {
+                if (line.startsWith('data:')) {
+                  const data = line.slice(5).trim();
+                  if (data) {
+                    res.write(`data: ${data}\n\n`);
+                  }
+                }
+              }
+            }
+            res.end();
+            break;
+          }
+
+          buffer += decoder.decode(value, { stream: true });
+          const lines = buffer.split('\n');
+          buffer = lines.pop() || '';
+
+          for (const line of lines) {
+            if (line.startsWith('data:')) {
+              const data = line.slice(5).trim();
+              if (data) {
+                res.write(`data: ${data}\n\n`);
+              }
+            }
+          }
+        }
+      } catch (error) {
+        console.error('Stream error:', error);
+        res.end();
+      }
+    };
+
+    readStream();
+
+    req.on('close', () => {
+      reader.cancel();
+    });
+  } catch (error) {
+    console.error('Chat error:', error);
+    res.status(500).json({ error: { message: error instanceof Error ? error.message : 'Internal server error' } });
+  }
+}
+
+export function getHealth(_req: Request, res: Response) {
+  res.json({ status: 'ok', timestamp: new Date().toISOString() });
+}
+

+ 8 - 0
backend/src/apps/mobile/chat/routes/chat.routes.ts

@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import { getHealth, postChat } from '../controllers/chat.controller.js';
+
+export const mobileChatApiRouter: ExpressRouter = Router();
+
+mobileChatApiRouter.post('/chat', postChat);
+mobileChatApiRouter.get('/health', getHealth);

+ 11 - 0
backend/src/apps/mobile/chat/server.ts

@@ -0,0 +1,11 @@
+import { createMobileChatApp } from './app.js';
+import { getNumberEnv } from '../../../shared/config/env.js';
+
+export function startMobileChatServer() {
+  const app = createMobileChatApp();
+  const port = getNumberEnv('MOBILE_CHAT_PORT', 3201);
+
+  app.listen(port, () => {
+    console.log(`mobile-chat server running on http://localhost:${port}`);
+  });
+}

+ 16 - 0
backend/src/apps/pc/health/app.ts

@@ -0,0 +1,16 @@
+import express from 'express';
+import cors from 'cors';
+import type { Express } from 'express';
+import { pcHealthApiRouter } from './routes/health.routes.js';
+
+export function createPcHealthApp(): Express {
+  const app = express();
+
+  app.use(cors());
+  app.use(express.json());
+
+  app.use('/api', pcHealthApiRouter);
+
+  return app;
+}
+

+ 6 - 0
backend/src/apps/pc/health/controllers/health.controller.ts

@@ -0,0 +1,6 @@
+import type { Request, Response } from 'express';
+
+export function getHealth(_req: Request, res: Response) {
+  res.json({ status: 'ok', timestamp: new Date().toISOString(), platform: 'pc' });
+}
+

+ 8 - 0
backend/src/apps/pc/health/routes/health.routes.ts

@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import { getHealth } from '../controllers/health.controller.js';
+
+export const pcHealthApiRouter: ExpressRouter = Router();
+
+pcHealthApiRouter.get('/health', getHealth);
+

+ 12 - 0
backend/src/apps/pc/health/server.ts

@@ -0,0 +1,12 @@
+import { createPcHealthApp } from './app.js';
+import { getNumberEnv } from '../../../shared/config/env.js';
+
+export function startPcServer() {
+  const app = createPcHealthApp();
+  const port = getNumberEnv('PC_PORT', 3101);
+
+  app.listen(port, () => {
+    console.log(`pc server running on http://localhost:${port}`);
+  });
+}
+

+ 4 - 149
backend/src/index.ts

@@ -1,153 +1,8 @@
-import express from 'express';
-import cors from 'cors';
 import { config } from 'dotenv';
+import { startMobileChatServer } from './apps/mobile/chat/server.js';
+import { startPcServer } from './apps/pc/health/server.js';
 
 config();
 
-const app = express();
-const PORT = process.env.PORT || 3000;
-
-app.use(cors());
-app.use(express.json());
-
-interface ChatRequest {
-  prompt: string;
-  sessionId?: string;
-}
-
-interface DashScopeRequest {
-  input: {
-    prompt: string;
-    session_id?: string;
-  };
-  parameters: {
-    incremental_output: boolean;
-    has_thoughts: boolean;
-  };
-  debug: Record<string, unknown>;
-}
-
-interface DashScopeErrorResponse {
-  message?: string;
-}
-
-app.post('/api/chat', async (req, res) => {
-  const { prompt, sessionId } = req.body as ChatRequest;
-
-  if (!prompt) {
-    res.status(400).json({ error: { message: 'Prompt is required' } });
-    return;
-  }
-
-  const apiKey = process.env.DASHSCOPE_API_KEY;
-  const appId = process.env.DASHSCOPE_APP_ID;
-
-  if (!apiKey || !appId) {
-    res.status(500).json({ error: { message: 'API configuration missing' } });
-    return;
-  }
-
-  const apiUrl = `https://dashscope.aliyuncs.com/api/v1/apps/${appId}/completion`;
-
-  const requestBody: DashScopeRequest = {
-    input: { prompt },
-    parameters: {
-      incremental_output: true,
-      has_thoughts: true
-    },
-    debug: {}
-  };
-
-  if (sessionId) {
-    requestBody.input.session_id = sessionId;
-  }
-
-  try {
-    const response = await fetch(apiUrl, {
-      method: 'POST',
-      headers: {
-        'Authorization': `Bearer ${apiKey}`,
-        'Content-Type': 'application/json',
-        'X-DashScope-SSE': 'enable'
-      },
-      body: JSON.stringify(requestBody)
-    });
-
-    if (!response.ok) {
-      const errorData = await response.json().catch(() => ({})) as DashScopeErrorResponse;
-      res.status(response.status).json({ error: { message: errorData.message || `Request failed: ${response.status}` } });
-      return;
-    }
-
-    res.setHeader('Content-Type', 'text/event-stream');
-    res.setHeader('Cache-Control', 'no-cache');
-    res.setHeader('Connection', 'keep-alive');
-
-    const reader = response.body?.getReader();
-    const decoder = new TextDecoder();
-
-    if (!reader) {
-      res.status(500).json({ error: { message: 'Failed to read response stream' } });
-      return;
-    }
-
-    let buffer = '';
-
-    const readStream = async () => {
-      try {
-        while (true) {
-          const { done, value } = await reader.read();
-          if (done) {
-            if (buffer) {
-              const lines = buffer.split('\n');
-              for (const line of lines) {
-                if (line.startsWith('data:')) {
-                  const data = line.slice(5).trim();
-                  if (data) {
-                    res.write(`data: ${data}\n\n`);
-                  }
-                }
-              }
-            }
-            res.end();
-            break;
-          }
-
-          buffer += decoder.decode(value, { stream: true });
-          const lines = buffer.split('\n');
-          buffer = lines.pop() || '';
-
-          for (const line of lines) {
-            if (line.startsWith('data:')) {
-              const data = line.slice(5).trim();
-              if (data) {
-                res.write(`data: ${data}\n\n`);
-              }
-            }
-          }
-        }
-      } catch (error) {
-        console.error('Stream error:', error);
-        res.end();
-      }
-    };
-
-    readStream();
-
-    req.on('close', () => {
-      reader.cancel();
-    });
-
-  } catch (error) {
-    console.error('Chat error:', error);
-    res.status(500).json({ error: { message: error instanceof Error ? error.message : 'Internal server error' } });
-  }
-});
-
-app.get('/api/health', (_req, res) => {
-  res.json({ status: 'ok', timestamp: new Date().toISOString() });
-});
-
-app.listen(PORT, () => {
-  console.log(`Server running on http://localhost:${PORT}`);
-});
+startPcServer();
+startMobileChatServer();

+ 21 - 0
backend/src/shared/config/env.ts

@@ -0,0 +1,21 @@
+export function getStringEnv(name: string): string | undefined {
+  const value = process.env[name];
+  if (!value) {
+    return undefined;
+  }
+  const trimmed = value.trim();
+  return trimmed ? trimmed : undefined;
+}
+
+export function getNumberEnv(name: string, fallback: number): number {
+  const value = getStringEnv(name);
+  if (!value) {
+    return fallback;
+  }
+  const parsed = Number(value);
+  if (!Number.isFinite(parsed)) {
+    return fallback;
+  }
+  return parsed;
+}
+

+ 287 - 0
doc/后端目录规范.md

@@ -0,0 +1,287 @@
+# 📐 Express 后端项目架构与开发规范文档(强隔离 · 多端口 · 分模块)
+
+## 一、架构设计理念
+
+本项目后端采用 **“强隔离 + 多端口 + 分模块”** 的 BFF(Backend For Frontend)风格架构。
+
+**核心思想:**
+- 同一个 `backend` 仓库中,PC 端与移动端的接口实现彼此隔离(避免互相影响)。
+- PC 端再按业务模块拆分为多个独立的 Express App,每个模块 **独立端口**(满足你“分模块、分端口”的诉求)。
+- 通用能力(环境变量解析、通用中间件、错误模型、工具函数)集中在 `shared/`,作为唯一允许跨模块复用的区域。
+
+**为什么要分端口?**
+- 模块级独立发布/重启:某个模块改动或异常时不影响其他模块。
+- 权限与流量隔离:可以在网关或反向代理层按端口做更明确的访问控制/限流。
+- 未来可拆仓库:把某个模块目录剪切出去即可形成独立服务,迁移成本更低。
+
+---
+
+## 二、目录结构概览
+
+后端代码根目录:`backend/src`
+
+```text
+backend/
+└── src/
+    ├── index.ts                          # [总启动器] 启动多个 Express 实例并监听不同端口
+    │
+    ├── apps/                             # [应用隔离区] 端 + 模块 的物理隔离
+    │   ├── pc/                           # [💻 PC 端隔离区]
+    │   │   ├── user/                     # PC - 用户模块(独立端口)
+    │   │   │   ├── app.ts                # 创建 Express app(挂中间件/路由/错误处理)
+    │   │   │   ├── server.ts             # 监听端口(从 env 读取)
+    │   │   │   ├── routes/               # 路由层(Router 组合)
+    │   │   │   ├── controllers/          # 控制器层(req/res 编排)
+    │   │   │   ├── services/             # 业务服务层(业务逻辑/聚合)
+    │   │   │   ├── models/               # 模型/DTO(模块内自洽)
+    │   │   │   ├── validators/           # 入参校验(模块内)
+    │   │   │   └── middlewares/          # 模块私有中间件(鉴权/审计等)
+    │   │   │
+    │   │   ├── order/                    # PC - 订单模块(独立端口)
+    │   │   └── ...                       # PC 其他模块(按需增加)
+    │   │
+    │   └── mobile/                       # [📱 移动端隔离区]
+    │       ├── user/                     # Mobile - 用户模块(独立端口)
+    │       ├── content/                  # Mobile - 内容模块(独立端口)
+    │       └── ...
+    │
+    └── shared/                           # [共享区] 唯一允许跨模块复用的区域
+        ├── config/                       # 环境变量、端口映射、运行环境
+        ├── http/                         # 通用 HTTP 能力(中间件/错误处理/响应封装)
+        ├── errors/                       # 通用错误类型(AppError 等)
+        ├── types/                        # 全局类型(仅 truly-shared)
+        └── utils/                        # 工具函数(不可依赖具体业务)
+```
+
+---
+
+## 三、核心开发纪律(必须遵守)
+
+### 🚫 1. 绝对禁止跨模块导入(防越界法则)
+
+为了保证“模块独立端口”真正成立:
+- `apps/pc/<module>/` **禁止**导入 `apps/pc/<other-module>/` 的任何文件。
+- `apps/mobile/<module>/` **禁止**导入 `apps/mobile/<other-module>/` 的任何文件。
+- `apps/pc/**` 与 `apps/mobile/**` 之间也 **禁止互相导入**。
+- **唯一例外**:允许导入 `shared/**`。
+
+如果双端/多模块存在相似实现:
+- 业务逻辑允许重复(解耦优先)。
+- 只有“纯工具 / 纯协议 / 纯错误模型”才放进 `shared/`。
+
+### 🚫 2. 禁止在 `src/` 根目录堆业务代码
+
+`src/` 根目录只允许:
+- `index.ts`(总启动器)
+- `apps/`(隔离区)
+- `shared/`(共享区)
+
+除此之外不应出现具体业务 controller/service/model。
+
+---
+
+## 四、端口与服务拆分规范
+
+### 1. 端口规划(建议)
+
+使用“端 + 模块”粒度进行端口分配,通过环境变量统一管理。
+
+示例(仅示意,可按你的模块数量调整):
+
+| 端 | 模块 | 环境变量 | 默认端口 |
+|---|---|---|---|
+| PC | user | `PC_USER_PORT` | 3101 |
+| PC | order | `PC_ORDER_PORT` | 3102 |
+| Mobile | user | `MOBILE_USER_PORT` | 3201 |
+| Mobile | content | `MOBILE_CONTENT_PORT` | 3202 |
+
+建议端口分段:
+- `31xx`:PC
+- `32xx`:Mobile
+
+### 2. URL 规范(即使分端口也要统一风格)
+
+每个服务内部的路由建议统一以 `/api` 作为前缀:
+- `http://localhost:3101/api/...`(PC-user)
+- `http://localhost:3202/api/...`(Mobile-content)
+
+### 3. 是否需要“网关聚合层”
+
+如果前端希望只配置一个 baseURL(减少多端口管理成本),可在未来增加一个“API Gateway”(仍在本仓库内):
+- 作为单端口入口(例如 `3000`),按路径转发到对应模块端口
+- 该网关只做转发与鉴权,不承载业务逻辑
+
+是否立刻引入取决于你的前端请求层如何管理多个 baseURL;本规范不强制。
+
+---
+
+## 五、模块内部代码分层规范
+
+每个模块建议遵循固定分层,以降低心智负担:
+
+### 1. `server.ts`(端口监听层)
+
+职责:
+- 读取本模块端口(env)
+- `app.listen(...)`
+- 打印启动信息(可选)
+
+要求:
+- 不写任何业务逻辑
+
+### 2. `app.ts`(装配层)
+
+职责:
+- 创建 `express()` 实例
+- 挂载通用中间件(`cors` / `express.json` / request id 等)
+- 挂载路由(`routes/`)
+- 挂载统一错误处理(来自 `shared/http`)
+
+### 3. `routes/`(路由层)
+
+职责:
+- 定义 URL 与 controller 映射
+- 只做“路由组织”,不写业务逻辑
+
+### 4. `controllers/`(控制器层)
+
+职责:
+- 解析请求参数(path/query/body)
+- 调用 service
+- 统一响应格式
+
+要求:
+- controller 内不直接写复杂业务逻辑
+- 外部依赖(数据库/第三方接口)应在 service 或 client 中处理
+
+### 5. `services/`(业务层)
+
+职责:
+- 编排业务流程
+- 聚合多个数据源
+- 产出业务结果或抛出业务错误
+
+### 6. `models/` 与 `validators/`
+
+建议:
+- `models/` 存 DTO、业务对象、接口响应结构类型
+- `validators/` 存入参校验逻辑(没有引入第三方校验库时,也可以写轻量手写校验)
+
+---
+
+## 六、`shared/` 共享区规范
+
+`shared/` 的存在是为了减少“不可避免的重复”,但必须克制使用。
+
+允许放入 `shared/` 的内容:
+- 环境变量读取与校验(例如 `shared/config/env.ts`)
+- 通用错误类型(例如 `shared/errors/app-error.ts`)
+- 通用 HTTP 中间件(例如统一错误处理、统一响应包装)
+- 与业务无关的工具函数(例如日期格式化、字符串处理)
+- 与端无关的协议类型(例如分页参数 `PageQuery`)
+
+禁止放入 `shared/` 的内容:
+- 任何“某个模块独有”的业务规则
+- 与某个端强绑定的鉴权逻辑(PC / Mobile 通常不同)
+- 依赖某个模块模型的工具(会引入反向耦合)
+
+---
+
+## 七、环境变量与配置规范
+
+### 1. `.env` 示例(建议)
+
+后端已使用 `dotenv`,建议将端口集中到环境变量中管理:
+
+```ini
+NODE_ENV=development
+
+PC_USER_PORT=3101
+PC_ORDER_PORT=3102
+
+MOBILE_USER_PORT=3201
+MOBILE_CONTENT_PORT=3202
+```
+
+### 2. 端口读取规范
+
+约定:每个模块只读取属于自己的端口变量,避免“误用其他模块端口”。
+
+---
+
+## 八、总启动器 `src/index.ts` 的职责边界
+
+`src/index.ts` 作为“进程内多端口启动器”,职责应保持克制:
+- 加载环境变量(`dotenv.config()`)
+- 启动各模块 server(例如 import 并调用 `startPcUserServer()`)
+- 处理进程级事件(例如 `SIGINT` 优雅退出,按需)
+
+它不应该:
+- 直接注册路由
+- 承载业务逻辑
+- 写模块内部的中间件细节
+
+---
+
+## 九、命名规范
+
+| 层级/类型 | 命名约定 | 示例 |
+|---|---|---|
+| 模块目录 | kebab 或 plain(统一即可) | `apps/pc/user` |
+| 路由文件 | `[resource].routes.ts` | `routes/user.routes.ts` |
+| 控制器 | `[resource].controller.ts` | `controllers/user.controller.ts` |
+| 服务 | `[domain].service.ts` | `services/user.service.ts` |
+| 模型/DTO | `[entity].model.ts` / `[name].dto.ts` | `models/user.model.ts` |
+| 校验 | `[name].validator.ts` | `validators/create-user.validator.ts` |
+
+---
+
+## 十、接口实现建议(最小可落地模板)
+
+每个模块建议至少包含:
+- 健康检查:`GET /api/health`
+- 版本信息:`GET /api/version`(可选)
+- 统一错误返回格式(来自 `shared/http`)
+
+统一响应格式(建议):
+
+```json
+{
+  "success": true,
+  "data": {},
+  "error": null
+}
+```
+
+错误响应(建议):
+
+```json
+{
+  "success": false,
+  "data": null,
+  "error": {
+    "message": "xxx",
+    "code": "SOME_ERROR_CODE"
+  }
+}
+```
+
+---
+
+## 十一、迁移当前 `backend/src/index.ts` 的建议落点
+
+你当前 `src/index.ts` 里包含了 `/api/chat` 与 `/api/health`,并且是单端口单应用形态。
+
+按本规范迁移时(建议,不要求一次做完):
+- 如果 `/api/chat` 属于 PC 或 Mobile 的某个模块:移动到对应的 `apps/<端>/<模块>/` 下
+- `src/index.ts` 只保留“启动多个模块 server”逻辑
+
+---
+
+## 十二、维护指南(接手/扩展时怎么做)
+
+1. 新增接口:先明确属于哪个端(pc/mobile),再明确属于哪个模块目录。
+2. 新增模块:在 `apps/<端>/<模块>/` 建目录,并新增端口环境变量。
+3. 改动共享能力:优先评估是否会影响所有模块;共享区变更需要更严格的自测。
+4. 需要复用但又不适合进 `shared/`:宁可复制一份(保持隔离),不要为了复用而耦合。
+

+ 14 - 3
frontend/proxy.conf.json

@@ -1,7 +1,18 @@
 {
-  "/api": {
-    "target": "http://localhost:3000",
+  "/api-mobile": {
+    "target": "http://localhost:3201",
     "secure": false,
-    "changeOrigin": true
+    "changeOrigin": true,
+    "pathRewrite": {
+      "^/api-mobile": "/api"
+    }
+  },
+  "/api-pc": {
+    "target": "http://localhost:3101",
+    "secure": false,
+    "changeOrigin": true,
+    "pathRewrite": {
+      "^/api-pc": "/api"
+    }
   }
 }

+ 1 - 1
frontend/src/app/mobile/features/qa/qa/qa.ts

@@ -296,7 +296,7 @@ export class Qa implements OnDestroy {
     };
 
     try {
-      for await (const chunk of this.aiChat.stream('/api/chat', body, this.abortController.signal)) {
+      for await (const chunk of this.aiChat.stream('/api-mobile/chat', body, this.abortController.signal)) {
         finalContent = chunk.text;
         finalThoughts = chunk.thoughts;
         finalImages = chunk.images;

+ 1 - 0
frontend/src/app/test/test.html

@@ -0,0 +1 @@
+<p>test works!</p>

+ 0 - 0
frontend/src/app/test/test.scss


+ 11 - 0
frontend/src/app/test/test.ts

@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+
+@Component({
+  selector: 'app-test',
+  imports: [],
+  templateUrl: './test.html',
+  styleUrl: './test.scss',
+})
+export class Test {
+
+}

+ 9 - 0
pnpm-lock.yaml

@@ -0,0 +1,9 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .: {}