Compare commits

..

31 Commits

Author SHA1 Message Date
dayjoy
d474216047 fix: 增加bug修复描述 2025-10-15 14:39:19 +08:00
dayjoy
71a7d7ba8c fix: 页面上不要展示主题切换按钮 2025-10-14 15:11:11 +08:00
dayjoy
bfaa656406 fix: 禁止出现横向滚动条 2025-10-14 15:09:35 +08:00
dayjoy
b293b91697 fix: Adjust font size for inputs on iOS devices 2025-10-14 14:47:12 +08:00
dayjoy
5495c7bf25 fix: 更新viewport meta,禁止缩放 2025-10-14 14:46:16 +08:00
dayjoy
2f38d95e8c fix: example改为字段描述和example 2025-10-13 14:13:37 +08:00
dayjoy
defe5ac49b fix: user的createdAt还是补充上 2025-10-13 10:47:57 +08:00
dayjoy
97bd5d013b feat: 每个接口都需要写 @Example 装饰器 2025-10-11 18:10:40 +08:00
dayjoy
1659d03040 feat: 补充接口example 2025-10-11 18:02:08 +08:00
dayjoy
99c8fa5f2d feat: 明确OperationId 2025-10-11 14:20:29 +08:00
dayjoy
7f057a50ca feat: 提示词更新:尽量不出现横向滚动条 2025-10-11 11:40:33 +08:00
dayjoy
b19b9f8b5f feat: 添加过滤内部接口的脚本 2025-10-11 11:07:18 +08:00
dayjoy
339dccdaa5 feat: 添加/api/swagger/swagger.json 2025-10-11 10:50:28 +08:00
dayjoy
c6464fe1d1 feat: 强调不许修改client相对路径 2025-10-10 16:45:02 +08:00
dayjoy
2341e36aa9 chore(comment): 注释改为英文 2025-09-30 14:27:53 +08:00
dayjoy
2651bd1373 feat(prompt): 确保页面背景色、文字色、按钮色等UI元素在不同主题下均有良好对比度和可读性 2025-09-30 14:23:22 +08:00
dayjoy
eb6eceffe9 feat(theme): 支持读取主题query参数 2025-09-30 14:23:04 +08:00
dayjoy
7c1241b26c feat(agent): 修改提示词以适应变化 2025-09-26 17:37:33 +08:00
dayjoy
718a535cb1 fix(client): rename getGroupMembers 2025-09-26 17:32:19 +08:00
dayjoy
eb39f41f0c fix(server): 处理错误消息 2025-09-26 17:28:06 +08:00
dayjoy
a4ccdada1c fix(script): 修改server启动命令 2025-09-26 17:25:58 +08:00
dayjoy
d8a921b50a fix(server): 处理错误消息 2025-09-26 17:25:43 +08:00
dayjoy
a5048bafb3 fix(client): 获取token逻辑修正 2025-09-26 17:10:17 +08:00
dayjoy
999a8e2520 fix(client): 接口结果修正 2025-09-26 16:57:09 +08:00
dayjoy
b78a07f192 fix(client): 替换接口path 2025-09-26 16:46:38 +08:00
dayjoy
5688bb4242 fix(server): 接口path修改 2025-09-26 16:45:12 +08:00
dayjoy
b1530ce06e fix(server): 用户信息接口不返回token 2025-09-26 16:29:26 +08:00
dayjoy
f53aed1b82 fix(script): 修改server启动命令 2025-09-26 16:29:09 +08:00
dayjoy
8c60050ff1 fix(server): 把/v3/api-docs加回来 2025-09-26 16:10:53 +08:00
dayjoy
8757f05e44 fix(server): 修复一个tsoa的问题,见issue
https://github.com/lukeautry/tsoa/issues/1090
2025-09-26 16:08:27 +08:00
dayjoy
d346a9f2f4 feat(server): add error handler & refactor controllers 2025-09-26 15:50:37 +08:00
25 changed files with 962 additions and 419 deletions

View File

