@ian2018cs/agenthub 0.1.44 → 0.1.46

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.
@@ -247,6 +247,63 @@ const runMigrations = () => {
247
247
  )
248
248
  `);
249
249
 
250
+ // Migration: Add super_admin role (recreate users table with updated CHECK constraint)
251
+ const hasAnyUser = db.prepare('SELECT COUNT(*) as count FROM users').get().count > 0;
252
+ const hasSuperAdmin = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'super_admin'").get().count > 0;
253
+
254
+ if (hasAnyUser && !hasSuperAdmin) {
255
+ console.log('Running migration: Adding super_admin role and promoting first admin');
256
+ db.exec('PRAGMA foreign_keys = OFF');
257
+ db.transaction(() => {
258
+ db.exec(`CREATE TABLE users_new (
259
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
260
+ username TEXT UNIQUE,
261
+ password_hash TEXT,
262
+ email TEXT UNIQUE,
263
+ uuid TEXT,
264
+ role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user', 'super_admin')),
265
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
266
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
267
+ last_login DATETIME,
268
+ is_active BOOLEAN DEFAULT 1,
269
+ git_name TEXT,
270
+ git_email TEXT,
271
+ has_completed_onboarding BOOLEAN DEFAULT 0,
272
+ total_limit_usd REAL DEFAULT NULL,
273
+ daily_limit_usd REAL DEFAULT NULL
274
+ )`);
275
+ db.exec('INSERT INTO users_new SELECT id,username,password_hash,email,uuid,role,status,created_at,last_login,is_active,git_name,git_email,has_completed_onboarding,total_limit_usd,daily_limit_usd FROM users');
276
+ db.exec('DROP TABLE users');
277
+ db.exec('ALTER TABLE users_new RENAME TO users');
278
+ db.exec("UPDATE users SET role='super_admin' WHERE id=(SELECT MIN(id) FROM users WHERE role='admin')");
279
+ })();
280
+ db.exec('PRAGMA foreign_keys = ON');
281
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid ON users(uuid)');
282
+ db.exec('CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)');
283
+ db.exec('CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)');
284
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)');
285
+ }
286
+
287
+ // 聊天图片持久化关联表
288
+ db.exec(`
289
+ CREATE TABLE IF NOT EXISTS message_images (
290
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
291
+ upload_batch_id TEXT NOT NULL,
292
+ user_uuid TEXT NOT NULL,
293
+ session_id TEXT,
294
+ message_content TEXT,
295
+ original_name TEXT NOT NULL,
296
+ file_hash TEXT NOT NULL,
297
+ file_ext TEXT NOT NULL,
298
+ file_size INTEGER NOT NULL,
299
+ mime_type TEXT NOT NULL,
300
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
301
+ )
302
+ `);
303
+ db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_batch ON message_images(upload_batch_id)');
304
+ db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_session ON message_images(session_id)');
305
+ db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_created ON message_images(created_at)');
306
+
250
307
  console.log('Database migrations completed successfully');
251
308
  } catch (error) {
252
309
  console.error('Error running migrations:', error.message);
@@ -418,6 +475,15 @@ const userDb = {
418
475
  }
419
476
  },
420
477
 
478
+ // Update user role
479
+ updateUserRole: (userId, role) => {
480
+ try {
481
+ db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, userId);
482
+ } catch (err) {
483
+ throw err;
484
+ }
485
+ },
486
+
421
487
  // Update user status
422
488
  updateUserStatus: (userId, status) => {
423
489
  try {
@@ -775,13 +841,13 @@ const usageDb = {
775
841
  try {
776
842
  return db.prepare(`
777
843
  SELECT
778
- user_uuid,
779
- SUM(total_cost_usd) as total_cost,
780
- SUM(request_count) as total_requests,
781
- SUM(session_count) as total_sessions,
782
- MAX(date) as last_active
783
- FROM usage_daily_summary
784
- GROUP BY user_uuid
844
+ uds.user_uuid,
845
+ SUM(uds.total_cost_usd) as total_cost,
846
+ SUM(uds.request_count) as total_requests,
847
+ (SELECT COUNT(DISTINCT ur.session_id) FROM usage_records ur WHERE ur.user_uuid = uds.user_uuid AND ur.session_id IS NOT NULL) as total_sessions,
848
+ MAX(uds.date) as last_active
849
+ FROM usage_daily_summary uds
850
+ GROUP BY uds.user_uuid
785
851
  ORDER BY total_cost DESC
