skill-base 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -0
- package/bin/skill-base.js +53 -0
- package/data/.gitkeep +0 -0
- package/package.json +36 -0
- package/src/database.js +119 -0
- package/src/index.js +88 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/admin.js +23 -0
- package/src/middleware/auth.js +96 -0
- package/src/middleware/error.js +23 -0
- package/src/models/.gitkeep +0 -0
- package/src/models/skill.js +57 -0
- package/src/models/user.js +130 -0
- package/src/models/version.js +57 -0
- package/src/routes/.gitkeep +0 -0
- package/src/routes/auth.js +173 -0
- package/src/routes/collaborators.js +260 -0
- package/src/routes/init.js +86 -0
- package/src/routes/publish.js +108 -0
- package/src/routes/skills.js +119 -0
- package/src/routes/users.js +169 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/crypto.js +35 -0
- package/src/utils/permission.js +45 -0
- package/src/utils/zip.js +35 -0
- package/static/admin/users.html +593 -0
- package/static/cli-code.html +203 -0
- package/static/css/.gitkeep +0 -0
- package/static/css/style.css +1567 -0
- package/static/diff.html +466 -0
- package/static/file.html +443 -0
- package/static/index.html +251 -0
- package/static/js/.gitkeep +0 -0
- package/static/js/admin/users.js +346 -0
- package/static/js/app.js +508 -0
- package/static/js/auth.js +151 -0
- package/static/js/cli-code.js +184 -0
- package/static/js/collaborators.js +283 -0
- package/static/js/diff.js +540 -0
- package/static/js/file.js +619 -0
- package/static/js/i18n.js +739 -0
- package/static/js/index.js +168 -0
- package/static/js/publish.js +718 -0
- package/static/js/settings.js +124 -0
- package/static/js/setup.js +157 -0
- package/static/js/skill.js +808 -0
- package/static/login.html +82 -0
- package/static/publish.html +459 -0
- package/static/settings.html +163 -0
- package/static/setup.html +101 -0
- package/static/skill.html +851 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const db = require('../database');
|
|
2
|
+
|
|
3
|
+
const UserModel = {
|
|
4
|
+
// 根据 ID 查询用户
|
|
5
|
+
findById(id) {
|
|
6
|
+
return db.prepare('SELECT id, username, name, role, status, created_at, updated_at FROM users WHERE id = ?').get(id);
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
// 根据用户名查询(含 password_hash,用于登录验证)
|
|
10
|
+
findByUsername(username) {
|
|
11
|
+
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
// 创建用户
|
|
15
|
+
create(username, passwordHash, role = 'developer') {
|
|
16
|
+
const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role);
|
|
17
|
+
return this.findById(result.lastInsertRowid);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// 列出用户(支持分页和搜索)
|
|
21
|
+
list({ q, status, page = 1, limit = 20 } = {}) {
|
|
22
|
+
let sql = 'SELECT id, username, name, role, status, created_at, updated_at FROM users WHERE 1=1';
|
|
23
|
+
let countSql = 'SELECT COUNT(*) as total FROM users WHERE 1=1';
|
|
24
|
+
const params = [];
|
|
25
|
+
const countParams = [];
|
|
26
|
+
|
|
27
|
+
if (q) {
|
|
28
|
+
sql += ' AND (username LIKE ? OR name LIKE ?)';
|
|
29
|
+
countSql += ' AND (username LIKE ? OR name LIKE ?)';
|
|
30
|
+
params.push(`%${q}%`, `%${q}%`);
|
|
31
|
+
countParams.push(`%${q}%`, `%${q}%`);
|
|
32
|
+
}
|
|
33
|
+
if (status) {
|
|
34
|
+
sql += ' AND status = ?';
|
|
35
|
+
countSql += ' AND status = ?';
|
|
36
|
+
params.push(status);
|
|
37
|
+
countParams.push(status);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const total = db.prepare(countSql).get(...countParams).total;
|
|
41
|
+
|
|
42
|
+
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
|
43
|
+
params.push(limit, (page - 1) * limit);
|
|
44
|
+
|
|
45
|
+
const users = db.prepare(sql).all(...params);
|
|
46
|
+
return { users, total, page, limit };
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// 更新用户名
|
|
50
|
+
updateUsername(id, username) {
|
|
51
|
+
const result = db.prepare(
|
|
52
|
+
"UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?"
|
|
53
|
+
).run(username, id);
|
|
54
|
+
return result.changes > 0;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// 更新密码
|
|
58
|
+
updatePassword(id, passwordHash) {
|
|
59
|
+
const result = db.prepare(
|
|
60
|
+
"UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?"
|
|
61
|
+
).run(passwordHash, id);
|
|
62
|
+
return result.changes > 0;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// 更新用户(管理员用)
|
|
66
|
+
update(id, fields) {
|
|
67
|
+
const allowed = ['role', 'status', 'username', 'name'];
|
|
68
|
+
const sets = [];
|
|
69
|
+
const params = [];
|
|
70
|
+
|
|
71
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
72
|
+
if (allowed.includes(key) && value !== undefined) {
|
|
73
|
+
sets.push(`${key} = ?`);
|
|
74
|
+
params.push(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sets.length === 0) return false;
|
|
79
|
+
|
|
80
|
+
sets.push("updated_at = datetime('now')");
|
|
81
|
+
params.push(id);
|
|
82
|
+
|
|
83
|
+
const result = db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
|
84
|
+
return result.changes > 0;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// 重置密码(管理员用)
|
|
88
|
+
resetPassword(id, passwordHash) {
|
|
89
|
+
const result = db.prepare(
|
|
90
|
+
"UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?"
|
|
91
|
+
).run(passwordHash, id);
|
|
92
|
+
return result.changes > 0;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// 查询用户详情(含创建者信息)
|
|
96
|
+
findByIdWithCreator(id) {
|
|
97
|
+
return db.prepare(`
|
|
98
|
+
SELECT u.id, u.username, u.name, u.role, u.status, u.created_at, u.updated_at,
|
|
99
|
+
c.id as creator_id, c.username as creator_username
|
|
100
|
+
FROM users u
|
|
101
|
+
LEFT JOIN users c ON u.created_by = c.id
|
|
102
|
+
WHERE u.id = ?
|
|
103
|
+
`).get(id);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// 更新用户名和姓名
|
|
107
|
+
updateProfile(id, { username, name }) {
|
|
108
|
+
const sets = [];
|
|
109
|
+
const params = [];
|
|
110
|
+
|
|
111
|
+
if (username !== undefined) {
|
|
112
|
+
sets.push('username = ?');
|
|
113
|
+
params.push(username);
|
|
114
|
+
}
|
|
115
|
+
if (name !== undefined) {
|
|
116
|
+
sets.push('name = ?');
|
|
117
|
+
params.push(name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sets.length === 0) return false;
|
|
121
|
+
|
|
122
|
+
sets.push("updated_at = datetime('now')");
|
|
123
|
+
params.push(id);
|
|
124
|
+
|
|
125
|
+
const result = db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
|
126
|
+
return result.changes > 0;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
module.exports = UserModel;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const db = require('../database');
|
|
2
|
+
|
|
3
|
+
const VersionModel = {
|
|
4
|
+
// 创建新版本
|
|
5
|
+
create(skillId, version, changelog, zipPath, uploaderId) {
|
|
6
|
+
const result = db.prepare(`
|
|
7
|
+
INSERT INTO skill_versions (skill_id, version, changelog, zip_path, uploader_id)
|
|
8
|
+
VALUES (?, ?, ?, ?, ?)
|
|
9
|
+
`).run(skillId, version, changelog || '', zipPath, uploaderId);
|
|
10
|
+
return this.findById(result.lastInsertRowid);
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
// 根据 ID 查询版本
|
|
14
|
+
findById(id) {
|
|
15
|
+
return db.prepare(`
|
|
16
|
+
SELECT sv.*, u.username as uploader_username, u.name as uploader_name
|
|
17
|
+
FROM skill_versions sv
|
|
18
|
+
LEFT JOIN users u ON sv.uploader_id = u.id
|
|
19
|
+
WHERE sv.id = ?
|
|
20
|
+
`).get(id);
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
// 根据 skill_id 和 version 查询
|
|
24
|
+
findByVersion(skillId, version) {
|
|
25
|
+
return db.prepare(`
|
|
26
|
+
SELECT sv.*, u.username as uploader_username, u.name as uploader_name
|
|
27
|
+
FROM skill_versions sv
|
|
28
|
+
LEFT JOIN users u ON sv.uploader_id = u.id
|
|
29
|
+
WHERE sv.skill_id = ? AND sv.version = ?
|
|
30
|
+
`).get(skillId, version);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// 列出某 Skill 的所有版本(按创建时间倒序)
|
|
34
|
+
listBySkillId(skillId) {
|
|
35
|
+
return db.prepare(`
|
|
36
|
+
SELECT sv.*, u.username as uploader_username, u.name as uploader_name
|
|
37
|
+
FROM skill_versions sv
|
|
38
|
+
LEFT JOIN users u ON sv.uploader_id = u.id
|
|
39
|
+
WHERE sv.skill_id = ?
|
|
40
|
+
ORDER BY sv.created_at DESC
|
|
41
|
+
`).all(skillId);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// 获取某 Skill 的最新版本
|
|
45
|
+
getLatest(skillId) {
|
|
46
|
+
return db.prepare(`
|
|
47
|
+
SELECT sv.*, u.username as uploader_username, u.name as uploader_name
|
|
48
|
+
FROM skill_versions sv
|
|
49
|
+
LEFT JOIN users u ON sv.uploader_id = u.id
|
|
50
|
+
WHERE sv.skill_id = ?
|
|
51
|
+
ORDER BY sv.created_at DESC
|
|
52
|
+
LIMIT 1
|
|
53
|
+
`).get(skillId);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = VersionModel;
|
|
File without changes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const db = require('../database');
|
|
2
|
+
const UserModel = require('../models/user');
|
|
3
|
+
const { verifyPassword, hashPassword, generateCliCode, generatePAT } = require('../utils/crypto');
|
|
4
|
+
|
|
5
|
+
async function authRoutes(fastify, options) {
|
|
6
|
+
// POST /login - 用户登录
|
|
7
|
+
fastify.post('/login', async (request, reply) => {
|
|
8
|
+
const { username, password } = request.body || {};
|
|
9
|
+
|
|
10
|
+
if (!username || !password) {
|
|
11
|
+
return reply.code(400).send({ detail: 'Username and password are required' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 查找用户
|
|
15
|
+
const user = UserModel.findByUsername(username);
|
|
16
|
+
if (!user) {
|
|
17
|
+
return reply.code(401).send({ detail: 'Invalid username or password' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check account status
|
|
21
|
+
if (user.status === 'disabled') {
|
|
22
|
+
return reply.code(401).send({
|
|
23
|
+
ok: false,
|
|
24
|
+
error: 'account_disabled',
|
|
25
|
+
detail: 'Account is disabled'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Verify password
|
|
30
|
+
if (!verifyPassword(password, user.password_hash)) {
|
|
31
|
+
return reply.code(401).send({ detail: 'Invalid username or password' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Create session and set cookie
|
|
35
|
+
const sessionId = fastify.createSession(user.id);
|
|
36
|
+
reply.setCookie('session_id', sessionId, { path: '/', httpOnly: true });
|
|
37
|
+
|
|
38
|
+
return { ok: true, user: { id: user.id, username: user.username, name: user.name || null, role: user.role } };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// POST /logout - 用户登出
|
|
42
|
+
fastify.post('/logout', async (request, reply) => {
|
|
43
|
+
const sessionId = request.cookies?.session_id;
|
|
44
|
+
if (sessionId) {
|
|
45
|
+
fastify.destroySession(sessionId);
|
|
46
|
+
}
|
|
47
|
+
reply.clearCookie('session_id', { path: '/' });
|
|
48
|
+
return { ok: true };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// POST /cli-code/generate - 生成 CLI 验证码
|
|
52
|
+
fastify.post('/cli-code/generate', {
|
|
53
|
+
preHandler: [fastify.authenticate]
|
|
54
|
+
}, async (request, reply) => {
|
|
55
|
+
const code = generateCliCode();
|
|
56
|
+
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
57
|
+
|
|
58
|
+
// 写入 cli_auth_codes 表
|
|
59
|
+
db.prepare(`
|
|
60
|
+
INSERT INTO cli_auth_codes (code, user_id, expires_at, used)
|
|
61
|
+
VALUES (?, ?, ?, FALSE)
|
|
62
|
+
`).run(code, request.user.id, expiresAt);
|
|
63
|
+
|
|
64
|
+
return { ok: true, code, expires_at: expiresAt };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// POST /cli-code/verify - 验证 CLI 验证码
|
|
68
|
+
fastify.post('/cli-code/verify', async (request, reply) => {
|
|
69
|
+
const { code } = request.body || {};
|
|
70
|
+
|
|
71
|
+
if (!code) {
|
|
72
|
+
return reply.code(400).send({ detail: 'Code is required' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 查找验证码:未使用且未过期
|
|
76
|
+
const codeRecord = db.prepare(`
|
|
77
|
+
SELECT * FROM cli_auth_codes
|
|
78
|
+
WHERE code = ? AND used = FALSE AND expires_at > datetime('now')
|
|
79
|
+
`).get(code);
|
|
80
|
+
|
|
81
|
+
if (!codeRecord) {
|
|
82
|
+
return reply.code(401).send({ detail: 'Invalid or expired code' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 标记为已使用
|
|
86
|
+
db.prepare('UPDATE cli_auth_codes SET used = TRUE WHERE code = ?').run(code);
|
|
87
|
+
|
|
88
|
+
// 生成 PAT
|
|
89
|
+
const token = generatePAT();
|
|
90
|
+
db.prepare(`
|
|
91
|
+
INSERT INTO personal_access_tokens (token, user_id, description)
|
|
92
|
+
VALUES (?, ?, ?)
|
|
93
|
+
`).run(token, codeRecord.user_id, 'CLI generated token');
|
|
94
|
+
|
|
95
|
+
// 获取用户信息
|
|
96
|
+
const user = UserModel.findById(codeRecord.user_id);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
token,
|
|
101
|
+
user: { id: user.id, username: user.username, name: user.name || null }
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// GET /me - 获取当前用户信息
|
|
106
|
+
fastify.get('/me', {
|
|
107
|
+
preHandler: [fastify.authenticate]
|
|
108
|
+
}, async (request, reply) => {
|
|
109
|
+
return request.user;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// PATCH /me - 更新个人信息(用户名和姓名)
|
|
113
|
+
fastify.patch('/me', {
|
|
114
|
+
preHandler: [fastify.authenticate]
|
|
115
|
+
}, async (request, reply) => {
|
|
116
|
+
const { username, name } = request.body || {};
|
|
117
|
+
|
|
118
|
+
// 至少需要提供一个字段
|
|
119
|
+
if (username === undefined && name === undefined) {
|
|
120
|
+
return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'At least one field must be provided' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 验证用户名
|
|
124
|
+
if (username !== undefined) {
|
|
125
|
+
if (typeof username !== 'string' || username.trim().length === 0) {
|
|
126
|
+
return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'Username cannot be empty' });
|
|
127
|
+
}
|
|
128
|
+
const trimmed = username.trim();
|
|
129
|
+
// 检查用户名是否已存在(排除自己)
|
|
130
|
+
const existing = UserModel.findByUsername(trimmed);
|
|
131
|
+
if (existing && existing.id !== request.user.id) {
|
|
132
|
+
return reply.code(400).send({ ok: false, error: 'username_exists', detail: 'Username already exists' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
UserModel.updateProfile(request.user.id, {
|
|
137
|
+
username: username ? username.trim() : undefined,
|
|
138
|
+
name: name !== undefined ? name : undefined
|
|
139
|
+
});
|
|
140
|
+
const updated = UserModel.findById(request.user.id);
|
|
141
|
+
|
|
142
|
+
return reply.send({ ok: true, user: updated });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// POST /me/change-password - 修改密码
|
|
146
|
+
fastify.post('/me/change-password', {
|
|
147
|
+
preHandler: [fastify.authenticate]
|
|
148
|
+
}, async (request, reply) => {
|
|
149
|
+
const { old_password, new_password } = request.body || {};
|
|
150
|
+
|
|
151
|
+
if (!old_password || !new_password) {
|
|
152
|
+
return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'Old password and new password are required' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (new_password.length < 6) {
|
|
156
|
+
return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'New password must be at least 6 characters' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 验证旧密码 - 需要获取含密码的用户信息
|
|
160
|
+
const user = UserModel.findByUsername(request.user.username);
|
|
161
|
+
|
|
162
|
+
if (!verifyPassword(old_password, user.password_hash)) {
|
|
163
|
+
return reply.code(401).send({ ok: false, error: 'wrong_password', detail: 'Incorrect old password' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const newHash = hashPassword(new_password);
|
|
167
|
+
UserModel.updatePassword(request.user.id, newHash);
|
|
168
|
+
|
|
169
|
+
return reply.send({ ok: true, message: 'Password changed successfully' });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = authRoutes;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const db = require('../database');
|
|
2
|
+
const { canManageSkill } = require('../utils/permission');
|
|
3
|
+
const UserModel = require('../models/user');
|
|
4
|
+
|
|
5
|
+
async function collaboratorsRoutes(fastify, options) {
|
|
6
|
+
|
|
7
|
+
// GET /:skill_id/collaborators - 获取协作者列表(公开)
|
|
8
|
+
fastify.get('/:skill_id/collaborators', async (request, reply) => {
|
|
9
|
+
const { skill_id } = request.params;
|
|
10
|
+
|
|
11
|
+
// 检查 Skill 是否存在
|
|
12
|
+
const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id);
|
|
13
|
+
if (!skill) {
|
|
14
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const collaborators = db.prepare(`
|
|
18
|
+
SELECT sc.id, sc.role, sc.created_at,
|
|
19
|
+
u.id as user_id, u.username, u.name, u.status,
|
|
20
|
+
cb.id as created_by_id, cb.username as created_by_username
|
|
21
|
+
FROM skill_collaborators sc
|
|
22
|
+
JOIN users u ON sc.user_id = u.id
|
|
23
|
+
LEFT JOIN users cb ON sc.created_by = cb.id
|
|
24
|
+
WHERE sc.skill_id = ?
|
|
25
|
+
ORDER BY CASE sc.role WHEN 'owner' THEN 0 ELSE 1 END, sc.created_at ASC
|
|
26
|
+
`).all(skill_id);
|
|
27
|
+
|
|
28
|
+
const result = collaborators.map(c => {
|
|
29
|
+
const item = {
|
|
30
|
+
id: c.id,
|
|
31
|
+
user: { id: c.user_id, username: c.username, name: c.name, status: c.status },
|
|
32
|
+
role: c.role,
|
|
33
|
+
created_at: c.created_at
|
|
34
|
+
};
|
|
35
|
+
if (c.created_by_id) {
|
|
36
|
+
item.created_by = { id: c.created_by_id, username: c.created_by_username };
|
|
37
|
+
}
|
|
38
|
+
return item;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return reply.send({ skill_id, collaborators: result });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// POST /:skill_id/collaborators - 添加协作者(owner/admin)
|
|
45
|
+
fastify.post('/:skill_id/collaborators', {
|
|
46
|
+
preHandler: [fastify.authenticate]
|
|
47
|
+
}, async (request, reply) => {
|
|
48
|
+
const { skill_id } = request.params;
|
|
49
|
+
const { user_id, username } = request.body || {};
|
|
50
|
+
|
|
51
|
+
// 检查 Skill 是否存在
|
|
52
|
+
const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id);
|
|
53
|
+
if (!skill) {
|
|
54
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 权限检查
|
|
58
|
+
if (!canManageSkill(request.user, skill_id)) {
|
|
59
|
+
return reply.code(403).send({ ok: false, error: 'forbidden', detail: 'Owner or admin permission required' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 查找目标用户
|
|
63
|
+
let targetUser;
|
|
64
|
+
if (user_id) {
|
|
65
|
+
targetUser = UserModel.findById(parseInt(user_id));
|
|
66
|
+
} else if (username) {
|
|
67
|
+
targetUser = UserModel.findByUsername(username.trim());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!targetUser) {
|
|
71
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'User not found' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 检查是否已是协作者
|
|
75
|
+
const existing = db.prepare(
|
|
76
|
+
'SELECT id FROM skill_collaborators WHERE skill_id = ? AND user_id = ?'
|
|
77
|
+
).get(skill_id, targetUser.id);
|
|
78
|
+
|
|
79
|
+
if (existing) {
|
|
80
|
+
return reply.code(400).send({ ok: false, error: 'already_collaborator', detail: 'User is already a collaborator' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 添加协作者
|
|
84
|
+
const result = db.prepare(
|
|
85
|
+
'INSERT INTO skill_collaborators (skill_id, user_id, role, created_by) VALUES (?, ?, ?, ?)'
|
|
86
|
+
).run(skill_id, targetUser.id, 'collaborator', request.user.id);
|
|
87
|
+
|
|
88
|
+
return reply.code(201).send({
|
|
89
|
+
ok: true,
|
|
90
|
+
collaborator: {
|
|
91
|
+
id: result.lastInsertRowid,
|
|
92
|
+
user: { id: targetUser.id, username: targetUser.username },
|
|
93
|
+
role: 'collaborator',
|
|
94
|
+
created_at: new Date().toISOString()
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// DELETE /:skill_id/collaborators/:user_id - 移除协作者(owner/admin)
|
|
100
|
+
fastify.delete('/:skill_id/collaborators/:user_id', {
|
|
101
|
+
preHandler: [fastify.authenticate]
|
|
102
|
+
}, async (request, reply) => {
|
|
103
|
+
const { skill_id, user_id } = request.params;
|
|
104
|
+
|
|
105
|
+
// 检查 Skill 是否存在
|
|
106
|
+
const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id);
|
|
107
|
+
if (!skill) {
|
|
108
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 权限检查
|
|
112
|
+
if (!canManageSkill(request.user, skill_id)) {
|
|
113
|
+
return reply.code(403).send({ ok: false, error: 'forbidden', detail: 'Owner or admin permission required' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 检查协作者记录
|
|
117
|
+
const collaborator = db.prepare(
|
|
118
|
+
'SELECT id, role FROM skill_collaborators WHERE skill_id = ? AND user_id = ?'
|
|
119
|
+
).get(skill_id, parseInt(user_id));
|
|
120
|
+
|
|
121
|
+
if (!collaborator) {
|
|
122
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Collaborator not found' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 不能移除所有者
|
|
126
|
+
if (collaborator.role === 'owner') {
|
|
127
|
+
return reply.code(400).send({ ok: false, error: 'cannot_remove_owner', detail: 'Cannot remove the owner' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
db.prepare('DELETE FROM skill_collaborators WHERE skill_id = ? AND user_id = ?')
|
|
131
|
+
.run(skill_id, parseInt(user_id));
|
|
132
|
+
|
|
133
|
+
return reply.send({ ok: true, message: 'Collaborator removed' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// POST /:skill_id/transfer-ownership - 转移所有权(owner/admin,事务)
|
|
137
|
+
fastify.post('/:skill_id/transfer-ownership', {
|
|
138
|
+
preHandler: [fastify.authenticate]
|
|
139
|
+
}, async (request, reply) => {
|
|
140
|
+
const { skill_id } = request.params;
|
|
141
|
+
const { new_owner_id } = request.body || {};
|
|
142
|
+
|
|
143
|
+
if (!new_owner_id) {
|
|
144
|
+
return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'New owner must be specified' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 检查 Skill 并获取当前所有者
|
|
148
|
+
const skill = db.prepare('SELECT id, owner_id FROM skills WHERE id = ?').get(skill_id);
|
|
149
|
+
if (!skill) {
|
|
150
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 权限检查
|
|
154
|
+
if (!canManageSkill(request.user, skill_id)) {
|
|
155
|
+
return reply.code(403).send({ ok: false, error: 'forbidden', detail: 'Owner or admin permission required' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const newOwnerId = parseInt(new_owner_id);
|
|
159
|
+
|
|
160
|
+
if (skill.owner_id === newOwnerId) {
|
|
161
|
+
return reply.code(400).send({ ok: false, error: 'same_owner', detail: 'New owner is the same as current owner' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 检查新所有者是否存在
|
|
165
|
+
const newOwner = UserModel.findById(newOwnerId);
|
|
166
|
+
if (!newOwner) {
|
|
167
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'User not found' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 事务操作
|
|
171
|
+
const transferTx = db.transaction(() => {
|
|
172
|
+
// 1. 更新 skills 表 owner_id
|
|
173
|
+
db.prepare('UPDATE skills SET owner_id = ?, updated_at = datetime("now") WHERE id = ?')
|
|
174
|
+
.run(newOwnerId, skill_id);
|
|
175
|
+
|
|
176
|
+
// 2. 原所有者降级为 collaborator
|
|
177
|
+
db.prepare('UPDATE skill_collaborators SET role = "collaborator" WHERE skill_id = ? AND user_id = ?')
|
|
178
|
+
.run(skill_id, skill.owner_id);
|
|
179
|
+
|
|
180
|
+
// 3. 新所有者升级为 owner(如不存在则新增)
|
|
181
|
+
const existing = db.prepare(
|
|
182
|
+
'SELECT id FROM skill_collaborators WHERE skill_id = ? AND user_id = ?'
|
|
183
|
+
).get(skill_id, newOwnerId);
|
|
184
|
+
|
|
185
|
+
if (existing) {
|
|
186
|
+
db.prepare('UPDATE skill_collaborators SET role = "owner" WHERE skill_id = ? AND user_id = ?')
|
|
187
|
+
.run(skill_id, newOwnerId);
|
|
188
|
+
} else {
|
|
189
|
+
db.prepare('INSERT INTO skill_collaborators (skill_id, user_id, role, created_by) VALUES (?, ?, "owner", ?)')
|
|
190
|
+
.run(skill_id, newOwnerId, request.user.id);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
transferTx();
|
|
195
|
+
|
|
196
|
+
return reply.send({
|
|
197
|
+
ok: true,
|
|
198
|
+
message: 'Ownership transferred',
|
|
199
|
+
new_owner: { id: newOwner.id, username: newOwner.username }
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// DELETE /:skill_id - 删除 Skill(owner/admin,需 confirm 参数,事务)
|
|
204
|
+
fastify.delete('/:skill_id', {
|
|
205
|
+
preHandler: [fastify.authenticate]
|
|
206
|
+
}, async (request, reply) => {
|
|
207
|
+
const { skill_id } = request.params;
|
|
208
|
+
const { confirm } = request.query;
|
|
209
|
+
|
|
210
|
+
// 确认参数校验
|
|
211
|
+
if (confirm !== skill_id) {
|
|
212
|
+
return reply.code(400).send({
|
|
213
|
+
ok: false,
|
|
214
|
+
error: 'confirm_required',
|
|
215
|
+
detail: 'Confirm parameter is required and must equal the Skill ID'
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 检查 Skill 是否存在
|
|
220
|
+
const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id);
|
|
221
|
+
if (!skill) {
|
|
222
|
+
return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 权限检查
|
|
226
|
+
if (!canManageSkill(request.user, skill_id)) {
|
|
227
|
+
return reply.code(403).send({ ok: false, error: 'forbidden', detail: 'Owner or admin permission required' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 获取版本数量(用于响应)
|
|
231
|
+
const versionsCount = db.prepare('SELECT COUNT(*) as count FROM skill_versions WHERE skill_id = ?')
|
|
232
|
+
.get(skill_id).count;
|
|
233
|
+
|
|
234
|
+
// 事务删除
|
|
235
|
+
const deleteSkillTx = db.transaction(() => {
|
|
236
|
+
db.prepare('DELETE FROM skill_versions WHERE skill_id = ?').run(skill_id);
|
|
237
|
+
db.prepare('DELETE FROM skill_collaborators WHERE skill_id = ?').run(skill_id);
|
|
238
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(skill_id);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
deleteSkillTx();
|
|
242
|
+
|
|
243
|
+
// 删除文件系统中的文件
|
|
244
|
+
const fs = require('fs');
|
|
245
|
+
const path = require('path');
|
|
246
|
+
const { getDataDir } = require('../utils/zip');
|
|
247
|
+
const skillDir = path.join(getDataDir(), skill_id);
|
|
248
|
+
if (fs.existsSync(skillDir)) {
|
|
249
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return reply.send({
|
|
253
|
+
ok: true,
|
|
254
|
+
message: 'Skill deleted',
|
|
255
|
+
deleted: { skill_id, versions_count: versionsCount }
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = collaboratorsRoutes;
|