@@ -14,16 +14,31 @@
- 基于响应式设计的单页应用SPA
- 专为移动端优化,**采用单一入口点架构**,所有功能模块通过组件化方式在同一页面内动态加载和切换,确保用户体验的连贯性和加载性能的优化
- **主题适配**: 确保组件支持亮色/暗色主题
- 确保页面背景色、文字色、按钮色等UI元素在不同主题下均有良好对比度和可读性
- 但是页面上不要展示主题切换按钮主题切换根据url参数 `?theme=dark``?theme=light` 来控制
- **错误展示**前端页面需要有统一的错误展示用于显示API请求失败或其他操作错误的信息
- 不需要实现登录页,默认访问应用的用户都是已登录状态
- 所有请求统一使用 `/src/api/index` 中的 `api` 方法进行调用,因为已经内置了必要的请求头封装
- 如果有需要,使用 `/src/api/user` 中的 `getUserInfo` 方法获取当前用户信息
- 如果有需要,使用 `/src/api/user` 中的 `getGroupUsers` 方法获取全部用户信息列表
- 如果有需要,使用 `/src/api/user` 中的 `getGroupMembers` 方法获取全部用户信息列表
### 2. 后端实现策略
- 所有接口以 `/api` 为前缀
- 已经为项目封装了当前用户信息获取的逻辑,提供了中间件,`userInfoMiddleware`,见: `src/middleware/auth.ts`
- 已经为项目封装了当前群组所有用户信息列表的获取逻辑,提供了中间件,`groupInfoMiddleware`,见: `src/middleware/auth.ts`
- 所有接口按照 `tsoa` 规范来编写,参考 `src/controllers` 目录下的 `UserController``GroupController` 示例
- 如果接口需要登录,请使用 `@Security('jwt')` 装饰器tsoa 会自动调用登录校验,并把 user 信息写到req上参考 `UserController` 示例
- **每个接口相关的类型定义(如interface、type)等都需要按照标准注释进行类型及字段的描述注释。不明确的字段如string类型的日期等还需要加上 @example 表明字段具体的格式示例**,例如:
```ts
/**
* 用户ID
* @example "123456"
*/
id: string;
/**
* 注册日期格式为yyyy-MM-dd
* @example "2025-01-01"
*/
registryDate: string;
```
### 3. 开发规范
- **文件命名**: 使用kebab-case命名文件和文件夹
@@ -32,6 +47,11 @@
- **类型定义**: 为所有数据结构定义TypeScript类型
- **代码组织**: 保持清晰的目录结构和模块化设计
### 4. 设计规范
- **响应式**: 确保所有页面在不同屏幕尺寸下均有良好展示效果
- **滚动条**: 根据屏幕宽度变化自适应,🈲禁止出现横向滚动条
## 任务执行流程
1. **需求分析**: 仔细解析JSON需求识别核心功能点
@@ -50,6 +70,8 @@
用户每次修改后,请在现有代码基础上**针对JSON中较上一次有变动的地方重点编辑实现**,确保所有变更都被正确反映。
此外用户还可能通过 `appBug` 字段反馈bug**如果 `appBug` 字段不为空字符串则必须优先修复对应的bug**并确保修复后代码依然符合用户最新的JSON需求。
## 注意事项
- 严格遵循现有的项目结构和编码规范
@@ -62,7 +84,9 @@
请基于以上规范和用户JSON需求生成完整的、可运行的、符合输出格式要求的代码实现。
接下来我会输出用户JSON需求其中前端需求放在 `webapp_requirements`, 后端需求放在 `node_backend_requirements`
接下来我会输出用户JSON需求其中前端需求放在 `webapp_requirements`, 后端需求放在 `node_backend_requirements`bug修复需求放在 `appBug`。
> 如果 webapp_requirements 中 is_needed = false代表不需要前端,只需要生成后端代码
> 如果 webapp_requirements 为 null代表不需要前端web页面或者web页面本次不需要变更,只需要生成后端代码
> 如果 node_backend_requirements 为 null代表后端代码本次不需要变更
> 如果 appBug 不为空字符串,代表本次需要修复 bug

View File