786
852
  `).all();
787
853
  } catch (err) {
@@ -819,10 +885,10 @@ const usageDb = {
819
885
  SUM(total_input_tokens) as total_input_tokens,
820
886
  SUM(total_output_tokens) as total_output_tokens,
821
887
  SUM(request_count) as total_requests,
822
- SUM(session_count) as total_sessions
888
+ (SELECT COUNT(DISTINCT session_id) FROM usage_records WHERE user_uuid = ? AND session_id IS NOT NULL) as total_sessions
823
889
  FROM usage_daily_summary
824
890
  WHERE user_uuid = ?
825
- `).get(userUuid);
891
+ `).get(userUuid, userUuid);
826
892
  } catch (err) {
827
893
  throw err;
828
894
  }
@@ -839,6 +905,7 @@ const usageDb = {
839
905
  COUNT(*) as requests
840
906
  FROM usage_records
841
907
  WHERE user_uuid = ? AND created_at >= ? AND created_at <= ?
908
+ AND COALESCE(raw_model, model) != '<synthetic>'
842
909
  GROUP BY COALESCE(raw_model, model)
843
910
  ORDER BY cost DESC
844
911
  `).all(userUuid, startDate + 'T00:00:00', endDate + 'T23:59:59');
@@ -854,11 +921,11 @@ const usageDb = {
854
921
  SELECT
855
922
  SUM(total_cost_usd) as total_cost,
856
923
  SUM(request_count) as total_requests,
857
- SUM(session_count) as total_sessions,
924
+ (SELECT COUNT(DISTINCT session_id) FROM usage_records WHERE date(created_at) >= ? AND date(created_at) <= ? AND session_id IS NOT NULL) as total_sessions,
858
925
  COUNT(DISTINCT user_uuid) as active_users
859
926
  FROM usage_daily_summary
860
927
  WHERE date >= ? AND date <= ?
861
- `).get(startDate, endDate);
928
+ `).get(startDate, endDate, startDate, endDate);
862
929
 
863
930
  const dailyTrend = db.prepare(`
864
931
  SELECT
@@ -879,6 +946,7 @@ const usageDb = {
879
946
  COUNT(*) as requests
880
947
  FROM usage_records
881
948
  WHERE created_at >= ? AND created_at <= ?
949
+ AND COALESCE(raw_model, model) != '<synthetic>'
882
950
  GROUP BY COALESCE(raw_model, model)
883
951
  ORDER BY cost DESC
884
952
  `).all(startDate + 'T00:00:00', endDate + 'T23:59:59');
@@ -1153,6 +1221,78 @@ const feishuDb = {
1153
1221
  }
1154
1222
  };
1155
1223
 
