agentworkshop 0.7.7 → 0.7.8
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/.AgentWorkShop/prompts/host-tools.json +1 -1
- package/.output/.AgentWorkShop/prompts/host-tools.json +1 -1
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{CQiKcuoG.js → Dc46i75T.js} +3 -3
- package/.output/public/_nuxt/R_Wv_Nd9.js +1 -0
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/e5bb1803-86d9-43b3-85be-dc787dc9a978.json +1 -0
- package/.output/public/_nuxt/{entry.DvlXlPwx.css → entry.5qf6EqfN.css} +1 -1
- package/.output/public/_nuxt/workshop.3kAcLB_M.css +1 -0
- package/.output/server/chunks/_/nitro.mjs +2468 -2407
- package/.output/server/chunks/_/nitro.mjs.map +1 -1
- package/.output/server/chunks/build/TownView-B5KifIaT.mjs +1 -1
- package/.output/server/chunks/build/_id_-C-ewLgfl.mjs +1 -1
- package/.output/server/chunks/build/_id_-Ct4Jhvl1.mjs +1 -1
- package/.output/server/chunks/build/daq-D9RA_uze.mjs +1 -1
- package/.output/server/chunks/build/dcw-BTM5YU-B.mjs +1 -1
- package/.output/server/chunks/build/useDaqStream-hwkhJHFU.mjs +1 -1
- package/.output/server/chunks/build/useDcwStream-Bdew_yrr.mjs +1 -1
- package/.output/server/chunks/build/{workshop-iCzNRifn.mjs → workshop-D5CQj2A8.mjs} +45 -39
- package/.output/server/chunks/build/workshop-D5CQj2A8.mjs.map +1 -0
- package/.output/server/chunks/routes/api/users/setup-status.get.mjs +25 -0
- package/.output/server/chunks/routes/api/users/setup-status.get.mjs.map +1 -0
- package/.output/server/chunks/routes/api/workshop/caller.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/channels.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/_id/samples.get.mjs +3 -2
- package/.output/server/chunks/routes/api/workshop/daq/_id/samples.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/index.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/infra/reconnect.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/infra.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/templates/_key_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/templates/_key_.patch.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/daq/test-driver.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id/bind.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id/param-ledger.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id/read.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id/test.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id/write.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/_id_.patch.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/controller.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index2.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index2.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index3.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/index4.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/journal/node/_nodeId/rollback.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/line/query.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/start.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/lines/_id/stop.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/lines/_id_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/lines/_id_.patch.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/optimizations/_id/judge.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/optimizations/_id/rollback.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/optimizations/_id/series.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/optimizations/_id_.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/products/_id_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/products/_id_.patch.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/apply.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/mark-good.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/rollback-good.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/runs/_id/close.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/runs/_id/data.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/runs/_id/rollback.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/templates/_key_.patch.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/dcw/test-driver.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/device-twins/_id_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/harnesses/_id/providers.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/harnesses.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/index4.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/index4.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/index5.get.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/index6.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
- package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
- package/.output/server/chunks/routes/renderer.mjs +2 -2
- package/.output/server/chunks/virtual/entry.mjs +10 -2
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/package.json +1 -1
- package/README-zh.md +27 -7
- package/README.md +455 -435
- package/app/pages/workshop/index.vue +25 -6
- package/config.yml +11 -1
- package/i18n/dicts/settings.json +4 -0
- package/i18n/locales/en.ts +4 -0
- package/i18n/locales/zh-CN.ts +4 -0
- package/package.json +1 -1
- package/scripts/_dbg-codex-handshake.ts +30 -0
- package/scripts/_dbg-codex-sandbox.ts +58 -0
- package/scripts/_dbg-codex-turn.ts +65 -0
- package/scripts/_dbg-interval-config.mjs +145 -0
- package/scripts/_dbg-setup-e2e.mjs +88 -0
- package/scripts/e2e-harness-provider-task.mjs +126 -0
- package/server/api/users/setup-status.get.ts +8 -0
- package/server/api/workshop/daq/[id]/samples.get.ts +4 -2
- package/server/data/daqs.json +97 -45
- package/server/data/dcw-lines.json +7 -0
- package/server/data/dcw-products.json +21 -0
- package/server/data/dcw-recipes.json +84 -0
- package/server/data/dcw-rollback.json +39 -6
- package/server/data/dcw-runs.json +81 -75
- package/server/data/dcw-writes.json +36 -0
- package/server/data/dcws.json +374 -299
- package/server/data/line-runs.json +6 -6
- package/server/plugins/security.ts +46 -50
- package/server/repositories/user.repository.ts +384 -414
- package/server/services/system-config.ts +2 -0
- package/server/services/user.service.ts +8 -3
- package/server/services/workshop/agents/adapters/line-spawn.ts +6 -3
- package/server/services/workshop/agents/host-tool-bridge.ts +21 -1
- package/server/services/workshop/agents/industrial-tools.ts +5 -3
- package/server/services/workshop/daq/daq-controller.ts +34 -6
- package/server/services/workshop/daq/daq-node.ts +3 -3
- package/server/services/workshop/daq/daq-runtime.ts +4 -3
- package/server/services/workshop/dcw/dcw-controller.ts +4 -2
- package/server/services/workshop/settings.ts +23 -0
- package/shared/config/schema.json +4 -0
- package/.output/public/_nuxt/C-GarxYC.js +0 -1
- package/.output/public/_nuxt/builds/meta/14df171d-89cb-43ec-ad6f-9df72d6da7f5.json +0 -1
- package/.output/public/_nuxt/workshop.B98sHiJq.css +0 -1
- package/.output/server/chunks/build/workshop-iCzNRifn.mjs.map +0 -1
|
@@ -1,414 +1,384 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { backupRegistry } from '../services/workshop/db/backup-registry'
|
|
3
|
-
import { join, resolve } from 'node:path'
|
|
4
|
-
import { randomBytes, randomUUID, scryptSync, createHash, timingSafeEqual } from 'node:crypto'
|
|
5
|
-
import { DatabaseSync } from 'node:sqlite'
|
|
6
|
-
import { ensureDataDir } from '@/shared/config/home.mjs'
|
|
7
|
-
import type { Paginated, User, UserListQuery, UserToken } from '../types/user'
|
|
8
|
-
import type { UserCreate, UserUpdate } from '../schemas/user.schema'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 全局用户数据访问层(Repository)—— SQLite 持久化(配置根 .AgentWorkShop/data/users.sqlite;
|
|
12
|
-
* repo 模式 <repo>/.AgentWorkShop/data,home 模式 ~/.AgentWorkShop/data,经 ensureDataDir 统一解析
|
|
13
|
-
* 并自动迁移旧 cwd/data 位置)。
|
|
14
|
-
* - users 表:用户档案 + 密码哈希(scrypt,salt 内嵌,明文不进库)
|
|
15
|
-
* - user_tokens 表:每用户多个 API Token(仅存 SHA-256 哈希;明文只在签发时返回一次)
|
|
16
|
-
* 进程内单例 DB 懒加载;数据源与业务逻辑解耦,service 层零改动。
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
// 配置根 .AgentWorkShop/data(repo: <repo>/.AgentWorkShop/data,home: ~/.AgentWorkShop/data;
|
|
20
|
-
// ensureDataDir 统一解析并自动迁移旧 cwd/data 位置)
|
|
21
|
-
const DB_PATH = join(ensureDataDir(), 'users.sqlite')
|
|
22
|
-
|
|
23
|
-
const SCHEMA_SQL = `
|
|
24
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
25
|
-
id TEXT PRIMARY KEY,
|
|
26
|
-
name TEXT NOT NULL,
|
|
27
|
-
email TEXT NOT NULL UNIQUE,
|
|
28
|
-
password_hash TEXT NOT NULL,
|
|
29
|
-
role TEXT NOT NULL DEFAULT 'user',
|
|
30
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
31
|
-
created_at TEXT NOT NULL
|
|
32
|
-
);
|
|
33
|
-
CREATE TABLE IF NOT EXISTS user_tokens (
|
|
34
|
-
id TEXT PRIMARY KEY,
|
|
35
|
-
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
36
|
-
label TEXT NOT NULL DEFAULT '',
|
|
37
|
-
token_hash TEXT NOT NULL UNIQUE,
|
|
38
|
-
token_plain TEXT,
|
|
39
|
-
created_at TEXT NOT NULL,
|
|
40
|
-
last_used_at TEXT
|
|
41
|
-
);
|
|
42
|
-
CREATE INDEX IF NOT EXISTS idx_user_tokens_user ON user_tokens(user_id);
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
let
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
function
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
createdAt:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const d = getDb()
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
return
|
|
371
|
-
},
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
|
|
375
|
-
const d = getDb()
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const row = d.prepare(
|
|
386
|
-
'SELECT token_plain AS tokenPlain FROM user_tokens WHERE id = ? AND user_id = ?',
|
|
387
|
-
).get(tokenId, userId) as { tokenPlain: string | null } | undefined
|
|
388
|
-
return row ? row.tokenPlain : undefined
|
|
389
|
-
},
|
|
390
|
-
|
|
391
|
-
/** 改 token 标签;不存在或非本人返回 false */
|
|
392
|
-
updateTokenLabel(userId: string, tokenId: string, label: string): boolean {
|
|
393
|
-
const d = getDb()
|
|
394
|
-
return d.prepare('UPDATE user_tokens SET label = ? WHERE id = ? AND user_id = ?').run(label, tokenId, userId).changes > 0
|
|
395
|
-
},
|
|
396
|
-
|
|
397
|
-
/** 删除/吊销 token;不存在或非本人返回 false */
|
|
398
|
-
revokeToken(userId: string, tokenId: string): boolean {
|
|
399
|
-
const d = getDb()
|
|
400
|
-
return d.prepare('DELETE FROM user_tokens WHERE id = ? AND user_id = ?').run(tokenId, userId).changes > 0
|
|
401
|
-
},
|
|
402
|
-
|
|
403
|
-
/** 登出当前 token(仅吊销该 token 本身) */
|
|
404
|
-
revokeTokenByValue(token: string): boolean {
|
|
405
|
-
const d = getDb()
|
|
406
|
-
return d.prepare('DELETE FROM user_tokens WHERE token_hash = ?').run(hashToken(token)).changes > 0
|
|
407
|
-
},
|
|
408
|
-
|
|
409
|
-
/** 测试钩子:替换内部 DB(重开内存库等) */
|
|
410
|
-
_resetForTest(): void {
|
|
411
|
-
db?.close()
|
|
412
|
-
db = null
|
|
413
|
-
},
|
|
414
|
-
}
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { backupRegistry } from '../services/workshop/db/backup-registry'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { randomBytes, randomUUID, scryptSync, createHash, timingSafeEqual } from 'node:crypto'
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
import { ensureDataDir } from '@/shared/config/home.mjs'
|
|
7
|
+
import type { Paginated, User, UserListQuery, UserToken } from '../types/user'
|
|
8
|
+
import type { UserCreate, UserUpdate } from '../schemas/user.schema'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 全局用户数据访问层(Repository)—— SQLite 持久化(配置根 .AgentWorkShop/data/users.sqlite;
|
|
12
|
+
* repo 模式 <repo>/.AgentWorkShop/data,home 模式 ~/.AgentWorkShop/data,经 ensureDataDir 统一解析
|
|
13
|
+
* 并自动迁移旧 cwd/data 位置)。
|
|
14
|
+
* - users 表:用户档案 + 密码哈希(scrypt,salt 内嵌,明文不进库)
|
|
15
|
+
* - user_tokens 表:每用户多个 API Token(仅存 SHA-256 哈希;明文只在签发时返回一次)
|
|
16
|
+
* 进程内单例 DB 懒加载;数据源与业务逻辑解耦,service 层零改动。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// 配置根 .AgentWorkShop/data(repo: <repo>/.AgentWorkShop/data,home: ~/.AgentWorkShop/data;
|
|
20
|
+
// ensureDataDir 统一解析并自动迁移旧 cwd/data 位置)
|
|
21
|
+
const DB_PATH = join(ensureDataDir(), 'users.sqlite')
|
|
22
|
+
|
|
23
|
+
const SCHEMA_SQL = `
|
|
24
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
name TEXT NOT NULL,
|
|
27
|
+
email TEXT NOT NULL UNIQUE,
|
|
28
|
+
password_hash TEXT NOT NULL,
|
|
29
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
30
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
31
|
+
created_at TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS user_tokens (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
36
|
+
label TEXT NOT NULL DEFAULT '',
|
|
37
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
38
|
+
token_plain TEXT,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
last_used_at TEXT
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_user_tokens_user ON user_tokens(user_id);
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
let db: DatabaseSync | null = null
|
|
46
|
+
|
|
47
|
+
function getDb(): DatabaseSync {
|
|
48
|
+
if (!db) {
|
|
49
|
+
// 目录由 ensureDataDir 内部创建(含旧位置迁移)
|
|
50
|
+
db = new DatabaseSync(DB_PATH)
|
|
51
|
+
// 主连接登记(备份 serialize 用;见 backup-registry 说明)
|
|
52
|
+
backupRegistry.register(DB_PATH, db)
|
|
53
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
54
|
+
db.exec(SCHEMA_SQL)
|
|
55
|
+
migrateSchema(db)
|
|
56
|
+
// 首启零用户:不播种任何账号,由使用者在登录页注册第一个账号(自动成为管理员,
|
|
57
|
+
// 见 user.service.register 的初始化判定)
|
|
58
|
+
importLegacyWorkshopUsers(db)
|
|
59
|
+
}
|
|
60
|
+
return db
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 轻量迁移:旧库 user_tokens 补 token_plain 列(明文存档,支持随时查看)+ expires_at(R2 后端过期) */
|
|
64
|
+
function migrateSchema(d: DatabaseSync): void {
|
|
65
|
+
const cols = d.prepare('PRAGMA table_info(user_tokens)').all() as Array<{ name: string }>
|
|
66
|
+
if (!cols.some(c => c.name === 'token_plain')) {
|
|
67
|
+
d.exec('ALTER TABLE user_tokens ADD COLUMN token_plain TEXT')
|
|
68
|
+
}
|
|
69
|
+
if (!cols.some(c => c.name === 'expires_at')) {
|
|
70
|
+
d.exec('ALTER TABLE user_tokens ADD COLUMN expires_at TEXT')
|
|
71
|
+
}
|
|
72
|
+
// 存量 token 宽限 30 天(从部署时刻起算,避免升级即全员掉线)
|
|
73
|
+
d.prepare('UPDATE user_tokens SET expires_at = ? WHERE expires_at IS NULL').run(isoInDays(TOKEN_TTL_DAYS))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** token 有效期(天,R2):过期后 findByToken 拒绝,用户重新登录即可 */
|
|
77
|
+
const TOKEN_TTL_DAYS = 30
|
|
78
|
+
|
|
79
|
+
/** 当前时刻 + n 天的 ISO 时间戳 */
|
|
80
|
+
function isoInDays(days: number): string {
|
|
81
|
+
return new Date(Date.now() + days * 86_400_000).toISOString()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 掩码预览:前 6 后 4(token 形如 ut-xxxxxxxx…,前 6 含前缀可辨识) */
|
|
85
|
+
function maskPreview(raw: string): string {
|
|
86
|
+
return `${raw.slice(0, 6)}${'•'.repeat(8)}${raw.slice(-4)}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 一次性迁移:旧 workshop 本地用户(data/workshop.sqlite 的 users 表)导入全局用户系统。
|
|
91
|
+
* - 保留原 id/name(资源归属 owner_user_id 不变,历史数据不丢)
|
|
92
|
+
* - 旧 token 原值重新注册为全局 token(老客户端免重新登录)
|
|
93
|
+
* - email 派生 + 随机密码(不可经密码登录,可用管理面重置)
|
|
94
|
+
* 幂等:按 id 判断已存在则跳过。
|
|
95
|
+
*/
|
|
96
|
+
function importLegacyWorkshopUsers(d: DatabaseSync): void {
|
|
97
|
+
// 迁移后配置根已有 workshop.sqlite;旧 cwd/data 位置兜底(仅历史未迁移场景)
|
|
98
|
+
const migrated = join(ensureDataDir(), 'workshop.sqlite')
|
|
99
|
+
const wsPath = existsSync(migrated) ? migrated : resolve(process.cwd(), 'data', 'workshop.sqlite')
|
|
100
|
+
if (!existsSync(wsPath)) return
|
|
101
|
+
let wdb: DatabaseSync
|
|
102
|
+
try {
|
|
103
|
+
wdb = new DatabaseSync(wsPath, { readOnly: true })
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return // workshop 库尚不存在或不可读:跳过
|
|
107
|
+
}
|
|
108
|
+
let rows: Array<{ id: string, name: string, token: string, created_at: string }>
|
|
109
|
+
try {
|
|
110
|
+
rows = wdb.prepare('SELECT id, name, token, created_at FROM users').all() as Array<{ id: string, name: string, token: string, created_at: string }>
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
wdb.close()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
wdb.close()
|
|
117
|
+
|
|
118
|
+
const exists = d.prepare('SELECT 1 FROM users WHERE id = ?')
|
|
119
|
+
const insertUser = d.prepare('INSERT INTO users (id, name, email, password_hash, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
120
|
+
const tokenExists = d.prepare('SELECT 1 FROM user_tokens WHERE token_hash = ?')
|
|
121
|
+
const insertToken = d.prepare('INSERT INTO user_tokens (id, user_id, label, token_hash, token_plain, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
122
|
+
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
if (exists.get(row.id)) continue
|
|
125
|
+
const email = `legacy-${row.id.slice(0, 8)}@workshop.local`
|
|
126
|
+
insertUser.run(row.id, row.name, email, hashPassword(randomPassword()), 'user', 'active', row.created_at ?? now())
|
|
127
|
+
const hash = hashToken(row.token)
|
|
128
|
+
if (!tokenExists.get(hash)) {
|
|
129
|
+
insertToken.run(randomUUID(), row.id, 'legacy', hash, row.token, row.created_at ?? now())
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 20 位随机口令(legacy 导入用户与自动建号共用) */
|
|
135
|
+
function randomPassword(): string {
|
|
136
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
|
137
|
+
const bytes = randomBytes(20)
|
|
138
|
+
let out = ''
|
|
139
|
+
for (const b of bytes) out += chars[b % chars.length]
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function now(): string {
|
|
144
|
+
return new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ===== 密码哈希(scrypt)=====
|
|
148
|
+
|
|
149
|
+
/** 生成 scrypt 哈希(格式 salt:hash,均为 hex) */
|
|
150
|
+
export function hashPassword(password: string): string {
|
|
151
|
+
const salt = randomBytes(16).toString('hex')
|
|
152
|
+
const hash = scryptSync(password, salt, 64).toString('hex')
|
|
153
|
+
return `${salt}:${hash}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 校验密码:长度与哈希双重防御(timingSafeEqual 防时序侧信道) */
|
|
157
|
+
export function verifyPassword(password: string, stored: string): boolean {
|
|
158
|
+
const [salt, hex] = stored.split(':')
|
|
159
|
+
if (!salt || !hex) return false
|
|
160
|
+
const expected = Buffer.from(hex, 'hex')
|
|
161
|
+
const actual = scryptSync(password, salt, expected.length)
|
|
162
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ===== Token 哈希 =====
|
|
166
|
+
|
|
167
|
+
function hashToken(token: string): string {
|
|
168
|
+
return createHash('sha256').update(token).digest('hex')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 签发新 token:明文返回一次并存档(token_plain,支持随时查看),库中另存哈希用于认证比对 */
|
|
172
|
+
function issueToken(userId: string, label: string): { raw: string, row: UserToken } {
|
|
173
|
+
const raw = `ut-${randomUUID().replace(/-/g, '')}`
|
|
174
|
+
const hash = hashToken(raw)
|
|
175
|
+
const row: UserToken = {
|
|
176
|
+
id: randomUUID(),
|
|
177
|
+
userId,
|
|
178
|
+
label,
|
|
179
|
+
createdAt: now(),
|
|
180
|
+
lastUsedAt: null,
|
|
181
|
+
preview: maskPreview(raw),
|
|
182
|
+
hasPlain: true,
|
|
183
|
+
}
|
|
184
|
+
const d = getDb()
|
|
185
|
+
d.prepare('INSERT INTO user_tokens (id, user_id, label, token_hash, token_plain, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
186
|
+
.run(row.id, userId, label, hash, raw, row.createdAt, isoInDays(TOKEN_TTL_DAYS))
|
|
187
|
+
return { raw, row }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 按 token 明文查用户(命中且未过期则刷新 last_used_at);无效/已过期返回 null。tokenId 供前端识别当前会话 token。 */
|
|
191
|
+
export function findByToken(token: string): (User & { tokenId: string }) | null {
|
|
192
|
+
const d = getDb()
|
|
193
|
+
const hash = hashToken(token)
|
|
194
|
+
const row = d.prepare(
|
|
195
|
+
`SELECT u.id, u.name, u.email, u.role, u.status, u.created_at AS createdAt, t.id AS tokenId, t.expires_at AS expiresAt
|
|
196
|
+
FROM user_tokens t JOIN users u ON u.id = t.user_id
|
|
197
|
+
WHERE t.token_hash = ?`,
|
|
198
|
+
).get(hash) as (User & { tokenId: string, expiresAt: string | null }) | undefined
|
|
199
|
+
if (!row) return null
|
|
200
|
+
// R2:过期单点判定(resolveUser/resolveAgentOrUser/me 全部经此收敛)
|
|
201
|
+
if (row.expiresAt && Date.parse(row.expiresAt) < Date.now()) return null
|
|
202
|
+
// 禁用账号即时失效(此前存量 token 在禁用后最长 30 天仍可用)
|
|
203
|
+
if (row.status && row.status !== 'active') return null
|
|
204
|
+
d.prepare('UPDATE user_tokens SET last_used_at = ? WHERE token_hash = ?').run(now(), hash)
|
|
205
|
+
return row
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** users 行 → User 领域对象(禁带 password_hash) */
|
|
209
|
+
function toUser(row: Record<string, unknown>): User {
|
|
210
|
+
return {
|
|
211
|
+
id: String(row.id),
|
|
212
|
+
name: String(row.name),
|
|
213
|
+
email: String(row.email),
|
|
214
|
+
role: row.role as User['role'],
|
|
215
|
+
status: row.status as User['status'],
|
|
216
|
+
createdAt: String(row.created_at ?? row.createdAt),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const userRepository = {
|
|
221
|
+
list({ page, pageSize, keyword }: UserListQuery): Paginated<User> {
|
|
222
|
+
const d = getDb()
|
|
223
|
+
const kw = keyword?.toLowerCase() ?? ''
|
|
224
|
+
const where = kw
|
|
225
|
+
? `WHERE LOWER(name) LIKE ? OR LOWER(email) LIKE ?`
|
|
226
|
+
: ''
|
|
227
|
+
const params = kw ? [`%${kw}%`, `%${kw}%`] : []
|
|
228
|
+
const total = (d.prepare(`SELECT COUNT(*) AS n FROM users ${where}`).get(...params) as { n: number }).n
|
|
229
|
+
const rows = d.prepare(
|
|
230
|
+
`SELECT id, name, email, role, status, created_at AS createdAt FROM users ${where}
|
|
231
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
232
|
+
).all(...params, pageSize, (page - 1) * pageSize) as Array<Record<string, unknown>>
|
|
233
|
+
return { items: rows.map(toUser), total, page, pageSize }
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
findById(id: string): User | undefined {
|
|
237
|
+
const d = getDb()
|
|
238
|
+
const row = d.prepare('SELECT id, name, email, role, status, created_at AS createdAt FROM users WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
|
239
|
+
return row ? toUser(row) : undefined
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
findByEmail(email: string): User | undefined {
|
|
243
|
+
const d = getDb()
|
|
244
|
+
const row = d.prepare('SELECT id, name, email, role, status, created_at AS createdAt FROM users WHERE LOWER(email) = LOWER(?)').get(email) as Record<string, unknown> | undefined
|
|
245
|
+
return row ? toUser(row) : undefined
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
findByName(name: string): User | undefined {
|
|
249
|
+
const d = getDb()
|
|
250
|
+
const row = d.prepare('SELECT id, name, email, role, status, created_at AS createdAt FROM users WHERE name = ?').get(name) as Record<string, unknown> | undefined
|
|
251
|
+
return row ? toUser(row) : undefined
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/** 是否存在活跃管理员(初始化判定:false → 系统处于首启注册模式,首个注册账号将成为管理员) */
|
|
255
|
+
hasActiveAdmin(): boolean {
|
|
256
|
+
const d = getDb()
|
|
257
|
+
const { n } = d.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND status = 'active'").get() as { n: number }
|
|
258
|
+
return n > 0
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/** 内部:取密码哈希(仅认证路径使用,不参与领域对象外泄) */
|
|
262
|
+
getPasswordHash(email: string): { id: string, hash: string } | null {
|
|
263
|
+
const d = getDb()
|
|
264
|
+
const row = d.prepare('SELECT id, password_hash AS hash FROM users WHERE LOWER(email) = LOWER(?)').get(email) as { id: string, hash: string } | undefined
|
|
265
|
+
return row ?? null
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
create(input: UserCreate & { password: string }): User {
|
|
269
|
+
const d = getDb()
|
|
270
|
+
const user: User = {
|
|
271
|
+
id: randomUUID(),
|
|
272
|
+
name: input.name,
|
|
273
|
+
email: input.email,
|
|
274
|
+
role: input.role,
|
|
275
|
+
status: input.status,
|
|
276
|
+
createdAt: now(),
|
|
277
|
+
}
|
|
278
|
+
d.prepare('INSERT INTO users (id, name, email, password_hash, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
279
|
+
.run(user.id, user.name, user.email, hashPassword(input.password), user.role, user.status, user.createdAt)
|
|
280
|
+
return user
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
update(id: string, input: UserUpdate): User | undefined {
|
|
284
|
+
const d = getDb()
|
|
285
|
+
const current = this.findById(id)
|
|
286
|
+
if (!current) return undefined
|
|
287
|
+
const fields: string[] = []
|
|
288
|
+
const params: string[] = []
|
|
289
|
+
if (input.name !== undefined) {
|
|
290
|
+
fields.push('name = ?')
|
|
291
|
+
params.push(input.name)
|
|
292
|
+
}
|
|
293
|
+
if (input.email !== undefined) {
|
|
294
|
+
fields.push('email = ?')
|
|
295
|
+
params.push(input.email)
|
|
296
|
+
}
|
|
297
|
+
if (input.role !== undefined) {
|
|
298
|
+
fields.push('role = ?')
|
|
299
|
+
params.push(input.role)
|
|
300
|
+
}
|
|
301
|
+
if (input.status !== undefined) {
|
|
302
|
+
fields.push('status = ?')
|
|
303
|
+
params.push(input.status)
|
|
304
|
+
}
|
|
305
|
+
if (input.password !== undefined) {
|
|
306
|
+
fields.push('password_hash = ?')
|
|
307
|
+
params.push(hashPassword(input.password))
|
|
308
|
+
}
|
|
309
|
+
if (fields.length === 0) return current
|
|
310
|
+
d.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...params, id)
|
|
311
|
+
return this.findById(id)
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
remove(id: string): boolean {
|
|
315
|
+
const d = getDb()
|
|
316
|
+
return d.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
// ===== API Token(每用户多个,CRUD)=====
|
|
320
|
+
|
|
321
|
+
/** 列出某用户全部 token(含掩码 preview,不含哈希/明文) */
|
|
322
|
+
listTokens(userId: string): UserToken[] {
|
|
323
|
+
const d = getDb()
|
|
324
|
+
const rows = d.prepare(
|
|
325
|
+
'SELECT id, user_id AS userId, label, token_plain AS tokenPlain, created_at AS createdAt, last_used_at AS lastUsedAt FROM user_tokens WHERE user_id = ? ORDER BY created_at ASC',
|
|
326
|
+
).all(userId) as Array<{ id: string, userId: string, label: string, tokenPlain: string | null, createdAt: string, lastUsedAt: string | null }>
|
|
327
|
+
return rows.map(r => ({
|
|
328
|
+
id: r.id,
|
|
329
|
+
userId: r.userId,
|
|
330
|
+
label: r.label,
|
|
331
|
+
createdAt: r.createdAt,
|
|
332
|
+
lastUsedAt: r.lastUsedAt,
|
|
333
|
+
preview: r.tokenPlain ? maskPreview(r.tokenPlain) : null,
|
|
334
|
+
hasPlain: r.tokenPlain != null && r.tokenPlain !== '',
|
|
335
|
+
}))
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/** 创建 token(label 缺省空串;明文仅此一次返回) */
|
|
339
|
+
createToken(userId: string, label: string): { raw: string, row: UserToken } {
|
|
340
|
+
return issueToken(userId, label)
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
/** 按 id 找某用户的 token(越权场景返回 null) */
|
|
344
|
+
findTokenById(userId: string, tokenId: string): UserToken | undefined {
|
|
345
|
+
const d = getDb()
|
|
346
|
+
const row = d.prepare(
|
|
347
|
+
'SELECT id, user_id AS userId, label, created_at AS createdAt, last_used_at AS lastUsedAt FROM user_tokens WHERE id = ? AND user_id = ?',
|
|
348
|
+
).get(tokenId, userId) as unknown as UserToken | undefined
|
|
349
|
+
return row
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/** 按 id 取某用户 token 的存档明文;非本人/不存在 → undefined,未存档(旧数据)→ null */
|
|
353
|
+
findTokenPlain(userId: string, tokenId: string): string | null | undefined {
|
|
354
|
+
const d = getDb()
|
|
355
|
+
const row = d.prepare(
|
|
356
|
+
'SELECT token_plain AS tokenPlain FROM user_tokens WHERE id = ? AND user_id = ?',
|
|
357
|
+
).get(tokenId, userId) as { tokenPlain: string | null } | undefined
|
|
358
|
+
return row ? row.tokenPlain : undefined
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/** 改 token 标签;不存在或非本人返回 false */
|
|
362
|
+
updateTokenLabel(userId: string, tokenId: string, label: string): boolean {
|
|
363
|
+
const d = getDb()
|
|
364
|
+
return d.prepare('UPDATE user_tokens SET label = ? WHERE id = ? AND user_id = ?').run(label, tokenId, userId).changes > 0
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
/** 删除/吊销 token;不存在或非本人返回 false */
|
|
368
|
+
revokeToken(userId: string, tokenId: string): boolean {
|
|
369
|
+
const d = getDb()
|
|
370
|
+
return d.prepare('DELETE FROM user_tokens WHERE id = ? AND user_id = ?').run(tokenId, userId).changes > 0
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/** 登出当前 token(仅吊销该 token 本身) */
|
|
374
|
+
revokeTokenByValue(token: string): boolean {
|
|
375
|
+
const d = getDb()
|
|
376
|
+
return d.prepare('DELETE FROM user_tokens WHERE token_hash = ?').run(hashToken(token)).changes > 0
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/** 测试钩子:替换内部 DB(重开内存库等) */
|
|
380
|
+
_resetForTest(): void {
|
|
381
|
+
db?.close()
|
|
382
|
+
db = null
|
|
383
|
+
},
|
|
384
|
+
}
|