@@ -6,9 +6,9 @@
],
"scripts": {
"start": "yarn start:all",
"start:all": "concurrently -n 'client,server' 'yarn workspace egret-app-client dev' 'yarn workspace egret-app-server start'",
"start:all": "concurrently -n 'client,server' 'yarn workspace egret-app-client dev' 'yarn workspace egret-app-server serve'",
"start:client": "yarn workspace egret-app-client dev",
"start:server": "yarn workspace egret-app-server start",
"start:server": "yarn workspace egret-app-server serve",
"build": "lerna run build && node ./scripts/post-build.js"
},
"devDependencies": {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/great-egret-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>Egret App</title>
</head>
<body>

View File

@@ -2,15 +2,30 @@ import "./App.css";
import React, { useEffect } from "react";
import { Routes } from "react-router-dom";
import {handleAuthTokenAndGroupIdFromUrl} from "./utils/auth";
import { useTheme } from "@/components/theme-provider";
const App: React.FC = () => {
const { setTheme } = useTheme();
useEffect(() => {
// 在组件挂载时处理URL中的authToken groupId
// Consume authToken & groupId from URL and persist
handleAuthTokenAndGroupIdFromUrl();
}, []);
// Read optional theme ('light' | 'dark') from URL and persist via ThemeProvider
const params = new URLSearchParams(window.location.search);
const themeParam = params.get('theme');
if (themeParam === 'light' || themeParam === 'dark') {
setTheme(themeParam);
// Clean up URL param after applying
const url = new URL(window.location.href);
url.searchParams.delete('theme');
window.history.replaceState(null, '', url.toString());
}
}, [setTheme]);
return (
<div className="w-full h-full bg-gray-50">
<div className="w-full min-h-dvh bg-background text-foreground">
<main>
<Routes></Routes>
</main>

View File

@@ -5,8 +5,7 @@ interface RequestOptions extends RequestInit {
}
/**
* 统一的fetch请求封装
* 自动添加Authorization头部
* Unified fetch wrapper that adds auth and group headers
*/
export const apiRequest = async <T = any>(
endpoint: string,
@@ -17,34 +16,34 @@ export const apiRequest = async <T = any>(
const url = endpoint;
// 准备请求头
// Prepare request headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
// 如果有authToken添加Authorization头
// Attach Authorization header if token exists
if (authToken) {
headers.authorization = `Bearer ${authToken}`;
}
// 如果有groupId添加groupId头
// Attach groupId header if present
if (groupId) {
headers.groupId = groupId;
}
// 发起请求
// Fire request
const response = await fetch(url, {
...options,
headers,
});
// 处理响应
// Handle non-2xx
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 尝试解析JSON如果失败则返回响应对象
// Try to parse JSON; fallback to raw response
try {
return await response.json();
} catch {
@@ -52,34 +51,40 @@ export const apiRequest = async <T = any>(
}
};
// 便捷的HTTP方法封装
// Convenience HTTP helpers
export const api = {
get: <T = any>(endpoint: string, options?: RequestOptions): Promise<T> =>
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
get: <T = any>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, { ...options, method: 'GET' }),
post: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> =>
apiRequest<T>(endpoint, {
post: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
}),
put: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> =>
apiRequest<T>(endpoint, {
put: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
}),
patch: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<T> =>
apiRequest<T>(endpoint, {
patch: <T = any>(endpoint: string, data?: any, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, {
...options,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T = any>(endpoint: string, options?: RequestOptions): Promise<T> =>
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
delete: <T = any>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> =>
apiRequest<ApiResponse<T>>(endpoint, { ...options, method: 'DELETE' }),
};
export type ApiResponse<T> = {
code: number;
message: string;
data: T;
}
export default api;

View File

@@ -1,5 +1,4 @@
import api from "@/api/index.ts";
import {getGroupId} from "@/utils/auth.ts";
export type UserInfo = {
userId: number;
@@ -13,17 +12,17 @@ export type UserInfo = {
}
/**
* 获取当前用户的信息
* Get current user information
*/
export const getUserInfo = async (): Promise<UserInfo> => {
return await api.post<UserInfo>('https://egret.byteawake.com/api/user/info');
export const getUserInfo = async (): Promise<UserInfo | null> => {
const res = await api.get<UserInfo>('/api/user/info');
return res.code === 200 ? res.data : null;
};
/**
* 获取群组内所有用户的信息
* Get all users in the current group
*/
export const getGroupUsers = async (): Promise<UserInfo[]> => {
return await api.post<UserInfo[]>('https://egret.byteawake.com/api/group/members', {
groupId: Number(getGroupId()),
});
export const getGroupMembers = async (): Promise<UserInfo[]> => {
const res = await api.get<UserInfo[]>('/api/group/members');
return res.code === 200 ? res.data || [] : [];
};

View File

@@ -130,3 +130,18 @@
@apply bg-background text-foreground;
}
}
@media only screen and (max-device-width: 414px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 2) {
/* Adjust font size for inputs on iOS devices */
.flex-input {
font-size: 16px !important;
}
}
@media screen and (-webkit-min-device-pixel-ratio:2) {
select,
textarea,
input {
font-size: 16px !important;
}
}

View File

@@ -1,17 +1,17 @@
// 处理认证相关的工具函数
// Auth utilities
const AUTH_TOKEN_KEY = 'authToken';
const GROUP_ID_KEY = 'groupId';
/**
* 从URL搜索参数中获取authToken
* Get authToken from URL search params
*/
export const getAuthTokenFromUrl = (): string | null => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('authToken');
};
/**
* 从URL搜索参数中获取groupId
* Get groupId from URL search params
*/
export const getGroupIdFromUrl = (): string | null => {
const urlParams = new URLSearchParams(window.location.search);
@@ -19,50 +19,51 @@ export const getGroupIdFromUrl = (): string | null => {
};
/**
* 保存authTokenlocalStorage
* Save authToken to localStorage
*/
export const saveAuthToken = (token: string): void => {
localStorage.setItem(AUTH_TOKEN_KEY, token);
};
/**
* 保存groupIdlocalStorage
* Save groupId to localStorage
*/
export const saveGroupId = (groupId: string): void => {
localStorage.setItem(GROUP_ID_KEY, groupId);
};
/**
* 从localStorage获取authToken
* Get authToken with URL fallback
*/
export const getAuthToken = (): string | null => {
return localStorage.getItem(AUTH_TOKEN_KEY);
const tokenFromUrl = getAuthTokenFromUrl();
return tokenFromUrl || localStorage.getItem(AUTH_TOKEN_KEY);
};
/**
* 从localStorage获取groupId
* Get groupId with URL fallback
*/
export const getGroupId = (): string | null => {
return localStorage.getItem(GROUP_ID_KEY);
const groupIdFromUrl = getGroupIdFromUrl();
return groupIdFromUrl || localStorage.getItem(GROUP_ID_KEY);
};
/**
* 清除authToken
* Clear authToken
*/
export const clearAuthToken = (): void => {
localStorage.removeItem(AUTH_TOKEN_KEY);
};
/**
* 清除groupId
* Clear groupId
*/
export const clearGroupId = (): void => {
localStorage.removeItem(GROUP_ID_KEY);
};
/**
* 检查并处理URL中的authToken
* 如果URL中有authToken参数则保存到localStorage并从URL中移除
* Consume authToken/groupId from URL and persist, then clean URL
*/
export const handleAuthTokenAndGroupIdFromUrl = (): void => {
const tokenFromUrl = getAuthTokenFromUrl();
@@ -78,7 +79,7 @@ export const handleAuthTokenAndGroupIdFromUrl = (): void => {
updated = true;
}
// Clean URL if we consumed any param
// Clean URL if any param was consumed
if (updated) {
const url = new URL(window.location.href);
url.searchParams.delete('authToken');

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"build": "tsoa spec-and-routes && tsc && tsc-alias",
"serve": "node ./build/index.js",
"build": "tsoa -c tsoaConfig.json spec-and-routes && node scripts/clean-swagger.js && tsc && tsc-alias",
"serve": "yarn build && node ./build/index.js",
"start": "nodemon"
},
"keywords": [],
@@ -19,7 +19,7 @@
"nocache": "^4.0.0",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7",
"tsoa": "^5.0.0",
"tsoa": "^6.6.0",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,71 @@
import fs from "fs";
const filePath = "./build/swagger.json";
const swagger = JSON.parse(fs.readFileSync(filePath, "utf8"));
// ==============================
// ① 删除不希望公开的接口路径
// ==============================
const pathsToRemove = ["/api/test", "/api/user/info", "/api/group/members"];
for (const path of pathsToRemove) {
if (swagger.paths?.[path]) {
delete swagger.paths[path];
console.log(`🧹 Removed path: ${path}`);
}
}
// ==============================
// ② 收集引用的 schema 名称(递归)
// ==============================
const referenced = new Set();
function collectRefs(obj) {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const value = obj[key];
if (key === "$ref" && typeof value === "string" && value.startsWith("#/components/schemas/")) {
const name = value.split("/").pop();
if (!referenced.has(name)) {
referenced.add(name);
// 递归收集该 schema 内的引用
const schema = swagger.components?.schemas?.[name];
if (schema) collectRefs(schema);
}
} else {
collectRefs(value);
}
}
}
// 先从 paths 开始收集
collectRefs(swagger.paths);
// 然后递归收集 schemas 内部互相引用的部分
let prevCount = 0;
do {
prevCount = referenced.size;
for (const name of Array.from(referenced)) {
const schema = swagger.components?.schemas?.[name];
if (schema) collectRefs(schema);
}
} while (referenced.size > prevCount);
// ==============================
// ③ 删除未被引用的 schema
// ==============================
if (swagger.components?.schemas) {
const allSchemas = Object.keys(swagger.components.schemas);
const unused = allSchemas.filter((name) => !referenced.has(name));
for (const name of unused) {
delete swagger.components.schemas[name];
}
console.log(`🧩 Removed ${unused.length} unused schemas`);
}
// ==============================
// ④ 保存结果
// ==============================
fs.writeFileSync(filePath, JSON.stringify(swagger, null, 2));
console.log("✅ Swagger cleanup complete!");

View File

@@ -0,0 +1,69 @@
import {Controller, Get, Route, Response, Tags, Request, Security, OperationId} from 'tsoa';
import {ApiError, ApiResponse} from '../types/api';
import type {Request as ExpressRequest} from 'express';
import axios from "axios";
import type {UserInfo} from "../types/user";
export const getGroupMembers = async (groupId: number, token: string): Promise<UserInfo[]> => {
try {
const response = await axios.post<ApiResponse<UserInfo[]>>(
"https://egret.byteawake.com/api/group/members",
{groupId},
{
headers: {
Authorization: `Bearer ${token}`,
},
timeout: 10000, // 10 second timeout
}
);
if (response.data.code !== 200) {
throw new Error(response.data.message);
}
return response.data.data ?? [];
} catch (error: any) {
if (error.response) {
// API returned error response
throw new ApiError(400, `Failed to get group members information: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
// Request was sent but no response received
throw new ApiError(400, "Failed to get group members information: timeout or network error");
} else {
// Other errors
throw new ApiError(400, `Failed to get group members information: ${error.message}`);
}
}
};
@Route('api/group')
@Tags('Chat Group')
export class GroupController extends Controller {
/**
* Get all users in the current chat group
* @summary Get group members
* @description Returns the full member list of the current chat group
*/
@Get('/members')
@OperationId('Group_GetGroupMembers') // MUST: Specify operationId for better API documentation
@Security("jwt")
@Response<ApiResponse<UserInfo[]>>(200, 'Success')
@Response<ApiResponse<null>>(400, 'Bad Request')
@Response<ApiResponse<null>>(401, 'Unauthorized')
public async getGroupMembers(@Request() req: ExpressRequest): Promise<ApiResponse<UserInfo[]>> {
const user = req.user;
const groupId = req.headers.groupid;
if (!groupId) {
throw new ApiError(400, 'groupId is required in headers');
}
const users = await getGroupMembers(Number(groupId), user.token);
return {
code: 200,
message: 'success',
data: users,
};
}
}

View File

@@ -1,18 +1,20 @@
import { Controller, Get, Route, Response, Tags } from 'tsoa';
import { ApiResponse } from '../types/api';
import {Controller, Get, Route, Response, Tags, OperationId} from 'tsoa';
import type { ApiResponse } from '../types/api';
@Route('api')
@Tags('Test')
export class TestController extends Controller {
/**
* 测试接口
* @summary 测试接口
* @description 用于测试API连通性的基础接口
* Test endpoint
* @summary Test endpoint
* @description Basic endpoint to verify API connectivity
*/
@Get('/test')
@OperationId('Test_GetTest') // MUST: Specify operationId for better API documentation
@Response<ApiResponse>(200, 'Success')
public async getTest(): Promise<ApiResponse> {
return {
code: 200,
message: 'success',
data: null,
};

View File

@@ -0,0 +1,28 @@
import {Controller, Get, Route, Response, Tags, Request, Security, OperationId} from 'tsoa';
import type { ApiResponse } from '../types/api';
import type { Request as ExpressRequest } from 'express';
import type {UserInfo} from "../types/user";
@Route('api/user')
@Tags('User')
export class UserController extends Controller {
/**
* Get current user information
* @summary Get current user information
* @description Returns the authenticated user's profile
*/
@Get('/info')
@OperationId('User_GetUserGroupInfo') // MUST: Specify operationId for better API documentation
@Security('jwt')
@Response<ApiResponse<UserInfo>>(200, 'Success')
@Response(401, 'Unauthorized')
public async getUserGroupInfo(@Request() req: ExpressRequest): Promise<ApiResponse<Omit<UserInfo, 'token'>>> {
const { token, ...rest } = req.user;
return {
code: 200,
message: 'success',
data: rest
};
}
}

View File

@@ -1,30 +0,0 @@
import { Controller, Get, Route, Response, Tags, Middlewares, Request } from 'tsoa';
import { ApiResponse, UserGroupInfo } from '../types/api';
import { userInfoMiddleware, groupInfoMiddleware } from '../middleware/auth';
import type { Request as ExpressRequest } from 'express';
@Route('api')
@Tags('User Group')
export class UserGroupController extends Controller {
/**
* 获取用户群组信息
* @summary 获取用户群组信息
* @description 获取当前用户的个人信息和所在群组的所有用户信息
*/
@Get('/user-group/info')
@Middlewares([userInfoMiddleware, groupInfoMiddleware])
@Response<ApiResponse<UserGroupInfo>>(200, 'Success')
@Response(401, 'Unauthorized')
public async getUserGroupInfo(@Request() req: ExpressRequest): Promise<ApiResponse<UserGroupInfo>> {
const userInfo = req.userInfo;
const groupInfo = req.groupInfo;
return {
message: 'success',
data: {
user: userInfo || null,
group: groupInfo || null
}
};
}
}

View File

@@ -8,8 +8,9 @@ import path from "path";
dotenv.config({ path: path.resolve(__dirname, "../../../.env") });
import "./database";
import { sequelize } from "@/database/instance";
import { RegisterTsoaRoutes } from "./middleware/tsoa.middleware";
import { sequelize } from "./database/instance";
import {errorHandler} from "./middleware/errorHandler";
import {RegisterTsoaRoutes} from "./middleware/tsoa.middleware";
const port = process.env.PORT || 3005;
@@ -19,12 +20,13 @@ app.use(nocache());
app.use(cors());
app.use(express.json({ limit: "100mb" }));
app.use(compression());
app.use(express.static(path.resolve(__dirname, "client")));
app.use(express.static(path.resolve(__dirname, "client"))); // DON'T MODIFY THIS LINE, IT IS USED IN BUILD
// Register tsoa routes
RegisterTsoaRoutes(app);
app.use(errorHandler);
const host = "0.0.0.0";
app.listen(Number(port), host, async () => {

View File

@@ -1,27 +1,25 @@
import {Request, Response, NextFunction} from "express";
import {Request} from "express";
import axios from "axios";
import {ApiError, ApiResponse} from "../types/api";
import type {UserInfo} from "../types/user";
export type UserInfo = {
userId: number;
nickname: string;
avatarUrl: string;
gender: 'MALE' | 'FEMALE' | 'UNKNOWN';
nimToken: string; // NetEase Cloud Communication token
nimAccountId: string; // NetEase Cloud Communication account ID
createdAt: string;
updatedAt: string;
}
export async function expressAuthentication(
request: Request,
securityName: string,
scopes?: string[]
): Promise<any> {
if (securityName === "jwt") {
const authHeader = request.headers.authorization;
const token = extractTokenFromHeader(authHeader);
declare global {
namespace Express {
interface Request {
userInfo?: UserInfo | null;
groupInfo?: {
groupId: number;
users: UserInfo[];
} | null;
if (!token) {
throw new ApiError(401, "Unauthorized");
}
// Returned object is assigned to request.user
return await getUserInfoByToken(token);
}
throw new ApiError(401, "Unsupported security scheme");
}
/**
@@ -43,11 +41,11 @@ export const extractTokenFromHeader = (authHeader: string | undefined): string |
/**
* Get user information by token
*/
export const getUserInfoByToken = async (token: string): Promise<any> => {
export const getUserInfoByToken = async (token: string): Promise<UserInfo> => {
try {
const response = await axios.post(
const response = await axios.post<ApiResponse<Omit<UserInfo, 'token'>>>(
"https://egret.byteawake.com/api/user/info",
{}, // 请求体数据,这里为空对象
{}, // Empty request body
{
headers: {
Authorization: `Bearer ${token}`,
@@ -56,123 +54,23 @@ export const getUserInfoByToken = async (token: string): Promise<any> => {
}
);
return response.data;
if (response.data.code !== 200 || !response.data.data) {
throw new Error(response.data.message);
}
return {...response.data.data, token};
} catch (error: any) {
if (error.response) {
// API returned error response
throw new Error(`Failed to get user information: ${error.response.status} ${error.response.statusText}`);
throw new ApiError(401, `Failed to get user information: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
// Request was sent but no response received
throw new Error("Failed to get user information: timeout or network error");
throw new ApiError(401, "Failed to get user information: timeout or network error");
} else {
// Other errors
throw new Error(`Failed to get user information: ${error.message}`);
}
}
};
/**
* User information middleware
* Automatically extract token from request header and get user information, store result in req.userInfo
*/
export const userInfoMiddleware = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(authHeader);
if (!token) {
res.status(401).json({
error: "No valid access token provided",
message: "Missing Bearer token in Authorization header",
});
return;
}
// Get user information
const userInfoRes = await getUserInfoByToken(token);
req.userInfo = userInfoRes.code === 200 ? userInfoRes.data : null;
next();
} catch (error: any) {
res.status(401).json({
error: "User authentication failed",
message: error.message,
});
}
};
//////// group
/**
* Get group users
*/
export const getGroupUsers = async (groupId: number, token: string): Promise<any> => {
try {
const response = await axios.post<UserInfo[]>(
"https://egret.byteawake.com/api/group/members",
{groupId},
{
headers: {
Authorization: `Bearer ${token}`,
},
timeout: 10000, // 10 second timeout
}
);
return response.data;
} catch (error: any) {
if (error.response) {
// API returned error response
throw new Error(`Failed to get user information: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
// Request was sent but no response received
throw new Error("Failed to get user information: timeout or network error");
} else {
// Other errors
throw new Error(`Failed to get user information: ${error.message}`);
throw new ApiError(401, `Failed to get user information: ${error.message}`);
}
}
};
/**
* group information middleware
*/
export const groupInfoMiddleware = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(authHeader);
const groupId = req.headers.groupid;
if (token && groupId) {
try {
const usersRes = await getGroupUsers(Number(groupId), token);
const users: UserInfo[] = usersRes.code === 200 ? usersRes.data : [];
req.groupInfo = {
groupId: Number(groupId),
users,
}
} catch (error) {
// If getting user information fails, do not block the request from continuing, but do not set userInfo
console.warn("Failed to get group user information:", error);
}
}
next();
} catch (error: any) {
res.status(400).json({
error: "Get Group Users failed",
message: error.message,
});
}
};

View File

@@ -0,0 +1,17 @@
import {Request, Response, NextFunction} from "express";
import {ApiError} from "../types/api";
export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
console.error(err);
if (err instanceof ApiError) {
return res.status(err.status).json({code: err.status, message: err.message, data: null});
}
// Validation errors generated by tsoa
if (err.status && err.status >= 400) {
return res.status(err.status).json({ message: err.message });
}
res.status(500).json({ message: 'Internal Server Error' });
}

View File

@@ -2,11 +2,18 @@ import swaggerUi from 'swagger-ui-express';
import path from 'path';
export function RegisterTsoaRoutes(app: any) {
// Register tsoa routes - 动态导入以避免编译时错误
// Register tsoa routes via dynamic import to avoid compile-time errors
const { RegisterRoutes } = require('../routes/routes');
RegisterRoutes(app);
// Serve swagger documentation - 动态导入swagger文档
// Serve swagger documentation (dynamic import)
const swaggerDocument = require(path.join(__dirname, '../swagger.json'));
app.use('/v3/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
const options = {
swaggerOptions: {
url: "/api/swagger/swagger.json",
},
}
app.get("/api/swagger/swagger.json", (req: any, res: any) => res.json(swaggerDocument));
// @ts-ignore
app.use('/api/swagger', swaggerUi.serveFiles(null, options), swaggerUi.setup(null, options));
}

View File

@@ -1,14 +1,26 @@
import { UserInfo } from '../middleware/auth';
/**
* Standard API response structure
*/
export interface ApiResponse<T = any> {
/**
* HTTP status code, e.g., 200 for success, 400 for client error, 500 for server error
*/
code: number;
/**
* Response message, e.g., "success" or error description
*/
message: string;
/**
* Response data; can be null if no data is returned
*/
data: T | null;
}
export interface UserGroupInfo {
user: UserInfo | null;
group: {
groupId: number;
users: UserInfo[];
} | null;
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
Object.setPrototypeOf(this, ApiError.prototype);
}
}

View File

@@ -0,0 +1,9 @@
import {UserInfo} from "./user";
declare global {
namespace Express {
interface Request {
user: UserInfo;
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* User information interface
*/
export interface UserInfo {
/**
* User ID
*/
userId: number;
/**
* User nickname
*/
nickname: string;
/**
* Avatar URL
* @example "https://example.com/avatar.jpg"
*/
avatarUrl: string;
gender: 'MALE' | 'FEMALE' | 'UNKNOWN';
/**
* NetEase Cloud Communication token
*/
nimToken: string;
/**
* NetEase Cloud Communication account ID
*/
nimAccountId: string;
/**
* Authentication token
*/
token: string;
/**
* Last update time yyyy-MM-dd HH:mm:ss
* @example "2023-10-05 14:48:00"
*/
updatedAt: string;
/**
* Account creation time yyyy-MM-dd HH:mm:ss
* @example "2023-10-05 14:48:00"
*/
createdAt: string;
}

View File

@@ -6,9 +6,6 @@
"module": "node16",
"moduleResolution": "node16",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"outDir": "./build",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,

View File

@@ -1,7 +1,7 @@
{
"entryFile": "src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["src/controllers/**/*Controller.ts"],
"controllerPathGlobs": ["src/controllers/**/*Controller.ts", "src/types/**/*.ts"],
"spec": {
"outputDirectory": "build",
"specVersion": 3,
@@ -13,6 +13,7 @@
"host": "localhost:3005"
},
"routes": {
"routesDir": "src/routes"
"routesDir": "src/routes",
"authenticationModule": "src/middleware/auth.ts"
}
}

714
yarn.lock

File diff suppressed because it is too large Load Diff