1224
+ // Image database operations (聊天图片持久化)
1225
+ const imageDb = {
1226
+ insertImage: ({ upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type }) => {
1227
+ const result = db.prepare(`
1228
+ INSERT INTO message_images (upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type)
1229
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1230
+ `).run(upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type);
1231
+ return result.lastInsertRowid;
1232
+ },
1233
+
1234
+ associateBatch: (uploadBatchId, sessionId, messageContent) => {
1235
+ db.prepare(`
1236
+ UPDATE message_images SET session_id = ?, message_content = ? WHERE upload_batch_id = ?
1237
+ `).run(sessionId, messageContent || null, uploadBatchId);
1238
+ },
1239
+
1240
+ getImagesBySession: (sessionId) => {
1241
+ return db.prepare(`
1242
+ SELECT id, upload_batch_id, original_name, file_hash, file_ext, mime_type, message_content, created_at
1243
+ FROM message_images WHERE session_id = ? ORDER BY created_at ASC
1244
+ `).all(sessionId);
1245
+ },
1246
+
1247
+ getImageById: (imageId) => {
1248
+ return db.prepare(`
1249
+ SELECT id, user_uuid, original_name, file_hash, file_ext, mime_type, created_at
1250
+ FROM message_images WHERE id = ?
1251
+ `).get(imageId) || null;
1252
+ },
1253
+
1254
+ getExpiredImages: (days) => {
1255
+ return db.prepare(`
1256
+ SELECT id, user_uuid, file_hash, file_ext
1257
+ FROM message_images WHERE created_at < datetime('now', '-' || ? || ' days')
1258
+ `).all(days);
1259
+ },
1260
+
1261
+ deleteImagesByIds: (ids) => {
1262
+ if (!ids.length) return;
1263
+ const placeholders = ids.map(() => '?').join(',');
1264
+ db.prepare(`DELETE FROM message_images WHERE id IN (${placeholders})`).run(...ids);
1265
+ },
1266
+
1267
+ deleteOrphanedImages: (hours) => {
1268
+ const rows = db.prepare(`
1269
+ SELECT id, user_uuid, file_hash, file_ext
1270
+ FROM message_images WHERE session_id IS NULL AND created_at < datetime('now', '-' || ? || ' hours')
1271
+ `).all(hours);
1272
+ if (rows.length) {
1273
+ const ids = rows.map(r => r.id);
1274
+ const placeholders = ids.map(() => '?').join(',');
1275
+ db.prepare(`DELETE FROM message_images WHERE id IN (${placeholders})`).run(...ids);
1276
+ }
1277
+ return rows;
1278
+ },
1279
+
1280
+ countByHash: (userUuid, fileHash, excludeIds) => {
1281
+ if (!excludeIds.length) {
1282
+ const row = db.prepare(`
1283
+ SELECT COUNT(*) as count FROM message_images WHERE user_uuid = ? AND file_hash = ?
1284
+ `).get(userUuid, fileHash);
1285
+ return row.count;
1286
+ }
1287
+ const placeholders = excludeIds.map(() => '?').join(',');
1288
+ const row = db.prepare(`
1289
+ SELECT COUNT(*) as count FROM message_images
1290
+ WHERE user_uuid = ? AND file_hash = ? AND id NOT IN (${placeholders})
1291
+ `).get(userUuid, fileHash, ...excludeIds);
1292
+ return row.count;
1293
+ }
1294
+ };
1295
+
1156
1296
  export {
1157
1297
  db,
1158
1298
  initializeDatabase,
@@ -1161,5 +1301,6 @@ export {
1161
1301
  verificationDb,
1162
1302
  domainWhitelistDb,
1163
1303
  settingsDb,
1164
- feishuDb
1304
+ feishuDb,
1305
+ imageDb
1165
1306
  };
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
11
11
  password_hash TEXT,
12
12
  email TEXT UNIQUE,
13
13
  uuid TEXT,
14
- role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user')),
14
+ role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user', 'super_admin')),
15
15
  status TEXT DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
16
16
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
17
17
  last_login DATETIME,
package/server/index.js CHANGED
@@ -55,11 +55,13 @@ import usageRoutes from './routes/usage.js';
55
55
  import skillsRoutes from './routes/skills.js';
56
56
  import mcpReposRoutes from './routes/mcp-repos.js';
57
57
  import settingsRoutes from './routes/settings.js';
58
- import { initializeDatabase, userDb } from './database/db.js';
58
+ import { initializeDatabase, userDb, imageDb } from './database/db.js';
59
59
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
60
60
  import { getUserPaths, initCodexDirectories, initGeminiDirectories } from './services/user-directories.js';
61
61
  import { startUsageScanner } from './services/usage-scanner.js';
62
62
  import { startFeishuService, stopFeishuService } from './services/feishu/index.js';
63
+ import { saveImage, getImagePath } from './services/image-storage.js';
64
+ import { startImageCleanup } from './services/image-cleanup.js';
63
65
 
64
66
  // File system watcher for projects folder - per user
65
67
  const userWatchers = new Map(); // Map<userUuid, { watcher, clients: Set<ws> }>
@@ -382,13 +384,40 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
382
384
 
383
385
  const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset, req.user.uuid);
384
386
 
387
+ // Query persistent images for this session
388
+ let sessionImages = [];
389
+ try {
390
+ const images = imageDb.getImagesBySession(sessionId);
391
+ if (images.length > 0) {
392
+ // Group by upload_batch_id
393
+ const batchMap = new Map();
394
+ for (const img of images) {
395
+ if (!batchMap.has(img.upload_batch_id)) {
396
+ batchMap.set(img.upload_batch_id, {
397
+ batchId: img.upload_batch_id,
398
+ messageContent: img.message_content,
399
+ createdAt: img.created_at,
400
+ images: [],
401
+ });
402
+ }
403
+ batchMap.get(img.upload_batch_id).images.push({
404
+ id: img.id,
405
+ name: img.original_name,
406
+ });
407
+ }
408
+ sessionImages = Array.from(batchMap.values());
409
+ }
410
+ } catch (err) {
411
+ console.error('[WARN] Failed to load session images:', err.message);
412
+ }
413
+
385
414
  // Handle both old and new response formats
