>}
8 | */
9 | async function getUsersByList(userIds) {
10 | const select = {
11 | id: true,
12 | username: true,
13 | display_name: true,
14 | status: true,
15 | regTime: true,
16 | motto: true,
17 | images: true,
18 | };
19 |
20 | // Get each user's info
21 | const users = await prisma.ow_users.findMany({
22 | where: {
23 | id: { in: userIds.map((id) => parseInt(id, 10)) },
24 | },
25 | select,
26 | });
27 |
28 | return users;
29 | }
30 |
31 | // 获取用户信息通过用户名
32 | export async function getUserByUsername(username) {
33 | try {
34 | const user = await prisma.ow_users.findFirst({
35 | where: { username },
36 | select: {
37 | id: true,
38 | username: true,
39 | display_name: true,
40 | status: true,
41 | regTime: true,
42 | motto: true,
43 | images: true,
44 | }
45 | });
46 | return user;
47 | } catch (err) {
48 | logger.error("Error fetching user by username:", err);
49 | throw err;
50 | }
51 | }
52 |
53 | export { getUsersByList };
54 |
55 |
--------------------------------------------------------------------------------
/data/README.md:
--------------------------------------------------------------------------------
1 | # MaxMind GeoIP 数据库
2 |
3 | ## 使用方法
4 |
5 | 1. 前往 [MaxMind官网](https://www.maxmind.com/) 注册账号,获取免费的 GeoLite2 许可证密钥和账户ID
6 | 2. 在数据库中配置以下参数:
7 | ```sql
8 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.license_key', '你的许可证密钥', 0);
9 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.account_id', '你的账户ID', 0);
10 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.enabled', 'true', 0);
11 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.update_interval', '30', 0); -- 可选,更新间隔天数
12 | ```
13 |
14 | ## 自动下载功能
15 |
16 | 系统具备以下自动功能:
17 |
18 | 1. **启动时自动检查**:应用启动时会自动检查数据库文件是否存在,如果不存在且功能已启用,则自动下载
19 | 2. **动态加载机制**:数据库下载完成后,系统会自动动态加载新数据库,无需重启应用
20 | 3. **热插拔支持**:系统可以在运行时动态更新GeoIP数据库,不会中断任何正在处理的请求
21 | 4. **自动加载配置**:从数据库自动加载配置,无需手动配置文件
22 | 5. **自动错误处理**:如果数据库文件不存在或下载失败,系统会自动回退到使用模拟数据
23 | 6. **进度显示**:下载和解压过程会在控制台显示进度,方便监控
24 |
25 | ## 数据库配置选项
26 |
27 | 系统从数据库的 `ow_config` 表中读取配置:
28 |
29 | | 键名 | 说明 | 是否必须 |
30 | |------|------|---------|
31 | | maxmind.enabled | 是否启用MaxMind功能 | 是 |
32 | | maxmind.license_key | MaxMind许可证密钥 | 是 |
33 | | maxmind.account_id | MaxMind账户ID | 是 |
34 | | maxmind.update_interval | 数据库更新间隔(天) | 否,默认30天 |
35 |
36 | ## 数据库文件
37 |
38 | 数据文件将固定保存在 `data/GeoLite2-City.mmdb` 位置。所有代码已经硬编码使用此位置。
39 |
40 | ## 数据库管理工具
41 |
42 | 系统提供了两个管理工具:
43 |
44 | ### 1. 手动下载数据库
45 |
46 | ```bash
47 | # 从数据库读取配置并下载最新数据库
48 | node tools/downloadMaxmindDb.js
49 | ```
50 |
51 | 该工具会:
52 | 1. 从数据库读取账户ID和许可证密钥
53 | 2. 使用官方API下载最新的数据库文件(显示下载进度)
54 | 3. 自动解压并安装到正确位置(显示解压进度)
55 |
56 | ### 2. 定时更新脚本
57 |
58 | ```bash
59 | # 检查数据库是否需要更新,如需要则自动下载
60 | node tools/updateGeoIPDatabase.js
61 |
62 | # 带自动重启参数
63 | node tools/updateGeoIPDatabase.js --restart
64 | ```
65 |
66 | 该脚本会:
67 | 1. 检查MaxMind功能是否启用
68 | 2. 检查数据库文件是否存在,或文件是否过期需要更新
69 | 3. 如果需要更新,则自动调用下载脚本并显示进度
70 | 4. 使用`--restart`参数时,下载完成后会自动重启应用
71 |
72 | ## 设置定时更新
73 |
74 | 虽然系统启动时会自动检查数据库,但推荐设置定时任务定期更新数据库:
75 |
76 | ### Linux/Unix (Cron)
77 |
78 | ```bash
79 | # 编辑crontab
80 | crontab -e
81 |
82 | # 添加以下内容,每周一凌晨3点更新,并自动动态加载新数据库
83 | 0 3 * * 1 cd /path/to/project && node tools/updateGeoIPDatabase.js --reload >> /path/to/logs/geoip-update.log 2>&1
84 | ```
85 |
86 | ### Windows (计划任务)
87 |
88 | 1. 创建批处理文件 `update-geoip.bat`:
89 | ```
90 | cd D:\path\to\project
91 | node tools/updateGeoIPDatabase.js --reload
92 | ```
93 | 2. 使用任务计划程序创建计划任务,指向该批处理文件
94 |
95 | ## 在代码中使用
96 |
97 | ```javascript
98 | import ipLocation from '../utils/ipLocation.js';
99 |
100 | // 更新配置 (将保存到数据库)
101 | await ipLocation.updateConfig({
102 | enabled: true,
103 | licenseKey: '你的许可证密钥',
104 | accountId: '你的账户ID'
105 | });
106 |
107 | // 使用IP定位
108 | const location = await ipLocation.getIPLocation('8.8.8.8');
109 | console.log(location);
110 | ```
111 |
112 | ## 注意事项
113 |
114 | 1. 数据库配置仅保存在数据库中,不使用任何本地配置文件或环境变量
115 | 2. 应用启动时会自动检查数据库文件,如果未找到且功能已启用则自动下载
116 | 3. 下载完成后系统会自动动态加载新数据库,无需重启应用
117 | 4. 系统支持运行时热更新,可以在不中断服务的情况下更新GeoIP数据
118 | 5. 数据库路径固定为 `data/GeoLite2-City.mmdb`,不可更改
119 | 6. 账户ID和许可证密钥必须正确配置,否则无法下载数据库
120 | 7. 如果启用了MaxMind但数据库文件不存在,系统会回退到使用模拟数据
121 | 8. GeoLite2数据库每周更新一次,建议设置定时任务定期更新
122 | 9. 下载和解压过程在控制台显示实时进度
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9' # 明确指定 Compose 文件版本
2 |
3 | services:
4 | zerocat:
5 | container_name: zerocat
6 | image: zerocat:1.0.0
7 | build:
8 | context: .
9 | dockerfile: Dockerfile
10 | ports:
11 | - "3000:3000"
12 | restart: unless-stopped # 推荐值,更安全
13 | environment:
14 | - NODE_ENV=production
15 | - DATABASE_URL=mysql://root:123456@127.0.0.1:3557/zerocat_develop
16 |
--------------------------------------------------------------------------------
/docs/EVENT_SYSTEM.md:
--------------------------------------------------------------------------------
1 | # Event System Documentation
2 |
3 | ## Overview
4 |
5 | The ZeroCat event system has been refactored to use a centralized JSON configuration for all event-related data. This makes the code more maintainable and easier to understand.
6 |
7 | ## Key Components
8 |
9 | ### 1. Configuration (`config/eventConfig.json`)
10 |
11 | This JSON file contains all event-related configuration in one place:
12 |
13 | - `targetTypes`: Defines the types of entities that can be targets of events (PROJECT, USER, COMMENT)
14 | - `eventTypes`: Constants for all supported event types
15 | - `eventConfig`: Configuration for each event type, including:
16 | - `public`: Whether the event is visible to all users
17 | - `notifyTargets`: Which users should be notified when this event occurs
18 |
19 | ### 2. Controller (`controllers/events.js`)
20 |
21 | The event controller imports the configuration and provides:
22 |
23 | - Event creation functionality
24 | - Event retrieval
25 | - Notification processing
26 | - Helper functions
27 |
28 | ### 3. Routes (`src/routes/event.routes.js`)
29 |
30 | The routes file defines the API endpoints for:
31 |
32 | - Getting events for a specific target
33 | - Getting events for a specific actor
34 | - Creating new events
35 | - Retrieving follower information
36 |
37 | ## How to Add a New Event Type
38 |
39 | 1. Add the new event type to `eventTypes` in `config/eventConfig.json`
40 | 2. Add configuration for the event in the `eventConfig` section, specifying:
41 | - `public`: Boolean indicating visibility
42 | - `notifyTargets`: Array of user roles to notify
43 |
44 | ## Notification Targets
45 |
46 | The following notification targets are supported:
47 |
48 | - `project_owner`: The owner of the project
49 | - `project_followers`: Users following the project
50 | - `user_followers`: Users following the actor
51 | - `page_owner`: The owner of a page where an action occurred
52 | - `thread_participants`: Users who have commented in a thread
--------------------------------------------------------------------------------
/docs/event-formats.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseEventFields": {
3 | "event_type": "字符串,事件类型,如 project_create, user_login 等",
4 | "actor_id": "数字,执行动作的用户ID",
5 | "target_type": "字符串,目标类型,如 project, user, comment 等",
6 | "target_id": "数字,目标对象的ID",
7 | "metadata": "可选,额外信息,键值对对象"
8 | },
9 | "eventTypes": {
10 | "project_commit": {
11 | "description": "项目提交代码事件",
12 | "fields": {
13 | "commit_id": "字符串,提交的唯一标识符",
14 | "commit_message": "字符串,提交信息",
15 | "branch": "字符串,分支名称",
16 | "commit_file": "字符串,提交的文件路径",
17 | "project_name": "字符串,项目名称",
18 | "project_title": "字符串,项目标题",
19 | "project_type": "字符串,项目类型",
20 | "project_description": "字符串,可选,项目描述",
21 | "project_state": "字符串,项目状态"
22 | },
23 | "notification": ["项目所有者", "项目关注者"]
24 | },
25 | "project_update": {
26 | "description": "项目更新事件",
27 | "fields": {
28 | "update_type": "字符串,更新类型",
29 | "old_value": "字符串,可选,旧值",
30 | "new_value": "字符串,可选,新值"
31 | },
32 | "notification": ["项目所有者"]
33 | },
34 | "project_fork": {
35 | "description": "项目分支(复刻)事件",
36 | "fields": {
37 | "fork_id": "数字,分支项目ID",
38 | "project_name": "字符串,项目名称",
39 | "project_title": "字符串,项目标题"
40 | },
41 | "notification": ["项目所有者"]
42 | },
43 | "project_create": {
44 | "description": "创建新项目事件",
45 | "fields": {
46 | "project_type": "字符串,项目类型",
47 | "project_name": "字符串,项目名称",
48 | "project_title": "字符串,项目标题",
49 | "project_description": "字符串,可选,项目描述",
50 | "project_state": "字符串,项目状态"
51 | },
52 | "notification": ["用户关注者"]
53 | },
54 | "project_publish": {
55 | "description": "发布项目事件",
56 | "fields": {
57 | "old_state": "字符串,旧状态",
58 | "new_state": "字符串,新状态",
59 | "project_title": "字符串,项目标题"
60 | },
61 | "notification": ["用户关注者"]
62 | },
63 | "comment_create": {
64 | "description": "创建评论事件",
65 | "fields": {
66 | "page_type": "字符串,页面类型",
67 | "page_id": "数字,页面ID",
68 | "pid": "数字,可选,父评论ID",
69 | "rid": "数字,可选,回复ID",
70 | "text": "字符串,评论内容,限制为100个字符"
71 | },
72 | "notification": ["页面所有者", "对话参与者"]
73 | },
74 | "user_profile_update": {
75 | "description": "用户资料更新事件",
76 | "fields": {
77 | "update_type": "字符串,更新类型",
78 | "old_value": "字符串,可选,旧值",
79 | "new_value": "字符串,可选,新值"
80 | },
81 | "notification": ["用户关注者"]
82 | },
83 | "user_login": {
84 | "description": "用户登录事件",
85 | "fields": {},
86 | "notification": []
87 | },
88 | "user_register": {
89 | "description": "用户注册事件",
90 | "fields": {
91 | "username": "字符串,用户名"
92 | },
93 | "notification": []
94 | },
95 | "project_rename": {
96 | "description": "项目重命名事件",
97 | "fields": {
98 | "old_name": "字符串,旧项目名",
99 | "new_name": "字符串,新项目名",
100 | "project_title": "字符串,项目标题",
101 | "project_type": "字符串,项目类型",
102 | "project_state": "字符串,项目状态"
103 | },
104 | "notification": ["项目关注者"]
105 | },
106 | "project_info_update": {
107 | "description": "项目信息更新事件",
108 | "fields": {
109 | "updated_fields": "字符串数组,更新的字段名",
110 | "old_values": "对象,包含更新字段的旧值",
111 | "new_values": "对象,包含更新字段的新值",
112 | "project_name": "字符串,项目名称",
113 | "project_title": "字符串,项目标题",
114 | "project_type": "字符串,项目类型",
115 | "project_description": "字符串,可选,项目描述",
116 | "project_state": "字符串,项目状态"
117 | },
118 | "notification": ["项目关注者"]
119 | }
120 | },
121 | "targetTypes": {
122 | "project": "项目",
123 | "user": "用户",
124 | "comment": "评论"
125 | },
126 | "usage": {
127 | "description": "此文档仅供人类阅读,用于了解事件系统中的各种事件格式。在代码中请使用实际的事件模型和验证器。",
128 | "example": {
129 | "创建项目事件": {
130 | "event_type": "project_create",
131 | "actor_id": 123,
132 | "target_type": "project",
133 | "target_id": 456,
134 | "project_type": "scratch",
135 | "project_name": "my_project",
136 | "project_title": "My Awesome Project",
137 | "project_description": "This is a cool project",
138 | "project_state": "draft"
139 | },
140 | "用户登录事件": {
141 | "event_type": "user_login",
142 | "actor_id": 123,
143 | "target_type": "user",
144 | "target_id": 123
145 | }
146 | }
147 | }
148 | }
--------------------------------------------------------------------------------
/docs/event-system-migration-guide.md:
--------------------------------------------------------------------------------
1 | # Event System Migration Guide
2 |
3 | ## Overview
4 |
5 | This document provides guidance for migrating from the old event system to the new schema-based event system. The new system uses Zod for schema validation and provides a more structured approach to event handling.
6 |
7 | ## Migration Steps
8 |
9 | ### 1. Install Dependencies
10 |
11 | The new event system requires the Zod library. Make sure it's installed:
12 |
13 | ```bash
14 | npm install zod
15 | # or
16 | pnpm add zod
17 | ```
18 |
19 | ### 2. Run Database Migrations
20 |
21 | Apply the database migration to add necessary indices:
22 |
23 | ```bash
24 | npx prisma migrate dev --name enhance_event_model
25 | ```
26 |
27 | This will create the following indices:
28 | - `idx_events_actor_id`: For faster queries by actor
29 | - `idx_events_type_target`: For faster queries by event type and target
30 | - `idx_events_public`: For faster filtering of public/private events
31 |
32 | ### 3. Update Event Creation Code
33 |
34 | If you're currently using the old event API:
35 |
36 | ```javascript
37 | // Old approach
38 | import { createEvent } from '../controllers/events.js';
39 |
40 | await createEvent(
41 | 'project_create',
42 | userId,
43 | 'project',
44 | projectId,
45 | {
46 | project_type: 'scratch',
47 | // other fields...
48 | }
49 | );
50 | ```
51 |
52 | You can continue using this API as it's backwards compatible. The controller will transform your event data internally to match the new schema requirements.
53 |
54 | ### 4. Update Event Retrieval Code
55 |
56 | For retrieving events, use the new methods:
57 |
58 | ```javascript
59 | // Get events for a target
60 | import { getTargetEvents, TargetTypes } from '../controllers/events.js';
61 |
62 | const events = await getTargetEvents(
63 | "project", // target type
64 | projectId, // target ID
65 | 10, // limit
66 | 0, // offset
67 | false // include private events?
68 | );
69 |
70 | // Get events for an actor
71 | import { getActorEvents } from '../controllers/events.js';
72 |
73 | const events = await getActorEvents(
74 | userId, // actor ID
75 | 10, // limit
76 | 0, // offset
77 | false // include private events?
78 | );
79 | ```
80 |
81 | ### 5. Add New Event Types
82 |
83 | If you need to add a new event type:
84 |
85 | 1. Define the schema in `models/events.js`:
86 |
87 | ```javascript
88 | export const MyNewEventSchema = BaseEventSchema.extend({
89 | // Add your event-specific fields here
90 | field1: z.string(),
91 | field2: z.number(),
92 | // etc.
93 | });
94 | ```
95 |
96 | 2. Add the event configuration to `EventConfig` in the same file:
97 |
98 | ```javascript
99 | export const EventConfig = {
100 | // ... existing event configs ...
101 |
102 | 'my_new_event': {
103 | schema: MyNewEventSchema,
104 | logToDatabase: true,
105 | public: true,
106 | notifyTargets: ['appropriate_targets'],
107 | },
108 | };
109 | ```
110 |
111 | ## Troubleshooting
112 |
113 | ### Schema Validation Errors
114 |
115 | If you get validation errors, check the event data against the schema defined in `models/events.js`. The logs will contain detailed error information.
116 |
117 | ### Migration Issues
118 |
119 | If you encounter issues during migration, try:
120 |
121 | 1. Make sure there are no conflicting migration files
122 | 2. Check that the database user has sufficient privileges to create indices
123 | 3. Verify the database connection configuration
124 |
125 | ## Additional Resources
126 |
127 | For more detailed information, refer to:
128 | - [Event System Documentation](./events-system.md)
129 | - [Zod Documentation](https://github.com/colinhacks/zod)
--------------------------------------------------------------------------------
/docs/event-system-updates.md:
--------------------------------------------------------------------------------
1 | # 事件系统更新文档
2 |
3 | ## 事件创建格式变更
4 |
5 | 事件系统已更新,现在所有事件创建时需要在事件数据中包含基本的事件元数据字段。这确保了事件数据的一致性并满足了 schema 验证的要求。
6 |
7 | ### 旧格式示例
8 |
9 | ```javascript
10 | await createEvent("project_create", userId, "project", projectId, {
11 | project_name: "my-project",
12 | project_title: "My Project",
13 | // 其他事件特定字段...
14 | });
15 | ```
16 |
17 | ### 新格式示例
18 |
19 | ```javascript
20 | await createEvent("project_create", userId, "project", projectId, {
21 | // 必须包含这些基本字段
22 | event_type: "project_create",
23 | actor_id: userId,
24 | target_type: "project",
25 | target_id: projectId,
26 |
27 | // 事件特定字段
28 | project_name: "my-project",
29 | project_title: "My Project",
30 | // 其他事件特定字段...
31 | });
32 | ```
33 |
34 | ## 更新说明
35 |
36 | 1. 每个事件数据对象现在必须包含以下基本字段:
37 | - `event_type`: 事件类型,与第一个参数保持一致
38 | - `actor_id`: 执行操作的用户ID,与第二个参数保持一致
39 | - `target_type`: 目标类型,与第三个参数保持一致
40 | - `target_id`: 目标ID,与第四个参数保持一致
41 |
42 | 2. 所有事件类型字符串必须使用小写格式(如 `user_login` 而非 `USER_LOGIN`)
43 |
44 | 3. 请参考 `docs/event-formats.json` 文件了解每种事件类型所需的特定字段
45 |
46 | ## 迁移指南
47 |
48 | 当更新现有代码时,请确保:
49 |
50 | 1. 将所有 `EventTypes.常量` 形式替换为对应的小写字符串格式
51 | - 例如:`EventTypes.USER_LOGIN` 更新为 `"user_login"`
52 |
53 | 2. 在事件数据对象中添加基本元数据字段:
54 | ```javascript
55 | {
56 | event_type: eventType,
57 | actor_id: actorId,
58 | target_type: targetType,
59 | target_id: targetId,
60 | // 事件特定字段...
61 | }
62 | ```
63 |
64 | 3. 继续使用第五个参数 `forcePrivate` 来控制事件的可见性(如果需要)
65 |
66 | ## 注意事项
67 |
68 | - 事件数据在保存前会使用 `models/events.js` 中定义的 schema 进行验证
69 | - 不符合对应事件类型 schema 的数据将导致事件创建失败
70 | - 请查看 `services/eventService.js` 中的 `createEvent` 函数了解完整的事件创建流程
--------------------------------------------------------------------------------
/docs/event-types.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_commit": {
3 | "event_type": "project_commit",
4 | "actor_id": 123,
5 | "target_type": "project",
6 | "target_id": 456,
7 | "public": 1,
8 | "event_data": {
9 | "commit_id": "a1b2c3d4",
10 | "commit_message": "更新了项目代码",
11 | "branch": "main"
12 | }
13 | },
14 | "project_update": {
15 | "event_type": "project_update",
16 | "actor_id": 123,
17 | "target_type": "project",
18 | "target_id": 456,
19 | "public": 1,
20 | "event_data": {
21 | "update_type": "code",
22 | "old_value": "",
23 | "new_value": ""
24 | }
25 | },
26 | "project_rename": {
27 | "event_type": "project_rename",
28 | "actor_id": 123,
29 | "target_type": "project",
30 | "target_id": 456,
31 | "public": 1,
32 | "event_data": {
33 | "old_name": "old-project-name",
34 | "new_name": "new-project-name"
35 | }
36 | },
37 | "project_info_update": {
38 | "event_type": "project_info_update",
39 | "actor_id": 123,
40 | "target_type": "project",
41 | "target_id": 456,
42 | "public": 1,
43 | "event_data": {
44 | "updated_fields": ["title", "description"],
45 | "old_values": {
46 | "title": "旧标题",
47 | "description": "旧描述"
48 | },
49 | "new_values": {
50 | "title": "新标题",
51 | "description": "新描述"
52 | }
53 | }
54 | },
55 | "user_profile_update": {
56 | "event_type": "user_profile_update",
57 | "actor_id": 123,
58 | "target_type": "user",
59 | "target_id": 123,
60 | "public": 1,
61 | "event_data": {
62 | "update_type": "display_name",
63 | "old_value": "旧用户名",
64 | "new_value": "新用户名"
65 | },
66 |
67 | "project_create": {
68 | "event_type": "project_create",
69 | "actor_id": 123,
70 | "target_type": "project",
71 | "target_id": 456,
72 | "public": 1,
73 | "event_data": {}
74 | },
75 | "project_fork": {
76 | "event_type": "project_fork",
77 | "actor_id": 123,
78 | "target_type": "project",
79 | "target_id": 456,
80 | "public": 1,
81 | "event_data": {
82 | "fork_id": 789
83 | }
84 | },
85 | "user_register": {
86 | "event_type": "user_register",
87 | "actor_id": 123,
88 | "target_type": "user",
89 | "target_id": 123,
90 | "public": 1,
91 | "event_data": {}
92 | }
93 | ,
94 | "comment_create": {
95 | "event_type": "comment_create",
96 | "actor_id": 123,
97 | "target_type": "comment",
98 | "target_id": 789,
99 | "public": 0,
100 | "event_data": {
101 | "page_type": "project",
102 | "page_id": 456
103 | }
104 | },
105 | "project_star": {
106 | "event_type": "project_star",
107 | "actor_id": 123,
108 | "target_type": "project",
109 | "target_id": 456,
110 | "public": 1,
111 | "event_data": {}
112 | },
113 | "project_publish": {
114 | "event_type": "project_publish",
115 | "actor_id": 123,
116 | "target_type": "project",
117 | "target_id": 456,
118 | "public": 1,
119 | "event_data": {}
120 | },
121 | "user_login": {
122 | "event_type": "user_login",
123 | "actor_id": 123,
124 | "target_type": "user",
125 | "target_id": 123,
126 | "public": 0,
127 | "event_data": {}
128 | }
129 | }
130 | }
--------------------------------------------------------------------------------
/docs/events-direct-schema.md:
--------------------------------------------------------------------------------
1 | # 直接存储事件数据的新架构
2 |
3 | ## 概述
4 |
5 | 我们对事件系统进行了重构,移除了旧的 `dbFields` 字段过滤方法,改为使用 Zod 验证模式和直接存储整个事件数据结构的方法。这种新方法简化了代码,提高了可维护性,并且增强了类型安全性。
6 |
7 | ## 重大变更
8 |
9 | 1. 移除了 `EventTypes` 中的 `dbFields` 数组
10 | 2. 使用 Zod 验证模式直接验证完整事件数据
11 | 3. 将整个验证通过的事件数据存储在数据库中
12 | 4. 为支持旧版代码提供了向后兼容层
13 |
14 | ## 新事件系统的工作方式
15 |
16 | ### 1. 定义事件模式
17 |
18 | 每个事件类型都使用 Zod 模式定义:
19 |
20 | ```javascript
21 | // models/events.js
22 | export const ProjectCommitEventSchema = BaseEventSchema.extend({
23 | commit_id: z.string(),
24 | commit_message: z.string(),
25 | branch: z.string(),
26 | // 其他字段...
27 | });
28 | ```
29 |
30 | ### 2. 事件配置
31 |
32 | 每个事件类型的配置包含验证模式和其他元数据:
33 |
34 | ```javascript
35 | export const EventConfig = {
36 | 'project_commit': {
37 | schema: ProjectCommitEventSchema,
38 | logToDatabase: true,
39 | public: true,
40 | notifyTargets: ['project_owner', 'project_followers'],
41 | },
42 | // 其他事件类型...
43 | };
44 | ```
45 |
46 | ### 3. 创建事件
47 |
48 | 创建事件时,所有数据都会通过验证模式进行验证:
49 |
50 | ```javascript
51 | // 创建事件
52 | await createEvent(
53 | 'project_create', // 事件类型
54 | userId, // 操作者ID
55 | 'project', // 目标类型
56 | projectId, // 目标ID
57 | {
58 | project_type: 'scratch',
59 | project_name: 'my-project',
60 | project_title: '我的项目',
61 | project_description: '项目描述',
62 | project_state: 'private'
63 | }
64 | );
65 | ```
66 |
67 | ### 4. 数据验证和存储
68 |
69 | 1. 事件数据通过 Zod 模式验证
70 | 2. 验证通过的完整数据直接存储到数据库
71 | 3. 不再需要提取特定字段
72 |
73 | ```javascript
74 | // 验证数据
75 | const validationResult = eventConfig.schema.safeParse(eventData);
76 |
77 | // 如果验证通过,存储完整数据
78 | const event = await prisma.ow_events.create({
79 | data: {
80 | event_type: normalizedEventType,
81 | actor_id: BigInt(validatedData.actor_id),
82 | target_type: validatedData.target_type,
83 | target_id: BigInt(validatedData.target_id),
84 | event_data: validatedData, // 存储完整的验证后数据
85 | public: isPublic ? 1 : 0
86 | },
87 | });
88 | ```
89 |
90 | ## 兼容旧版代码
91 |
92 | 为确保与使用旧版 `EventTypes` 对象的代码兼容,我们提供了向后兼容层:
93 |
94 | ```javascript
95 | // 旧版 EventTypes 常量兼容层
96 | export const EventTypes = {
97 | // 映射旧版结构到新版
98 | 'project_commit': 'project_commit',
99 | 'project_update': 'project_update',
100 | // ...其他映射
101 |
102 | // 常用的事件类型常量(大写格式)
103 | PROJECT_CREATE: 'project_create',
104 | PROJECT_DELETE: 'project_delete',
105 | // ...其他常量
106 |
107 | // 获取事件配置的辅助方法
108 | get(eventType) {
109 | const type = typeof eventType === 'string' ? eventType : String(eventType);
110 | return EventConfig[type.toLowerCase()];
111 | }
112 | };
113 | ```
114 |
115 | ## 升级指南
116 |
117 | ### 1. 直接使用事件类型字符串
118 |
119 | ```javascript
120 | // 旧代码
121 | await createEvent(
122 | EventTypes.PROJECT_CREATE,
123 | userId,
124 | "project",
125 | projectId,
126 | // ...
127 | );
128 |
129 | // 新代码 - 使用字符串
130 | await createEvent(
131 | 'project_create',
132 | userId,
133 | "project",
134 | projectId,
135 | // ...
136 | );
137 | ```
138 |
139 | ### 2. 不再需要考虑 dbFields
140 |
141 | 旧代码:
142 | ```javascript
143 | // 旧系统 - 需要提供 dbFields 中定义的所有字段
144 | const eventData = {
145 | project_type: project.type, // 在 dbFields 中
146 | project_name: project.name, // 在 dbFields 中
147 | // ...其他必需字段
148 | };
149 | ```
150 |
151 | 新代码:
152 | ```javascript
153 | // 新系统 - 提供所有相关数据,由 Zod 验证确保正确性
154 | const eventData = {
155 | // 根据事件类型提供所有相关数据
156 | project_type: project.type,
157 | project_name: project.name,
158 | project_title: project.title,
159 | // ...其他数据
160 | };
161 | ```
162 |
163 | ### 3. 验证错误处理
164 |
165 | 如果事件数据不符合定义的模式,系统会拒绝创建事件并记录错误:
166 |
167 | ```javascript
168 | // 错误数据将被验证拒绝
169 | const invalidData = { /* 缺少必需字段 */ };
170 | const result = await createEvent('project_create', userId, 'project', projectId, invalidData);
171 | // result 将为 null,错误会被记录
172 | ```
173 |
174 | ## 迁移注意事项
175 |
176 | 1. 检查所有使用 `EventTypes.XXX` 形式常量的代码
177 | 2. 考虑直接使用字符串形式的事件类型
178 | 3. 确保提供事件所需的所有数据字段
179 | 4. 使用 Zod 验证模式中定义的类型作为指导
180 |
181 | ## 总结
182 |
183 | 新的事件系统移除了不必要的 `dbFields` 过滤步骤,使用 Zod 验证模式直接验证和存储完整事件数据。这种方式更加简洁、类型安全,并且保持了向后兼容性。
--------------------------------------------------------------------------------
/docs/events-system.md:
--------------------------------------------------------------------------------
1 | # Event System Documentation
2 |
3 | ## Overview
4 |
5 | The event system allows the application to track and respond to various activities within the platform. It was restructured to use schema validation and standardize event data formats.
6 |
7 | ## Key Features
8 |
9 | - **Schema Validation**: All events are validated against predefined schemas
10 | - **Standardized Event Format**: Consistent event data structure across the application
11 | - **Notification Support**: Automatic notification of relevant users based on event type
12 | - **Privacy Controls**: Events can be marked as public or private
13 |
14 | ## Using the Event System
15 |
16 | ### Event Types
17 |
18 | Each event type is defined in `models/events.js` with its own schema. The available event types are:
19 |
20 | - `project_commit`: When a user commits changes to a project
21 | - `project_update`: When a project is updated
22 | - `project_fork`: When a project is forked
23 | - `project_create`: When a new project is created
24 | - `project_publish`: When a project is published
25 | - `comment_create`: When a user creates a comment
26 | - `user_profile_update`: When a user updates their profile
27 | - `user_login`: When a user logs in
28 | - `user_register`: When a user registers
29 | - `project_rename`: When a project is renamed
30 | - `project_info_update`: When project information is updated
31 |
32 | ### Creating Events
33 |
34 | To create an event, use the `createEvent` function from the events controller:
35 |
36 | ```javascript
37 | import { createEvent, TargetTypes } from '../controllers/events.js';
38 |
39 | // Example: Create a project_create event
40 | await createEvent(
41 | 'project_create', // event type
42 | userId, // actor ID
43 | "project", // target type
44 | projectId, // target ID
45 | {
46 | project_type: 'scratch',
47 | project_name: 'my-project',
48 | project_title: 'My Project',
49 | project_description: 'Description of my project',
50 | project_state: 'private'
51 | }
52 | );
53 | ```
54 |
55 | The event data will be validated against the schema defined for the event type.
56 |
57 | ### Retrieving Events
58 |
59 | To get events for a specific target:
60 |
61 | ```javascript
62 | import { getTargetEvents, TargetTypes } from '../controllers/events.js';
63 |
64 | // Get events for a project
65 | const events = await getTargetEvents(
66 | "project", // target type
67 | projectId, // target ID
68 | 10, // limit
69 | 0, // offset
70 | false // include private events?
71 | );
72 | ```
73 |
74 | To get events for a specific actor:
75 |
76 | ```javascript
77 | import { getActorEvents } from '../controllers/events.js';
78 |
79 | // Get events for a user
80 | const events = await getActorEvents(
81 | userId, // actor ID
82 | 10, // limit
83 | 0, // offset
84 | false // include private events?
85 | );
86 | ```
87 |
88 | ## Internal Architecture
89 |
90 | ### Schema-Based Validation
91 |
92 | All event data is validated using Zod schemas defined in `models/events.js`. This ensures that event data is consistent and contains all required fields.
93 |
94 | ### Event Processing Flow
95 |
96 | 1. Controller receives event creation request
97 | 2. Data is validated against the schema
98 | 3. Event is stored in the database
99 | 4. Notifications are sent to relevant users
100 |
101 | ### Database Structure
102 |
103 | Events are stored in the `events` table with the following structure:
104 |
105 | - `id`: Unique identifier for the event
106 | - `event_type`: Type of event
107 | - `actor_id`: ID of the user who performed the action
108 | - `target_type`: Type of the target object (project, user, etc.)
109 | - `target_id`: ID of the target object
110 | - `event_data`: JSON data specific to the event type
111 | - `created_at`: Timestamp when the event was created
112 | - `public`: Whether the event is publicly visible
113 |
114 | ## Migration Notes
115 |
116 | The migration script `20240705000000_enhance_event_model` adds the following indices to improve query performance:
117 |
118 | - `idx_events_actor_id`: Index on actor_id
119 | - `idx_events_type_target`: Combined index on event_type and target_id
120 | - `idx_events_public`: Index on public field
--------------------------------------------------------------------------------
/docs/notificationTypes.json:
--------------------------------------------------------------------------------
1 | {
2 | "notifications": {
3 | "project_related": [
4 | {
5 | "id": 1,
6 | "name": "PROJECT_COMMENT",
7 | "description": "When someone comments on a project",
8 | "data_structure": {
9 | "project_id": "Project ID",
10 | "project_title": "Project title",
11 | "comment_text": "Comment text snippet",
12 | "comment_id": "Comment ID"
13 | }
14 | },
15 | {
16 | "id": 2,
17 | "name": "PROJECT_STAR",
18 | "description": "When someone stars a project",
19 | "data_structure": {
20 | "project_id": "Project ID",
21 | "project_title": "Project title",
22 | "star_count": "Total star count after action"
23 | }
24 | },
25 | {
26 | "id": 3,
27 | "name": "PROJECT_FORK",
28 | "description": "When someone forks a project",
29 | "data_structure": {
30 | "project_id": "Original project ID",
31 | "project_title": "Original project title",
32 | "fork_id": "New forked project ID",
33 | "fork_title": "New forked project title",
34 | "fork_count": "Total fork count after action"
35 | }
36 | },
37 | {
38 | "id": 4,
39 | "name": "PROJECT_MENTION",
40 | "description": "When someone mentions a user in a project",
41 | "data_structure": {
42 | "project_id": "Project ID",
43 | "project_title": "Project title",
44 | "mention_text": "Text containing the mention"
45 | }
46 | },
47 | {
48 | "id": 5,
49 | "name": "PROJECT_UPDATE",
50 | "description": "When a project is updated",
51 | "data_structure": {
52 | "project_id": "Project ID",
53 | "project_title": "Project title",
54 | "update_details": "Details of what was updated"
55 | }
56 | },
57 | {
58 | "id": 6,
59 | "name": "PROJECT_COLLABORATION_INVITE",
60 | "description": "When a user is invited to collaborate on a project",
61 | "data_structure": {
62 | "project_id": "Project ID",
63 | "project_title": "Project title",
64 | "invite_id": "Invitation ID"
65 | }
66 | },
67 | {
68 | "id": 7,
69 | "name": "PROJECT_COLLABORATION_ACCEPT",
70 | "description": "When a user accepts a collaboration invite",
71 | "data_structure": {
72 | "project_id": "Project ID",
73 | "project_title": "Project title"
74 | }
75 | }
76 | ],
77 | "user_related": [
78 | {
79 | "id": 20,
80 | "name": "USER_FOLLOW",
81 | "description": "When someone follows a user",
82 | "data_structure": {
83 | "follower_count": "Total follower count after action"
84 | }
85 | },
86 | {
87 | "id": 21,
88 | "name": "USER_MENTION",
89 | "description": "When someone mentions a user",
90 | "data_structure": {
91 | "mention_text": "Text containing the mention",
92 | "context_type": "Context type (comment, project, etc.)",
93 | "context_id": "ID of the context"
94 | }
95 | },
96 | {
97 | "id": 25,
98 | "name": "USER_LIKE",
99 | "description": "When someone likes a user's content",
100 | "data_structure": {
101 | "content_type": "Type of content that was liked",
102 | "content_id": "ID of the content",
103 | "content_excerpt": "Excerpt of the content"
104 | }
105 | }
106 | ],
107 | "system_related": [
108 | {
109 | "id": 50,
110 | "name": "SYSTEM_ANNOUNCEMENT",
111 | "description": "System-wide announcements",
112 | "data_structure": {
113 | "announcement_text": "Text of the announcement",
114 | "announcement_id": "ID of the announcement",
115 | "level": "Announcement importance level"
116 | }
117 | },
118 | {
119 | "id": 51,
120 | "name": "SYSTEM_MAINTENANCE",
121 | "description": "System maintenance notifications",
122 | "data_structure": {
123 | "maintenance_text": "Text about the maintenance",
124 | "start_time": "Start time of maintenance",
125 | "end_time": "Expected end time of maintenance",
126 | "services_affected": "Services affected by the maintenance"
127 | }
128 | }
129 | ],
130 | "comment_related": [
131 | {
132 | "id": 100,
133 | "name": "COMMENT_REPLY",
134 | "description": "When someone replies to a comment",
135 | "data_structure": {
136 | "reply_text": "Text of the reply",
137 | "reply_id": "ID of the reply",
138 | "original_comment_id": "ID of the original comment",
139 | "context_type": "Context where the comment was made",
140 | "context_id": "ID of the context"
141 | }
142 | },
143 | {
144 | "id": 101,
145 | "name": "COMMENT_LIKE",
146 | "description": "When someone likes a comment",
147 | "data_structure": {
148 | "comment_id": "ID of the comment",
149 | "comment_excerpt": "Excerpt of the comment",
150 | "context_type": "Context where the comment was made",
151 | "context_id": "ID of the context"
152 | }
153 | },
154 | {
155 | "id": 102,
156 | "name": "COMMENT_MENTION",
157 | "description": "When someone mentions a user in a comment",
158 | "data_structure": {
159 | "mention_text": "Text containing the mention",
160 | "comment_id": "ID of the comment",
161 | "context_type": "Context where the comment was made",
162 | "context_id": "ID of the context"
163 | }
164 | }
165 | ],
166 | "custom_related": [
167 | {
168 | "id": 800,
169 | "name": "CUSTOM_NOTIFICATION",
170 | "description": "Generic custom notification",
171 | "data_structure": {
172 | "title": "Notification title",
173 | "body": "Notification content"
174 | }
175 | },
176 | {
177 | "id": 801,
178 | "name": "CUSTOM_TOPIC_REPLY",
179 | "description": "Custom topic reply notification",
180 | "data_structure": {
181 | "topic_title": "Title of the topic",
182 | "topic_id": "ID of the topic",
183 | "reply_preview": "Preview of the reply",
184 | "post_number": "Post number in the topic"
185 | }
186 | },
187 | {
188 | "id": 802,
189 | "name": "CUSTOM_TOPIC_MENTION",
190 | "description": "Custom topic mention notification",
191 | "data_structure": {
192 | "topic_title": "Title of the topic",
193 | "topic_id": "ID of the topic",
194 | "mention_text": "Text containing the mention",
195 | "post_number": "Post number in the topic"
196 | }
197 | }
198 | ]
199 | }
200 | }
--------------------------------------------------------------------------------
/docs/notifications.md:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview 事件模型定义和相关常量
3 | */
4 |
5 | /**
6 | * 事件类型定义
7 | * @enum {number}
8 | */
9 | export const EventTypes = {
10 | // 用户事件 (1-99)
11 | USER_REGISTER: 1,
12 | USER_LOGIN: 2,
13 | USER_LOGOUT: 3,
14 | USER_PROFILE_UPDATE: 4,
15 | USER_PASSWORD_CHANGE: 5,
16 | USER_EMAIL_CHANGE: 6,
17 | USER_AVATAR_CHANGE: 7,
18 | USER_ACCOUNT_DELETE: 8,
19 | USER_ACCOUNT_SUSPEND: 9,
20 | USER_ACCOUNT_RESTORE: 10,
21 |
22 | USER_SETTINGS_CHANGE: 20,
23 | USER_NOTIFICATION_SETTINGS: 21,
24 | USER_PRIVACY_SETTINGS: 22,
25 |
26 | // 项目事件 (100-199)
27 | PROJECT_CREATE: 100,
28 | PROJECT_UPDATE: 101,
29 | PROJECT_DELETE: 102,
30 | PROJECT_VISIBILITY_CHANGE: 103,
31 | PROJECT_OWNERSHIP_TRANSFER: 104,
32 |
33 | PROJECT_VIEW: 110,
34 | PROJECT_STAR: 111,
35 | PROJECT_UNSTAR: 112,
36 | PROJECT_LIKE: 113,
37 | PROJECT_UNLIKE: 114,
38 | PROJECT_FORK: 115,
39 | PROJECT_DOWNLOAD: 116,
40 | PROJECT_SHARE: 117,
41 |
42 | PROJECT_COLLABORATOR_INVITE: 120,
43 | PROJECT_COLLABORATOR_JOIN: 121,
44 | PROJECT_COLLABORATOR_LEAVE: 122,
45 | PROJECT_COLLABORATOR_REMOVE: 123,
46 | PROJECT_PERMISSION_CHANGE: 124,
47 |
48 | PROJECT_COMMIT: 130,
49 | PROJECT_BRANCH_CREATE: 131,
50 | PROJECT_BRANCH_DELETE: 132,
51 | PROJECT_MERGE: 133,
52 | PROJECT_RELEASE: 134,
53 |
54 | // 社交事件 (200-299)
55 | USER_FOLLOW: 200,
56 | USER_UNFOLLOW: 201,
57 | USER_BLOCK: 202,
58 | USER_UNBLOCK: 203,
59 |
60 | USER_MENTION: 210,
61 | USER_DIRECT_MESSAGE: 211,
62 |
63 | // 内容事件 (300-399)
64 | COMMENT_CREATE: 300,
65 | COMMENT_UPDATE: 301,
66 | COMMENT_DELETE: 302,
67 | COMMENT_LIKE: 303,
68 | COMMENT_UNLIKE: 304,
69 | COMMENT_REPLY: 305,
70 |
71 | COLLECTION_CREATE: 320,
72 | COLLECTION_UPDATE: 321,
73 | COLLECTION_DELETE: 322,
74 | COLLECTION_ADD_ITEM: 323,
75 | COLLECTION_REMOVE_ITEM: 324,
76 |
77 | // 系统事件 (500-599)
78 | SYSTEM_CONFIG_CHANGE: 500,
79 | SYSTEM_MAINTENANCE_START: 501,
80 | SYSTEM_MAINTENANCE_END: 502,
81 | SYSTEM_BACKUP: 503,
82 | SYSTEM_RESTORE: 504,
83 |
84 | SYSTEM_ERROR: 510,
85 | SYSTEM_PERFORMANCE_ISSUE: 511,
86 | API_RATE_LIMIT_EXCEEDED: 512,
87 |
88 | // 安全事件 (600-699)
89 | SECURITY_LOGIN_ATTEMPT_FAILED: 600,
90 | SECURITY_PASSWORD_RESET: 601,
91 | SECURITY_SUSPICIOUS_ACTIVITY: 602,
92 | SECURITY_PERMISSION_CHANGE: 603,
93 | SECURITY_API_KEY_GENERATED: 604,
94 | SECURITY_API_KEY_REVOKED: 605
95 | };
96 |
97 | /**
98 | * 目标类型枚举
99 | * @enum {string}
100 | */
101 | export const TargetTypes = {
102 | USER: 'user',
103 | PROJECT: 'project',
104 | COMMENT: 'comment',
105 | TOPIC: 'topic',
106 | POST: 'post',
107 | SYSTEM: 'system',
108 | COLLECTION: 'collection',
109 | API: 'api'
110 | };
111 |
112 | /**
113 | * 事件类型按分类的映射
114 | * 用于按类别检索事件类型
115 | */
116 | export const EventCategories = {
117 | USER: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 21, 22],
118 | PROJECT: [100, 101, 102, 103, 104, 110, 111, 112, 113, 114, 115, 116, 117, 120, 121, 122, 123, 124, 130, 131, 132, 133, 134],
119 | SOCIAL: [200, 201, 202, 203, 210, 211],
120 | CONTENT: [300, 301, 302, 303, 304, 305, 320, 321, 322, 323, 324],
121 | SYSTEM: [500, 501, 502, 503, 504, 510, 511, 512],
122 | SECURITY: [600, 601, 602, 603, 604, 605]
123 | };
124 |
125 | /**
126 | * 事件中需要记录敏感数据的类型
127 | * 这些事件在记录时应额外关注隐私保护
128 | */
129 | export const SensitiveEventTypes = [
130 | EventTypes.USER_PASSWORD_CHANGE,
131 | EventTypes.USER_EMAIL_CHANGE,
132 | EventTypes.SECURITY_LOGIN_ATTEMPT_FAILED,
133 | EventTypes.SECURITY_PASSWORD_RESET,
134 | EventTypes.SECURITY_SUSPICIOUS_ACTIVITY,
135 | EventTypes.SECURITY_API_KEY_GENERATED
136 | ];
137 |
138 | /**
139 | * 需要发送实时通知的事件类型
140 | */
141 | export const RealtimeNotificationEvents = [
142 | EventTypes.PROJECT_COMMENT_CREATE,
143 | EventTypes.PROJECT_STAR,
144 | EventTypes.PROJECT_FORK,
145 | EventTypes.USER_FOLLOW,
146 | EventTypes.USER_MENTION,
147 | EventTypes.COMMENT_REPLY
148 | ];
149 |
150 | export default {
151 | EventTypes,
152 | TargetTypes,
153 | EventCategories,
154 | SensitiveEventTypes,
155 | RealtimeNotificationEvents
156 | };
--------------------------------------------------------------------------------
/docs/oauth_temp_token.md:
--------------------------------------------------------------------------------
1 | # OAuth临时令牌认证流程
2 |
3 | ## 概述
4 |
5 | 为了提高安全性,OAuth登录流程现已更新为使用临时令牌(Temporary Token)模式。这种方式将敏感的用户数据安全地存储在Redis中,而不是直接在URL中传递。
6 |
7 | ## 认证流程
8 |
9 | 1. 用户点击OAuth登录按钮,后端生成state并重定向到OAuth提供商
10 | 2. 用户在OAuth提供商页面完成授权
11 | 3. OAuth提供商回调我们的系统
12 | 4. 后端验证OAuth信息并生成临时令牌,将用户数据存储在Redis中
13 | 5. 后端将临时令牌通过URL参数重定向到前端
14 | 6. 前端获取临时令牌,调用验证API获取正式登录凭证和用户信息
15 |
16 | ## 前端实现
17 |
18 | ### 1. 获取临时令牌
19 |
20 | 当用户完成OAuth授权后,系统会重定向到前端的回调页面,URL中包含临时令牌:
21 |
22 | ```
23 | https://your-frontend.com/app/account/callback?temp_token=abcdef123456
24 | ```
25 |
26 | ### 2. 使用临时令牌获取用户信息和正式令牌
27 |
28 | 前端需要从URL中提取临时令牌,然后调用API获取用户信息和正式的登录令牌:
29 |
30 | ```javascript
31 | // 从URL中提取临时令牌
32 | const urlParams = new URLSearchParams(window.location.search);
33 | const tempToken = urlParams.get('temp_token');
34 |
35 | if (tempToken) {
36 | // 调用API验证临时令牌并获取用户信息
37 | fetch(`https://your-api.com/account/oauth/validate-token/${tempToken}`)
38 | .then(response => response.json())
39 | .then(data => {
40 | if (data.status === 'success') {
41 | // 存储用户信息和令牌
42 | localStorage.setItem('token', data.token);
43 | localStorage.setItem('refresh_token', data.refresh_token);
44 | localStorage.setItem('user', JSON.stringify({
45 | userid: data.userid,
46 | username: data.username,
47 | display_name: data.display_name,
48 | avatar: data.avatar,
49 | email: data.email
50 | }));
51 |
52 | // 登录成功后的操作...
53 | // 重定向到用户主页
54 | window.location.href = '/app/dashboard';
55 | } else {
56 | // 处理错误
57 | console.error('登录失败:', data.message);
58 | // 显示错误消息
59 | showError(data.message);
60 | }
61 | })
62 | .catch(error => {
63 | console.error('验证临时令牌时出错:', error);
64 | showError('登录过程中发生错误,请重试');
65 | });
66 | }
67 | ```
68 |
69 | ## 临时令牌安全性
70 |
71 | - 临时令牌存储在Redis中,有效期为24小时
72 | - 临时令牌仅可使用一次,验证后自动失效
73 | - 用户数据存储在Redis中,而不是直接在URL中传递
74 | - 临时令牌只能通过特定的API端点验证,增加了安全性
75 |
76 | ## API参考
77 |
78 | ### 获取用户信息和令牌
79 |
80 | **请求**
81 |
82 | ```
83 | GET /account/oauth/validate-token/:token
84 | ```
85 |
86 | **响应**
87 |
88 | 成功:
89 | ```json
90 | {
91 | "status": "success",
92 | "message": "登录成功",
93 | "userid": 123,
94 | "username": "user123",
95 | "display_name": "用户名称",
96 | "avatar": "avatar.jpg",
97 | "email": "user@example.com",
98 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
99 | "refresh_token": "abcdef123456789...",
100 | "expires_at": "2023-06-01T12:00:00Z",
101 | "refresh_expires_at": "2023-07-01T12:00:00Z"
102 | }
103 | ```
104 |
105 | 失败:
106 | ```json
107 | {
108 | "status": "error",
109 | "message": "令牌不存在或已过期"
110 | }
111 | ```
--------------------------------------------------------------------------------
/docs/user-relationships.md:
--------------------------------------------------------------------------------
1 | # User Relationships System
2 |
3 | ## Overview
4 |
5 | The ZeroCat User Relationships system provides a flexible way to manage different types of relationships between users, such as follows, blocks, mutes, and favorites. This system replaces the previous follows-specific implementation with a more generalized approach that can handle multiple relationship types.
6 |
7 | ## Database Schema
8 |
9 | The system uses a `user_relationships` table with the following structure:
10 |
11 | | Field | Type | Description |
12 | |--------------------|--------------------------|-------------------------------------------------|
13 | | `id` | Int (auto-increment) | Primary key |
14 | | `source_user_id` | Int | The user initiating the relationship |
15 | | `target_user_id` | Int | The user receiving the relationship |
16 | | `relationship_type`| Enum | Type of relationship (follow, block, mute, etc.)|
17 | | `created_at` | DateTime | When the relationship was created |
18 | | `updated_at` | DateTime | When the relationship was last updated |
19 | | `metadata` | Json | Additional data for the relationship |
20 |
21 | The table has a unique constraint on `(source_user_id, target_user_id, relationship_type)` to ensure that a user can only have one relationship of each type with another user.
22 |
23 | ## Relationship Types
24 |
25 | The current supported relationship types are:
26 |
27 | - `follow`: User follows another user to see their content in feeds
28 | - `block`: User blocks another user to prevent interactions
29 | - `mute`: User mutes another user to hide their content but still allow interactions
30 | - `favorite`: User marks another user as a favorite
31 |
32 | Additional relationship types can be added to the enum as needed.
33 |
34 | ## API Endpoints
35 |
36 | ### Follow Management
37 |
38 | - `POST /api/follows/:userId` - Follow a user
39 | - `DELETE /api/follows/:userId` - Unfollow a user
40 | - `GET /api/follows/followers/:userId` - Get followers of a user
41 | - `GET /api/follows/following/:userId` - Get users followed by a user
42 | - `GET /api/follows/check/:userId` - Check if logged in user is following a user
43 |
44 | ### Block Management
45 |
46 | - `POST /api/follows/block/:userId` - Block a user
47 | - `DELETE /api/follows/block/:userId` - Unblock a user
48 | - `GET /api/follows/blocked` - Get users blocked by the logged in user
49 | - `GET /api/follows/check-block/:userId` - Check if logged in user has blocked a user
50 |
51 | ### General Relationship Management
52 |
53 | - `GET /api/follows/relationships/:userId` - Get all relationships between logged in user and another user
54 |
55 | ## Usage Examples
56 |
57 | ### Following a User
58 |
59 | ```javascript
60 | // Client-side
61 | const response = await fetch(`/api/follows/${userId}`, {
62 | method: 'POST',
63 | headers: {
64 | 'Content-Type': 'application/json'
65 | }
66 | });
67 |
68 | const result = await response.json();
69 | ```
70 |
71 | ### Blocking a User
72 |
73 | ```javascript
74 | // Client-side
75 | const response = await fetch(`/api/follows/block/${userId}`, {
76 | method: 'POST',
77 | headers: {
78 | 'Content-Type': 'application/json'
79 | }
80 | });
81 |
82 | const result = await response.json();
83 | ```
84 |
85 | ### Checking Relationships
86 |
87 | ```javascript
88 | // Client-side
89 | const response = await fetch(`/api/follows/relationships/${userId}`);
90 | const result = await response.json();
91 |
92 | // Example result
93 | {
94 | "success": true,
95 | "data": {
96 | "isFollowing": true,
97 | "isFollowedBy": false,
98 | "isBlocking": false,
99 | "isBlockedBy": false,
100 | "isMuting": false,
101 | "hasFavorited": true,
102 | "relationships": {
103 | "outgoing": [...],
104 | "incoming": [...]
105 | }
106 | }
107 | }
108 | ```
109 |
110 | ## Implementing Custom Relationship Types
111 |
112 | To add a new relationship type:
113 |
114 | 1. Add the new type to the `user_relationship_type` enum in `prisma/schema.prisma`
115 | 2. Add controller methods for the new relationship type in `controllers/follows.js`
116 | 3. Add route handlers for the new relationship type in `routes/follows.js`
117 |
118 | ## Data Migration
119 |
120 | When deploying this system to replace the previous follows system, run the migration script to transfer existing data:
121 |
122 | ```
123 | node migrations/migrate-follows-to-relationships.js
124 | ```
125 |
126 | This script will transfer all follows from the old `user_follows` table to the new `user_relationships` table with the relationship type set to `follow`.
127 |
128 | ## Code Example: Adding a New Relationship Type
129 |
130 | 1. First, add the new type to the enum in the Prisma schema:
131 |
132 | ```prisma
133 | enum user_relationship_type {
134 | follow
135 | block
136 | mute
137 | favorite
138 | super_follow // New type
139 | }
140 | ```
141 |
142 | 2. Add controller methods:
143 |
144 | ```javascript
145 | // Add to controllers/follows.js
146 | export async function superFollowUser(followerId, followedId) {
147 | try {
148 | // Implementation
149 | const relationship = await prisma.ow_user_relationships.create({
150 | data: {
151 | source_user_id: followerId,
152 | target_user_id: followedId,
153 | relationship_type: 'super_follow',
154 | metadata: { /* additional data */ }
155 | }
156 | });
157 |
158 | return relationship;
159 | } catch (error) {
160 | logger.error("Error in superFollowUser:", error);
161 | throw error;
162 | }
163 | }
164 | ```
165 |
166 | 3. Add route handlers:
167 |
168 | ```javascript
169 | // Add to routes/follows.js
170 | router.post('/super/:userId', needLogin, async (req, res) => {
171 | try {
172 | const followerId = req.user.id;
173 | const followedId = parseInt(req.params.userId);
174 |
175 | const result = await followsController.superFollowUser(followerId, followedId);
176 | res.json({ success: true, data: result });
177 | } catch (error) {
178 | handleError(res, error);
179 | }
180 | });
181 | ```
--------------------------------------------------------------------------------
/docs/user_status_migration.md:
--------------------------------------------------------------------------------
1 | # 用户状态迁移指南
2 |
3 | 本文档详细说明了如何将用户状态(`status`)字段从数字类型转换为描述性字符串类型,使代码更加直观和易于维护。
4 |
5 | ## 迁移概述
6 |
7 | 当前数据库中用户状态使用数字编码:
8 | - `0` = 待激活(新注册账户)
9 | - `1` = 正常(正常活跃账户)
10 | - `2` = 已暂停(临时禁用)
11 | - `3` = 已封禁(永久禁用)
12 |
13 | 迁移后,状态将使用更直观的字符串:
14 | - `pending` = 待激活
15 | - `active` = 正常
16 | - `suspended` = 已暂停
17 | - `banned` = 已封禁
18 |
19 | ## 迁移SQL脚本
20 |
21 | 下面是完整的SQL脚本,您可以直接在MySQL命令行或管理工具中执行。您也可以在项目根目录下找到此脚本`prisma/user_status_migration.sql`。
22 |
23 | ```sql
24 | -- 删除可能已存在的备份表
25 | DROP TABLE IF EXISTS `ow_users_backup`;
26 |
27 | -- 创建备份表
28 | CREATE TABLE `ow_users_backup` LIKE `ow_users`;
29 | INSERT INTO `ow_users_backup` SELECT * FROM `ow_users`;
30 |
31 | -- 添加临时列(如果原status列存在且为INT类型)
32 | ALTER TABLE `ow_users` ADD COLUMN IF NOT EXISTS `status_text` VARCHAR(20) NOT NULL DEFAULT 'pending';
33 |
34 | -- 填充数据(仅当status为INT类型且status_text存在时需要)
35 | UPDATE `ow_users` SET `status_text` = 'pending' WHERE `status` = 0;
36 | UPDATE `ow_users` SET `status_text` = 'active' WHERE `status` = 1;
37 | UPDATE `ow_users` SET `status_text` = 'suspended' WHERE `status` = 2;
38 | UPDATE `ow_users` SET `status_text` = 'banned' WHERE `status` = 3;
39 |
40 | -- 删除旧列
41 | ALTER TABLE `ow_users` DROP COLUMN IF EXISTS `status`;
42 |
43 | -- 重命名新列
44 | ALTER TABLE `ow_users` CHANGE COLUMN `status_text` `status` VARCHAR(20) NOT NULL DEFAULT 'pending';
45 |
46 | -- 添加索引(可选)
47 | DROP INDEX IF EXISTS `idx_user_status` ON `ow_users`;
48 | CREATE INDEX `idx_user_status` ON `ow_users` (`status`);
49 | ```
50 |
51 | ## 执行步骤
52 |
53 | 1. **备份数据库**
54 | 在执行任何迁移操作前,务必先备份整个数据库
55 |
56 | 2. **连接到数据库**
57 | ```bash
58 | mysql -u 用户名 -p 数据库名
59 | ```
60 |
61 | 3. **执行迁移脚本**
62 | 有两种方式:
63 | - 直接复制上述SQL语句到MySQL命令行中执行
64 | - 使用文件执行:
65 | ```bash
66 | mysql -u 用户名 -p 数据库名 < prisma/user_status_migration.sql
67 | ```
68 |
69 | 4. **验证迁移**
70 | ```sql
71 | DESCRIBE ow_users;
72 | SELECT id, username, status FROM ow_users LIMIT 10;
73 | ```
74 |
75 | 5. **更新Prisma模型**
76 | ```bash
77 | npx prisma db pull
78 | npx prisma generate
79 | ```
80 |
81 | ## 常见错误及解决方案
82 |
83 | ### 错误:Duplicate entry for key 'id_UNIQUE'
84 |
85 | **错误消息**:
86 | ```
87 | Error Code: 1062. Duplicate entry '1' for key 'ow_users_backup.id_UNIQUE'
88 | ```
89 |
90 | **原因**:备份表已存在且包含数据,主键冲突。
91 |
92 | **解决方案**:
93 | 1. 确保在创建备份表前先删除已存在的备份表:
94 | ```sql
95 | DROP TABLE IF EXISTS `ow_users_backup`;
96 | ```
97 |
98 | ### 错误:Column 'status_text' already exists
99 |
100 | **原因**:临时列已经存在,可能是之前迁移中断。
101 |
102 | **解决方案**:
103 | 1. 检查临时列是否存在:
104 | ```sql
105 | SHOW COLUMNS FROM `ow_users` LIKE 'status_text';
106 | ```
107 |
108 | 2. 如果存在,可以继续执行后续步骤,或先删除该列:
109 | ```sql
110 | ALTER TABLE `ow_users` DROP COLUMN `status_text`;
111 | ```
112 |
113 | ### 错误:Unknown column 'status' in 'ow_users'
114 |
115 | **原因**:原始的status列可能已经被删除或已迁移完成。
116 |
117 | **解决方案**:
118 | 1. 检查当前表结构:
119 | ```sql
120 | DESCRIBE ow_users;
121 | ```
122 |
123 | 2. 如果status列已是VARCHAR类型,说明迁移可能已完成,可以跳过这次迁移。
124 |
125 | ## 修复已知问题
126 |
127 | 如果您遇到事件表(`events`)索引相关的错误,可以执行以下SQL来修复:
128 |
129 | ```sql
130 | -- 删除可能存在的索引
131 | DROP INDEX IF EXISTS `idx_events_actor_id` ON `events`;
132 | DROP INDEX IF EXISTS `idx_events_type_target` ON `events`;
133 | DROP INDEX IF EXISTS `idx_events_public` ON `events`;
134 |
135 | -- 重新创建索引
136 | CREATE INDEX `idx_events_actor_id` ON `events` (`actor_id`);
137 | CREATE INDEX `idx_events_type_target` ON `events` (`event_type`, `target_id`);
138 | CREATE INDEX `idx_events_public` ON `events` (`public`);
139 | ```
140 |
141 | ## 回滚方案
142 |
143 | 如果迁移出现问题,您可以使用备份表恢复数据:
144 |
145 | ```sql
146 | -- 删除修改后的表
147 | DROP TABLE `ow_users`;
148 |
149 | -- 从备份表恢复
150 | CREATE TABLE `ow_users` LIKE `ow_users_backup`;
151 | INSERT INTO `ow_users` SELECT * FROM `ow_users_backup`;
152 |
153 | -- 可选:删除备份表
154 | -- DROP TABLE `ow_users_backup`;
155 | ```
156 |
157 | ## 代码适配
158 |
159 | 迁移数据库后,请确保更新相关代码,使用新的字符串状态值。特别是以下文件:
160 |
161 | 1. `src/middleware/auth.middleware.js` - 使用 `isActive()` 函数检查状态
162 | 2. `utils/userStatus.js` - 包含状态值常量和辅助函数
163 |
164 | ## 注意事项
165 |
166 | - 此迁移不支持自动回滚,请确保先备份数据库
167 | - 如迁移过程中断,可能需要手动清理临时列
168 | - 生产环境中请在低峰期执行迁移
169 | - 使用新字符串值的代码不应在迁移完成前部署
170 | - 如果您的MySQL版本不支持`IF EXISTS`或`IF NOT EXISTS`语法,请删除这些修饰符
--------------------------------------------------------------------------------
/meilisearch/config.yml:
--------------------------------------------------------------------------------
1 | debug: true
2 | source:
3 | type: mysql
4 | host: host.docker.internal
5 | port: 3557
6 | user: root
7 | password: "123456"
8 | database: zerocat_develop
9 | server_id: 1
10 |
11 | meilisearch:
12 | api_url: http://host.docker.internal:7700
13 | api_key: BXi0YPZzzVanUgZDp9LjdQk59CKaQhviAfiYFdpCTl0
14 | insert_size: 1000
15 | insert_interval: 10
16 |
17 | progress:
18 | type: file
19 | path: progress.json
20 |
21 | sync:
22 | - table: ow_projects_search
23 | index: projects
24 | full: true
25 | fields:
26 | id:
27 | name:
28 | title:
29 | description:
30 | type:
31 | license:
32 | authorid:
33 | state:
34 | view_count:
35 | like_count:
36 | favo_count:
37 | star_count:
38 | time:
39 | tags:
40 | tag_list:
41 | latest_source:
42 | comment_count:
43 | recent_comments_full:
44 | star_users_full:
45 | star_users_names:
46 | author_info:
47 | recent_commits:
48 | commit_count:
49 | fork_details:
50 | included_in_lists:
51 | searchable_attributes:
52 | - name
53 | - title
54 | - description
55 | - tags
56 | - tag_list
57 | - latest_source
58 | - recent_comments_full
59 | - star_users_names
60 | - author_info
61 | filterable_attributes:
62 | - type
63 | - license
64 | - state
65 | - authorid
66 | - view_count
67 | - like_count
68 | - star_count
69 | - comment_count
70 | - commit_count
71 | sortable_attributes:
72 | - star_count
73 | - comment_count
74 | - time
75 |
--------------------------------------------------------------------------------
/meilisearch/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | meilisync-admin:
4 | image: ghcr.io/long2ice/meilisync-admin/meilisync-admin
5 | container_name: meilisync-admin
6 | restart: always
7 | networks:
8 | - meilisync-net
9 | ports:
10 | - 7701:8000
11 | environment:
12 | - DB_URL=mysql://root:123456@host.docker.internal:3557/meilisync_admin # 可选:如果你后续不使用 MySQL 可移除
13 | - REDIS_URL=redis://redis:6379/0
14 | - SECRET_KEY=secret
15 | - SENTRY_DSN=
16 |
17 | redis:
18 | image: redis:7
19 | container_name: redis
20 | restart: always
21 | networks:
22 | - meilisync-net
23 |
24 | networks:
25 | meilisync-net:
26 | driver: bridge
27 |
--------------------------------------------------------------------------------
/middleware/captcha.js:
--------------------------------------------------------------------------------
1 | import { error as loggerError, debug } from "../logger.js";
2 | import { get } from "../zcconfig.js";
3 | import axios from "axios";
4 | import { URL } from "url";
5 |
6 | const captchaMiddleware = async (req, res, next) => {
7 | const recaptcha = req.body.recaptcha || req.query.recaptcha;
8 |
9 | if (!recaptcha) {
10 | return res.status(400).send({ message: "请完成验证码" });
11 | }
12 |
13 | try {
14 | const { url, secret } = await get("captcha");
15 |
16 | const response = await axios.post(
17 | new URL("/siteverify", url),
18 | null,
19 | {
20 | params: {
21 | secret,
22 | response: recaptcha,
23 | },
24 | }
25 | );
26 |
27 | if (response.data.success) {
28 | next();
29 | } else {
30 | res.status(400).send({ message: "验证码无效", response: response.data });
31 | }
32 | } catch (error) {
33 | loggerError("Error verifying recaptcha:", error);
34 | res.status(500).send({ message: "验证码验证失败", error: error.message });
35 | }
36 | };
37 |
38 | export default captchaMiddleware;
39 |
40 |
--------------------------------------------------------------------------------
/middleware/geetest.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import zcconfig from "../services/config/zcconfig.js";
3 | import axios from "axios";
4 | import { createHmac } from "crypto";
5 |
6 | // Get configuration values
7 | let GEE_CAPTCHA_ID = '';
8 | let GEE_CAPTCHA_KEY = '';
9 | let GEE_API_SERVER = "http://gcaptcha4.geetest.com/validate";
10 |
11 | // Initialize configuration async
12 | async function initConfig() {
13 | try {
14 | GEE_CAPTCHA_ID = await zcconfig.get("captcha.GEE_CAPTCHA_ID", "");
15 | GEE_CAPTCHA_KEY = await zcconfig.get("captcha.GEE_CAPTCHA_KEY", "");
16 | logger.debug("Geetest config loaded");
17 | } catch (err) {
18 | logger.error("Failed to load Geetest config:", err);
19 | }
20 | }
21 |
22 | // Initialize config
23 | initConfig();
24 |
25 | /**
26 | * 生成签名的函数,使用 HMAC-SHA256
27 | * @param {String} value - 待签名的字符串
28 | * @param {String} key - 签名密钥
29 | * @returns {String} 签名结果
30 | */
31 | function hmacSha256Encode(value, key) {
32 | return createHmac("sha256", key).update(value, "utf8").digest("hex");
33 | }
34 |
35 | /**
36 | * 验证码中间件
37 | * @param {Object} req - express的request对象
38 | * @param {Object} res - express的response对象
39 | * @param {Function} next - express的next函数
40 | */
41 | async function geetestMiddleware(req, res, next) {
42 | // 开发环境下跳过验证码检查
43 | if (process.env.NODE_ENV === "development") {
44 | logger.debug("Development mode: Bypassing captcha validation");
45 | return next();
46 | }
47 |
48 | // 如果未正确配置验证码,也跳过检查
49 | if (!GEE_CAPTCHA_ID || !GEE_CAPTCHA_KEY) {
50 | logger.warn("Geetest is not configured properly, bypassing captcha validation");
51 | return next();
52 | }
53 |
54 | // 验证码信息
55 | let geetest = {};
56 |
57 | // 处理验证码信息
58 | try {
59 | logger.debug(req.body.captcha);
60 | if (req.body.captcha) {
61 | // 如果是字符串则转为json
62 | if (typeof req.body.captcha === "string") {
63 | geetest = JSON.parse(req.body.captcha);
64 | } else {
65 | geetest = req.body.captcha;
66 | }
67 | } else {
68 | geetest = req.query || req.body;
69 | }
70 | } catch (error) {
71 | logger.error("Captcha Parsing Error:", error);
72 | return res.status(400).json({
73 | status: "error",
74 | code: 400,
75 | message: "验证码数据无效"
76 | });
77 | }
78 |
79 | if (!geetest.lot_number || !geetest.captcha_output || !geetest.captcha_id || !geetest.pass_token || !geetest.gen_time) {
80 | logger.error("Captcha data missing");
81 | return res.status(400).json({
82 | status: "error",
83 | code: 400,
84 | message: "验证码数据不完整"
85 | });
86 | }
87 |
88 | logger.debug(geetest);
89 |
90 | // 生成签名
91 | const sign_token = hmacSha256Encode(geetest.lot_number, GEE_CAPTCHA_KEY);
92 |
93 | // 准备请求参数
94 | const datas = {
95 | lot_number: geetest.lot_number,
96 | captcha_output: geetest.captcha_output,
97 | captcha_id: geetest.captcha_id,
98 | pass_token: geetest.pass_token,
99 | gen_time: geetest.gen_time,
100 | sign_token,
101 | };
102 | logger.debug(datas);
103 |
104 | try {
105 | // 发送请求到极验服务
106 | logger.debug("Sending request to Geetest server...");
107 | const result = await axios.post(GEE_API_SERVER, datas, {
108 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
109 | });
110 | logger.debug(result.data);
111 |
112 | if (result.data.result === "success") {
113 | next(); // 验证成功,继续处理请求
114 | } else {
115 | logger.debug(`Validate fail: ${result.data.reason}`);
116 | return res.status(400).json({
117 | status: "error",
118 | code: 400,
119 | message: `请完成验证码/${result.data.reason}`,
120 | });
121 | }
122 | } catch (error) {
123 | logger.error("Geetest server error:", error);
124 | // 极验服务器出错时放行,避免阻塞业务逻辑
125 | next();
126 | }
127 | }
128 |
129 | export default geetestMiddleware;
130 |
131 |
--------------------------------------------------------------------------------
/middleware/rateLimit.js:
--------------------------------------------------------------------------------
1 | import rateLimit from 'express-rate-limit';
2 | import RedisStore from 'rate-limit-redis';
3 |
4 | export const sensitiveActionLimiter = rateLimit({
5 | store: new RedisStore({
6 | client: redis
7 | }),
8 | windowMs: 15 * 60 * 1000, // 15分钟
9 | max: 5, // 限制5次请求
10 | message: {
11 | status: 'error',
12 | message: '请求过于频繁,请稍后再试'
13 | }
14 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zerocat",
3 | "version": "1.0.3",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node server.js",
9 | "prisma": "prisma generate",
10 | "prisma:pull": "prisma db pull && prisma generate",
11 | "dev": "NODE_ENV=development nodemon server.js"
12 | },
13 | "dependencies": {
14 | "@aws-sdk/client-s3": "^3.782.0",
15 | "@maxmind/geoip2-node": "^4.2.0",
16 | "@prisma/client": "^6.8.2",
17 | "axios": "^1.8.4",
18 | "base32-encode": "^2.0.0",
19 | "bcrypt": "^5.1.1",
20 | "body-parser": "^2.2.0",
21 | "compression": "^1.8.0",
22 | "connect-multiparty": "^2.2.0",
23 | "cookie-parser": "^1.4.7",
24 | "cors": "^2.8.5",
25 | "crypto-js": "^4.1.1",
26 | "dotenv": "^16.4.7",
27 | "ejs": "^3.1.10",
28 | "express": "^5.1.0",
29 | "express-jwt": "^8.5.1",
30 | "express-session": "^1.18.1",
31 | "express-winston": "^4.2.0",
32 | "html-entities": "^2.6.0",
33 | "ioredis": "^5.6.1",
34 | "jsonwebtoken": "^9.0.0",
35 | "morgan": "^1.10.0",
36 | "multer": "1.4.5-lts.1",
37 | "mysql2": "^3.6.0",
38 | "nodemailer": "^6.10.0",
39 | "otpauth": "^9.4.0",
40 | "phpass": "^0.1.1",
41 | "tar": "^6.2.1",
42 | "ua-parser-js": "^2.0.3",
43 | "uuid": "^11.1.0",
44 | "winston": "^3.17.0",
45 | "winston-daily-rotate-file": "^5.0.0"
46 | },
47 | "devDependencies": {
48 | "prisma": "^6.8.2"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/prisma/migrations/20250531113639_fix_timezone_and_add_search_view/migration.sql:
--------------------------------------------------------------------------------
1 |
2 | -- CreateView
3 | CREATE OR REPLACE VIEW `ow_projects_search` AS
4 | SELECT
5 | p.*,
6 | (
7 | SELECT pf.source
8 | FROM ow_projects_file pf
9 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
10 | WHERE pc.project_id = p.id
11 | ORDER BY pc.commit_date DESC
12 | LIMIT 1
13 | ) as latest_source,
14 | (
15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
16 | FROM ow_projects_tags pt
17 | WHERE pt.projectid = p.id
18 | GROUP BY pt.projectid
19 | ) as tag_list,
20 | (
21 | SELECT COUNT(*)
22 | FROM ow_comment c
23 | WHERE c.page_type = 'project'
24 | AND c.page_id = p.id
25 | ) as comment_count,
26 | (
27 | SELECT c.text
28 | FROM ow_comment c
29 | WHERE c.page_type = 'project'
30 | AND c.page_id = p.id
31 | ORDER BY c.insertedAt DESC
32 | LIMIT 1
33 | ) as latest_comment
34 | FROM ow_projects p;
--------------------------------------------------------------------------------
/prisma/migrations/20250531113640_add_enhanced_projects_search_view/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.*,
5 | (
6 | SELECT pf.source
7 | FROM ow_projects_file pf
8 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
9 | WHERE pc.project_id = p.id
10 | ORDER BY pc.commit_date DESC
11 | LIMIT 1
12 | ) as latest_source,
13 | (
14 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
15 | FROM ow_projects_tags pt
16 | WHERE pt.projectid = p.id
17 | GROUP BY pt.projectid
18 | ) as tag_list,
19 | (
20 | SELECT COUNT(*)
21 | FROM ow_comment c
22 | WHERE c.page_type = 'project'
23 | AND c.page_id = p.id
24 | ) as comment_count,
25 | (
26 | SELECT JSON_ARRAYAGG(
27 | JSON_OBJECT(
28 | 'id', c.id,
29 | 'text', c.text,
30 | 'insertedAt', c.insertedAt,
31 | 'user', (
32 | SELECT JSON_OBJECT(
33 | 'id', u.id,
34 | 'username', u.username,
35 | 'display_name', u.display_name,
36 | 'avatar', u.avatar,
37 | 'type', u.type
38 | )
39 | FROM ow_users u
40 | WHERE u.id = c.user_id
41 | )
42 | )
43 | )
44 | FROM (
45 | SELECT * FROM ow_comment c
46 | WHERE c.page_type = 'project'
47 | AND c.page_id = p.id
48 | ORDER BY c.insertedAt DESC
49 | LIMIT 10
50 | ) c
51 | ) as recent_comments_full,
52 | (
53 | SELECT JSON_ARRAYAGG(
54 | JSON_OBJECT(
55 | 'id', ps.id,
56 | 'createTime', ps.createTime,
57 | 'user', (
58 | SELECT JSON_OBJECT(
59 | 'id', u.id,
60 | 'username', u.username,
61 | 'display_name', u.display_name,
62 | 'avatar', u.avatar,
63 | 'type', u.type,
64 | 'motto', u.motto
65 | )
66 | FROM ow_users u
67 | WHERE u.id = ps.userid
68 | )
69 | )
70 | )
71 | FROM ow_projects_stars ps
72 | WHERE ps.projectid = p.id
73 | ) as star_users_full,
74 | (
75 | SELECT GROUP_CONCAT(DISTINCT u.display_name SEPARATOR ', ')
76 | FROM ow_projects_stars ps
77 | INNER JOIN ow_users u ON ps.userid = u.id
78 | WHERE ps.projectid = p.id
79 | ) as star_users_names,
80 | (
81 | SELECT JSON_OBJECT(
82 | 'id', u.id,
83 | 'username', u.username,
84 | 'display_name', u.display_name,
85 | 'avatar', u.avatar,
86 | 'type', u.type,
87 | 'motto', u.motto,
88 | 'github', u.github,
89 | 'twitter', u.twitter,
90 | 'url', u.url
91 | )
92 | FROM ow_users u
93 | WHERE u.id = p.authorid
94 | ) as author_info,
95 | (
96 | SELECT JSON_ARRAYAGG(
97 | JSON_OBJECT(
98 | 'id', pc.id,
99 | 'commit_message', pc.commit_message,
100 | 'commit_date', pc.commit_date,
101 | 'commit_description', pc.commit_description,
102 | 'branch', pc.branch,
103 | 'author', (
104 | SELECT JSON_OBJECT(
105 | 'id', u.id,
106 | 'username', u.username,
107 | 'display_name', u.display_name,
108 | 'avatar', u.avatar
109 | )
110 | FROM ow_users u
111 | WHERE u.id = pc.author_id
112 | )
113 | )
114 | )
115 | FROM (
116 | SELECT * FROM ow_projects_commits pc
117 | WHERE pc.project_id = p.id
118 | ORDER BY pc.commit_date DESC
119 | LIMIT 5
120 | ) pc
121 | ) as recent_commits,
122 | (
123 | SELECT COUNT(DISTINCT pc.id)
124 | FROM ow_projects_commits pc
125 | WHERE pc.project_id = p.id
126 | ) as commit_count,
127 | (
128 | SELECT JSON_OBJECT(
129 | 'fork_count', (
130 | SELECT COUNT(*)
131 | FROM ow_projects
132 | WHERE fork = p.id
133 | ),
134 | 'fork_info', CASE
135 | WHEN p.fork IS NOT NULL THEN (
136 | SELECT JSON_OBJECT(
137 | 'id', op.id,
138 | 'name', op.name,
139 | 'author', (
140 | SELECT JSON_OBJECT(
141 | 'id', u.id,
142 | 'username', u.username,
143 | 'display_name', u.display_name
144 | )
145 | FROM ow_users u
146 | WHERE u.id = op.authorid
147 | )
148 | )
149 | FROM ow_projects op
150 | WHERE op.id = p.fork
151 | )
152 | ELSE NULL
153 | END
154 | )
155 | ) as fork_details,
156 | (
157 | SELECT JSON_ARRAYAGG(
158 | JSON_OBJECT(
159 | 'id', pl.id,
160 | 'title', pl.title,
161 | 'description', pl.description,
162 | 'author', (
163 | SELECT JSON_OBJECT(
164 | 'id', u.id,
165 | 'username', u.username,
166 | 'display_name', u.display_name
167 | )
168 | FROM ow_users u
169 | WHERE u.id = pl.authorid
170 | )
171 | )
172 | )
173 | FROM ow_projects_list_items pli
174 | INNER JOIN ow_projects_lists pl ON pli.listid = pl.id
175 | WHERE pli.projectid = p.id
176 | AND pl.state = 'public'
177 | ) as included_in_lists
178 | FROM ow_projects p;
--------------------------------------------------------------------------------
/prisma/migrations/20250601042102_update_only_show_public_project/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropIndex
2 | DROP INDEX `idx_projects_comments` ON `ow_comment`;
3 |
4 | -- DropIndex
5 | DROP INDEX `idx_projects_state` ON `ow_projects`;
6 |
7 | -- DropIndex
8 | DROP INDEX `idx_projects_commits_project_date` ON `ow_projects_commits`;
9 |
10 | -- DropIndex
11 | DROP INDEX `idx_projects_stars_project` ON `ow_projects_stars`;
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20250601042631_update_index/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateIndex
2 | CREATE INDEX `idx_projects_comments` ON `ow_comment`(`page_type`, `page_id`, `insertedAt`);
3 |
4 | -- CreateIndex
5 | CREATE INDEX `idx_projects_state` ON `ow_projects`(`state`);
6 |
7 | -- CreateIndex
8 | CREATE INDEX `idx_projects_commits_project_date` ON `ow_projects_commits`(`project_id`, `commit_date`);
9 |
10 | -- CreateIndex
11 | CREATE INDEX `idx_projects_stars_project` ON `ow_projects_stars`(`projectid`);
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20250601043102_add_author_fields_to_search_view/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.id,
5 | p.name,
6 | p.title,
7 | p.description,
8 | p.authorid,
9 | p.state,
10 | p.type,
11 | p.license,
12 | p.view_count,
13 | p.like_count,
14 | p.favo_count,
15 | p.star_count,
16 | p.time,
17 | p.tags,
18 | u.display_name as author_display_name,
19 | u.username as author_username,
20 | u.motto as author_motto,
21 | u.images as author_images,
22 | u.type as author_type,
23 | (
24 | SELECT pf.source
25 | FROM ow_projects_file pf
26 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
27 | WHERE pc.project_id = p.id
28 | AND p.state = 'public'
29 | ORDER BY pc.commit_date DESC
30 | LIMIT 1
31 | ) as latest_source,
32 | (
33 | SELECT COUNT(*)
34 | FROM ow_comment c
35 | WHERE c.page_type = 'project'
36 | AND c.page_id = p.id
37 | ) as comment_count,
38 | (
39 | SELECT c.text
40 | FROM ow_comment c
41 | WHERE c.page_type = 'project'
42 | AND c.page_id = p.id
43 | ORDER BY c.insertedAt DESC
44 | LIMIT 1
45 | ) as latest_comment
46 | FROM ow_projects p
47 | LEFT JOIN ow_users u ON p.authorid = u.id
48 | WHERE p.state = 'public';
--------------------------------------------------------------------------------
/prisma/migrations/20250601043103_add_author_fields_to_search_view copy2/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.id,
5 | p.name,
6 | p.title,
7 | p.description,
8 | p.authorid,
9 | p.state,
10 | p.type,
11 | p.license,
12 | p.star_count,
13 | p.time,
14 | (
15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
16 | FROM ow_projects_tags pt
17 | WHERE pt.projectid = p.id
18 | GROUP BY pt.projectid
19 | ) as tags,
20 | u.display_name as author_display_name,
21 | u.username as author_username,
22 | u.motto as author_motto,
23 | u.images as author_images,
24 | u.type as author_type,
25 | (
26 | SELECT pf.source
27 | FROM ow_projects_file pf
28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
29 | WHERE pc.project_id = p.id
30 | AND p.state = 'public'
31 | ORDER BY pc.commit_date DESC
32 | LIMIT 1
33 | ) as latest_source,
34 | (
35 | SELECT COUNT(*)
36 | FROM ow_comment c
37 | WHERE c.page_type = 'project'
38 | AND c.page_id = p.id
39 | ) as comment_count,
40 | (
41 | SELECT c.text
42 | FROM ow_comment c
43 | WHERE c.page_type = 'project'
44 | AND c.page_id = p.id
45 | ORDER BY c.insertedAt DESC
46 | LIMIT 1
47 | ) as latest_comment
48 | FROM ow_projects p
49 | LEFT JOIN ow_users u ON p.authorid = u.id
50 | WHERE p.state = 'public';
--------------------------------------------------------------------------------
/prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches copy2/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.id,
5 | p.name,
6 | p.title,
7 | p.description,
8 | p.authorid,
9 | p.state,
10 | p.type,
11 | p.license,
12 | p.star_count,
13 | p.time,
14 | (
15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
16 | FROM ow_projects_tags pt
17 | WHERE pt.projectid = p.id
18 | GROUP BY pt.projectid
19 | ) as tags,
20 | u.display_name as author_display_name,
21 | u.username as author_username,
22 | u.motto as author_motto,
23 | u.images as author_images,
24 | u.type as author_type,
25 | (
26 | SELECT pf.source
27 | FROM ow_projects_file pf
28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
29 | WHERE pc.project_id = p.id
30 | AND p.state = 'public'
31 | ORDER BY pc.commit_date DESC
32 | LIMIT 1
33 | ) as latest_source,
34 | (
35 | SELECT COUNT(*)
36 | FROM ow_comment c
37 | WHERE c.page_type = 'project'
38 | AND c.page_id = p.id
39 | ) as comment_count,
40 | (
41 | SELECT c.text
42 | FROM ow_comment c
43 | WHERE c.page_type = 'project'
44 | AND c.page_id = p.id
45 | ORDER BY c.insertedAt DESC
46 | LIMIT 1
47 | ) as latest_comment,
48 | (
49 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR ',')
50 | FROM ow_comment c
51 | WHERE c.page_type = 'project'
52 | AND c.page_id = p.id
53 | LIMIT 10
54 | ) as recent_comments,
55 | (
56 | SELECT JSON_ARRAYAGG(
57 | JSON_OBJECT(
58 | 'id', pc.id,
59 | 'message', pc.commit_message,
60 | 'description', pc.commit_description,
61 | 'date', pc.commit_date
62 | )
63 | )
64 | FROM ow_projects_commits pc
65 | WHERE pc.project_id = p.id
66 | ORDER BY pc.commit_date DESC
67 | LIMIT 5
68 | ) as recent_commits,
69 | (
70 | SELECT JSON_ARRAYAGG(
71 | JSON_OBJECT(
72 | 'id', pb.id,
73 | 'name', pb.name,
74 | 'description', pb.description
75 | )
76 | )
77 | FROM ow_projects_branch pb
78 | WHERE pb.projectid = p.id
79 | ) as branches
80 | FROM ow_projects p
81 | LEFT JOIN ow_users u ON p.authorid = u.id
82 | WHERE p.state = 'public';
--------------------------------------------------------------------------------
/prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches copy3/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.id,
5 | p.name,
6 | p.title,
7 | p.description,
8 | p.authorid,
9 | p.state,
10 | p.type,
11 | p.license,
12 | p.star_count,
13 | p.time,
14 | COALESCE(
15 | (
16 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
17 | FROM ow_projects_tags pt
18 | WHERE pt.projectid = p.id
19 | GROUP BY pt.projectid
20 | ),
21 | ''
22 | ) as tags,
23 | u.display_name as author_display_name,
24 | u.username as author_username,
25 | u.motto as author_motto,
26 | u.images as author_images,
27 | u.type as author_type,
28 | COALESCE(
29 | (
30 | SELECT pf.source
31 | FROM ow_projects_file pf
32 | LEFT JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
33 | WHERE pc.project_id = p.id
34 | AND p.state = 'public'
35 | ORDER BY pc.commit_date DESC
36 | LIMIT 1
37 | ),
38 | NULL
39 | ) as latest_source,
40 | COALESCE(
41 | (
42 | SELECT COUNT(*)
43 | FROM ow_comment c
44 | WHERE c.page_type = 'project'
45 | AND c.page_id = p.id
46 | ),
47 | 0
48 | ) as comment_count,
49 | COALESCE(
50 | (
51 | SELECT c.text
52 | FROM ow_comment c
53 | WHERE c.page_type = 'project'
54 | AND c.page_id = p.id
55 | ORDER BY c.insertedAt DESC
56 | LIMIT 1
57 | ),
58 | NULL
59 | ) as latest_comment,
60 | COALESCE(
61 | (
62 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR ',')
63 | FROM ow_comment c
64 | WHERE c.page_type = 'project'
65 | AND c.page_id = p.id
66 | LIMIT 10
67 | ),
68 | NULL
69 | ) as recent_comments,
70 | COALESCE(
71 | (
72 | SELECT JSON_ARRAYAGG(
73 | JSON_OBJECT(
74 | 'id', pc.id,
75 | 'message', pc.commit_message,
76 | 'description', pc.commit_description,
77 | 'date', pc.commit_date
78 | )
79 | )
80 | FROM ow_projects_commits pc
81 | WHERE pc.project_id = p.id
82 | ORDER BY pc.commit_date DESC
83 | LIMIT 5
84 | ),
85 | JSON_ARRAY()
86 | ) as recent_commits,
87 | COALESCE(
88 | (
89 | SELECT JSON_ARRAYAGG(
90 | JSON_OBJECT(
91 | 'id', pb.id,
92 | 'name', pb.name,
93 | 'description', pb.description
94 | )
95 | )
96 | FROM ow_projects_branch pb
97 | WHERE pb.projectid = p.id
98 | ),
99 | JSON_ARRAY()
100 | ) as branches
101 | FROM ow_projects p
102 | LEFT JOIN ow_users u ON p.authorid = u.id
103 | WHERE p.state = 'public';
--------------------------------------------------------------------------------
/prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateView
2 | CREATE OR REPLACE VIEW `ow_projects_search` AS
3 | SELECT
4 | p.id,
5 | p.name,
6 | p.title,
7 | p.description,
8 | p.authorid,
9 | p.state,
10 | p.type,
11 | p.license,
12 | p.star_count,
13 | p.time,
14 | (
15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ')
16 | FROM ow_projects_tags pt
17 | WHERE pt.projectid = p.id
18 | GROUP BY pt.projectid
19 | ) as tags,
20 | u.display_name as author_display_name,
21 | u.username as author_username,
22 | u.motto as author_motto,
23 | u.images as author_images,
24 | u.type as author_type,
25 | (
26 | SELECT pf.source
27 | FROM ow_projects_file pf
28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256
29 | WHERE pc.project_id = p.id
30 | AND p.state = 'public'
31 | ORDER BY pc.commit_date DESC
32 | LIMIT 1
33 | ) as latest_source,
34 | (
35 | SELECT COUNT(*)
36 | FROM ow_comment c
37 | WHERE c.page_type = 'project'
38 | AND c.page_id = p.id
39 | ) as comment_count,
40 | (
41 | SELECT c.text
42 | FROM ow_comment c
43 | WHERE c.page_type = 'project'
44 | AND c.page_id = p.id
45 | ORDER BY c.insertedAt DESC
46 | LIMIT 1
47 | ) as latest_comment,
48 | (
49 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR '|||')
50 | FROM ow_comment c
51 | WHERE c.page_type = 'project'
52 | AND c.page_id = p.id
53 | LIMIT 10
54 | ) as recent_comments,
55 | (
56 | SELECT JSON_ARRAYAGG(
57 | JSON_OBJECT(
58 | 'id', pc.id,
59 | 'message', pc.commit_message,
60 | 'description', pc.commit_description,
61 | 'date', pc.commit_date
62 | )
63 | )
64 | FROM ow_projects_commits pc
65 | WHERE pc.project_id = p.id
66 | ORDER BY pc.commit_date DESC
67 | LIMIT 5
68 | ) as recent_commits,
69 | (
70 | SELECT JSON_ARRAYAGG(
71 | JSON_OBJECT(
72 | 'id', pb.id,
73 | 'name', pb.name,
74 | 'description', pb.description
75 | )
76 | )
77 | FROM ow_projects_branch pb
78 | WHERE pb.projectid = p.id
79 | ) as branches
80 | FROM ow_projects p
81 | LEFT JOIN ow_users u ON p.authorid = u.id
82 | WHERE p.state = 'public';
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "mysql"
4 |
--------------------------------------------------------------------------------
/process.json:
--------------------------------------------------------------------------------
1 | {
2 | "apps": [
3 | {
4 | "name": "ZeroCat",
5 | "script": "app.js",
6 | "error_file": "./logs/err.log",
7 | "out_file": "./logs/out.log",
8 | "log_date_format": "YYYY-MM-DD HH:mm Z",
9 |
10 | "autostart": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/public/Node.js.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/14a82aeede9e34fdbd7deb249ff54e837af131eb/public/Node.js.png
--------------------------------------------------------------------------------
/redis/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | redis:
5 | image: redis:7.4.3
6 | container_name: zerocat_redis
7 | restart: unless-stopped
8 | ports:
9 | - "6379:6379"
10 | command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
11 | volumes:
12 | # Windows 相对路径兼容写法(推荐)
13 | - ./redis.conf:/usr/local/etc/redis/redis.conf:ro
14 | - ./data:/data
15 | networks:
16 | - internal
17 | environment:
18 | - TZ=Asia/Shanghai
19 |
20 | networks:
21 | internal:
22 | driver: bridge
23 |
--------------------------------------------------------------------------------
/routes/router_comment.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js";
3 | import zcconfig from "../services/config/zcconfig.js";
4 | import notificationUtils from "../controllers/notifications.js";
5 | import { UAParser } from "ua-parser-js";
6 | import ipLocation from "../services/ip/ipLocation.js";
7 |
8 | import { Router } from "express";
9 | const router = Router();
10 | import { prisma } from "../services/global.js";
11 | import { getUsersByList } from "../controllers/users.js";
12 | import { createEvent } from "../controllers/events.js";
13 | // 中间件,确保所有请求均经过该处理
14 |
15 | // 统一的错误处理函数
16 | const handleError = (res, err, message) => {
17 | logger.error(err);
18 | res.status(500).send({ errno: 1, errmsg: message, data: err });
19 | };
20 |
21 | // 获取排序条件
22 | const getSortCondition = (req) => {
23 | const sortBy = req.query.sortBy;
24 | if (sortBy == "insertedAt_desc") return { id: "desc" };
25 | if (sortBy == "insertedAt_asc") return { id: "asc" };
26 | if (sortBy == "like_desc") return { like: "desc" };
27 | return {};
28 | };
29 |
30 | // 转换评论数据
31 | const transformComment = async (comments) => {
32 | return Promise.all(
33 | comments.map(async (comment) => {
34 | const time = new Date(comment.insertedAt).getTime();
35 | const objectId = comment.id;
36 |
37 | // 使用 UAParser 解析 UA
38 | const parser = new UAParser(comment.user_ua || "");
39 | const result = parser.getResult();
40 | const browser = result.browser.name || "未知";
41 | const os = result.os.name || "未知";
42 |
43 | // 获取 IP 地址位置信息
44 | let ipInfo = await ipLocation.getIPLocation(comment.user_ip);
45 |
46 | return {
47 | ...comment,
48 | time,
49 | objectId,
50 | browser,
51 | os,
52 | addr: ipInfo.address,
53 | most_specific_country_or_region: ipInfo.most_specific_country_or_region,
54 | };
55 | })
56 | );
57 | };
58 |
59 | // 读取评论
60 | router.get("/api/comment", async (req, res, next) => {
61 | try {
62 | const { path, page, pageSize } = req.query;
63 | const sort = getSortCondition(req);
64 |
65 | const comments = await prisma.ow_comment.findMany({
66 | where: { page_key: path, pid: null, rid: null, type: "comment" },
67 | orderBy: sort,
68 | take: Number(pageSize) || 10,
69 | skip: (page - 1) * pageSize,
70 | });
71 |
72 | const transformedComments = await transformComment(comments);
73 |
74 | const ids = transformedComments.map((comment) => comment.id);
75 |
76 | const childrenComments = await prisma.ow_comment.findMany({
77 | where: { page_key: path, rid: { in: ids }, type: "comment" },
78 | });
79 |
80 | const transformedChildrenComments = await transformComment(
81 | childrenComments
82 | );
83 | // 获取评论的用户id
84 |
85 | var user_ids = transformedComments.map((comment) => comment.user_id);
86 | user_ids = user_ids.concat(
87 | transformedChildrenComments.map((comment) => comment.user_id)
88 | );
89 | //去重
90 | user_ids = Array.from(new Set(user_ids));
91 |
92 | logger.debug(user_ids);
93 | const users = await getUsersByList(user_ids);
94 | const result = transformedComments.map((comment) => {
95 | const children = transformedChildrenComments.filter(
96 | (child) => child.rid == comment.id
97 | );
98 | return { ...comment, children };
99 | });
100 |
101 | const count = await prisma.ow_comment.count({
102 | where: { page_key: path, pid: null, rid: null, type: "comment" },
103 | });
104 |
105 | res.status(200).send({
106 | errno: 0,
107 | errmsg: "",
108 | data: {
109 | page,
110 | totalPages: Math.ceil(count / pageSize),
111 | pageSize,
112 | count,
113 | data: result,
114 | },
115 | users,
116 | });
117 | } catch (err) {
118 | next(err);
119 | }
120 | });
121 |
122 | // 创建评论
123 | router.post("/api/comment", needLogin, async (req, res, next) => {
124 | try {
125 | const { url, comment, pid, rid } = req.body;
126 | const { userid, display_name } = res.locals;
127 | const user_ua = req.headers["user-agent"] || "";
128 |
129 | const newComment = await prisma.ow_comment.create({
130 | data: {
131 | user_id: userid,
132 | type: "comment",
133 | user_ip: req.ip,
134 | page_type: url.split("-")[0],
135 | page_id: Number(url.split("-")[1]) || null,
136 | text: comment,
137 | link: `/user/${userid}`,
138 | user_ua,
139 | pid: pid || null,
140 | rid: rid || null,
141 | },
142 | });
143 |
144 | const transformedComment = (await transformComment([newComment]))[0];
145 | res.status(200).send({
146 | errno: 0,
147 | errmsg: "",
148 | data: transformedComment,
149 | });
150 |
151 | let user_id, targetType, targetId;
152 | if (url.split("-")[0] == "user") {
153 | targetType = "user";
154 | targetId = url.split("-")[1];
155 | user_id = targetId;
156 | } else if (url.split("-")[0] == "project") {
157 | const project = await prisma.ow_projects.findUnique({
158 | where: {
159 | id: Number(url.split("-")[1]),
160 | },
161 | });
162 | user_id = project.authorid;
163 | targetType = "project";
164 | targetId = url.split("-")[1];
165 | } else if (url.split("-")[0] == "projectlist") {
166 | targetType = "projectlist";
167 | targetId = url.split("-")[1];
168 | const projectlist = await prisma.ow_projectlists.findUnique({
169 | where: {
170 | id: Number(url.split("-")[1]),
171 | },
172 | });
173 | user_id = projectlist.authorid;
174 | } else {
175 | user_id = userid;
176 | targetType = "user";
177 | targetId = userid;
178 | }
179 | await createEvent({
180 | eventType: "comment_reply",
181 | actorId: userid,
182 | targetType: targetType,
183 | targetId: targetId,
184 | data: { comment: newComment.text },
185 | });
186 |
187 | } catch (err) {
188 | next(err);
189 | }
190 | });
191 |
192 | // 删除评论
193 | router.delete("/api/comment/:id", async (req, res, next) => {
194 | try {
195 | const { id } = req.params;
196 | const { user_id } = res.locals;
197 |
198 | const comment = await prisma.ow_comment.findFirst({
199 | where: { id: Number(id) },
200 | });
201 |
202 | if (comment.user_id == user_id || true) {
203 | await prisma.ow_comment.delete({
204 | where: { id: Number(id) },
205 | });
206 | }
207 |
208 | res.status(200).send({ errno: 0, errmsg: "", data: "" });
209 | } catch (err) {
210 | next(err);
211 | }
212 | });
213 |
214 | export default router;
215 |
--------------------------------------------------------------------------------
/routes/router_event.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Event routes
3 | */
4 | import express from "express";
5 | import { needLogin } from "../middleware/auth.js";
6 | import {
7 | createEvent,
8 | getTargetEvents,
9 | getActorEvents,
10 | getProjectFollowersExternal,
11 | getUserFollowersExternal,
12 | } from "../controllers/events.js";
13 |
14 | const router = express.Router();
15 |
16 | /**
17 | * @route GET /events/target/:targetType/:targetId
18 | * @desc Get events for a specific target
19 | * @access Public/Private (depends on event privacy)
20 | */
21 | router.get("/target/:targetType/:targetId", async (req, res, next) => {
22 | try {
23 | const { targetType, targetId } = req.params;
24 | const { limit = 10, offset = 0 } = req.query;
25 | const includePrivate = req.user ? true : false;
26 |
27 | const events = await getTargetEvents(
28 | targetType,
29 | targetId,
30 | Number(limit),
31 | Number(offset),
32 | includePrivate
33 | );
34 |
35 | res.json({
36 | status: "success",
37 | data: events,
38 | });
39 | } catch (error) {
40 | next(error);
41 | }
42 | });
43 |
44 | /**
45 | * @route GET /events/actor/:actorId
46 | * @desc Get events for a specific actor
47 | * @access Public/Private (depends on event privacy)
48 | */
49 | router.get("/actor/:actorId", async (req, res, next) => {
50 | try {
51 | const { actorId } = req.params;
52 | const { limit = 10, offset = 0 } = req.query;
53 | const includePrivate =
54 | req.user && (req.user.id === Number(actorId) || req.user.isAdmin);
55 |
56 | const events = await getActorEvents(
57 | actorId,
58 | Number(limit),
59 | Number(offset),
60 | includePrivate
61 | );
62 |
63 | res.json({
64 | status: "success",
65 | data: events,
66 | });
67 | } catch (error) {
68 | next(error);
69 | }
70 | });
71 |
72 | /**
73 | * @route POST /events
74 | * @desc Create a new event
75 | * @access Private
76 | */
77 | router.post("/", needLogin, async (req, res, next) => {
78 | try {
79 | const { eventType, targetType, targetId, ...eventData } = req.body;
80 |
81 | // Use current user as actor if not specified
82 | const actorId = eventData.actor_id || req.user.id;
83 |
84 | const event = await createEvent(
85 | eventType,
86 | actorId,
87 | targetType,
88 | targetId,
89 | eventData
90 | );
91 |
92 | if (!event) {
93 | return res.status(400).json({
94 | status: "error",
95 | message: "Failed to create event",
96 | });
97 | }
98 |
99 | res.status(201).json({
100 | status: "success",
101 | data: event,
102 | });
103 | } catch (error) {
104 | next(error);
105 | }
106 | });
107 |
108 | /**
109 | * @route GET /events/project-followers/:projectId
110 | * @desc Get followers of a project
111 | * @access Public
112 | */
113 | router.get("/project-followers/:projectId", async (req, res, next) => {
114 | try {
115 | const { projectId } = req.params;
116 | const followers = await getProjectFollowersExternal(projectId);
117 |
118 | res.json({
119 | status: "success",
120 | data: followers,
121 | });
122 | } catch (error) {
123 | next(error);
124 | }
125 | });
126 |
127 | /**
128 | * @route GET /events/user-followers/:userId
129 | * @desc Get followers of a user
130 | * @access Public
131 | */
132 | router.get("/user-followers/:userId", async (req, res, next) => {
133 | try {
134 | const { userId } = req.params;
135 | const followers = await getUserFollowersExternal(userId);
136 |
137 | res.json({
138 | status: "success",
139 | data: followers,
140 | });
141 | } catch (error) {
142 | next(error);
143 | }
144 | });
145 |
146 | export default router;
147 |
--------------------------------------------------------------------------------
/routes/router_lists.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import { Router } from "express";
3 | import { needLogin } from "../middleware/auth.js";
4 | import {
5 | getProjectList,
6 | getUserListInfoAndCheak,
7 | createList,
8 | deleteList,
9 | addProjectToList,
10 | removeProjectFromList,
11 | getUserListInfo,
12 | getUserListInfoPublic,
13 | updateList,
14 | } from "../controllers/lists.js";
15 |
16 | const router = Router();
17 |
18 | // Get a specific list by ID
19 | router.get("/listid/:id", async (req, res) => {
20 | try {
21 | const list = await getProjectList(req.params.id, res.locals.userid);
22 | if (!list) {
23 | return res.status(404).send({ status: "error", message: "列表不存在" });
24 | }
25 |
26 | res
27 | .status(200)
28 | .send({ status: "success", message: "获取成功", data: list });
29 | } catch (err) {
30 | logger.error("Error getting project list:", err);
31 | res.status(500).send({ status: "error", message: "获取项目列表时出错" });
32 | }
33 | });
34 |
35 | // Get public lists for a user
36 | router.get("/userid/:id/public", async (req, res) => {
37 | try {
38 | const list = await getUserListInfoPublic(req.params.id, res.locals.userid);
39 | res
40 | .status(200)
41 | .send({ status: "success", message: "获取成功", data: list });
42 | } catch (err) {
43 | logger.error("Error getting public user list info:", err);
44 | res
45 | .status(500)
46 | .send({ status: "error", message: "获取公共用户列表信息时出错" });
47 | }
48 | });
49 |
50 | // Get current user's lists
51 | router.get("/my", async (req, res) => {
52 | try {
53 | const list = await getUserListInfo(res.locals.userid);
54 | res
55 | .status(200)
56 | .send({ status: "success", message: "获取成功", data: list });
57 | } catch (err) {
58 | logger.error("Error getting my list info:", err);
59 | res
60 | .status(500)
61 | .send({ status: "error", message: "获取我的列表信息时出错" });
62 | }
63 | });
64 |
65 | // Check if a project is in any of the user's lists
66 | router.get("/check", async (req, res) => {
67 | try {
68 | const { projectid } = req.query;
69 |
70 | if (!projectid) {
71 | return res
72 | .status(400)
73 | .send({ status: "error", message: "项目ID不能为空" });
74 | }
75 |
76 | const result = await getUserListInfoAndCheak(res.locals.userid, projectid);
77 | res
78 | .status(200)
79 | .send({ status: "success", message: "获取成功", data: result });
80 | } catch (err) {
81 | logger.error("Error checking user list info:", err);
82 | res
83 | .status(500)
84 | .send({ status: "error", message: "检查用户列表信息时出错" });
85 | }
86 | });
87 |
88 | // Create a new list
89 | router.post("/create", needLogin, async (req, res) => {
90 | try {
91 | const { title, description } = req.body;
92 |
93 | if (!title) {
94 | return res.status(400).send({ status: "error", message: "标题不能为空" });
95 | }
96 |
97 | const list = await createList(res.locals.userid, title, description);
98 | res
99 | .status(200)
100 | .send({ status: "success", message: "创建成功", data: list });
101 | } catch (err) {
102 | logger.error("Error creating list:", err);
103 | res.status(500).send({ status: "error", message: "创建列表时出错" });
104 | }
105 | });
106 |
107 | // Delete a list
108 | router.post("/delete", needLogin, async (req, res) => {
109 | try {
110 | const { id } = req.body;
111 |
112 | if (!id) {
113 | return res
114 | .status(400)
115 | .send({ status: "error", message: "列表ID不能为空" });
116 | }
117 |
118 | const list = await deleteList(res.locals.userid, id);
119 | res
120 | .status(200)
121 | .send({ status: "success", message: "删除成功", data: list });
122 | } catch (err) {
123 | logger.error("Error deleting list:", err);
124 | res.status(500).send({ status: "error", message: "删除列表时出错" });
125 | }
126 | });
127 |
128 | // Add a project to a list
129 | router.post("/add", needLogin, async (req, res) => {
130 | try {
131 | const { listid, projectid } = req.body;
132 |
133 | if (!listid || !projectid) {
134 | return res
135 | .status(400)
136 | .send({ status: "error", message: "列表ID和项目ID不能为空" });
137 | }
138 |
139 | const list = await addProjectToList(res.locals.userid, listid, projectid);
140 | res
141 | .status(200)
142 | .send({ status: "success", message: "添加成功", data: list });
143 | } catch (err) {
144 | logger.error("Error adding project to list:", err);
145 | res.status(500).send({ status: "error", message: "添加项目到列表时出错" });
146 | }
147 | });
148 |
149 | // Remove a project from a list
150 | router.post("/remove", needLogin, async (req, res) => {
151 | try {
152 | const { listid, projectid } = req.body;
153 |
154 | if (!listid || !projectid) {
155 | return res
156 | .status(400)
157 | .send({ status: "error", message: "列表ID和项目ID不能为空" });
158 | }
159 |
160 | const list = await removeProjectFromList(
161 | res.locals.userid,
162 | listid,
163 | projectid
164 | );
165 | res
166 | .status(200)
167 | .send({ status: "success", message: "删除成功", data: list });
168 | } catch (err) {
169 | logger.error("Error removing project from list:", err);
170 | res
171 | .status(500)
172 | .send({ status: "error", message: "从列表中删除项目时出错" });
173 | }
174 | });
175 |
176 | // Update list details
177 | router.post("/update/:id", needLogin, async (req, res) => {
178 | try {
179 | const list = await updateList(res.locals.userid, req.params.id, req.body);
180 | res
181 | .status(200)
182 | .send({ status: "success", message: "修改成功", data: list });
183 | } catch (err) {
184 | logger.error("Error updating list:", err);
185 | res.status(500).send({ status: "error", message: "修改列表时出错" });
186 | }
187 | });
188 |
189 | export default router;
190 |
--------------------------------------------------------------------------------
/routes/router_my.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js";
3 | import zcconfig from "../services/config/zcconfig.js";
4 | import fs from "fs";
5 |
6 | //个人中心
7 | import { Router } from "express";
8 | var router = Router();
9 | import { createReadStream } from "fs";
10 | import { createHash } from "crypto";
11 | //功能函数集
12 | import { S3update, checkhash, hash, prisma } from "../services/global.js";
13 | //数据库
14 | import geetestMiddleware from "../middleware/geetest.js";
15 | import multer from "multer";
16 | import { createEvent } from "../controllers/events.js";
17 |
18 | const upload = multer({ dest: "./usercontent" });
19 |
20 | // Migrated to use the global parseToken middleware
21 |
22 | router.post("/set/avatar", upload.single("zcfile"), async (req, res) => {
23 | if (!req.file) {
24 | return res
25 | .status(400)
26 | .send({ status: "error", message: "No file uploaded" });
27 | }
28 |
29 | try {
30 | const file = req.file;
31 | const hash = createHash("md5");
32 | const chunks = createReadStream(file.path);
33 |
34 | chunks.on("data", (chunk) => {
35 | if (chunk) hash.update(chunk);
36 | });
37 |
38 | chunks.on("end", async () => {
39 | const hashValue = hash.digest("hex");
40 | const fileBuffer = await fs.promises.readFile(file.path);
41 | await S3update(`user/${hashValue}`, fileBuffer);
42 | await prisma.ow_users.update({
43 | where: { id: res.locals.userid },
44 | data: { images: hashValue },
45 | });
46 | res.status(200).send({ status: "success", message: "头像上传成功" });
47 | });
48 |
49 | chunks.on("error", (err) => {
50 | logger.error("Error processing file upload:", err);
51 | res.status(500).send({ status: "error", message: "图片上传失败" });
52 | });
53 | } catch (err) {
54 | logger.error("Unexpected error:", err);
55 | res.status(500).send({ status: "error", message: "图片上传失败" });
56 | }
57 | });
58 |
59 | router.use((err, req, res, next) => {
60 | if (err.code === "LIMIT_UNEXPECTED_FILE") {
61 | logger.error("Unexpected end of form: ", err);
62 | res.status(400).send({ status: "error", message: "数据传输异常" });
63 | } else {
64 | next(err);
65 | }
66 | });
67 |
68 | //修改个人信息
69 | router.post("/set/userinfo", async (req, res) => {
70 | try {
71 | await prisma.ow_users.update({
72 | where: { id: res.locals.userid },
73 | data: {
74 | display_name: req.body["display_name"],
75 | motto: req.body["aboutme"],
76 | sex: req.body["sex"],
77 | birthday: new Date(`2000-01-01 00:00:00`),
78 | },
79 | });
80 |
81 | // 添加个人资料更新事件
82 | await createEvent(
83 | "user_profile_update",
84 | res.locals.userid,
85 | "user",
86 | res.locals.userid,
87 | {
88 | event_type: "user_profile_update",
89 | actor_id: res.locals.userid,
90 | target_type: "user",
91 | target_id: res.locals.userid,
92 | update_type: "profile_update",
93 | updated_fields: ["display_name", "motto", "sex", "birthday"],
94 | old_value: null,
95 | new_value: JSON.stringify({
96 | display_name: req.body["display_name"],
97 | motto: req.body["aboutme"],
98 | sex: req.body["sex"]
99 | })
100 | }
101 | );
102 |
103 | res.status(200).send({ status: "success", message: "个人信息修改成功" });
104 | } catch (error) {
105 | logger.error("Error updating user info:", error);
106 | res.status(500).send({ status: "error", message: "修改个人信息失败" });
107 | }
108 | });
109 |
110 | //修改用户名
111 | router.post("/set/username", async (req, res) => {
112 | await prisma.ow_users.update({
113 | where: { id: res.locals.userid },
114 | data: {
115 | username: req.body.username,
116 | },
117 | });
118 | res.locals.username = req.body.username;
119 |
120 | res.status(200).send({ status: "success", message: "用户名修成成功" });
121 | });
122 |
123 | //修改密码:动作
124 | router.post("/set/pw", async (req, res) => {
125 | const USER = await prisma.ow_users.findUnique({
126 | where: { id: res.locals.userid },
127 | });
128 | if (!USER) {
129 | return res.status(200).send({ status: "错误", message: "用户不存在" });
130 | }
131 | if (checkhash(req.body["oldpw"], USER.password) == false) {
132 | return res.status(200).send({ status: "错误", message: "旧密码错误" });
133 | }
134 | const newPW = hash(req.body["newpw"]);
135 | await prisma.ow_users.update({
136 | where: { id: res.locals.userid },
137 | data: { password: newPW },
138 | });
139 | res.status(200).send({ status: "success", message: "密码修改成功" });
140 | });
141 |
142 | export default router;
143 |
--------------------------------------------------------------------------------
/routes/router_projectlist.js:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js";
3 | import starsRouter from "./router_stars.js";
4 | import listsRouter from "./router_lists.js";
5 |
6 | const router = Router();
7 |
8 | // Mount the star and list routers
9 | router.use("/stars", starsRouter);
10 | router.use("/lists", listsRouter);
11 |
12 | export default router;
13 |
--------------------------------------------------------------------------------
/routes/router_search.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js";
3 | import zcconfig from "../services/config/zcconfig.js";
4 |
5 | import { Router } from "express";
6 | const router = Router();
7 | import { prisma } from "../services/global.js"; // 功能函数集
8 |
9 | // 搜索:Scratch项目列表:数据(只搜索标题)
10 | router.get("/", async (req, res, next) => {
11 | try {
12 | const {
13 | search_userid: userid,
14 | search_type: type,
15 | search_title: title,
16 | search_source: source,
17 | search_description: description,
18 | search_orderby: orderbyQuery = "time_down",
19 | search_tag: tags,
20 | curr = 1,
21 | limit = 10,
22 | search_state: stateQuery = "",
23 | } = req.query;
24 |
25 | const isCurrentUser =
26 | userid && res.locals.userid && userid == res.locals.userid;
27 | let state =
28 | stateQuery == ""
29 | ? isCurrentUser
30 | ? ["private", "public"]
31 | : ["public"]
32 | : stateQuery == "private"
33 | ? isCurrentUser
34 | ? ["private"]
35 | : ["public"]
36 | : [stateQuery];
37 |
38 | // 处理排序
39 | const [orderbyField, orderDirection] = orderbyQuery.split("_");
40 | const orderbyMap = { view: "view_count", time: "time", id: "id",star:"star_count" };
41 | const orderDirectionMap = { up: "asc", down: "desc" }; // 修正排序方向
42 | const orderBy = orderbyMap[orderbyField] || "time";
43 | const order = orderDirectionMap[orderDirection] || "desc";
44 |
45 | // 构建基本搜索条件
46 | const searchinfo = {
47 | title: title ? { contains: title } : undefined,
48 | source: source ? { contains: source } : undefined,
49 | description: description ? { contains: description } : undefined,
50 | type: type ? { contains: type } : undefined,
51 | state: state ? { in: state } : undefined,
52 | authorid: userid ? { equals: Number(userid) } : undefined,
53 | tags: tags ? { contains: tags } : undefined,
54 | };
55 |
56 | // 查询项目总数
57 | const totalCount = await prisma.ow_projects.count({
58 | where: searchinfo,
59 | });
60 |
61 | // 查询项目结果
62 | const projectresult = await prisma.ow_projects.findMany({
63 | where: searchinfo,
64 | orderBy: { [orderBy]: order },
65 | select: { id: true },
66 | skip: (Number(curr) - 1) * Number(limit),
67 | take: Number(limit),
68 | });
69 |
70 | res.status(200).send({
71 | projects: projectresult.map((item) => item.id),
72 | totalCount: totalCount,
73 | });
74 | } catch (error) {
75 | next(error);
76 | }
77 | });
78 |
79 | export default router;
80 |
--------------------------------------------------------------------------------
/routes/router_stars.js:
--------------------------------------------------------------------------------
1 | import logger from "../services/logger.js";
2 | import { Router } from "express";
3 | import { needLogin } from "../middleware/auth.js";
4 | import { createEvent } from "../controllers/events.js";
5 | import {
6 | starProject,
7 | unstarProject,
8 | getProjectStarStatus,
9 | getProjectStars,
10 | } from "../controllers/stars.js";
11 |
12 | const router = Router();
13 |
14 | /**
15 | * Star a project
16 | * @route POST /star
17 | * @access Private
18 | */
19 | router.post("/star", needLogin, async (req, res) => {
20 | try {
21 | const projectId = parseInt(req.body.projectid);
22 |
23 | if (!projectId) {
24 | return res
25 | .status(400)
26 | .send({ status: "error", message: "项目ID不能为空" });
27 | }
28 |
29 | await starProject(res.locals.userid, projectId);
30 |
31 | // Add star event
32 | await createEvent(
33 | "project_star",
34 | res.locals.userid,
35 | "project",
36 | projectId,
37 | {
38 | event_type: "project_star",
39 | actor_id: res.locals.userid,
40 | target_type: "project",
41 | target_id: projectId,
42 | action: "star"
43 | }
44 | );
45 |
46 | res.status(200).send({ status: "success", message: "收藏成功", star: 1 });
47 | } catch (err) {
48 | logger.error("Error starring project:", err);
49 | res.status(500).send({ status: "error", message: "收藏项目时出错" });
50 | }
51 | });
52 |
53 | /**
54 | * Unstar a project
55 | * @route POST /unstar
56 | * @access Private
57 | */
58 | router.post("/unstar", needLogin, async (req, res) => {
59 | try {
60 | const projectId = parseInt(req.body.projectid);
61 |
62 | if (!projectId) {
63 | return res
64 | .status(400)
65 | .send({ status: "error", message: "项目ID不能为空" });
66 | }
67 |
68 | await unstarProject(res.locals.userid, projectId);
69 |
70 | res
71 | .status(200)
72 | .send({ status: "success", message: "取消收藏成功", star: 0 });
73 | } catch (err) {
74 | logger.error("Error unstarring project:", err);
75 | res.status(500).send({ status: "error", message: "取消收藏项目时出错" });
76 | }
77 | });
78 |
79 | /**
80 | * Check if a project is starred by the current user
81 | * @route GET /checkstar
82 | * @access Public
83 | */
84 | router.get("/checkstar", async (req, res) => {
85 | try {
86 | const projectId = parseInt(req.query.projectid);
87 |
88 | if (!projectId) {
89 | return res
90 | .status(400)
91 | .send({ status: "error", message: "项目ID不能为空" });
92 | }
93 |
94 | const status = await getProjectStarStatus(res.locals.userid, projectId);
95 | res.status(200).send({
96 | status: "success",
97 | message: "获取成功",
98 | star: status,
99 | });
100 | } catch (err) {
101 | logger.error("Error checking star status:", err);
102 | res.status(500).send({ status: "error", message: "检查收藏状态时出错" });
103 | }
104 | });
105 |
106 | /**
107 | * Get the number of stars for a project
108 | * @route GET /project/:id/stars
109 | * @access Public
110 | */
111 | router.get("/project/:id/stars", async (req, res) => {
112 | try {
113 | const projectId = parseInt(req.params.id);
114 |
115 | if (!projectId) {
116 | return res.status(400).send({ status: "error", message: "项目ID不能为空" });
117 | }
118 |
119 | const stars = await getProjectStars(projectId);
120 | res.status(200).send({
121 | status: "success",
122 | message: "获取成功",
123 | data: stars,
124 | });
125 | } catch (err) {
126 | logger.error("Error getting project stars:", err);
127 | res.status(500).send({ status: "error", message: "获取项目收藏数时出错" });
128 | }
129 | });
130 |
131 | export default router;
132 |
--------------------------------------------------------------------------------
/routes/router_timeline.js:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { prisma } from "../services/global.js";
3 | import logger from "../services/logger.js";
4 | import { EventConfig } from "../controllers/events.js";
5 | import { needLogin } from "../middleware/auth.js";
6 |
7 | const router = Router();
8 |
9 | // 新增一个函数来处理事件格式化
10 | async function formatEvents(events, actorMap) {
11 | return await Promise.all(
12 | events.map(async (event) => {
13 | try {
14 | const actor = actorMap.get(Number(event.actor_id));
15 | if (!actor) {
16 | logger.warn(
17 | `Actor not found for event ${event.id}, actor_id: ${event.actor_id}`
18 | );
19 | return null;
20 | }
21 |
22 | const eventConfig = EventConfig[event.event_type];
23 | if (!eventConfig) {
24 | logger.warn(`Event type config not found: ${event.event_type}`);
25 | return null;
26 | }
27 |
28 | const formattedEvent = {
29 | id: event.id.toString(),
30 | type: event.event_type,
31 | actor: {
32 | id: actor.id,
33 | username: actor.username,
34 | display_name: actor.display_name,
35 | },
36 | target: {
37 | type: event.target_type,
38 | id: Number(event.target_id),
39 | page: {},
40 | },
41 | created_at: event.created_at,
42 | event_data: event.event_data,
43 | public: event.public === 1,
44 | };
45 |
46 | // 对于评论类型的事件,添加额外的定位信息到 page 中
47 | if (event.event_type === "comment_create" && event.event_data) {
48 | formattedEvent.target.id =
49 | event.event_data.page_id || event.event_data.page.id;
50 | formattedEvent.target.type =
51 | event.event_data.page_type || event.event_data.page.type;
52 | formattedEvent.target.page = {
53 | page_type: event.event_data.page_type,
54 | page_id: event.event_data.page_id,
55 | parent_id: event.event_data.parent_id,
56 | reply_id: event.event_data.reply_id,
57 | };
58 | }
59 |
60 | return formattedEvent;
61 | } catch (error) {
62 | logger.error("Error formatting event:", {
63 | error,
64 | event_id: event.id,
65 | event_type: event.event_type,
66 | });
67 | return null;
68 | }
69 | })
70 | );
71 | }
72 |
73 | // 获取用户时间线
74 | router.get("/user/:userid", async (req, res) => {
75 | try {
76 | const { userid } = req.params;
77 | const { page = 1, limit = 20 } = req.query;
78 | const isOwner = res.locals.userid === Number(userid);
79 |
80 | logger.debug("Fetching timeline for user", {
81 | userid,
82 | isOwner,
83 | currentUser: res.locals.userid,
84 | });
85 |
86 | const where = {
87 | actor_id: Number(userid),
88 | ...(isOwner ? {} : { public: 1 }),
89 | };
90 |
91 | const events = await prisma.ow_events.findMany({
92 | where,
93 | orderBy: { created_at: "desc" },
94 | skip: (Number(page) - 1) * Number(limit),
95 | take: Number(limit),
96 | });
97 |
98 | const total = await prisma.ow_events.count({ where });
99 |
100 | const actorIds = [
101 | ...new Set(events.map((event) => Number(event.actor_id))),
102 | ];
103 | const actors = await prisma.ow_users.findMany({
104 | where: { id: { in: actorIds } },
105 | select: { id: true, username: true, display_name: true },
106 | });
107 |
108 | const actorMap = new Map(actors.map((actor) => [actor.id, actor]));
109 |
110 | // 使用新函数格式化事件
111 | const formattedEvents = await formatEvents(events, actorMap);
112 | const filteredEvents = formattedEvents.filter((e) => e !== null);
113 |
114 | res.status(200).send({
115 | status: "success",
116 | data: {
117 | events: filteredEvents,
118 | pagination: {
119 | current: Number(page),
120 | size: Number(limit),
121 | total,
122 | },
123 | },
124 | });
125 | } catch (error) {
126 | logger.error("Error fetching timeline:", error);
127 | res.status(500).send({
128 | status: "error",
129 | message: "获取时间线失败",
130 | details: error.message,
131 | });
132 | }
133 | });
134 |
135 | // 获取关注的用户的时间线(只显示公开事件)
136 | router.get("/following", needLogin, async (req, res) => {
137 | try {
138 | const { page = 1, limit = 20 } = req.query;
139 |
140 | const following = await prisma.ow_users_follows.findMany({
141 | where: { follower_id: res.locals.userid },
142 | });
143 |
144 | const followingIds = following.map((f) => f.following_id);
145 |
146 | const events = await prisma.ow_events.findMany({
147 | where: {
148 | actor_id: { in: followingIds.map((id) => BigInt(id)) },
149 | public: 1,
150 | },
151 | orderBy: { created_at: "desc" },
152 | skip: (Number(page) - 1) * Number(limit),
153 | take: Number(limit),
154 | });
155 |
156 | const actorIds = [
157 | ...new Set(events.map((event) => Number(event.actor_id))),
158 | ];
159 | const actors = await prisma.ow_users.findMany({
160 | where: { id: { in: actorIds } },
161 | select: { id: true, username: true, display_name: true },
162 | });
163 |
164 | const actorMap = new Map(actors.map((actor) => [actor.id, actor]));
165 |
166 | // 使用新函数格式化事件
167 | const formattedEvents = await formatEvents(events, actorMap);
168 |
169 | res.status(200).send({
170 | status: "success",
171 | data: {
172 | events: formattedEvents.filter((e) => e !== null),
173 | },
174 | });
175 | } catch (error) {
176 | logger.error("Error fetching following timeline:", error);
177 | res.status(500).send({
178 | status: "error",
179 | message: "获取关注时间线失败",
180 | });
181 | }
182 | });
183 |
184 | export default router;
185 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * ZeroCat Backend 服务器入口文件
5 | */
6 |
7 | import "dotenv/config";
8 | import logger from './services/logger.js';
9 | import { serverConfig } from './src/index.js';
10 | import { execSync } from 'child_process';
11 |
12 | /**
13 | * 运行Prisma迁移和生成
14 | */
15 | async function runPrismaMigrations() {
16 | // 在调试模式下跳过迁移
17 | if (process.env.NODE_ENV === 'development') {
18 | logger.info('调试模式:跳过Prisma迁移和生成');
19 | return;
20 | }
21 |
22 | try {
23 | logger.info('开始运行Prisma迁移...');
24 | execSync('npx prisma migrate deploy', { stdio: 'inherit' });
25 | logger.info('Prisma迁移完成');
26 |
27 | logger.info('开始生成Prisma客户端...');
28 | execSync('npx prisma generate', { stdio: 'inherit' });
29 | logger.info('Prisma客户端生成完成');
30 | } catch (error) {
31 | logger.error('Prisma迁移或生成失败:', error);
32 | throw error;
33 | }
34 | }
35 |
36 | /**
37 | * 应用主函数
38 | */
39 | async function main() {
40 | try {
41 | // 打印启动Banner
42 | printBanner();
43 |
44 | // 运行Prisma迁移和生成
45 | await runPrismaMigrations();
46 |
47 | // 启动HTTP服务器
48 | await serverConfig.start();
49 |
50 | // 设置进程事件处理
51 | setupProcessHandlers();
52 | } catch (error) {
53 | logger.error('应用启动失败:', error);
54 | process.exit(1);
55 | }
56 | }
57 |
58 | /**
59 | * 打印启动Banner
60 | */
61 | function printBanner() {
62 | const banner = `
63 | =============================================================
64 | ZeroCat Backend Server
65 |
66 | Version: ${process.env.npm_package_version || '1.0.0'}
67 | Environment: ${process.env.NODE_ENV}
68 | Node.js: ${process.version}
69 | =============================================================
70 | `;
71 | console.log(banner);
72 | }
73 |
74 | /**
75 | * 设置进程事件处理
76 | */
77 | function setupProcessHandlers() {
78 | // 处理SIGTERM信号
79 | process.on('SIGTERM', async () => {
80 | logger.info('接收到SIGTERM信号,开始关闭...');
81 | await gracefulShutdown();
82 | });
83 |
84 | // 处理SIGINT信号
85 | process.on('SIGINT', async () => {
86 | logger.info('接收到SIGINT信号,开始关闭...');
87 | await gracefulShutdown();
88 | });
89 | }
90 |
91 | /**
92 | * 优雅关闭应用
93 | */
94 | async function gracefulShutdown() {
95 | try {
96 | logger.info('开始关闭...');
97 |
98 | // 等待15秒后强制退出
99 | const forceExitTimeout = setTimeout(() => {
100 | logger.error('关闭超时,强制退出');
101 | process.exit(1);
102 | }, 15000);
103 |
104 | // 关闭服务器
105 | await serverConfig.stop();
106 |
107 | // 取消强制退出定时器
108 | clearTimeout(forceExitTimeout);
109 |
110 | logger.info('应用已安全关闭');
111 | process.exit(0);
112 | } catch (error) {
113 | logger.error('关闭过程中出错:', error);
114 | process.exit(1);
115 | }
116 | }
117 |
118 | // 运行应用
119 | main().catch(error => {
120 | logger.error('应用运行失败:', error);
121 | process.exit(1);
122 | });
--------------------------------------------------------------------------------
/services/auth/magiclink.js:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 | import jsonwebtoken from 'jsonwebtoken';
3 | import zcconfig from '../config/zcconfig.js';
4 | import redisClient from '../redis.js';
5 | import logger from '../logger.js';
6 | import { sendEmail } from '../email/emailService.js';
7 | import { checkRateLimit, VerificationType } from './verification.js';
8 | import { createJWT } from './tokenUtils.js';
9 |
10 | // 生成魔术链接
11 | export async function generateMagicLinkForLogin(userId, email, options = {}) {
12 | try {
13 | // 默认10分钟过期
14 | const expiresIn = options.expiresIn || 600;
15 |
16 | // 客户端ID用于区分不同客户端的魔术链接
17 | const clientId = options.clientId || crypto.randomBytes(16).toString('hex');
18 |
19 | // 使用统一的JWT创建函数
20 | const token = await createJWT({
21 | id: userId,
22 | email,
23 | type: 'magic_link',
24 | clientId
25 | }, expiresIn);
26 |
27 | // 存储到Redis
28 | const redisKey = `magic_link:${token}`;
29 | await redisClient.set(redisKey, {
30 | userId,
31 | email,
32 | clientId,
33 | used: false,
34 | createdAt: Date.now()
35 | }, expiresIn);
36 |
37 | // 生成链接
38 | const frontendUrl = await zcconfig.get('urls.frontend');
39 | const magicLink = `${frontendUrl}/app/account/magiclink/validate?token=${token}${options.redirect ? `&redirect=${encodeURIComponent(options.redirect)}` : ''}`;
40 |
41 | return {
42 | success: true,
43 | token,
44 | magicLink,
45 | expiresIn
46 | };
47 | } catch (error) {
48 | logger.error('生成魔术链接失败:', error);
49 | return {
50 | success: false,
51 | message: '生成魔术链接失败'
52 | };
53 | }
54 | }
55 |
56 | // 发送魔术链接邮件
57 | export async function sendMagicLinkEmail(email, magicLink, options = {}) {
58 | try {
59 | const templateType = options.templateType || 'login';
60 | let subject, content;
61 |
62 | switch (templateType) {
63 | case 'register':
64 | subject = '完成您的账户注册';
65 | content = `
66 | 完成您的账户注册
67 | 您好,感谢您注册我们的服务!
68 | 请点击以下链接完成账户设置:
69 | 完成注册
70 | 或者您可以复制以下链接到浏览器地址栏:
71 | ${magicLink}
72 | 此链接将在10分钟内有效。
73 | 如果这不是您的操作,请忽略此邮件。
74 | `;
75 | break;
76 |
77 | case 'password_reset':
78 | subject = '重置您的密码';
79 | content = `
80 | 密码重置请求
81 | 您好,我们收到了重置您密码的请求。
82 | 请点击以下链接设置新密码:
83 | 重置密码
84 | 或者您可以复制以下链接到浏览器地址栏:
85 | ${magicLink}
86 | 此链接将在10分钟内有效。
87 | 如果这不是您的操作,请忽略此邮件并考虑修改您的密码。
88 | `;
89 | break;
90 |
91 | default: // login
92 | subject = '魔术链接登录';
93 | content = `
94 | 魔术链接登录请求
95 | 您好,您请求了使用魔术链接登录。
96 | 请点击以下链接登录:
97 | 登录
98 | 或者您可以复制以下链接到浏览器地址栏:
99 | ${magicLink}
100 | 此链接将在10分钟内有效。
101 | 如果这不是您的操作,请忽略此邮件并考虑修改您的密码。
102 | `;
103 | break;
104 | }
105 |
106 | await sendEmail(email, subject, content);
107 |
108 | return {
109 | success: true
110 | };
111 | } catch (error) {
112 | logger.error('发送魔术链接邮件失败:', error);
113 | return {
114 | success: false,
115 | message: '发送魔术链接邮件失败'
116 | };
117 | }
118 | }
119 |
120 | // 验证魔术链接
121 | export async function validateMagicLinkAndLogin(token) {
122 | try {
123 | // 检查Redis中的状态
124 | const redisKey = `magic_link:${token}`;
125 | const magicLinkData = await redisClient.get(redisKey);
126 |
127 | if (!magicLinkData) {
128 | return {
129 | success: false,
130 | message: '魔术链接不存在或已过期'
131 | };
132 | }
133 |
134 | if (magicLinkData.used) {
135 | return {
136 | success: false,
137 | message: '此魔术链接已被使用'
138 | };
139 | }
140 |
141 | // 验证JWT
142 | const jwtSecret = await zcconfig.get('security.jwttoken');
143 | let decoded;
144 |
145 | try {
146 | decoded = jsonwebtoken.verify(token, jwtSecret);
147 | } catch (err) {
148 | return {
149 | success: false,
150 | message: '魔术链接已过期或无效'
151 | };
152 | }
153 |
154 | return {
155 | success: true,
156 | userId: decoded.id,
157 | email: decoded.email,
158 | clientId: decoded.clientId,
159 | data: magicLinkData
160 | };
161 | } catch (error) {
162 | logger.error('验证魔术链接失败:', error);
163 | return {
164 | success: false,
165 | message: '验证魔术链接失败'
166 | };
167 | }
168 | }
169 |
170 | // 标记魔术链接为已使用
171 | export async function markMagicLinkAsUsed(token) {
172 | try {
173 | const redisKey = `magic_link:${token}`;
174 | const magicLinkData = await redisClient.get(redisKey);
175 |
176 | if (!magicLinkData) {
177 | return {
178 | success: false,
179 | message: '魔术链接不存在或已过期'
180 | };
181 | }
182 |
183 | if (magicLinkData.used) {
184 | return {
185 | success: false,
186 | message: '此魔术链接已被使用'
187 | };
188 | }
189 |
190 | // 标记为已使用
191 | magicLinkData.used = true;
192 | magicLinkData.usedAt = Date.now();
193 |
194 | // 更新Redis,保持原过期时间
195 | const ttl = await redisClient.ttl(redisKey);
196 | if (ttl > 0) {
197 | await redisClient.set(redisKey, magicLinkData, ttl);
198 | }
199 |
200 | return {
201 | success: true
202 | };
203 | } catch (error) {
204 | logger.error('标记魔术链接为已使用失败:', error);
205 | return {
206 | success: false,
207 | message: '标记魔术链接为已使用失败'
208 | };
209 | }
210 | }
211 |
212 | // 检查魔术链接速率限制
213 | export async function checkMagicLinkRateLimit(email) {
214 | return checkRateLimit(email, VerificationType.LOGIN);
215 | }
216 |
217 | // 向后兼容
218 | export async function generateMagicLink(userId, email, options = {}) {
219 | logger.warn('generateMagicLink is deprecated, use generateMagicLinkForLogin instead');
220 | return await generateMagicLinkForLogin(userId, email, options);
221 | }
222 |
223 | // 向后兼容
224 | export async function validateMagicLink(token) {
225 | logger.warn('validateMagicLink is deprecated, use validateMagicLinkAndLogin instead');
226 | return await validateMagicLinkAndLogin(token);
227 | }
--------------------------------------------------------------------------------
/services/auth/permissionManager.js:
--------------------------------------------------------------------------------
1 | import { prisma } from "../global.js";
2 |
3 | export async function hasProjectPermission(projectId, userId, permission) {
4 | const project = await prisma.ow_projects.findFirst({
5 | where: { id: Number(projectId) },
6 | });
7 |
8 | if (!project) {
9 | return false;
10 | }
11 |
12 | if (permission === "read") {
13 | if (project.state === "public" || project.authorid === userId) {
14 | return true;
15 | }
16 | } else if (permission === "write") {
17 | if (project.authorid === userId) {
18 | return true;
19 | }
20 | }
21 |
22 | return false;
23 | }
24 |
--------------------------------------------------------------------------------
/services/auth/tokenManager.js:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 | import zcconfig from "../config/zcconfig.js";
3 | import { createTypedJWT } from "./tokenUtils.js";
4 | import logger from "../logger.js";
5 | export async function generateFileAccessToken(sha256, userid) {
6 | return createTypedJWT("file", {
7 | action: "read",
8 | issuer: await zcconfig.get("site.domain"),
9 | sha256: sha256,
10 | userid: userid,
11 | }, 5 * 60); // 5分钟
12 | }
13 |
14 | export async function verifyFileAccessToken(token, userid) {
15 | const decoded = jwt.verify(token, await zcconfig.get("security.jwttoken"));
16 | if (!decoded) {
17 | throw new Error("Invalid token");
18 | }
19 | const { sha256, action, userid: tokenUserid } = decoded.data;
20 | const type = decoded.type;
21 | if (type !== "file" || action !== "read" || (tokenUserid !== userid && tokenUserid !== 0)) {
22 |
23 |
24 | throw new Error("Invalid token");
25 | }
26 | return sha256;
27 | }
28 |
--------------------------------------------------------------------------------
/services/email/emailService.js:
--------------------------------------------------------------------------------
1 | import { createTransport } from "nodemailer";
2 | import zcconfig from "../config/zcconfig.js";
3 | import logger from "../logger.js";
4 |
5 | let transporter;
6 |
7 | const getMailConfig = async () => {
8 | const enabled = await zcconfig.get("mail.enabled");
9 | if (!enabled) {
10 | return null;
11 | }
12 |
13 | const host = await zcconfig.get("mail.host");
14 | const port = await zcconfig.get("mail.port");
15 | const secure = await zcconfig.get("mail.secure");
16 | const user = await zcconfig.get("mail.auth.user");
17 | const pass = await zcconfig.get("mail.auth.pass");
18 | const fromName = await zcconfig.get("mail.from_name");
19 | const fromAddress = await zcconfig.get("mail.from_address");
20 |
21 | if (!host || !port || !user || !pass) {
22 | logger.error("Missing required mail configuration");
23 | return null;
24 | }
25 |
26 | const config = {
27 | host,
28 | port,
29 | secure,
30 | auth: {
31 | user,
32 | pass,
33 | }
34 | };
35 |
36 | return {
37 | config,
38 | from: fromName ? `${fromName} <${fromAddress}>` : fromAddress
39 | };
40 | };
41 |
42 | const initializeTransporter = async () => {
43 | try {
44 | const mailConfig = await getMailConfig();
45 | if (!mailConfig) {
46 | logger.info("Email service is disabled or not properly configured");
47 | return false;
48 | }
49 |
50 | logger.debug("Initializing email transporter with config:", mailConfig.config);
51 | transporter = createTransport(mailConfig.config);
52 |
53 | // Test the connection
54 | await transporter.verify();
55 | logger.info("Email service initialized successfully");
56 | return true;
57 | } catch (error) {
58 | logger.error("Failed to initialize email service:", error);
59 | return false;
60 | }
61 | };
62 |
63 | const sendEmail = async (to, subject, html) => {
64 | try {
65 | if (!transporter) {
66 | const initialized = await initializeTransporter();
67 | if (!initialized) {
68 | throw new Error("Email service is not available or not properly configured");
69 | }
70 | }
71 |
72 | const mailConfig = await getMailConfig();
73 | if (!mailConfig) {
74 | throw new Error("Email service is disabled or not properly configured");
75 | }
76 |
77 | await transporter.sendMail({
78 | from: mailConfig.from,
79 | to: to,
80 | subject: subject,
81 | html: html,
82 | });
83 |
84 | return true;
85 | } catch (error) {
86 | logger.error("Error sending email:", error);
87 | throw error;
88 | }
89 | };
90 |
91 | // Initialize email service when the module is loaded
92 | initializeTransporter().catch(error => {
93 | logger.error("Failed to initialize email service on module load:", error);
94 | });
95 |
96 | export { sendEmail };
--------------------------------------------------------------------------------
/services/errorHandler.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview 全局错误处理服务
3 | * 提供统一的错误处理机制,包括未捕获异常、Express错误等
4 | */
5 | import logger from './logger.js';
6 |
7 | /**
8 | * 错误处理服务类
9 | */
10 | class ErrorHandlerService {
11 | /**
12 | * 创建Express错误处理中间件
13 | * @returns {Function} Express错误处理中间件
14 | */
15 | createExpressErrorHandler() {
16 | return (err, req, res, next) => {
17 | // 记录错误
18 | this.logError(err, req);
19 |
20 | // 获取错误状态码,默认500
21 | const statusCode = err.status || err.statusCode || 500;
22 |
23 | // 判断是否为生产环境
24 | const isProd = process.env.NODE_ENV === 'production';
25 |
26 | // 构造错误响应
27 | const errorResponse = {
28 | status: 'error',
29 | code: err.code || 'server_error',
30 | message: err.message || '服务器内部错误'
31 | };
32 |
33 | // 在非生产环境下,添加详细错误信息
34 | if (!isProd) {
35 | errorResponse.stack = err.stack;
36 | errorResponse.details = err.details || null;
37 | }
38 |
39 | // 发送错误响应
40 | res.status(statusCode).json(errorResponse);
41 | };
42 | }
43 |
44 | /**
45 | * 注册全局未捕获异常处理器
46 | */
47 | registerGlobalHandlers() {
48 | // 处理未捕获的Promise异常
49 | process.on('unhandledRejection', (reason, promise) => {
50 | logger.error('未捕获的Promise异常:', reason);
51 | });
52 |
53 | // 处理未捕获的同步异常
54 | process.on('uncaughtException', (error) => {
55 | logger.error('未捕获的异常:', error);
56 |
57 | // 如果是严重错误,可能需要优雅退出
58 | if (this.isFatalError(error)) {
59 | logger.error('检测到严重错误,应用将在1秒后退出');
60 |
61 | // 延迟退出,给日志写入时间
62 | setTimeout(() => {
63 | process.exit(1);
64 | }, 1000);
65 | }
66 | });
67 |
68 | logger.info('全局错误处理器已注册');
69 | }
70 |
71 | /**
72 | * 判断是否为致命错误
73 | * @param {Error} error - 错误对象
74 | * @returns {boolean} 是否为致命错误
75 | */
76 | isFatalError(error) {
77 | // 这些类型的错误通常表明程序状态已不可靠
78 | const fatalErrorTypes = [
79 | 'EvalError',
80 | 'RangeError',
81 | 'ReferenceError',
82 | 'SyntaxError',
83 | 'URIError'
84 | ];
85 |
86 | // 一些系统错误也可能是致命的
87 | const fatalSystemErrors = [
88 | 'EADDRINUSE', // 端口被占用
89 | 'ECONNREFUSED', // 连接被拒绝
90 | 'EACCES', // 权限拒绝
91 | 'ENOENT', // 找不到文件
92 | 'ESOCKETTIMEDOUT' // 套接字超时
93 | ];
94 |
95 | return (
96 | fatalErrorTypes.includes(error.name) ||
97 | (error.code && fatalSystemErrors.includes(error.code))
98 | );
99 | }
100 |
101 | /**
102 | * 记录错误信息
103 | * @param {Error} error - 错误对象
104 | * @param {Object} req - Express请求对象
105 | */
106 | logError(error, req = null) {
107 | // 构建基本错误信息
108 | const errorInfo = {
109 | message: error.message,
110 | stack: error.stack,
111 | name: error.name,
112 | code: error.code
113 | };
114 |
115 | // 如果有请求对象,添加请求信息
116 | if (req) {
117 | errorInfo.request = {
118 | method: req.method,
119 | url: req.originalUrl || req.url,
120 | headers: this.sanitizeHeaders(req.headers),
121 | ip: req.ip || req.connection.remoteAddress
122 | };
123 | }
124 |
125 | // 记录详细错误日志
126 | logger.error('应用错误:', errorInfo);
127 | }
128 |
129 | /**
130 | * 清理请求头中的敏感信息
131 | * @param {Object} headers - 请求头对象
132 | * @returns {Object} 清理后的请求头
133 | */
134 | sanitizeHeaders(headers) {
135 | const sanitized = { ...headers };
136 |
137 | // 移除敏感信息
138 | const sensitiveHeaders = [
139 | 'authorization',
140 | 'cookie',
141 | 'set-cookie',
142 | 'x-api-key'
143 | ];
144 |
145 | sensitiveHeaders.forEach(header => {
146 | if (sanitized[header]) {
147 | sanitized[header] = '[REDACTED]';
148 | }
149 | });
150 |
151 | return sanitized;
152 | }
153 | }
154 |
155 | // 创建单例
156 | const errorHandlerService = new ErrorHandlerService();
157 |
158 | export default errorHandlerService;
--------------------------------------------------------------------------------
/services/global.js:
--------------------------------------------------------------------------------
1 | import zcconfig from "./config/zcconfig.js";
2 | import logger from "./logger.js";
3 | import crypto from "crypto";
4 | import jwt from "jsonwebtoken";
5 | import { PasswordHash } from "phpass";
6 | import fs from "fs";
7 |
8 | //prisma client
9 | import { PrismaClient } from "@prisma/client";
10 |
11 | const prisma = new PrismaClient()
12 |
13 |
14 | import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
15 |
16 | const pwdHash = new PasswordHash();
17 | const s3config = {
18 | endpoint: await zcconfig.get("s3.endpoint"),
19 | region: await zcconfig.get("s3.region"),
20 | credentials: {
21 | accessKeyId: await zcconfig.get("s3.AWS_ACCESS_KEY_ID"),
22 | secretAccessKey: await zcconfig.get("s3.AWS_SECRET_ACCESS_KEY"),
23 | },
24 | };
25 | logger.debug(s3config);
26 |
27 | const s3 = new S3Client(s3config);
28 |
29 | async function S3update(name, fileContent) {
30 | try {
31 | const command = new PutObjectCommand({
32 | Bucket: await zcconfig.get("s3.bucket"),
33 | Key: name,
34 | Body: fileContent,
35 | });
36 |
37 | const data = await s3.send(command);
38 | logger.debug(data);
39 | logger.debug(
40 | `成功上传了文件 ${await zcconfig.get("s3.bucket")}/${name}`
41 | );
42 | } catch (err) {
43 | logger.error("S3 update Error:", err);
44 | }
45 | }
46 |
47 | async function S3updateFromPath(name, path) {
48 | try {
49 | const fileContent = fs.readFileSync(path);
50 | await S3update(name, fileContent);
51 | } catch (err) {
52 | logger.error("S3 update Error:", err);
53 | }
54 | }
55 |
56 | function md5(data) {
57 | return crypto.createHash("md5").update(data).digest("base64");
58 | }
59 |
60 | function hash(data) {
61 | return pwdHash.hashPassword(data);
62 | }
63 |
64 | function checkhash(pwd, storeHash) {
65 | return pwdHash.checkPassword(pwd, storeHash);
66 | }
67 |
68 | function userpwTest(pw) {
69 | return /^(?:\d+|[a-zA-Z]+|[!@#$%^&*]+){6,16}$/.test(pw);
70 | }
71 |
72 | function emailTest(email) {
73 | return /^([a-zA-Z]|[0-9])(\w|\-)+@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/.test(email);
74 | }
75 |
76 |
77 | function randomPassword(len = 12) {
78 | const chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";
79 | const maxPos = chars.length;
80 | const password = Array.from({ length: len - 4 }, () =>
81 | chars.charAt(Math.floor(Math.random() * maxPos))
82 | ).join("");
83 | return `${password}@Aa1`;
84 | }
85 |
86 | async function generateJwt(json) {
87 | try {
88 | const secret = await zcconfig.get("security.jwttoken");
89 | logger.debug(secret);
90 | if (!secret) {
91 | throw new Error("JWT secret is not defined in the configuration");
92 | }
93 | return jwt.sign(json, secret);
94 | } catch (error) {
95 | logger.error("Error generating JWT:", error);
96 | throw error;
97 | }
98 | }
99 |
100 | function isJSON(str) {
101 | if (typeof str !== "string") return false;
102 | try {
103 | const obj = JSON.parse(str);
104 | return obj && typeof obj === "object";
105 | } catch (e) {
106 | logger.error("error:", str, e);
107 | return false;
108 | }
109 | }
110 |
111 | export {
112 | prisma,
113 | S3updateFromPath,
114 | S3update,
115 | md5,
116 | hash,
117 | checkhash,
118 | userpwTest,
119 | emailTest,
120 | randomPassword,
121 | generateJwt,
122 | isJSON,
123 | };
124 |
125 |
--------------------------------------------------------------------------------
/services/ip/ipLocation.js:
--------------------------------------------------------------------------------
1 | import logger from "../logger.js";
2 | import fs from "fs";
3 | import path from "path";
4 | import { fileURLToPath } from "url";
5 | import { Reader } from "@maxmind/geoip2-node";
6 | import zcconfig from "../config/zcconfig.js";
7 | import downloadMaxmindDb from "./downloadMaxmindDb.js";
8 | // 固定的数据库文件路径
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
10 | const DB_FILE = path.resolve(__dirname, "../../data/GeoLite2-City.mmdb");
11 |
12 | // 配置参数
13 | const CONFIG = {
14 | enabled: false, // 是否启用MaxMind
15 | };
16 |
17 | // 存储Reader实例
18 | let geoipReader = null;
19 | const defaultResponse = {
20 | address: "未知",
21 | most_specific_country_or_region: "未知",
22 | location: {
23 | accuracyRadius: -1,
24 | latitude: 0,
25 | longitude: 0,
26 | metroCode: -1,
27 | timeZone: "未知",
28 | },
29 | };
30 | // 从数据库加载配置
31 | const loadConfigFromDB = async () => {
32 | try {
33 | const enabled = await zcconfig.get("maxmind.enabled");
34 | if (enabled !== null) {
35 | CONFIG.enabled = enabled === "true" || enabled === "1";
36 | }
37 | logger.debug("已从数据库加载MaxMind配置", CONFIG);
38 | await initMaxMind();
39 | return CONFIG;
40 | } catch (error) {
41 | logger.error("从数据库加载MaxMind配置失败:", error);
42 | }
43 | };
44 |
45 | // 初始化MaxMind数据库
46 | const initMaxMind = async () => {
47 | if (geoipReader) {
48 | geoipReader = null;
49 | }
50 |
51 | if (!CONFIG.enabled) {
52 | logger.debug("MaxMind GeoIP未启用,跳过初始化");
53 | return;
54 | }
55 |
56 | try {
57 | await downloadMaxmindDb.loadMaxmind();
58 |
59 | // 加载数据库
60 | const dbBuffer = fs.readFileSync(DB_FILE);
61 | geoipReader = Reader.openBuffer(dbBuffer);
62 | logger.info("MaxMind GeoIP数据库加载成功");
63 | } catch (error) {
64 | logger.error("初始化MaxMind GeoIP数据库失败:", error);
65 | geoipReader = null;
66 | }
67 | };
68 |
69 | /**
70 | * 获取IP地址的地理位置信息
71 | * @param {string} ipAddress - 需要定位的IP地址
72 | * @returns {Object} 地理位置信息
73 | {
74 | * address: "未知",
75 | * most_specific_country_or_region: "未知",
76 | * location: {
77 | * accuracyRadius: -1,
78 | * latitude: 0,
79 | * longitude: 0,
80 | * metroCode: -1,
81 | * timeZone: "未知",
82 | * },
83 | * }
84 | */
85 | const getIPLocation = async (ipAddress) => {
86 | if (!ipAddress) {
87 | logger.warn("IP地址为空");
88 | return defaultResponse;
89 | }
90 |
91 | if (CONFIG.enabled && geoipReader) {
92 | try {
93 | const response = geoipReader.city("128.101.101.101");
94 | if (!response) {
95 | logger.debug(`MaxMind查询IP(${ipAddress})位置失败: 返回空响应`);
96 | return defaultResponse;
97 | }
98 |
99 | return {
100 | address: `${
101 | response.city?.names?.["zh-CN"] || response.city?.names?.en || ""
102 | } ${
103 | response.subdivisions?.[0]?.names?.["zh-CN"] ||
104 | response.subdivisions?.[0]?.names?.en ||
105 | ""
106 | } ${
107 | response.country?.names?.["zh-CN"] ||
108 | response.country?.names?.en ||
109 | "未知"
110 | }(${response.country?.isoCode || ""}) ${
111 | response.continent?.names?.["zh-CN"] ||
112 | response.continent?.names?.en ||
113 | ""
114 | }`,
115 | most_specific_country_or_region:
116 | response.city?.names?.["zh-CN"] ||
117 | // response.city?.names?.en ||
118 | response.subdivisions?.[0]?.names?.["zh-CN"] ||
119 | // response.subdivisions?.[0]?.names?.en ||
120 | response.country?.names?.["zh-CN"] ||
121 | // response.country?.names?.en ||
122 | response.continent?.names?.["zh-CN"] ||
123 | // response.continent?.names?.en ||
124 | response.registeredCountry?.names?.["zh-CN"] ||
125 | response.registeredCountry?.names?.en ||
126 | "未知",
127 |
128 | location: response.location,
129 | //response: response,
130 | };
131 | } catch (error) {
132 | logger.debug(`MaxMind查询IP(${ipAddress})位置失败: ${error.message}`);
133 | }
134 | }
135 |
136 | return defaultResponse;
137 | };
138 |
139 | // 导出模块
140 | export default {
141 | getIPLocation,
142 | loadConfigFromDB,
143 | };
144 |
--------------------------------------------------------------------------------
/services/logger.js:
--------------------------------------------------------------------------------
1 | import { createLogger, format, transports } from "winston";
2 | const { combine, timestamp, printf, errors, colorize } = format;
3 | import DailyRotateFile from "winston-daily-rotate-file";
4 | import { join } from "path";
5 |
6 | // 获取环境变量中的日志级别和日志目录
7 | const logLevel = process.env.LOG_LEVEL || "info";
8 | const logDirectory = process.env.LOG_DIR || "logs";
9 |
10 | // 使用单例模式,确保只有一个logger实例
11 | let loggerInstance = null;
12 |
13 | // 自定义日志格式化方式
14 | const logFormat = printf(({ level, message, timestamp, stack }) => {
15 | // 确保 message 是一个字符串类型,如果是对象,则使用 JSON.stringify()
16 | let logMessage = `${timestamp} ${level.padEnd(7)}: ${typeof message === 'object' ? JSON.stringify(message) : message}`;
17 |
18 | // 如果存在 stack(通常是错误对象的堆栈),确保它是字符串
19 | if (stack) {
20 | logMessage += `\n${typeof stack === 'object' ? JSON.stringify(stack) : stack}`;
21 | }
22 |
23 | return logMessage;
24 | });
25 |
26 | // 创建logger单例
27 | const createLoggerInstance = () => {
28 | if (loggerInstance) {
29 | return loggerInstance;
30 | }
31 |
32 | // 确定控制台日志级别 - 开发环境使用debug,生产环境使用配置的级别
33 | const consoleLogLevel = process.env.NODE_ENV === "development" ? "debug" : logLevel;
34 |
35 | loggerInstance = createLogger({
36 | level: logLevel,
37 | format: combine(
38 | timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // 自定义时间格式
39 | errors({ stack: true }), // 捕获错误堆栈信息
40 | logFormat // 自定义日志格式
41 | ),
42 | transports: [
43 | // 控制台输出 - 根据环境配置级别
44 | new transports.Console({
45 | level: consoleLogLevel,
46 | format: combine(
47 | colorize(), // 控制台输出颜色
48 | logFormat // 输出格式
49 | ),
50 | }),
51 |
52 | // 错误日志文件:每天生成一个错误日志文件
53 | new DailyRotateFile({
54 | level: "error",
55 | filename: join(logDirectory, "error-%DATE%.log"),
56 | datePattern: "YYYY-MM-DD",
57 | zippedArchive: true,
58 | maxSize: "20m",
59 | maxFiles: "14d",
60 | }),
61 |
62 | // 综合日志文件:记录所有日志
63 | new DailyRotateFile({
64 | level: logLevel,
65 | filename: join(logDirectory, "combined-%DATE%.log"),
66 | datePattern: "YYYY-MM-DD",
67 | zippedArchive: true,
68 | maxSize: "20m",
69 | maxFiles: "14d",
70 | }),
71 | ],
72 | });
73 |
74 | return loggerInstance;
75 | };
76 |
77 | // 导出logger单例
78 | export default createLoggerInstance();
79 |
--------------------------------------------------------------------------------
/services/memoryCache.js:
--------------------------------------------------------------------------------
1 | class MemoryCache {
2 | constructor() {
3 | this.cache = new Map();
4 | }
5 |
6 | get(key) {
7 | const item = this.cache.get(key);
8 | if (item) {
9 | if (item.expiry && item.expiry < Date.now()) {
10 | this.cache.delete(key);
11 | return null;
12 | }
13 | return item.value;
14 | }
15 | return null;
16 | }
17 |
18 | set(key, value, ttlSeconds) {
19 | const expiry = ttlSeconds ? Date.now() + (ttlSeconds * 1000) : null;
20 | this.cache.set(key, { value, expiry });
21 | }
22 |
23 | delete(key) {
24 | this.cache.delete(key);
25 | }
26 |
27 | // 清理过期的缓存项
28 | cleanup() {
29 | const now = Date.now();
30 | for (const [key, item] of this.cache.entries()) {
31 | if (item.expiry && item.expiry < now) {
32 | this.cache.delete(key);
33 | }
34 | }
35 | }
36 | }
37 |
38 | // 创建单例实例
39 | const memoryCache = new MemoryCache();
40 |
41 | // 每小时清理一次过期的缓存项
42 | setInterval(() => {
43 | memoryCache.cleanup();
44 | }, 3600000);
45 |
46 | export default memoryCache;
--------------------------------------------------------------------------------
/services/redis.js:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis';
2 | import logger from './logger.js';
3 | import zcconfig from './config/zcconfig.js';
4 |
5 | class RedisService {
6 | constructor() {
7 | this.client = null;
8 | this.isConnected = false;
9 | this.initConnection();
10 | }
11 |
12 | async initConnection() {
13 | try {
14 | const host = await zcconfig.get('redis.host')||'localhost';
15 | logger.debug(host);
16 | const port = await zcconfig.get('redis.port')||6379;
17 | logger.debug(port);
18 | const password = await zcconfig.get('redis.password')||'';
19 | logger.debug(password);
20 | const db = 0;
21 |
22 | const options = {
23 | host,
24 | port,
25 | db: parseInt(db),
26 | retryStrategy: (times) => {
27 | const delay = Math.min(times * 50, 2000);
28 | return delay;
29 | }
30 | };
31 |
32 | if (password) {
33 | options.password = password;
34 | }
35 |
36 | this.client = new Redis(options);
37 |
38 | this.client.on('connect', () => {
39 | this.isConnected = true;
40 | logger.info('Redis连接成功');
41 | });
42 |
43 | this.client.on('error', (err) => {
44 | this.isConnected = false;
45 | logger.error('Redis连接错误:', err);
46 | });
47 |
48 | this.client.on('reconnecting', () => {
49 | logger.info('正在重新连接Redis...');
50 | });
51 | } catch (error) {
52 | logger.error('初始化Redis连接失败:', error);
53 | }
54 | }
55 |
56 | // 设置键值,支持过期时间(秒)
57 | async set(key, value, ttlSeconds = null) {
58 | try {
59 | if (!this.client || !this.isConnected) {
60 | throw new Error('Redis未连接');
61 | }
62 |
63 | if (typeof value !== 'string') {
64 | value = JSON.stringify(value);
65 | }
66 |
67 | if (ttlSeconds) {
68 | await this.client.setex(key, ttlSeconds, value);
69 | } else {
70 | await this.client.set(key, value);
71 | }
72 | return true;
73 | } catch (error) {
74 | logger.error(`Redis set错误 [${key}]:`, error);
75 | return false;
76 | }
77 | }
78 |
79 | // 获取键值
80 | async get(key) {
81 | try {
82 | if (!this.client || !this.isConnected) {
83 | throw new Error('Redis未连接');
84 | }
85 |
86 | const value = await this.client.get(key);
87 | if (!value) return null;
88 |
89 | try {
90 | return JSON.parse(value);
91 | } catch (e) {
92 | return value; // 如果不是JSON则返回原始值
93 | }
94 | } catch (error) {
95 | logger.error(`Redis get错误 [${key}]:`, error);
96 | return null;
97 | }
98 | }
99 |
100 | // 删除键
101 | async delete(key) {
102 | try {
103 | if (!this.client || !this.isConnected) {
104 | throw new Error('Redis未连接');
105 | }
106 |
107 | await this.client.del(key);
108 | return true;
109 | } catch (error) {
110 | logger.error(`Redis delete错误 [${key}]:`, error);
111 | return false;
112 | }
113 | }
114 |
115 | // 检查键是否存在
116 | async exists(key) {
117 | try {
118 | if (!this.client || !this.isConnected) {
119 | throw new Error('Redis未连接');
120 | }
121 |
122 | const exists = await this.client.exists(key);
123 | return exists === 1;
124 | } catch (error) {
125 | logger.error(`Redis exists错误 [${key}]:`, error);
126 | return false;
127 | }
128 | }
129 |
130 | // 设置键的过期时间
131 | async expire(key, ttlSeconds) {
132 | try {
133 | if (!this.client || !this.isConnected) {
134 | throw new Error('Redis未连接');
135 | }
136 |
137 | await this.client.expire(key, ttlSeconds);
138 | return true;
139 | } catch (error) {
140 | logger.error(`Redis expire错误 [${key}]:`, error);
141 | return false;
142 | }
143 | }
144 |
145 | // 获取键的过期时间
146 | async ttl(key) {
147 | try {
148 | if (!this.client || !this.isConnected) {
149 | throw new Error('Redis未连接');
150 | }
151 |
152 | return await this.client.ttl(key);
153 | } catch (error) {
154 | logger.error(`Redis ttl错误 [${key}]:`, error);
155 | return -2; // -2表示键不存在
156 | }
157 | }
158 |
159 | // 递增
160 | async incr(key) {
161 | try {
162 | if (!this.client || !this.isConnected) {
163 | throw new Error('Redis未连接');
164 | }
165 |
166 | return await this.client.incr(key);
167 | } catch (error) {
168 | logger.error(`Redis incr错误 [${key}]:`, error);
169 | return null;
170 | }
171 | }
172 |
173 | // 递增指定值
174 | async incrby(key, increment) {
175 | try {
176 | if (!this.client || !this.isConnected) {
177 | throw new Error('Redis未连接');
178 | }
179 |
180 | return await this.client.incrby(key, increment);
181 | } catch (error) {
182 | logger.error(`Redis incrby错误 [${key}]:`, error);
183 | return null;
184 | }
185 | }
186 | }
187 |
188 | // 创建单例实例
189 | const redisClient = new RedisService();
190 |
191 | export default redisClient;
--------------------------------------------------------------------------------
/services/scheduler.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview 定时任务调度服务
3 | * 负责管理和执行系统中的各种定时任务
4 | */
5 | import logger from './logger.js';
6 |
7 | // 存储所有注册的任务
8 | const tasks = new Map();
9 |
10 | // 存储任务执行句柄,用于停止任务
11 | const taskHandles = new Map();
12 |
13 | /**
14 | * 任务调度器服务
15 | */
16 | class SchedulerService {
17 | /**
18 | * 初始化调度器
19 | */
20 | initialize() {
21 | logger.info('正在初始化调度器服务...');
22 |
23 | // 注册默认任务
24 | this.registerDefaultTasks();
25 |
26 | // 启动所有任务
27 | this.startAllTasks();
28 |
29 | logger.info('调度器服务初始化完成');
30 |
31 | return this;
32 | }
33 |
34 | /**
35 | * 注册默认任务
36 | */
37 | registerDefaultTasks() {
38 | // 示例:注册一个每小时执行一次的清理任务
39 | this.registerTask('hourly-cleanup', {
40 | interval: 60 * 60 * 1000, // 1小时
41 | handler: async () => {
42 | try {
43 | logger.info('执行每小时清理任务');
44 | // 实际清理逻辑
45 | } catch (error) {
46 | logger.error('每小时清理任务失败:', error);
47 | }
48 | }
49 | });
50 |
51 | // 示例:注册一个每天执行一次的统计任务
52 | this.registerTask('daily-stats', {
53 | interval: 24 * 60 * 60 * 1000, // 24小时
54 | handler: async () => {
55 | try {
56 | logger.info('执行每日统计任务');
57 | // 实际统计逻辑
58 | } catch (error) {
59 | logger.error('每日统计任务失败:', error);
60 | }
61 | }
62 | });
63 | }
64 |
65 | /**
66 | * 注册一个新任务
67 | * @param {string} taskId - 任务ID
68 | * @param {Object} taskConfig - 任务配置
69 | * @param {number} taskConfig.interval - 任务执行间隔(毫秒)
70 | * @param {Function} taskConfig.handler - 任务处理函数
71 | * @param {boolean} [taskConfig.runImmediately=false] - 是否立即执行一次
72 | * @returns {boolean} 是否注册成功
73 | */
74 | registerTask(taskId, taskConfig) {
75 | if (tasks.has(taskId)) {
76 | logger.warn(`任务 ${taskId} 已经存在,请先移除`);
77 | return false;
78 | }
79 |
80 | tasks.set(taskId, taskConfig);
81 | logger.info(`任务 ${taskId} 注册成功`);
82 |
83 | // 如果需要立即启动
84 | if (taskConfig.runImmediately) {
85 | this.startTask(taskId);
86 | }
87 |
88 | return true;
89 | }
90 |
91 | /**
92 | * 移除一个任务
93 | * @param {string} taskId - 任务ID
94 | * @returns {boolean} 是否移除成功
95 | */
96 | removeTask(taskId) {
97 | if (!tasks.has(taskId)) {
98 | logger.warn(`任务 ${taskId} 不存在`);
99 | return false;
100 | }
101 |
102 | // 停止任务
103 | this.stopTask(taskId);
104 |
105 | // 从注册表中移除
106 | tasks.delete(taskId);
107 | logger.info(`任务 ${taskId} 已移除`);
108 |
109 | return true;
110 | }
111 |
112 | /**
113 | * 启动一个任务
114 | * @param {string} taskId - 任务ID
115 | * @returns {boolean} 是否启动成功
116 | */
117 | startTask(taskId) {
118 | if (!tasks.has(taskId)) {
119 | logger.warn(`任务 ${taskId} 不存在`);
120 | return false;
121 | }
122 |
123 | if (taskHandles.has(taskId)) {
124 | logger.warn(`任务 ${taskId} 已经在运行`);
125 | return false;
126 | }
127 |
128 | const task = tasks.get(taskId);
129 |
130 | // 如果需要立即执行一次
131 | if (task.runImmediately) {
132 | task.handler().catch(err => logger.error(`任务 ${taskId} 立即执行失败:`, err));
133 | }
134 |
135 | // 设置定时执行
136 | const handle = setInterval(() => {
137 | task.handler().catch(err => logger.error(`任务 ${taskId} 执行失败:`, err));
138 | }, task.interval);
139 |
140 | // 保存任务句柄
141 | taskHandles.set(taskId, handle);
142 | logger.info(`任务 ${taskId} 已启动,间隔 ${task.interval}ms`);
143 |
144 | return true;
145 | }
146 |
147 | /**
148 | * 停止一个任务
149 | * @param {string} taskId - 任务ID
150 | * @returns {boolean} 是否停止成功
151 | */
152 | stopTask(taskId) {
153 | if (!taskHandles.has(taskId)) {
154 | logger.warn(`任务 ${taskId} 未在运行`);
155 | return false;
156 | }
157 |
158 | // 清除定时器
159 | clearInterval(taskHandles.get(taskId));
160 |
161 | // 从运行表中移除
162 | taskHandles.delete(taskId);
163 | logger.info(`任务 ${taskId} 已停止`);
164 |
165 | return true;
166 | }
167 |
168 | /**
169 | * 启动所有注册的任务
170 | */
171 | startAllTasks() {
172 | logger.info('正在启动所有注册的任务...');
173 |
174 | for (const taskId of tasks.keys()) {
175 | this.startTask(taskId);
176 | }
177 |
178 | logger.info(`已启动 ${taskHandles.size} 个任务`);
179 | }
180 |
181 | /**
182 | * 停止所有运行中的任务
183 | */
184 | stopAllTasks() {
185 | logger.info('正在停止所有运行中的任务...');
186 |
187 | for (const taskId of taskHandles.keys()) {
188 | this.stopTask(taskId);
189 | }
190 |
191 | logger.info('所有任务已停止');
192 | }
193 | }
194 |
195 | // 创建单例实例
196 | const schedulerService = new SchedulerService();
197 |
198 | export default schedulerService;
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import express from "express";
3 | import path from "path";
4 | import { fileURLToPath } from "url";
5 | import logger from "../services/logger.js";
6 |
7 | // 导入配置模块
8 | import { configureMiddleware } from "./index.js";
9 | import { configureRoutes } from "./routes.js";
10 | import zcconfigInstance from "../services/config/zcconfig.js";
11 |
12 | // 导入服务
13 | import geoIpService from "../services/ip/ipLocation.js";
14 | import schedulerService from "../services/scheduler.js";
15 | import errorHandlerService from "../services/errorHandler.js";
16 |
17 | // 全局初始化标志,防止重复初始化
18 | global.appInitialized = global.appInitialized || false;
19 |
20 | /**
21 | * 应用程序主类
22 | */
23 | class Application {
24 | constructor() {
25 | this.app = express();
26 | this._initPromise = this.configureApp();
27 | }
28 |
29 | /**
30 | * 获取初始化完成的Promise
31 | * @returns {Promise} 初始化Promise
32 | */
33 | get initialized() {
34 | return this._initPromise;
35 | }
36 |
37 | /**
38 | * 配置应用程序
39 | */
40 | async configureApp() {
41 | try {
42 | logger.debug('开始配置应用程序...');
43 |
44 | // 初始化配置并设置为全局变量
45 | await zcconfigInstance.initialize();
46 | global.config = {};
47 |
48 | // 设置全局配置访问器
49 | Object.defineProperty(global, 'config', {
50 | get: () => {
51 | const configs = {};
52 | for (const [key, value] of zcconfigInstance.cache.entries()) {
53 | configs[key] = value;
54 | }
55 | return configs;
56 | },
57 | configurable: false,
58 | enumerable: true
59 | });
60 |
61 | // 设置全局公共配置访问器
62 | Object.defineProperty(global, 'publicconfig', {
63 | get: () => {
64 | return zcconfigInstance.getPublicConfigs();
65 | },
66 | configurable: false,
67 | enumerable: true
68 | });
69 |
70 | // 配置中间件
71 | await configureMiddleware(this.app);
72 | logger.debug('中间件配置完成');
73 | // 配置路由
74 | await configureRoutes(this.app);
75 | logger.debug('路由配置完成');
76 | // 添加全局错误处理中间件
77 | this.app.use(errorHandlerService.createExpressErrorHandler());
78 | logger.debug('全局错误处理中间件配置完成');
79 | // 设置未捕获异常处理
80 | this.setupExceptionHandling();
81 | logger.debug('未捕获异常处理配置完成');
82 | logger.info('应用程序配置完成');
83 | } catch (error) {
84 | logger.error('应用配置失败:', error);
85 | process.exit(1);
86 | }
87 | }
88 |
89 | /**
90 | * 设置全局异常处理
91 | */
92 | setupExceptionHandling() {
93 | // 使用错误处理服务注册全局处理器
94 | errorHandlerService.registerGlobalHandlers();
95 | }
96 |
97 | /**
98 | * 初始化服务
99 | */
100 | async initializeServices() {
101 | try {
102 | // 防止重复初始化服务
103 | if (global.appInitialized) {
104 | logger.debug('服务已经初始化过,跳过重复初始化');
105 | return;
106 | }
107 |
108 | logger.info('开始初始化服务...');
109 | //TODO 初始化MaxMind GeoIP服务
110 | // 初始化GeoIP服务
111 | await geoIpService.loadConfigFromDB().catch(error => {
112 | logger.error('初始化MaxMind GeoIP失败:', error);
113 | });
114 |
115 | // 初始化调度服务
116 | schedulerService.initialize();
117 |
118 | logger.info('所有服务初始化完成');
119 |
120 | // 标记应用已初始化
121 | global.appInitialized = true;
122 | } catch (error) {
123 | logger.error('服务初始化失败:', error);
124 | }
125 | }
126 |
127 | /**
128 | * 启动应用
129 | * @returns {express.Application} Express应用实例
130 | */
131 | getApp() {
132 | return this.app;
133 | }
134 | }
135 |
136 | // 创建应用实例
137 | const application = new Application();
138 |
139 | // 初始化服务
140 | Promise.all([
141 | application.initialized,
142 | application.initializeServices()
143 | ]).catch(error => {
144 | logger.error('初始化失败:', error);
145 | });
146 |
147 | // 导出Express应用实例
148 | export default application.getApp();
149 |
--------------------------------------------------------------------------------
/src/default_project.js:
--------------------------------------------------------------------------------
1 | const project = {
2 | scratch: "ddd5546735d2af86721d037443e4bb917040ae78f1778df6afb985957426dab7",
3 | python: "da7548a7a8cffc35d1ed73b39cf8b095b9be7bff30e74896b2288aebca20d10a",
4 | text: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
5 | };
6 | export default project;
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import paths from './paths.js';
2 | import defaultProject from './default_project.js';
3 | import { configureMiddleware } from './middleware.js';
4 | import serverConfig from './server.js';
5 |
6 | export {
7 | paths,
8 | defaultProject,
9 | configureMiddleware,
10 | serverConfig
11 | };
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import expressWinston from 'express-winston';
3 | import cors from 'cors';
4 | import bodyParser from 'body-parser';
5 | import compress from 'compression';
6 | import logger from '../services/logger.js';
7 | import zcconfig from '../services/config/zcconfig.js';
8 |
9 | /**
10 | * 配置Express应用的中间件
11 | * @param {express.Application} app Express应用实例
12 | */
13 | export async function configureMiddleware(app) {
14 | // 日志中间件 - 只记录HTTP请求,避免重复记录应用日志
15 | app.use(
16 | expressWinston.logger({
17 | winstonInstance: logger,
18 | meta: true,
19 | msg: "HTTP {{req.method}} {{res.statusCode}} {{res.responseTime}}ms {{req.url}} {{req.ip}}",
20 | colorize: false,
21 | ignoreRoute: (req, res) => false,
22 | level: "info",
23 | // 避免重复日志,只记录请求级别的元数据
24 | metaField: null, // 不要记录元数据的子对象
25 | expressFormat: false, // 不使用express默认格式避免重复
26 | dynamicMeta: (req, res) => {
27 | // 只记录必要的请求元数据,避免重复
28 | return {
29 | reqId: req.id,
30 | method: req.method,
31 | url: req.url
32 | };
33 | }
34 | })
35 | );
36 |
37 | // CORS配置
38 | const corslist = (await zcconfig.get("cors"));
39 | const corsOptionsDelegate = (origin, callback) => {
40 | if (!origin || corslist.includes(new URL(origin).hostname)) {
41 | return callback(null, true);
42 | } else {
43 | logger.error("CORS限制,请求来源:" + origin);
44 | return callback(new Error("CORS限制,请求来源可能存在风险"));
45 | }
46 | };
47 |
48 | app.use(
49 | cors({
50 | credentials: true,
51 | origin: (origin, callback) => corsOptionsDelegate(origin, callback),
52 | })
53 | );
54 |
55 | // 请求体解析
56 | app.use(bodyParser.urlencoded({ limit: "100mb", extended: false }));
57 | app.use(bodyParser.json({ limit: "100mb" }));
58 | app.use(bodyParser.text({ limit: "100mb" }));
59 | app.use(bodyParser.raw({ limit: "100mb" }));
60 |
61 | // 压缩中间件
62 | app.use(compress());
63 |
64 | // 认证中间件 - 使用动态导入避免循环依赖
65 | app.use(async (req, res, next) => {
66 | // 尝试从多种来源获取token:
67 | // 1. Authorization header (Bearer token)
68 | // 2. Query parameter 'token'
69 | // 3. Cookie 'token'
70 | let token = null;
71 |
72 | // 检查Authorization header
73 | const authHeader = req.headers["authorization"];
74 | if (authHeader) {
75 | // 支持"Bearer token"格式或直接提供token
76 | const parts = authHeader.split(" ");
77 | if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
78 | token = parts[1];
79 | } else {
80 | token = authHeader;
81 | }
82 | }
83 |
84 | // 如果header中没有token,检查query参数
85 | if (!token && req.query.token) {
86 | token = req.query.token;
87 | }
88 |
89 | // 如果query中没有token,检查cookies
90 | if (!token && req.cookies && req.cookies.token) {
91 | token = req.cookies.token;
92 | }
93 |
94 | if (!token) {
95 | // 没有令牌,继续处理请求但不设置用户信息
96 | return next();
97 | }
98 |
99 | try {
100 | // 动态导入auth工具,避免循环依赖
101 | const authModule = await import('../services/auth/auth.js');
102 | const authUtils = authModule.default;
103 |
104 | // 使用令牌验证系统,传递IP地址用于追踪
105 | const { valid, user, message } = await authUtils.verifyToken(token, req.ip);
106 |
107 | if (valid && user) {
108 | // 设置用户信息
109 | res.locals.userid = user.userid;
110 | res.locals.username = user.username;
111 | res.locals.display_name = user.display_name;
112 | res.locals.email = user.email;
113 | res.locals.tokenId = user.token_id;
114 | } else {
115 | logger.debug(`令牌验证失败: ${message}`);
116 | }
117 | } catch (err) {
118 | logger.error("解析令牌时出错:", err);
119 | }
120 |
121 | next();
122 | });
123 | }
--------------------------------------------------------------------------------
/src/paths.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { fileURLToPath } from 'url';
3 |
4 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
5 | const ROOT_DIR = path.resolve(__dirname, '..');
6 |
7 | export default {
8 | ROOT_DIR,
9 | DATA_DIR: path.resolve(ROOT_DIR, 'data'),
10 | VIEWS_DIR: path.resolve(ROOT_DIR, 'views'),
11 | PUBLIC_DIR: path.resolve(ROOT_DIR, 'public'),
12 | TOOLS_DIR: path.resolve(ROOT_DIR, 'tools'),
13 | };
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import logger from '../services/logger.js';
3 | import paths from './paths.js';
4 | import zcconfig from '../services/config/zcconfig.js';
5 |
6 |
7 | /**
8 | * 配置应用路由
9 | * @param {express.Application} app Express应用实例
10 | */
11 | export async function configureRoutes(app) {
12 | // 加载配置信息到全局
13 | await zcconfig.loadConfigsFromDB();
14 | logger.info('配置信息已加载到全局');
15 |
16 | // 设置视图目录和引擎
17 | app.set("env", process.cwd());
18 | app.set("data", paths.DATA_DIR);
19 | app.set("views", paths.VIEWS_DIR);
20 | app.set("view engine", "ejs");
21 |
22 | logger.debug(paths.VIEWS_DIR);
23 | // 首页路由
24 | app.get("/", (req, res) => {
25 | res.render("index");
26 | });
27 |
28 | // 健康检查路由
29 | app.get("/check", (req, res) => {
30 | res.status(200).json({
31 | message: "success",
32 | code: 200,
33 | });
34 | });
35 |
36 | // Scratch工具路由
37 | app.get("/scratchtool", (req, res) => {
38 | res.set("Content-Type", "application/javascript");
39 | res.render("scratchtool");
40 | });
41 |
42 | // 注册业务路由
43 | await registerBusinessRoutes(app);
44 |
45 | // 404路由处理
46 | app.all("/{*path}", (req, res) => {
47 | res.status(404).json({
48 | status: "error",
49 | code: "404",
50 | message: "找不到页面",
51 | });
52 | });
53 | }
54 |
55 | /**
56 | * 注册业务相关路由
57 | * @param {express.Application} app Express应用实例
58 | */
59 | async function registerBusinessRoutes(app) {
60 | try {
61 | // 新的标准化路由注册
62 | const accountModule = await import('../routes/router_account.js');
63 | app.use("/account", accountModule.default);
64 |
65 | const eventModule = await import('../routes/router_event.js');
66 | app.use("/events", eventModule.default);
67 | // app.use("/users", userRoutes);
68 |
69 | // 使用新的通知路由 (获取绝对路径版本)
70 | const notificationModule = await import('../routes/router_notifications.js');
71 | app.use("/notifications", notificationModule.default);
72 |
73 | // 以下路由暂时保持原有导入方式,等待迁移完成
74 |
75 | // 个人中心路由
76 | const myModule = await import('../routes/router_my.js');
77 | app.use("/my", myModule.default);
78 |
79 | // 搜索API路由
80 | const searchModule = await import('../routes/router_search.js');
81 | app.use("/searchapi", searchModule.default);
82 |
83 | // Scratch路由
84 | const scratchModule = await import('../routes/router_scratch.js');
85 | app.use("/scratch", scratchModule.default);
86 |
87 | // API路由
88 | const apiModule = await import('../routes/router_api.js');
89 | app.use("/api", apiModule.default);
90 |
91 | // 管理后台路由
92 | const adminModule = await import('../routes/router_admin.js');
93 | app.use("/admin", adminModule.default);
94 |
95 | // 项目列表路由
96 | const projectlistModule = await import('../routes/router_projectlist.js');
97 | app.use("/projectlist", projectlistModule.default);
98 |
99 | // 项目路由
100 | const projectModule = await import('../routes/router_project.js');
101 | app.use("/project", projectModule.default);
102 |
103 | // 评论路由
104 | const commentModule = await import('../routes/router_comment.js');
105 | app.use("/comment", commentModule.default);
106 |
107 | // 用户路由
108 | const userModule = await import('../routes/router_user.js');
109 | app.use("/user", userModule.default);
110 |
111 | // 时间线路由
112 | const timelineModule = await import('../routes/router_timeline.js');
113 | app.use("/timeline", timelineModule.default);
114 |
115 | // 关注路由
116 | const followsModule = await import('../routes/router_follows.js');
117 | app.use("/follows", followsModule.default);
118 |
119 | logger.info('所有业务路由注册成功');
120 | } catch (error) {
121 | logger.error('Error registering business routes:', error);
122 | throw error;
123 | }
124 | }
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import logger from '../services/logger.js';
2 | import http from 'http';
3 | import app from './app.js';
4 |
5 | /**
6 | * 服务器配置和启动类
7 | */
8 | class ServerConfig {
9 | constructor() {
10 | this.port = process.env.PORT || 3000;
11 | this.host = process.env.HOST || '0.0.0.0';
12 | this.server = null;
13 | }
14 |
15 | /**
16 | * 启动HTTP服务器
17 | * @returns {Promise} HTTP服务器实例
18 | */
19 | async start() {
20 | return new Promise((resolve, reject) => {
21 | try {
22 | // 创建HTTP服务器
23 | this.server = http.createServer(app);
24 |
25 | // 设置错误处理
26 | this.server.on('error', this.handleServerError);
27 |
28 | // 启动服务器
29 | this.server.listen(this.port, this.host, () => {
30 | logger.info(`服务器已启动,监听 http://${this.host}:${this.port}`);
31 | resolve(this.server);
32 | });
33 | } catch (error) {
34 | logger.error('启动服务器失败:', error);
35 | reject(error);
36 | }
37 | });
38 | }
39 |
40 | /**
41 | * 处理服务器错误
42 | * @param {Error} error 服务器错误
43 | */
44 | handleServerError(error) {
45 | if (error.code === 'EADDRINUSE') {
46 | logger.error(`端口 ${this.port} 已被占用,请尝试不同端口`);
47 | } else {
48 | logger.error('服务器错误:', error);
49 | }
50 |
51 | // 严重错误,退出进程
52 | process.exit(1);
53 | }
54 |
55 | /**
56 | * 关闭服务器
57 | * @returns {Promise}
58 | */
59 | async stop() {
60 | if (!this.server) {
61 | logger.warn('尝试关闭未启动的服务器');
62 | return;
63 | }
64 |
65 | return new Promise((resolve, reject) => {
66 | this.server.close((error) => {
67 | if (error) {
68 | logger.error('关闭服务器出错:', error);
69 | reject(error);
70 | } else {
71 | logger.info('服务器已优雅关闭');
72 | resolve();
73 | }
74 | });
75 | });
76 | }
77 | }
78 |
79 | // 导出配置类
80 | export default new ServerConfig();
--------------------------------------------------------------------------------
/usercontent/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/14a82aeede9e34fdbd7deb249ff54e837af131eb/usercontent/.gitkeep
--------------------------------------------------------------------------------
/views/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= global.config['site.name'] %>
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
22 | <% const config = global.publicconfig || {}; %>
23 | <% Object.entries(config).forEach(([key, value])=> { %>
24 |
26 | <% }); %>
27 |
28 | 打开网站
29 | 了解更多
30 | <% if (global.publicconfig['feedback.qq.group']) { %>
31 | href="<%= global.config['feedback.qq.link'] %>"<% } %> active
32 | rounded><%= global.config['feedback.qq.description'] %>
33 | <% } %>
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
44 |
--------------------------------------------------------------------------------