386
415
  if (Array.isArray(result)) {
387
416
  // Backward compatibility: no pagination parameters were provided
388
- res.json({ messages: result });
417
+ res.json({ messages: result, sessionImages });
389
418
  } else {
390
419
  // New format with pagination info
391
- res.json(result);
420
+ res.json({ ...result, sessionImages });
392
421
  }
393
422
  } catch (error) {
394
423
  res.status(500).json({ error: error.message });
@@ -729,6 +758,22 @@ function handleChatConnection(ws, userData) {
729
758
  ...data.options,
730
759
  userUuid,
731
760
  }, writer);
761
+
762
+ // Associate uploaded images with the session after query completes
763
+ if (data.options?.uploadBatchId) {
764
+ const sessionId = writer.getSessionId();
765
+ if (sessionId) {
766
+ try {
767
+ imageDb.associateBatch(
768
+ data.options.uploadBatchId,
769
+ sessionId,
770
+ data.command?.substring(0, 500) || ''
771
+ );
772
+ } catch (err) {
773
+ console.error('[WARN] Failed to associate image batch:', err.message);
774
+ }
775
+ }
776
+ }
732
777
  } else if (data.type === 'abort-session') {
733
778
  console.log('[DEBUG] Abort session request:', data.sessionId);
734
779
  // Use Claude Agents SDK
@@ -1749,6 +1794,40 @@ Agent instructions:`;
1749
1794
  }
1750
1795
  });
1751
1796
 
1797
+ // Serve persistent chat images
1798
+ app.get('/api/images/:imageId', authenticateToken, (req, res) => {
1799
+ try {
1800
+ const imageId = parseInt(req.params.imageId, 10);
1801
+ if (isNaN(imageId)) {
1802
+ return res.status(400).json({ error: 'Invalid image ID' });
1803
+ }
1804
+
1805
+ const image = imageDb.getImageById(imageId);
1806
+ if (!image) {
1807
+ return res.status(404).json({ error: 'Image not found' });
1808
+ }
1809
+
1810
+ // Security: users can only access their own images
1811
+ if (image.user_uuid !== req.user.uuid) {
1812
+ return res.status(403).json({ error: 'Forbidden' });
1813
+ }
1814
+
1815
+ const filePath = getImagePath(image.user_uuid, image.file_hash, image.file_ext);
1816
+
1817
+ // Check if file still exists on disk (may have been cleaned up)
1818
+ if (!fs.existsSync(filePath)) {
1819
+ return res.status(410).json({ error: 'Image expired' });
1820
+ }
1821
+
1822
+ res.setHeader('Content-Type', image.mime_type);
1823
+ res.setHeader('Cache-Control', 'private, max-age=86400');
1824
+ res.sendFile(filePath);
1825
+ } catch (error) {
1826
+ console.error('Error serving image:', error);
1827
+ res.status(500).json({ error: 'Internal server error' });
1828
+ }
1829
+ });
1830
+
1752
1831
  // Image upload endpoint
1753
1832
  app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
1754
1833
  try {
@@ -1800,6 +1879,11 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
1800
1879
  }
1801
1880
 
1802
1881
  try {
1882
+ // Generate a batch ID for this upload group
1883
+ const { randomUUID } = await import('crypto');
1884
+ const uploadBatchId = randomUUID();
1885
+ const userUuid = req.user.uuid;
1886
+
1803
1887
  // Process uploaded images
1804
1888
  const processedImages = await Promise.all(
1805
1889
  req.files.map(async (file) => {
@@ -1811,7 +1895,23 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
1811
1895
  // Clean up temp file immediately
1812
1896
  await fs.unlink(file.path);
1813
1897
 
1898
+ // Persist image to file system (hash-deduplicated)
1899
+ const ext = file.originalname.split('.').pop()?.toLowerCase() || 'bin';
1900
+ const { fileHash } = await saveImage(userUuid, buffer, ext);
1901
+
1902
+ // Insert DB record (session_id=NULL, will be associated later)
1903
+ const imageId = imageDb.insertImage({
1904
+ upload_batch_id: uploadBatchId,
1905
+ user_uuid: userUuid,
1906
+ original_name: file.originalname,
1907
+ file_hash: fileHash,
1908
+ file_ext: ext,
1909
+ file_size: file.size,
1910
+ mime_type: mimeType,
1911
+ });
1912
+
1814
1913
  return {
1914
+ id: Number(imageId),
1815
1915
  name: file.originalname,
1816
1916
  data: `data:${mimeType};base64,${base64}`,
1817
1917
  size: file.size,
@@ -1820,7 +1920,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
1820
1920
  })
1821
1921
  );
1822
1922
 
1823
- res.json({ images: processedImages });
1923
+ res.json({ images: processedImages, uploadBatchId });
1824
1924
  } catch (error) {
1825
1925
  console.error('Error processing images:', error);
1826
1926
  // Clean up any remaining files
@@ -2251,6 +2351,9 @@ async function startServer() {
2251
2351
  // Start usage scanner service
2252
2352
  startUsageScanner();
2253
2353
 
2354
+ // Start image cleanup service (30-day expiry)
2355
+ startImageCleanup();
2356
+
2254
2357
  // Start Feishu integration service (if configured)
2255
2358
  if (process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET) {
2256
2359
  startFeishuService().catch(err =>
@@ -7,14 +7,22 @@ import { deleteUserDirectories, initUserDirectories } from '../services/user-dir
7
7
 
8
8
  const router = express.Router();
9
9
 
10
- // Admin middleware
10
+ // Admin middleware - allows both admin and super_admin
11
11
  const requireAdmin = (req, res, next) => {
12
- if (req.user.role !== 'admin') {
12
+ if (req.user.role !== 'admin' && req.user.role !== 'super_admin') {
13
13
  return res.status(403).json({ error: 'Admin access required' });
14
14
  }
15
15
  next();
16
16
  };
17
17
 
18
+ // Super admin only middleware
19
+ const requireSuperAdmin = (req, res, next) => {
20
+ if (req.user.role !== 'super_admin') {
21
+ return res.status(403).json({ error: 'Super admin access required' });
22
+ }
23
+ next();
24
+ };
25
+
18
26
  // Apply auth and admin middleware to all routes
19
27
  router.use(authenticateToken);
20
28
  router.use(requireAdmin);
@@ -108,6 +116,11 @@ router.patch('/users/:id', (req, res) => {
108
116
  return res.status(404).json({ error: 'User not found' });
109
117
  }
110
118
 
119
+ // admin cannot operate on other admin/super_admin users
120
+ if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
121
+ return res.status(403).json({ error: '无权操作管理员账户' });
122
+ }
123
+
111
124
  userDb.updateUserStatus(id, status);
112
125
  res.json({ success: true, message: `User status updated to ${status}` });
113
126
  } catch (error) {
@@ -131,6 +144,11 @@ router.delete('/users/:id', async (req, res) => {
131
144
  return res.status(404).json({ error: 'User not found' });
132
145
  }
133
146
 
147
+ // admin cannot delete other admin/super_admin users
148
+ if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
149
+ return res.status(403).json({ error: '无权删除管理员账户' });
150
+ }
151
+
134
152
  // Delete user directories
135
153
  if (user.uuid) {
136
154
  await deleteUserDirectories(user.uuid);
@@ -167,6 +185,11 @@ router.patch('/users/:id/password', async (req, res) => {
167
185
  return res.status(404).json({ error: '用户不存在' });
168
186
  }
169
187
 
188
+ // admin cannot reset password of other admin/super_admin users
189
+ if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
190
+ return res.status(403).json({ error: '无权重置管理员密码' });
191
+ }
192
+
170
193
  // Can only reset password for password-login users
171
194
  if (!user.password_hash) {
172
195
  return res.status(400).json({ error: '该用户使用邮箱验证码登录,无法重置密码' });
@@ -186,6 +209,37 @@ router.patch('/users/:id/password', async (req, res) => {
186
209
  }
187
210
  });
188
211
 
212
+ // Update user role (super_admin only)
213
+ router.patch('/users/:id/role', requireSuperAdmin, (req, res) => {
214
+ try {
215
+ const { id } = req.params;
216
+ const { role } = req.body;
217
+
218
+ if (!['admin', 'user'].includes(role)) {
219
+ return res.status(400).json({ error: '只能设置为 admin 或 user' });
220
+ }
221
+
222
+ if (parseInt(id) === req.user.id) {
223
+ return res.status(400).json({ error: '不能修改自己的角色' });
224
+ }
225
+
226
+ const user = userDb.getUserById(id);
227
+ if (!user) {
228
+ return res.status(404).json({ error: 'User not found' });
229
+ }
230
+
231
+ if (user.role === 'super_admin') {
232
+ return res.status(403).json({ error: '不能修改超级管理员的角色' });
233
+ }
234
+
235
+ userDb.updateUserRole(id, role);
236
+ res.json({ success: true, message: '用户角色已更新' });
237
+ } catch (error) {
238
+ console.error('Error updating user role:', error);
239
+ res.status(500).json({ error: '更新用户角色失败' });
240
+ }
241
+ });
242
+
189
243
  // ==================== User Spending Limits ====================
190
244
 
191
245
  // Get user spending limits
@@ -227,9 +281,17 @@ router.patch('/users/:id/limits', (req, res) => {
227
281
  }
228
282
  }
229
283
 
230
- // Prevent modifying own limits
231
- if (parseInt(id) === req.user.id) {
232
- return res.status(400).json({ error: '不能修改自己的额度限制' });
284
+ const isSelf = parseInt(id) === req.user.id;
285
+
286
+ // admin can only set limits for themselves or for user-role accounts
287
+ if (req.user.role === 'admin' && !isSelf) {
288
+ const targetUser = userDb.getUserById(id);
289
+ if (!targetUser) {
290
+ return res.status(404).json({ error: 'User not found' });
291
+ }
292
+ if (['admin', 'super_admin'].includes(targetUser.role)) {
293
+ return res.status(403).json({ error: '无权修改管理员账户的限额' });
294
+ }
233
295
  }
234
296
 
235
297
  const user = userDb.getUserById(id);
@@ -111,13 +111,13 @@ router.post('/verify-code', async (req, res) => {
111
111
  // If user doesn't exist, create new user (registration)
112
112
  if (!user) {
113
113
  const userCount = userDb.getUserCount();
114
- const role = userCount === 0 ? 'admin' : 'user';
114
+ const role = userCount === 0 ? 'super_admin' : 'user';
115
115
  const uuid = uuidv4();
116
116
 
117
117
  user = userDb.createUserWithEmail(email, uuid, role);
118
118
 
119
- // Apply default total limit for non-admin users
120
- if (role !== 'admin') {
119
+ // Apply default total limit for regular users only
120
+ if (role === 'user') {
121
121
  const defaultTotalLimit = settingsDb.get('default_total_limit_usd');
122
122
  if (defaultTotalLimit !== null) {
123
123
  userDb.updateUserLimits(user.id, parseFloat(defaultTotalLimit), null);
@@ -276,6 +276,7 @@ router.patch('/change-password', authenticateToken, async (req, res) => {
276
276
  // Get current user's spending limit status
277
277
  router.get('/limit-status', authenticateToken, (req, res) => {
278
278
  try {
279
+ if (req.user.role === 'super_admin') return res.json({ allowed: true });
279
280
  const status = usageDb.checkUserLimits(req.user.uuid);
280
281
  res.json(status);
281
282
  } catch (error) {
@@ -7,7 +7,7 @@ const router = express.Router();
7
7
 
8
8
  // Admin middleware
9
9
  const requireAdmin = (req, res, next) => {
10
- if (req.user.role !== 'admin') {
10
+ if (req.user.role !== 'admin' && req.user.role !== 'super_admin') {
11
11
  return res.status(403).json({ error: 'Admin access required' });
12
12
  }
13
13
  next();
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { queryClaudeSDK } from '../../claude-sdk.js';
19
- import { usageDb } from '../../database/db.js';
19
+ import { usageDb, userDb } from '../../database/db.js';
20
20
  import { feishuDb } from './feishu-db.js';
21
21
  import fs from 'fs/promises';
22
22
  import path from 'path';
@@ -413,14 +413,17 @@ async function runQuery({
413
413
  larkClient,
414
414
  pendingApprovals,
415
415
  }) {
416
- // 限额检查
417
- const limitStatus = usageDb.checkUserLimits(userUuid);
418
- if (!limitStatus.allowed) {
419
- const reason = limitStatus.reason === 'daily_limit_exceeded'
420
- ? `每日用量已达上限 ($${limitStatus.limit.toFixed(2)})`
421
- : `总用量已达上限 ($${limitStatus.limit.toFixed(2)})`;
422
- await larkClient.sendText(chatId, `⚠️ ${reason},请联系管理员。`);
423
- return;
416
+ // 限额检查(super_admin 豁免)
417
+ const feishuUser = userDb.getUserByUuid(userUuid);
418
+ if (feishuUser?.role !== 'super_admin') {
419
+ const limitStatus = usageDb.checkUserLimits(userUuid);
420
+ if (!limitStatus.allowed) {
421
+ const reason = limitStatus.reason === 'daily_limit_exceeded'
422
+ ? `每日用量已达上限 ($${limitStatus.limit.toFixed(2)})`
423
+ : `总用量已达上限 ($${limitStatus.limit.toFixed(2)})`;
424
+ await larkClient.sendText(chatId, `⚠️ ${reason},请联系管理员。`);
425
+ return;
426
+ }
424
427
  }
425
428
 
426
429
  const { claude_session_id, cwd, permission_mode } = state || {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * image-cleanup.js — 定时清理过期图片
3
+ *
4
+ * - 每小时运行一次
5
+ * - 删除 30 天以上的图片记录和文件
6
+ * - 删除 24 小时以上未关联 session 的孤儿图片
7
+ * - 删文件前检查同 hash 是否还有其他引用(去重安全)
8
+ */
9
+
10
+ import { imageDb } from '../database/db.js';
11
+ import { deleteImageFile } from './image-storage.js';
12
+
13
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
14
+ const RETENTION_DAYS = 30;
15
+ const ORPHAN_HOURS = 24;
16
+
17
+ let cleanupInterval = null;
18
+
19
+ export function startImageCleanup() {
20
+ console.log('[ImageCleanup] Starting image cleanup service');
21
+
22
+ // Initial cleanup after 30s
23
+ setTimeout(() => runCleanup(), 30000);
24
+
25
+ // Schedule periodic cleanup
26
+ cleanupInterval = setInterval(() => runCleanup(), CLEANUP_INTERVAL_MS);
27
+ console.log(`[ImageCleanup] Scheduled to run every ${CLEANUP_INTERVAL_MS / 1000 / 60} minutes`);
28
+ }
29
+
30
+ export function stopImageCleanup() {
31
+ if (cleanupInterval) {
32
+ clearInterval(cleanupInterval);
33
+ cleanupInterval = null;
34
+ }
35
+ }
36
+
37
+ async function runCleanup() {
38
+ try {
39
+ let deletedFiles = 0;
40
+ let deletedRecords = 0;
41
+
42
+ // 1. Clean expired images (>30 days)
43
+ const expiredImages = imageDb.getExpiredImages(RETENTION_DAYS);
44
+ if (expiredImages.length > 0) {
45
+ const expiredIds = expiredImages.map(img => img.id);
46
+
47
+ // Delete files only when no other records reference the same hash
48
+ for (const img of expiredImages) {
49
+ const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, expiredIds);
50
+ if (otherRefs === 0) {
51
+ await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
52
+ deletedFiles++;
53
+ }
54
+ }
55
+
56
+ imageDb.deleteImagesByIds(expiredIds);
57
+ deletedRecords += expiredIds.length;
58
+ }
59
+
60
+ // 2. Clean orphaned uploads (session_id IS NULL, >24h)
61
+ const orphaned = imageDb.deleteOrphanedImages(ORPHAN_HOURS);
62
+ if (orphaned.length > 0) {
63
+ for (const img of orphaned) {
64
+ const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, [img.id]);
65
+ if (otherRefs === 0) {
66
+ await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
67
+ deletedFiles++;
68
+ }
69
+ }
70
+ deletedRecords += orphaned.length;
71
+ }
72
+
73
+ if (deletedRecords > 0) {
74
+ console.log(`[ImageCleanup] Cleaned up ${deletedRecords} records, ${deletedFiles} files`);
75
+ }
76
+ } catch (error) {
77
+ console.error('[ImageCleanup] Error during cleanup:', error);
78
+ }
79
+ }