Skip to content
Clone

feat(chat): add chat module with room management, message handling, and session revocation

ZhenYi committed
parent 98e2c80 commit f0a1552 Browse files

36 files changed

+14445 −2315

File tree 36
Modified admin/src/app/layout.tsx View admin/src/app/layout.tsx
@@ -5,8 +5,10 @@
55 LayoutDashboard,
66 ListChecks,
77 LogOut,
8 MessagesSquare,
89 Moon,
910 ScrollText,
11 Settings,
1012 ShieldCheck,
1113 Sun,
1214 Users,
@@ -20,11 +22,13 @@
2022 { to: '/', label: '系统状态', icon: LayoutDashboard, end: true },
2123 { to: '/users', label: '用户', icon: Users },
2224 { to: '/spaces', label: 'Space', icon: Database },
25 { to: '/chat', label: '聊天记录', icon: MessagesSquare },
2326 { to: '/storage', label: '存储', icon: HardDrive },
2427 { to: '/queues', label: '队列', icon: Activity },
2528 { to: '/tasks', label: '任务', icon: ListChecks },
2629 { to: '/logs', label: '日志', icon: ScrollText },
2730 { to: '/admins', label: '管理员', icon: ShieldCheck },
31 { to: '/system', label: '系统配置', icon: Settings },
2832]
2933
3034function roleLabel(role: string): string {
Modified admin/src/app/routes.tsx View admin/src/app/routes.tsx
@@ -1,6 +1,9 @@
11import { createBrowserRouter, Navigate } from 'react-router-dom'
22import { AdminShell } from '@/app/layout'
33import { AdminsPage } from '@/pages/admins'
4import { ChatRoomDetailPage } from '@/pages/chat-room-detail'
5import { ChatRoomsPage } from '@/pages/chat-rooms'
6import { ChatMessagesPage } from '@/pages/chat-messages'
47import { DashboardPage } from '@/pages/dashboard'
58import { LoginPage } from '@/pages/login'
69import { LogsPage } from '@/pages/logs'
@@ -10,6 +13,7 @@
1013import { SpaceDetailPage } from '@/pages/space-detail'
1114import { SpacesPage } from '@/pages/spaces'
1215import { StoragePage } from '@/pages/storage'
16import { SystemPage } from '@/pages/system'
1317import { TasksPage } from '@/pages/tasks'
1418import { UserDetailPage } from '@/pages/user-detail'
1519import { UsersPage } from '@/pages/users'
@@ -26,11 +30,15 @@
2630 { path: 'users/:uid', element: <UserDetailPage /> },
2731 { path: 'spaces', element: <SpacesPage /> },
2832 { path: 'spaces/:id', element: <SpaceDetailPage /> },
33 { path: 'chat', element: <ChatRoomsPage /> },
34 { path: 'chat/rooms/:id', element: <ChatRoomDetailPage /> },
35 { path: 'chat/messages', element: <ChatMessagesPage /> },
2936 { path: 'storage', element: <StoragePage /> },
3037 { path: 'queues', element: <QueuesPage /> },
3138 { path: 'tasks', element: <TasksPage /> },
3239 { path: 'logs', element: <LogsPage /> },
3340 { path: 'admins', element: <AdminsPage /> },
41 { path: 'system', element: <SystemPage /> },
3442 { path: '*', element: <NotFoundPage /> },
3543 ],
3644 },
Modified admin/src/pages/admins.tsx View admin/src/pages/admins.tsx
@@ -167,6 +167,15 @@
167167 >
168168 重置口令
169169 </Button>
170 <Button
171 size="sm"
172 variant="secondary"
173 onClick={() =>
174 void run(() => adminAccounts.revokeSessions(row.id), '该管理员的会话已失效')
175 }
176 >
177 踢出会话
178 </Button>
170179 <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>
171180 删除
172181 </Button>
Added admin/src/pages/chat-messages.tsx View admin/src/pages/chat-messages.tsx
@@ -0,0 +1,615 @@
1import { useCallback, useEffect, useMemo, useState } from 'react'
2import { Link, useSearchParams } from 'react-router-dom'
3import { adminChat } from '@/shared/api'
4import type {
5 AdminChatAttachment,
6 AdminChatMessage,
7 AdminChatMessageDetail,
8} from '@/shared/api'
9import { ApiError } from '@/shared/api'
10import { formatBytes, formatDateTime, prettyJson } from '@/shared/format'
11import {
12 Badge,
13 Button,
14 Card,
15 ConfirmDialog,
16 DataTable,
17 ErrorBanner,
18 Field,
19 Loading,
20 Modal,
21 Pagination,
22 Select,
23 TextInput,
24 statusTone,
25 useToast,
26} from '@/shared/ui'
27
28const PAGE_SIZE = 20
29
30const STATUS_OPTIONS = [
31 { value: '', label: '全部消息' },
32 { value: 'active', label: '未删除' },
33 { value: 'deleted', label: '已删除' },
34 { value: 'pinned', label: '已置顶' },
35]
36
37const ATTACHMENT_STATUS_OPTIONS = [
38 { value: '', label: '全部状态' },
39 { value: 'uploaded', label: '已上传' },
40 { value: 'pending', label: '待上传' },
41]
42
43function toIso(value: string): string | undefined {
44 if (!value) {
45 return undefined
46 }
47 const date = new Date(value)
48 return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
49}
50
51export function ChatMessagesPage() {
52 const toast = useToast()
53 const [params, setParams] = useSearchParams()
54 const roomId = params.get('room_id') ?? ''
55 const [authorId, setAuthorId] = useState('')
56 const [status, setStatus] = useState('')
57 const [query, setQuery] = useState('')
58 const [from, setFrom] = useState('')
59 const [to, setTo] = useState('')
60 const [applied, setApplied] = useState({
61 authorId: '',
62 status: '',
63 query: '',
64 from: '',
65 to: '',
66 })
67 const [offset, setOffset] = useState(0)
68 const [data, setData] = useState<{
69 items: AdminChatMessage[]
70 total: number
71 limit: number
72 offset: number
73 } | null>(null)
74 const [detail, setDetail] = useState<AdminChatMessageDetail | null>(null)
75 const [error, setError] = useState<string | null>(null)
76 const [busy, setBusy] = useState(false)
77 const [selected, setSelected] = useState<string[]>([])
78 const [bulkOpen, setBulkOpen] = useState(false)
79 const [bulkReason, setBulkReason] = useState('')
80 const [tab, setTab] = useState<'messages' | 'attachments'>('messages')
81 const [attachmentStatus, setAttachmentStatus] = useState('')
82 const [attachmentQuery, setAttachmentQuery] = useState('')
83 const [attachmentApplied, setAttachmentApplied] = useState({ status: '', query: '' })
84 const [attachments, setAttachments] = useState<{
85 items: AdminChatAttachment[]
86 total: number
87 limit: number
88 offset: number
89 } | null>(null)
90 const [attachmentOffset, setAttachmentOffset] = useState(0)
91 const [attachmentDelete, setAttachmentDelete] = useState<AdminChatAttachment | null>(null)
92
93 const messageParams = useMemo(
94 () => ({
95 room_id: roomId || undefined,
96 author_id: applied.authorId || undefined,
97 status: applied.status || undefined,
98 q: applied.query || undefined,
99 from: toIso(applied.from),
100 to: toIso(applied.to),
101 limit: PAGE_SIZE,
102 offset,
103 }),
104 [roomId, applied, offset],
105 )
106
107 const load = useCallback(async () => {
108 setBusy(true)
109 setError(null)
110 try {
111 const [page, attachmentPage] = await Promise.all([
112 adminChat.messages(messageParams),
113 adminChat.attachments({
114 room_id: roomId || undefined,
115 status: attachmentApplied.status || undefined,
116 q: attachmentApplied.query || undefined,
117 limit: PAGE_SIZE,
118 offset: attachmentOffset,
119 }),
120 ])
121 setData(page)
122 setAttachments(attachmentPage)
123 setSelected((current) =>
124 current.filter((id) => page.items.some((item) => item.id === id)),
125 )
126 } catch (err) {
127 setError(err instanceof ApiError ? err.message : '消息列表加载失败')
128 } finally {
129 setBusy(false)
130 }
131 }, [messageParams, roomId, attachmentApplied, attachmentOffset])
132
133 useEffect(() => {
134 void load()
135 }, [load])
136
137 const run = async (action: () => Promise<unknown>, message: string) => {
138 try {
139 await action()
140 toast(message)
141 setBulkOpen(false)
142 setAttachmentDelete(null)
143 await load()
144 } catch (err) {
145 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
146 }
147 }
148
149 const allSelected = data !== null && data.items.length > 0 && selected.length === data.items.length
150
151 const toggleRoomFilter = (value: string) => {
152 const next = new URLSearchParams(params)
153 if (value) {
154 next.set('room_id', value)
155 } else {
156 next.delete('room_id')
157 }
158 setParams(next)
159 setOffset(0)
160 }
161
162 return (
163 <div className="space-y-4">
164 <div className="flex flex-wrap items-end justify-between gap-3">
165 <div>
166 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">消息管理</h1>
167 <p className="text-xs text-slate-500">
168 全站消息检索、内容审核、批量删除与聊天附件清理
169 </p>
170 </div>
171 <div className="flex items-end gap-2">
172 <Button
173 variant="secondary"
174 onClick={() => adminChat.exportMessagesCsv(messageParams)}
175 disabled={!data || data.total === 0}
176 >
177 导出 CSV
178 </Button>
179 <Link
180 to="/chat"
181 className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
182 >
183 返回房间
184 </Link>
185 </div>
186 </div>
187
188 <div className="flex gap-2">
189 <Button
190 variant={tab === 'messages' ? 'primary' : 'secondary'}
191 size="sm"
192 onClick={() => setTab('messages')}
193 >
194 消息
195 </Button>
196 <Button
197 variant={tab === 'attachments' ? 'primary' : 'secondary'}
198 size="sm"
199 onClick={() => setTab('attachments')}
200 >
201 附件
202 </Button>
203 </div>
204
205 <ErrorBanner message={error} />
206
207 {tab === 'messages' ? (
208 <Card
209 title={`消息列表${roomId ? `(房间 ${roomId})` : ''}`}
210 action={
211 selected.length > 0 ? (
212 <div className="flex items-center gap-2">
213 <span className="text-xs text-slate-500">已选 {selected.length} 条</span>
214 <Button size="sm" variant="danger" onClick={() => setBulkOpen(true)}>
215 批量删除
216 </Button>
217 </div>
218 ) : (
219 <span className="text-xs text-slate-400">勾选消息后可批量删除</span>
220 )
221 }
222 >
223 <form
224 className="mb-3 flex flex-wrap items-end gap-2"
225 onSubmit={(event) => {
226 event.preventDefault()
227 setOffset(0)
228 setApplied({ authorId, status, query, from, to })
229 }}
230 >
231 <Field label="房间 ID">
232 <TextInput
233 value={roomId}
234 onChange={toggleRoomFilter}
235 placeholder="room uuid"
236 />
237 </Field>
238 <Field label="作者 ID">
239 <TextInput value={authorId} onChange={setAuthorId} placeholder="user uuid" />
240 </Field>
241 <Field label="状态">
242 <Select value={status} onChange={setStatus} options={STATUS_OPTIONS} />
243 </Field>
244 <Field label="关键字">
245 <TextInput value={query} onChange={setQuery} placeholder="消息内容" />
246 </Field>
247 <Field label="起始时间">
248 <TextInput value={from} onChange={setFrom} type="datetime-local" />
249 </Field>
250 <Field label="结束时间">
251 <TextInput value={to} onChange={setTo} type="datetime-local" />
252 </Field>
253 <Button type="submit" disabled={busy}>
254 查询
255 </Button>
256 </form>
257
258 {data ? (
259 <>
260 <DataTable
261 columns={[
262 {
263 key: 'select',
264 title: (
265 <input
266 type="checkbox"
267 checked={allSelected}
268 onChange={(event) =>
269 setSelected(event.target.checked ? data.items.map((row) => row.id) : [])
270 }
271 />
272 ),
273 render: (row) => (
274 <input
275 type="checkbox"
276 checked={selected.includes(row.id)}
277 onChange={(event) =>
278 setSelected((current) =>
279 event.target.checked
280 ? [...current, row.id]
281 : current.filter((id) => id !== row.id),
282 )
283 }
284 />
285 ),
286 },
287 {
288 key: 'content',
289 title: '内容',
290 render: (row) => (
291 <div className="min-w-0 max-w-[320px]">
292 <div className="truncate text-sm">
293 {row.deleted_at ? (
294 <span className="text-slate-400">(已删除)</span>
295 ) : (
296 row.content || '-'
297 )}
298 </div>
299 <div className="text-xs text-slate-400">{row.id}</div>
300 </div>
301 ),
302 },
303 {
304 key: 'room',
305 title: '房间',
306 render: (row) => (
307 <Link
308 to={`/chat/rooms/${row.room_id}`}
309 className="text-xs text-sky-600 hover:underline dark:text-sky-400"
310 >
311 {row.room_name ?? row.room_id}
312 </Link>
313 ),
314 },
315 {
316 key: 'author',
317 title: '作者',
318 render: (row) => (
319 <div className="text-xs">
320 <div>{row.author_display_name ?? row.author_username ?? row.author_id}</div>
321 <div className="text-slate-400">{row.author_type}</div>
322 </div>
323 ),
324 },
325 {
326 key: 'stats',
327 title: '附件/表情',
328 render: (row) => (
329 <span className="text-xs text-slate-500">
330 {row.attachment_count} / {row.reaction_count}
331 </span>
332 ),
333 },
334 {
335 key: 'flags',
336 title: '状态',
337 render: (row) => (
338 <div className="flex flex-wrap gap-1">
339 <Badge tone={row.deleted_at ? 'danger' : 'success'}>
340 {row.deleted_at ? '已删除' : '正常'}
341 </Badge>
342 {row.pinned && <Badge tone="info">置顶</Badge>}
343 {row.edited_at && <Badge tone="warning">已编辑</Badge>}
344 </div>
345 ),
346 },
347 {
348 key: 'created',
349 title: '时间',
350 render: (row) => (
351 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
352 ),
353 },
354 {
355 key: 'actions',
356 title: '操作',
357 render: (row) => (
358 <Button
359 size="sm"
360 variant="secondary"
361 onClick={async () => {
362 try {
363 setDetail(await adminChat.message(row.id))
364 } catch (err) {
365 toast(err instanceof ApiError ? err.message : '详情加载失败', 'danger')
366 }
367 }}
368 >
369 详情
370 </Button>
371 ),
372 },
373 ]}
374 rows={data.items}
375 rowKey={(row) => row.id}
376 empty="没有消息"
377 />
378 <Pagination
379 total={data.total}
380 limit={data.limit}
381 offset={data.offset}
382 onChange={setOffset}
383 />
384 </>
385 ) : (
386 <Loading />
387 )}
388 </Card>
389 ) : (
390 <Card title={`聊天附件${roomId ? `(房间 ${roomId})` : ''}`}>
391 <form
392 className="mb-3 flex flex-wrap items-end gap-2"
393 onSubmit={(event) => {
394 event.preventDefault()
395 setAttachmentOffset(0)
396 setAttachmentApplied({ status: attachmentStatus, query: attachmentQuery })
397 }}
398 >
399 <Field label="状态">
400 <Select
401 value={attachmentStatus}
402 onChange={setAttachmentStatus}
403 options={ATTACHMENT_STATUS_OPTIONS}
404 />
405 </Field>
406 <Field label="关键字">
407 <TextInput
408 value={attachmentQuery}
409 onChange={setAttachmentQuery}
410 placeholder="文件名 / S3 key"
411 />
412 </Field>
413 <Button type="submit" disabled={busy}>
414 查询
415 </Button>
416 </form>
417
418 {attachments ? (
419 <>
420 <DataTable
421 columns={[
422 { key: 'filename', title: '文件', render: (row) => row.filename },
423 {
424 key: 'owner',
425 title: '上传者',
426 render: (row) => row.display_name ?? row.username ?? row.user_id,
427 },
428 {
429 key: 'type',
430 title: '类型',
431 render: (row) => (
432 <span className="text-xs text-slate-500">{row.content_type}</span>
433 ),
434 },
435 {
436 key: 'size',
437 title: '大小',
438 render: (row) => formatBytes(row.size),
439 },
440 {
441 key: 'status',
442 title: '状态',
443 render: (row) => (
444 <Badge tone={row.status === 'uploaded' ? 'success' : 'warning'}>
445 {row.status}
446 </Badge>
447 ),
448 },
449 {
450 key: 'created',
451 title: '时间',
452 render: (row) => (
453 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
454 ),
455 },
456 {
457 key: 'actions',
458 title: '操作',
459 render: (row) => (
460 <Button size="sm" variant="danger" onClick={() => setAttachmentDelete(row)}>
461 删除
462 </Button>
463 ),
464 },
465 ]}
466 rows={attachments.items}
467 rowKey={(row) => row.id}
468 empty="没有附件"
469 />
470 <Pagination
471 total={attachments.total}
472 limit={attachments.limit}
473 offset={attachments.offset}
474 onChange={setAttachmentOffset}
475 />
476 </>
477 ) : (
478 <Loading />
479 )}
480 </Card>
481 )}
482
483 <Modal open={detail !== null} title="消息详情" onClose={() => setDetail(null)}>
484 {detail && (
485 <div className="space-y-3">
486 <div className="flex flex-wrap items-center gap-2">
487 <Badge tone={statusTone(detail.message.deleted_at ? 'deleted' : 'active')}>
488 {detail.message.deleted_at ? '已删除' : '正常'}
489 </Badge>
490 <span className="text-xs text-slate-500">
491 {detail.message.message_type} · {detail.message.id}
492 </span>
493 </div>
494 <div className="rounded bg-slate-100 p-2 text-sm break-all whitespace-pre-wrap dark:bg-slate-800">
495 {detail.message.content || '(空内容)'}
496 </div>
497 <dl className="grid grid-cols-2 gap-2 text-xs text-slate-600 dark:text-slate-300">
498 <div>房间:{detail.message.room_name ?? detail.message.room_id}</div>
499 <div>作者:{detail.message.author_username ?? detail.message.author_id}</div>
500 <div>序号:{detail.message.seq}</div>
501 <div>创建:{formatDateTime(detail.message.created_at)}</div>
502 <div>编辑:{formatDateTime(detail.message.edited_at)}</div>
503 <div>删除:{formatDateTime(detail.message.deleted_at)}</div>
504 </dl>
505 {detail.reactions.length > 0 && (
506 <div>
507 <div className="mb-1 text-xs font-medium text-slate-500">表情反应</div>
508 <div className="flex flex-wrap gap-1">
509 {detail.reactions.map((reaction) => (
510 <Badge key={reaction.emoji} tone="info">
511 {reaction.emoji} {reaction.count}
512 </Badge>
513 ))}
514 </div>
515 </div>
516 )}
517 {detail.thread && (
518 <div className="text-xs text-slate-500">
519 线程:{detail.thread.name ?? detail.thread.id}({detail.thread.reply_count} 回复 /{' '}
520 {detail.thread.participant_count} 参与者)
521 </div>
522 )}
523 {detail.edits.length > 0 && (
524 <div>
525 <div className="mb-1 text-xs font-medium text-slate-500">编辑历史</div>
526 <div className="max-h-40 space-y-1 overflow-y-auto">
527 {detail.edits.map((edit) => (
528 <div key={edit.id} className="rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
529 <div className="text-slate-400">
530 {edit.editor_username ?? edit.editor_id} · {formatDateTime(edit.created_at)}
531 </div>
532 <div className="break-all whitespace-pre-wrap">{edit.content}</div>
533 </div>
534 ))}
535 </div>
536 </div>
537 )}
538 {detail.attachments.length > 0 && (
539 <div>
540 <div className="mb-1 text-xs font-medium text-slate-500">附件</div>
541 <div className="space-y-1">
542 {detail.attachments.map((attachment) => (
543 <div
544 key={attachment.id}
545 className="flex items-center justify-between rounded bg-slate-100 p-2 text-xs dark:bg-slate-800"
546 >
547 <span className="break-all">{attachment.filename}</span>
548 <span className="text-slate-500">{formatBytes(attachment.size)}</span>
549 </div>
550 ))}
551 </div>
552 </div>
553 )}
554 <details>
555 <summary className="cursor-pointer text-xs text-slate-500">原始记录</summary>
556 <pre className="mt-1 max-h-60 overflow-auto rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
557 {prettyJson(detail.message)}
558 </pre>
559 </details>
560 </div>
561 )}
562 </Modal>
563
564 <Modal
565 open={bulkOpen}
566 title="批量删除消息"
567 onClose={() => setBulkOpen(false)}
568 footer={
569 <>
570 <Button variant="secondary" onClick={() => setBulkOpen(false)}>
571 取消
572 </Button>
573 <Button
574 variant="danger"
575 onClick={() =>
576 void run(
577 () =>
578 adminChat.bulkDelete({
579 message_ids: selected,
580 reason: bulkReason || undefined,
581 }),
582 '批量删除已完成',
583 )
584 }
585 >
586 删除 {selected.length} 条
587 </Button>
588 </>
589 }
590 >
591 <p>将清空所选消息内容并移除其表情、编辑记录与附件引用,操作写入审计日志。</p>
592 <Field label="删除原因" hint="可选,会记录在审计日志中">
593 <TextInput value={bulkReason} onChange={setBulkReason} />
594 </Field>
595 </Modal>
596
597 <ConfirmDialog
598 open={attachmentDelete !== null}
599 title="删除聊天附件"
600 description={`将删除附件 ${attachmentDelete?.filename ?? ''} 的记录,并在没有其它引用时清理对象存储文件。`}
601 confirmLabel="删除附件"
602 requireText={attachmentDelete?.filename}
603 onClose={() => setAttachmentDelete(null)}
604 onConfirm={() => {
605 if (!attachmentDelete) {
606 return
607 }
608 void run(() => adminChat.removeAttachment(attachmentDelete.id), '附件已删除')
609 }}
610 />
611
612 {busy && <Loading />}
613 </div>
614 )
615}
Added admin/src/pages/chat-room-detail.tsx View admin/src/pages/chat-room-detail.tsx
@@ -0,0 +1,316 @@
1import { useCallback, useEffect, useState } from 'react'
2import { Link, useParams } from 'react-router-dom'
3import { adminChat } from '@/shared/api'
4import type { AdminChatRoomDetail, AdminChatRoomMember } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatBytes, formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 Checkbox,
12 ConfirmDialog,
13 DataTable,
14 ErrorBanner,
15 Field,
16 Loading,
17 Modal,
18 Pagination,
19 TextInput,
20 useToast,
21} from '@/shared/ui'
22
23const PAGE_SIZE = 20
24
25export function ChatRoomDetailPage() {
26 const { id = '' } = useParams()
27 const toast = useToast()
28 const [detail, setDetail] = useState<AdminChatRoomDetail | null>(null)
29 const [error, setError] = useState<string | null>(null)
30 const [busy, setBusy] = useState(false)
31 const [name, setName] = useState('')
32 const [topic, setTopic] = useState('')
33 const [editOpen, setEditOpen] = useState(false)
34 const [purgeOpen, setPurgeOpen] = useState(false)
35 const [deleteOpen, setDeleteOpen] = useState(false)
36 const [members, setMembers] = useState<{
37 items: AdminChatRoomMember[]
38 total: number
39 limit: number
40 offset: number
41 } | null>(null)
42 const [memberOffset, setMemberOffset] = useState(0)
43 const [memberStatus, setMemberStatus] = useState('active')
44
45 const load = useCallback(async () => {
46 setBusy(true)
47 setError(null)
48 try {
49 const [value, memberPage] = await Promise.all([
50 adminChat.room(id),
51 adminChat.members(id, { status: memberStatus, limit: PAGE_SIZE, offset: memberOffset }),
52 ])
53 setDetail(value)
54 setName(value.room.name)
55 setTopic(value.room.topic ?? '')
56 setMembers(memberPage)
57 } catch (err) {
58 setError(err instanceof ApiError ? err.message : '房间详情加载失败')
59 } finally {
60 setBusy(false)
61 }
62 }, [id, memberStatus, memberOffset])
63
64 useEffect(() => {
65 void load()
66 }, [load])
67
68 const run = async (action: () => Promise<unknown>, message: string) => {
69 try {
70 await action()
71 toast(message)
72 setEditOpen(false)
73 setPurgeOpen(false)
74 setDeleteOpen(false)
75 await load()
76 } catch (err) {
77 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
78 }
79 }
80
81 if (!detail) {
82 return (
83 <div className="space-y-3">
84 <ErrorBanner message={error} />
85 <Loading />
86 </div>
87 )
88 }
89
90 const room = detail.room
91
92 return (
93 <div className="space-y-4">
94 <div className="flex flex-wrap items-center justify-between gap-3">
95 <div>
96 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{room.name}</h1>
97 <p className="text-xs text-slate-500">
98 {room.kind} · {room.id}
99 </p>
100 </div>
101 <div className="flex flex-wrap gap-2">
102 <Link
103 to={`/chat/messages?room_id=${room.id}`}
104 className="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
105 >
106 查看消息
107 </Link>
108 <Button variant="secondary" size="md" onClick={() => setEditOpen(true)}>
109 编辑属性
110 </Button>
111 <Button variant="secondary" size="md" onClick={() => setPurgeOpen(true)}>
112 清空消息
113 </Button>
114 <Button variant="danger" size="md" onClick={() => setDeleteOpen(true)}>
115 删除房间
116 </Button>
117 </div>
118 </div>
119
120 <ErrorBanner message={error} />
121
122 <div className="grid gap-3 lg:grid-cols-3">
123 <Card title="房间状态">
124 <div className="flex flex-wrap gap-1">
125 <Badge tone={room.is_active ? 'success' : 'danger'}>{room.is_active ? '启用' : '停用'}</Badge>
126 {room.is_private && <Badge tone="warning">私有</Badge>}
127 {room.nsfw && <Badge tone="danger">NSFW</Badge>}
128 </div>
129 <dl className="mt-3 space-y-1 text-xs text-slate-600 dark:text-slate-300">
130 <div>所有者:{room.owner_username ?? room.owner_id}</div>
131 <div>Space:{room.space_name ?? '-'}</div>
132 <div>主题:{room.topic ?? '-'}</div>
133 <div>创建:{formatDateTime(room.created_at)}</div>
134 <div>更新:{formatDateTime(room.updated_at)}</div>
135 <div>最近消息:{formatDateTime(room.last_message_at)}</div>
136 </dl>
137 </Card>
138
139 <Card title="内容统计">
140 <dl className="divide-y divide-slate-100 text-sm dark:divide-slate-800">
141 {(
142 [
143 ['消息', room.messages],
144 ['已删除消息', detail.deleted_messages],
145 ['线程', detail.threads],
146 ['成员', room.members],
147 ['封禁', detail.bans],
148 ['邀请', detail.invites],
149 ['附件', detail.attachments],
150 ['附件用量', formatBytes(detail.attachment_bytes)],
151 ] as [string, string | number][]
152 ).map(([label, value]) => (
153 <div key={label} className="flex items-center justify-between py-1.5">
154 <dt className="text-slate-500 dark:text-slate-400">{label}</dt>
155 <dd className="text-slate-800 dark:text-slate-100">{value}</dd>
156 </div>
157 ))}
158 </dl>
159 </Card>
160
161 <Card title="成员管理">
162 <div className="flex items-center gap-2">
163 <Checkbox
164 checked={memberStatus === 'active'}
165 onChange={(value) => {
166 setMemberOffset(0)
167 setMemberStatus(value ? 'active' : '')
168 }}
169 label="仅在席成员"
170 />
171 </div>
172 {members ? (
173 <>
174 <DataTable
175 columns={[
176 {
177 key: 'user',
178 title: '成员',
179 render: (row) => (
180 <div>
181 <div className="text-sm">{row.display_name ?? row.username ?? row.user_id}</div>
182 <div className="text-xs text-slate-400">{row.username ?? row.user_id}</div>
183 </div>
184 ),
185 },
186 { key: 'role', title: '角色', render: (row) => row.role },
187 {
188 key: 'state',
189 title: '状态',
190 render: (row) =>
191 row.left_at ? (
192 <Badge tone="neutral">已离开</Badge>
193 ) : (
194 <Badge tone="success">在席</Badge>
195 ),
196 },
197 {
198 key: 'actions',
199 title: '操作',
200 render: (row) =>
201 row.left_at ? (
202 <span className="text-xs text-slate-400">-</span>
203 ) : (
204 <Button
205 size="sm"
206 variant="danger"
207 onClick={() =>
208 void run(
209 () => adminChat.removeMember(room.id, row.user_id),
210 '成员已移出房间',
211 )
212 }
213 >
214 移出
215 </Button>
216 ),
217 },
218 ]}
219 rows={members.items}
220 rowKey={(row) => row.user_id}
221 empty="没有成员"
222 />
223 <Pagination
224 total={members.total}
225 limit={members.limit}
226 offset={members.offset}
227 onChange={setMemberOffset}
228 />
229 </>
230 ) : (
231 <Loading />
232 )}
233 </Card>
234 </div>
235
236 <Modal
237 open={editOpen}
238 title="编辑房间属性"
239 onClose={() => setEditOpen(false)}
240 footer={
241 <>
242 <Button variant="secondary" onClick={() => setEditOpen(false)}>
243 取消
244 </Button>
245 <Button
246 onClick={() =>
247 void run(
248 () =>
249 adminChat.updateRoom(room.id, {
250 name,
251 topic,
252 }),
253 '房间属性已更新',
254 )
255 }
256 >
257 保存
258 </Button>
259 </>
260 }
261 >
262 <Field label="名称">
263 <TextInput value={name} onChange={setName} />
264 </Field>
265 <Field label="主题">
266 <TextInput value={topic} onChange={setTopic} />
267 </Field>
268 <div className="flex flex-wrap gap-4">
269 <Checkbox
270 checked={room.is_private}
271 onChange={(value) =>
272 void run(() => adminChat.updateRoom(room.id, { is_private: value }), '可见性已更新')
273 }
274 label="私有房间"
275 />
276 <Checkbox
277 checked={room.nsfw}
278 onChange={(value) =>
279 void run(() => adminChat.updateRoom(room.id, { nsfw: value }), 'NSFW 标记已更新')
280 }
281 label="NSFW"
282 />
283 <Checkbox
284 checked={room.is_active}
285 onChange={(value) =>
286 void run(() => adminChat.updateRoom(room.id, { is_active: value }), '启停已更新')
287 }
288 label="启用房间"
289 />
290 </div>
291 </Modal>
292
293 <ConfirmDialog
294 open={purgeOpen}
295 title="清空房间消息"
296 description="将永久删除该房间全部消息、线程与附件(保留房间本身),操作不可恢复,并写入审计日志。"
297 confirmLabel="清空消息"
298 requireText={room.name}
299 onClose={() => setPurgeOpen(false)}
300 onConfirm={() => void run(() => adminChat.purgeRoom(room.id), '房间消息已清空')}
301 />
302
303 <ConfirmDialog
304 open={deleteOpen}
305 title="删除房间"
306 description="将永久删除该房间及其全部消息、成员、线程与附件,操作不可恢复。"
307 confirmLabel="删除房间"
308 requireText={room.name}
309 onClose={() => setDeleteOpen(false)}
310 onConfirm={() => void run(() => adminChat.removeRoom(room.id), '房间已删除')}
311 />
312
313 {busy && <Loading />}
314 </div>
315 )
316}
Added admin/src/pages/chat-rooms.tsx View admin/src/pages/chat-rooms.tsx
@@ -0,0 +1,234 @@
1import { useCallback, useEffect, useState } from 'react'
2import { Link } from 'react-router-dom'
3import { adminChat } from '@/shared/api'
4import type { AdminChatRoomSummary, AdminChatStats } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 DataTable,
12 ErrorBanner,
13 Field,
14 Loading,
15 Pagination,
16 Select,
17 TextInput,
18} from '@/shared/ui'
19
20const PAGE_SIZE = 20
21
22const KIND_OPTIONS = [
23 { value: '', label: '全部类型' },
24 { value: 'text', label: 'text' },
25 { value: 'voice', label: 'voice' },
26 { value: 'category', label: 'category' },
27 { value: 'news', label: 'news' },
28 { value: 'stage', label: 'stage' },
29 { value: 'thread', label: 'thread' },
30 { value: 'guild_room', label: 'guild_room' },
31 { value: 'group_dm', label: 'group_dm' },
32 { value: 'dm', label: 'dm' },
33 { value: 'direct_message', label: 'direct_message' },
34]
35
36const STATUS_OPTIONS = [
37 { value: '', label: '全部状态' },
38 { value: 'active', label: '启用' },
39 { value: 'disabled', label: '停用' },
40]
41
42const VISIBILITY_OPTIONS = [
43 { value: '', label: '全部可见性' },
44 { value: 'public', label: '公开' },
45 { value: 'private', label: '私有' },
46]
47
48export function ChatRoomsPage() {
49 const [query, setQuery] = useState('')
50 const [kind, setKind] = useState('')
51 const [status, setStatus] = useState('')
52 const [visibility, setVisibility] = useState('')
53 const [applied, setApplied] = useState({ query: '', kind: '', status: '', visibility: '' })
54 const [offset, setOffset] = useState(0)
55 const [data, setData] = useState<{ items: AdminChatRoomSummary[]; total: number; limit: number; offset: number } | null>(null)
56 const [stats, setStats] = useState<AdminChatStats | null>(null)
57 const [error, setError] = useState<string | null>(null)
58 const [busy, setBusy] = useState(false)
59
60 const load = useCallback(async () => {
61 setBusy(true)
62 setError(null)
63 try {
64 const [page, statsValue] = await Promise.all([
65 adminChat.rooms({
66 q: applied.query || undefined,
67 kind: applied.kind || undefined,
68 status: applied.status || undefined,
69 visibility: applied.visibility || undefined,
70 limit: PAGE_SIZE,
71 offset,
72 }),
73 adminChat.stats(),
74 ])
75 setData(page)
76 setStats(statsValue)
77 } catch (err) {
78 setError(err instanceof ApiError ? err.message : '房间列表加载失败')
79 } finally {
80 setBusy(false)
81 }
82 }, [applied, offset])
83
84 useEffect(() => {
85 void load()
86 }, [load])
87
88 return (
89 <div className="space-y-4">
90 <div className="flex flex-wrap items-end justify-between gap-3">
91 <div>
92 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">聊天记录</h1>
93 <p className="text-xs text-slate-500">房间、消息、线程与聊天附件的统一管理视图</p>
94 </div>
95 <form
96 className="flex flex-wrap items-end gap-2"
97 onSubmit={(event) => {
98 event.preventDefault()
99 setOffset(0)
100 setApplied({ query, kind, status, visibility })
101 }}
102 >
103 <Field label="关键字">
104 <TextInput value={query} onChange={setQuery} placeholder="房间名" />
105 </Field>
106 <Field label="类型">
107 <Select value={kind} onChange={setKind} options={KIND_OPTIONS} />
108 </Field>
109 <Field label="状态">
110 <Select value={status} onChange={setStatus} options={STATUS_OPTIONS} />
111 </Field>
112 <Field label="可见性">
113 <Select value={visibility} onChange={setVisibility} options={VISIBILITY_OPTIONS} />
114 </Field>
115 <Button type="submit" disabled={busy}>
116 查询
117 </Button>
118 </form>
119 </div>
120
121 <ErrorBanner message={error} />
122
123 {stats && (
124 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
125 {(
126 [
127 ['房间', stats.rooms],
128 ['启用房间', stats.active_rooms],
129 ['私有房间', stats.private_rooms],
130 ['消息', stats.messages],
131 ['24h 消息', stats.messages_24h],
132 ['已删除消息', stats.deleted_messages],
133 ['线程', stats.threads],
134 ['附件', stats.attachments],
135 ['待上传附件', stats.pending_attachments],
136 ['成员', stats.members],
137 ['封禁', stats.bans],
138 ] as [string, number][]
139 ).map(([label, value]) => (
140 <Card key={label} title={label}>
141 <div className="text-xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
142 </Card>
143 ))}
144 </div>
145 )}
146
147 <Card
148 title="房间列表"
149 action={
150 <Link
151 to="/chat/messages"
152 className="text-xs text-sky-600 hover:underline dark:text-sky-400"
153 >
154 消息管理 →
155 </Link>
156 }
157 >
158 {data ? (
159 <>
160 <DataTable
161 columns={[
162 {
163 key: 'name',
164 title: '房间',
165 render: (row) => (
166 <div className="min-w-0">
167 <Link
168 to={`/chat/rooms/${row.id}`}
169 className="font-medium text-sky-600 hover:underline dark:text-sky-400"
170 >
171 {row.name}
172 </Link>
173 <div className="text-xs text-slate-400">{row.id}</div>
174 </div>
175 ),
176 },
177 {
178 key: 'kind',
179 title: '类型',
180 render: (row) => <Badge tone="info">{row.kind}</Badge>,
181 },
182 {
183 key: 'space',
184 title: 'Space',
185 render: (row) => row.space_name ?? <span className="text-slate-400">-</span>,
186 },
187 {
188 key: 'owner',
189 title: '所有者',
190 render: (row) => row.owner_username ?? row.owner_id,
191 },
192 {
193 key: 'counts',
194 title: '成员/消息',
195 render: (row) => (
196 <span className="text-xs text-slate-500">
197 {row.members} / {row.messages}
198 </span>
199 ),
200 },
201 {
202 key: 'flags',
203 title: '可见性',
204 render: (row) => (
205 <div className="flex flex-wrap gap-1">
206 <Badge tone={row.is_active ? 'success' : 'danger'}>
207 {row.is_active ? '启用' : '停用'}
208 </Badge>
209 {row.is_private && <Badge tone="warning">私有</Badge>}
210 {row.nsfw && <Badge tone="danger">NSFW</Badge>}
211 </div>
212 ),
213 },
214 {
215 key: 'last',
216 title: '最近消息',
217 render: (row) => (
218 <span className="text-xs text-slate-500">{formatDateTime(row.last_message_at)}</span>
219 ),
220 },
221 ]}
222 rows={data.items}
223 rowKey={(row) => row.id}
224 empty="没有房间"
225 />
226 <Pagination total={data.total} limit={data.limit} offset={data.offset} onChange={setOffset} />
227 </>
228 ) : (
229 <Loading />
230 )}
231 </Card>
232 </div>
233 )
234}
Modified admin/src/pages/dashboard.tsx View admin/src/pages/dashboard.tsx
@@ -1,10 +1,17 @@
11import { useCallback, useEffect, useState } from 'react'
2import { Link } from 'react-router-dom'
23import { adminSystem } from '@/shared/api'
3import type { SystemStatus } from '@/shared/api'
4import { formatDateTime, formatUptime } from '@/shared/format'
4import type { Dashboard, SystemStatus, TrendPoint } from '@/shared/api'
5import { formatDateTime, formatUptime, shortId } from '@/shared/format'
56import { Badge, Button, Card, DataTable, ErrorBanner, Loading, statusTone } from '@/shared/ui'
67
7function DependencyCard({ name, dependency }: { name: string; dependency: { ok: boolean; latency_ms: number | null; detail: string | null } }) {
8function DependencyCard({
9 name,
10 dependency,
11}: {
12 name: string
13 dependency: { ok: boolean; latency_ms: number | null; detail: string | null }
14}) {
815 return (
916 <Card title={name}>
1017 <div className="flex items-center justify-between">
@@ -20,8 +27,53 @@
2027 )
2128}
2229
30function TrendChart({ trend }: { trend: TrendPoint[] }) {
31 const max = Math.max(1, ...trend.map((point) => Math.max(point.users, point.spaces, point.repos)))
32 return (
33 <div>
34 <div className="flex h-36 items-end gap-1">
35 {trend.map((point) => (
36 <div
37 key={point.date}
38 className="flex h-full flex-1 flex-col items-center justify-end gap-1"
39 title={`${point.date} 用户 ${point.users} · Space ${point.spaces} · 仓库 ${point.repos}`}
40 >
41 <div className="flex h-full w-full items-end justify-center gap-[2px]">
42 <div
43 className="w-1/4 rounded-t bg-sky-500"
44 style={{ height: `${Math.round((point.users / max) * 100)}%` }}
45 />
46 <div
47 className="w-1/4 rounded-t bg-emerald-500"
48 style={{ height: `${Math.round((point.spaces / max) * 100)}%` }}
49 />
50 <div
51 className="w-1/4 rounded-t bg-violet-500"
52 style={{ height: `${Math.round((point.repos / max) * 100)}%` }}
53 />
54 </div>
55 <span className="text-[10px] text-slate-400">{point.date.slice(5)}</span>
56 </div>
57 ))}
58 </div>
59 <div className="mt-2 flex gap-4 text-xs text-slate-500">
60 <span className="flex items-center gap-1">
61 <span className="inline-block h-2 w-2 rounded bg-sky-500" /> 新用户
62 </span>
63 <span className="flex items-center gap-1">
64 <span className="inline-block h-2 w-2 rounded bg-emerald-500" /> 新 Space
65 </span>
66 <span className="flex items-center gap-1">
67 <span className="inline-block h-2 w-2 rounded bg-violet-500" /> 新仓库
68 </span>
69 </div>
70 </div>
71 )
72}
73
2374export function DashboardPage() {
2475 const [status, setStatus] = useState<SystemStatus | null>(null)
76 const [dashboard, setDashboard] = useState<Dashboard | null>(null)
2577 const [error, setError] = useState<string | null>(null)
2678 const [busy, setBusy] = useState(false)
2779
@@ -29,7 +81,12 @@
2981 setBusy(true)
3082 setError(null)
3183 try {
32 setStatus(await adminSystem.status())
84 const [statusValue, dashboardValue] = await Promise.all([
85 adminSystem.status(),
86 adminSystem.dashboard(),
87 ])
88 setStatus(statusValue)
89 setDashboard(dashboardValue)
3390 } catch {
3491 setError('系统状态加载失败')
3592 } finally {
@@ -45,7 +102,7 @@
45102 return () => window.clearInterval(timer)
46103 }, [load])
47104
48 if (!status) {
105 if (!status || !dashboard) {
49106 return (
50107 <div className="space-y-3">
51108 <ErrorBanner message={error} />
@@ -54,7 +111,15 @@
54111 )
55112 }
56113
57 const counts = status.counts
114 const counts = dashboard.counts
115 const trendTotals = dashboard.trend.reduce(
116 (acc, point) => ({
117 users: acc.users + point.users,
118 spaces: acc.spaces + point.spaces,
119 repos: acc.repos + point.repos,
120 }),
121 { users: 0, spaces: 0, repos: 0 },
122 )
58123
59124 return (
60125 <div className="space-y-4">
@@ -63,7 +128,7 @@
63128 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">系统状态</h1>
64129 <p className="text-xs text-slate-500">
65130 adminhost v{status.process.version} · 运行 {formatUptime(status.process.uptime_secs)} · 更新于{' '}
66 {formatDateTime(status.generated_at)}
131 {formatDateTime(dashboard.generated_at)}
67132 </p>
68133 </div>
69134 <Button variant="secondary" size="sm" disabled={busy} onClick={() => void load()}>
@@ -80,18 +145,21 @@
80145 <DependencyCard name="搜索" dependency={status.dependencies.search} />
81146 </div>
82147
83 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
148 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
84149 {(
85150 [
86151 ['用户', counts.users],
87152 ['活跃用户', counts.active_users],
153 ['活跃会话', dashboard.active_sessions],
154 ['受限用户', dashboard.restricted_users],
88155 ['Space', counts.spaces],
156 ['活跃 Space', counts.active_spaces],
89157 ['仓库', counts.repos],
90158 ['房间', counts.rooms],
91159 ['工单', counts.issues],
92160 ['消息', counts.messages],
93161 ['管理员', counts.admin_users],
94 ['活跃 Space', counts.active_spaces],
162 ['14 天新增用户', trendTotals.users],
95163 ] as [string, number][]
96164 ).map(([label, value]) => (
97165 <Card key={label} title={label}>
@@ -100,8 +168,19 @@
100168 ))}
101169 </div>
102170
171 <Card
172 title="14 天增长趋势"
173 action={
174 <span className="text-xs text-slate-500">
175 新增 {trendTotals.users} 用户 / {trendTotals.spaces} Space / {trendTotals.repos} 仓库
176 </span>
177 }
178 >
179 <TrendChart trend={dashboard.trend} />
180 </Card>
181
103182 <div className="grid gap-3 lg:grid-cols-2">
104 <Card title="队列概览">
183 <Card title="队列概览" action={<Link className="text-xs text-sky-600 hover:underline dark:text-sky-400" to="/queues">进入队列</Link>}>
105184 <DataTable
106185 columns={[
107186 { key: 'name', title: '队列', render: (row) => row.name },
@@ -121,12 +200,12 @@
121200 ),
122201 },
123202 ]}
124 rows={status.queues}
203 rows={dashboard.queues}
125204 rowKey={(row) => row.name}
126205 />
127206 </Card>
128207
129 <Card title="最近失败">
208 <Card title="最近失败" action={<Link className="text-xs text-sky-600 hover:underline dark:text-sky-400" to="/tasks">进入任务</Link>}>
130209 <DataTable
131210 columns={[
132211 { key: 'source', title: '来源', render: (row) => row.source },
@@ -152,21 +231,57 @@
152231 ),
153232 },
154233 ]}
155 rows={status.recent_failures}
234 rows={dashboard.recent_failures}
156235 rowKey={(row) => `${row.source}-${row.id}`}
157236 empty="没有失败记录"
158237 />
159238 </Card>
160239 </div>
161240
162 <Card title="存储">
241 <Card
242 title="最近管理操作"
243 action={<Link className="text-xs text-sky-600 hover:underline dark:text-sky-400" to="/logs">进入审计日志</Link>}
244 >
245 <DataTable
246 columns={[
247 { key: 'admin', title: '管理员', render: (row) => row.admin_username },
248 { key: 'action', title: '动作', render: (row) => row.action },
249 {
250 key: 'target',
251 title: '目标',
252 render: (row) => (
253 <span className="text-xs text-slate-500">
254 {row.target_type ? `${row.target_type} ${shortId(row.target_id ?? '')}` : '-'}
255 </span>
256 ),
257 },
258 {
259 key: 'ip',
260 title: '来源',
261 render: (row) => <span className="text-xs text-slate-500">{row.ip ?? '-'}</span>,
262 },
263 {
264 key: 'created_at',
265 title: '时间',
266 render: (row) => (
267 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
268 ),
269 },
270 ]}
271 rows={dashboard.recent_audit}
272 rowKey={(row) => row.id}
273 empty="暂无审计记录"
274 />
275 </Card>
276
277 <Card title="功能开关" action={<Link className="text-xs text-sky-600 hover:underline dark:text-sky-400" to="/system">查看配置</Link>}>
163278 <p className="text-xs text-slate-500">
164 存储用量与对象浏览请前往「存储」页面;当前功能开关:搜索{' '}
165 {status.features.search_enabled ? '开启' : '关闭'},导入{' '}
279 搜索 {status.features.search_enabled ? '开启' : '关闭'},导入{' '}
166280 {status.features.import_enabled ? '开启' : '关闭'},邮件{' '}
167 {status.features.email_enabled ? '开启' : '关闭'}。
281 {status.features.email_enabled ? '开启' : '关闭'},邮件队列{' '}
282 {status.features.email_queue_enabled ? '开启' : '关闭'}。
168283 </p>
169284 </Card>
170285 </div>
171286 )
172}
287}
No newline at end of file
Modified admin/src/pages/logs.tsx View admin/src/pages/logs.tsx
@@ -1,5 +1,5 @@
11import { useCallback, useEffect, useState } from 'react'
2import { adminLogs } from '@/shared/api'
2import { adminLogs, csvDownload } from '@/shared/api'
33import type { AuditLogPage, JobLogPage } from '@/shared/api'
44import { ApiError } from '@/shared/api'
55import { formatDateTime, prettyJson } from '@/shared/format'
@@ -24,6 +24,10 @@
2424 const [tab, setTab] = useState<'audit' | 'jobs'>('audit')
2525 const [action, setAction] = useState('')
2626 const [targetType, setTargetType] = useState('')
27 const [keyword, setKeyword] = useState('')
28 const [adminUsername, setAdminUsername] = useState('')
29 const [from, setFrom] = useState('')
30 const [to, setTo] = useState('')
2731 const [source, setSource] = useState('')
2832 const [offset, setOffset] = useState(0)
2933 const [audit, setAudit] = useState<AuditLogPage | null>(null)
@@ -39,6 +43,10 @@
3943 await adminLogs.audit({
4044 action: action || undefined,
4145 target_type: targetType || undefined,
46 q: keyword || undefined,
47 admin_username: adminUsername || undefined,
48 from: from ? new Date(from).toISOString() : undefined,
49 to: to ? new Date(to).toISOString() : undefined,
4250 limit: PAGE_SIZE,
4351 offset,
4452 }),
@@ -49,7 +57,7 @@
4957 } catch (err) {
5058 setError(err instanceof ApiError ? err.message : '日志加载失败')
5159 }
52 }, [tab, action, targetType, source, offset])
60 }, [tab, action, targetType, keyword, adminUsername, from, to, source, offset])
5361
5462 useEffect(() => {
5563 void load()
@@ -93,22 +101,50 @@
93101 title="操作审计"
94102 action={
95103 <form
96 className="flex items-end gap-2"
104 className="flex flex-wrap items-end gap-2"
97105 onSubmit={(event) => {
98106 event.preventDefault()
99107 setOffset(0)
100108 void load()
101109 }}
102110 >
111 <Field label="关键字">
112 <TextInput value={keyword} onChange={setKeyword} placeholder="管理员 / 动作 / 目标 ID" />
113 </Field>
114 <Field label="管理员">
115 <TextInput value={adminUsername} onChange={setAdminUsername} placeholder="admin" />
116 </Field>
103117 <Field label="动作">
104118 <TextInput value={action} onChange={setAction} placeholder="user.suspended …" />
105119 </Field>
106120 <Field label="目标类型">
107121 <TextInput value={targetType} onChange={setTargetType} placeholder="user / space …" />
108122 </Field>
123 <Field label="开始时间">
124 <TextInput value={from} onChange={setFrom} type="datetime-local" />
125 </Field>
126 <Field label="结束时间">
127 <TextInput value={to} onChange={setTo} type="datetime-local" />
128 </Field>
109129 <Button type="submit" size="sm" variant="secondary">
110130 过滤
111131 </Button>
132 <Button
133 size="sm"
134 variant="secondary"
135 onClick={() =>
136 csvDownload('/admin/logs/audit/export', {
137 q: keyword || undefined,
138 admin_username: adminUsername || undefined,
139 action: action || undefined,
140 target_type: targetType || undefined,
141 from: from ? new Date(from).toISOString() : undefined,
142 to: to ? new Date(to).toISOString() : undefined,
143 })
144 }
145 >
146 导出 CSV
147 </Button>
112148 </form>
113149 }
114150 >
Modified admin/src/pages/queues.tsx View admin/src/pages/queues.tsx
@@ -1,7 +1,8 @@
11import { useCallback, useEffect, useState } from 'react'
22import { adminQueues } from '@/shared/api'
3import type { QueueSnapshot } from '@/shared/api'
3import type { JobLogPage, QueueSnapshot } from '@/shared/api'
44import { ApiError } from '@/shared/api'
5import { formatDateTime } from '@/shared/format'
56import {
67 Badge,
78 Button,
@@ -10,6 +11,9 @@
1011 DataTable,
1112 ErrorBanner,
1213 Loading,
14 Modal,
15 Pagination,
16 statusTone,
1317 useToast,
1418} from '@/shared/ui'
1519
@@ -18,6 +22,9 @@
1822 const [queues, setQueues] = useState<QueueSnapshot[] | null>(null)
1923 const [error, setError] = useState<string | null>(null)
2024 const [purgeTarget, setPurgeTarget] = useState<QueueSnapshot | null>(null)
25 const [failedQueue, setFailedQueue] = useState<string | null>(null)
26 const [failedJobs, setFailedJobs] = useState<JobLogPage | null>(null)
27 const [failedOffset, setFailedOffset] = useState(0)
2128
2229 const load = useCallback(async () => {
2330 setError(null)
@@ -36,6 +43,23 @@
3643 return () => window.clearInterval(timer)
3744 }, [load])
3845
46 const loadFailed = useCallback(async () => {
47 if (failedQueue === null) {
48 setFailedJobs(null)
49 return
50 }
51 try {
52 setFailedJobs(await adminQueues.failed(failedQueue, { limit: 10, offset: failedOffset }))
53 } catch (err) {
54 setFailedJobs(null)
55 toast(err instanceof ApiError ? err.message : '失败明细加载失败', 'danger')
56 }
57 }, [failedQueue, failedOffset, toast])
58
59 useEffect(() => {
60 void loadFailed()
61 }, [loadFailed])
62
3963 const run = async (action: () => Promise<{ queue: string; affected: number }>, message: string) => {
4064 try {
4165 const result = await action()
@@ -107,6 +131,17 @@
107131 <div className="flex flex-wrap gap-1">
108132 <Button
109133 size="sm"
134 variant="ghost"
135 disabled={row.failed === 0}
136 onClick={() => {
137 setFailedOffset(0)
138 setFailedQueue(row.name)
139 }}
140 >
141 失败明细
142 </Button>
143 <Button
144 size="sm"
110145 variant="secondary"
111146 onClick={() => void run(() => adminQueues.requeueFailed(row.name), '失败作业已重排')}
112147 >
@@ -125,6 +160,57 @@
125160 )}
126161 </Card>
127162
163 <Modal
164 open={failedQueue !== null}
165 title={`失败明细 ${failedQueue ?? ''}`}
166 onClose={() => setFailedQueue(null)}
167 >
168 {failedJobs === null ? (
169 <Loading />
170 ) : (
171 <>
172 <DataTable
173 columns={[
174 { key: 'source', title: '来源', render: (row) => row.source },
175 {
176 key: 'state',
177 title: '状态',
178 render: (row) => <Badge tone={statusTone(row.state)}>{row.state}</Badge>,
179 },
180 {
181 key: 'error',
182 title: '错误',
183 render: (row) => (
184 <span className="line-clamp-2 max-w-[260px] break-all text-xs text-slate-500">
185 {row.error ?? '-'}
186 </span>
187 ),
188 },
189 {
190 key: 'created_at',
191 title: '时间',
192 render: (row) => (
193 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
194 ),
195 },
196 ]}
197 rows={failedJobs.items}
198 rowKey={(row) => `${row.source}-${row.id}`}
199 empty="没有失败记录"
200 />
201 <Pagination
202 total={failedJobs.total}
203 limit={failedJobs.limit}
204 offset={failedJobs.offset}
205 onChange={setFailedOffset}
206 />
207 <p className="mt-2 text-xs text-slate-500">
208 Redis 同步与邮件队列没有可查询的失败明细;需要重试请前往「任务」页面按来源操作。
209 </p>
210 </>
211 )}
212 </Modal>
213
128214 <ConfirmDialog
129215 open={purgeTarget !== null}
130216 title={`清空队列 ${purgeTarget?.name ?? ''}`}
Modified admin/src/pages/space-detail.tsx View admin/src/pages/space-detail.tsx
@@ -4,7 +4,7 @@
44import type { AdminSpaceDetail, AdminSpaceMember, Page } from '@/shared/api'
55import { ApiError } from '@/shared/api'
66import { formatBytes, formatDateTime } from '@/shared/format'
7import { Badge, Button, Card, DataTable, ErrorBanner, Loading, Pagination, useToast } from '@/shared/ui'
7import { Badge, Button, Card, DataTable, ErrorBanner, Field, Loading, Pagination, TextInput, useToast } from '@/shared/ui'
88
99export function SpaceDetailPage() {
1010 const { id = '' } = useParams()
@@ -16,6 +16,7 @@
1616 const [error, setError] = useState<string | null>(null)
1717 const [name, setName] = useState('')
1818 const [description, setDescription] = useState('')
19 const [newMemberId, setNewMemberId] = useState('')
1920
2021 const load = useCallback(async () => {
2122 setError(null)
@@ -115,13 +116,73 @@
115116 >
116117 {space.is_active ? '停用 Space' : '启用 Space'}
117118 </Button>
119 <Button
120 size="sm"
121 variant="secondary"
122 onClick={() =>
123 void run(
124 () => adminSpaces.update(id, { allow_join: !space.allow_join }),
125 space.allow_join ? '已关闭自助加入' : '已开启自助加入',
126 )
127 }
128 >
129 {space.allow_join ? '关闭自助加入' : '开启自助加入'}
130 </Button>
131 <Button
132 size="sm"
133 variant="secondary"
134 onClick={() =>
135 void run(
136 () => adminSpaces.update(id, { allow_invite: !space.allow_invite }),
137 space.allow_invite ? '已关闭邀请' : '已开启邀请',
138 )
139 }
140 >
141 {space.allow_invite ? '关闭邀请' : '开启邀请'}
142 </Button>
143 <Button
144 size="sm"
145 variant="secondary"
146 onClick={() =>
147 void run(
148 () => adminSpaces.update(id, { member_must_enable_2fa: !space.member_must_enable_2fa }),
149 '成员 2FA 要求已更新',
150 )
151 }
152 >
153 {space.member_must_enable_2fa ? '取消成员 2FA 要求' : '要求成员开启 2FA'}
154 </Button>
118155 </div>
119156 <p className="mt-2 text-xs text-slate-500">
120157 Owner:{space.owner_username ?? space.owner_id} · 创建于 {formatDateTime(space.created_at)}
121158 </p>
122159 </Card>
123160
124 <Card title="成员">
161 <Card
162 title="成员"
163 action={
164 <div className="flex items-end gap-2">
165 <Field label="用户 UUID">
166 <TextInput value={newMemberId} onChange={setNewMemberId} placeholder="00000000-0000-0000-0000-000000000000" />
167 </Field>
168 <Button
169 size="sm"
170 disabled={newMemberId.trim().length === 0}
171 onClick={() =>
172 void run(
173 async () => {
174 await adminSpaces.addMember(id, newMemberId.trim())
175 setNewMemberId('')
176 },
177 '成员已加入',
178 )
179 }
180 >
181 添加成员
182 </Button>
183 </div>
184 }
185 >
125186 {members === null ? (
126187 <Loading />
127188 ) : (
Added admin/src/pages/system.tsx View admin/src/pages/system.tsx
@@ -0,0 +1,195 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminSystem } from '@/shared/api'
3import type { SystemConfig } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import { formatDateTime } from '@/shared/format'
6import { Badge, Button, Card, ErrorBanner, Loading } from '@/shared/ui'
7
8function Rows({ entries }: { entries: [string, string | number | boolean | null | undefined][] }) {
9 return (
10 <dl className="divide-y divide-slate-100 text-sm dark:divide-slate-800">
11 {entries.map(([label, value]) => (
12 <div key={label} className="flex items-center justify-between gap-3 py-1.5">
13 <dt className="text-slate-500 dark:text-slate-400">{label}</dt>
14 <dd className="break-all text-right text-slate-800 dark:text-slate-100">
15 {value === null || value === undefined || value === '' ? '-' : String(value)}
16 </dd>
17 </div>
18 ))}
19 </dl>
20 )
21}
22
23function onOff(value: boolean | null | undefined): string {
24 if (value === null || value === undefined) {
25 return '-'
26 }
27 return value ? '开启' : '关闭'
28}
29
30export function SystemPage() {
31 const [config, setConfig] = useState<SystemConfig | null>(null)
32 const [error, setError] = useState<string | null>(null)
33 const [busy, setBusy] = useState(false)
34
35 const load = useCallback(async () => {
36 setBusy(true)
37 setError(null)
38 try {
39 setConfig(await adminSystem.config())
40 } catch (err) {
41 setError(err instanceof ApiError ? err.message : '配置快照加载失败')
42 } finally {
43 setBusy(false)
44 }
45 }, [])
46
47 useEffect(() => {
48 void load()
49 }, [load])
50
51 if (!config) {
52 return (
53 <div className="space-y-3">
54 <ErrorBanner message={error} />
55 <Loading />
56 </div>
57 )
58 }
59
60 return (
61 <div className="space-y-4">
62 <div className="flex items-center justify-between">
63 <div>
64 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">系统配置</h1>
65 <p className="text-xs text-slate-500">
66 adminhost v{config.version} · 快照于 {formatDateTime(config.generated_at)} · 不含任何密钥
67 </p>
68 </div>
69 <Button variant="secondary" size="sm" disabled={busy} onClick={() => void load()}>
70 刷新
71 </Button>
72 </div>
73
74 <ErrorBanner message={error} />
75
76 <div className="grid gap-3 lg:grid-cols-2">
77 <Card title="进程与监听">
78 <Rows
79 entries={[
80 ...config.hosts.map(
81 (host) =>
82 [
83 host.name,
84 `${host.bind_addr ?? '-'}:${host.port ?? '-'} · ${host.enabled ? '启用' : '停用'}`,
85 ] as [string, string],
86 ),
87 ['adminhost', `${config.admin.bind_addr}:${config.admin.port}`],
88 ['管理端 Cookie', config.admin.session_cookie_name],
89 ['管理端会话表', config.admin.session_table_name],
90 ['管理端跨域来源', config.admin.cors_allow_origins.join(', ') || '未配置(同源部署)'],
91 ['跨域携带凭据', onOff(config.admin.cors_allow_credentials)],
92 ]}
93 />
94 </Card>
95
96 <Card title="功能开关">
97 <Rows
98 entries={[
99 ['搜索', onOff(config.features.search_enabled)],
100 ['导入', onOff(config.features.import_enabled)],
101 ['邮件', onOff(config.features.email_enabled)],
102 ['邮件队列', onOff(config.features.email_queue_enabled)],
103 ]}
104 />
105 </Card>
106
107 <Card title="数据库">
108 <Rows
109 entries={[
110 ['端点', config.database.endpoint],
111 ['主机', `${config.database.host ?? '-'}:${config.database.port ?? '-'}`],
112 ['库名', config.database.database],
113 ['SSL 模式', config.database.ssl_mode],
114 ['连接池', `${config.database.min_connections ?? '-'} ~ ${config.database.max_connections ?? '-'}`],
115 ['只读副本', config.database.replica_count],
116 ]}
117 />
118 </Card>
119
120 <Card title="Redis">
121 <Rows
122 entries={[
123 ['端点', config.redis.endpoint],
124 ['模式', config.redis.mode],
125 ['库', config.redis.database],
126 ['连接池', config.redis.pool_size],
127 ]}
128 />
129 </Card>
130
131 <Card title="会话">
132 <Rows
133 entries={[
134 ['模式', config.session.mode],
135 ['Cookie', config.session.cookie_name],
136 ['Cookie 域', config.session.cookie_domain],
137 ['Secure', onOff(config.session.cookie_secure)],
138 ['SameSite', config.session.cookie_same_site],
139 ['生命周期(秒)', config.session.lifespan_secs],
140 ['会话表', config.session.table_name],
141 ]}
142 />
143 </Card>
144
145 <Card title="存储与邮件">
146 <Rows
147 entries={[
148 ['Bucket', config.storage.bucket],
149 ['Region', config.storage.region],
150 ['Path style', onOff(config.storage.path_style)],
151 ['SMTP', `${config.email.smtp_host}:${config.email.smtp_port}`],
152 ['发件人', config.email.from_address],
153 ['发件人名称', config.email.from_name],
154 ]}
155 />
156 </Card>
157
158 <Card title="检索与导入">
159 <Rows
160 entries={[
161 ['索引前缀', config.search.index_prefix],
162 ['单文件上限(字节)', config.search.max_file_bytes],
163 ['批大小', config.search.batch_size],
164 ['轮询间隔(秒)', config.search.poll_interval_secs],
165 ['导入并发', config.import.max_concurrent_jobs],
166 ['仓库体积上限(字节)', config.import.max_repo_bytes],
167 ['最大尝试次数', config.import.max_attempts],
168 ['允许主机', config.import.allowed_hosts.join(', ') || '未配置'],
169 ]}
170 />
171 {config.import.allow_insecure_tls && (
172 <p className="mt-2">
173 <Badge tone="warning">导入允许不安全 TLS</Badge>
174 </p>
175 )}
176 </Card>
177
178 <Card title="Git">
179 <Rows
180 entries={[
181 ['仓库根目录', config.git.repo_root],
182 ['对象目录', config.git.objects_root],
183 ['单对象上限(字节)', config.git.max_blob_size],
184 ['同步 topic', config.git.sync_topic],
185 ['同步并发', config.git.sync_concurrency],
186 ['基础地址', config.git.base_url],
187 ['GraphQL 复杂度上限', config.git.graphql_complexity_limit],
188 ['GraphQL introspection', onOff(config.git.graphql_introspection)],
189 ]}
190 />
191 </Card>
192 </div>
193 </div>
194 )
195}
No newline at end of file
Modified admin/src/pages/tasks.tsx View admin/src/pages/tasks.tsx
@@ -1,6 +1,6 @@
11import { useCallback, useEffect, useState } from 'react'
22import { adminTasks } from '@/shared/api'
3import type { TaskDetail, TaskPage } from '@/shared/api'
3import type { TaskDetail, TaskPage, TaskStats } from '@/shared/api'
44import { ApiError } from '@/shared/api'
55import { formatDateTime, prettyJson } from '@/shared/format'
66import {
@@ -44,19 +44,29 @@
4444 const [error, setError] = useState<string | null>(null)
4545 const [busy, setBusy] = useState(false)
4646 const [deleteTarget, setDeleteTarget] = useState<{ source: string; id: string } | null>(null)
47 const [stats, setStats] = useState<TaskStats | null>(null)
48 const [selected, setSelected] = useState<string[]>([])
49 const [bulkAction, setBulkAction] = useState<'retry' | 'cancel'>('retry')
50 const [bulkOpen, setBulkOpen] = useState(false)
4751
4852 const load = useCallback(async () => {
4953 setBusy(true)
5054 setError(null)
5155 try {
52 setData(
53 await adminTasks.list({
56 const [page, statsValue] = await Promise.all([
57 adminTasks.list({
5458 source: applied.source || undefined,
5559 state: applied.state || undefined,
5660 q: applied.query || undefined,
5761 limit: PAGE_SIZE,
5862 offset,
5963 }),
64 adminTasks.stats(),
65 ])
66 setData(page)
67 setStats(statsValue)
68 setSelected((current) =>
69 current.filter((key) => page.items.some((item) => `${item.source}:${item.id}` === key)),
6070 )
6171 } catch (err) {
6272 setError(err instanceof ApiError ? err.message : '任务列表加载失败')
@@ -74,12 +84,35 @@
7484 await action()
7585 toast(message)
7686 setDeleteTarget(null)
87 setBulkOpen(false)
7788 await load()
7889 } catch (err) {
7990 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
8091 }
8192 }
8293
94 const runBulk = () => {
95 const targetSource = applied.source
96 if (!targetSource) {
97 toast('批量操作需要先筛选单一来源', 'danger')
98 setBulkOpen(false)
99 return
100 }
101 void run(async () => {
102 const result = await adminTasks.bulk(targetSource, {
103 action: bulkAction,
104 ids: selected.map((key) => key.split(':')[1]).filter((id): id is string => Boolean(id)),
105 })
106 if (result.failed.length > 0) {
107 toast(`${result.succeeded.length} 个成功,${result.failed.length} 个失败`, 'danger')
108 }
109 return result
110 }, '批量操作已完成')
111 }
112
113 const allSelected =
114 data !== null && data.items.length > 0 && selected.length === data.items.length
115
83116 return (
84117 <div className="space-y-4">
85118 <div className="flex flex-wrap items-end justify-between gap-3">
@@ -112,6 +145,68 @@
112145
113146 <ErrorBanner message={error} />
114147
148 {stats && (
149 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
150 {stats.sources.map((item) => (
151 <Card key={item.source} title={item.source}>
152 <div className="flex items-baseline justify-between">
153 <span className="text-xl font-semibold text-slate-900 dark:text-slate-100">
154 {item.queued + item.running}
155 </span>
156 <span className="text-xs text-slate-500">进行中</span>
157 </div>
158 <div className="mt-1 flex flex-wrap gap-1 text-xs">
159 <Badge tone="warning">排队 {item.queued}</Badge>
160 {item.running > 0 && <Badge tone="info">执行 {item.running}</Badge>}
161 {item.failed > 0 ? (
162 <Badge tone="danger">失败 {item.failed}</Badge>
163 ) : (
164 <Badge tone="success">失败 0</Badge>
165 )}
166 </div>
167 <button
168 className="mt-2 text-xs text-sky-600 hover:underline dark:text-sky-400"
169 onClick={() => {
170 setSource(item.source)
171 setOffset(0)
172 setApplied({ source: item.source, state, query })
173 }}
174 >
175 只看该来源
176 </button>
177 </Card>
178 ))}
179 </div>
180 )}
181
182 {selected.length > 0 && (
183 <Card title={`已选择 ${selected.length} 个任务`}>
184 <div className="flex flex-wrap items-end gap-2">
185 <Field label="批量操作">
186 <Select
187 value={bulkAction}
188 onChange={(value) => setBulkAction(value === 'cancel' ? 'cancel' : 'retry')}
189 options={[
190 { value: 'retry', label: '重试' },
191 { value: 'cancel', label: '取消' },
192 ]}
193 />
194 </Field>
195 <Button variant={bulkAction === 'cancel' ? 'danger' : 'primary'} onClick={() => setBulkOpen(true)}>
196 执行
197 </Button>
198 <Button variant="secondary" onClick={() => setSelected([])}>
199 清除选择
200 </Button>
201 {!applied.source && (
202 <span className="text-xs text-amber-600 dark:text-amber-400">
203 批量操作需要先在筛选条件中选择单一来源
204 </span>
205 )}
206 </div>
207 </Card>
208 )}
209
115210 <Card title="任务列表">
116211 {data === null ? (
117212 <Loading />
@@ -120,6 +215,41 @@
120215 <DataTable
121216 columns={[
122217 {
218 key: 'select',
219 title: (
220 <input
221 type="checkbox"
222 className="h-4 w-4 rounded border-slate-300"
223 checked={allSelected}
224 onChange={(event) =>
225 setSelected(
226 event.target.checked && data
227 ? data.items.map((item) => `${item.source}:${item.id}`)
228 : [],
229 )
230 }
231 />
232 ),
233 className: 'w-8',
234 render: (row) => {
235 const key = `${row.source}:${row.id}`
236 return (
237 <input
238 type="checkbox"
239 className="h-4 w-4 rounded border-slate-300"
240 checked={selected.includes(key)}
241 onChange={(event) =>
242 setSelected((current) =>
243 event.target.checked
244 ? [...current, key]
245 : current.filter((item) => item !== key),
246 )
247 }
248 />
249 )
250 },
251 },
252 {
123253 key: 'source',
124254 title: '来源',
125255 render: (row) => <span className="text-xs text-slate-500">{row.source}</span>,
@@ -223,6 +353,17 @@
223353 </Modal>
224354
225355 <ConfirmDialog
356 open={bulkOpen}
357 title={bulkAction === 'retry' ? '批量重试任务' : '批量取消任务'}
358 description={`将对已选择的 ${selected.length} 个任务执行${bulkAction === 'retry' ? '重试' : '取消'},仅作用于「${
359 applied.source || '未筛选'
360 }」来源。`}
361 confirmLabel={bulkAction === 'retry' ? '批量重试' : '批量取消'}
362 onClose={() => setBulkOpen(false)}
363 onConfirm={runBulk}
364 />
365
366 <ConfirmDialog
226367 open={deleteTarget !== null}
227368 title="删除任务记录"
228369 description="仅终态任务可删除,删除后不可恢复。"
Modified admin/src/pages/user-detail.tsx View admin/src/pages/user-detail.tsx
@@ -1,7 +1,7 @@
11import { useCallback, useEffect, useState } from 'react'
22import { Link, useNavigate, useParams } from 'react-router-dom'
33import { adminUsers } from '@/shared/api'
4import type { AdminUserDetail } from '@/shared/api'
4import type { AdminUserDetail, AdminUserSecurity, AdminUserSessionPage } from '@/shared/api'
55import { ApiError } from '@/shared/api'
66import { formatDateTime } from '@/shared/format'
77import {
@@ -14,10 +14,30 @@
1414 Field,
1515 Loading,
1616 Modal,
17 Pagination,
1718 TextInput,
1819 useToast,
1920} from '@/shared/ui'
2021
22function Rows({
23 entries,
24}: {
25 entries: [string, string | number | boolean | null | undefined][]
26}) {
27 return (
28 <dl className="divide-y divide-slate-100 text-sm dark:divide-slate-800">
29 {entries.map(([label, value]) => (
30 <div key={label} className="flex items-center justify-between gap-3 py-1.5">
31 <dt className="text-slate-500 dark:text-slate-400">{label}</dt>
32 <dd className="break-all text-right text-slate-800 dark:text-slate-100">
33 {value === null || value === undefined || value === '' ? '-' : String(value)}
34 </dd>
35 </div>
36 ))}
37 </dl>
38 )
39}
40
2141export function UserDetailPage() {
2242 const { uid = '' } = useParams()
2343 const navigate = useNavigate()
@@ -29,20 +49,35 @@
2949 const [passwordOpen, setPasswordOpen] = useState(false)
3050 const [newPassword, setNewPassword] = useState('')
3151 const [deleteOpen, setDeleteOpen] = useState(false)
52 const [security, setSecurity] = useState<AdminUserSecurity | null>(null)
53 const [sessions, setSessions] = useState<AdminUserSessionPage | null>(null)
54 const [sessionOffset, setSessionOffset] = useState(0)
55 const [includeRevoked, setIncludeRevoked] = useState(false)
56 const [clearTwoFactorOpen, setClearTwoFactorOpen] = useState(false)
3257
3358 const load = useCallback(async () => {
3459 setBusy(true)
3560 setError(null)
3661 try {
37 const value = await adminUsers.get(uid)
62 const [value, securityValue, sessionValue] = await Promise.all([
63 adminUsers.get(uid),
64 adminUsers.security(uid),
65 adminUsers.sessions(uid, {
66 limit: 10,
67 offset: sessionOffset,
68 include_revoked: includeRevoked,
69 }),
70 ])
3871 setDetail(value)
3972 setDisplayName(value.user.display_name)
73 setSecurity(securityValue)
74 setSessions(sessionValue)
4075 } catch (err) {
4176 setError(err instanceof ApiError ? err.message : '用户详情加载失败')
4277 } finally {
4378 setBusy(false)
4479 }
45 }, [uid])
80 }, [uid, sessionOffset, includeRevoked])
4681
4782 useEffect(() => {
4883 void load()
@@ -54,6 +89,7 @@
5489 toast(message)
5590 setPasswordOpen(false)
5691 setDeleteOpen(false)
92 setClearTwoFactorOpen(false)
5793 await load()
5894 } catch (err) {
5995 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
@@ -229,39 +265,138 @@
229265 />
230266 </Card>
231267
232 <Card title="会话">
233 <DataTable
234 columns={[
235 { key: 'kind', title: '类型', render: (row) => row.kind },
236 {
237 key: 'last_seen',
238 title: '最后活跃',
239 render: (row) => (
240 <span className="text-xs text-slate-500">{formatDateTime(row.last_seen_at)}</span>
241 ),
242 },
243 {
244 key: 'expires',
245 title: '过期',
246 render: (row) => (
247 <span className="text-xs text-slate-500">{formatDateTime(row.expires_at)}</span>
248 ),
249 },
250 {
251 key: 'revoked',
252 title: '状态',
253 render: (row) => (
254 <Badge tone={row.is_revoked ? 'danger' : 'success'}>
255 {row.is_revoked ? '已吊销' : '有效'}
256 </Badge>
257 ),
258 },
259 ]}
260 rows={detail.sessions}
261 rowKey={(row) => row.id}
262 />
268 <Card title="安全状态">
269 {security === null ? (
270 <Loading />
271 ) : (
272 <>
273 <Rows
274 entries={[
275 ['二次验证', security.totp_enabled ? '已启用' : '未启用'],
276 [
277 '恢复码',
278 `${security.recovery_codes_total} 个(已用 ${security.recovery_codes_used})`,
279 ],
280 ['活跃会话', security.active_sessions],
281 ['SSH Key', security.ssh_keys],
282 ['访问密钥', security.access_keys],
283 ['口令版本', security.passwd_version],
284 ['口令更新于', formatDateTime(security.password_last_changed_at)],
285 ]}
286 />
287 <div className="mt-3">
288 <Button
289 size="sm"
290 variant="danger"
291 disabled={!security.totp_enabled}
292 onClick={() => setClearTwoFactorOpen(true)}
293 >
294 强制清除二次验证
295 </Button>
296 </div>
297 </>
298 )}
263299 </Card>
264300
301 <Card
302 title="会话管理"
303 action={
304 <div className="flex items-center gap-2">
305 <label className="flex items-center gap-1 text-xs text-slate-500">
306 <input
307 type="checkbox"
308 className="h-3.5 w-3.5 rounded border-slate-300"
309 checked={includeRevoked}
310 onChange={(event) => {
311 setSessionOffset(0)
312 setIncludeRevoked(event.target.checked)
313 }}
314 />
315 含已吊销
316 </label>
317 <Button
318 size="sm"
319 variant="secondary"
320 onClick={() => void run(() => adminUsers.revokeSessions(user.uid), '会话已吊销')}
321 >
322 吊销全部
323 </Button>
324 </div>
325 }
326 >
327 {sessions === null ? (
328 <Loading />
329 ) : (
330 <>
331 <DataTable
332 columns={[
333 { key: 'kind', title: '类型', render: (row) => row.kind },
334 {
335 key: 'created',
336 title: '创建',
337 render: (row) => (
338 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
339 ),
340 },
341 {
342 key: 'last_seen',
343 title: '最后活跃',
344 render: (row) => (
345 <span className="text-xs text-slate-500">{formatDateTime(row.last_seen_at)}</span>
346 ),
347 },
348 {
349 key: 'expires',
350 title: '过期',
351 render: (row) => (
352 <span className="text-xs text-slate-500">{formatDateTime(row.expires_at)}</span>
353 ),
354 },
355 {
356 key: 'revoked',
357 title: '状态',
358 render: (row) => (
359 <Badge tone={row.is_revoked ? 'danger' : 'success'}>
360 {row.is_revoked ? '已吊销' : '有效'}
361 </Badge>
362 ),
363 },
364 {
365 key: 'actions',
366 title: '操作',
367 render: (row) =>
368 row.is_revoked ? (
369 <span className="text-xs text-slate-400">-</span>
370 ) : (
371 <Button
372 size="sm"
373 variant="secondary"
374 onClick={() =>
375 void run(
376 () => adminUsers.revokeSession(user.uid, row.id),
377 '会话已吊销',
378 )
379 }
380 >
381 吊销
382 </Button>
383 ),
384 },
385 ]}
386 rows={sessions.items}
387 rowKey={(row) => row.id}
388 empty="没有会话记录"
389 />
390 <Pagination
391 total={sessions.total}
392 limit={sessions.limit}
393 offset={sessions.offset}
394 onChange={setSessionOffset}
395 />
396 </>
397 )}
398 </Card>
399
265400 <Card title="封禁记录">
266401 <DataTable
267402 columns={[
@@ -330,6 +465,18 @@
330465 </Modal>
331466
332467 <ConfirmDialog
468 open={clearTwoFactorOpen}
469 title="清除二次验证"
470 description="将关闭该账号的二次验证并删除全部恢复码。用户可重新自行绑定,操作会写入审计日志。"
471 confirmLabel="清除二次验证"
472 requireText={user.username}
473 onClose={() => setClearTwoFactorOpen(false)}
474 onConfirm={() =>
475 void run(() => adminUsers.clearTwoFactor(user.uid), '二次验证已清除')
476 }
477 />
478
479 <ConfirmDialog
333480 open={deleteOpen}
334481 title="强制注销用户"
335482 description="将立即匿名化该用户、吊销会话并禁用账号,操作不可恢复。"
Modified admin/src/pages/users.tsx View admin/src/pages/users.tsx
@@ -1,6 +1,6 @@
11import { useCallback, useEffect, useState } from 'react'
22import { useNavigate } from 'react-router-dom'
3import { adminUsers } from '@/shared/api'
3import { adminUsers, csvDownload } from '@/shared/api'
44import type { AdminUserSummary, Page } from '@/shared/api'
55import { ApiError } from '@/shared/api'
66import { formatDateTime } from '@/shared/format'
@@ -22,6 +22,16 @@
2222
2323const PAGE_SIZE = 20
2424
25const BULK_ACTIONS: { value: string; label: string; danger?: boolean }[] = [
26 { value: 'activate', label: '启用账号' },
27 { value: 'deactivate', label: '停用账号' },
28 { value: 'restrict', label: '标记受限' },
29 { value: 'unrestrict', label: '解除受限' },
30 { value: 'revoke_sessions', label: '踢下线' },
31 { value: 'suspend', label: '永久封禁', danger: true },
32 { value: 'delete', label: '强制注销', danger: true },
33]
34
2535export function UsersPage() {
2636 const navigate = useNavigate()
2737 const toast = useToast()
@@ -40,18 +50,31 @@
4050 const [passwordOpen, setPasswordOpen] = useState(false)
4151 const [newPassword, setNewPassword] = useState('')
4252 const [deleteOpen, setDeleteOpen] = useState(false)
53 const [createOpen, setCreateOpen] = useState(false)
54 const [createForm, setCreateForm] = useState({
55 username: '',
56 email: '',
57 password: '',
58 display_name: '',
59 email_activated: true,
60 })
61 const [selected, setSelected] = useState<string[]>([])
62 const [bulkAction, setBulkAction] = useState('revoke_sessions')
63 const [bulkOpen, setBulkOpen] = useState(false)
4364
4465 const load = useCallback(async () => {
4566 setBusy(true)
4667 setError(null)
4768 try {
48 setData(
49 await adminUsers.list({
50 q: applied.query || undefined,
51 status: applied.status || undefined,
52 limit: PAGE_SIZE,
53 offset,
54 }),
69 const page = await adminUsers.list({
70 q: applied.query || undefined,
71 status: applied.status || undefined,
72 limit: PAGE_SIZE,
73 offset,
74 })
75 setData(page)
76 setSelected((current) =>
77 current.filter((uid) => page.items.some((item) => item.uid === uid)),
5578 )
5679 } catch (err) {
5780 setError(err instanceof ApiError ? err.message : '用户列表加载失败')
@@ -71,6 +94,8 @@
7194 setSuspendOpen(false)
7295 setPasswordOpen(false)
7396 setDeleteOpen(false)
97 setCreateOpen(false)
98 setBulkOpen(false)
7499 setTarget(null)
75100 await load()
76101 } catch (err) {
@@ -78,12 +103,35 @@
78103 }
79104 }
80105
106 const toggleAll = (checked: boolean) => {
107 setSelected(checked && data ? data.items.map((item) => item.uid) : [])
108 }
109
110 const runBulk = () =>
111 run(
112 async () => {
113 const result = await adminUsers.bulk({
114 action: bulkAction,
115 ids: selected,
116 suspension_type: bulkAction === 'suspend' ? 'permanent' : undefined,
117 reason: bulkAction === 'suspend' ? '批量封禁' : undefined,
118 })
119 if (result.failed.length > 0) {
120 toast(`${result.succeeded.length} 个成功,${result.failed.length} 个失败`, 'danger')
121 }
122 return result
123 },
124 '批量操作已完成',
125 )
126
127 const allSelected = data !== null && data.items.length > 0 && selected.length === data.items.length
128
81129 return (
82130 <div className="space-y-4">
83131 <div className="flex flex-wrap items-end justify-between gap-3">
84132 <div>
85133 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">用户</h1>
86 <p className="text-xs text-slate-500">检索、封禁、重置口令与会话管理</p>
134 <p className="text-xs text-slate-500">检索、批量治理、封禁、重置口令与会话管理</p>
87135 </div>
88136 <form
89137 className="flex flex-wrap items-end gap-2"
@@ -112,11 +160,43 @@
112160 <Button type="submit" disabled={busy}>
113161 查询
114162 </Button>
163 <Button variant="secondary" onClick={() => setCreateOpen(true)}>
164 新建用户
165 </Button>
166 <Button
167 variant="secondary"
168 onClick={() => csvDownload('/admin/users/export', { q: applied.query, status: applied.status })}
169 >
170 导出 CSV
171 </Button>
115172 </form>
116173 </div>
117174
118175 <ErrorBanner message={error} />
119176
177 {selected.length > 0 && (
178 <Card title={`已选择 ${selected.length} 个用户`}>
179 <div className="flex flex-wrap items-end gap-2">
180 <Field label="批量操作">
181 <Select
182 value={bulkAction}
183 onChange={setBulkAction}
184 options={BULK_ACTIONS.map((item) => ({ value: item.value, label: item.label }))}
185 />
186 </Field>
187 <Button
188 variant={BULK_ACTIONS.find((item) => item.value === bulkAction)?.danger ? 'danger' : 'primary'}
189 onClick={() => setBulkOpen(true)}
190 >
191 执行
192 </Button>
193 <Button variant="secondary" onClick={() => setSelected([])}>
194 清除选择
195 </Button>
196 </div>
197 </Card>
198 )}
199
120200 <Card title="用户列表" action={busy ? <span className="text-xs text-slate-400">加载中…</span> : null}>
121201 {data === null ? (
122202 <Loading />
@@ -125,6 +205,32 @@
125205 <DataTable
126206 columns={[
127207 {
208 key: 'select',
209 title: (
210 <input
211 type="checkbox"
212 className="h-4 w-4 rounded border-slate-300"
213 checked={allSelected}
214 onChange={(event) => toggleAll(event.target.checked)}
215 />
216 ),
217 className: 'w-8',
218 render: (row) => (
219 <input
220 type="checkbox"
221 className="h-4 w-4 rounded border-slate-300"
222 checked={selected.includes(row.uid)}
223 onChange={(event) =>
224 setSelected((current) =>
225 event.target.checked
226 ? [...current, row.uid]
227 : current.filter((uid) => uid !== row.uid),
228 )
229 }
230 />
231 ),
232 },
233 {
128234 key: 'username',
129235 title: '用户名',
130236 render: (row) => (
@@ -228,6 +334,75 @@
228334 </Card>
229335
230336 <Modal
337 open={createOpen}
338 title="新建用户"
339 onClose={() => setCreateOpen(false)}
340 footer={
341 <>
342 <Button variant="secondary" onClick={() => setCreateOpen(false)}>
343 取消
344 </Button>
345 <Button
346 disabled={!createForm.username || !createForm.email || !createForm.password}
347 onClick={() =>
348 void run(
349 () =>
350 adminUsers.create({
351 username: createForm.username,
352 email: createForm.email,
353 password: createForm.password,
354 display_name: createForm.display_name || undefined,
355 email_activated: createForm.email_activated,
356 }),
357 '用户已创建',
358 )
359 }
360 >
361 创建
362 </Button>
363 </>
364 }
365 >
366 <Field label="用户名" hint="1-64 位字母、数字、下划线、点或短横线">
367 <TextInput
368 value={createForm.username}
369 onChange={(value) => setCreateForm({ ...createForm, username: value })}
370 />
371 </Field>
372 <Field label="邮箱">
373 <TextInput
374 value={createForm.email}
375 onChange={(value) => setCreateForm({ ...createForm, email: value })}
376 />
377 </Field>
378 <Field label="初始口令" hint="需满足站点口令策略">
379 <TextInput
380 value={createForm.password}
381 onChange={(value) => setCreateForm({ ...createForm, password: value })}
382 type="password"
383 />
384 </Field>
385 <Field label="显示名" hint="可选,默认与用户名相同">
386 <TextInput
387 value={createForm.display_name}
388 onChange={(value) => setCreateForm({ ...createForm, display_name: value })}
389 />
390 </Field>
391 <Field label="邮箱状态">
392 <Select
393 value={createForm.email_activated ? 'activated' : 'pending'}
394 onChange={(value) =>
395 setCreateForm({ ...createForm, email_activated: value === 'activated' })
396 }
397 options={[
398 { value: 'activated', label: '视为已验证(管理员直接创建)' },
399 { value: 'pending', label: '待验证' },
400 ]}
401 />
402 </Field>
403 </Modal>
404
405 <Modal
231406 open={suspendOpen}
232407 title={`封禁用户 ${target?.username ?? ''}`}
233408 onClose={() => setSuspendOpen(false)}
@@ -310,6 +485,16 @@
310485 </Modal>
311486
312487 <ConfirmDialog
488 open={bulkOpen}
489 title={`批量操作:${BULK_ACTIONS.find((item) => item.value === bulkAction)?.label ?? bulkAction}`}
490 description={`将对已选择的 ${selected.length} 个用户执行该操作。`}
491 confirmLabel="执行批量操作"
492 requireText={bulkAction === 'delete' ? 'DELETE' : undefined}
493 onClose={() => setBulkOpen(false)}
494 onConfirm={() => void runBulk()}
495 />
496
497 <ConfirmDialog
313498 open={deleteOpen}
314499 title="强制注销用户"
315500 description="将立即匿名化该用户、吊销会话并禁用账号,操作不可恢复。"
Modified admin/src/shared/api-types.ts View admin/src/shared/api-types.ts
@@ -91,11 +91,172 @@
9191 owner_username: string | null
9292 is_private: boolean
9393 is_active: boolean
94 allow_join: boolean
95 allow_invite: boolean
96 member_must_enable_2fa: boolean
9497 nums_members: number
9598 created_at: string
9699 updated_at: string
97100}
98101
102export interface AdminUserSessionRow {
103 id: string
104 kind: string
105 device_id: string | null
106 user_agent_hash: string | null
107 client_ip_hash: string | null
108 app_id: string | null
109 last_seen_at: string
110 created_at: string
111 expires_at: string
112 is_revoked: boolean
113 revoked_at: string | null
114}
115
116export interface AdminUserSessionPage {
117 items: AdminUserSessionRow[]
118 total: number
119 limit: number
120 offset: number
121}
122
123export interface AdminUserSecurity {
124 totp_enabled: boolean
125 recovery_codes_total: number
126 recovery_codes_used: number
127 active_sessions: number
128 ssh_keys: number
129 access_keys: number
130 passwd_version: number
131 password_last_changed_at: string | null
132}
133
134export interface CreateUserResp {
135 uid: string
136 username: string
137 email: string
138}
139
140export interface BulkUsersResp {
141 action: string
142 succeeded: string[]
143 failed: { uid: string; error: string }[]
144}
145
146export interface BulkTaskResp {
147 source: string
148 action: string
149 succeeded: string[]
150 failed: { id: string; error: string }[]
151}
152
153export interface TaskSourceStat {
154 source: string
155 queued: number
156 running: number
157 succeeded: number
158 failed: number
159}
160
161export interface TaskStats {
162 sources: TaskSourceStat[]
163 total: number
164 generated_at: string
165}
166
167export interface TrendPoint {
168 date: string
169 users: number
170 spaces: number
171 repos: number
172}
173
174export interface Dashboard {
175 counts: SystemStatus['counts']
176 trend: TrendPoint[]
177 active_sessions: number
178 restricted_users: number
179 recent_audit: AuditLog[]
180 recent_failures: JobLog[]
181 queues: QueueSnapshot[]
182 generated_at: string
183}
184
185export interface SystemConfig {
186 version: string
187 hosts: { name: string; enabled: boolean; bind_addr: string | null; port: number | null }[]
188 admin: {
189 enabled: boolean
190 bind_addr: string
191 port: number
192 session_cookie_name: string
193 session_table_name: string
194 cors_allow_origins: string[]
195 cors_allow_credentials: boolean
196 }
197 features: {
198 search_enabled: boolean
199 import_enabled: boolean
200 email_enabled: boolean
201 email_queue_enabled: boolean
202 }
203 search: {
204 enabled: boolean
205 index_prefix: string
206 max_file_bytes: number
207 batch_size: number
208 poll_interval_secs: number
209 }
210 import: {
211 enabled: boolean
212 allowed_hosts: string[]
213 allow_insecure_tls: boolean
214 max_concurrent_jobs: number
215 max_repo_bytes: number
216 max_attempts: number
217 }
218 git: {
219 repo_root: string
220 objects_root: string
221 max_blob_size: number
222 sync_topic: string
223 sync_concurrency: number
224 base_url: string
225 graphql_complexity_limit: number
226 graphql_introspection: boolean
227 }
228 storage: { bucket: string | null; region: string | null; path_style: boolean | null }
229 database: {
230 endpoint: string | null
231 host: string | null
232 port: number | null
233 database: string | null
234 ssl_mode: string | null
235 max_connections: number | null
236 min_connections: number | null
237 replica_count: number
238 }
239 redis: { endpoint: string; mode: string; database: number | null; pool_size: number }
240 session: {
241 mode: string
242 cookie_name: string | null
243 cookie_domain: string | null
244 cookie_secure: boolean | null
245 cookie_same_site: string
246 lifespan_secs: number | null
247 table_name: string | null
248 }
249 email: {
250 enabled: boolean
251 queue_enabled: boolean
252 smtp_host: string
253 smtp_port: number
254 from_address: string | null
255 from_name: string | null
256 }
257 generated_at: string
258}
259
99260export interface AdminSpaceDetail {
100261 space: AdminSpaceSummary
101262 repos_count: number
@@ -272,3 +433,168 @@
272433 this.errorCode = errorCode
273434 }
274435}
436
437export interface AdminChatKindCount {
438 kind: string
439 count: number
440}
441
442export interface AdminChatStats {
443 rooms: number
444 active_rooms: number
445 private_rooms: number
446 kinds: AdminChatKindCount[]
447 messages: number
448 deleted_messages: number
449 messages_24h: number
450 threads: number
451 attachments: number
452 attachment_bytes: number
453 pending_attachments: number
454 members: number
455 bans: number
456}
457
458export interface AdminChatRoomSummary {
459 id: string
460 space_id: string | null
461 space_name: string | null
462 kind: string
463 name: string
464 lower_name: string
465 topic: string | null
466 owner_id: string
467 owner_username: string | null
468 is_private: boolean
469 nsfw: boolean
470 is_active: boolean
471 members: number
472 messages: number
473 last_message_at: string | null
474 created_at: string
475 updated_at: string
476}
477
478export interface AdminChatRoomDetail {
479 room: AdminChatRoomSummary
480 deleted_messages: number
481 threads: number
482 attachments: number
483 attachment_bytes: number
484 bans: number
485 invites: number
486}
487
488export interface AdminChatRoomMember {
489 user_id: string
490 username: string | null
491 display_name: string | null
492 role: string
493 nick: string | null
494 joined_at: string
495 left_at: string | null
496}
497
498export interface AdminChatMessage {
499 id: string
500 room_id: string
501 room_name: string | null
502 author_id: string
503 author_username: string | null
504 author_display_name: string | null
505 author_type: string
506 message_type: string
507 content: string
508 seq: number
509 flags: number
510 pinned: boolean
511 thread_id: string | null
512 reply_to_id: string | null
513 attachment_count: number
514 reaction_count: number
515 edited_at: string | null
516 deleted_at: string | null
517 created_at: string
518}
519
520export interface AdminChatMessageEdit {
521 id: string
522 editor_id: string
523 editor_username: string | null
524 content: string
525 created_at: string
526}
527
528export interface AdminChatReactionGroup {
529 emoji: string
530 count: number
531}
532
533export interface AdminChatAttachment {
534 id: string
535 user_id: string
536 username: string | null
537 display_name: string | null
538 room_id: string | null
539 message_id: string | null
540 filename: string
541 description: string | null
542 content_type: string
543 size: number
544 bucket: string
545 s3_key: string
546 status: string
547 created_at: string
548}
549
550export interface AdminChatThread {
551 id: string
552 name: string | null
553 owner_id: string
554 reply_count: number
555 participant_count: number
556 archived: boolean
557 locked: boolean
558 created_at: string
559}
560
561export interface AdminChatMessageDetail {
562 message: AdminChatMessage
563 edits: AdminChatMessageEdit[]
564 reactions: AdminChatReactionGroup[]
565 attachments: AdminChatAttachment[]
566 thread: AdminChatThread | null
567}
568
569export interface BulkChatMessageResp {
570 succeeded: string[]
571 failed: { id: string; error: string }[]
572}
573
574export interface PurgeChatMessagesResp {
575 room_id: string
576 messages_deleted: number
577 attachments_deleted: number
578 threads_deleted: number
579}
580
581export interface DeleteChatRoomResp {
582 room_id: string
583 messages_deleted: number
584 members_deleted: number
585 attachments_deleted: number
586 threads_deleted: number
587}
588
589export interface DeleteChatAttachmentResp {
590 id: string
591 s3_key: string
592 deleted: boolean
593 storage_deleted: boolean
594}
595
596export interface RemoveChatMemberResp {
597 room_id: string
598 user_id: string
599 removed: boolean
600}
Modified admin/src/shared/api.ts View admin/src/shared/api.ts
@@ -1,4 +1,11 @@
11import type {
2 AdminChatAttachment,
3 AdminChatMessage,
4 AdminChatMessageDetail,
5 AdminChatRoomDetail,
6 AdminChatRoomMember,
7 AdminChatRoomSummary,
8 AdminChatStats,
29 AdminDto,
310 AdminLoginResp,
411 AdminSpaceDetail,
@@ -6,18 +13,31 @@
613 AdminSpaceSummary,
714 AdminSuspension,
815 AdminUserDetail,
16 AdminUserSecurity,
17 AdminUserSessionPage,
918 AdminUserSummary,
1019 AuditLogPage,
20 BulkChatMessageResp,
21 BulkTaskResp,
22 BulkUsersResp,
23 CreateUserResp,
24 DeleteChatAttachmentResp,
25 DeleteChatRoomResp,
26 Dashboard,
1127 JobLogPage,
1228 Page,
1329 QueueActionResp,
30 PurgeChatMessagesResp,
1431 QueueSnapshot,
32 RemoveChatMemberResp,
1533 StorageObjectPage,
1634 StorageOverview,
35 SystemConfig,
1736 SystemStatus,
1837 TaskActionResp,
1938 TaskDetail,
2039 TaskPage,
40 TaskStats,
2141} from './api-types'
2242import { ApiError } from './api-types'
2343
@@ -78,6 +98,15 @@
7898 return (envelope?.data as T) ?? (undefined as T)
7999}
80100
101export function csvDownload(path: string, params?: Record<string, unknown>): void {
102 const link = document.createElement('a')
103 link.href = `${BASE}${path}${qs(params)}`
104 link.rel = 'noopener'
105 document.body.append(link)
106 link.click()
107 link.remove()
108}
109
81110export const adminAuth = {
82111 login: (username: string, password: string) =>
83112 request<AdminLoginResp>('POST', '/admin/auth/login', { body: { username, password } }),
@@ -107,6 +136,7 @@
107136 request<void>('POST', `/admin/admins/${id}/reset-password`, {
108137 body: { new_password: newPassword },
109138 }),
139 revokeSessions: (id: string) => request<void>('POST', `/admin/admins/${id}/revoke-sessions`),
110140}
111141export const adminUsers = {
112142 list: (params: {
@@ -140,6 +170,29 @@
140170 request<{ revoked: number }>('POST', `/admin/users/${uid}/revoke-sessions`),
141171 remove: (uid: string) =>
142172 request<{ user_id: string; anonymized: boolean }>('DELETE', `/admin/users/${uid}`),
173 create: (body: {
174 username: string
175 email: string
176 password: string
177 display_name?: string
178 is_active?: boolean
179 email_activated?: boolean
180 }) => request<CreateUserResp>('POST', '/admin/users', { body }),
181 bulk: (body: {
182 action: string
183 ids: string[]
184 reason?: string
185 suspension_type?: string
186 ends_at?: string
187 }) => request<BulkUsersResp>('POST', '/admin/users/bulk', { body }),
188 sessions: (uid: string, params: { kind?: string; include_revoked?: boolean; limit?: number; offset?: number }) =>
189 request<AdminUserSessionPage>('GET', `/admin/users/${uid}/sessions`, { params }),
190 revokeSession: (uid: string, sessionId: string) =>
191 request<void>('DELETE', `/admin/users/${uid}/sessions/${sessionId}`),
192 security: (uid: string) => request<AdminUserSecurity>('GET', `/admin/users/${uid}/security`),
193 clearTwoFactor: (uid: string) => request<void>('DELETE', `/admin/users/${uid}/two-factor`),
194 exportCsv: (params: { q?: string; status?: string; sort?: string }) =>
195 csvDownload('/admin/users/export', params),
143196}
144197
145198export const adminSpaces = {
@@ -153,7 +206,15 @@
153206 get: (id: string) => request<AdminSpaceDetail>('GET', `/admin/spaces/${id}`),
154207 update: (
155208 id: string,
156 body: { name?: string; description?: string | null; is_private?: boolean; is_active?: boolean },
209 body: {
210 name?: string
211 description?: string | null
212 is_private?: boolean
213 is_active?: boolean
214 allow_join?: boolean
215 allow_invite?: boolean
216 member_must_enable_2fa?: boolean
217 },
157218 ) => request<AdminSpaceDetail>('PATCH', `/admin/spaces/${id}`, { body }),
158219 transfer: (id: string, newOwnerId: string) =>
159220 request<AdminSpaceDetail>('POST', `/admin/spaces/${id}/transfer`, {
@@ -167,8 +228,81 @@
167228 'DELETE',
168229 `/admin/spaces/${id}/members/${uid}`,
169230 ),
231 addMember: (id: string, userId: string, aliasName?: string) =>
232 request<{ space_id: string; user_id: string; status: string; added: boolean }>(
233 'POST',
234 `/admin/spaces/${id}/members`,
235 { body: { user_id: userId, alias_name: aliasName } },
236 ),
237}
238
239export interface ChatRoomQuery extends Record<string, unknown> {
240 q?: string
241 space_id?: string
242 kind?: string
243 status?: string
244 visibility?: string
245 limit?: number
246 offset?: number
170247}
171248
249export interface ChatMessageQuery extends Record<string, unknown> {
250 room_id?: string
251 author_id?: string
252 status?: string
253 q?: string
254 from?: string
255 to?: string
256 limit?: number
257 offset?: number
258}
259
260export interface ChatAttachmentQuery extends Record<string, unknown> {
261 room_id?: string
262 user_id?: string
263 status?: string
264 q?: string
265 limit?: number
266 offset?: number
267}
268
269export const adminChat = {
270 stats: () => request<AdminChatStats>('GET', '/admin/chat/stats'),
271 rooms: (params: ChatRoomQuery) =>
272 request<Page<AdminChatRoomSummary>>('GET', '/admin/chat/rooms', { params }),
273 room: (id: string) => request<AdminChatRoomDetail>('GET', `/admin/chat/rooms/${id}`),
274 updateRoom: (
275 id: string,
276 body: {
277 name?: string
278 topic?: string
279 is_private?: boolean
280 nsfw?: boolean
281 is_active?: boolean
282 },
283 ) => request<AdminChatRoomDetail>('PATCH', `/admin/chat/rooms/${id}`, { body }),
284 removeRoom: (id: string) =>
285 request<DeleteChatRoomResp>('DELETE', `/admin/chat/rooms/${id}`),
286 members: (id: string, params: { status?: string; limit?: number; offset?: number }) =>
287 request<Page<AdminChatRoomMember>>('GET', `/admin/chat/rooms/${id}/members`, { params }),
288 removeMember: (id: string, uid: string) =>
289 request<RemoveChatMemberResp>('DELETE', `/admin/chat/rooms/${id}/members/${uid}`),
290 purgeRoom: (id: string) =>
291 request<PurgeChatMessagesResp>('POST', `/admin/chat/rooms/${id}/purge`),
292 messages: (params: ChatMessageQuery) =>
293 request<Page<AdminChatMessage>>('GET', '/admin/chat/messages', { params }),
294 message: (id: string) =>
295 request<AdminChatMessageDetail>('GET', `/admin/chat/messages/${id}`),
296 bulkDelete: (body: { message_ids: string[]; reason?: string }) =>
297 request<BulkChatMessageResp>('POST', '/admin/chat/messages/bulk-delete', { body }),
298 exportMessagesCsv: (params: ChatMessageQuery) =>
299 csvDownload('/admin/chat/messages/export', params),
300 attachments: (params: ChatAttachmentQuery) =>
301 request<Page<AdminChatAttachment>>('GET', '/admin/chat/attachments', { params }),
302 removeAttachment: (id: string) =>
303 request<DeleteChatAttachmentResp>('DELETE', `/admin/chat/attachments/${id}`),
304}
305
172306export const adminStorage = {
173307 overview: (refresh?: boolean) =>
174308 request<StorageOverview>('GET', '/admin/storage/overview', { params: { refresh } }),
@@ -191,6 +325,8 @@
191325 requeueFailed: (name: string) =>
192326 request<QueueActionResp>('POST', `/admin/queues/${name}/requeue-failed`),
193327 purge: (name: string) => request<QueueActionResp>('POST', `/admin/queues/${name}/purge`),
328 failed: (name: string, params: { limit?: number; offset?: number }) =>
329 request<JobLogPage>('GET', `/admin/queues/${name}/failed`, { params }),
194330}
195331
196332export const adminTasks = {
@@ -207,20 +343,33 @@
207343 cancel: (source: string, id: string) =>
208344 request<TaskActionResp>('POST', `/admin/tasks/${source}/${id}/cancel`),
209345 remove: (source: string, id: string) => request<void>('DELETE', `/admin/tasks/${source}/${id}`),
346 stats: () => request<TaskStats>('GET', '/admin/tasks/stats'),
347 bulk: (source: string, body: { action: string; ids: string[] }) =>
348 request<BulkTaskResp>('POST', `/admin/tasks/${source}/bulk`, { body }),
210349}
211350
351export interface AuditQuery extends Record<string, unknown> {
352 q?: string
353 action?: string
354 target_type?: string
355 target_id?: string
356 admin_id?: string
357 admin_username?: string
358 from?: string
359 to?: string
360 limit?: number
361 offset?: number
362}
363
212364export const adminLogs = {
213 audit: (params: {
214 action?: string
215 target_type?: string
216 admin_id?: string
217 limit?: number
218 offset?: number
219 }) => request<AuditLogPage>('GET', '/admin/logs/audit', { params }),
365 audit: (params: AuditQuery) => request<AuditLogPage>('GET', '/admin/logs/audit', { params }),
220366 jobs: (params: { source?: string; limit?: number; offset?: number }) =>
221367 request<JobLogPage>('GET', '/admin/logs/jobs', { params }),
368 exportAuditCsv: (params: AuditQuery) => csvDownload('/admin/logs/audit/export', params),
222369}
223370
224371export const adminSystem = {
225372 status: () => request<SystemStatus>('GET', '/admin/system/status'),
373 dashboard: () => request<Dashboard>('GET', '/admin/dashboard'),
374 config: () => request<SystemConfig>('GET', '/admin/system/config'),
226375}
No newline at end of file
Modified admin/openapi.json View admin/openapi.json
@@ -351,6 +351,59 @@
351351 }
352352 }
353353 },
354 "/admin/admins/{id}/revoke-sessions": {
355 "post": {
356 "tags": [
357 "admin-admins"
358 ],
359 "description": "踢出管理员全部会话:提升口令版本使已签发会话立即失效;操作他人需超级管理员。",
360 "operationId": "revoke_admin_sessions",
361 "parameters": [
362 {
363 "name": "id",
364 "in": "path",
365 "description": "admin id",
366 "required": true,
367 "schema": {
368 "type": "string",
369 "format": "uuid"
370 }
371 }
372 ],
373 "responses": {
374 "200": {
375 "description": "sessions revoked",
376 "content": {
377 "application/json": {
378 "schema": {
379 "$ref": "#/components/schemas/AppResponseData_NoData"
380 }
381 }
382 }
383 },
384 "403": {
385 "description": "operation not allowed",
386 "content": {
387 "application/json": {
388 "schema": {
389 "$ref": "#/components/schemas/AppResponseData_NoData"
390 }
391 }
392 }
393 },
394 "404": {
395 "description": "resource not found",
396 "content": {
397 "application/json": {
398 "schema": {
399 "$ref": "#/components/schemas/AppResponseData_NoData"
400 }
401 }
402 }
403 }
404 }
405 }
406 },
354407 "/admin/auth/login": {
355408 "post": {
356409 "tags": [
@@ -433,16 +486,16 @@
433486 }
434487 }
435488 },
436 "/admin/logs/audit": {
489 "/admin/chat/attachments": {
437490 "get": {
438491 "tags": [
439 "admin-logs"
492 "admin-chat"
440493 ],
441 "description": "管理操作审计日志(登录事件与成功变更)。",
442 "operationId": "list_audit_logs",
494 "description": "聊天附件列表:按房间/上传者/状态/文件名筛选。",
495 "operationId": "list_attachments",
443496 "parameters": [
444497 {
445 "name": "admin_id",
498 "name": "room_id",
446499 "in": "query",
447500 "required": false,
448501 "schema": {
@@ -454,18 +507,19 @@
454507 }
455508 },
456509 {
457 "name": "action",
510 "name": "user_id",
458511 "in": "query",
459512 "required": false,
460513 "schema": {
461514 "type": [
462515 "string",
463516 "null"
464 ]
517 ],
518 "format": "uuid"
465519 }
466520 },
467521 {
468 "name": "target_type",
522 "name": "status",
469523 "in": "query",
470524 "required": false,
471525 "schema": {
@@ -476,7 +530,7 @@
476530 }
477531 },
478532 {
479 "name": "target_id",
533 "name": "q",
480534 "in": "query",
481535 "required": false,
482536 "schema": {
@@ -484,30 +538,6 @@
484538 "string",
485539 "null"
486540 ]
487 }
488 },
489 {
490 "name": "from",
491 "in": "query",
492 "required": false,
493 "schema": {
494 "type": [
495 "string",
496 "null"
497 ],
498 "format": "date-time"
499 }
500 },
501 {
502 "name": "to",
503 "in": "query",
504 "required": false,
505 "schema": {
506 "type": [
507 "string",
508 "null"
509 ],
510 "format": "date-time"
511541 }
512542 },
513543 {
@@ -539,11 +569,11 @@
539569 ],
540570 "responses": {
541571 "200": {
542 "description": "audit page",
572 "description": "attachment page",
543573 "content": {
544574 "application/json": {
545575 "schema": {
546 "$ref": "#/components/schemas/AppResponseData_AuditLogPageDto"
576 "$ref": "#/components/schemas/AppResponseData_Page_AdminChatAttachmentDto"
547577 }
548578 }
549579 }
@@ -561,16 +591,94 @@
561591 }
562592 }
563593 },
564 "/admin/logs/jobs": {
594 "/admin/chat/attachments/{id}": {
595 "delete": {
596 "tags": [
597 "admin-chat"
598 ],
599 "description": "删除聊天附件记录并清理未被其它记录引用的对象存储文件;仅超级管理员可操作。",
600 "operationId": "delete_attachment",
601 "parameters": [
602 {
603 "name": "id",
604 "in": "path",
605 "description": "attachment id",
606 "required": true,
607 "schema": {
608 "type": "string",
609 "format": "uuid"
610 }
611 }
612 ],
613 "responses": {
614 "200": {
615 "description": "attachment deleted",
616 "content": {
617 "application/json": {
618 "schema": {
619 "$ref": "#/components/schemas/AppResponseData_DeleteChatAttachmentResp"
620 }
621 }
622 }
623 },
624 "404": {
625 "description": "resource not found",
626 "content": {
627 "application/json": {
628 "schema": {
629 "$ref": "#/components/schemas/AppResponseData_NoData"
630 }
631 }
632 }
633 }
634 }
635 }
636 },
637 "/admin/chat/messages": {
565638 "get": {
566639 "tags": [
567 "admin-logs"
640 "admin-chat"
568641 ],
569 "description": "作业错误日志:聚合 import/search/system_tasks/webhook/pipeline 的失败信息。",
570 "operationId": "list_job_logs",
642 "description": "消息列表:按房间/作者/状态/关键字/时间筛选,返回附件与表情计数。",
643 "operationId": "list_messages",
571644 "parameters": [
572645 {
573 "name": "source",
646 "name": "room_id",
647 "in": "query",
648 "required": false,
649 "schema": {
650 "type": [
651 "string",
652 "null"
653 ],
654 "format": "uuid"
655 }
656 },
657 {
658 "name": "author_id",
659 "in": "query",
660 "required": false,
661 "schema": {
662 "type": [
663 "string",
664 "null"
665 ],
666 "format": "uuid"
667 }
668 },
669 {
670 "name": "status",
671 "in": "query",
672 "required": false,
673 "schema": {
674 "type": [
675 "string",
676 "null"
677 ]
678 }
679 },
680 {
681 "name": "q",
574682 "in": "query",
575683 "required": false,
576684 "schema": {
@@ -633,11 +741,11 @@
633741 ],
634742 "responses": {
635743 "200": {
636 "description": "job log page",
744 "description": "message page",
637745 "content": {
638746 "application/json": {
639747 "schema": {
640 "$ref": "#/components/schemas/AppResponseData_JobLogPageDto"
748 "$ref": "#/components/schemas/AppResponseData_Page_AdminChatMessageDto"
641749 }
642750 }
643751 }
@@ -655,49 +763,18 @@
655763 }
656764 }
657765 },
658 "/admin/me": {
659 "get": {
766 "/admin/chat/messages/bulk-delete": {
767 "post": {
660768 "tags": [
661 "admin-auth"
769 "admin-chat"
662770 ],
663 "description": "当前管理员资料。",
664 "operationId": "me",
665 "responses": {
666 "200": {
667 "description": "current admin",
668 "content": {
669 "application/json": {
670 "schema": {
671 "$ref": "#/components/schemas/AppResponseData_AdminDto"
672 }
673 }
674 }
675 },
676 "401": {
677 "description": "authentication required",
678 "content": {
679 "application/json": {
680 "schema": {
681 "$ref": "#/components/schemas/AppResponseData_NoData"
682 }
683 }
684 }
685 }
686 }
687 }
688 },
689 "/admin/me/password": {
690 "patch": {
691 "tags": [
692 "admin-auth"
693 ],
694 "description": "修改当前管理员口令;口令版本变更会失效其他管理端会话。",
695 "operationId": "change_password",
771 "description": "批量软删除消息(≤100 条),清空内容并移除附件/表情/编辑等从属记录。",
772 "operationId": "bulk_delete_messages",
696773 "requestBody": {
697774 "content": {
698775 "application/json": {
699776 "schema": {
700 "$ref": "#/components/schemas/ChangePasswordCmd"
777 "$ref": "#/components/schemas/BulkDeleteChatMessagesCmd"
701778 }
702779 }
703780 },
@@ -705,27 +782,17 @@
705782 },
706783 "responses": {
707784 "200": {
708 "description": "password changed",
785 "description": "bulk delete result",
709786 "content": {
710787 "application/json": {
711788 "schema": {
712 "$ref": "#/components/schemas/AppResponseData_NoData"
789 "$ref": "#/components/schemas/AppResponseData_BulkDeleteChatMessagesResp"
713790 }
714791 }
715792 }
716793 },
717794 "400": {
718795 "description": "request failed validation",
719 "content": {
720 "application/json": {
721 "schema": {
722 "$ref": "#/components/schemas/AppResponseData_NoData"
723 }
724 }
725 }
726 },
727 "401": {
728 "description": "authentication required",
729796 "content": {
730797 "application/json": {
731798 "schema": {
@@ -737,110 +804,150 @@
737804 }
738805 }
739806 },
740 "/admin/queues": {
807 "/admin/chat/messages/export": {
741808 "get": {
742809 "tags": [
743 "admin-queues"
810 "admin-chat"
744811 ],
745 "description": "队列快照:Redis git 同步队列、邮件 pub/sub 与各数据库作业队列深度/状态。",
746 "operationId": "list_queues",
747 "responses": {
748 "200": {
749 "description": "queue snapshots",
750 "content": {
751 "application/json": {
752 "schema": {
753 "$ref": "#/components/schemas/AppResponseData_Vec_QueueSnapshotDto"
754 }
755 }
812 "description": "按当前筛选导出消息 CSV(最多 10000 行)。",
813 "operationId": "export_messages",
814 "parameters": [
815 {
816 "name": "room_id",
817 "in": "query",
818 "required": false,
819 "schema": {
820 "type": [
821 "string",
822 "null"
823 ],
824 "format": "uuid"
825 }
826 },
827 {
828 "name": "author_id",
829 "in": "query",
830 "required": false,
831 "schema": {
832 "type": [
833 "string",
834 "null"
835 ],
836 "format": "uuid"
837 }
838 },
839 {
840 "name": "status",
841 "in": "query",
842 "required": false,
843 "schema": {
844 "type": [
845 "string",
846 "null"
847 ]
848 }
849 },
850 {
851 "name": "q",
852 "in": "query",
853 "required": false,
854 "schema": {
855 "type": [
856 "string",
857 "null"
858 ]
859 }
860 },
861 {
862 "name": "from",
863 "in": "query",
864 "required": false,
865 "schema": {
866 "type": [
867 "string",
868 "null"
869 ],
870 "format": "date-time"
871 }
872 },
873 {
874 "name": "to",
875 "in": "query",
876 "required": false,
877 "schema": {
878 "type": [
879 "string",
880 "null"
881 ],
882 "format": "date-time"
756883 }
757884 },
758 "401": {
759 "description": "authentication required",
760 "content": {
761 "application/json": {
762 "schema": {
763 "$ref": "#/components/schemas/AppResponseData_NoData"
764 }
765 }
885 {
886 "name": "limit",
887 "in": "query",
888 "required": false,
889 "schema": {
890 "type": [
891 "integer",
892 "null"
893 ],
894 "format": "int64",
895 "minimum": 0
766896 }
767 }
768 }
769 }
770 },
771 "/admin/queues/{name}/purge": {
772 "post": {
773 "tags": [
774 "admin-queues"
775 ],
776 "description": "清空队列:git 同步队列直接删除 Redis list,数据库队列删除终态记录;仅超级管理员可操作。",
777 "operationId": "purge",
778 "parameters": [
897 },
779898 {
780 "name": "name",
781 "in": "path",
782 "description": "queue name",
783 "required": true,
899 "name": "offset",
900 "in": "query",
901 "required": false,
784902 "schema": {
785 "type": "string"
903 "type": [
904 "integer",
905 "null"
906 ],
907 "format": "int64",
908 "minimum": 0
786909 }
787910 }
788911 ],
789912 "responses": {
790913 "200": {
791 "description": "queue purged",
792 "content": {
793 "application/json": {
794 "schema": {
795 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
796 }
797 }
798 }
799 },
800 "400": {
801 "description": "unsupported queue",
802 "content": {
803 "application/json": {
804 "schema": {
805 "$ref": "#/components/schemas/AppResponseData_NoData"
806 }
807 }
808 }
914 "description": "csv export"
809915 }
810916 }
811917 }
812918 },
813 "/admin/queues/{name}/requeue-failed": {
814 "post": {
919 "/admin/chat/messages/{id}": {
920 "get": {
815921 "tags": [
816 "admin-queues"
922 "admin-chat"
817923 ],
818 "description": "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
819 "operationId": "requeue_failed",
924 "description": "消息详情:正文、编辑历史、表情聚合、附件与所属线程。",
925 "operationId": "get_message",
820926 "parameters": [
821927 {
822 "name": "name",
928 "name": "id",
823929 "in": "path",
824 "description": "queue name",
930 "description": "message id",
825931 "required": true,
826932 "schema": {
827 "type": "string"
933 "type": "string",
934 "format": "uuid"
828935 }
829936 }
830937 ],
831938 "responses": {
832939 "200": {
833 "description": "failed items requeued",
940 "description": "message detail",
834941 "content": {
835942 "application/json": {
836943 "schema": {
837 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
944 "$ref": "#/components/schemas/AppResponseData_AdminChatMessageDetail"
838945 }
839946 }
840947 }
841948 },
842 "400": {
843 "description": "unsupported queue",
949 "404": {
950 "description": "resource not found",
844951 "content": {
845952 "application/json": {
846953 "schema": {
@@ -852,13 +959,13 @@
852959 }
853960 }
854961 },
855 "/admin/spaces": {
962 "/admin/chat/rooms": {
856963 "get": {
857964 "tags": [
858 "admin-spaces"
965 "admin-chat"
859966 ],
860 "description": "Space 列表:关键字、状态、Owner 与分页。",
861 "operationId": "list_spaces",
967 "description": "房间列表:关键字、Space、类型、可见性与启停状态筛选。",
968 "operationId": "list_rooms",
862969 "parameters": [
863970 {
864971 "name": "q",
@@ -872,6 +979,29 @@
872979 }
873980 },
874981 {
982 "name": "space_id",
983 "in": "query",
984 "required": false,
985 "schema": {
986 "type": [
987 "string",
988 "null"
989 ],
990 "format": "uuid"
991 }
992 },
993 {
994 "name": "kind",
995 "in": "query",
996 "required": false,
997 "schema": {
998 "type": [
999 "string",
1000 "null"
1001 ]
1002 }
1003 },
1004 {
8751005 "name": "status",
8761006 "in": "query",
8771007 "required": false,
@@ -883,15 +1013,14 @@
8831013 }
8841014 },
8851015 {
886 "name": "owner_id",
1016 "name": "visibility",
8871017 "in": "query",
8881018 "required": false,
8891019 "schema": {
8901020 "type": [
8911021 "string",
8921022 "null"
893 ],
894 "format": "uuid"
1023 ]
8951024 }
8961025 },
8971026 {
@@ -923,17 +1052,17 @@
9231052 ],
9241053 "responses": {
9251054 "200": {
926 "description": "space page",
1055 "description": "room page",
9271056 "content": {
9281057 "application/json": {
9291058 "schema": {
930 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceSummary"
1059 "$ref": "#/components/schemas/AppResponseData_Page_AdminChatRoomSummary"
9311060 }
9321061 }
9331062 }
9341063 },
935 "401": {
936 "description": "authentication required",
1064 "400": {
1065 "description": "request failed validation",
9371066 "content": {
9381067 "application/json": {
9391068 "schema": {
@@ -945,18 +1074,18 @@
9451074 }
9461075 }
9471076 },
948 "/admin/spaces/{id}": {
1077 "/admin/chat/rooms/{id}": {
9491078 "get": {
9501079 "tags": [
951 "admin-spaces"
1080 "admin-chat"
9521081 ],
953 "description": "Space 详情:Owner、成员数、仓库/房间/目录统计。",
954 "operationId": "get_space",
1082 "description": "房间详情:基础信息、成员/消息计数与删除/封禁/邀请统计。",
1083 "operationId": "get_room",
9551084 "parameters": [
9561085 {
9571086 "name": "id",
9581087 "in": "path",
959 "description": "space id",
1088 "description": "room id",
9601089 "required": true,
9611090 "schema": {
9621091 "type": "string",
@@ -966,11 +1095,11 @@
9661095 ],
9671096 "responses": {
9681097 "200": {
969 "description": "space detail",
1098 "description": "room detail",
9701099 "content": {
9711100 "application/json": {
9721101 "schema": {
973 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
1102 "$ref": "#/components/schemas/AppResponseData_AdminChatRoomDetail"
9741103 }
9751104 }
9761105 }
@@ -989,15 +1118,15 @@
9891118 },
9901119 "delete": {
9911120 "tags": [
992 "admin-spaces"
1121 "admin-chat"
9931122 ],
994 "description": "停用 Space(软删除);仅超级管理员可操作。",
995 "operationId": "delete_space",
1123 "description": "删除房间及其消息/成员/线程/附件,动作不可恢复;仅超级管理员可操作。",
1124 "operationId": "delete_room",
9961125 "parameters": [
9971126 {
9981127 "name": "id",
9991128 "in": "path",
1000 "description": "space id",
1129 "description": "room id",
10011130 "required": true,
10021131 "schema": {
10031132 "type": "string",
@@ -1007,17 +1136,17 @@
10071136 ],
10081137 "responses": {
10091138 "200": {
1010 "description": "space deleted",
1139 "description": "room deleted",
10111140 "content": {
10121141 "application/json": {
10131142 "schema": {
1014 "$ref": "#/components/schemas/AppResponseData_NoData"
1143 "$ref": "#/components/schemas/AppResponseData_DeleteChatRoomResp"
10151144 }
10161145 }
10171146 }
10181147 },
1019 "404": {
1020 "description": "resource not found",
1148 "409": {
1149 "description": "child rooms exist",
10211150 "content": {
10221151 "application/json": {
10231152 "schema": {
@@ -1030,15 +1159,15 @@
10301159 },
10311160 "patch": {
10321161 "tags": [
1033 "admin-spaces"
1162 "admin-chat"
10341163 ],
1035 "description": "更新 Space:名称、描述、私有与启用状态。",
1036 "operationId": "update_space",
1164 "description": "更新房间属性:名称、主题、私有、NSFW 与启停。",
1165 "operationId": "update_room",
10371166 "parameters": [
10381167 {
10391168 "name": "id",
10401169 "in": "path",
1041 "description": "space id",
1170 "description": "room id",
10421171 "required": true,
10431172 "schema": {
10441173 "type": "string",
@@ -1050,7 +1179,7 @@
10501179 "content": {
10511180 "application/json": {
10521181 "schema": {
1053 "$ref": "#/components/schemas/UpdateSpaceCmd"
1182 "$ref": "#/components/schemas/UpdateChatRoomCmd"
10541183 }
10551184 }
10561185 },
@@ -1058,17 +1187,17 @@
10581187 },
10591188 "responses": {
10601189 "200": {
1061 "description": "space updated",
1190 "description": "room updated",
10621191 "content": {
10631192 "application/json": {
10641193 "schema": {
1065 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
1194 "$ref": "#/components/schemas/AppResponseData_AdminChatRoomDetail"
10661195 }
10671196 }
10681197 }
10691198 },
1070 "404": {
1071 "description": "resource not found",
1199 "409": {
1200 "description": "resource conflict",
10721201 "content": {
10731202 "application/json": {
10741203 "schema": {
@@ -1080,18 +1209,18 @@
10801209 }
10811210 }
10821211 },
1083 "/admin/spaces/{id}/members": {
1212 "/admin/chat/rooms/{id}/members": {
10841213 "get": {
10851214 "tags": [
1086 "admin-spaces"
1215 "admin-chat"
10871216 ],
1088 "description": "Space 成员列表。",
1089 "operationId": "list_space_members",
1217 "description": "房间成员列表:可按在席/已离开筛选。",
1218 "operationId": "list_members",
10901219 "parameters": [
10911220 {
10921221 "name": "id",
10931222 "in": "path",
1094 "description": "space id",
1223 "description": "room id",
10951224 "required": true,
10961225 "schema": {
10971226 "type": "string",
@@ -1142,7 +1271,7 @@
11421271 "content": {
11431272 "application/json": {
11441273 "schema": {
1145 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceMemberDto"
1274 "$ref": "#/components/schemas/AppResponseData_Page_AdminChatRoomMemberDto"
11461275 }
11471276 }
11481277 }
@@ -1160,18 +1289,18 @@
11601289 }
11611290 }
11621291 },
1163 "/admin/spaces/{id}/members/{uid}": {
1292 "/admin/chat/rooms/{id}/members/{uid}": {
11641293 "delete": {
11651294 "tags": [
1166 "admin-spaces"
1295 "admin-chat"
11671296 ],
1168 "description": "将成员移出 Space。",
1169 "operationId": "remove_space_member",
1297 "description": "将成员移出房间,动作即时生效并广播 member_left 事件。",
1298 "operationId": "remove_member",
11701299 "parameters": [
11711300 {
11721301 "name": "id",
11731302 "in": "path",
1174 "description": "space id",
1303 "description": "room id",
11751304 "required": true,
11761305 "schema": {
11771306 "type": "string",
@@ -1195,7 +1324,7 @@
11951324 "content": {
11961325 "application/json": {
11971326 "schema": {
1198 "$ref": "#/components/schemas/AppResponseData_RemoveMemberResp"
1327 "$ref": "#/components/schemas/AppResponseData_RemoveChatMemberResp"
11991328 }
12001329 }
12011330 }
@@ -1213,18 +1342,18 @@
12131342 }
12141343 }
12151344 },
1216 "/admin/spaces/{id}/transfer": {
1345 "/admin/chat/rooms/{id}/purge": {
12171346 "post": {
12181347 "tags": [
1219 "admin-spaces"
1348 "admin-chat"
12201349 ],
1221 "description": "转移 Space 所有权;仅超级管理员可操作。",
1222 "operationId": "transfer_space",
1350 "description": "清空房间全部消息、线程与附件(保留房间本身);仅超级管理员可操作。",
1351 "operationId": "purge_room",
12231352 "parameters": [
12241353 {
12251354 "name": "id",
12261355 "in": "path",
1227 "description": "space id",
1356 "description": "room id",
12281357 "required": true,
12291358 "schema": {
12301359 "type": "string",
@@ -1232,29 +1361,19 @@
12321361 }
12331362 }
12341363 ],
1235 "requestBody": {
1236 "content": {
1237 "application/json": {
1238 "schema": {
1239 "$ref": "#/components/schemas/TransferSpaceCmd"
1240 }
1241 }
1242 },
1243 "required": true
1244 },
12451364 "responses": {
12461365 "200": {
1247 "description": "space transferred",
1366 "description": "room messages purged",
12481367 "content": {
12491368 "application/json": {
12501369 "schema": {
1251 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
1370 "$ref": "#/components/schemas/AppResponseData_PurgeChatMessagesResp"
12521371 }
12531372 }
12541373 }
12551374 },
1256 "400": {
1257 "description": "request failed validation",
1375 "404": {
1376 "description": "resource not found",
12581377 "content": {
12591378 "application/json": {
12601379 "schema": {
@@ -1262,9 +1381,30 @@
12621381 }
12631382 }
12641383 }
1384 }
1385 }
1386 }
1387 },
1388 "/admin/chat/stats": {
1389 "get": {
1390 "tags": [
1391 "admin-chat"
1392 ],
1393 "description": "聊天域统计:房间/消息/线程/附件/成员/封禁汇总与房间类型分布。",
1394 "operationId": "stats",
1395 "responses": {
1396 "200": {
1397 "description": "chat stats",
1398 "content": {
1399 "application/json": {
1400 "schema": {
1401 "$ref": "#/components/schemas/AppResponseData_AdminChatStatsDto"
1402 }
1403 }
1404 }
12651405 },
1266 "409": {
1267 "description": "resource conflict",
1406 "401": {
1407 "description": "authentication required",
12681408 "content": {
12691409 "application/json": {
12701410 "schema": {
@@ -1276,36 +1416,26 @@
12761416 }
12771417 }
12781418 },
1279 "/admin/storage/download-url": {
1419 "/admin/dashboard": {
12801420 "get": {
12811421 "tags": [
1282 "admin-storage"
1422 "admin-system"
12831423 ],
1284 "description": "生成对象预签名下载地址(最长 1 小时)。",
1285 "operationId": "download_url",
1286 "parameters": [
1287 {
1288 "name": "key",
1289 "in": "query",
1290 "required": true,
1291 "schema": {
1292 "type": "string"
1293 }
1294 }
1295 ],
1424 "description": "管理看板聚合:核心计数、14 天增长趋势、活跃会话、最近审计与失败、队列概览。",
1425 "operationId": "dashboard",
12961426 "responses": {
12971427 "200": {
1298 "description": "presigned download url",
1428 "description": "dashboard",
12991429 "content": {
13001430 "application/json": {
13011431 "schema": {
1302 "$ref": "#/components/schemas/AppResponseData_DownloadUrlDto"
1432 "$ref": "#/components/schemas/AppResponseData_DashboardDto"
13031433 }
13041434 }
13051435 }
13061436 },
1307 "404": {
1308 "description": "resource not found",
1437 "401": {
1438 "description": "authentication required",
13091439 "content": {
13101440 "application/json": {
13111441 "schema": {
@@ -1317,16 +1447,61 @@
13171447 }
13181448 }
13191449 },
1320 "/admin/storage/objects": {
1450 "/admin/logs/audit": {
13211451 "get": {
13221452 "tags": [
1323 "admin-storage"
1453 "admin-logs"
13241454 ],
1325 "description": "对象浏览:前缀过滤与 token 分页。",
1326 "operationId": "list_objects",
1455 "description": "管理操作审计日志(登录事件与成功变更)。",
1456 "operationId": "list_audit_logs",
13271457 "parameters": [
13281458 {
1329 "name": "prefix",
1459 "name": "admin_id",
1460 "in": "query",
1461 "required": false,
1462 "schema": {
1463 "type": [
1464 "string",
1465 "null"
1466 ],
1467 "format": "uuid"
1468 }
1469 },
1470 {
1471 "name": "admin_username",
1472 "in": "query",
1473 "required": false,
1474 "schema": {
1475 "type": [
1476 "string",
1477 "null"
1478 ]
1479 }
1480 },
1481 {
1482 "name": "action",
1483 "in": "query",
1484 "required": false,
1485 "schema": {
1486 "type": [
1487 "string",
1488 "null"
1489 ]
1490 }
1491 },
1492 {
1493 "name": "target_type",
1494 "in": "query",
1495 "required": false,
1496 "schema": {
1497 "type": [
1498 "string",
1499 "null"
1500 ]
1501 }
1502 },
1503 {
1504 "name": "target_id",
13301505 "in": "query",
13311506 "required": false,
13321507 "schema": {
@@ -1337,7 +1512,7 @@
13371512 }
13381513 },
13391514 {
1340 "name": "token",
1515 "name": "q",
13411516 "in": "query",
13421517 "required": false,
13431518 "schema": {
@@ -1348,6 +1523,30 @@
13481523 }
13491524 },
13501525 {
1526 "name": "from",
1527 "in": "query",
1528 "required": false,
1529 "schema": {
1530 "type": [
1531 "string",
1532 "null"
1533 ],
1534 "format": "date-time"
1535 }
1536 },
1537 {
1538 "name": "to",
1539 "in": "query",
1540 "required": false,
1541 "schema": {
1542 "type": [
1543 "string",
1544 "null"
1545 ],
1546 "format": "date-time"
1547 }
1548 },
1549 {
13511550 "name": "limit",
13521551 "in": "query",
13531552 "required": false,
@@ -1356,62 +1555,37 @@
13561555 "integer",
13571556 "null"
13581557 ],
1558 "format": "int64",
13591559 "minimum": 0
13601560 }
1361 }
1362 ],
1363 "responses": {
1364 "200": {
1365 "description": "object page",
1366 "content": {
1367 "application/json": {
1368 "schema": {
1369 "$ref": "#/components/schemas/AppResponseData_StorageObjectPageDto"
1370 }
1371 }
1372 }
13731561 },
1374 "401": {
1375 "description": "authentication required",
1376 "content": {
1377 "application/json": {
1378 "schema": {
1379 "$ref": "#/components/schemas/AppResponseData_NoData"
1380 }
1381 }
1382 }
1383 }
1384 }
1385 },
1386 "delete": {
1387 "tags": [
1388 "admin-storage"
1389 ],
1390 "description": "删除存储对象;仅超级管理员可操作。",
1391 "operationId": "delete_object",
1392 "parameters": [
13931562 {
1394 "name": "key",
1563 "name": "offset",
13951564 "in": "query",
1396 "required": true,
1565 "required": false,
13971566 "schema": {
1398 "type": "string"
1567 "type": [
1568 "integer",
1569 "null"
1570 ],
1571 "format": "int64",
1572 "minimum": 0
13991573 }
14001574 }
14011575 ],
14021576 "responses": {
14031577 "200": {
1404 "description": "object deleted",
1578 "description": "audit page",
14051579 "content": {
14061580 "application/json": {
14071581 "schema": {
1408 "$ref": "#/components/schemas/AppResponseData_DeleteObjectResp"
1582 "$ref": "#/components/schemas/AppResponseData_AuditLogPageDto"
14091583 }
14101584 }
14111585 }
14121586 },
1413 "404": {
1414 "description": "resource not found",
1587 "401": {
1588 "description": "authentication required",
14151589 "content": {
14161590 "application/json": {
14171591 "schema": {
@@ -1423,91 +1597,50 @@
14231597 }
14241598 }
14251599 },
1426 "/admin/storage/overview": {
1600 "/admin/logs/audit/export": {
14271601 "get": {
14281602 "tags": [
1429 "admin-storage"
1603 "admin-logs"
14301604 ],
1431 "description": "存储总览:单桶按前缀聚合的用量与对象数(60s 缓存,refresh=true 强制重算)。",
1432 "operationId": "overview",
1605 "description": "按筛选条件导出审计日志 CSV(最多 10000 行)。",
1606 "operationId": "export_audit_logs",
14331607 "parameters": [
14341608 {
1435 "name": "refresh",
1609 "name": "admin_id",
14361610 "in": "query",
14371611 "required": false,
14381612 "schema": {
14391613 "type": [
1440 "boolean",
1614 "string",
14411615 "null"
1442 ]
1616 ],
1617 "format": "uuid"
14431618 }
1444 }
1445 ],
1446 "responses": {
1447 "200": {
1448 "description": "storage overview",
1449 "content": {
1450 "application/json": {
1451 "schema": {
1452 "$ref": "#/components/schemas/AppResponseData_StorageOverviewDto"
1453 }
1454 }
1619 },
1620 {
1621 "name": "admin_username",
1622 "in": "query",
1623 "required": false,
1624 "schema": {
1625 "type": [
1626 "string",
1627 "null"
1628 ]
14551629 }
14561630 },
1457 "401": {
1458 "description": "authentication required",
1459 "content": {
1460 "application/json": {
1461 "schema": {
1462 "$ref": "#/components/schemas/AppResponseData_NoData"
1463 }
1464 }
1465 }
1466 }
1467 }
1468 }
1469 },
1470 "/admin/system/status": {
1471 "get": {
1472 "tags": [
1473 "admin-system"
1474 ],
1475 "description": "系统状态:进程信息、DB/Redis/S3/搜索健康、核心计数、队列汇总与最近失败。",
1476 "operationId": "status",
1477 "responses": {
1478 "200": {
1479 "description": "system status",
1480 "content": {
1481 "application/json": {
1482 "schema": {
1483 "$ref": "#/components/schemas/AppResponseData_SystemStatusDto"
1484 }
1485 }
1631 {
1632 "name": "action",
1633 "in": "query",
1634 "required": false,
1635 "schema": {
1636 "type": [
1637 "string",
1638 "null"
1639 ]
14861640 }
14871641 },
1488 "401": {
1489 "description": "authentication required",
1490 "content": {
1491 "application/json": {
1492 "schema": {
1493 "$ref": "#/components/schemas/AppResponseData_NoData"
1494 }
1495 }
1496 }
1497 }
1498 }
1499 }
1500 },
1501 "/admin/tasks": {
1502 "get": {
1503 "tags": [
1504 "admin-tasks"
1505 ],
1506 "description": "统一任务视图:import/search_index/chat_webhook/pipeline/deletion/system_tasks。",
1507 "operationId": "list_tasks",
1508 "parameters": [
15091642 {
1510 "name": "source",
1643 "name": "target_type",
15111644 "in": "query",
15121645 "required": false,
15131646 "schema": {
@@ -1518,7 +1651,7 @@
15181651 }
15191652 },
15201653 {
1521 "name": "state",
1654 "name": "target_id",
15221655 "in": "query",
15231656 "required": false,
15241657 "schema": {
@@ -1592,119 +1725,94 @@
15921725 ],
15931726 "responses": {
15941727 "200": {
1595 "description": "task page",
1596 "content": {
1597 "application/json": {
1598 "schema": {
1599 "$ref": "#/components/schemas/AppResponseData_TaskPageDto"
1600 }
1601 }
1602 }
1603 },
1604 "400": {
1605 "description": "request failed validation",
1606 "content": {
1607 "application/json": {
1608 "schema": {
1609 "$ref": "#/components/schemas/AppResponseData_NoData"
1610 }
1611 }
1612 }
1728 "description": "csv export"
16131729 }
16141730 }
16151731 }
16161732 },
1617 "/admin/tasks/{source}/{id}": {
1733 "/admin/logs/jobs": {
16181734 "get": {
16191735 "tags": [
1620 "admin-tasks"
1736 "admin-logs"
16211737 ],
1622 "description": "任务详情;import token 与 webhook payload 已脱敏。",
1623 "operationId": "get_task",
1738 "description": "作业错误日志:聚合 import/search/system_tasks/webhook/pipeline 的失败信息。",
1739 "operationId": "list_job_logs",
16241740 "parameters": [
16251741 {
16261742 "name": "source",
1627 "in": "path",
1628 "description": "task source",
1629 "required": true,
1743 "in": "query",
1744 "required": false,
16301745 "schema": {
1631 "type": "string"
1746 "type": [
1747 "string",
1748 "null"
1749 ]
16321750 }
16331751 },
16341752 {
1635 "name": "id",
1636 "in": "path",
1637 "description": "task id",
1638 "required": true,
1753 "name": "from",
1754 "in": "query",
1755 "required": false,
16391756 "schema": {
1640 "type": "string",
1641 "format": "uuid"
1757 "type": [
1758 "string",
1759 "null"
1760 ],
1761 "format": "date-time"
16421762 }
1643 }
1644 ],
1645 "responses": {
1646 "200": {
1647 "description": "task detail",
1648 "content": {
1649 "application/json": {
1650 "schema": {
1651 "$ref": "#/components/schemas/AppResponseData_TaskDetailDto"
1652 }
1653 }
1763 },
1764 {
1765 "name": "to",
1766 "in": "query",
1767 "required": false,
1768 "schema": {
1769 "type": [
1770 "string",
1771 "null"
1772 ],
1773 "format": "date-time"
16541774 }
16551775 },
1656 "404": {
1657 "description": "resource not found",
1658 "content": {
1659 "application/json": {
1660 "schema": {
1661 "$ref": "#/components/schemas/AppResponseData_NoData"
1662 }
1663 }
1664 }
1665 }
1666 }
1667 },
1668 "delete": {
1669 "tags": [
1670 "admin-tasks"
1671 ],
1672 "description": "删除终态任务记录;仅超级管理员可操作。",
1673 "operationId": "delete_task",
1674 "parameters": [
16751776 {
1676 "name": "source",
1677 "in": "path",
1678 "description": "task source",
1679 "required": true,
1777 "name": "limit",
1778 "in": "query",
1779 "required": false,
16801780 "schema": {
1681 "type": "string"
1781 "type": [
1782 "integer",
1783 "null"
1784 ],
1785 "format": "int64",
1786 "minimum": 0
16821787 }
16831788 },
16841789 {
1685 "name": "id",
1686 "in": "path",
1687 "description": "task id",
1688 "required": true,
1790 "name": "offset",
1791 "in": "query",
1792 "required": false,
16891793 "schema": {
1690 "type": "string",
1691 "format": "uuid"
1794 "type": [
1795 "integer",
1796 "null"
1797 ],
1798 "format": "int64",
1799 "minimum": 0
16921800 }
16931801 }
16941802 ],
16951803 "responses": {
16961804 "200": {
1697 "description": "task deleted",
1805 "description": "job log page",
16981806 "content": {
16991807 "application/json": {
17001808 "schema": {
1701 "$ref": "#/components/schemas/AppResponseData_NoData"
1809 "$ref": "#/components/schemas/AppResponseData_JobLogPageDto"
17021810 }
17031811 }
17041812 }
17051813 },
1706 "409": {
1707 "description": "resource conflict",
1814 "401": {
1815 "description": "authentication required",
17081816 "content": {
17091817 "application/json": {
17101818 "schema": {
@@ -1716,47 +1824,26 @@
17161824 }
17171825 }
17181826 },
1719 "/admin/tasks/{source}/{id}/cancel": {
1720 "post": {
1827 "/admin/me": {
1828 "get": {
17211829 "tags": [
1722 "admin-tasks"
1830 "admin-auth"
17231831 ],
1724 "description": "取消任务:队列中/进行中的作业按来源置取消态。",
1725 "operationId": "cancel_task",
1726 "parameters": [
1727 {
1728 "name": "source",
1729 "in": "path",
1730 "description": "task source",
1731 "required": true,
1732 "schema": {
1733 "type": "string"
1734 }
1735 },
1736 {
1737 "name": "id",
1738 "in": "path",
1739 "description": "task id",
1740 "required": true,
1741 "schema": {
1742 "type": "string",
1743 "format": "uuid"
1744 }
1745 }
1746 ],
1832 "description": "当前管理员资料。",
1833 "operationId": "me",
17471834 "responses": {
17481835 "200": {
1749 "description": "task cancelled",
1836 "description": "current admin",
17501837 "content": {
17511838 "application/json": {
17521839 "schema": {
1753 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
1840 "$ref": "#/components/schemas/AppResponseData_AdminDto"
17541841 }
17551842 }
17561843 }
17571844 },
1758 "409": {
1759 "description": "resource conflict",
1845 "401": {
1846 "description": "authentication required",
17601847 "content": {
17611848 "application/json": {
17621849 "schema": {
@@ -1768,47 +1855,229 @@
17681855 }
17691856 }
17701857 },
1771 "/admin/tasks/{source}/{id}/retry": {
1772 "post": {
1858 "/admin/me/password": {
1859 "patch": {
17731860 "tags": [
1774 "admin-tasks"
1861 "admin-auth"
1862 ],
1863 "description": "修改当前管理员口令;口令版本变更会失效其他管理端会话。",
1864 "operationId": "change_password",
1865 "requestBody": {
1866 "content": {
1867 "application/json": {
1868 "schema": {
1869 "$ref": "#/components/schemas/ChangePasswordCmd"
1870 }
1871 }
1872 },
1873 "required": true
1874 },
1875 "responses": {
1876 "200": {
1877 "description": "password changed",
1878 "content": {
1879 "application/json": {
1880 "schema": {
1881 "$ref": "#/components/schemas/AppResponseData_NoData"
1882 }
1883 }
1884 }
1885 },
1886 "400": {
1887 "description": "request failed validation",
1888 "content": {
1889 "application/json": {
1890 "schema": {
1891 "$ref": "#/components/schemas/AppResponseData_NoData"
1892 }
1893 }
1894 }
1895 },
1896 "401": {
1897 "description": "authentication required",
1898 "content": {
1899 "application/json": {
1900 "schema": {
1901 "$ref": "#/components/schemas/AppResponseData_NoData"
1902 }
1903 }
1904 }
1905 }
1906 }
1907 }
1908 },
1909 "/admin/queues": {
1910 "get": {
1911 "tags": [
1912 "admin-queues"
1913 ],
1914 "description": "队列快照:Redis git 同步队列、邮件 pub/sub 与各数据库作业队列深度/状态。",
1915 "operationId": "list_queues",
1916 "responses": {
1917 "200": {
1918 "description": "queue snapshots",
1919 "content": {
1920 "application/json": {
1921 "schema": {
1922 "$ref": "#/components/schemas/AppResponseData_Vec_QueueSnapshotDto"
1923 }
1924 }
1925 }
1926 },
1927 "401": {
1928 "description": "authentication required",
1929 "content": {
1930 "application/json": {
1931 "schema": {
1932 "$ref": "#/components/schemas/AppResponseData_NoData"
1933 }
1934 }
1935 }
1936 }
1937 }
1938 }
1939 },
1940 "/admin/queues/{name}/failed": {
1941 "get": {
1942 "tags": [
1943 "admin-queues"
1944 ],
1945 "description": "队列失败明细:返回该队列来源的失败作业(数据库队列),可按任务模块重试。",
1946 "operationId": "failed_jobs",
1947 "parameters": [
1948 {
1949 "name": "name",
1950 "in": "path",
1951 "description": "queue name",
1952 "required": true,
1953 "schema": {
1954 "type": "string"
1955 }
1956 },
1957 {
1958 "name": "limit",
1959 "in": "query",
1960 "required": false,
1961 "schema": {
1962 "type": [
1963 "integer",
1964 "null"
1965 ],
1966 "format": "int64",
1967 "minimum": 0
1968 }
1969 },
1970 {
1971 "name": "offset",
1972 "in": "query",
1973 "required": false,
1974 "schema": {
1975 "type": [
1976 "integer",
1977 "null"
1978 ],
1979 "format": "int64",
1980 "minimum": 0
1981 }
1982 }
1983 ],
1984 "responses": {
1985 "200": {
1986 "description": "failed job page",
1987 "content": {
1988 "application/json": {
1989 "schema": {
1990 "$ref": "#/components/schemas/AppResponseData_JobLogPageDto"
1991 }
1992 }
1993 }
1994 },
1995 "400": {
1996 "description": "unsupported queue",
1997 "content": {
1998 "application/json": {
1999 "schema": {
2000 "$ref": "#/components/schemas/AppResponseData_NoData"
2001 }
2002 }
2003 }
2004 }
2005 }
2006 }
2007 },
2008 "/admin/queues/{name}/purge": {
2009 "post": {
2010 "tags": [
2011 "admin-queues"
17752012 ],
1776 "description": "重试任务:仅失败/取消态可重试。",
1777 "operationId": "retry_task",
2013 "description": "清空队列:git 同步队列直接删除 Redis list,数据库队列删除终态记录;仅超级管理员可操作。",
2014 "operationId": "purge",
17782015 "parameters": [
17792016 {
1780 "name": "source",
2017 "name": "name",
17812018 "in": "path",
1782 "description": "task source",
2019 "description": "queue name",
17832020 "required": true,
17842021 "schema": {
17852022 "type": "string"
17862023 }
2024 }
2025 ],
2026 "responses": {
2027 "200": {
2028 "description": "queue purged",
2029 "content": {
2030 "application/json": {
2031 "schema": {
2032 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
2033 }
2034 }
2035 }
17872036 },
2037 "400": {
2038 "description": "unsupported queue",
2039 "content": {
2040 "application/json": {
2041 "schema": {
2042 "$ref": "#/components/schemas/AppResponseData_NoData"
2043 }
2044 }
2045 }
2046 }
2047 }
2048 }
2049 },
2050 "/admin/queues/{name}/requeue-failed": {
2051 "post": {
2052 "tags": [
2053 "admin-queues"
2054 ],
2055 "description": "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
2056 "operationId": "requeue_failed",
2057 "parameters": [
17882058 {
1789 "name": "id",
2059 "name": "name",
17902060 "in": "path",
1791 "description": "task id",
2061 "description": "queue name",
17922062 "required": true,
17932063 "schema": {
1794 "type": "string",
1795 "format": "uuid"
2064 "type": "string"
17962065 }
17972066 }
17982067 ],
17992068 "responses": {
18002069 "200": {
1801 "description": "task retried",
2070 "description": "failed items requeued",
18022071 "content": {
18032072 "application/json": {
18042073 "schema": {
1805 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
2074 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
18062075 }
18072076 }
18082077 }
18092078 },
1810 "409": {
1811 "description": "resource conflict",
2079 "400": {
2080 "description": "unsupported queue",
18122081 "content": {
18132082 "application/json": {
18142083 "schema": {
@@ -1820,13 +2089,13 @@
18202089 }
18212090 }
18222091 },
1823 "/admin/users": {
2092 "/admin/spaces": {
18242093 "get": {
18252094 "tags": [
1826 "admin-users"
2095 "admin-spaces"
18272096 ],
1828 "description": "用户列表:关键字(用户名/显示名/邮箱)、状态筛选与分页。",
1829 "operationId": "list_users",
2097 "description": "Space 列表:关键字、状态、Owner 与分页。",
2098 "operationId": "list_spaces",
18302099 "parameters": [
18312100 {
18322101 "name": "q",
@@ -1851,20 +2120,19 @@
18512120 }
18522121 },
18532122 {
1854 "name": "limit",
2123 "name": "owner_id",
18552124 "in": "query",
18562125 "required": false,
18572126 "schema": {
18582127 "type": [
1859 "integer",
2128 "string",
18602129 "null"
18612130 ],
1862 "format": "int64",
1863 "minimum": 0
2131 "format": "uuid"
18642132 }
18652133 },
18662134 {
1867 "name": "offset",
2135 "name": "limit",
18682136 "in": "query",
18692137 "required": false,
18702138 "schema": {
@@ -1877,24 +2145,26 @@
18772145 }
18782146 },
18792147 {
1880 "name": "sort",
2148 "name": "offset",
18812149 "in": "query",
18822150 "required": false,
18832151 "schema": {
18842152 "type": [
1885 "string",
2153 "integer",
18862154 "null"
1887 ]
2155 ],
2156 "format": "int64",
2157 "minimum": 0
18882158 }
18892159 }
18902160 ],
18912161 "responses": {
18922162 "200": {
1893 "description": "user page",
2163 "description": "space page",
18942164 "content": {
18952165 "application/json": {
18962166 "schema": {
1897 "$ref": "#/components/schemas/AppResponseData_Page_AdminUserSummary"
2167 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceSummary"
18982168 }
18992169 }
19002170 }
@@ -1912,18 +2182,18 @@
19122182 }
19132183 }
19142184 },
1915 "/admin/users/{uid}": {
2185 "/admin/spaces/{id}": {
19162186 "get": {
19172187 "tags": [
1918 "admin-users"
2188 "admin-spaces"
19192189 ],
1920 "description": "用户详情:账号状态、邮箱、会话、所属 Space 与封禁记录。",
1921 "operationId": "get_user",
2190 "description": "Space 详情:Owner、成员数、仓库/房间/目录统计。",
2191 "operationId": "get_space",
19222192 "parameters": [
19232193 {
1924 "name": "uid",
2194 "name": "id",
19252195 "in": "path",
1926 "description": "user id",
2196 "description": "space id",
19272197 "required": true,
19282198 "schema": {
19292199 "type": "string",
@@ -1933,11 +2203,11 @@
19332203 ],
19342204 "responses": {
19352205 "200": {
1936 "description": "user detail",
2206 "description": "space detail",
19372207 "content": {
19382208 "application/json": {
19392209 "schema": {
1940 "$ref": "#/components/schemas/AppResponseData_AdminUserDetail"
2210 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
19412211 }
19422212 }
19432213 }
@@ -1956,15 +2226,15 @@
19562226 },
19572227 "delete": {
19582228 "tags": [
1959 "admin-users"
2229 "admin-spaces"
19602230 ],
1961 "description": "强制注销用户:立即执行匿名化并禁用账号;仅超级管理员可操作。",
1962 "operationId": "delete_user",
2231 "description": "停用 Space(软删除);仅超级管理员可操作。",
2232 "operationId": "delete_space",
19632233 "parameters": [
19642234 {
1965 "name": "uid",
2235 "name": "id",
19662236 "in": "path",
1967 "description": "user id",
2237 "description": "space id",
19682238 "required": true,
19692239 "schema": {
19702240 "type": "string",
@@ -1974,17 +2244,17 @@
19742244 ],
19752245 "responses": {
19762246 "200": {
1977 "description": "user deleted",
2247 "description": "space deleted",
19782248 "content": {
19792249 "application/json": {
19802250 "schema": {
1981 "$ref": "#/components/schemas/AppResponseData_DeleteUserResp"
2251 "$ref": "#/components/schemas/AppResponseData_NoData"
19822252 }
19832253 }
19842254 }
19852255 },
1986 "403": {
1987 "description": "operation not allowed",
2256 "404": {
2257 "description": "resource not found",
19882258 "content": {
19892259 "application/json": {
19902260 "schema": {
@@ -1997,15 +2267,15 @@
19972267 },
19982268 "patch": {
19992269 "tags": [
2000 "admin-users"
2270 "admin-spaces"
20012271 ],
2002 "description": "更新用户账号标志:显示名、启用、受限、禁止登录。",
2003 "operationId": "update_user",
2272 "description": "更新 Space:名称、描述、私有与启用状态。",
2273 "operationId": "update_space",
20042274 "parameters": [
20052275 {
2006 "name": "uid",
2276 "name": "id",
20072277 "in": "path",
2008 "description": "user id",
2278 "description": "space id",
20092279 "required": true,
20102280 "schema": {
20112281 "type": "string",
@@ -2017,7 +2287,7 @@
20172287 "content": {
20182288 "application/json": {
20192289 "schema": {
2020 "$ref": "#/components/schemas/UpdateUserCmd"
2290 "$ref": "#/components/schemas/UpdateSpaceCmd"
20212291 }
20222292 }
20232293 },
@@ -2025,11 +2295,11 @@
20252295 },
20262296 "responses": {
20272297 "200": {
2028 "description": "user updated",
2298 "description": "space updated",
20292299 "content": {
20302300 "application/json": {
20312301 "schema": {
2032 "$ref": "#/components/schemas/AppResponseData_AdminUserSummary"
2302 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
20332303 }
20342304 }
20352305 }
@@ -2047,18 +2317,96 @@
20472317 }
20482318 }
20492319 },
2050 "/admin/users/{uid}/reset-password": {
2320 "/admin/spaces/{id}/members": {
2321 "get": {
2322 "tags": [
2323 "admin-spaces"
2324 ],
2325 "description": "Space 成员列表。",
2326 "operationId": "list_space_members",
2327 "parameters": [
2328 {
2329 "name": "id",
2330 "in": "path",
2331 "description": "space id",
2332 "required": true,
2333 "schema": {
2334 "type": "string",
2335 "format": "uuid"
2336 }
2337 },
2338 {
2339 "name": "status",
2340 "in": "query",
2341 "required": false,
2342 "schema": {
2343 "type": [
2344 "string",
2345 "null"
2346 ]
2347 }
2348 },
2349 {
2350 "name": "limit",
2351 "in": "query",
2352 "required": false,
2353 "schema": {
2354 "type": [
2355 "integer",
2356 "null"
2357 ],
2358 "format": "int64",
2359 "minimum": 0
2360 }
2361 },
2362 {
2363 "name": "offset",
2364 "in": "query",
2365 "required": false,
2366 "schema": {
2367 "type": [
2368 "integer",
2369 "null"
2370 ],
2371 "format": "int64",
2372 "minimum": 0
2373 }
2374 }
2375 ],
2376 "responses": {
2377 "200": {
2378 "description": "member page",
2379 "content": {
2380 "application/json": {
2381 "schema": {
2382 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceMemberDto"
2383 }
2384 }
2385 }
2386 },
2387 "404": {
2388 "description": "resource not found",
2389 "content": {
2390 "application/json": {
2391 "schema": {
2392 "$ref": "#/components/schemas/AppResponseData_NoData"
2393 }
2394 }
2395 }
2396 }
2397 }
2398 },
20512399 "post": {
20522400 "tags": [
2053 "admin-users"
2401 "admin-spaces"
20542402 ],
2055 "description": "重置用户口令并吊销全部会话。",
2056 "operationId": "reset_user_password",
2403 "description": "管理员将用户加入 Space:已存在记录则重新激活,并同步成员计数。",
2404 "operationId": "add_space_member",
20572405 "parameters": [
20582406 {
2059 "name": "uid",
2407 "name": "id",
20602408 "in": "path",
2061 "description": "user id",
2409 "description": "space id",
20622410 "required": true,
20632411 "schema": {
20642412 "type": "string",
@@ -2070,7 +2418,7 @@
20702418 "content": {
20712419 "application/json": {
20722420 "schema": {
2073 "$ref": "#/components/schemas/ResetUserPasswordCmd"
2421 "$ref": "#/components/schemas/AddSpaceMemberCmd"
20742422 }
20752423 }
20762424 },
@@ -2078,11 +2426,11 @@
20782426 },
20792427 "responses": {
20802428 "200": {
2081 "description": "password reset",
2429 "description": "member added",
20822430 "content": {
20832431 "application/json": {
20842432 "schema": {
2085 "$ref": "#/components/schemas/AppResponseData_NoData"
2433 "$ref": "#/components/schemas/AppResponseData_AddSpaceMemberResp"
20862434 }
20872435 }
20882436 }
@@ -2110,15 +2458,25 @@
21102458 }
21112459 }
21122460 },
2113 "/admin/users/{uid}/revoke-sessions": {
2114 "post": {
2461 "/admin/spaces/{id}/members/{uid}": {
2462 "delete": {
21152463 "tags": [
2116 "admin-users"
2464 "admin-spaces"
21172465 ],
2118 "description": "吊销用户全部有效会话。",
2119 "operationId": "revoke_user_sessions",
2466 "description": "将成员移出 Space。",
2467 "operationId": "remove_space_member",
21202468 "parameters": [
21212469 {
2470 "name": "id",
2471 "in": "path",
2472 "description": "space id",
2473 "required": true,
2474 "schema": {
2475 "type": "string",
2476 "format": "uuid"
2477 }
2478 },
2479 {
21222480 "name": "uid",
21232481 "in": "path",
21242482 "description": "user id",
@@ -2131,11 +2489,11 @@
21312489 ],
21322490 "responses": {
21332491 "200": {
2134 "description": "sessions revoked",
2492 "description": "member removed",
21352493 "content": {
21362494 "application/json": {
21372495 "schema": {
2138 "$ref": "#/components/schemas/AppResponseData_RevokeSessionsResp"
2496 "$ref": "#/components/schemas/AppResponseData_RemoveMemberResp"
21392497 }
21402498 }
21412499 }
@@ -2153,18 +2511,18 @@
21532511 }
21542512 }
21552513 },
2156 "/admin/users/{uid}/suspend": {
2514 "/admin/spaces/{id}/transfer": {
21572515 "post": {
21582516 "tags": [
2159 "admin-users"
2517 "admin-spaces"
21602518 ],
2161 "description": "封禁用户:写入封禁记录、标记受限并吊销全部会话。",
2162 "operationId": "suspend_user",
2519 "description": "转移 Space 所有权;仅超级管理员可操作。",
2520 "operationId": "transfer_space",
21632521 "parameters": [
21642522 {
2165 "name": "uid",
2523 "name": "id",
21662524 "in": "path",
2167 "description": "user id",
2525 "description": "space id",
21682526 "required": true,
21692527 "schema": {
21702528 "type": "string",
@@ -2176,7 +2534,7 @@
21762534 "content": {
21772535 "application/json": {
21782536 "schema": {
2179 "$ref": "#/components/schemas/SuspendUserCmd"
2537 "$ref": "#/components/schemas/TransferSpaceCmd"
21802538 }
21812539 }
21822540 },
@@ -2184,11 +2542,11 @@
21842542 },
21852543 "responses": {
21862544 "200": {
2187 "description": "user suspended",
2545 "description": "space transferred",
21882546 "content": {
21892547 "application/json": {
21902548 "schema": {
2191 "$ref": "#/components/schemas/AppResponseData_AdminSuspensionDto"
2549 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
21922550 }
21932551 }
21942552 }
@@ -2203,8 +2561,8 @@
22032561 }
22042562 }
22052563 },
2206 "404": {
2207 "description": "resource not found",
2564 "409": {
2565 "description": "resource conflict",
22082566 "content": {
22092567 "application/json": {
22102568 "schema": {
@@ -2216,42 +2574,30 @@
22162574 }
22172575 }
22182576 },
2219 "/admin/users/{uid}/suspensions/{suspension_id}/revoke": {
2220 "post": {
2577 "/admin/storage/download-url": {
2578 "get": {
22212579 "tags": [
2222 "admin-users"
2580 "admin-storage"
22232581 ],
2224 "description": "撤销用户封禁记录;无剩余生效封禁时解除受限标志。",
2225 "operationId": "revoke_suspension",
2582 "description": "生成对象预签名下载地址(最长 1 小时)。",
2583 "operationId": "download_url",
22262584 "parameters": [
22272585 {
2228 "name": "uid",
2229 "in": "path",
2230 "description": "user id",
2586 "name": "key",
2587 "in": "query",
22312588 "required": true,
22322589 "schema": {
2233 "type": "string",
2234 "format": "uuid"
2235 }
2236 },
2237 {
2238 "name": "suspension_id",
2239 "in": "path",
2240 "description": "suspension id",
2241 "required": true,
2242 "schema": {
2243 "type": "string",
2244 "format": "uuid"
2590 "type": "string"
22452591 }
22462592 }
22472593 ],
22482594 "responses": {
22492595 "200": {
2250 "description": "suspension revoked",
2596 "description": "presigned download url",
22512597 "content": {
22522598 "application/json": {
22532599 "schema": {
2254 "$ref": "#/components/schemas/AppResponseData_NoData"
2600 "$ref": "#/components/schemas/AppResponseData_DownloadUrlDto"
22552601 }
22562602 }
22572603 }
@@ -2268,33 +2614,2141 @@
22682614 }
22692615 }
22702616 }
2271 }
2272 },
2273 "components": {
2274 "schemas": {
2275 "AdminDto": {
2276 "type": "object",
2277 "required": [
2278 "id",
2279 "username",
2280 "display_name",
2281 "role",
2282 "is_active",
2283 "must_change_password",
2284 "created_at",
2285 "updated_at"
2617 },
2618 "/admin/storage/objects": {
2619 "get": {
2620 "tags": [
2621 "admin-storage"
22862622 ],
2287 "properties": {
2288 "created_at": {
2289 "type": "string",
2290 "format": "date-time"
2623 "description": "对象浏览:前缀过滤与 token 分页。",
2624 "operationId": "list_objects",
2625 "parameters": [
2626 {
2627 "name": "prefix",
2628 "in": "query",
2629 "required": false,
2630 "schema": {
2631 "type": [
2632 "string",
2633 "null"
2634 ]
2635 }
22912636 },
2292 "display_name": {
2293 "type": "string"
2637 {
2638 "name": "token",
2639 "in": "query",
2640 "required": false,
2641 "schema": {
2642 "type": [
2643 "string",
2644 "null"
2645 ]
2646 }
22942647 },
2295 "id": {
2296 "type": "string",
2297 "format": "uuid"
2648 {
2649 "name": "limit",
2650 "in": "query",
2651 "required": false,
2652 "schema": {
2653 "type": [
2654 "integer",
2655 "null"
2656 ],
2657 "minimum": 0
2658 }
2659 }
2660 ],
2661 "responses": {
2662 "200": {
2663 "description": "object page",
2664 "content": {
2665 "application/json": {
2666 "schema": {
2667 "$ref": "#/components/schemas/AppResponseData_StorageObjectPageDto"
2668 }
2669 }
2670 }
2671 },
2672 "401": {
2673 "description": "authentication required",
2674 "content": {
2675 "application/json": {
2676 "schema": {
2677 "$ref": "#/components/schemas/AppResponseData_NoData"
2678 }
2679 }
2680 }
2681 }
2682 }
2683 },
2684 "delete": {
2685 "tags": [
2686 "admin-storage"
2687 ],
2688 "description": "删除存储对象;仅超级管理员可操作。",
2689 "operationId": "delete_object",
2690 "parameters": [
2691 {
2692 "name": "key",
2693 "in": "query",
2694 "required": true,
2695 "schema": {
2696 "type": "string"
2697 }
2698 }
2699 ],
2700 "responses": {
2701 "200": {
2702 "description": "object deleted",
2703 "content": {
2704 "application/json": {
2705 "schema": {
2706 "$ref": "#/components/schemas/AppResponseData_DeleteObjectResp"
2707 }
2708 }
2709 }
2710 },
2711 "404": {
2712 "description": "resource not found",
2713 "content": {
2714 "application/json": {
2715 "schema": {
2716 "$ref": "#/components/schemas/AppResponseData_NoData"
2717 }
2718 }
2719 }
2720 }
2721 }
2722 }
2723 },
2724 "/admin/storage/overview": {
2725 "get": {
2726 "tags": [
2727 "admin-storage"
2728 ],
2729 "description": "存储总览:单桶按前缀聚合的用量与对象数(60s 缓存,refresh=true 强制重算)。",
2730 "operationId": "overview",
2731 "parameters": [
2732 {
2733 "name": "refresh",
2734 "in": "query",
2735 "required": false,
2736 "schema": {
2737 "type": [
2738 "boolean",
2739 "null"
2740 ]
2741 }
2742 }
2743 ],
2744 "responses": {
2745 "200": {
2746 "description": "storage overview",
2747 "content": {
2748 "application/json": {
2749 "schema": {
2750 "$ref": "#/components/schemas/AppResponseData_StorageOverviewDto"
2751 }
2752 }
2753 }
2754 },
2755 "401": {
2756 "description": "authentication required",
2757 "content": {
2758 "application/json": {
2759 "schema": {
2760 "$ref": "#/components/schemas/AppResponseData_NoData"
2761 }
2762 }
2763 }
2764 }
2765 }
2766 }
2767 },
2768 "/admin/system/config": {
2769 "get": {
2770 "tags": [
2771 "admin-system"
2772 ],
2773 "description": "生效配置快照(脱敏):进程监听、功能开关、检索/导入/Git 限制、存储与数据库/Redis/会话/邮件端点,不含任何密钥。",
2774 "operationId": "config",
2775 "responses": {
2776 "200": {
2777 "description": "system config",
2778 "content": {
2779 "application/json": {
2780 "schema": {
2781 "$ref": "#/components/schemas/AppResponseData_SystemConfigDto"
2782 }
2783 }
2784 }
2785 },
2786 "401": {
2787 "description": "authentication required",
2788 "content": {
2789 "application/json": {
2790 "schema": {
2791 "$ref": "#/components/schemas/AppResponseData_NoData"
2792 }
2793 }
2794 }
2795 }
2796 }
2797 }
2798 },
2799 "/admin/system/status": {
2800 "get": {
2801 "tags": [
2802 "admin-system"
2803 ],
2804 "description": "系统状态:进程信息、DB/Redis/S3/搜索健康、核心计数、队列汇总与最近失败。",
2805 "operationId": "status",
2806 "responses": {
2807 "200": {
2808 "description": "system status",
2809 "content": {
2810 "application/json": {
2811 "schema": {
2812 "$ref": "#/components/schemas/AppResponseData_SystemStatusDto"
2813 }
2814 }
2815 }
2816 },
2817 "401": {
2818 "description": "authentication required",
2819 "content": {
2820 "application/json": {
2821 "schema": {
2822 "$ref": "#/components/schemas/AppResponseData_NoData"
2823 }
2824 }
2825 }
2826 }
2827 }
2828 }
2829 },
2830 "/admin/tasks": {
2831 "get": {
2832 "tags": [
2833 "admin-tasks"
2834 ],
2835 "description": "统一任务视图:import/search_index/chat_webhook/pipeline/deletion/system_tasks。",
2836 "operationId": "list_tasks",
2837 "parameters": [
2838 {
2839 "name": "source",
2840 "in": "query",
2841 "required": false,
2842 "schema": {
2843 "type": [
2844 "string",
2845 "null"
2846 ]
2847 }
2848 },
2849 {
2850 "name": "state",
2851 "in": "query",
2852 "required": false,
2853 "schema": {
2854 "type": [
2855 "string",
2856 "null"
2857 ]
2858 }
2859 },
2860 {
2861 "name": "q",
2862 "in": "query",
2863 "required": false,
2864 "schema": {
2865 "type": [
2866 "string",
2867 "null"
2868 ]
2869 }
2870 },
2871 {
2872 "name": "from",
2873 "in": "query",
2874 "required": false,
2875 "schema": {
2876 "type": [
2877 "string",
2878 "null"
2879 ],
2880 "format": "date-time"
2881 }
2882 },
2883 {
2884 "name": "to",
2885 "in": "query",
2886 "required": false,
2887 "schema": {
2888 "type": [
2889 "string",
2890 "null"
2891 ],
2892 "format": "date-time"
2893 }
2894 },
2895 {
2896 "name": "limit",
2897 "in": "query",
2898 "required": false,
2899 "schema": {
2900 "type": [
2901 "integer",
2902 "null"
2903 ],
2904 "format": "int64",
2905 "minimum": 0
2906 }
2907 },
2908 {
2909 "name": "offset",
2910 "in": "query",
2911 "required": false,
2912 "schema": {
2913 "type": [
2914 "integer",
2915 "null"
2916 ],
2917 "format": "int64",
2918 "minimum": 0
2919 }
2920 }
2921 ],
2922 "responses": {
2923 "200": {
2924 "description": "task page",
2925 "content": {
2926 "application/json": {
2927 "schema": {
2928 "$ref": "#/components/schemas/AppResponseData_TaskPageDto"
2929 }
2930 }
2931 }
2932 },
2933 "400": {
2934 "description": "request failed validation",
2935 "content": {
2936 "application/json": {
2937 "schema": {
2938 "$ref": "#/components/schemas/AppResponseData_NoData"
2939 }
2940 }
2941 }
2942 }
2943 }
2944 }
2945 },
2946 "/admin/tasks/stats": {
2947 "get": {
2948 "tags": [
2949 "admin-tasks"
2950 ],
2951 "description": "任务统计:各来源的排队/执行中/成功/失败计数,用于任务总览与看板。",
2952 "operationId": "task_stats",
2953 "responses": {
2954 "200": {
2955 "description": "task stats",
2956 "content": {
2957 "application/json": {
2958 "schema": {
2959 "$ref": "#/components/schemas/AppResponseData_TaskStatsDto"
2960 }
2961 }
2962 }
2963 }
2964 }
2965 }
2966 },
2967 "/admin/tasks/{source}/bulk": {
2968 "post": {
2969 "tags": [
2970 "admin-tasks"
2971 ],
2972 "description": "批量重试或取消同一来源的任务,单次最多 100 个;逐条返回结果。",
2973 "operationId": "bulk_tasks",
2974 "parameters": [
2975 {
2976 "name": "source",
2977 "in": "path",
2978 "description": "task source",
2979 "required": true,
2980 "schema": {
2981 "type": "string"
2982 }
2983 }
2984 ],
2985 "requestBody": {
2986 "content": {
2987 "application/json": {
2988 "schema": {
2989 "$ref": "#/components/schemas/BulkTaskCmd"
2990 }
2991 }
2992 },
2993 "required": true
2994 },
2995 "responses": {
2996 "200": {
2997 "description": "bulk result",
2998 "content": {
2999 "application/json": {
3000 "schema": {
3001 "$ref": "#/components/schemas/AppResponseData_BulkTaskResp"
3002 }
3003 }
3004 }
3005 },
3006 "400": {
3007 "description": "request failed validation",
3008 "content": {
3009 "application/json": {
3010 "schema": {
3011 "$ref": "#/components/schemas/AppResponseData_NoData"
3012 }
3013 }
3014 }
3015 }
3016 }
3017 }
3018 },
3019 "/admin/tasks/{source}/{id}": {
3020 "get": {
3021 "tags": [
3022 "admin-tasks"
3023 ],
3024 "description": "任务详情;import token 与 webhook payload 已脱敏。",
3025 "operationId": "get_task",
3026 "parameters": [
3027 {
3028 "name": "source",
3029 "in": "path",
3030 "description": "task source",
3031 "required": true,
3032 "schema": {
3033 "type": "string"
3034 }
3035 },
3036 {
3037 "name": "id",
3038 "in": "path",
3039 "description": "task id",
3040 "required": true,
3041 "schema": {
3042 "type": "string",
3043 "format": "uuid"
3044 }
3045 }
3046 ],
3047 "responses": {
3048 "200": {
3049 "description": "task detail",
3050 "content": {
3051 "application/json": {
3052 "schema": {
3053 "$ref": "#/components/schemas/AppResponseData_TaskDetailDto"
3054 }
3055 }
3056 }
3057 },
3058 "404": {
3059 "description": "resource not found",
3060 "content": {
3061 "application/json": {
3062 "schema": {
3063 "$ref": "#/components/schemas/AppResponseData_NoData"
3064 }
3065 }
3066 }
3067 }
3068 }
3069 },
3070 "delete": {
3071 "tags": [
3072 "admin-tasks"
3073 ],
3074 "description": "删除终态任务记录;仅超级管理员可操作。",
3075 "operationId": "delete_task",
3076 "parameters": [
3077 {
3078 "name": "source",
3079 "in": "path",
3080 "description": "task source",
3081 "required": true,
3082 "schema": {
3083 "type": "string"
3084 }
3085 },
3086 {
3087 "name": "id",
3088 "in": "path",
3089 "description": "task id",
3090 "required": true,
3091 "schema": {
3092 "type": "string",
3093 "format": "uuid"
3094 }
3095 }
3096 ],
3097 "responses": {
3098 "200": {
3099 "description": "task deleted",
3100 "content": {
3101 "application/json": {
3102 "schema": {
3103 "$ref": "#/components/schemas/AppResponseData_NoData"
3104 }
3105 }
3106 }
3107 },
3108 "409": {
3109 "description": "resource conflict",
3110 "content": {
3111 "application/json": {
3112 "schema": {
3113 "$ref": "#/components/schemas/AppResponseData_NoData"
3114 }
3115 }
3116 }
3117 }
3118 }
3119 }
3120 },
3121 "/admin/tasks/{source}/{id}/cancel": {
3122 "post": {
3123 "tags": [
3124 "admin-tasks"
3125 ],
3126 "description": "取消任务:队列中/进行中的作业按来源置取消态。",
3127 "operationId": "cancel_task",
3128 "parameters": [
3129 {
3130 "name": "source",
3131 "in": "path",
3132 "description": "task source",
3133 "required": true,
3134 "schema": {
3135 "type": "string"
3136 }
3137 },
3138 {
3139 "name": "id",
3140 "in": "path",
3141 "description": "task id",
3142 "required": true,
3143 "schema": {
3144 "type": "string",
3145 "format": "uuid"
3146 }
3147 }
3148 ],
3149 "responses": {
3150 "200": {
3151 "description": "task cancelled",
3152 "content": {
3153 "application/json": {
3154 "schema": {
3155 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
3156 }
3157 }
3158 }
3159 },
3160 "409": {
3161 "description": "resource conflict",
3162 "content": {
3163 "application/json": {
3164 "schema": {
3165 "$ref": "#/components/schemas/AppResponseData_NoData"
3166 }
3167 }
3168 }
3169 }
3170 }
3171 }
3172 },
3173 "/admin/tasks/{source}/{id}/retry": {
3174 "post": {
3175 "tags": [
3176 "admin-tasks"
3177 ],
3178 "description": "重试任务:仅失败/取消态可重试。",
3179 "operationId": "retry_task",
3180 "parameters": [
3181 {
3182 "name": "source",
3183 "in": "path",
3184 "description": "task source",
3185 "required": true,
3186 "schema": {
3187 "type": "string"
3188 }
3189 },
3190 {
3191 "name": "id",
3192 "in": "path",
3193 "description": "task id",
3194 "required": true,
3195 "schema": {
3196 "type": "string",
3197 "format": "uuid"
3198 }
3199 }
3200 ],
3201 "responses": {
3202 "200": {
3203 "description": "task retried",
3204 "content": {
3205 "application/json": {
3206 "schema": {
3207 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
3208 }
3209 }
3210 }
3211 },
3212 "409": {
3213 "description": "resource conflict",
3214 "content": {
3215 "application/json": {
3216 "schema": {
3217 "$ref": "#/components/schemas/AppResponseData_NoData"
3218 }
3219 }
3220 }
3221 }
3222 }
3223 }
3224 },
3225 "/admin/users": {
3226 "get": {
3227 "tags": [
3228 "admin-users"
3229 ],
3230 "description": "用户列表:关键字(用户名/显示名/邮箱)、状态筛选与分页。",
3231 "operationId": "list_users",
3232 "parameters": [
3233 {
3234 "name": "q",
3235 "in": "query",
3236 "required": false,
3237 "schema": {
3238 "type": [
3239 "string",
3240 "null"
3241 ]
3242 }
3243 },
3244 {
3245 "name": "status",
3246 "in": "query",
3247 "required": false,
3248 "schema": {
3249 "type": [
3250 "string",
3251 "null"
3252 ]
3253 }
3254 },
3255 {
3256 "name": "limit",
3257 "in": "query",
3258 "required": false,
3259 "schema": {
3260 "type": [
3261 "integer",
3262 "null"
3263 ],
3264 "format": "int64",
3265 "minimum": 0
3266 }
3267 },
3268 {
3269 "name": "offset",
3270 "in": "query",
3271 "required": false,
3272 "schema": {
3273 "type": [
3274 "integer",
3275 "null"
3276 ],
3277 "format": "int64",
3278 "minimum": 0
3279 }
3280 },
3281 {
3282 "name": "sort",
3283 "in": "query",
3284 "required": false,
3285 "schema": {
3286 "type": [
3287 "string",
3288 "null"
3289 ]
3290 }
3291 }
3292 ],
3293 "responses": {
3294 "200": {
3295 "description": "user page",
3296 "content": {
3297 "application/json": {
3298 "schema": {
3299 "$ref": "#/components/schemas/AppResponseData_Page_AdminUserSummary"
3300 }
3301 }
3302 }
3303 },
3304 "401": {
3305 "description": "authentication required",
3306 "content": {
3307 "application/json": {
3308 "schema": {
3309 "$ref": "#/components/schemas/AppResponseData_NoData"
3310 }
3311 }
3312 }
3313 }
3314 }
3315 },
3316 "post": {
3317 "tags": [
3318 "admin-users"
3319 ],
3320 "description": "创建用户(管理员代办):直接写入账号、资料与已激活邮箱;仅超级管理员可操作。",
3321 "operationId": "create_user",
3322 "requestBody": {
3323 "content": {
3324 "application/json": {
3325 "schema": {
3326 "$ref": "#/components/schemas/CreateUserCmd"
3327 }
3328 }
3329 },
3330 "required": true
3331 },
3332 "responses": {
3333 "200": {
3334 "description": "user created",
3335 "content": {
3336 "application/json": {
3337 "schema": {
3338 "$ref": "#/components/schemas/AppResponseData_CreateUserResp"
3339 }
3340 }
3341 }
3342 },
3343 "400": {
3344 "description": "request failed validation",
3345 "content": {
3346 "application/json": {
3347 "schema": {
3348 "$ref": "#/components/schemas/AppResponseData_NoData"
3349 }
3350 }
3351 }
3352 },
3353 "403": {
3354 "description": "operation not allowed",
3355 "content": {
3356 "application/json": {
3357 "schema": {
3358 "$ref": "#/components/schemas/AppResponseData_NoData"
3359 }
3360 }
3361 }
3362 },
3363 "409": {
3364 "description": "resource conflict",
3365 "content": {
3366 "application/json": {
3367 "schema": {
3368 "$ref": "#/components/schemas/AppResponseData_NoData"
3369 }
3370 }
3371 }
3372 }
3373 }
3374 }
3375 },
3376 "/admin/users/bulk": {
3377 "post": {
3378 "tags": [
3379 "admin-users"
3380 ],
3381 "description": "批量用户操作:activate/deactivate/restrict/unrestrict/revoke_sessions/suspend/delete,单次最多 100 个目标。",
3382 "operationId": "bulk_users",
3383 "requestBody": {
3384 "content": {
3385 "application/json": {
3386 "schema": {
3387 "$ref": "#/components/schemas/BulkUsersCmd"
3388 }
3389 }
3390 },
3391 "required": true
3392 },
3393 "responses": {
3394 "200": {
3395 "description": "bulk result",
3396 "content": {
3397 "application/json": {
3398 "schema": {
3399 "$ref": "#/components/schemas/AppResponseData_BulkUsersResp"
3400 }
3401 }
3402 }
3403 },
3404 "400": {
3405 "description": "request failed validation",
3406 "content": {
3407 "application/json": {
3408 "schema": {
3409 "$ref": "#/components/schemas/AppResponseData_NoData"
3410 }
3411 }
3412 }
3413 },
3414 "403": {
3415 "description": "operation not allowed",
3416 "content": {
3417 "application/json": {
3418 "schema": {
3419 "$ref": "#/components/schemas/AppResponseData_NoData"
3420 }
3421 }
3422 }
3423 }
3424 }
3425 }
3426 },
3427 "/admin/users/export": {
3428 "get": {
3429 "tags": [
3430 "admin-users"
3431 ],
3432 "description": "按当前筛选条件导出用户 CSV(最多 5000 行)。",
3433 "operationId": "export_users",
3434 "parameters": [
3435 {
3436 "name": "q",
3437 "in": "query",
3438 "required": false,
3439 "schema": {
3440 "type": [
3441 "string",
3442 "null"
3443 ]
3444 }
3445 },
3446 {
3447 "name": "status",
3448 "in": "query",
3449 "required": false,
3450 "schema": {
3451 "type": [
3452 "string",
3453 "null"
3454 ]
3455 }
3456 },
3457 {
3458 "name": "limit",
3459 "in": "query",
3460 "required": false,
3461 "schema": {
3462 "type": [
3463 "integer",
3464 "null"
3465 ],
3466 "format": "int64",
3467 "minimum": 0
3468 }
3469 },
3470 {
3471 "name": "offset",
3472 "in": "query",
3473 "required": false,
3474 "schema": {
3475 "type": [
3476 "integer",
3477 "null"
3478 ],
3479 "format": "int64",
3480 "minimum": 0
3481 }
3482 },
3483 {
3484 "name": "sort",
3485 "in": "query",
3486 "required": false,
3487 "schema": {
3488 "type": [
3489 "string",
3490 "null"
3491 ]
3492 }
3493 }
3494 ],
3495 "responses": {
3496 "200": {
3497 "description": "csv export"
3498 }
3499 }
3500 }
3501 },
3502 "/admin/users/{uid}": {
3503 "get": {
3504 "tags": [
3505 "admin-users"
3506 ],
3507 "description": "用户详情:账号状态、邮箱、会话、所属 Space 与封禁记录。",
3508 "operationId": "get_user",
3509 "parameters": [
3510 {
3511 "name": "uid",
3512 "in": "path",
3513 "description": "user id",
3514 "required": true,
3515 "schema": {
3516 "type": "string",
3517 "format": "uuid"
3518 }
3519 }
3520 ],
3521 "responses": {
3522 "200": {
3523 "description": "user detail",
3524 "content": {
3525 "application/json": {
3526 "schema": {
3527 "$ref": "#/components/schemas/AppResponseData_AdminUserDetail"
3528 }
3529 }
3530 }
3531 },
3532 "404": {
3533 "description": "resource not found",
3534 "content": {
3535 "application/json": {
3536 "schema": {
3537 "$ref": "#/components/schemas/AppResponseData_NoData"
3538 }
3539 }
3540 }
3541 }
3542 }
3543 },
3544 "delete": {
3545 "tags": [
3546 "admin-users"
3547 ],
3548 "description": "强制注销用户:立即执行匿名化并禁用账号;仅超级管理员可操作。",
3549 "operationId": "delete_user",
3550 "parameters": [
3551 {
3552 "name": "uid",
3553 "in": "path",
3554 "description": "user id",
3555 "required": true,
3556 "schema": {
3557 "type": "string",
3558 "format": "uuid"
3559 }
3560 }
3561 ],
3562 "responses": {
3563 "200": {
3564 "description": "user deleted",
3565 "content": {
3566 "application/json": {
3567 "schema": {
3568 "$ref": "#/components/schemas/AppResponseData_DeleteUserResp"
3569 }
3570 }
3571 }
3572 },
3573 "403": {
3574 "description": "operation not allowed",
3575 "content": {
3576 "application/json": {
3577 "schema": {
3578 "$ref": "#/components/schemas/AppResponseData_NoData"
3579 }
3580 }
3581 }
3582 }
3583 }
3584 },
3585 "patch": {
3586 "tags": [
3587 "admin-users"
3588 ],
3589 "description": "更新用户账号标志:显示名、启用、受限、禁止登录。",
3590 "operationId": "update_user",
3591 "parameters": [
3592 {
3593 "name": "uid",
3594 "in": "path",
3595 "description": "user id",
3596 "required": true,
3597 "schema": {
3598 "type": "string",
3599 "format": "uuid"
3600 }
3601 }
3602 ],
3603 "requestBody": {
3604 "content": {
3605 "application/json": {
3606 "schema": {
3607 "$ref": "#/components/schemas/UpdateUserCmd"
3608 }
3609 }
3610 },
3611 "required": true
3612 },
3613 "responses": {
3614 "200": {
3615 "description": "user updated",
3616 "content": {
3617 "application/json": {
3618 "schema": {
3619 "$ref": "#/components/schemas/AppResponseData_AdminUserSummary"
3620 }
3621 }
3622 }
3623 },
3624 "404": {
3625 "description": "resource not found",
3626 "content": {
3627 "application/json": {
3628 "schema": {
3629 "$ref": "#/components/schemas/AppResponseData_NoData"
3630 }
3631 }
3632 }
3633 }
3634 }
3635 }
3636 },
3637 "/admin/users/{uid}/reset-password": {
3638 "post": {
3639 "tags": [
3640 "admin-users"
3641 ],
3642 "description": "重置用户口令并吊销全部会话。",
3643 "operationId": "reset_user_password",
3644 "parameters": [
3645 {
3646 "name": "uid",
3647 "in": "path",
3648 "description": "user id",
3649 "required": true,
3650 "schema": {
3651 "type": "string",
3652 "format": "uuid"
3653 }
3654 }
3655 ],
3656 "requestBody": {
3657 "content": {
3658 "application/json": {
3659 "schema": {
3660 "$ref": "#/components/schemas/ResetUserPasswordCmd"
3661 }
3662 }
3663 },
3664 "required": true
3665 },
3666 "responses": {
3667 "200": {
3668 "description": "password reset",
3669 "content": {
3670 "application/json": {
3671 "schema": {
3672 "$ref": "#/components/schemas/AppResponseData_NoData"
3673 }
3674 }
3675 }
3676 },
3677 "400": {
3678 "description": "request failed validation",
3679 "content": {
3680 "application/json": {
3681 "schema": {
3682 "$ref": "#/components/schemas/AppResponseData_NoData"
3683 }
3684 }
3685 }
3686 },
3687 "404": {
3688 "description": "resource not found",
3689 "content": {
3690 "application/json": {
3691 "schema": {
3692 "$ref": "#/components/schemas/AppResponseData_NoData"
3693 }
3694 }
3695 }
3696 }
3697 }
3698 }
3699 },
3700 "/admin/users/{uid}/revoke-sessions": {
3701 "post": {
3702 "tags": [
3703 "admin-users"
3704 ],
3705 "description": "吊销用户全部有效会话。",
3706 "operationId": "revoke_user_sessions",
3707 "parameters": [
3708 {
3709 "name": "uid",
3710 "in": "path",
3711 "description": "user id",
3712 "required": true,
3713 "schema": {
3714 "type": "string",
3715 "format": "uuid"
3716 }
3717 }
3718 ],
3719 "responses": {
3720 "200": {
3721 "description": "sessions revoked",
3722 "content": {
3723 "application/json": {
3724 "schema": {
3725 "$ref": "#/components/schemas/AppResponseData_RevokeSessionsResp"
3726 }
3727 }
3728 }
3729 },
3730 "404": {
3731 "description": "resource not found",
3732 "content": {
3733 "application/json": {
3734 "schema": {
3735 "$ref": "#/components/schemas/AppResponseData_NoData"
3736 }
3737 }
3738 }
3739 }
3740 }
3741 }
3742 },
3743 "/admin/users/{uid}/security": {
3744 "get": {
3745 "tags": [
3746 "admin-users"
3747 ],
3748 "description": "用户安全概览:二次验证状态、恢复码、活跃会话、SSH Key 与访问密钥数量。",
3749 "operationId": "get_user_security",
3750 "parameters": [
3751 {
3752 "name": "uid",
3753 "in": "path",
3754 "description": "user id",
3755 "required": true,
3756 "schema": {
3757 "type": "string",
3758 "format": "uuid"
3759 }
3760 }
3761 ],
3762 "responses": {
3763 "200": {
3764 "description": "security overview",
3765 "content": {
3766 "application/json": {
3767 "schema": {
3768 "$ref": "#/components/schemas/AppResponseData_AdminUserSecurityDto"
3769 }
3770 }
3771 }
3772 },
3773 "404": {
3774 "description": "resource not found",
3775 "content": {
3776 "application/json": {
3777 "schema": {
3778 "$ref": "#/components/schemas/AppResponseData_NoData"
3779 }
3780 }
3781 }
3782 }
3783 }
3784 }
3785 },
3786 "/admin/users/{uid}/sessions": {
3787 "get": {
3788 "tags": [
3789 "admin-users"
3790 ],
3791 "description": "用户会话列表:可按 kind 过滤、包含已吊销会话,用于会话级踢下线。",
3792 "operationId": "list_user_sessions",
3793 "parameters": [
3794 {
3795 "name": "uid",
3796 "in": "path",
3797 "description": "user id",
3798 "required": true,
3799 "schema": {
3800 "type": "string",
3801 "format": "uuid"
3802 }
3803 },
3804 {
3805 "name": "kind",
3806 "in": "query",
3807 "required": false,
3808 "schema": {
3809 "type": [
3810 "string",
3811 "null"
3812 ]
3813 }
3814 },
3815 {
3816 "name": "include_revoked",
3817 "in": "query",
3818 "required": false,
3819 "schema": {
3820 "type": [
3821 "boolean",
3822 "null"
3823 ]
3824 }
3825 },
3826 {
3827 "name": "limit",
3828 "in": "query",
3829 "required": false,
3830 "schema": {
3831 "type": [
3832 "integer",
3833 "null"
3834 ],
3835 "format": "int64",
3836 "minimum": 0
3837 }
3838 },
3839 {
3840 "name": "offset",
3841 "in": "query",
3842 "required": false,
3843 "schema": {
3844 "type": [
3845 "integer",
3846 "null"
3847 ],
3848 "format": "int64",
3849 "minimum": 0
3850 }
3851 }
3852 ],
3853 "responses": {
3854 "200": {
3855 "description": "session page",
3856 "content": {
3857 "application/json": {
3858 "schema": {
3859 "$ref": "#/components/schemas/AppResponseData_AdminUserSessionPage"
3860 }
3861 }
3862 }
3863 },
3864 "404": {
3865 "description": "resource not found",
3866 "content": {
3867 "application/json": {
3868 "schema": {
3869 "$ref": "#/components/schemas/AppResponseData_NoData"
3870 }
3871 }
3872 }
3873 }
3874 }
3875 }
3876 },
3877 "/admin/users/{uid}/sessions/{session_id}": {
3878 "delete": {
3879 "tags": [
3880 "admin-users"
3881 ],
3882 "description": "吊销用户单个会话;运维及以上角色可操作。",
3883 "operationId": "revoke_user_session",
3884 "parameters": [
3885 {
3886 "name": "uid",
3887 "in": "path",
3888 "description": "user id",
3889 "required": true,
3890 "schema": {
3891 "type": "string",
3892 "format": "uuid"
3893 }
3894 },
3895 {
3896 "name": "session_id",
3897 "in": "path",
3898 "description": "session id",
3899 "required": true,
3900 "schema": {
3901 "type": "string",
3902 "format": "uuid"
3903 }
3904 }
3905 ],
3906 "responses": {
3907 "200": {
3908 "description": "session revoked",
3909 "content": {
3910 "application/json": {
3911 "schema": {
3912 "$ref": "#/components/schemas/AppResponseData_NoData"
3913 }
3914 }
3915 }
3916 },
3917 "404": {
3918 "description": "resource not found",
3919 "content": {
3920 "application/json": {
3921 "schema": {
3922 "$ref": "#/components/schemas/AppResponseData_NoData"
3923 }
3924 }
3925 }
3926 }
3927 }
3928 }
3929 },
3930 "/admin/users/{uid}/suspend": {
3931 "post": {
3932 "tags": [
3933 "admin-users"
3934 ],
3935 "description": "封禁用户:写入封禁记录、标记受限并吊销全部会话。",
3936 "operationId": "suspend_user",
3937 "parameters": [
3938 {
3939 "name": "uid",
3940 "in": "path",
3941 "description": "user id",
3942 "required": true,
3943 "schema": {
3944 "type": "string",
3945 "format": "uuid"
3946 }
3947 }
3948 ],
3949 "requestBody": {
3950 "content": {
3951 "application/json": {
3952 "schema": {
3953 "$ref": "#/components/schemas/SuspendUserCmd"
3954 }
3955 }
3956 },
3957 "required": true
3958 },
3959 "responses": {
3960 "200": {
3961 "description": "user suspended",
3962 "content": {
3963 "application/json": {
3964 "schema": {
3965 "$ref": "#/components/schemas/AppResponseData_AdminSuspensionDto"
3966 }
3967 }
3968 }
3969 },
3970 "400": {
3971 "description": "request failed validation",
3972 "content": {
3973 "application/json": {
3974 "schema": {
3975 "$ref": "#/components/schemas/AppResponseData_NoData"
3976 }
3977 }
3978 }
3979 },
3980 "404": {
3981 "description": "resource not found",
3982 "content": {
3983 "application/json": {
3984 "schema": {
3985 "$ref": "#/components/schemas/AppResponseData_NoData"
3986 }
3987 }
3988 }
3989 }
3990 }
3991 }
3992 },
3993 "/admin/users/{uid}/suspensions/{suspension_id}/revoke": {
3994 "post": {
3995 "tags": [
3996 "admin-users"
3997 ],
3998 "description": "撤销用户封禁记录;无剩余生效封禁时解除受限标志。",
3999 "operationId": "revoke_suspension",
4000 "parameters": [
4001 {
4002 "name": "uid",
4003 "in": "path",
4004 "description": "user id",
4005 "required": true,
4006 "schema": {
4007 "type": "string",
4008 "format": "uuid"
4009 }
4010 },
4011 {
4012 "name": "suspension_id",
4013 "in": "path",
4014 "description": "suspension id",
4015 "required": true,
4016 "schema": {
4017 "type": "string",
4018 "format": "uuid"
4019 }
4020 }
4021 ],
4022 "responses": {
4023 "200": {
4024 "description": "suspension revoked",
4025 "content": {
4026 "application/json": {
4027 "schema": {
4028 "$ref": "#/components/schemas/AppResponseData_NoData"
4029 }
4030 }
4031 }
4032 },
4033 "404": {
4034 "description": "resource not found",
4035 "content": {
4036 "application/json": {
4037 "schema": {
4038 "$ref": "#/components/schemas/AppResponseData_NoData"
4039 }
4040 }
4041 }
4042 }
4043 }
4044 }
4045 },
4046 "/admin/users/{uid}/two-factor": {
4047 "delete": {
4048 "tags": [
4049 "admin-users"
4050 ],
4051 "description": "强制清除用户二次验证(用户遗失设备时使用);仅超级管理员可操作。",
4052 "operationId": "clear_user_two_factor",
4053 "parameters": [
4054 {
4055 "name": "uid",
4056 "in": "path",
4057 "description": "user id",
4058 "required": true,
4059 "schema": {
4060 "type": "string",
4061 "format": "uuid"
4062 }
4063 }
4064 ],
4065 "responses": {
4066 "200": {
4067 "description": "two-factor cleared",
4068 "content": {
4069 "application/json": {
4070 "schema": {
4071 "$ref": "#/components/schemas/AppResponseData_NoData"
4072 }
4073 }
4074 }
4075 },
4076 "403": {
4077 "description": "operation not allowed",
4078 "content": {
4079 "application/json": {
4080 "schema": {
4081 "$ref": "#/components/schemas/AppResponseData_NoData"
4082 }
4083 }
4084 }
4085 }
4086 }
4087 }
4088 }
4089 },
4090 "components": {
4091 "schemas": {
4092 "AddSpaceMemberCmd": {
4093 "type": "object",
4094 "required": [
4095 "user_id"
4096 ],
4097 "properties": {
4098 "alias_name": {
4099 "type": [
4100 "string",
4101 "null"
4102 ]
4103 },
4104 "user_id": {
4105 "type": "string",
4106 "format": "uuid"
4107 }
4108 }
4109 },
4110 "AddSpaceMemberResp": {
4111 "type": "object",
4112 "required": [
4113 "space_id",
4114 "user_id",
4115 "status",
4116 "added"
4117 ],
4118 "properties": {
4119 "added": {
4120 "type": "boolean"
4121 },
4122 "space_id": {
4123 "type": "string",
4124 "format": "uuid"
4125 },
4126 "status": {
4127 "type": "string"
4128 },
4129 "user_id": {
4130 "type": "string",
4131 "format": "uuid"
4132 }
4133 }
4134 },
4135 "AdminChatAttachmentDto": {
4136 "type": "object",
4137 "required": [
4138 "id",
4139 "user_id",
4140 "filename",
4141 "content_type",
4142 "size",
4143 "bucket",
4144 "s3_key",
4145 "status",
4146 "created_at"
4147 ],
4148 "properties": {
4149 "bucket": {
4150 "type": "string"
4151 },
4152 "content_type": {
4153 "type": "string"
4154 },
4155 "created_at": {
4156 "type": "string",
4157 "format": "date-time"
4158 },
4159 "description": {
4160 "type": [
4161 "string",
4162 "null"
4163 ]
4164 },
4165 "display_name": {
4166 "type": [
4167 "string",
4168 "null"
4169 ]
4170 },
4171 "filename": {
4172 "type": "string"
4173 },
4174 "id": {
4175 "type": "string",
4176 "format": "uuid"
4177 },
4178 "message_id": {
4179 "type": [
4180 "string",
4181 "null"
4182 ],
4183 "format": "uuid"
4184 },
4185 "room_id": {
4186 "type": [
4187 "string",
4188 "null"
4189 ],
4190 "format": "uuid"
4191 },
4192 "s3_key": {
4193 "type": "string"
4194 },
4195 "size": {
4196 "type": "integer",
4197 "format": "int64"
4198 },
4199 "status": {
4200 "type": "string"
4201 },
4202 "user_id": {
4203 "type": "string",
4204 "format": "uuid"
4205 },
4206 "username": {
4207 "type": [
4208 "string",
4209 "null"
4210 ]
4211 }
4212 }
4213 },
4214 "AdminChatKindCount": {
4215 "type": "object",
4216 "required": [
4217 "kind",
4218 "count"
4219 ],
4220 "properties": {
4221 "count": {
4222 "type": "integer",
4223 "format": "int64"
4224 },
4225 "kind": {
4226 "type": "string"
4227 }
4228 }
4229 },
4230 "AdminChatMessageDetail": {
4231 "type": "object",
4232 "required": [
4233 "message",
4234 "edits",
4235 "reactions",
4236 "attachments"
4237 ],
4238 "properties": {
4239 "attachments": {
4240 "type": "array",
4241 "items": {
4242 "$ref": "#/components/schemas/AdminChatAttachmentDto"
4243 }
4244 },
4245 "edits": {
4246 "type": "array",
4247 "items": {
4248 "$ref": "#/components/schemas/AdminChatMessageEditDto"
4249 }
4250 },
4251 "message": {
4252 "$ref": "#/components/schemas/AdminChatMessageDto"
4253 },
4254 "reactions": {
4255 "type": "array",
4256 "items": {
4257 "$ref": "#/components/schemas/AdminChatReactionGroupDto"
4258 }
4259 },
4260 "thread": {
4261 "oneOf": [
4262 {
4263 "type": "null"
4264 },
4265 {
4266 "$ref": "#/components/schemas/AdminChatThreadDto"
4267 }
4268 ]
4269 }
4270 }
4271 },
4272 "AdminChatMessageDto": {
4273 "type": "object",
4274 "required": [
4275 "id",
4276 "room_id",
4277 "author_id",
4278 "author_type",
4279 "message_type",
4280 "content",
4281 "seq",
4282 "flags",
4283 "pinned",
4284 "attachment_count",
4285 "reaction_count",
4286 "created_at"
4287 ],
4288 "properties": {
4289 "attachment_count": {
4290 "type": "integer",
4291 "format": "int64"
4292 },
4293 "author_display_name": {
4294 "type": [
4295 "string",
4296 "null"
4297 ]
4298 },
4299 "author_id": {
4300 "type": "string",
4301 "format": "uuid"
4302 },
4303 "author_type": {
4304 "type": "string"
4305 },
4306 "author_username": {
4307 "type": [
4308 "string",
4309 "null"
4310 ]
4311 },
4312 "content": {
4313 "type": "string"
4314 },
4315 "created_at": {
4316 "type": "string",
4317 "format": "date-time"
4318 },
4319 "deleted_at": {
4320 "type": [
4321 "string",
4322 "null"
4323 ],
4324 "format": "date-time"
4325 },
4326 "edited_at": {
4327 "type": [
4328 "string",
4329 "null"
4330 ],
4331 "format": "date-time"
4332 },
4333 "flags": {
4334 "type": "integer",
4335 "format": "int64"
4336 },
4337 "id": {
4338 "type": "string",
4339 "format": "uuid"
4340 },
4341 "message_type": {
4342 "type": "string"
4343 },
4344 "pinned": {
4345 "type": "boolean"
4346 },
4347 "reaction_count": {
4348 "type": "integer",
4349 "format": "int64"
4350 },
4351 "reply_to_id": {
4352 "type": [
4353 "string",
4354 "null"
4355 ],
4356 "format": "uuid"
4357 },
4358 "room_id": {
4359 "type": "string",
4360 "format": "uuid"
4361 },
4362 "room_name": {
4363 "type": [
4364 "string",
4365 "null"
4366 ]
4367 },
4368 "seq": {
4369 "type": "integer",
4370 "format": "int64"
4371 },
4372 "thread_id": {
4373 "type": [
4374 "string",
4375 "null"
4376 ],
4377 "format": "uuid"
4378 }
4379 }
4380 },
4381 "AdminChatMessageEditDto": {
4382 "type": "object",
4383 "required": [
4384 "id",
4385 "editor_id",
4386 "content",
4387 "created_at"
4388 ],
4389 "properties": {
4390 "content": {
4391 "type": "string"
4392 },
4393 "created_at": {
4394 "type": "string",
4395 "format": "date-time"
4396 },
4397 "editor_id": {
4398 "type": "string",
4399 "format": "uuid"
4400 },
4401 "editor_username": {
4402 "type": [
4403 "string",
4404 "null"
4405 ]
4406 },
4407 "id": {
4408 "type": "string",
4409 "format": "uuid"
4410 }
4411 }
4412 },
4413 "AdminChatReactionGroupDto": {
4414 "type": "object",
4415 "required": [
4416 "emoji",
4417 "count"
4418 ],
4419 "properties": {
4420 "count": {
4421 "type": "integer",
4422 "format": "int64"
4423 },
4424 "emoji": {
4425 "type": "string"
4426 }
4427 }
4428 },
4429 "AdminChatRoomDetail": {
4430 "type": "object",
4431 "required": [
4432 "room",
4433 "deleted_messages",
4434 "threads",
4435 "attachments",
4436 "attachment_bytes",
4437 "bans",
4438 "invites"
4439 ],
4440 "properties": {
4441 "attachment_bytes": {
4442 "type": "integer",
4443 "format": "int64"
4444 },
4445 "attachments": {
4446 "type": "integer",
4447 "format": "int64"
4448 },
4449 "bans": {
4450 "type": "integer",
4451 "format": "int64"
4452 },
4453 "deleted_messages": {
4454 "type": "integer",
4455 "format": "int64"
4456 },
4457 "invites": {
4458 "type": "integer",
4459 "format": "int64"
4460 },
4461 "room": {
4462 "$ref": "#/components/schemas/AdminChatRoomSummary"
4463 },
4464 "threads": {
4465 "type": "integer",
4466 "format": "int64"
4467 }
4468 }
4469 },
4470 "AdminChatRoomMemberDto": {
4471 "type": "object",
4472 "required": [
4473 "user_id",
4474 "role",
4475 "joined_at"
4476 ],
4477 "properties": {
4478 "display_name": {
4479 "type": [
4480 "string",
4481 "null"
4482 ]
4483 },
4484 "joined_at": {
4485 "type": "string",
4486 "format": "date-time"
4487 },
4488 "left_at": {
4489 "type": [
4490 "string",
4491 "null"
4492 ],
4493 "format": "date-time"
4494 },
4495 "nick": {
4496 "type": [
4497 "string",
4498 "null"
4499 ]
4500 },
4501 "role": {
4502 "type": "string"
4503 },
4504 "user_id": {
4505 "type": "string",
4506 "format": "uuid"
4507 },
4508 "username": {
4509 "type": [
4510 "string",
4511 "null"
4512 ]
4513 }
4514 }
4515 },
4516 "AdminChatRoomSummary": {
4517 "type": "object",
4518 "required": [
4519 "id",
4520 "kind",
4521 "name",
4522 "lower_name",
4523 "owner_id",
4524 "is_private",
4525 "nsfw",
4526 "is_active",
4527 "members",
4528 "messages",
4529 "created_at",
4530 "updated_at"
4531 ],
4532 "properties": {
4533 "created_at": {
4534 "type": "string",
4535 "format": "date-time"
4536 },
4537 "id": {
4538 "type": "string",
4539 "format": "uuid"
4540 },
4541 "is_active": {
4542 "type": "boolean"
4543 },
4544 "is_private": {
4545 "type": "boolean"
4546 },
4547 "kind": {
4548 "type": "string"
4549 },
4550 "last_message_at": {
4551 "type": [
4552 "string",
4553 "null"
4554 ],
4555 "format": "date-time"
4556 },
4557 "lower_name": {
4558 "type": "string"
4559 },
4560 "members": {
4561 "type": "integer",
4562 "format": "int64"
4563 },
4564 "messages": {
4565 "type": "integer",
4566 "format": "int64"
4567 },
4568 "name": {
4569 "type": "string"
4570 },
4571 "nsfw": {
4572 "type": "boolean"
4573 },
4574 "owner_id": {
4575 "type": "string",
4576 "format": "uuid"
4577 },
4578 "owner_username": {
4579 "type": [
4580 "string",
4581 "null"
4582 ]
4583 },
4584 "space_id": {
4585 "type": [
4586 "string",
4587 "null"
4588 ],
4589 "format": "uuid"
4590 },
4591 "space_name": {
4592 "type": [
4593 "string",
4594 "null"
4595 ]
4596 },
4597 "topic": {
4598 "type": [
4599 "string",
4600 "null"
4601 ]
4602 },
4603 "updated_at": {
4604 "type": "string",
4605 "format": "date-time"
4606 }
4607 }
4608 },
4609 "AdminChatStatsDto": {
4610 "type": "object",
4611 "required": [
4612 "rooms",
4613 "active_rooms",
4614 "private_rooms",
4615 "kinds",
4616 "messages",
4617 "deleted_messages",
4618 "messages_24h",
4619 "threads",
4620 "attachments",
4621 "attachment_bytes",
4622 "pending_attachments",
4623 "members",
4624 "bans"
4625 ],
4626 "properties": {
4627 "active_rooms": {
4628 "type": "integer",
4629 "format": "int64"
4630 },
4631 "attachment_bytes": {
4632 "type": "integer",
4633 "format": "int64"
4634 },
4635 "attachments": {
4636 "type": "integer",
4637 "format": "int64"
4638 },
4639 "bans": {
4640 "type": "integer",
4641 "format": "int64"
4642 },
4643 "deleted_messages": {
4644 "type": "integer",
4645 "format": "int64"
4646 },
4647 "kinds": {
4648 "type": "array",
4649 "items": {
4650 "$ref": "#/components/schemas/AdminChatKindCount"
4651 }
4652 },
4653 "members": {
4654 "type": "integer",
4655 "format": "int64"
4656 },
4657 "messages": {
4658 "type": "integer",
4659 "format": "int64"
4660 },
4661 "messages_24h": {
4662 "type": "integer",
4663 "format": "int64"
4664 },
4665 "pending_attachments": {
4666 "type": "integer",
4667 "format": "int64"
4668 },
4669 "private_rooms": {
4670 "type": "integer",
4671 "format": "int64"
4672 },
4673 "rooms": {
4674 "type": "integer",
4675 "format": "int64"
4676 },
4677 "threads": {
4678 "type": "integer",
4679 "format": "int64"
4680 }
4681 }
4682 },
4683 "AdminChatThreadDto": {
4684 "type": "object",
4685 "required": [
4686 "id",
4687 "owner_id",
4688 "reply_count",
4689 "participant_count",
4690 "archived",
4691 "locked",
4692 "created_at"
4693 ],
4694 "properties": {
4695 "archived": {
4696 "type": "boolean"
4697 },
4698 "created_at": {
4699 "type": "string",
4700 "format": "date-time"
4701 },
4702 "id": {
4703 "type": "string",
4704 "format": "uuid"
4705 },
4706 "locked": {
4707 "type": "boolean"
4708 },
4709 "name": {
4710 "type": [
4711 "string",
4712 "null"
4713 ]
4714 },
4715 "owner_id": {
4716 "type": "string",
4717 "format": "uuid"
4718 },
4719 "participant_count": {
4720 "type": "integer",
4721 "format": "int64"
4722 },
4723 "reply_count": {
4724 "type": "integer",
4725 "format": "int64"
4726 }
4727 }
4728 },
4729 "AdminDto": {
4730 "type": "object",
4731 "required": [
4732 "id",
4733 "username",
4734 "display_name",
4735 "role",
4736 "is_active",
4737 "must_change_password",
4738 "created_at",
4739 "updated_at"
4740 ],
4741 "properties": {
4742 "created_at": {
4743 "type": "string",
4744 "format": "date-time"
4745 },
4746 "display_name": {
4747 "type": "string"
4748 },
4749 "id": {
4750 "type": "string",
4751 "format": "uuid"
22984752 },
22994753 "is_active": {
23004754 "type": "boolean"
@@ -2327,6 +4781,46 @@
23274781 }
23284782 }
23294783 },
4784 "AdminEndpointDto": {
4785 "type": "object",
4786 "required": [
4787 "enabled",
4788 "bind_addr",
4789 "port",
4790 "session_cookie_name",
4791 "session_table_name",
4792 "cors_allow_origins",
4793 "cors_allow_credentials"
4794 ],
4795 "properties": {
4796 "bind_addr": {
4797 "type": "string"
4798 },
4799 "cors_allow_credentials": {
4800 "type": "boolean"
4801 },
4802 "cors_allow_origins": {
4803 "type": "array",
4804 "items": {
4805 "type": "string"
4806 }
4807 },
4808 "enabled": {
4809 "type": "boolean"
4810 },
4811 "port": {
4812 "type": "integer",
4813 "format": "int32",
4814 "minimum": 0
4815 },
4816 "session_cookie_name": {
4817 "type": "string"
4818 },
4819 "session_table_name": {
4820 "type": "string"
4821 }
4822 }
4823 },
23304824 "AdminLoginCmd": {
23314825 "type": "object",
23324826 "required": [
@@ -2340,404 +4834,3112 @@
23404834 "username": {
23414835 "type": "string"
23424836 }
2343 }
2344 },
2345 "AdminLoginResp": {
2346 "type": "object",
2347 "required": [
2348 "admin",
2349 "must_change_password"
2350 ],
2351 "properties": {
2352 "admin": {
2353 "$ref": "#/components/schemas/AdminDto"
4837 }
4838 },
4839 "AdminLoginResp": {
4840 "type": "object",
4841 "required": [
4842 "admin",
4843 "must_change_password"
4844 ],
4845 "properties": {
4846 "admin": {
4847 "$ref": "#/components/schemas/AdminDto"
4848 },
4849 "must_change_password": {
4850 "type": "boolean"
4851 }
4852 }
4853 },
4854 "AdminRole": {
4855 "type": "string",
4856 "enum": [
4857 "super_admin",
4858 "operator",
4859 "viewer"
4860 ]
4861 },
4862 "AdminSpaceDetail": {
4863 "type": "object",
4864 "required": [
4865 "space",
4866 "repos_count",
4867 "repos_size",
4868 "rooms_count",
4869 "dirs_count"
4870 ],
4871 "properties": {
4872 "dirs_count": {
4873 "type": "integer",
4874 "format": "int64"
4875 },
4876 "repos_count": {
4877 "type": "integer",
4878 "format": "int64"
4879 },
4880 "repos_size": {
4881 "type": "integer",
4882 "format": "int64"
4883 },
4884 "rooms_count": {
4885 "type": "integer",
4886 "format": "int64"
4887 },
4888 "space": {
4889 "$ref": "#/components/schemas/AdminSpaceSummary"
4890 }
4891 }
4892 },
4893 "AdminSpaceMemberDto": {
4894 "type": "object",
4895 "required": [
4896 "user_id",
4897 "status",
4898 "joined_at"
4899 ],
4900 "properties": {
4901 "alias_name": {
4902 "type": [
4903 "string",
4904 "null"
4905 ]
4906 },
4907 "display_name": {
4908 "type": [
4909 "string",
4910 "null"
4911 ]
4912 },
4913 "joined_at": {
4914 "type": "string",
4915 "format": "date-time"
4916 },
4917 "last_active_at": {
4918 "type": [
4919 "string",
4920 "null"
4921 ],
4922 "format": "date-time"
4923 },
4924 "status": {
4925 "type": "string"
4926 },
4927 "user_id": {
4928 "type": "string",
4929 "format": "uuid"
4930 },
4931 "username": {
4932 "type": [
4933 "string",
4934 "null"
4935 ]
4936 }
4937 }
4938 },
4939 "AdminSpaceSummary": {
4940 "type": "object",
4941 "required": [
4942 "id",
4943 "name",
4944 "lower_name",
4945 "owner_id",
4946 "is_private",
4947 "is_active",
4948 "allow_join",
4949 "allow_invite",
4950 "member_must_enable_2fa",
4951 "nums_members",
4952 "created_at",
4953 "updated_at"
4954 ],
4955 "properties": {
4956 "allow_invite": {
4957 "type": "boolean"
4958 },
4959 "allow_join": {
4960 "type": "boolean"
4961 },
4962 "created_at": {
4963 "type": "string",
4964 "format": "date-time"
4965 },
4966 "description": {
4967 "type": [
4968 "string",
4969 "null"
4970 ]
4971 },
4972 "id": {
4973 "type": "string",
4974 "format": "uuid"
4975 },
4976 "is_active": {
4977 "type": "boolean"
4978 },
4979 "is_private": {
4980 "type": "boolean"
4981 },
4982 "lower_name": {
4983 "type": "string"
4984 },
4985 "member_must_enable_2fa": {
4986 "type": "boolean"
4987 },
4988 "name": {
4989 "type": "string"
4990 },
4991 "nums_members": {
4992 "type": "integer",
4993 "format": "int64"
4994 },
4995 "owner_id": {
4996 "type": "string",
4997 "format": "uuid"
4998 },
4999 "owner_username": {
5000 "type": [
5001 "string",
5002 "null"
5003 ]
5004 },
5005 "updated_at": {
5006 "type": "string",
5007 "format": "date-time"
5008 }
5009 }
5010 },
5011 "AdminSuspensionDto": {
5012 "type": "object",
5013 "required": [
5014 "id",
5015 "suspension_type",
5016 "starts_at"
5017 ],
5018 "properties": {
5019 "ends_at": {
5020 "type": [
5021 "string",
5022 "null"
5023 ],
5024 "format": "date-time"
5025 },
5026 "id": {
5027 "type": "string",
5028 "format": "uuid"
5029 },
5030 "reason": {
5031 "type": [
5032 "string",
5033 "null"
5034 ]
5035 },
5036 "revoked_at": {
5037 "type": [
5038 "string",
5039 "null"
5040 ],
5041 "format": "date-time"
5042 },
5043 "starts_at": {
5044 "type": "string",
5045 "format": "date-time"
5046 },
5047 "suspended_by": {
5048 "type": [
5049 "string",
5050 "null"
5051 ],
5052 "format": "uuid"
5053 },
5054 "suspension_type": {
5055 "type": "string"
5056 }
5057 }
5058 },
5059 "AdminUserDetail": {
5060 "type": "object",
5061 "required": [
5062 "user",
5063 "emails",
5064 "sessions",
5065 "spaces",
5066 "suspensions"
5067 ],
5068 "properties": {
5069 "emails": {
5070 "type": "array",
5071 "items": {
5072 "$ref": "#/components/schemas/AdminUserEmailDto"
5073 }
5074 },
5075 "sessions": {
5076 "type": "array",
5077 "items": {
5078 "$ref": "#/components/schemas/AdminUserSessionDto"
5079 }
5080 },
5081 "spaces": {
5082 "type": "array",
5083 "items": {
5084 "$ref": "#/components/schemas/AdminUserSpaceDto"
5085 }
5086 },
5087 "suspensions": {
5088 "type": "array",
5089 "items": {
5090 "$ref": "#/components/schemas/AdminSuspensionDto"
5091 }
5092 },
5093 "user": {
5094 "$ref": "#/components/schemas/AdminUserSummary"
5095 }
5096 }
5097 },
5098 "AdminUserEmailDto": {
5099 "type": "object",
5100 "required": [
5101 "id",
5102 "email",
5103 "is_primary",
5104 "is_activated",
5105 "created_at"
5106 ],
5107 "properties": {
5108 "created_at": {
5109 "type": "string",
5110 "format": "date-time"
5111 },
5112 "email": {
5113 "type": "string"
5114 },
5115 "id": {
5116 "type": "string",
5117 "format": "uuid"
5118 },
5119 "is_activated": {
5120 "type": "boolean"
5121 },
5122 "is_primary": {
5123 "type": "boolean"
5124 }
5125 }
5126 },
5127 "AdminUserSecurityDto": {
5128 "type": "object",
5129 "required": [
5130 "totp_enabled",
5131 "recovery_codes_total",
5132 "recovery_codes_used",
5133 "active_sessions",
5134 "ssh_keys",
5135 "access_keys",
5136 "passwd_version"
5137 ],
5138 "properties": {
5139 "access_keys": {
5140 "type": "integer",
5141 "format": "int64"
5142 },
5143 "active_sessions": {
5144 "type": "integer",
5145 "format": "int64"
5146 },
5147 "passwd_version": {
5148 "type": "integer",
5149 "format": "int64"
5150 },
5151 "password_last_changed_at": {
5152 "type": [
5153 "string",
5154 "null"
5155 ],
5156 "format": "date-time"
5157 },
5158 "recovery_codes_total": {
5159 "type": "integer",
5160 "format": "int64"
5161 },
5162 "recovery_codes_used": {
5163 "type": "integer",
5164 "format": "int64"
5165 },
5166 "ssh_keys": {
5167 "type": "integer",
5168 "format": "int64"
5169 },
5170 "totp_enabled": {
5171 "type": "boolean"
5172 }
5173 }
5174 },
5175 "AdminUserSessionDto": {
5176 "type": "object",
5177 "required": [
5178 "id",
5179 "kind",
5180 "last_seen_at",
5181 "created_at",
5182 "expires_at",
5183 "is_revoked"
5184 ],
5185 "properties": {
5186 "created_at": {
5187 "type": "string",
5188 "format": "date-time"
5189 },
5190 "expires_at": {
5191 "type": "string",
5192 "format": "date-time"
5193 },
5194 "id": {
5195 "type": "string",
5196 "format": "uuid"
5197 },
5198 "is_revoked": {
5199 "type": "boolean"
5200 },
5201 "kind": {
5202 "type": "string"
5203 },
5204 "last_seen_at": {
5205 "type": "string",
5206 "format": "date-time"
5207 }
5208 }
5209 },
5210 "AdminUserSessionPage": {
5211 "type": "object",
5212 "required": [
5213 "items",
5214 "total",
5215 "limit",
5216 "offset"
5217 ],
5218 "properties": {
5219 "items": {
5220 "type": "array",
5221 "items": {
5222 "$ref": "#/components/schemas/AdminUserSessionRow"
5223 }
5224 },
5225 "limit": {
5226 "type": "integer",
5227 "format": "int64",
5228 "minimum": 0
5229 },
5230 "offset": {
5231 "type": "integer",
5232 "format": "int64",
5233 "minimum": 0
5234 },
5235 "total": {
5236 "type": "integer",
5237 "format": "int64"
5238 }
5239 }
5240 },
5241 "AdminUserSessionRow": {
5242 "type": "object",
5243 "required": [
5244 "id",
5245 "kind",
5246 "last_seen_at",
5247 "created_at",
5248 "expires_at",
5249 "is_revoked"
5250 ],
5251 "properties": {
5252 "app_id": {
5253 "type": [
5254 "string",
5255 "null"
5256 ]
5257 },
5258 "client_ip_hash": {
5259 "type": [
5260 "string",
5261 "null"
5262 ]
5263 },
5264 "created_at": {
5265 "type": "string",
5266 "format": "date-time"
5267 },
5268 "device_id": {
5269 "type": [
5270 "string",
5271 "null"
5272 ],
5273 "format": "uuid"
5274 },
5275 "expires_at": {
5276 "type": "string",
5277 "format": "date-time"
5278 },
5279 "id": {
5280 "type": "string",
5281 "format": "uuid"
5282 },
5283 "is_revoked": {
5284 "type": "boolean"
5285 },
5286 "kind": {
5287 "type": "string"
5288 },
5289 "last_seen_at": {
5290 "type": "string",
5291 "format": "date-time"
5292 },
5293 "revoked_at": {
5294 "type": [
5295 "string",
5296 "null"
5297 ],
5298 "format": "date-time"
5299 },
5300 "user_agent_hash": {
5301 "type": [
5302 "string",
5303 "null"
5304 ]
5305 }
5306 }
5307 },
5308 "AdminUserSpaceDto": {
5309 "type": "object",
5310 "required": [
5311 "id",
5312 "name",
5313 "lower_name",
5314 "is_active"
5315 ],
5316 "properties": {
5317 "id": {
5318 "type": "string",
5319 "format": "uuid"
5320 },
5321 "is_active": {
5322 "type": "boolean"
5323 },
5324 "lower_name": {
5325 "type": "string"
5326 },
5327 "name": {
5328 "type": "string"
5329 }
5330 }
5331 },
5332 "AdminUserSummary": {
5333 "type": "object",
5334 "required": [
5335 "uid",
5336 "username",
5337 "display_name",
5338 "avatar_url",
5339 "is_active",
5340 "is_restricted",
5341 "prohibit_login",
5342 "bot",
5343 "system",
5344 "suspended",
5345 "created_at"
5346 ],
5347 "properties": {
5348 "avatar_url": {
5349 "type": "string"
5350 },
5351 "bot": {
5352 "type": "boolean"
5353 },
5354 "created_at": {
5355 "type": "string",
5356 "format": "date-time"
5357 },
5358 "display_name": {
5359 "type": "string"
5360 },
5361 "is_active": {
5362 "type": "boolean"
5363 },
5364 "is_restricted": {
5365 "type": "boolean"
5366 },
5367 "last_login_at": {
5368 "type": [
5369 "string",
5370 "null"
5371 ],
5372 "format": "date-time"
5373 },
5374 "prohibit_login": {
5375 "type": "boolean"
5376 },
5377 "suspended": {
5378 "type": "boolean"
5379 },
5380 "system": {
5381 "type": "boolean"
5382 },
5383 "uid": {
5384 "type": "string",
5385 "format": "uuid"
5386 },
5387 "username": {
5388 "type": "string"
5389 }
5390 }
5391 },
5392 "AppResponseData_AddSpaceMemberResp": {
5393 "type": "object",
5394 "required": [
5395 "code",
5396 "message",
5397 "request_id",
5398 "log_id"
5399 ],
5400 "properties": {
5401 "code": {
5402 "type": "integer",
5403 "format": "int32"
5404 },
5405 "data": {
5406 "type": "object",
5407 "required": [
5408 "space_id",
5409 "user_id",
5410 "status",
5411 "added"
5412 ],
5413 "properties": {
5414 "added": {
5415 "type": "boolean"
5416 },
5417 "space_id": {
5418 "type": "string",
5419 "format": "uuid"
5420 },
5421 "status": {
5422 "type": "string"
5423 },
5424 "user_id": {
5425 "type": "string",
5426 "format": "uuid"
5427 }
5428 }
5429 },
5430 "error": {
5431 "type": [
5432 "string",
5433 "null"
5434 ]
5435 },
5436 "error_code": {
5437 "type": [
5438 "string",
5439 "null"
5440 ]
5441 },
5442 "log_id": {
5443 "type": "string"
5444 },
5445 "message": {
5446 "type": "string"
5447 },
5448 "request_id": {
5449 "type": "string"
5450 }
5451 }
5452 },
5453 "AppResponseData_AdminChatMessageDetail": {
5454 "type": "object",
5455 "required": [
5456 "code",
5457 "message",
5458 "request_id",
5459 "log_id"
5460 ],
5461 "properties": {
5462 "code": {
5463 "type": "integer",
5464 "format": "int32"
5465 },
5466 "data": {
5467 "type": "object",
5468 "required": [
5469 "message",
5470 "edits",
5471 "reactions",
5472 "attachments"
5473 ],
5474 "properties": {
5475 "attachments": {
5476 "type": "array",
5477 "items": {
5478 "$ref": "#/components/schemas/AdminChatAttachmentDto"
5479 }
5480 },
5481 "edits": {
5482 "type": "array",
5483 "items": {
5484 "$ref": "#/components/schemas/AdminChatMessageEditDto"
5485 }
5486 },
5487 "message": {
5488 "$ref": "#/components/schemas/AdminChatMessageDto"
5489 },
5490 "reactions": {
5491 "type": "array",
5492 "items": {
5493 "$ref": "#/components/schemas/AdminChatReactionGroupDto"
5494 }
5495 },
5496 "thread": {
5497 "oneOf": [
5498 {
5499 "type": "null"
5500 },
5501 {
5502 "$ref": "#/components/schemas/AdminChatThreadDto"
5503 }
5504 ]
5505 }
5506 }
5507 },
5508 "error": {
5509 "type": [
5510 "string",
5511 "null"
5512 ]
5513 },
5514 "error_code": {
5515 "type": [
5516 "string",
5517 "null"
5518 ]
5519 },
5520 "log_id": {
5521 "type": "string"
5522 },
5523 "message": {
5524 "type": "string"
5525 },
5526 "request_id": {
5527 "type": "string"
5528 }
5529 }
5530 },
5531 "AppResponseData_AdminChatRoomDetail": {
5532 "type": "object",
5533 "required": [
5534 "code",
5535 "message",
5536 "request_id",
5537 "log_id"
5538 ],
5539 "properties": {
5540 "code": {
5541 "type": "integer",
5542 "format": "int32"
5543 },
5544 "data": {
5545 "type": "object",
5546 "required": [
5547 "room",
5548 "deleted_messages",
5549 "threads",
5550 "attachments",
5551 "attachment_bytes",
5552 "bans",
5553 "invites"
5554 ],
5555 "properties": {
5556 "attachment_bytes": {
5557 "type": "integer",
5558 "format": "int64"
5559 },
5560 "attachments": {
5561 "type": "integer",
5562 "format": "int64"
5563 },
5564 "bans": {
5565 "type": "integer",
5566 "format": "int64"
5567 },
5568 "deleted_messages": {
5569 "type": "integer",
5570 "format": "int64"
5571 },
5572 "invites": {
5573 "type": "integer",
5574 "format": "int64"
5575 },
5576 "room": {
5577 "$ref": "#/components/schemas/AdminChatRoomSummary"
5578 },
5579 "threads": {
5580 "type": "integer",
5581 "format": "int64"
5582 }
5583 }
5584 },
5585 "error": {
5586 "type": [
5587 "string",
5588 "null"
5589 ]
5590 },
5591 "error_code": {
5592 "type": [
5593 "string",
5594 "null"
5595 ]
5596 },
5597 "log_id": {
5598 "type": "string"
5599 },
5600 "message": {
5601 "type": "string"
5602 },
5603 "request_id": {
5604 "type": "string"
5605 }
5606 }
5607 },
5608 "AppResponseData_AdminChatStatsDto": {
5609 "type": "object",
5610 "required": [
5611 "code",
5612 "message",
5613 "request_id",
5614 "log_id"
5615 ],
5616 "properties": {
5617 "code": {
5618 "type": "integer",
5619 "format": "int32"
5620 },
5621 "data": {
5622 "type": "object",
5623 "required": [
5624 "rooms",
5625 "active_rooms",
5626 "private_rooms",
5627 "kinds",
5628 "messages",
5629 "deleted_messages",
5630 "messages_24h",
5631 "threads",
5632 "attachments",
5633 "attachment_bytes",
5634 "pending_attachments",
5635 "members",
5636 "bans"
5637 ],
5638 "properties": {
5639 "active_rooms": {
5640 "type": "integer",
5641 "format": "int64"
5642 },
5643 "attachment_bytes": {
5644 "type": "integer",
5645 "format": "int64"
5646 },
5647 "attachments": {
5648 "type": "integer",
5649 "format": "int64"
5650 },
5651 "bans": {
5652 "type": "integer",
5653 "format": "int64"
5654 },
5655 "deleted_messages": {
5656 "type": "integer",
5657 "format": "int64"
5658 },
5659 "kinds": {
5660 "type": "array",
5661 "items": {
5662 "$ref": "#/components/schemas/AdminChatKindCount"
5663 }
5664 },
5665 "members": {
5666 "type": "integer",
5667 "format": "int64"
5668 },
5669 "messages": {
5670 "type": "integer",
5671 "format": "int64"
5672 },
5673 "messages_24h": {
5674 "type": "integer",
5675 "format": "int64"
5676 },
5677 "pending_attachments": {
5678 "type": "integer",
5679 "format": "int64"
5680 },
5681 "private_rooms": {
5682 "type": "integer",
5683 "format": "int64"
5684 },
5685 "rooms": {
5686 "type": "integer",
5687 "format": "int64"
5688 },
5689 "threads": {
5690 "type": "integer",
5691 "format": "int64"
5692 }
5693 }
5694 },
5695 "error": {
5696 "type": [
5697 "string",
5698 "null"
5699 ]
5700 },
5701 "error_code": {
5702 "type": [
5703 "string",
5704 "null"
5705 ]
5706 },
5707 "log_id": {
5708 "type": "string"
5709 },
5710 "message": {
5711 "type": "string"
5712 },
5713 "request_id": {
5714 "type": "string"
5715 }
5716 }
5717 },
5718 "AppResponseData_AdminDto": {
5719 "type": "object",
5720 "required": [
5721 "code",
5722 "message",
5723 "request_id",
5724 "log_id"
5725 ],
5726 "properties": {
5727 "code": {
5728 "type": "integer",
5729 "format": "int32"
5730 },
5731 "data": {
5732 "type": "object",
5733 "required": [
5734 "id",
5735 "username",
5736 "display_name",
5737 "role",
5738 "is_active",
5739 "must_change_password",
5740 "created_at",
5741 "updated_at"
5742 ],
5743 "properties": {
5744 "created_at": {
5745 "type": "string",
5746 "format": "date-time"
5747 },
5748 "display_name": {
5749 "type": "string"
5750 },
5751 "id": {
5752 "type": "string",
5753 "format": "uuid"
5754 },
5755 "is_active": {
5756 "type": "boolean"
5757 },
5758 "last_login_at": {
5759 "type": [
5760 "string",
5761 "null"
5762 ],
5763 "format": "date-time"
5764 },
5765 "last_login_ip": {
5766 "type": [
5767 "string",
5768 "null"
5769 ]
5770 },
5771 "must_change_password": {
5772 "type": "boolean"
5773 },
5774 "role": {
5775 "$ref": "#/components/schemas/AdminRole"
5776 },
5777 "updated_at": {
5778 "type": "string",
5779 "format": "date-time"
5780 },
5781 "username": {
5782 "type": "string"
5783 }
5784 }
5785 },
5786 "error": {
5787 "type": [
5788 "string",
5789 "null"
5790 ]
5791 },
5792 "error_code": {
5793 "type": [
5794 "string",
5795 "null"
5796 ]
5797 },
5798 "log_id": {
5799 "type": "string"
5800 },
5801 "message": {
5802 "type": "string"
5803 },
5804 "request_id": {
5805 "type": "string"
5806 }
5807 }
5808 },
5809 "AppResponseData_AdminLoginResp": {
5810 "type": "object",
5811 "required": [
5812 "code",
5813 "message",
5814 "request_id",
5815 "log_id"
5816 ],
5817 "properties": {
5818 "code": {
5819 "type": "integer",
5820 "format": "int32"
5821 },
5822 "data": {
5823 "type": "object",
5824 "required": [
5825 "admin",
5826 "must_change_password"
5827 ],
5828 "properties": {
5829 "admin": {
5830 "$ref": "#/components/schemas/AdminDto"
5831 },
5832 "must_change_password": {
5833 "type": "boolean"
5834 }
5835 }
5836 },
5837 "error": {
5838 "type": [
5839 "string",
5840 "null"
5841 ]
5842 },
5843 "error_code": {
5844 "type": [
5845 "string",
5846 "null"
5847 ]
5848 },
5849 "log_id": {
5850 "type": "string"
5851 },
5852 "message": {
5853 "type": "string"
5854 },
5855 "request_id": {
5856 "type": "string"
5857 }
5858 }
5859 },
5860 "AppResponseData_AdminSpaceDetail": {
5861 "type": "object",
5862 "required": [
5863 "code",
5864 "message",
5865 "request_id",
5866 "log_id"
5867 ],
5868 "properties": {
5869 "code": {
5870 "type": "integer",
5871 "format": "int32"
5872 },
5873 "data": {
5874 "type": "object",
5875 "required": [
5876 "space",
5877 "repos_count",
5878 "repos_size",
5879 "rooms_count",
5880 "dirs_count"
5881 ],
5882 "properties": {
5883 "dirs_count": {
5884 "type": "integer",
5885 "format": "int64"
5886 },
5887 "repos_count": {
5888 "type": "integer",
5889 "format": "int64"
5890 },
5891 "repos_size": {
5892 "type": "integer",
5893 "format": "int64"
5894 },
5895 "rooms_count": {
5896 "type": "integer",
5897 "format": "int64"
5898 },
5899 "space": {
5900 "$ref": "#/components/schemas/AdminSpaceSummary"
5901 }
5902 }
5903 },
5904 "error": {
5905 "type": [
5906 "string",
5907 "null"
5908 ]
5909 },
5910 "error_code": {
5911 "type": [
5912 "string",
5913 "null"
5914 ]
5915 },
5916 "log_id": {
5917 "type": "string"
5918 },
5919 "message": {
5920 "type": "string"
5921 },
5922 "request_id": {
5923 "type": "string"
5924 }
5925 }
5926 },
5927 "AppResponseData_AdminSuspensionDto": {
5928 "type": "object",
5929 "required": [
5930 "code",
5931 "message",
5932 "request_id",
5933 "log_id"
5934 ],
5935 "properties": {
5936 "code": {
5937 "type": "integer",
5938 "format": "int32"
5939 },
5940 "data": {
5941 "type": "object",
5942 "required": [
5943 "id",
5944 "suspension_type",
5945 "starts_at"
5946 ],
5947 "properties": {
5948 "ends_at": {
5949 "type": [
5950 "string",
5951 "null"
5952 ],
5953 "format": "date-time"
5954 },
5955 "id": {
5956 "type": "string",
5957 "format": "uuid"
5958 },
5959 "reason": {
5960 "type": [
5961 "string",
5962 "null"
5963 ]
5964 },
5965 "revoked_at": {
5966 "type": [
5967 "string",
5968 "null"
5969 ],
5970 "format": "date-time"
5971 },
5972 "starts_at": {
5973 "type": "string",
5974 "format": "date-time"
5975 },
5976 "suspended_by": {
5977 "type": [
5978 "string",
5979 "null"
5980 ],
5981 "format": "uuid"
5982 },
5983 "suspension_type": {
5984 "type": "string"
5985 }
5986 }
5987 },
5988 "error": {
5989 "type": [
5990 "string",
5991 "null"
5992 ]
5993 },
5994 "error_code": {
5995 "type": [
5996 "string",
5997 "null"
5998 ]
5999 },
6000 "log_id": {
6001 "type": "string"
6002 },
6003 "message": {
6004 "type": "string"
6005 },
6006 "request_id": {
6007 "type": "string"
6008 }
6009 }
6010 },
6011 "AppResponseData_AdminUserDetail": {
6012 "type": "object",
6013 "required": [
6014 "code",
6015 "message",
6016 "request_id",
6017 "log_id"
6018 ],
6019 "properties": {
6020 "code": {
6021 "type": "integer",
6022 "format": "int32"
6023 },
6024 "data": {
6025 "type": "object",
6026 "required": [
6027 "user",
6028 "emails",
6029 "sessions",
6030 "spaces",
6031 "suspensions"
6032 ],
6033 "properties": {
6034 "emails": {
6035 "type": "array",
6036 "items": {
6037 "$ref": "#/components/schemas/AdminUserEmailDto"
6038 }
6039 },
6040 "sessions": {
6041 "type": "array",
6042 "items": {
6043 "$ref": "#/components/schemas/AdminUserSessionDto"
6044 }
6045 },
6046 "spaces": {
6047 "type": "array",
6048 "items": {
6049 "$ref": "#/components/schemas/AdminUserSpaceDto"
6050 }
6051 },
6052 "suspensions": {
6053 "type": "array",
6054 "items": {
6055 "$ref": "#/components/schemas/AdminSuspensionDto"
6056 }
6057 },
6058 "user": {
6059 "$ref": "#/components/schemas/AdminUserSummary"
6060 }
6061 }
6062 },
6063 "error": {
6064 "type": [
6065 "string",
6066 "null"
6067 ]
6068 },
6069 "error_code": {
6070 "type": [
6071 "string",
6072 "null"
6073 ]
6074 },
6075 "log_id": {
6076 "type": "string"
6077 },
6078 "message": {
6079 "type": "string"
6080 },
6081 "request_id": {
6082 "type": "string"
6083 }
6084 }
6085 },
6086 "AppResponseData_AdminUserSecurityDto": {
6087 "type": "object",
6088 "required": [
6089 "code",
6090 "message",
6091 "request_id",
6092 "log_id"
6093 ],
6094 "properties": {
6095 "code": {
6096 "type": "integer",
6097 "format": "int32"
6098 },
6099 "data": {
6100 "type": "object",
6101 "required": [
6102 "totp_enabled",
6103 "recovery_codes_total",
6104 "recovery_codes_used",
6105 "active_sessions",
6106 "ssh_keys",
6107 "access_keys",
6108 "passwd_version"
6109 ],
6110 "properties": {
6111 "access_keys": {
6112 "type": "integer",
6113 "format": "int64"
6114 },
6115 "active_sessions": {
6116 "type": "integer",
6117 "format": "int64"
6118 },
6119 "passwd_version": {
6120 "type": "integer",
6121 "format": "int64"
6122 },
6123 "password_last_changed_at": {
6124 "type": [
6125 "string",
6126 "null"
6127 ],
6128 "format": "date-time"
6129 },
6130 "recovery_codes_total": {
6131 "type": "integer",
6132 "format": "int64"
6133 },
6134 "recovery_codes_used": {
6135 "type": "integer",
6136 "format": "int64"
6137 },
6138 "ssh_keys": {
6139 "type": "integer",
6140 "format": "int64"
6141 },
6142 "totp_enabled": {
6143 "type": "boolean"
6144 }
6145 }
6146 },
6147 "error": {
6148 "type": [
6149 "string",
6150 "null"
6151 ]
6152 },
6153 "error_code": {
6154 "type": [
6155 "string",
6156 "null"
6157 ]
6158 },
6159 "log_id": {
6160 "type": "string"
6161 },
6162 "message": {
6163 "type": "string"
6164 },
6165 "request_id": {
6166 "type": "string"
6167 }
6168 }
6169 },
6170 "AppResponseData_AdminUserSessionPage": {
6171 "type": "object",
6172 "required": [
6173 "code",
6174 "message",
6175 "request_id",
6176 "log_id"
6177 ],
6178 "properties": {
6179 "code": {
6180 "type": "integer",
6181 "format": "int32"
6182 },
6183 "data": {
6184 "type": "object",
6185 "required": [
6186 "items",
6187 "total",
6188 "limit",
6189 "offset"
6190 ],
6191 "properties": {
6192 "items": {
6193 "type": "array",
6194 "items": {
6195 "$ref": "#/components/schemas/AdminUserSessionRow"
6196 }
6197 },
6198 "limit": {
6199 "type": "integer",
6200 "format": "int64",
6201 "minimum": 0
6202 },
6203 "offset": {
6204 "type": "integer",
6205 "format": "int64",
6206 "minimum": 0
6207 },
6208 "total": {
6209 "type": "integer",
6210 "format": "int64"
6211 }
6212 }
6213 },
6214 "error": {
6215 "type": [
6216 "string",
6217 "null"
6218 ]
6219 },
6220 "error_code": {
6221 "type": [
6222 "string",
6223 "null"
6224 ]
6225 },
6226 "log_id": {
6227 "type": "string"
6228 },
6229 "message": {
6230 "type": "string"
6231 },
6232 "request_id": {
6233 "type": "string"
6234 }
6235 }
6236 },
6237 "AppResponseData_AdminUserSummary": {
6238 "type": "object",
6239 "required": [
6240 "code",
6241 "message",
6242 "request_id",
6243 "log_id"
6244 ],
6245 "properties": {
6246 "code": {
6247 "type": "integer",
6248 "format": "int32"
6249 },
6250 "data": {
6251 "type": "object",
6252 "required": [
6253 "uid",
6254 "username",
6255 "display_name",
6256 "avatar_url",
6257 "is_active",
6258 "is_restricted",
6259 "prohibit_login",
6260 "bot",
6261 "system",
6262 "suspended",
6263 "created_at"
6264 ],
6265 "properties": {
6266 "avatar_url": {
6267 "type": "string"
6268 },
6269 "bot": {
6270 "type": "boolean"
6271 },
6272 "created_at": {
6273 "type": "string",
6274 "format": "date-time"
6275 },
6276 "display_name": {
6277 "type": "string"
6278 },
6279 "is_active": {
6280 "type": "boolean"
6281 },
6282 "is_restricted": {
6283 "type": "boolean"
6284 },
6285 "last_login_at": {
6286 "type": [
6287 "string",
6288 "null"
6289 ],
6290 "format": "date-time"
6291 },
6292 "prohibit_login": {
6293 "type": "boolean"
6294 },
6295 "suspended": {
6296 "type": "boolean"
6297 },
6298 "system": {
6299 "type": "boolean"
6300 },
6301 "uid": {
6302 "type": "string",
6303 "format": "uuid"
6304 },
6305 "username": {
6306 "type": "string"
6307 }
6308 }
6309 },
6310 "error": {
6311 "type": [
6312 "string",
6313 "null"
6314 ]
6315 },
6316 "error_code": {
6317 "type": [
6318 "string",
6319 "null"
6320 ]
6321 },
6322 "log_id": {
6323 "type": "string"
6324 },
6325 "message": {
6326 "type": "string"
6327 },
6328 "request_id": {
6329 "type": "string"
6330 }
6331 }
6332 },
6333 "AppResponseData_AuditLogPageDto": {
6334 "type": "object",
6335 "required": [
6336 "code",
6337 "message",
6338 "request_id",
6339 "log_id"
6340 ],
6341 "properties": {
6342 "code": {
6343 "type": "integer",
6344 "format": "int32"
6345 },
6346 "data": {
6347 "type": "object",
6348 "required": [
6349 "items",
6350 "total",
6351 "limit",
6352 "offset"
6353 ],
6354 "properties": {
6355 "items": {
6356 "type": "array",
6357 "items": {
6358 "$ref": "#/components/schemas/AuditLogDto"
6359 }
6360 },
6361 "limit": {
6362 "type": "integer",
6363 "format": "int64",
6364 "minimum": 0
6365 },
6366 "offset": {
6367 "type": "integer",
6368 "format": "int64",
6369 "minimum": 0
6370 },
6371 "total": {
6372 "type": "integer",
6373 "format": "int64"
6374 }
6375 }
6376 },
6377 "error": {
6378 "type": [
6379 "string",
6380 "null"
6381 ]
6382 },
6383 "error_code": {
6384 "type": [
6385 "string",
6386 "null"
6387 ]
6388 },
6389 "log_id": {
6390 "type": "string"
6391 },
6392 "message": {
6393 "type": "string"
6394 },
6395 "request_id": {
6396 "type": "string"
6397 }
6398 }
6399 },
6400 "AppResponseData_BulkDeleteChatMessagesResp": {
6401 "type": "object",
6402 "required": [
6403 "code",
6404 "message",
6405 "request_id",
6406 "log_id"
6407 ],
6408 "properties": {
6409 "code": {
6410 "type": "integer",
6411 "format": "int32"
6412 },
6413 "data": {
6414 "type": "object",
6415 "required": [
6416 "succeeded",
6417 "failed"
6418 ],
6419 "properties": {
6420 "failed": {
6421 "type": "array",
6422 "items": {
6423 "$ref": "#/components/schemas/BulkChatMessageFailure"
6424 }
6425 },
6426 "succeeded": {
6427 "type": "array",
6428 "items": {
6429 "type": "string",
6430 "format": "uuid"
6431 }
6432 }
6433 }
6434 },
6435 "error": {
6436 "type": [
6437 "string",
6438 "null"
6439 ]
6440 },
6441 "error_code": {
6442 "type": [
6443 "string",
6444 "null"
6445 ]
6446 },
6447 "log_id": {
6448 "type": "string"
6449 },
6450 "message": {
6451 "type": "string"
6452 },
6453 "request_id": {
6454 "type": "string"
6455 }
6456 }
6457 },
6458 "AppResponseData_BulkTaskResp": {
6459 "type": "object",
6460 "required": [
6461 "code",
6462 "message",
6463 "request_id",
6464 "log_id"
6465 ],
6466 "properties": {
6467 "code": {
6468 "type": "integer",
6469 "format": "int32"
6470 },
6471 "data": {
6472 "type": "object",
6473 "required": [
6474 "source",
6475 "action",
6476 "succeeded",
6477 "failed"
6478 ],
6479 "properties": {
6480 "action": {
6481 "type": "string"
6482 },
6483 "failed": {
6484 "type": "array",
6485 "items": {
6486 "$ref": "#/components/schemas/BulkTaskFailure"
6487 }
6488 },
6489 "source": {
6490 "type": "string"
6491 },
6492 "succeeded": {
6493 "type": "array",
6494 "items": {
6495 "type": "string",
6496 "format": "uuid"
6497 }
6498 }
6499 }
6500 },
6501 "error": {
6502 "type": [
6503 "string",
6504 "null"
6505 ]
6506 },
6507 "error_code": {
6508 "type": [
6509 "string",
6510 "null"
6511 ]
6512 },
6513 "log_id": {
6514 "type": "string"
6515 },
6516 "message": {
6517 "type": "string"
6518 },
6519 "request_id": {
6520 "type": "string"
6521 }
6522 }
6523 },
6524 "AppResponseData_BulkUsersResp": {
6525 "type": "object",
6526 "required": [
6527 "code",
6528 "message",
6529 "request_id",
6530 "log_id"
6531 ],
6532 "properties": {
6533 "code": {
6534 "type": "integer",
6535 "format": "int32"
6536 },
6537 "data": {
6538 "type": "object",
6539 "required": [
6540 "action",
6541 "succeeded",
6542 "failed"
6543 ],
6544 "properties": {
6545 "action": {
6546 "type": "string"
6547 },
6548 "failed": {
6549 "type": "array",
6550 "items": {
6551 "$ref": "#/components/schemas/BulkUserFailure"
6552 }
6553 },
6554 "succeeded": {
6555 "type": "array",
6556 "items": {
6557 "type": "string",
6558 "format": "uuid"
6559 }
6560 }
6561 }
6562 },
6563 "error": {
6564 "type": [
6565 "string",
6566 "null"
6567 ]
6568 },
6569 "error_code": {
6570 "type": [
6571 "string",
6572 "null"
6573 ]
6574 },
6575 "log_id": {
6576 "type": "string"
6577 },
6578 "message": {
6579 "type": "string"
6580 },
6581 "request_id": {
6582 "type": "string"
6583 }
6584 }
6585 },
6586 "AppResponseData_CreateUserResp": {
6587 "type": "object",
6588 "required": [
6589 "code",
6590 "message",
6591 "request_id",
6592 "log_id"
6593 ],
6594 "properties": {
6595 "code": {
6596 "type": "integer",
6597 "format": "int32"
6598 },
6599 "data": {
6600 "type": "object",
6601 "required": [
6602 "uid",
6603 "username",
6604 "email"
6605 ],
6606 "properties": {
6607 "email": {
6608 "type": "string"
6609 },
6610 "uid": {
6611 "type": "string",
6612 "format": "uuid"
6613 },
6614 "username": {
6615 "type": "string"
6616 }
6617 }
6618 },
6619 "error": {
6620 "type": [
6621 "string",
6622 "null"
6623 ]
6624 },
6625 "error_code": {
6626 "type": [
6627 "string",
6628 "null"
6629 ]
6630 },
6631 "log_id": {
6632 "type": "string"
6633 },
6634 "message": {
6635 "type": "string"
6636 },
6637 "request_id": {
6638 "type": "string"
6639 }
6640 }
6641 },
6642 "AppResponseData_DashboardDto": {
6643 "type": "object",
6644 "required": [
6645 "code",
6646 "message",
6647 "request_id",
6648 "log_id"
6649 ],
6650 "properties": {
6651 "code": {
6652 "type": "integer",
6653 "format": "int32"
6654 },
6655 "data": {
6656 "type": "object",
6657 "required": [
6658 "counts",
6659 "trend",
6660 "active_sessions",
6661 "restricted_users",
6662 "recent_audit",
6663 "recent_failures",
6664 "queues",
6665 "generated_at"
6666 ],
6667 "properties": {
6668 "active_sessions": {
6669 "type": "integer",
6670 "format": "int64"
6671 },
6672 "counts": {
6673 "$ref": "#/components/schemas/CountsDto"
6674 },
6675 "generated_at": {
6676 "type": "string",
6677 "format": "date-time"
6678 },
6679 "queues": {
6680 "type": "array",
6681 "items": {
6682 "$ref": "#/components/schemas/QueueSnapshotDto"
6683 }
6684 },
6685 "recent_audit": {
6686 "type": "array",
6687 "items": {
6688 "$ref": "#/components/schemas/AuditLogDto"
6689 }
6690 },
6691 "recent_failures": {
6692 "type": "array",
6693 "items": {
6694 "$ref": "#/components/schemas/JobLogDto"
6695 }
6696 },
6697 "restricted_users": {
6698 "type": "integer",
6699 "format": "int64"
6700 },
6701 "trend": {
6702 "type": "array",
6703 "items": {
6704 "$ref": "#/components/schemas/TrendPointDto"
6705 }
6706 }
6707 }
6708 },
6709 "error": {
6710 "type": [
6711 "string",
6712 "null"
6713 ]
6714 },
6715 "error_code": {
6716 "type": [
6717 "string",
6718 "null"
6719 ]
6720 },
6721 "log_id": {
6722 "type": "string"
6723 },
6724 "message": {
6725 "type": "string"
6726 },
6727 "request_id": {
6728 "type": "string"
6729 }
6730 }
6731 },
6732 "AppResponseData_DeleteChatAttachmentResp": {
6733 "type": "object",
6734 "required": [
6735 "code",
6736 "message",
6737 "request_id",
6738 "log_id"
6739 ],
6740 "properties": {
6741 "code": {
6742 "type": "integer",
6743 "format": "int32"
6744 },
6745 "data": {
6746 "type": "object",
6747 "required": [
6748 "id",
6749 "s3_key",
6750 "deleted",
6751 "storage_deleted"
6752 ],
6753 "properties": {
6754 "deleted": {
6755 "type": "boolean"
6756 },
6757 "id": {
6758 "type": "string",
6759 "format": "uuid"
6760 },
6761 "s3_key": {
6762 "type": "string"
6763 },
6764 "storage_deleted": {
6765 "type": "boolean"
6766 }
6767 }
6768 },
6769 "error": {
6770 "type": [
6771 "string",
6772 "null"
6773 ]
6774 },
6775 "error_code": {
6776 "type": [
6777 "string",
6778 "null"
6779 ]
6780 },
6781 "log_id": {
6782 "type": "string"
6783 },
6784 "message": {
6785 "type": "string"
6786 },
6787 "request_id": {
6788 "type": "string"
6789 }
6790 }
6791 },
6792 "AppResponseData_DeleteChatRoomResp": {
6793 "type": "object",
6794 "required": [
6795 "code",
6796 "message",
6797 "request_id",
6798 "log_id"
6799 ],
6800 "properties": {
6801 "code": {
6802 "type": "integer",
6803 "format": "int32"
6804 },
6805 "data": {
6806 "type": "object",
6807 "required": [
6808 "room_id",
6809 "messages_deleted",
6810 "members_deleted",
6811 "attachments_deleted",
6812 "threads_deleted"
6813 ],
6814 "properties": {
6815 "attachments_deleted": {
6816 "type": "integer",
6817 "format": "int64",
6818 "minimum": 0
6819 },
6820 "members_deleted": {
6821 "type": "integer",
6822 "format": "int64",
6823 "minimum": 0
6824 },
6825 "messages_deleted": {
6826 "type": "integer",
6827 "format": "int64",
6828 "minimum": 0
6829 },
6830 "room_id": {
6831 "type": "string",
6832 "format": "uuid"
6833 },
6834 "threads_deleted": {
6835 "type": "integer",
6836 "format": "int64",
6837 "minimum": 0
6838 }
6839 }
6840 },
6841 "error": {
6842 "type": [
6843 "string",
6844 "null"
6845 ]
6846 },
6847 "error_code": {
6848 "type": [
6849 "string",
6850 "null"
6851 ]
6852 },
6853 "log_id": {
6854 "type": "string"
6855 },
6856 "message": {
6857 "type": "string"
6858 },
6859 "request_id": {
6860 "type": "string"
6861 }
6862 }
6863 },
6864 "AppResponseData_DeleteObjectResp": {
6865 "type": "object",
6866 "required": [
6867 "code",
6868 "message",
6869 "request_id",
6870 "log_id"
6871 ],
6872 "properties": {
6873 "code": {
6874 "type": "integer",
6875 "format": "int32"
6876 },
6877 "data": {
6878 "type": "object",
6879 "required": [
6880 "key",
6881 "deleted"
6882 ],
6883 "properties": {
6884 "deleted": {
6885 "type": "boolean"
6886 },
6887 "key": {
6888 "type": "string"
6889 }
6890 }
6891 },
6892 "error": {
6893 "type": [
6894 "string",
6895 "null"
6896 ]
6897 },
6898 "error_code": {
6899 "type": [
6900 "string",
6901 "null"
6902 ]
6903 },
6904 "log_id": {
6905 "type": "string"
6906 },
6907 "message": {
6908 "type": "string"
6909 },
6910 "request_id": {
6911 "type": "string"
6912 }
6913 }
6914 },
6915 "AppResponseData_DeleteUserResp": {
6916 "type": "object",
6917 "required": [
6918 "code",
6919 "message",
6920 "request_id",
6921 "log_id"
6922 ],
6923 "properties": {
6924 "code": {
6925 "type": "integer",
6926 "format": "int32"
6927 },
6928 "data": {
6929 "type": "object",
6930 "required": [
6931 "user_id",
6932 "anonymized"
6933 ],
6934 "properties": {
6935 "anonymized": {
6936 "type": "boolean"
6937 },
6938 "user_id": {
6939 "type": "string",
6940 "format": "uuid"
6941 }
6942 }
6943 },
6944 "error": {
6945 "type": [
6946 "string",
6947 "null"
6948 ]
6949 },
6950 "error_code": {
6951 "type": [
6952 "string",
6953 "null"
6954 ]
6955 },
6956 "log_id": {
6957 "type": "string"
6958 },
6959 "message": {
6960 "type": "string"
6961 },
6962 "request_id": {
6963 "type": "string"
6964 }
6965 }
6966 },
6967 "AppResponseData_DownloadUrlDto": {
6968 "type": "object",
6969 "required": [
6970 "code",
6971 "message",
6972 "request_id",
6973 "log_id"
6974 ],
6975 "properties": {
6976 "code": {
6977 "type": "integer",
6978 "format": "int32"
6979 },
6980 "data": {
6981 "type": "object",
6982 "required": [
6983 "key",
6984 "url",
6985 "expires_in_secs"
6986 ],
6987 "properties": {
6988 "expires_in_secs": {
6989 "type": "integer",
6990 "format": "int32",
6991 "minimum": 0
6992 },
6993 "key": {
6994 "type": "string"
6995 },
6996 "url": {
6997 "type": "string"
6998 }
6999 }
7000 },
7001 "error": {
7002 "type": [
7003 "string",
7004 "null"
7005 ]
7006 },
7007 "error_code": {
7008 "type": [
7009 "string",
7010 "null"
7011 ]
7012 },
7013 "log_id": {
7014 "type": "string"
7015 },
7016 "message": {
7017 "type": "string"
7018 },
7019 "request_id": {
7020 "type": "string"
7021 }
7022 }
7023 },
7024 "AppResponseData_JobLogPageDto": {
7025 "type": "object",
7026 "required": [
7027 "code",
7028 "message",
7029 "request_id",
7030 "log_id"
7031 ],
7032 "properties": {
7033 "code": {
7034 "type": "integer",
7035 "format": "int32"
7036 },
7037 "data": {
7038 "type": "object",
7039 "required": [
7040 "items",
7041 "total",
7042 "limit",
7043 "offset"
7044 ],
7045 "properties": {
7046 "items": {
7047 "type": "array",
7048 "items": {
7049 "$ref": "#/components/schemas/JobLogDto"
7050 }
7051 },
7052 "limit": {
7053 "type": "integer",
7054 "format": "int64",
7055 "minimum": 0
7056 },
7057 "offset": {
7058 "type": "integer",
7059 "format": "int64",
7060 "minimum": 0
7061 },
7062 "total": {
7063 "type": "integer",
7064 "format": "int64"
7065 }
7066 }
7067 },
7068 "error": {
7069 "type": [
7070 "string",
7071 "null"
7072 ]
7073 },
7074 "error_code": {
7075 "type": [
7076 "string",
7077 "null"
7078 ]
7079 },
7080 "log_id": {
7081 "type": "string"
7082 },
7083 "message": {
7084 "type": "string"
7085 },
7086 "request_id": {
7087 "type": "string"
7088 }
7089 }
7090 },
7091 "AppResponseData_NoData": {
7092 "type": "object",
7093 "required": [
7094 "code",
7095 "message",
7096 "request_id",
7097 "log_id"
7098 ],
7099 "properties": {
7100 "code": {
7101 "type": "integer",
7102 "format": "int32"
7103 },
7104 "data": {
7105 "default": null
7106 },
7107 "error": {
7108 "type": [
7109 "string",
7110 "null"
7111 ]
7112 },
7113 "error_code": {
7114 "type": [
7115 "string",
7116 "null"
7117 ]
7118 },
7119 "log_id": {
7120 "type": "string"
7121 },
7122 "message": {
7123 "type": "string"
7124 },
7125 "request_id": {
7126 "type": "string"
7127 }
7128 }
7129 },
7130 "AppResponseData_Page_AdminChatAttachmentDto": {
7131 "type": "object",
7132 "required": [
7133 "code",
7134 "message",
7135 "request_id",
7136 "log_id"
7137 ],
7138 "properties": {
7139 "code": {
7140 "type": "integer",
7141 "format": "int32"
7142 },
7143 "data": {
7144 "type": "object",
7145 "required": [
7146 "items",
7147 "total",
7148 "limit",
7149 "offset"
7150 ],
7151 "properties": {
7152 "items": {
7153 "type": "array",
7154 "items": {
7155 "type": "object",
7156 "required": [
7157 "id",
7158 "user_id",
7159 "filename",
7160 "content_type",
7161 "size",
7162 "bucket",
7163 "s3_key",
7164 "status",
7165 "created_at"
7166 ],
7167 "properties": {
7168 "bucket": {
7169 "type": "string"
7170 },
7171 "content_type": {
7172 "type": "string"
7173 },
7174 "created_at": {
7175 "type": "string",
7176 "format": "date-time"
7177 },
7178 "description": {
7179 "type": [
7180 "string",
7181 "null"
7182 ]
7183 },
7184 "display_name": {
7185 "type": [
7186 "string",
7187 "null"
7188 ]
7189 },
7190 "filename": {
7191 "type": "string"
7192 },
7193 "id": {
7194 "type": "string",
7195 "format": "uuid"
7196 },
7197 "message_id": {
7198 "type": [
7199 "string",
7200 "null"
7201 ],
7202 "format": "uuid"
7203 },
7204 "room_id": {
7205 "type": [
7206 "string",
7207 "null"
7208 ],
7209 "format": "uuid"
7210 },
7211 "s3_key": {
7212 "type": "string"
7213 },
7214 "size": {
7215 "type": "integer",
7216 "format": "int64"
7217 },
7218 "status": {
7219 "type": "string"
7220 },
7221 "user_id": {
7222 "type": "string",
7223 "format": "uuid"
7224 },
7225 "username": {
7226 "type": [
7227 "string",
7228 "null"
7229 ]
7230 }
7231 }
7232 }
7233 },
7234 "limit": {
7235 "type": "integer",
7236 "format": "int64",
7237 "minimum": 0
7238 },
7239 "offset": {
7240 "type": "integer",
7241 "format": "int64",
7242 "minimum": 0
7243 },
7244 "total": {
7245 "type": "integer",
7246 "format": "int64"
7247 }
7248 }
7249 },
7250 "error": {
7251 "type": [
7252 "string",
7253 "null"
7254 ]
7255 },
7256 "error_code": {
7257 "type": [
7258 "string",
7259 "null"
7260 ]
7261 },
7262 "log_id": {
7263 "type": "string"
7264 },
7265 "message": {
7266 "type": "string"
7267 },
7268 "request_id": {
7269 "type": "string"
7270 }
7271 }
7272 },
7273 "AppResponseData_Page_AdminChatMessageDto": {
7274 "type": "object",
7275 "required": [
7276 "code",
7277 "message",
7278 "request_id",
7279 "log_id"
7280 ],
7281 "properties": {
7282 "code": {
7283 "type": "integer",
7284 "format": "int32"
7285 },
7286 "data": {
7287 "type": "object",
7288 "required": [
7289 "items",
7290 "total",
7291 "limit",
7292 "offset"
7293 ],
7294 "properties": {
7295 "items": {
7296 "type": "array",
7297 "items": {
7298 "type": "object",
7299 "required": [
7300 "id",
7301 "room_id",
7302 "author_id",
7303 "author_type",
7304 "message_type",
7305 "content",
7306 "seq",
7307 "flags",
7308 "pinned",
7309 "attachment_count",
7310 "reaction_count",
7311 "created_at"
7312 ],
7313 "properties": {
7314 "attachment_count": {
7315 "type": "integer",
7316 "format": "int64"
7317 },
7318 "author_display_name": {
7319 "type": [
7320 "string",
7321 "null"
7322 ]
7323 },
7324 "author_id": {
7325 "type": "string",
7326 "format": "uuid"
7327 },
7328 "author_type": {
7329 "type": "string"
7330 },
7331 "author_username": {
7332 "type": [
7333 "string",
7334 "null"
7335 ]
7336 },
7337 "content": {
7338 "type": "string"
7339 },
7340 "created_at": {
7341 "type": "string",
7342 "format": "date-time"
7343 },
7344 "deleted_at": {
7345 "type": [
7346 "string",
7347 "null"
7348 ],
7349 "format": "date-time"
7350 },
7351 "edited_at": {
7352 "type": [
7353 "string",
7354 "null"
7355 ],
7356 "format": "date-time"
7357 },
7358 "flags": {
7359 "type": "integer",
7360 "format": "int64"
7361 },
7362 "id": {
7363 "type": "string",
7364 "format": "uuid"
7365 },
7366 "message_type": {
7367 "type": "string"
7368 },
7369 "pinned": {
7370 "type": "boolean"
7371 },
7372 "reaction_count": {
7373 "type": "integer",
7374 "format": "int64"
7375 },
7376 "reply_to_id": {
7377 "type": [
7378 "string",
7379 "null"
7380 ],
7381 "format": "uuid"
7382 },
7383 "room_id": {
7384 "type": "string",
7385 "format": "uuid"
7386 },
7387 "room_name": {
7388 "type": [
7389 "string",
7390 "null"
7391 ]
7392 },
7393 "seq": {
7394 "type": "integer",
7395 "format": "int64"
7396 },
7397 "thread_id": {
7398 "type": [
7399 "string",
7400 "null"
7401 ],
7402 "format": "uuid"
7403 }
7404 }
7405 }
7406 },
7407 "limit": {
7408 "type": "integer",
7409 "format": "int64",
7410 "minimum": 0
7411 },
7412 "offset": {
7413 "type": "integer",
7414 "format": "int64",
7415 "minimum": 0
7416 },
7417 "total": {
7418 "type": "integer",
7419 "format": "int64"
7420 }
7421 }
7422 },
7423 "error": {
7424 "type": [
7425 "string",
7426 "null"
7427 ]
7428 },
7429 "error_code": {
7430 "type": [
7431 "string",
7432 "null"
7433 ]
7434 },
7435 "log_id": {
7436 "type": "string"
7437 },
7438 "message": {
7439 "type": "string"
7440 },
7441 "request_id": {
7442 "type": "string"
7443 }
7444 }
7445 },
7446 "AppResponseData_Page_AdminChatRoomMemberDto": {
7447 "type": "object",
7448 "required": [
7449 "code",
7450 "message",
7451 "request_id",
7452 "log_id"
7453 ],
7454 "properties": {
7455 "code": {
7456 "type": "integer",
7457 "format": "int32"
7458 },
7459 "data": {
7460 "type": "object",
7461 "required": [
7462 "items",
7463 "total",
7464 "limit",
7465 "offset"
7466 ],
7467 "properties": {
7468 "items": {
7469 "type": "array",
7470 "items": {
7471 "type": "object",
7472 "required": [
7473 "user_id",
7474 "role",
7475 "joined_at"
7476 ],
7477 "properties": {
7478 "display_name": {
7479 "type": [
7480 "string",
7481 "null"
7482 ]
7483 },
7484 "joined_at": {
7485 "type": "string",
7486 "format": "date-time"
7487 },
7488 "left_at": {
7489 "type": [
7490 "string",
7491 "null"
7492 ],
7493 "format": "date-time"
7494 },
7495 "nick": {
7496 "type": [
7497 "string",
7498 "null"
7499 ]
7500 },
7501 "role": {
7502 "type": "string"
7503 },
7504 "user_id": {
7505 "type": "string",
7506 "format": "uuid"
7507 },
7508 "username": {
7509 "type": [
7510 "string",
7511 "null"
7512 ]
7513 }
7514 }
7515 }
7516 },
7517 "limit": {
7518 "type": "integer",
7519 "format": "int64",
7520 "minimum": 0
7521 },
7522 "offset": {
7523 "type": "integer",
7524 "format": "int64",
7525 "minimum": 0
7526 },
7527 "total": {
7528 "type": "integer",
7529 "format": "int64"
7530 }
7531 }
23547532 },
2355 "must_change_password": {
2356 "type": "boolean"
2357 }
2358 }
2359 },
2360 "AdminRole": {
2361 "type": "string",
2362 "enum": [
2363 "super_admin",
2364 "operator",
2365 "viewer"
2366 ]
2367 },
2368 "AdminSpaceDetail": {
2369 "type": "object",
2370 "required": [
2371 "space",
2372 "repos_count",
2373 "repos_size",
2374 "rooms_count",
2375 "dirs_count"
2376 ],
2377 "properties": {
2378 "dirs_count": {
2379 "type": "integer",
2380 "format": "int64"
2381 },
2382 "repos_count": {
2383 "type": "integer",
2384 "format": "int64"
2385 },
2386 "repos_size": {
2387 "type": "integer",
2388 "format": "int64"
2389 },
2390 "rooms_count": {
2391 "type": "integer",
2392 "format": "int64"
2393 },
2394 "space": {
2395 "$ref": "#/components/schemas/AdminSpaceSummary"
2396 }
2397 }
2398 },
2399 "AdminSpaceMemberDto": {
2400 "type": "object",
2401 "required": [
2402 "user_id",
2403 "status",
2404 "joined_at"
2405 ],
2406 "properties": {
2407 "alias_name": {
7533 "error": {
24087534 "type": [
24097535 "string",
24107536 "null"
24117537 ]
24127538 },
2413 "display_name": {
7539 "error_code": {
24147540 "type": [
24157541 "string",
24167542 "null"
24177543 ]
24187544 },
2419 "joined_at": {
2420 "type": "string",
2421 "format": "date-time"
7545 "log_id": {
7546 "type": "string"
24227547 },
2423 "last_active_at": {
2424 "type": [
2425 "string",
2426 "null"
2427 ],
2428 "format": "date-time"
7548 "message": {
7549 "type": "string"
24297550 },
2430 "status": {
7551 "request_id": {
24317552 "type": "string"
2432 },
2433 "user_id": {
2434 "type": "string",
2435 "format": "uuid"
2436 },
2437 "username": {
2438 "type": [
2439 "string",
2440 "null"
2441 ]
24427553 }
24437554 }
24447555 },
2445 "AdminSpaceSummary": {
7556 "AppResponseData_Page_AdminChatRoomSummary": {
24467557 "type": "object",
24477558 "required": [
2448 "id",
2449 "name",
2450 "lower_name",
2451 "owner_id",
2452 "is_private",
2453 "is_active",
2454 "nums_members",
2455 "created_at",
2456 "updated_at"
7559 "code",
7560 "message",
7561 "request_id",
7562 "log_id"
24577563 ],
24587564 "properties": {
2459 "created_at": {
2460 "type": "string",
2461 "format": "date-time"
7565 "code": {
7566 "type": "integer",
7567 "format": "int32"
24627568 },
2463 "description": {
2464 "type": [
2465 "string",
2466 "null"
2467 ]
2468 },
2469 "id": {
2470 "type": "string",
2471 "format": "uuid"
2472 },
2473 "is_active": {
2474 "type": "boolean"
2475 },
2476 "is_private": {
2477 "type": "boolean"
2478 },
2479 "lower_name": {
2480 "type": "string"
2481 },
2482 "name": {
2483 "type": "string"
2484 },
2485 "nums_members": {
2486 "type": "integer",
2487 "format": "int64"
7569 "data": {
7570 "type": "object",
7571 "required": [
7572 "items",
7573 "total",
7574 "limit",
7575 "offset"
7576 ],
7577 "properties": {
7578 "items": {
7579 "type": "array",
7580 "items": {
7581 "type": "object",
7582 "required": [
7583 "id",
7584 "kind",
7585 "name",
7586 "lower_name",
7587 "owner_id",
7588 "is_private",
7589 "nsfw",
7590 "is_active",
7591 "members",
7592 "messages",
7593 "created_at",
7594 "updated_at"
7595 ],
7596 "properties": {
7597 "created_at": {
7598 "type": "string",
7599 "format": "date-time"
7600 },
7601 "id": {
7602 "type": "string",
7603 "format": "uuid"
7604 },
7605 "is_active": {
7606 "type": "boolean"
7607 },
7608 "is_private": {
7609 "type": "boolean"
7610 },
7611 "kind": {
7612 "type": "string"
7613 },
7614 "last_message_at": {
7615 "type": [
7616 "string",
7617 "null"
7618 ],
7619 "format": "date-time"
7620 },
7621 "lower_name": {
7622 "type": "string"
7623 },
7624 "members": {
7625 "type": "integer",
7626 "format": "int64"
7627 },
7628 "messages": {
7629 "type": "integer",
7630 "format": "int64"
7631 },
7632 "name": {
7633 "type": "string"
7634 },
7635 "nsfw": {
7636 "type": "boolean"
7637 },
7638 "owner_id": {
7639 "type": "string",
7640 "format": "uuid"
7641 },
7642 "owner_username": {
7643 "type": [
7644 "string",
7645 "null"
7646 ]
7647 },
7648 "space_id": {
7649 "type": [
7650 "string",
7651 "null"
7652 ],
7653 "format": "uuid"
7654 },
7655 "space_name": {
7656 "type": [
7657 "string",
7658 "null"
7659 ]
7660 },
7661 "topic": {
7662 "type": [
7663 "string",
7664 "null"
7665 ]
7666 },
7667 "updated_at": {
7668 "type": "string",
7669 "format": "date-time"
7670 }
7671 }
7672 }
7673 },
7674 "limit": {
7675 "type": "integer",
7676 "format": "int64",
7677 "minimum": 0
7678 },
7679 "offset": {
7680 "type": "integer",
7681 "format": "int64",
7682 "minimum": 0
7683 },
7684 "total": {
7685 "type": "integer",
7686 "format": "int64"
7687 }
7688 }
24887689 },
2489 "owner_id": {
2490 "type": "string",
2491 "format": "uuid"
2492 },
2493 "owner_username": {
7690 "error": {
24947691 "type": [
24957692 "string",
24967693 "null"
24977694 ]
24987695 },
2499 "updated_at": {
2500 "type": "string",
2501 "format": "date-time"
2502 }
2503 }
2504 },
2505 "AdminSuspensionDto": {
2506 "type": "object",
2507 "required": [
2508 "id",
2509 "suspension_type",
2510 "starts_at"
2511 ],
2512 "properties": {
2513 "ends_at": {
2514 "type": [
2515 "string",
2516 "null"
2517 ],
2518 "format": "date-time"
2519 },
2520 "id": {
2521 "type": "string",
2522 "format": "uuid"
2523 },
2524 "reason": {
7696 "error_code": {
25257697 "type": [
25267698 "string",
25277699 "null"
25287700 ]
25297701 },
2530 "revoked_at": {
2531 "type": [
2532 "string",
2533 "null"
2534 ],
2535 "format": "date-time"
7702 "log_id": {
7703 "type": "string"
25367704 },
2537 "starts_at": {
2538 "type": "string",
2539 "format": "date-time"
7705 "message": {
7706 "type": "string"
25407707 },
2541 "suspended_by": {
2542 "type": [
2543 "string",
2544 "null"
2545 ],
2546 "format": "uuid"
2547 },
2548 "suspension_type": {
7708 "request_id": {
25497709 "type": "string"
25507710 }
25517711 }
25527712 },
2553 "AdminUserDetail": {
7713 "AppResponseData_Page_AdminDto": {
25547714 "type": "object",
25557715 "required": [
2556 "user",
2557 "emails",
2558 "sessions",
2559 "spaces",
2560 "suspensions"
7716 "code",
7717 "message",
7718 "request_id",
7719 "log_id"
25617720 ],
25627721 "properties": {
2563 "emails": {
2564 "type": "array",
2565 "items": {
2566 "$ref": "#/components/schemas/AdminUserEmailDto"
2567 }
7722 "code": {
7723 "type": "integer",
7724 "format": "int32"
25687725 },
2569 "sessions": {
2570 "type": "array",
2571 "items": {
2572 "$ref": "#/components/schemas/AdminUserSessionDto"
7726 "data": {
7727 "type": "object",
7728 "required": [
7729 "items",
7730 "total",
7731 "limit",
7732 "offset"
7733 ],
7734 "properties": {
7735 "items": {
7736 "type": "array",
7737 "items": {
7738 "type": "object",
7739 "required": [
7740 "id",
7741 "username",
7742 "display_name",
7743 "role",
7744 "is_active",
7745 "must_change_password",
7746 "created_at",
7747 "updated_at"
7748 ],
7749 "properties": {
7750 "created_at": {
7751 "type": "string",
7752 "format": "date-time"
7753 },
7754 "display_name": {
7755 "type": "string"
7756 },
7757 "id": {
7758 "type": "string",
7759 "format": "uuid"
7760 },
7761 "is_active": {
7762 "type": "boolean"
7763 },
7764 "last_login_at": {
7765 "type": [
7766 "string",
7767 "null"
7768 ],
7769 "format": "date-time"
7770 },
7771 "last_login_ip": {
7772 "type": [
7773 "string",
7774 "null"
7775 ]
7776 },
7777 "must_change_password": {
7778 "type": "boolean"
7779 },
7780 "role": {
7781 "$ref": "#/components/schemas/AdminRole"
7782 },
7783 "updated_at": {
7784 "type": "string",
7785 "format": "date-time"
7786 },
7787 "username": {
7788 "type": "string"
7789 }
7790 }
7791 }
7792 },
7793 "limit": {
7794 "type": "integer",
7795 "format": "int64",
7796 "minimum": 0
7797 },
7798 "offset": {
7799 "type": "integer",
7800 "format": "int64",
7801 "minimum": 0
7802 },
7803 "total": {
7804 "type": "integer",
7805 "format": "int64"
7806 }
25737807 }
25747808 },
2575 "spaces": {
2576 "type": "array",
2577 "items": {
2578 "$ref": "#/components/schemas/AdminUserSpaceDto"
2579 }
7809 "error": {
7810 "type": [
7811 "string",
7812 "null"
7813 ]
25807814 },
2581 "suspensions": {
2582 "type": "array",
2583 "items": {
2584 "$ref": "#/components/schemas/AdminSuspensionDto"
2585 }
2586 },
2587 "user": {
2588 "$ref": "#/components/schemas/AdminUserSummary"
2589 }
2590 }
2591 },
2592 "AdminUserEmailDto": {
2593 "type": "object",
2594 "required": [
2595 "id",
2596 "email",
2597 "is_primary",
2598 "is_activated",
2599 "created_at"
2600 ],
2601 "properties": {
2602 "created_at": {
2603 "type": "string",
2604 "format": "date-time"
2605 },
2606 "email": {
2607 "type": "string"
2608 },
2609 "id": {
2610 "type": "string",
2611 "format": "uuid"
2612 },
2613 "is_activated": {
2614 "type": "boolean"
2615 },
2616 "is_primary": {
2617 "type": "boolean"
2618 }
2619 }
2620 },
2621 "AdminUserSessionDto": {
2622 "type": "object",
2623 "required": [
2624 "id",
2625 "kind",
2626 "last_seen_at",
2627 "created_at",
2628 "expires_at",
2629 "is_revoked"
2630 ],
2631 "properties": {
2632 "created_at": {
2633 "type": "string",
2634 "format": "date-time"
2635 },
2636 "expires_at": {
2637 "type": "string",
2638 "format": "date-time"
2639 },
2640 "id": {
2641 "type": "string",
2642 "format": "uuid"
2643 },
2644 "is_revoked": {
2645 "type": "boolean"
2646 },
2647 "kind": {
2648 "type": "string"
2649 },
2650 "last_seen_at": {
2651 "type": "string",
2652 "format": "date-time"
2653 }
2654 }
2655 },
2656 "AdminUserSpaceDto": {
2657 "type": "object",
2658 "required": [
2659 "id",
2660 "name",
2661 "lower_name",
2662 "is_active"
2663 ],
2664 "properties": {
2665 "id": {
2666 "type": "string",
2667 "format": "uuid"
7815 "error_code": {
7816 "type": [
7817 "string",
7818 "null"
7819 ]
26687820 },
2669 "is_active": {
2670 "type": "boolean"
7821 "log_id": {
7822 "type": "string"
26717823 },
2672 "lower_name": {
7824 "message": {
26737825 "type": "string"
26747826 },
2675 "name": {
7827 "request_id": {
26767828 "type": "string"
26777829 }
26787830 }
26797831 },
2680 "AdminUserSummary": {
7832 "AppResponseData_Page_AdminSpaceMemberDto": {
26817833 "type": "object",
26827834 "required": [
2683 "uid",
2684 "username",
2685 "display_name",
2686 "avatar_url",
2687 "is_active",
2688 "is_restricted",
2689 "prohibit_login",
2690 "bot",
2691 "system",
2692 "suspended",
2693 "created_at"
7835 "code",
7836 "message",
7837 "request_id",
7838 "log_id"
26947839 ],
26957840 "properties": {
2696 "avatar_url": {
2697 "type": "string"
7841 "code": {
7842 "type": "integer",
7843 "format": "int32"
26987844 },
2699 "bot": {
2700 "type": "boolean"
7845 "data": {
7846 "type": "object",
7847 "required": [
7848 "items",
7849 "total",
7850 "limit",
7851 "offset"
7852 ],
7853 "properties": {
7854 "items": {
7855 "type": "array",
7856 "items": {
7857 "type": "object",
7858 "required": [
7859 "user_id",
7860 "status",
7861 "joined_at"
7862 ],
7863 "properties": {
7864 "alias_name": {
7865 "type": [
7866 "string",
7867 "null"
7868 ]
7869 },
7870 "display_name": {
7871 "type": [
7872 "string",
7873 "null"
7874 ]
7875 },
7876 "joined_at": {
7877 "type": "string",
7878 "format": "date-time"
7879 },
7880 "last_active_at": {
7881 "type": [
7882 "string",
7883 "null"
7884 ],
7885 "format": "date-time"
7886 },
7887 "status": {
7888 "type": "string"
7889 },
7890 "user_id": {
7891 "type": "string",
7892 "format": "uuid"
7893 },
7894 "username": {
7895 "type": [
7896 "string",
7897 "null"
7898 ]
7899 }
7900 }
7901 }
7902 },
7903 "limit": {
7904 "type": "integer",
7905 "format": "int64",
7906 "minimum": 0
7907 },
7908 "offset": {
7909 "type": "integer",
7910 "format": "int64",
7911 "minimum": 0
7912 },
7913 "total": {
7914 "type": "integer",
7915 "format": "int64"
7916 }
7917 }
27017918 },
2702 "created_at": {
2703 "type": "string",
2704 "format": "date-time"
7919 "error": {
7920 "type": [
7921 "string",
7922 "null"
7923 ]
27057924 },
2706 "display_name": {
2707 "type": "string"
2708 },
2709 "is_active": {
2710 "type": "boolean"
2711 },
2712 "is_restricted": {
2713 "type": "boolean"
2714 },
2715 "last_login_at": {
7925 "error_code": {
27167926 "type": [
27177927 "string",
27187928 "null"
2719 ],
2720 "format": "date-time"
7929 ]
27217930 },
2722 "prohibit_login": {
2723 "type": "boolean"
7931 "log_id": {
7932 "type": "string"
27247933 },
2725 "suspended": {
2726 "type": "boolean"
7934 "message": {
7935 "type": "string"
27277936 },
2728 "system": {
2729 "type": "boolean"
2730 },
2731 "uid": {
2732 "type": "string",
2733 "format": "uuid"
2734 },
2735 "username": {
7937 "request_id": {
27367938 "type": "string"
27377939 }
27387940 }
27397941 },
2740 "AppResponseData_AdminDto": {
7942 "AppResponseData_Page_AdminSpaceSummary": {
27417943 "type": "object",
27427944 "required": [
27437945 "code",
@@ -2753,55 +7955,100 @@
27537955 "data": {
27547956 "type": "object",
27557957 "required": [
2756 "id",
2757 "username",
2758 "display_name",
2759 "role",
2760 "is_active",
2761 "must_change_password",
2762 "created_at",
2763 "updated_at"
7958 "items",
7959 "total",
7960 "limit",
7961 "offset"
27647962 ],
27657963 "properties": {
2766 "created_at": {
2767 "type": "string",
2768 "format": "date-time"
2769 },
2770 "display_name": {
2771 "type": "string"
2772 },
2773 "id": {
2774 "type": "string",
2775 "format": "uuid"
2776 },
2777 "is_active": {
2778 "type": "boolean"
2779 },
2780 "last_login_at": {
2781 "type": [
2782 "string",
2783 "null"
2784 ],
2785 "format": "date-time"
7964 "items": {
7965 "type": "array",
7966 "items": {
7967 "type": "object",
7968 "required": [
7969 "id",
7970 "name",
7971 "lower_name",
7972 "owner_id",
7973 "is_private",
7974 "is_active",
7975 "allow_join",
7976 "allow_invite",
7977 "member_must_enable_2fa",
7978 "nums_members",
7979 "created_at",
7980 "updated_at"
7981 ],
7982 "properties": {
7983 "allow_invite": {
7984 "type": "boolean"
7985 },
7986 "allow_join": {
7987 "type": "boolean"
7988 },
7989 "created_at": {
7990 "type": "string",
7991 "format": "date-time"
7992 },
7993 "description": {
7994 "type": [
7995 "string",
7996 "null"
7997 ]
7998 },
7999 "id": {
8000 "type": "string",
8001 "format": "uuid"
8002 },
8003 "is_active": {
8004 "type": "boolean"
8005 },
8006 "is_private": {
8007 "type": "boolean"
8008 },
8009 "lower_name": {
8010 "type": "string"
8011 },
8012 "member_must_enable_2fa": {
8013 "type": "boolean"
8014 },
8015 "name": {
8016 "type": "string"
8017 },
8018 "nums_members": {
8019 "type": "integer",
8020 "format": "int64"
8021 },
8022 "owner_id": {
8023 "type": "string",
8024 "format": "uuid"
8025 },
8026 "owner_username": {
8027 "type": [
8028 "string",
8029 "null"
8030 ]
8031 },
8032 "updated_at": {
8033 "type": "string",
8034 "format": "date-time"
8035 }
8036 }
8037 }
27868038 },
2787 "last_login_ip": {
2788 "type": [
2789 "string",
2790 "null"
2791 ]
8039 "limit": {
8040 "type": "integer",
8041 "format": "int64",
8042 "minimum": 0
27928043 },
2793 "must_change_password": {
2794 "type": "boolean"
8044 "offset": {
8045 "type": "integer",
8046 "format": "int64",
8047 "minimum": 0
27958048 },
2796 "role": {
2797 "$ref": "#/components/schemas/AdminRole"
2798 },
2799 "updated_at": {
2800 "type": "string",
2801 "format": "date-time"
2802 },
2803 "username": {
2804 "type": "string"
8049 "total": {
8050 "type": "integer",
8051 "format": "int64"
28058052 }
28068053 }
28078054 },
@@ -2828,7 +8075,7 @@
28288075 }
28298076 }
28308077 },
2831 "AppResponseData_AdminLoginResp": {
8078 "AppResponseData_Page_AdminUserSummary": {
28328079 "type": "object",
28338080 "required": [
28348081 "code",
@@ -2844,82 +8091,88 @@
28448091 "data": {
28458092 "type": "object",
28468093 "required": [
2847 "admin",
2848 "must_change_password"
8094 "items",
8095 "total",
8096 "limit",
8097 "offset"
28498098 ],
28508099 "properties": {
2851 "admin": {
2852 "$ref": "#/components/schemas/AdminDto"
8100 "items": {
8101 "type": "array",
8102 "items": {
8103 "type": "object",
8104 "required": [
8105 "uid",
8106 "username",
8107 "display_name",
8108 "avatar_url",
8109 "is_active",
8110 "is_restricted",
8111 "prohibit_login",
8112 "bot",
8113 "system",
8114 "suspended",
8115 "created_at"
8116 ],
8117 "properties": {
8118 "avatar_url": {
8119 "type": "string"
8120 },
8121 "bot": {
8122 "type": "boolean"
8123 },
8124 "created_at": {
8125 "type": "string",
8126 "format": "date-time"
8127 },
8128 "display_name": {
8129 "type": "string"
8130 },
8131 "is_active": {
8132 "type": "boolean"
8133 },
8134 "is_restricted": {
8135 "type": "boolean"
8136 },
8137 "last_login_at": {
8138 "type": [
8139 "string",
8140 "null"
8141 ],
8142 "format": "date-time"
8143 },
8144 "prohibit_login": {
8145 "type": "boolean"
8146 },
8147 "suspended": {
8148 "type": "boolean"
8149 },
8150 "system": {
8151 "type": "boolean"
8152 },
8153 "uid": {
8154 "type": "string",
8155 "format": "uuid"
8156 },
8157 "username": {
8158 "type": "string"
8159 }
8160 }
8161 }
28538162 },
2854 "must_change_password": {
2855 "type": "boolean"
2856 }
2857 }
2858 },
2859 "error": {
2860 "type": [
2861 "string",
2862 "null"
2863 ]
2864 },
2865 "error_code": {
2866 "type": [
2867 "string",
2868 "null"
2869 ]
2870 },
2871 "log_id": {
2872 "type": "string"
2873 },
2874 "message": {
2875 "type": "string"
2876 },
2877 "request_id": {
2878 "type": "string"
2879 }
2880 }
2881 },
2882 "AppResponseData_AdminSpaceDetail": {
2883 "type": "object",
2884 "required": [
2885 "code",
2886 "message",
2887 "request_id",
2888 "log_id"
2889 ],
2890 "properties": {
2891 "code": {
2892 "type": "integer",
2893 "format": "int32"
2894 },
2895 "data": {
2896 "type": "object",
2897 "required": [
2898 "space",
2899 "repos_count",
2900 "repos_size",
2901 "rooms_count",
2902 "dirs_count"
2903 ],
2904 "properties": {
2905 "dirs_count": {
8163 "limit": {
29068164 "type": "integer",
2907 "format": "int64"
8165 "format": "int64",
8166 "minimum": 0
29088167 },
2909 "repos_count": {
8168 "offset": {
29108169 "type": "integer",
2911 "format": "int64"
2912 },
2913 "repos_size": {
2914 "type": "integer",
2915 "format": "int64"
8170 "format": "int64",
8171 "minimum": 0
29168172 },
2917 "rooms_count": {
8173 "total": {
29188174 "type": "integer",
29198175 "format": "int64"
2920 },
2921 "space": {
2922 "$ref": "#/components/schemas/AdminSpaceSummary"
29238176 }
29248177 }
29258178 },
@@ -2946,7 +8199,7 @@
29468199 }
29478200 }
29488201 },
2949 "AppResponseData_AdminSuspensionDto": {
8202 "AppResponseData_PurgeChatMessagesResp": {
29508203 "type": "object",
29518204 "required": [
29528205 "code",
@@ -2962,48 +8215,30 @@
29628215 "data": {
29638216 "type": "object",
29648217 "required": [
2965 "id",
2966 "suspension_type",
2967 "starts_at"
8218 "room_id",
8219 "messages_deleted",
8220 "attachments_deleted",
8221 "threads_deleted"
29688222 ],
29698223 "properties": {
2970 "ends_at": {
2971 "type": [
2972 "string",
2973 "null"
2974 ],
2975 "format": "date-time"
8224 "attachments_deleted": {
8225 "type": "integer",
8226 "format": "int64",
8227 "minimum": 0
29768228 },
2977 "id": {
2978 "type": "string",
2979 "format": "uuid"
8229 "messages_deleted": {
8230 "type": "integer",
8231 "format": "int64",
8232 "minimum": 0
29808233 },
2981 "reason": {
2982 "type": [
2983 "string",
2984 "null"
2985 ]
2986 },
2987 "revoked_at": {
2988 "type": [
2989 "string",
2990 "null"
2991 ],
2992 "format": "date-time"
2993 },
2994 "starts_at": {
8234 "room_id": {
29958235 "type": "string",
2996 "format": "date-time"
2997 },
2998 "suspended_by": {
2999 "type": [
3000 "string",
3001 "null"
3002 ],
30038236 "format": "uuid"
30048237 },
3005 "suspension_type": {
3006 "type": "string"
8238 "threads_deleted": {
8239 "type": "integer",
8240 "format": "int64",
8241 "minimum": 0
30078242 }
30088243 }
30098244 },
@@ -3030,7 +8265,7 @@
30308265 }
30318266 }
30328267 },
3033 "AppResponseData_AdminUserDetail": {
8268 "AppResponseData_QueueActionResp": {
30348269 "type": "object",
30358270 "required": [
30368271 "code",
@@ -3046,39 +8281,17 @@
30468281 "data": {
30478282 "type": "object",
30488283 "required": [
3049 "user",
3050 "emails",
3051 "sessions",
3052 "spaces",
3053 "suspensions"
8284 "queue",
8285 "affected"
30548286 ],
30558287 "properties": {
3056 "emails": {
3057 "type": "array",
3058 "items": {
3059 "$ref": "#/components/schemas/AdminUserEmailDto"
3060 }
3061 },
3062 "sessions": {
3063 "type": "array",
3064 "items": {
3065 "$ref": "#/components/schemas/AdminUserSessionDto"
3066 }
3067 },
3068 "spaces": {
3069 "type": "array",
3070 "items": {
3071 "$ref": "#/components/schemas/AdminUserSpaceDto"
3072 }
8288 "affected": {
8289 "type": "integer",
8290 "format": "int64",
8291 "minimum": 0
30738292 },
3074 "suspensions": {
3075 "type": "array",
3076 "items": {
3077 "$ref": "#/components/schemas/AdminSuspensionDto"
3078 }
3079 },
3080 "user": {
3081 "$ref": "#/components/schemas/AdminUserSummary"
8293 "queue": {
8294 "type": "string"
30828295 }
30838296 }
30848297 },
@@ -3105,7 +8318,7 @@
31058318 }
31068319 }
31078320 },
3108 "AppResponseData_AdminUserSummary": {
8321 "AppResponseData_RemoveChatMemberResp": {
31098322 "type": "object",
31108323 "required": [
31118324 "code",
@@ -3121,60 +8334,21 @@
31218334 "data": {
31228335 "type": "object",
31238336 "required": [
3124 "uid",
3125 "username",
3126 "display_name",
3127 "avatar_url",
3128 "is_active",
3129 "is_restricted",
3130 "prohibit_login",
3131 "bot",
3132 "system",
3133 "suspended",
3134 "created_at"
8337 "room_id",
8338 "user_id",
8339 "removed"
31358340 ],
31368341 "properties": {
3137 "avatar_url": {
3138 "type": "string"
3139 },
3140 "bot": {
8342 "removed": {
31418343 "type": "boolean"
31428344 },
3143 "created_at": {
8345 "room_id": {
31448346 "type": "string",
3145 "format": "date-time"
8347 "format": "uuid"
31468348 },
3147 "display_name": {
3148 "type": "string"
3149 },
3150 "is_active": {
3151 "type": "boolean"
3152 },
3153 "is_restricted": {
3154 "type": "boolean"
3155 },
3156 "last_login_at": {
3157 "type": [
3158 "string",
3159 "null"
3160 ],
3161 "format": "date-time"
3162 },
3163 "prohibit_login": {
3164 "type": "boolean"
3165 },
3166 "suspended": {
3167 "type": "boolean"
3168 },
3169 "system": {
3170 "type": "boolean"
3171 },
3172 "uid": {
8349 "user_id": {
31738350 "type": "string",
31748351 "format": "uuid"
3175 },
3176 "username": {
3177 "type": "string"
31788352 }
31798353 }
31808354 },
@@ -3201,7 +8375,7 @@
32018375 }
32028376 }
32038377 },
3204 "AppResponseData_AuditLogPageDto": {
8378 "AppResponseData_RemoveMemberResp": {
32058379 "type": "object",
32068380 "required": [
32078381 "code",
@@ -3217,31 +8391,21 @@
32178391 "data": {
32188392 "type": "object",
32198393 "required": [
3220 "items",
3221 "total",
3222 "limit",
3223 "offset"
8394 "space_id",
8395 "user_id",
8396 "removed"
32248397 ],
32258398 "properties": {
3226 "items": {
3227 "type": "array",
3228 "items": {
3229 "$ref": "#/components/schemas/AuditLogDto"
3230 }
8399 "removed": {
8400 "type": "boolean"
32318401 },
3232 "limit": {
3233 "type": "integer",
3234 "format": "int64",
3235 "minimum": 0
8402 "space_id": {
8403 "type": "string",
8404 "format": "uuid"
32368405 },
3237 "offset": {
3238 "type": "integer",
3239 "format": "int64",
3240 "minimum": 0
3241 },
3242 "total": {
3243 "type": "integer",
3244 "format": "int64"
8406 "user_id": {
8407 "type": "string",
8408 "format": "uuid"
32458409 }
32468410 }
32478411 },
@@ -3268,7 +8432,7 @@
32688432 }
32698433 }
32708434 },
3271 "AppResponseData_DeleteObjectResp": {
8435 "AppResponseData_RevokeSessionsResp": {
32728436 "type": "object",
32738437 "required": [
32748438 "code",
@@ -3284,15 +8448,13 @@
32848448 "data": {
32858449 "type": "object",
32868450 "required": [
3287 "key",
3288 "deleted"
8451 "revoked"
32898452 ],
32908453 "properties": {
3291 "deleted": {
3292 "type": "boolean"
3293 },
3294 "key": {
3295 "type": "string"
8454 "revoked": {
8455 "type": "integer",
8456 "format": "int64",
8457 "minimum": 0
32968458 }
32978459 }
32988460 },
@@ -3319,7 +8481,7 @@
33198481 }
33208482 }
33218483 },
3322 "AppResponseData_DeleteUserResp": {
8484 "AppResponseData_StorageObjectPageDto": {
33238485 "type": "object",
33248486 "required": [
33258487 "code",
@@ -3335,16 +8497,24 @@
33358497 "data": {
33368498 "type": "object",
33378499 "required": [
3338 "user_id",
3339 "anonymized"
8500 "prefix",
8501 "items"
33408502 ],
33418503 "properties": {
3342 "anonymized": {
3343 "type": "boolean"
8504 "items": {
8505 "type": "array",
8506 "items": {
8507 "$ref": "#/components/schemas/StorageObjectDto"
8508 }
8509 },
8510 "next_token": {
8511 "type": [
8512 "string",
8513 "null"
8514 ]
33448515 },
3345 "user_id": {
3346 "type": "string",
3347 "format": "uuid"
8516 "prefix": {
8517 "type": "string"
33488518 }
33498519 }
33508520 },
@@ -3371,7 +8541,7 @@
33718541 }
33728542 }
33738543 },
3374 "AppResponseData_DownloadUrlDto": {
8544 "AppResponseData_StorageOverviewDto": {
33758545 "type": "object",
33768546 "required": [
33778547 "code",
@@ -3387,21 +8557,39 @@
33878557 "data": {
33888558 "type": "object",
33898559 "required": [
3390 "key",
3391 "url",
3392 "expires_in_secs"
8560 "bucket",
8561 "total_bytes",
8562 "total_objects",
8563 "categories",
8564 "generated_at",
8565 "cached"
33938566 ],
33948567 "properties": {
3395 "expires_in_secs": {
8568 "bucket": {
8569 "type": "string"
8570 },
8571 "cached": {
8572 "type": "boolean"
8573 },
8574 "categories": {
8575 "type": "array",
8576 "items": {
8577 "$ref": "#/components/schemas/StorageCategoryDto"
8578 }
8579 },
8580 "generated_at": {
8581 "type": "string",
8582 "format": "date-time"
8583 },
8584 "total_bytes": {
33968585 "type": "integer",
3397 "format": "int32",
8586 "format": "int64",
33988587 "minimum": 0
33998588 },
3400 "key": {
3401 "type": "string"
3402 },
3403 "url": {
3404 "type": "string"
8589 "total_objects": {
8590 "type": "integer",
8591 "format": "int64",
8592 "minimum": 0
34058593 }
34068594 }
34078595 },
@@ -3428,7 +8616,7 @@
34288616 }
34298617 }
34308618 },
3431 "AppResponseData_JobLogPageDto": {
8619 "AppResponseData_SystemConfigDto": {
34328620 "type": "object",
34338621 "required": [
34348622 "code",
@@ -3444,72 +8632,65 @@
34448632 "data": {
34458633 "type": "object",
34468634 "required": [
3447 "items",
3448 "total",
3449 "limit",
3450 "offset"
8635 "version",
8636 "hosts",
8637 "admin",
8638 "features",
8639 "search",
8640 "import",
8641 "git",
8642 "storage",
8643 "database",
8644 "redis",
8645 "session",
8646 "email",
8647 "generated_at"
34518648 ],
34528649 "properties": {
3453 "items": {
8650 "admin": {
8651 "$ref": "#/components/schemas/AdminEndpointDto"
8652 },
8653 "database": {
8654 "$ref": "#/components/schemas/DatabaseConfigDto"
8655 },
8656 "email": {
8657 "$ref": "#/components/schemas/EmailConfigDto"
8658 },
8659 "features": {
8660 "$ref": "#/components/schemas/FeaturesDto"
8661 },
8662 "generated_at": {
8663 "type": "string",
8664 "format": "date-time"
8665 },
8666 "git": {
8667 "$ref": "#/components/schemas/GitConfigDto"
8668 },
8669 "hosts": {
34548670 "type": "array",
34558671 "items": {
3456 "$ref": "#/components/schemas/JobLogDto"
8672 "$ref": "#/components/schemas/HostEntryDto"
34578673 }
34588674 },
3459 "limit": {
3460 "type": "integer",
3461 "format": "int64",
3462 "minimum": 0
8675 "import": {
8676 "$ref": "#/components/schemas/ImportConfigDto"
8677 },
8678 "redis": {
8679 "$ref": "#/components/schemas/RedisConfigDto"
8680 },
8681 "search": {
8682 "$ref": "#/components/schemas/SearchConfigDto"
8683 },
8684 "session": {
8685 "$ref": "#/components/schemas/SessionConfigDto"
34638686 },
3464 "offset": {
3465 "type": "integer",
3466 "format": "int64",
3467 "minimum": 0
8687 "storage": {
8688 "$ref": "#/components/schemas/StorageConfigDto"
34688689 },
3469 "total": {
3470 "type": "integer",
3471 "format": "int64"
8690 "version": {
8691 "type": "string"
34728692 }
34738693 }
3474 },
3475 "error": {
3476 "type": [
3477 "string",
3478 "null"
3479 ]
3480 },
3481 "error_code": {
3482 "type": [
3483 "string",
3484 "null"
3485 ]
3486 },
3487 "log_id": {
3488 "type": "string"
3489 },
3490 "message": {
3491 "type": "string"
3492 },
3493 "request_id": {
3494 "type": "string"
3495 }
3496 }
3497 },
3498 "AppResponseData_NoData": {
3499 "type": "object",
3500 "required": [
3501 "code",
3502 "message",
3503 "request_id",
3504 "log_id"
3505 ],
3506 "properties": {
3507 "code": {
3508 "type": "integer",
3509 "format": "int32"
3510 },
3511 "data": {
3512 "default": null
35138694 },
35148695 "error": {
35158696 "type": [
@@ -3534,7 +8715,7 @@
35348715 }
35358716 }
35368717 },
3537 "AppResponseData_Page_AdminDto": {
8718 "AppResponseData_SystemStatusDto": {
35388719 "type": "object",
35398720 "required": [
35408721 "code",
@@ -3550,83 +8731,42 @@
35508731 "data": {
35518732 "type": "object",
35528733 "required": [
3553 "items",
3554 "total",
3555 "limit",
3556 "offset"
8734 "process",
8735 "dependencies",
8736 "features",
8737 "counts",
8738 "queues",
8739 "recent_failures",
8740 "generated_at"
35578741 ],
35588742 "properties": {
3559 "items": {
8743 "counts": {
8744 "$ref": "#/components/schemas/CountsDto"
8745 },
8746 "dependencies": {
8747 "$ref": "#/components/schemas/DependenciesDto"
8748 },
8749 "features": {
8750 "$ref": "#/components/schemas/FeaturesDto"
8751 },
8752 "generated_at": {
8753 "type": "string",
8754 "format": "date-time"
8755 },
8756 "process": {
8757 "$ref": "#/components/schemas/ProcessInfoDto"
8758 },
8759 "queues": {
35608760 "type": "array",
35618761 "items": {
3562 "type": "object",
3563 "required": [
3564 "id",
3565 "username",
3566 "display_name",
3567 "role",
3568 "is_active",
3569 "must_change_password",
3570 "created_at",
3571 "updated_at"
3572 ],
3573 "properties": {
3574 "created_at": {
3575 "type": "string",
3576 "format": "date-time"
3577 },
3578 "display_name": {
3579 "type": "string"
3580 },
3581 "id": {
3582 "type": "string",
3583 "format": "uuid"
3584 },
3585 "is_active": {
3586 "type": "boolean"
3587 },
3588 "last_login_at": {
3589 "type": [
3590 "string",
3591 "null"
3592 ],
3593 "format": "date-time"
3594 },
3595 "last_login_ip": {
3596 "type": [
3597 "string",
3598 "null"
3599 ]
3600 },
3601 "must_change_password": {
3602 "type": "boolean"
3603 },
3604 "role": {
3605 "$ref": "#/components/schemas/AdminRole"
3606 },
3607 "updated_at": {
3608 "type": "string",
3609 "format": "date-time"
3610 },
3611 "username": {
3612 "type": "string"
3613 }
3614 }
8762 "$ref": "#/components/schemas/QueueSnapshotDto"
36158763 }
36168764 },
3617 "limit": {
3618 "type": "integer",
3619 "format": "int64",
3620 "minimum": 0
3621 },
3622 "offset": {
3623 "type": "integer",
3624 "format": "int64",
3625 "minimum": 0
3626 },
3627 "total": {
3628 "type": "integer",
3629 "format": "int64"
8765 "recent_failures": {
8766 "type": "array",
8767 "items": {
8768 "$ref": "#/components/schemas/JobLogDto"
8769 }
36308770 }
36318771 }
36328772 },
@@ -3653,7 +8793,7 @@
36538793 }
36548794 }
36558795 },
3656 "AppResponseData_Page_AdminSpaceMemberDto": {
8796 "AppResponseData_TaskActionResp": {
36578797 "type": "object",
36588798 "required": [
36598799 "code",
@@ -3669,74 +8809,20 @@
36698809 "data": {
36708810 "type": "object",
36718811 "required": [
3672 "items",
3673 "total",
3674 "limit",
3675 "offset"
8812 "source",
8813 "id",
8814 "state"
36768815 ],
36778816 "properties": {
3678 "items": {
3679 "type": "array",
3680 "items": {
3681 "type": "object",
3682 "required": [
3683 "user_id",
3684 "status",
3685 "joined_at"
3686 ],
3687 "properties": {
3688 "alias_name": {
3689 "type": [
3690 "string",
3691 "null"
3692 ]
3693 },
3694 "display_name": {
3695 "type": [
3696 "string",
3697 "null"
3698 ]
3699 },
3700 "joined_at": {
3701 "type": "string",
3702 "format": "date-time"
3703 },
3704 "last_active_at": {
3705 "type": [
3706 "string",
3707 "null"
3708 ],
3709 "format": "date-time"
3710 },
3711 "status": {
3712 "type": "string"
3713 },
3714 "user_id": {
3715 "type": "string",
3716 "format": "uuid"
3717 },
3718 "username": {
3719 "type": [
3720 "string",
3721 "null"
3722 ]
3723 }
3724 }
3725 }
8817 "id": {
8818 "type": "string",
8819 "format": "uuid"
37268820 },
3727 "limit": {
3728 "type": "integer",
3729 "format": "int64",
3730 "minimum": 0
8821 "source": {
8822 "type": "string"
37318823 },
3732 "offset": {
3733 "type": "integer",
3734 "format": "int64",
3735 "minimum": 0
3736 },
3737 "total": {
3738 "type": "integer",
3739 "format": "int64"
8824 "state": {
8825 "type": "string"
37408826 }
37418827 }
37428828 },
@@ -3763,7 +8849,7 @@
37638849 }
37648850 }
37658851 },
3766 "AppResponseData_Page_AdminSpaceSummary": {
8852 "AppResponseData_TaskDetailDto": {
37678853 "type": "object",
37688854 "required": [
37698855 "code",
@@ -3779,88 +8865,13 @@
37798865 "data": {
37808866 "type": "object",
37818867 "required": [
3782 "items",
3783 "total",
3784 "limit",
3785 "offset"
8868 "task",
8869 "detail"
37868870 ],
37878871 "properties": {
3788 "items": {
3789 "type": "array",
3790 "items": {
3791 "type": "object",
3792 "required": [
3793 "id",
3794 "name",
3795 "lower_name",
3796 "owner_id",
3797 "is_private",
3798 "is_active",
3799 "nums_members",
3800 "created_at",
3801 "updated_at"
3802 ],
3803 "properties": {
3804 "created_at": {
3805 "type": "string",
3806 "format": "date-time"
3807 },
3808 "description": {
3809 "type": [
3810 "string",
3811 "null"
3812 ]
3813 },
3814 "id": {
3815 "type": "string",
3816 "format": "uuid"
3817 },
3818 "is_active": {
3819 "type": "boolean"
3820 },
3821 "is_private": {
3822 "type": "boolean"
3823 },
3824 "lower_name": {
3825 "type": "string"
3826 },
3827 "name": {
3828 "type": "string"
3829 },
3830 "nums_members": {
3831 "type": "integer",
3832 "format": "int64"
3833 },
3834 "owner_id": {
3835 "type": "string",
3836 "format": "uuid"
3837 },
3838 "owner_username": {
3839 "type": [
3840 "string",
3841 "null"
3842 ]
3843 },
3844 "updated_at": {
3845 "type": "string",
3846 "format": "date-time"
3847 }
3848 }
3849 }
3850 },
3851 "limit": {
3852 "type": "integer",
3853 "format": "int64",
3854 "minimum": 0
3855 },
3856 "offset": {
3857 "type": "integer",
3858 "format": "int64",
3859 "minimum": 0
3860 },
3861 "total": {
3862 "type": "integer",
3863 "format": "int64"
8872 "detail": {},
8873 "task": {
8874 "$ref": "#/components/schemas/TaskDto"
38648875 }
38658876 }
38668877 },
@@ -3887,7 +8898,7 @@
38878898 }
38888899 }
38898900 },
3890 "AppResponseData_Page_AdminUserSummary": {
8901 "AppResponseData_TaskPageDto": {
38918902 "type": "object",
38928903 "required": [
38938904 "code",
@@ -3912,64 +8923,7 @@
39128923 "items": {
39138924 "type": "array",
39148925 "items": {
3915 "type": "object",
3916 "required": [
3917 "uid",
3918 "username",
3919 "display_name",
3920 "avatar_url",
3921 "is_active",
3922 "is_restricted",
3923 "prohibit_login",
3924 "bot",
3925 "system",
3926 "suspended",
3927 "created_at"
3928 ],
3929 "properties": {
3930 "avatar_url": {
3931 "type": "string"
3932 },
3933 "bot": {
3934 "type": "boolean"
3935 },
3936 "created_at": {
3937 "type": "string",
3938 "format": "date-time"
3939 },
3940 "display_name": {
3941 "type": "string"
3942 },
3943 "is_active": {
3944 "type": "boolean"
3945 },
3946 "is_restricted": {
3947 "type": "boolean"
3948 },
3949 "last_login_at": {
3950 "type": [
3951 "string",
3952 "null"
3953 ],
3954 "format": "date-time"
3955 },
3956 "prohibit_login": {
3957 "type": "boolean"
3958 },
3959 "suspended": {
3960 "type": "boolean"
3961 },
3962 "system": {
3963 "type": "boolean"
3964 },
3965 "uid": {
3966 "type": "string",
3967 "format": "uuid"
3968 },
3969 "username": {
3970 "type": "string"
3971 }
3972 }
8926 "$ref": "#/components/schemas/TaskDto"
39738927 }
39748928 },
39758929 "limit": {
@@ -4011,7 +8965,7 @@
40118965 }
40128966 }
40138967 },
4014 "AppResponseData_QueueActionResp": {
8968 "AppResponseData_TaskStatsDto": {
40158969 "type": "object",
40168970 "required": [
40178971 "code",
@@ -4027,17 +8981,24 @@
40278981 "data": {
40288982 "type": "object",
40298983 "required": [
4030 "queue",
4031 "affected"
8984 "sources",
8985 "total",
8986 "generated_at"
40328987 ],
40338988 "properties": {
4034 "affected": {
8989 "generated_at": {
8990 "type": "string",
8991 "format": "date-time"
8992 },
8993 "sources": {
8994 "type": "array",
8995 "items": {
8996 "$ref": "#/components/schemas/TaskSourceStatDto"
8997 }
8998 },
8999 "total": {
40359000 "type": "integer",
4036 "format": "int64",
4037 "minimum": 0
4038 },
4039 "queue": {
4040 "type": "string"
9001 "format": "int64"
40419002 }
40429003 }
40439004 },
@@ -4064,7 +9025,7 @@
40649025 }
40659026 }
40669027 },
4067 "AppResponseData_RemoveMemberResp": {
9028 "AppResponseData_Vec_QueueSnapshotDto": {
40689029 "type": "object",
40699030 "required": [
40709031 "code",
@@ -4078,23 +9039,57 @@
40789039 "format": "int32"
40799040 },
40809041 "data": {
4081 "type": "object",
4082 "required": [
4083 "space_id",
4084 "user_id",
4085 "removed"
4086 ],
4087 "properties": {
4088 "removed": {
4089 "type": "boolean"
4090 },
4091 "space_id": {
4092 "type": "string",
4093 "format": "uuid"
4094 },
4095 "user_id": {
4096 "type": "string",
4097 "format": "uuid"
9042 "type": "array",
9043 "items": {
9044 "type": "object",
9045 "required": [
9046 "name",
9047 "kind",
9048 "enabled",
9049 "queued",
9050 "running",
9051 "failed",
9052 "succeeded"
9053 ],
9054 "properties": {
9055 "depth": {
9056 "type": [
9057 "integer",
9058 "null"
9059 ],
9060 "format": "int64"
9061 },
9062 "enabled": {
9063 "type": "boolean"
9064 },
9065 "failed": {
9066 "type": "integer",
9067 "format": "int64"
9068 },
9069 "kind": {
9070 "type": "string"
9071 },
9072 "name": {
9073 "type": "string"
9074 },
9075 "note": {
9076 "type": [
9077 "string",
9078 "null"
9079 ]
9080 },
9081 "queued": {
9082 "type": "integer",
9083 "format": "int64"
9084 },
9085 "running": {
9086 "type": "integer",
9087 "format": "int64"
9088 },
9089 "succeeded": {
9090 "type": "integer",
9091 "format": "int64"
9092 }
40989093 }
40999094 }
41009095 },
@@ -4121,838 +9116,900 @@
41219116 }
41229117 }
41239118 },
4124 "AppResponseData_RevokeSessionsResp": {
9119 "AuditLogDto": {
41259120 "type": "object",
41269121 "required": [
4127 "code",
4128 "message",
4129 "request_id",
4130 "log_id"
9122 "id",
9123 "admin_username",
9124 "action",
9125 "detail",
9126 "created_at"
41319127 ],
41329128 "properties": {
4133 "code": {
4134 "type": "integer",
4135 "format": "int32"
9129 "action": {
9130 "type": "string"
41369131 },
4137 "data": {
4138 "type": "object",
4139 "required": [
4140 "revoked"
9132 "admin_id": {
9133 "type": [
9134 "string",
9135 "null"
41419136 ],
4142 "properties": {
4143 "revoked": {
4144 "type": "integer",
4145 "format": "int64",
4146 "minimum": 0
4147 }
4148 }
9137 "format": "uuid"
9138 },
9139 "admin_username": {
9140 "type": "string"
9141 },
9142 "created_at": {
9143 "type": "string",
9144 "format": "date-time"
9145 },
9146 "detail": {},
9147 "id": {
9148 "type": "string",
9149 "format": "uuid"
9150 },
9151 "ip": {
9152 "type": [
9153 "string",
9154 "null"
9155 ]
9156 },
9157 "request_id": {
9158 "type": [
9159 "string",
9160 "null"
9161 ]
9162 },
9163 "target_id": {
9164 "type": [
9165 "string",
9166 "null"
9167 ]
41499168 },
4150 "error": {
9169 "target_type": {
41519170 "type": [
41529171 "string",
41539172 "null"
41549173 ]
41559174 },
4156 "error_code": {
9175 "user_agent": {
41579176 "type": [
41589177 "string",
41599178 "null"
41609179 ]
9180 }
9181 }
9182 },
9183 "AuditLogPageDto": {
9184 "type": "object",
9185 "required": [
9186 "items",
9187 "total",
9188 "limit",
9189 "offset"
9190 ],
9191 "properties": {
9192 "items": {
9193 "type": "array",
9194 "items": {
9195 "$ref": "#/components/schemas/AuditLogDto"
9196 }
41619197 },
4162 "log_id": {
4163 "type": "string"
9198 "limit": {
9199 "type": "integer",
9200 "format": "int64",
9201 "minimum": 0
41649202 },
4165 "message": {
4166 "type": "string"
9203 "offset": {
9204 "type": "integer",
9205 "format": "int64",
9206 "minimum": 0
41679207 },
4168 "request_id": {
4169 "type": "string"
9208 "total": {
9209 "type": "integer",
9210 "format": "int64"
41709211 }
41719212 }
41729213 },
4173 "AppResponseData_StorageObjectPageDto": {
9214 "BulkChatMessageFailure": {
41749215 "type": "object",
41759216 "required": [
4176 "code",
4177 "message",
4178 "request_id",
4179 "log_id"
9217 "id",
9218 "error"
41809219 ],
41819220 "properties": {
4182 "code": {
4183 "type": "integer",
4184 "format": "int32"
4185 },
4186 "data": {
4187 "type": "object",
4188 "required": [
4189 "prefix",
4190 "items"
4191 ],
4192 "properties": {
4193 "items": {
4194 "type": "array",
4195 "items": {
4196 "$ref": "#/components/schemas/StorageObjectDto"
4197 }
4198 },
4199 "next_token": {
4200 "type": [
4201 "string",
4202 "null"
4203 ]
4204 },
4205 "prefix": {
4206 "type": "string"
4207 }
4208 }
4209 },
42109221 "error": {
4211 "type": [
4212 "string",
4213 "null"
4214 ]
4215 },
4216 "error_code": {
4217 "type": [
4218 "string",
4219 "null"
4220 ]
4221 },
4222 "log_id": {
42239222 "type": "string"
42249223 },
4225 "message": {
4226 "type": "string"
4227 },
4228 "request_id": {
4229 "type": "string"
9224 "id": {
9225 "type": "string",
9226 "format": "uuid"
42309227 }
42319228 }
42329229 },
4233 "AppResponseData_StorageOverviewDto": {
9230 "BulkDeleteChatMessagesCmd": {
42349231 "type": "object",
42359232 "required": [
4236 "code",
4237 "message",
4238 "request_id",
4239 "log_id"
9233 "message_ids"
42409234 ],
42419235 "properties": {
4242 "code": {
4243 "type": "integer",
4244 "format": "int32"
4245 },
4246 "data": {
4247 "type": "object",
4248 "required": [
4249 "bucket",
4250 "total_bytes",
4251 "total_objects",
4252 "categories",
4253 "generated_at",
4254 "cached"
4255 ],
4256 "properties": {
4257 "bucket": {
4258 "type": "string"
4259 },
4260 "cached": {
4261 "type": "boolean"
4262 },
4263 "categories": {
4264 "type": "array",
4265 "items": {
4266 "$ref": "#/components/schemas/StorageCategoryDto"
4267 }
4268 },
4269 "generated_at": {
4270 "type": "string",
4271 "format": "date-time"
4272 },
4273 "total_bytes": {
4274 "type": "integer",
4275 "format": "int64",
4276 "minimum": 0
4277 },
4278 "total_objects": {
4279 "type": "integer",
4280 "format": "int64",
4281 "minimum": 0
4282 }
9236 "message_ids": {
9237 "type": "array",
9238 "items": {
9239 "type": "string",
9240 "format": "uuid"
42839241 }
42849242 },
4285 "error": {
9243 "reason": {
42869244 "type": [
42879245 "string",
42889246 "null"
42899247 ]
9248 }
9249 }
9250 },
9251 "BulkDeleteChatMessagesResp": {
9252 "type": "object",
9253 "required": [
9254 "succeeded",
9255 "failed"
9256 ],
9257 "properties": {
9258 "failed": {
9259 "type": "array",
9260 "items": {
9261 "$ref": "#/components/schemas/BulkChatMessageFailure"
9262 }
42909263 },
4291 "error_code": {
4292 "type": [
4293 "string",
4294 "null"
4295 ]
4296 },
4297 "log_id": {
9264 "succeeded": {
9265 "type": "array",
9266 "items": {
9267 "type": "string",
9268 "format": "uuid"
9269 }
9270 }
9271 }
9272 },
9273 "BulkTaskCmd": {
9274 "type": "object",
9275 "required": [
9276 "action",
9277 "ids"
9278 ],
9279 "properties": {
9280 "action": {
42989281 "type": "string"
42999282 },
4300 "message": {
9283 "ids": {
9284 "type": "array",
9285 "items": {
9286 "type": "string",
9287 "format": "uuid"
9288 }
9289 }
9290 }
9291 },
9292 "BulkTaskFailure": {
9293 "type": "object",
9294 "required": [
9295 "id",
9296 "error"
9297 ],
9298 "properties": {
9299 "error": {
43019300 "type": "string"
43029301 },
4303 "request_id": {
4304 "type": "string"
9302 "id": {
9303 "type": "string",
9304 "format": "uuid"
43059305 }
43069306 }
43079307 },
4308 "AppResponseData_SystemStatusDto": {
9308 "BulkTaskResp": {
43099309 "type": "object",
43109310 "required": [
4311 "code",
4312 "message",
4313 "request_id",
4314 "log_id"
9311 "source",
9312 "action",
9313 "succeeded",
9314 "failed"
43159315 ],
43169316 "properties": {
4317 "code": {
4318 "type": "integer",
4319 "format": "int32"
9317 "action": {
9318 "type": "string"
43209319 },
4321 "data": {
4322 "type": "object",
4323 "required": [
4324 "process",
4325 "dependencies",
4326 "features",
4327 "counts",
4328 "queues",
4329 "recent_failures",
4330 "generated_at"
4331 ],
4332 "properties": {
4333 "counts": {
4334 "$ref": "#/components/schemas/CountsDto"
4335 },
4336 "dependencies": {
4337 "$ref": "#/components/schemas/DependenciesDto"
4338 },
4339 "features": {
4340 "$ref": "#/components/schemas/FeaturesDto"
4341 },
4342 "generated_at": {
4343 "type": "string",
4344 "format": "date-time"
4345 },
4346 "process": {
4347 "$ref": "#/components/schemas/ProcessInfoDto"
4348 },
4349 "queues": {
4350 "type": "array",
4351 "items": {
4352 "$ref": "#/components/schemas/QueueSnapshotDto"
4353 }
4354 },
4355 "recent_failures": {
4356 "type": "array",
4357 "items": {
4358 "$ref": "#/components/schemas/JobLogDto"
4359 }
4360 }
9320 "failed": {
9321 "type": "array",
9322 "items": {
9323 "$ref": "#/components/schemas/BulkTaskFailure"
43619324 }
43629325 },
4363 "error": {
4364 "type": [
4365 "string",
4366 "null"
4367 ]
4368 },
4369 "error_code": {
4370 "type": [
4371 "string",
4372 "null"
4373 ]
4374 },
4375 "log_id": {
9326 "source": {
43769327 "type": "string"
43779328 },
4378 "message": {
9329 "succeeded": {
9330 "type": "array",
9331 "items": {
9332 "type": "string",
9333 "format": "uuid"
9334 }
9335 }
9336 }
9337 },
9338 "BulkUserFailure": {
9339 "type": "object",
9340 "required": [
9341 "uid",
9342 "error"
9343 ],
9344 "properties": {
9345 "error": {
43799346 "type": "string"
43809347 },
4381 "request_id": {
4382 "type": "string"
9348 "uid": {
9349 "type": "string",
9350 "format": "uuid"
43839351 }
43849352 }
43859353 },
4386 "AppResponseData_TaskActionResp": {
9354 "BulkUsersCmd": {
43879355 "type": "object",
43889356 "required": [
4389 "code",
4390 "message",
4391 "request_id",
4392 "log_id"
9357 "action",
9358 "ids"
43939359 ],
43949360 "properties": {
4395 "code": {
4396 "type": "integer",
4397 "format": "int32"
9361 "action": {
9362 "type": "string"
43989363 },
4399 "data": {
4400 "type": "object",
4401 "required": [
4402 "source",
4403 "id",
4404 "state"
9364 "ends_at": {
9365 "type": [
9366 "string",
9367 "null"
44059368 ],
4406 "properties": {
4407 "id": {
4408 "type": "string",
4409 "format": "uuid"
4410 },
4411 "source": {
4412 "type": "string"
4413 },
4414 "state": {
4415 "type": "string"
4416 }
9369 "format": "date-time"
9370 },
9371 "ids": {
9372 "type": "array",
9373 "items": {
9374 "type": "string",
9375 "format": "uuid"
44179376 }
44189377 },
4419 "error": {
9378 "reason": {
44209379 "type": [
44219380 "string",
44229381 "null"
44239382 ]
44249383 },
4425 "error_code": {
4426 "type": [
4427 "string",
4428 "null"
9384 "suspension_type": {
9385 "oneOf": [
9386 {
9387 "type": "null"
9388 },
9389 {
9390 "$ref": "#/components/schemas/SuspensionType"
9391 }
44299392 ]
9393 }
9394 }
9395 },
9396 "BulkUsersResp": {
9397 "type": "object",
9398 "required": [
9399 "action",
9400 "succeeded",
9401 "failed"
9402 ],
9403 "properties": {
9404 "action": {
9405 "type": "string"
44309406 },
4431 "log_id": {
4432 "type": "string"
9407 "failed": {
9408 "type": "array",
9409 "items": {
9410 "$ref": "#/components/schemas/BulkUserFailure"
9411 }
44339412 },
4434 "message": {
9413 "succeeded": {
9414 "type": "array",
9415 "items": {
9416 "type": "string",
9417 "format": "uuid"
9418 }
9419 }
9420 }
9421 },
9422 "ChangePasswordCmd": {
9423 "type": "object",
9424 "required": [
9425 "current_password",
9426 "new_password"
9427 ],
9428 "properties": {
9429 "current_password": {
44359430 "type": "string"
44369431 },
4437 "request_id": {
9432 "new_password": {
44389433 "type": "string"
44399434 }
44409435 }
44419436 },
4442 "AppResponseData_TaskDetailDto": {
9437 "CountsDto": {
44439438 "type": "object",
44449439 "required": [
4445 "code",
4446 "message",
4447 "request_id",
4448 "log_id"
9440 "users",
9441 "active_users",
9442 "spaces",
9443 "active_spaces",
9444 "repos",
9445 "rooms",
9446 "issues",
9447 "messages",
9448 "admin_users"
44499449 ],
44509450 "properties": {
4451 "code": {
9451 "active_spaces": {
9452 "type": "integer",
9453 "format": "int64"
9454 },
9455 "active_users": {
9456 "type": "integer",
9457 "format": "int64"
9458 },
9459 "admin_users": {
44529460 "type": "integer",
4453 "format": "int32"
9461 "format": "int64"
44549462 },
4455 "data": {
4456 "type": "object",
4457 "required": [
4458 "task",
4459 "detail"
4460 ],
4461 "properties": {
4462 "detail": {},
4463 "task": {
4464 "$ref": "#/components/schemas/TaskDto"
4465 }
4466 }
9463 "issues": {
9464 "type": "integer",
9465 "format": "int64"
44679466 },
4468 "error": {
4469 "type": [
4470 "string",
4471 "null"
4472 ]
9467 "messages": {
9468 "type": "integer",
9469 "format": "int64"
44739470 },
4474 "error_code": {
4475 "type": [
4476 "string",
4477 "null"
4478 ]
9471 "repos": {
9472 "type": "integer",
9473 "format": "int64"
44799474 },
4480 "log_id": {
4481 "type": "string"
9475 "rooms": {
9476 "type": "integer",
9477 "format": "int64"
44829478 },
4483 "message": {
4484 "type": "string"
9479 "spaces": {
9480 "type": "integer",
9481 "format": "int64"
44859482 },
4486 "request_id": {
4487 "type": "string"
9483 "users": {
9484 "type": "integer",
9485 "format": "int64"
44889486 }
44899487 }
44909488 },
4491 "AppResponseData_TaskPageDto": {
9489 "CreateAdminCmd": {
44929490 "type": "object",
44939491 "required": [
4494 "code",
4495 "message",
4496 "request_id",
4497 "log_id"
9492 "username",
9493 "password",
9494 "role"
44989495 ],
44999496 "properties": {
4500 "code": {
4501 "type": "integer",
4502 "format": "int32"
4503 },
4504 "data": {
4505 "type": "object",
4506 "required": [
4507 "items",
4508 "total",
4509 "limit",
4510 "offset"
4511 ],
4512 "properties": {
4513 "items": {
4514 "type": "array",
4515 "items": {
4516 "$ref": "#/components/schemas/TaskDto"
4517 }
4518 },
4519 "limit": {
4520 "type": "integer",
4521 "format": "int64",
4522 "minimum": 0
4523 },
4524 "offset": {
4525 "type": "integer",
4526 "format": "int64",
4527 "minimum": 0
4528 },
4529 "total": {
4530 "type": "integer",
4531 "format": "int64"
4532 }
4533 }
4534 },
4535 "error": {
9497 "display_name": {
45369498 "type": [
45379499 "string",
45389500 "null"
45399501 ]
45409502 },
4541 "error_code": {
4542 "type": [
4543 "string",
4544 "null"
4545 ]
4546 },
4547 "log_id": {
9503 "password": {
45489504 "type": "string"
45499505 },
4550 "message": {
4551 "type": "string"
9506 "role": {
9507 "$ref": "#/components/schemas/AdminRole"
45529508 },
4553 "request_id": {
9509 "username": {
45549510 "type": "string"
45559511 }
45569512 }
45579513 },
4558 "AppResponseData_Vec_QueueSnapshotDto": {
9514 "CreateUserCmd": {
45599515 "type": "object",
45609516 "required": [
4561 "code",
4562 "message",
4563 "request_id",
4564 "log_id"
9517 "username",
9518 "email",
9519 "password"
45659520 ],
45669521 "properties": {
4567 "code": {
4568 "type": "integer",
4569 "format": "int32"
9522 "display_name": {
9523 "type": [
9524 "string",
9525 "null"
9526 ]
45709527 },
4571 "data": {
4572 "type": "array",
4573 "items": {
4574 "type": "object",
4575 "required": [
4576 "name",
4577 "kind",
4578 "enabled",
4579 "queued",
4580 "running",
4581 "failed",
4582 "succeeded"
4583 ],
4584 "properties": {
4585 "depth": {
4586 "type": [
4587 "integer",
4588 "null"
4589 ],
4590 "format": "int64"
4591 },
4592 "enabled": {
4593 "type": "boolean"
4594 },
4595 "failed": {
4596 "type": "integer",
4597 "format": "int64"
4598 },
4599 "kind": {
4600 "type": "string"
4601 },
4602 "name": {
4603 "type": "string"
4604 },
4605 "note": {
4606 "type": [
4607 "string",
4608 "null"
4609 ]
4610 },
4611 "queued": {
4612 "type": "integer",
4613 "format": "int64"
4614 },
4615 "running": {
4616 "type": "integer",
4617 "format": "int64"
4618 },
4619 "succeeded": {
4620 "type": "integer",
4621 "format": "int64"
4622 }
4623 }
4624 }
9528 "email": {
9529 "type": "string"
46259530 },
4626 "error": {
9531 "email_activated": {
46279532 "type": [
4628 "string",
9533 "boolean",
46299534 "null"
46309535 ]
46319536 },
4632 "error_code": {
9537 "is_active": {
46339538 "type": [
4634 "string",
9539 "boolean",
46359540 "null"
46369541 ]
46379542 },
4638 "log_id": {
9543 "password": {
46399544 "type": "string"
46409545 },
4641 "message": {
9546 "username": {
46429547 "type": "string"
4643 },
4644 "request_id": {
4645 "type": "string"
46469548 }
46479549 }
46489550 },
4649 "AuditLogDto": {
9551 "CreateUserResp": {
46509552 "type": "object",
46519553 "required": [
4652 "id",
4653 "admin_username",
4654 "action",
4655 "detail",
4656 "created_at"
9554 "uid",
9555 "username",
9556 "email"
46579557 ],
46589558 "properties": {
4659 "action": {
9559 "email": {
46609560 "type": "string"
46619561 },
4662 "admin_id": {
4663 "type": [
4664 "string",
4665 "null"
4666 ],
9562 "uid": {
9563 "type": "string",
46679564 "format": "uuid"
46689565 },
4669 "admin_username": {
9566 "username": {
46709567 "type": "string"
9568 }
9569 }
9570 },
9571 "DashboardDto": {
9572 "type": "object",
9573 "required": [
9574 "counts",
9575 "trend",
9576 "active_sessions",
9577 "restricted_users",
9578 "recent_audit",
9579 "recent_failures",
9580 "queues",
9581 "generated_at"
9582 ],
9583 "properties": {
9584 "active_sessions": {
9585 "type": "integer",
9586 "format": "int64"
9587 },
9588 "counts": {
9589 "$ref": "#/components/schemas/CountsDto"
46719590 },
4672 "created_at": {
9591 "generated_at": {
46739592 "type": "string",
46749593 "format": "date-time"
46759594 },
4676 "detail": {},
4677 "id": {
4678 "type": "string",
4679 "format": "uuid"
9595 "queues": {
9596 "type": "array",
9597 "items": {
9598 "$ref": "#/components/schemas/QueueSnapshotDto"
9599 }
9600 },
9601 "recent_audit": {
9602 "type": "array",
9603 "items": {
9604 "$ref": "#/components/schemas/AuditLogDto"
9605 }
9606 },
9607 "recent_failures": {
9608 "type": "array",
9609 "items": {
9610 "$ref": "#/components/schemas/JobLogDto"
9611 }
9612 },
9613 "restricted_users": {
9614 "type": "integer",
9615 "format": "int64"
46809616 },
4681 "ip": {
9617 "trend": {
9618 "type": "array",
9619 "items": {
9620 "$ref": "#/components/schemas/TrendPointDto"
9621 }
9622 }
9623 }
9624 },
9625 "DatabaseConfigDto": {
9626 "type": "object",
9627 "required": [
9628 "replica_count"
9629 ],
9630 "properties": {
9631 "database": {
46829632 "type": [
46839633 "string",
46849634 "null"
46859635 ]
46869636 },
4687 "request_id": {
9637 "endpoint": {
46889638 "type": [
46899639 "string",
46909640 "null"
46919641 ]
46929642 },
4693 "target_id": {
9643 "host": {
46949644 "type": [
46959645 "string",
46969646 "null"
46979647 ]
46989648 },
4699 "target_type": {
9649 "max_connections": {
9650 "type": [
9651 "integer",
9652 "null"
9653 ],
9654 "format": "int32",
9655 "minimum": 0
9656 },
9657 "min_connections": {
9658 "type": [
9659 "integer",
9660 "null"
9661 ],
9662 "format": "int32",
9663 "minimum": 0
9664 },
9665 "port": {
47009666 "type": [
4701 "string",
9667 "integer",
47029668 "null"
4703 ]
9669 ],
9670 "format": "int32",
9671 "minimum": 0
9672 },
9673 "replica_count": {
9674 "type": "integer",
9675 "minimum": 0
47049676 },
4705 "user_agent": {
9677 "ssl_mode": {
47069678 "type": [
47079679 "string",
47089680 "null"
4709 ]
9681 ]
9682 }
9683 }
9684 },
9685 "DeleteChatAttachmentResp": {
9686 "type": "object",
9687 "required": [
9688 "id",
9689 "s3_key",
9690 "deleted",
9691 "storage_deleted"
9692 ],
9693 "properties": {
9694 "deleted": {
9695 "type": "boolean"
9696 },
9697 "id": {
9698 "type": "string",
9699 "format": "uuid"
9700 },
9701 "s3_key": {
9702 "type": "string"
9703 },
9704 "storage_deleted": {
9705 "type": "boolean"
47109706 }
47119707 }
47129708 },
4713 "AuditLogPageDto": {
9709 "DeleteChatRoomResp": {
47149710 "type": "object",
47159711 "required": [
4716 "items",
4717 "total",
4718 "limit",
4719 "offset"
9712 "room_id",
9713 "messages_deleted",
9714 "members_deleted",
9715 "attachments_deleted",
9716 "threads_deleted"
47209717 ],
47219718 "properties": {
4722 "items": {
4723 "type": "array",
4724 "items": {
4725 "$ref": "#/components/schemas/AuditLogDto"
4726 }
9719 "attachments_deleted": {
9720 "type": "integer",
9721 "format": "int64",
9722 "minimum": 0
47279723 },
4728 "limit": {
9724 "members_deleted": {
47299725 "type": "integer",
47309726 "format": "int64",
47319727 "minimum": 0
47329728 },
4733 "offset": {
9729 "messages_deleted": {
47349730 "type": "integer",
47359731 "format": "int64",
47369732 "minimum": 0
47379733 },
4738 "total": {
9734 "room_id": {
9735 "type": "string",
9736 "format": "uuid"
9737 },
9738 "threads_deleted": {
47399739 "type": "integer",
4740 "format": "int64"
9740 "format": "int64",
9741 "minimum": 0
47419742 }
47429743 }
47439744 },
4744 "ChangePasswordCmd": {
9745 "DeleteObjectResp": {
47459746 "type": "object",
47469747 "required": [
4747 "current_password",
4748 "new_password"
9748 "key",
9749 "deleted"
47499750 ],
47509751 "properties": {
4751 "current_password": {
4752 "type": "string"
9752 "deleted": {
9753 "type": "boolean"
47539754 },
4754 "new_password": {
9755 "key": {
47559756 "type": "string"
47569757 }
47579758 }
47589759 },
4759 "CountsDto": {
9760 "DeleteUserResp": {
47609761 "type": "object",
47619762 "required": [
4762 "users",
4763 "active_users",
4764 "spaces",
4765 "active_spaces",
4766 "repos",
4767 "rooms",
4768 "issues",
4769 "messages",
4770 "admin_users"
9763 "user_id",
9764 "anonymized"
47719765 ],
47729766 "properties": {
4773 "active_spaces": {
4774 "type": "integer",
4775 "format": "int64"
9767 "anonymized": {
9768 "type": "boolean"
47769769 },
4777 "active_users": {
4778 "type": "integer",
4779 "format": "int64"
9770 "user_id": {
9771 "type": "string",
9772 "format": "uuid"
9773 }
9774 }
9775 },
9776 "DependenciesDto": {
9777 "type": "object",
9778 "required": [
9779 "database",
9780 "redis",
9781 "storage",
9782 "search"
9783 ],
9784 "properties": {
9785 "database": {
9786 "$ref": "#/components/schemas/DependencyDto"
47809787 },
4781 "admin_users": {
4782 "type": "integer",
4783 "format": "int64"
9788 "redis": {
9789 "$ref": "#/components/schemas/DependencyDto"
47849790 },
4785 "issues": {
4786 "type": "integer",
4787 "format": "int64"
4788 },
4789 "messages": {
4790 "type": "integer",
4791 "format": "int64"
4792 },
4793 "repos": {
4794 "type": "integer",
4795 "format": "int64"
4796 },
4797 "rooms": {
4798 "type": "integer",
4799 "format": "int64"
4800 },
4801 "spaces": {
4802 "type": "integer",
4803 "format": "int64"
9791 "search": {
9792 "$ref": "#/components/schemas/DependencyDto"
48049793 },
4805 "users": {
4806 "type": "integer",
4807 "format": "int64"
9794 "storage": {
9795 "$ref": "#/components/schemas/DependencyDto"
48089796 }
48099797 }
48109798 },
4811 "CreateAdminCmd": {
9799 "DependencyDto": {
48129800 "type": "object",
48139801 "required": [
4814 "username",
4815 "password",
4816 "role"
9802 "ok"
48179803 ],
48189804 "properties": {
4819 "display_name": {
9805 "detail": {
48209806 "type": [
48219807 "string",
48229808 "null"
48239809 ]
48249810 },
4825 "password": {
9811 "latency_ms": {
9812 "type": [
9813 "integer",
9814 "null"
9815 ],
9816 "format": "int64",
9817 "minimum": 0
9818 },
9819 "ok": {
9820 "type": "boolean"
9821 }
9822 }
9823 },
9824 "DownloadUrlDto": {
9825 "type": "object",
9826 "required": [
9827 "key",
9828 "url",
9829 "expires_in_secs"
9830 ],
9831 "properties": {
9832 "expires_in_secs": {
9833 "type": "integer",
9834 "format": "int32",
9835 "minimum": 0
9836 },
9837 "key": {
48269838 "type": "string"
48279839 },
4828 "role": {
4829 "$ref": "#/components/schemas/AdminRole"
4830 },
4831 "username": {
9840 "url": {
48329841 "type": "string"
48339842 }
48349843 }
48359844 },
4836 "DeleteObjectResp": {
9845 "EmailConfigDto": {
48379846 "type": "object",
48389847 "required": [
4839 "key",
4840 "deleted"
9848 "enabled",
9849 "queue_enabled",
9850 "smtp_host",
9851 "smtp_port"
48419852 ],
48429853 "properties": {
4843 "deleted": {
9854 "enabled": {
9855 "type": "boolean"
9856 },
9857 "from_address": {
9858 "type": [
9859 "string",
9860 "null"
9861 ]
9862 },
9863 "from_name": {
9864 "type": [
9865 "string",
9866 "null"
9867 ]
9868 },
9869 "queue_enabled": {
48449870 "type": "boolean"
48459871 },
4846 "key": {
9872 "smtp_host": {
48479873 "type": "string"
9874 },
9875 "smtp_port": {
9876 "type": "integer",
9877 "format": "int32",
9878 "minimum": 0
48489879 }
48499880 }
48509881 },
4851 "DeleteUserResp": {
9882 "FeaturesDto": {
48529883 "type": "object",
48539884 "required": [
4854 "user_id",
4855 "anonymized"
9885 "search_enabled",
9886 "import_enabled",
9887 "email_enabled",
9888 "email_queue_enabled"
48569889 ],
48579890 "properties": {
4858 "anonymized": {
9891 "email_enabled": {
9892 "type": "boolean"
9893 },
9894 "email_queue_enabled": {
9895 "type": "boolean"
9896 },
9897 "import_enabled": {
48599898 "type": "boolean"
48609899 },
4861 "user_id": {
4862 "type": "string",
4863 "format": "uuid"
9900 "search_enabled": {
9901 "type": "boolean"
48649902 }
48659903 }
48669904 },
4867 "DependenciesDto": {
9905 "GitConfigDto": {
48689906 "type": "object",
48699907 "required": [
4870 "database",
4871 "redis",
4872 "storage",
4873 "search"
9908 "repo_root",
9909 "objects_root",
9910 "max_blob_size",
9911 "sync_topic",
9912 "sync_concurrency",
9913 "base_url",
9914 "graphql_complexity_limit",
9915 "graphql_introspection"
48749916 ],
48759917 "properties": {
4876 "database": {
4877 "$ref": "#/components/schemas/DependencyDto"
9918 "base_url": {
9919 "type": "string"
9920 },
9921 "graphql_complexity_limit": {
9922 "type": "integer",
9923 "format": "int64",
9924 "minimum": 0
9925 },
9926 "graphql_introspection": {
9927 "type": "boolean"
9928 },
9929 "max_blob_size": {
9930 "type": "integer",
9931 "format": "int64",
9932 "minimum": 0
9933 },
9934 "objects_root": {
9935 "type": "string"
48789936 },
4879 "redis": {
4880 "$ref": "#/components/schemas/DependencyDto"
9937 "repo_root": {
9938 "type": "string"
48819939 },
4882 "search": {
4883 "$ref": "#/components/schemas/DependencyDto"
9940 "sync_concurrency": {
9941 "type": "integer",
9942 "minimum": 0
48849943 },
4885 "storage": {
4886 "$ref": "#/components/schemas/DependencyDto"
9944 "sync_topic": {
9945 "type": "string"
48879946 }
48889947 }
48899948 },
4890 "DependencyDto": {
9949 "HostEntryDto": {
48919950 "type": "object",
48929951 "required": [
4893 "ok"
9952 "name",
9953 "enabled"
48949954 ],
48959955 "properties": {
4896 "detail": {
9956 "bind_addr": {
48979957 "type": [
48989958 "string",
48999959 "null"
49009960 ]
49019961 },
4902 "latency_ms": {
9962 "enabled": {
9963 "type": "boolean"
9964 },
9965 "name": {
9966 "type": "string"
9967 },
9968 "port": {
49039969 "type": [
49049970 "integer",
49059971 "null"
49069972 ],
4907 "format": "int64",
9973 "format": "int32",
49089974 "minimum": 0
4909 },
4910 "ok": {
4911 "type": "boolean"
49129975 }
49139976 }
49149977 },
4915 "DownloadUrlDto": {
9978 "ImportConfigDto": {
49169979 "type": "object",
49179980 "required": [
4918 "key",
4919 "url",
4920 "expires_in_secs"
9981 "enabled",
9982 "allowed_hosts",
9983 "allow_insecure_tls",
9984 "max_concurrent_jobs",
9985 "max_repo_bytes",
9986 "max_attempts"
49219987 ],
49229988 "properties": {
4923 "expires_in_secs": {
4924 "type": "integer",
4925 "format": "int32",
4926 "minimum": 0
9989 "allow_insecure_tls": {
9990 "type": "boolean"
49279991 },
4928 "key": {
4929 "type": "string"
9992 "allowed_hosts": {
9993 "type": "array",
9994 "items": {
9995 "type": "string"
9996 }
49309997 },
4931 "url": {
4932 "type": "string"
4933 }
4934 }
4935 },
4936 "FeaturesDto": {
4937 "type": "object",
4938 "required": [
4939 "search_enabled",
4940 "import_enabled",
4941 "email_enabled",
4942 "email_queue_enabled"
4943 ],
4944 "properties": {
4945 "email_enabled": {
9998 "enabled": {
49469999 "type": "boolean"
494710000 },
4948 "email_queue_enabled": {
4949 "type": "boolean"
10001 "max_attempts": {
10002 "type": "integer",
10003 "format": "int32"
495010004 },
4951 "import_enabled": {
4952 "type": "boolean"
10005 "max_concurrent_jobs": {
10006 "type": "integer",
10007 "minimum": 0
495310008 },
4954 "search_enabled": {
4955 "type": "boolean"
10009 "max_repo_bytes": {
10010 "type": "integer",
10011 "format": "int64",
10012 "minimum": 0
495610013 }
495710014 }
495810015 },
@@ -5072,6 +10129,36 @@
507210129 }
507310130 }
507410131 },
10132 "PurgeChatMessagesResp": {
10133 "type": "object",
10134 "required": [
10135 "room_id",
10136 "messages_deleted",
10137 "attachments_deleted",
10138 "threads_deleted"
10139 ],
10140 "properties": {
10141 "attachments_deleted": {
10142 "type": "integer",
10143 "format": "int64",
10144 "minimum": 0
10145 },
10146 "messages_deleted": {
10147 "type": "integer",
10148 "format": "int64",
10149 "minimum": 0
10150 },
10151 "room_id": {
10152 "type": "string",
10153 "format": "uuid"
10154 },
10155 "threads_deleted": {
10156 "type": "integer",
10157 "format": "int64",
10158 "minimum": 0
10159 }
10160 }
10161 },
507510162 "QueueActionResp": {
507610163 "type": "object",
507710164 "required": [
@@ -5141,6 +10228,55 @@
514110228 }
514210229 }
514310230 },
10231 "RedisConfigDto": {
10232 "type": "object",
10233 "required": [
10234 "endpoint",
10235 "mode",
10236 "pool_size"
10237 ],
10238 "properties": {
10239 "database": {
10240 "type": [
10241 "integer",
10242 "null"
10243 ],
10244 "format": "int32",
10245 "minimum": 0
10246 },
10247 "endpoint": {
10248 "type": "string"
10249 },
10250 "mode": {
10251 "type": "string"
10252 },
10253 "pool_size": {
10254 "type": "integer",
10255 "minimum": 0
10256 }
10257 }
10258 },
10259 "RemoveChatMemberResp": {
10260 "type": "object",
10261 "required": [
10262 "room_id",
10263 "user_id",
10264 "removed"
10265 ],
10266 "properties": {
10267 "removed": {
10268 "type": "boolean"
10269 },
10270 "room_id": {
10271 "type": "string",
10272 "format": "uuid"
10273 },
10274 "user_id": {
10275 "type": "string",
10276 "format": "uuid"
10277 }
10278 }
10279 },
514410280 "RemoveMemberResp": {
514510281 "type": "object",
514610282 "required": [
@@ -5163,37 +10299,114 @@
516310299 }
516410300 },
516510301 "ResetAdminPasswordCmd": {
5166 "type": "object",
5167 "required": [
5168 "new_password"
5169 ],
5170 "properties": {
5171 "new_password": {
10302 "type": "object",
10303 "required": [
10304 "new_password"
10305 ],
10306 "properties": {
10307 "new_password": {
10308 "type": "string"
10309 }
10310 }
10311 },
10312 "ResetUserPasswordCmd": {
10313 "type": "object",
10314 "required": [
10315 "new_password"
10316 ],
10317 "properties": {
10318 "new_password": {
10319 "type": "string"
10320 }
10321 }
10322 },
10323 "RevokeSessionsResp": {
10324 "type": "object",
10325 "required": [
10326 "revoked"
10327 ],
10328 "properties": {
10329 "revoked": {
10330 "type": "integer",
10331 "format": "int64",
10332 "minimum": 0
10333 }
10334 }
10335 },
10336 "SearchConfigDto": {
10337 "type": "object",
10338 "required": [
10339 "enabled",
10340 "index_prefix",
10341 "max_file_bytes",
10342 "batch_size",
10343 "poll_interval_secs"
10344 ],
10345 "properties": {
10346 "batch_size": {
10347 "type": "integer",
10348 "minimum": 0
10349 },
10350 "enabled": {
10351 "type": "boolean"
10352 },
10353 "index_prefix": {
10354 "type": "string"
10355 },
10356 "max_file_bytes": {
10357 "type": "integer",
10358 "minimum": 0
10359 },
10360 "poll_interval_secs": {
10361 "type": "integer",
10362 "format": "int64",
10363 "minimum": 0
10364 }
10365 }
10366 },
10367 "SessionConfigDto": {
10368 "type": "object",
10369 "required": [
10370 "mode",
10371 "cookie_same_site"
10372 ],
10373 "properties": {
10374 "cookie_domain": {
10375 "type": [
10376 "string",
10377 "null"
10378 ]
10379 },
10380 "cookie_name": {
10381 "type": [
10382 "string",
10383 "null"
10384 ]
10385 },
10386 "cookie_same_site": {
10387 "type": "string"
10388 },
10389 "cookie_secure": {
10390 "type": [
10391 "boolean",
10392 "null"
10393 ]
10394 },
10395 "lifespan_secs": {
10396 "type": [
10397 "integer",
10398 "null"
10399 ],
10400 "format": "int64"
10401 },
10402 "mode": {
517210403 "type": "string"
5173 }
5174 }
5175 },
5176 "ResetUserPasswordCmd": {
5177 "type": "object",
5178 "required": [
5179 "new_password"
5180 ],
5181 "properties": {
5182 "new_password": {
5183 "type": "string"
5184 }
5185 }
5186 },
5187 "RevokeSessionsResp": {
5188 "type": "object",
5189 "required": [
5190 "revoked"
5191 ],
5192 "properties": {
5193 "revoked": {
5194 "type": "integer",
5195 "format": "int64",
5196 "minimum": 0
10404 },
10405 "table_name": {
10406 "type": [
10407 "string",
10408 "null"
10409 ]
519710410 }
519810411 }
519910412 },
@@ -5224,6 +10437,29 @@
522410437 }
522510438 }
522610439 },
10440 "StorageConfigDto": {
10441 "type": "object",
10442 "properties": {
10443 "bucket": {
10444 "type": [
10445 "string",
10446 "null"
10447 ]
10448 },
10449 "path_style": {
10450 "type": [
10451 "boolean",
10452 "null"
10453 ]
10454 },
10455 "region": {
10456 "type": [
10457 "string",
10458 "null"
10459 ]
10460 }
10461 }
10462 },
522710463 "StorageObjectDto": {
522810464 "type": "object",
522910465 "required": [
@@ -5342,6 +10578,69 @@
534210578 "permanent"
534310579 ]
534410580 },
10581 "SystemConfigDto": {
10582 "type": "object",
10583 "required": [
10584 "version",
10585 "hosts",
10586 "admin",
10587 "features",
10588 "search",
10589 "import",
10590 "git",
10591 "storage",
10592 "database",
10593 "redis",
10594 "session",
10595 "email",
10596 "generated_at"
10597 ],
10598 "properties": {
10599 "admin": {
10600 "$ref": "#/components/schemas/AdminEndpointDto"
10601 },
10602 "database": {
10603 "$ref": "#/components/schemas/DatabaseConfigDto"
10604 },
10605 "email": {
10606 "$ref": "#/components/schemas/EmailConfigDto"
10607 },
10608 "features": {
10609 "$ref": "#/components/schemas/FeaturesDto"
10610 },
10611 "generated_at": {
10612 "type": "string",
10613 "format": "date-time"
10614 },
10615 "git": {
10616 "$ref": "#/components/schemas/GitConfigDto"
10617 },
10618 "hosts": {
10619 "type": "array",
10620 "items": {
10621 "$ref": "#/components/schemas/HostEntryDto"
10622 }
10623 },
10624 "import": {
10625 "$ref": "#/components/schemas/ImportConfigDto"
10626 },
10627 "redis": {
10628 "$ref": "#/components/schemas/RedisConfigDto"
10629 },
10630 "search": {
10631 "$ref": "#/components/schemas/SearchConfigDto"
10632 },
10633 "session": {
10634 "$ref": "#/components/schemas/SessionConfigDto"
10635 },
10636 "storage": {
10637 "$ref": "#/components/schemas/StorageConfigDto"
10638 },
10639 "version": {
10640 "type": "string"
10641 }
10642 }
10643 },
534510644 "SystemStatusDto": {
534610645 "type": "object",
534710646 "required": [
@@ -5497,6 +10796,61 @@
549710796 }
549810797 }
549910798 },
10799 "TaskSourceStatDto": {
10800 "type": "object",
10801 "required": [
10802 "source",
10803 "queued",
10804 "running",
10805 "succeeded",
10806 "failed"
10807 ],
10808 "properties": {
10809 "failed": {
10810 "type": "integer",
10811 "format": "int64"
10812 },
10813 "queued": {
10814 "type": "integer",
10815 "format": "int64"
10816 },
10817 "running": {
10818 "type": "integer",
10819 "format": "int64"
10820 },
10821 "source": {
10822 "type": "string"
10823 },
10824 "succeeded": {
10825 "type": "integer",
10826 "format": "int64"
10827 }
10828 }
10829 },
10830 "TaskStatsDto": {
10831 "type": "object",
10832 "required": [
10833 "sources",
10834 "total",
10835 "generated_at"
10836 ],
10837 "properties": {
10838 "generated_at": {
10839 "type": "string",
10840 "format": "date-time"
10841 },
10842 "sources": {
10843 "type": "array",
10844 "items": {
10845 "$ref": "#/components/schemas/TaskSourceStatDto"
10846 }
10847 },
10848 "total": {
10849 "type": "integer",
10850 "format": "int64"
10851 }
10852 }
10853 },
550010854 "TransferSpaceCmd": {
550110855 "type": "object",
550210856 "required": [
@@ -5509,6 +10863,32 @@
550910863 }
551010864 }
551110865 },
10866 "TrendPointDto": {
10867 "type": "object",
10868 "required": [
10869 "date",
10870 "users",
10871 "spaces",
10872 "repos"
10873 ],
10874 "properties": {
10875 "date": {
10876 "type": "string"
10877 },
10878 "repos": {
10879 "type": "integer",
10880 "format": "int64"
10881 },
10882 "spaces": {
10883 "type": "integer",
10884 "format": "int64"
10885 },
10886 "users": {
10887 "type": "integer",
10888 "format": "int64"
10889 }
10890 }
10891 },
551210892 "UpdateAdminCmd": {
551310893 "type": "object",
551410894 "properties": {
@@ -5536,9 +10916,56 @@
553610916 }
553710917 }
553810918 },
10919 "UpdateChatRoomCmd": {
10920 "type": "object",
10921 "properties": {
10922 "is_active": {
10923 "type": [
10924 "boolean",
10925 "null"
10926 ]
10927 },
10928 "is_private": {
10929 "type": [
10930 "boolean",
10931 "null"
10932 ]
10933 },
10934 "name": {
10935 "type": [
10936 "string",
10937 "null"
10938 ]
10939 },
10940 "nsfw": {
10941 "type": [
10942 "boolean",
10943 "null"
10944 ]
10945 },
10946 "topic": {
10947 "type": [
10948 "string",
10949 "null"
10950 ]
10951 }
10952 }
10953 },
553910954 "UpdateSpaceCmd": {
554010955 "type": "object",
554110956 "properties": {
10957 "allow_invite": {
10958 "type": [
10959 "boolean",
10960 "null"
10961 ]
10962 },
10963 "allow_join": {
10964 "type": [
10965 "boolean",
10966 "null"
10967 ]
10968 },
554210969 "description": {
554310970 "type": [
554410971 "string",
@@ -5557,6 +10984,12 @@
555710984 "null"
555810985 ]
555910986 },
10987 "member_must_enable_2fa": {
10988 "type": [
10989 "boolean",
10990 "null"
10991 ]
10992 },
556010993 "name": {
556110994 "type": [
556210995 "string",
Modified lib/admin/src/handlers/admins.rs View lib/admin/src/handlers/admins.rs
@@ -155,4 +155,28 @@
155155 .reset_admin_password(&actor, id, cmd, &ctx)
156156 .await?;
157157 Ok(AppResponseData::empty())
158}
159
160#[utoipa::path(
161 post,
162 path = "/admin/admins/{id}/revoke-sessions",
163 tag = "admin-admins",
164 description = "踢出管理员全部会话:提升口令版本使已签发会话立即失效;操作他人需超级管理员。",
165 params(("id" = Uuid, Path, description = "admin id")),
166 responses(
167 (status = 200, description = "sessions revoked", body = AppResponseData<NoData>),
168 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>),
169 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
170 )
171)]
172pub async fn revoke_admin_sessions(
173 State(service): State<AdminService>,
174 CurrentAdmin(actor): CurrentAdmin,
175 Path(id): Path<Uuid>,
176 headers: HeaderMap,
177 request_id: Option<Extension<RequestId>>,
178) -> Result<AppResponseData<NoData>, AdminApiError> {
179 let ctx = audit_ctx(&headers, request_id);
180 service.revoke_admin_sessions(&actor, id, &ctx).await?;
181 Ok(AppResponseData::empty())
158182}
No newline at end of file
Added lib/admin/src/handlers/chat.rs View lib/admin/src/handlers/chat.rs
@@ -0,0 +1,338 @@
1use axum::Json;
2use axum::extract::{Extension, Path, Query, State};
3use axum::http::HeaderMap;
4use axum::response::Response;
5use export::pagination::Page;
6use export::middleware::RequestId;
7use export::rest::{AppResponseData, NoData};
8use uuid::Uuid;
9
10use crate::errors::AdminApiError;
11use crate::services::chat::{
12 AdminChatAttachmentDto, AdminChatMessageDetail, AdminChatMessageDto, AdminChatRoomDetail,
13 AdminChatRoomMemberDto, AdminChatRoomSummary, AdminChatStatsDto, BulkDeleteChatMessagesCmd,
14 BulkDeleteChatMessagesResp, DeleteChatAttachmentResp, DeleteChatRoomResp, ListChatAttachmentsQuery,
15 ListChatMembersQuery, ListChatMessagesQuery, ListChatRoomsQuery, PurgeChatMessagesResp,
16 RemoveChatMemberResp, UpdateChatRoomCmd,
17};
18use crate::services::service::AdminService;
19use crate::share::roles::CurrentAdmin;
20
21use super::{audit_ctx, csv_field, csv_response};
22
23#[utoipa::path(
24 get,
25 path = "/admin/chat/stats",
26 tag = "admin-chat",
27 description = "聊天域统计:房间/消息/线程/附件/成员/封禁汇总与房间类型分布。",
28 responses(
29 (status = 200, description = "chat stats", body = AppResponseData<AdminChatStatsDto>),
30 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
31 )
32)]
33pub async fn stats(
34 State(service): State<AdminService>,
35) -> Result<AppResponseData<AdminChatStatsDto>, AdminApiError> {
36 Ok(AppResponseData::ok(service.chat_stats().await?))
37}
38
39#[utoipa::path(
40 get,
41 path = "/admin/chat/rooms",
42 tag = "admin-chat",
43 description = "房间列表:关键字、Space、类型、可见性与启停状态筛选。",
44 params(ListChatRoomsQuery),
45 responses(
46 (status = 200, description = "room page", body = AppResponseData<Page<AdminChatRoomSummary>>),
47 (status = 400, description = "request failed validation", body = AppResponseData<NoData>)
48 )
49)]
50pub async fn list_rooms(
51 State(service): State<AdminService>,
52 Query(query): Query<ListChatRoomsQuery>,
53) -> Result<AppResponseData<Page<AdminChatRoomSummary>>, AdminApiError> {
54 Ok(AppResponseData::ok(service.list_chat_rooms(query).await?))
55}
56
57#[utoipa::path(
58 get,
59 path = "/admin/chat/rooms/{id}",
60 tag = "admin-chat",
61 description = "房间详情:基础信息、成员/消息计数与删除/封禁/邀请统计。",
62 params(("id" = Uuid, Path, description = "room id")),
63 responses(
64 (status = 200, description = "room detail", body = AppResponseData<AdminChatRoomDetail>),
65 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
66 )
67)]
68pub async fn get_room(
69 State(service): State<AdminService>,
70 Path(id): Path<Uuid>,
71) -> Result<AppResponseData<AdminChatRoomDetail>, AdminApiError> {
72 Ok(AppResponseData::ok(service.get_chat_room(id).await?))
73}
74
75#[utoipa::path(
76 patch,
77 path = "/admin/chat/rooms/{id}",
78 tag = "admin-chat",
79 description = "更新房间属性:名称、主题、私有、NSFW 与启停。",
80 params(("id" = Uuid, Path, description = "room id")),
81 request_body = UpdateChatRoomCmd,
82 responses(
83 (status = 200, description = "room updated", body = AppResponseData<AdminChatRoomDetail>),
84 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
85 )
86)]
87pub async fn update_room(
88 State(service): State<AdminService>,
89 CurrentAdmin(actor): CurrentAdmin,
90 Path(id): Path<Uuid>,
91 headers: HeaderMap,
92 request_id: Option<Extension<RequestId>>,
93 Json(cmd): Json<UpdateChatRoomCmd>,
94) -> Result<AppResponseData<AdminChatRoomDetail>, AdminApiError> {
95 let ctx = audit_ctx(&headers, request_id);
96 Ok(AppResponseData::ok(
97 service.update_chat_room(&actor, id, cmd, &ctx).await?,
98 ))
99}
100
101#[utoipa::path(
102 delete,
103 path = "/admin/chat/rooms/{id}",
104 tag = "admin-chat",
105 description = "删除房间及其消息/成员/线程/附件,动作不可恢复;仅超级管理员可操作。",
106 params(("id" = Uuid, Path, description = "room id")),
107 responses(
108 (status = 200, description = "room deleted", body = AppResponseData<DeleteChatRoomResp>),
109 (status = 409, description = "child rooms exist", body = AppResponseData<NoData>)
110 )
111)]
112pub async fn delete_room(
113 State(service): State<AdminService>,
114 CurrentAdmin(actor): CurrentAdmin,
115 Path(id): Path<Uuid>,
116 headers: HeaderMap,
117 request_id: Option<Extension<RequestId>>,
118) -> Result<AppResponseData<DeleteChatRoomResp>, AdminApiError> {
119 let ctx = audit_ctx(&headers, request_id);
120 Ok(AppResponseData::ok(
121 service.delete_chat_room(&actor, id, &ctx).await?,
122 ))
123}
124
125#[utoipa::path(
126 get,
127 path = "/admin/chat/rooms/{id}/members",
128 tag = "admin-chat",
129 description = "房间成员列表:可按在席/已离开筛选。",
130 params(("id" = Uuid, Path, description = "room id"), ListChatMembersQuery),
131 responses(
132 (status = 200, description = "member page", body = AppResponseData<Page<AdminChatRoomMemberDto>>),
133 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
134 )
135)]
136pub async fn list_members(
137 State(service): State<AdminService>,
138 Path(id): Path<Uuid>,
139 Query(query): Query<ListChatMembersQuery>,
140) -> Result<AppResponseData<Page<AdminChatRoomMemberDto>>, AdminApiError> {
141 Ok(AppResponseData::ok(
142 service.list_chat_room_members(id, query).await?,
143 ))
144}
145
146#[utoipa::path(
147 delete,
148 path = "/admin/chat/rooms/{id}/members/{uid}",
149 tag = "admin-chat",
150 description = "将成员移出房间,动作即时生效并广播 member_left 事件。",
151 params(
152 ("id" = Uuid, Path, description = "room id"),
153 ("uid" = Uuid, Path, description = "user id")
154 ),
155 responses(
156 (status = 200, description = "member removed", body = AppResponseData<RemoveChatMemberResp>),
157 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
158 )
159)]
160pub async fn remove_member(
161 State(service): State<AdminService>,
162 CurrentAdmin(actor): CurrentAdmin,
163 Path((id, uid)): Path<(Uuid, Uuid)>,
164 headers: HeaderMap,
165 request_id: Option<Extension<RequestId>>,
166) -> Result<AppResponseData<RemoveChatMemberResp>, AdminApiError> {
167 let ctx = audit_ctx(&headers, request_id);
168 Ok(AppResponseData::ok(
169 service.remove_chat_room_member(&actor, id, uid, &ctx).await?,
170 ))
171}
172
173#[utoipa::path(
174 post,
175 path = "/admin/chat/rooms/{id}/purge",
176 tag = "admin-chat",
177 description = "清空房间全部消息、线程与附件(保留房间本身);仅超级管理员可操作。",
178 params(("id" = Uuid, Path, description = "room id")),
179 responses(
180 (status = 200, description = "room messages purged", body = AppResponseData<PurgeChatMessagesResp>),
181 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
182 )
183)]
184pub async fn purge_room(
185 State(service): State<AdminService>,
186 CurrentAdmin(actor): CurrentAdmin,
187 Path(id): Path<Uuid>,
188 headers: HeaderMap,
189 request_id: Option<Extension<RequestId>>,
190) -> Result<AppResponseData<PurgeChatMessagesResp>, AdminApiError> {
191 let ctx = audit_ctx(&headers, request_id);
192 Ok(AppResponseData::ok(
193 service.purge_chat_room_messages(&actor, id, &ctx).await?,
194 ))
195}
196
197#[utoipa::path(
198 get,
199 path = "/admin/chat/messages",
200 tag = "admin-chat",
201 description = "消息列表:按房间/作者/状态/关键字/时间筛选,返回附件与表情计数。",
202 params(ListChatMessagesQuery),
203 responses(
204 (status = 200, description = "message page", body = AppResponseData<Page<AdminChatMessageDto>>),
205 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
206 )
207)]
208pub async fn list_messages(
209 State(service): State<AdminService>,
210 Query(query): Query<ListChatMessagesQuery>,
211) -> Result<AppResponseData<Page<AdminChatMessageDto>>, AdminApiError> {
212 Ok(AppResponseData::ok(service.list_chat_messages(query).await?))
213}
214
215#[utoipa::path(
216 get,
217 path = "/admin/chat/messages/export",
218 tag = "admin-chat",
219 description = "按当前筛选导出消息 CSV(最多 10000 行)。",
220 params(ListChatMessagesQuery),
221 responses((status = 200, description = "csv export"))
222)]
223pub async fn export_messages(
224 State(service): State<AdminService>,
225 Query(query): Query<ListChatMessagesQuery>,
226) -> Result<Response, AdminApiError> {
227 let rows = service.export_chat_messages(query).await?;
228 let mut body = String::from(
229 "id,room_id,room_name,author_id,author_username,message_type,content,seq,pinned,attachment_count,reaction_count,edited_at,deleted_at,created_at\n",
230 );
231 for row in rows {
232 let line = [
233 row.id.to_string(),
234 row.room_id.to_string(),
235 csv_field(row.room_name.as_deref().unwrap_or("")),
236 row.author_id.to_string(),
237 csv_field(row.author_username.as_deref().unwrap_or("")),
238 csv_field(&row.message_type),
239 csv_field(&row.content),
240 row.seq.to_string(),
241 row.pinned.to_string(),
242 row.attachment_count.to_string(),
243 row.reaction_count.to_string(),
244 csv_field(&row.edited_at.map(|value| value.to_string()).unwrap_or_default()),
245 csv_field(&row.deleted_at.map(|value| value.to_string()).unwrap_or_default()),
246 csv_field(&row.created_at.to_string()),
247 ];
248 body.push_str(&line.join(","));
249 body.push('\n');
250 }
251 Ok(csv_response("gitdata-chat-messages.csv", body))
252}
253
254#[utoipa::path(
255 get,
256 path = "/admin/chat/messages/{id}",
257 tag = "admin-chat",
258 description = "消息详情:正文、编辑历史、表情聚合、附件与所属线程。",
259 params(("id" = Uuid, Path, description = "message id")),
260 responses(
261 (status = 200, description = "message detail", body = AppResponseData<AdminChatMessageDetail>),
262 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
263 )
264)]
265pub async fn get_message(
266 State(service): State<AdminService>,
267 Path(id): Path<Uuid>,
268) -> Result<AppResponseData<AdminChatMessageDetail>, AdminApiError> {
269 Ok(AppResponseData::ok(service.get_chat_message(id).await?))
270}
271
272#[utoipa::path(
273 post,
274 path = "/admin/chat/messages/bulk-delete",
275 tag = "admin-chat",
276 description = "批量软删除消息(≤100 条),清空内容并移除附件/表情/编辑等从属记录。",
277 request_body = BulkDeleteChatMessagesCmd,
278 responses(
279 (status = 200, description = "bulk delete result", body = AppResponseData<BulkDeleteChatMessagesResp>),
280 (status = 400, description = "request failed validation", body = AppResponseData<NoData>)
281 )
282)]
283pub async fn bulk_delete_messages(
284 State(service): State<AdminService>,
285 CurrentAdmin(actor): CurrentAdmin,
286 headers: HeaderMap,
287 request_id: Option<Extension<RequestId>>,
288 Json(cmd): Json<BulkDeleteChatMessagesCmd>,
289) -> Result<AppResponseData<BulkDeleteChatMessagesResp>, AdminApiError> {
290 let ctx = audit_ctx(&headers, request_id);
291 Ok(AppResponseData::ok(
292 service.bulk_delete_chat_messages(&actor, cmd, &ctx).await?,
293 ))
294}
295
296#[utoipa::path(
297 get,
298 path = "/admin/chat/attachments",
299 tag = "admin-chat",
300 description = "聊天附件列表:按房间/上传者/状态/文件名筛选。",
301 params(ListChatAttachmentsQuery),
302 responses(
303 (status = 200, description = "attachment page", body = AppResponseData<Page<AdminChatAttachmentDto>>),
304 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
305 )
306)]
307pub async fn list_attachments(
308 State(service): State<AdminService>,
309 Query(query): Query<ListChatAttachmentsQuery>,
310) -> Result<AppResponseData<Page<AdminChatAttachmentDto>>, AdminApiError> {
311 Ok(AppResponseData::ok(
312 service.list_chat_attachments(query).await?,
313 ))
314}
315
316#[utoipa::path(
317 delete,
318 path = "/admin/chat/attachments/{id}",
319 tag = "admin-chat",
320 description = "删除聊天附件记录并清理未被其它记录引用的对象存储文件;仅超级管理员可操作。",
321 params(("id" = Uuid, Path, description = "attachment id")),
322 responses(
323 (status = 200, description = "attachment deleted", body = AppResponseData<DeleteChatAttachmentResp>),
324 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
325 )
326)]
327pub async fn delete_attachment(
328 State(service): State<AdminService>,
329 CurrentAdmin(actor): CurrentAdmin,
330 Path(id): Path<Uuid>,
331 headers: HeaderMap,
332 request_id: Option<Extension<RequestId>>,
333) -> Result<AppResponseData<DeleteChatAttachmentResp>, AdminApiError> {
334 let ctx = audit_ctx(&headers, request_id);
335 Ok(AppResponseData::ok(
336 service.delete_chat_attachment(&actor, id, &ctx).await?,
337 ))
338}
Modified lib/admin/src/handlers/logs.rs View lib/admin/src/handlers/logs.rs
@@ -1,10 +1,13 @@
11use axum::extract::{Query, State};
2use axum::response::Response;
23use export::rest::{AppResponseData, NoData};
34
45use crate::errors::AdminApiError;
56use crate::services::logs::{AuditLogPageDto, JobLogPageDto, ListAuditQuery, ListJobLogsQuery};
67use crate::services::service::AdminService;
78
9use super::{csv_field, csv_response};
10
811#[utoipa::path(
912 get,
1013 path = "/admin/logs/audit",
@@ -41,4 +44,38 @@
4144) -> Result<AppResponseData<JobLogPageDto>, AdminApiError> {
4245 let result = service.list_job_logs(query).await?;
4346 Ok(AppResponseData::ok(result))
47}
48#[utoipa::path(
49 get,
50 path = "/admin/logs/audit/export",
51 tag = "admin-logs",
52 description = "按筛选条件导出审计日志 CSV(最多 10000 行)。",
53 params(ListAuditQuery),
54 responses(
55 (status = 200, description = "csv export")
56 )
57)]
58pub async fn export_audit_logs(
59 State(service): State<AdminService>,
60 Query(query): Query<ListAuditQuery>,
61) -> Result<Response, AdminApiError> {
62 let rows = service.export_audit_logs(query).await?;
63 let mut body = String::from(
64 "created_at,admin_username,action,target_type,target_id,ip,request_id,detail\n",
65 );
66 for row in rows {
67 let line = [
68 csv_field(&row.created_at.to_string()),
69 csv_field(&row.admin_username),
70 csv_field(&row.action),
71 csv_field(row.target_type.as_deref().unwrap_or_default()),
72 csv_field(row.target_id.as_deref().unwrap_or_default()),
73 csv_field(row.ip.as_deref().unwrap_or_default()),
74 csv_field(row.request_id.as_deref().unwrap_or_default()),
75 csv_field(&row.detail.to_string()),
76 ];
77 body.push_str(&line.join(","));
78 body.push('\n');
79 }
80 Ok(csv_response("gitdata-admin-audit.csv", body))
4481}
No newline at end of file
Modified lib/admin/src/handlers/mod.rs View lib/admin/src/handlers/mod.rs
@@ -1,5 +1,6 @@
11pub mod admins;
22pub mod auth;
3pub mod chat;
34pub mod logs;
45pub mod queues;
56pub mod spaces;
@@ -9,11 +10,34 @@
910pub mod users;
1011
1112use axum::extract::Extension;
12use axum::http::HeaderMap;
13use axum::http::header;
14use axum::http::{HeaderMap, HeaderValue};
15use axum::response::{IntoResponse, Response};
1316use export::middleware::RequestId;
1417
1518use crate::services::audit::AuditCtx;
1619
1720pub(crate) fn audit_ctx(headers: &HeaderMap, request_id: Option<Extension<RequestId>>) -> AuditCtx {
1821 AuditCtx::from_headers(headers).with_request_id(request_id.map(|Extension(value)| value.0))
22}
23pub(crate) fn csv_response(filename: &str, body: String) -> Response {
24 let mut response = body.into_response();
25 response.headers_mut().insert(
26 header::CONTENT_TYPE,
27 HeaderValue::from_static("text/csv; charset=utf-8"),
28 );
29 let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
30 .unwrap_or_else(|_| HeaderValue::from_static("attachment"));
31 response
32 .headers_mut()
33 .insert(header::CONTENT_DISPOSITION, disposition);
34 response
35}
36
37pub(crate) fn csv_field(value: &str) -> String {
38 if value.contains([',', '"', '\n', '\r']) {
39 format!("\"{}\"", value.replace('"', "\"\""))
40 } else {
41 value.to_string()
42 }
1943}
No newline at end of file
Modified lib/admin/src/handlers/queues.rs View lib/admin/src/handlers/queues.rs
@@ -1,9 +1,12 @@
1use axum::extract::{Extension, Path, State};
1use axum::extract::{Extension, Path, Query, State};
22use axum::http::HeaderMap;
33use export::rest::{AppResponseData, NoData};
44use export::middleware::RequestId;
5use serde::Deserialize;
6use utoipa::IntoParams;
57
68use crate::errors::AdminApiError;
9use crate::services::logs::JobLogPageDto;
710use crate::services::queues::{QueueActionResp, QueueSnapshotDto};
811use crate::services::service::AdminService;
912use crate::share::roles::CurrentAdmin;
@@ -71,4 +74,31 @@
7174 let ctx = audit_ctx(&headers, request_id);
7275 let result = service.purge_queue(&actor, &name, &ctx).await?;
7376 Ok(AppResponseData::ok(result))
77}
78#[derive(Debug, Clone, Deserialize, IntoParams)]
79pub struct QueueFailedQuery {
80 pub limit: Option<u64>,
81 pub offset: Option<u64>,
82}
83
84#[utoipa::path(
85 get,
86 path = "/admin/queues/{name}/failed",
87 tag = "admin-queues",
88 description = "队列失败明细:返回该队列来源的失败作业(数据库队列),可按任务模块重试。",
89 params(("name" = String, Path, description = "queue name"), QueueFailedQuery),
90 responses(
91 (status = 200, description = "failed job page", body = AppResponseData<JobLogPageDto>),
92 (status = 400, description = "unsupported queue", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn failed_jobs(
96 State(service): State<AdminService>,
97 Path(name): Path<String>,
98 Query(query): Query<QueueFailedQuery>,
99) -> Result<AppResponseData<JobLogPageDto>, AdminApiError> {
100 let result = service
101 .queue_failed_jobs(&name, query.limit.unwrap_or(20), query.offset.unwrap_or(0))
102 .await?;
103 Ok(AppResponseData::ok(result))
74104}
No newline at end of file
Modified lib/admin/src/handlers/spaces.rs View lib/admin/src/handlers/spaces.rs
@@ -9,8 +9,8 @@
99use crate::errors::AdminApiError;
1010use crate::services::service::AdminService;
1111use crate::services::spaces::{
12 AdminSpaceDetail, AdminSpaceMemberDto, AdminSpaceSummary, ListMembersQuery, ListSpacesQuery,
13 RemoveMemberResp, TransferSpaceCmd, UpdateSpaceCmd,
12 AddSpaceMemberCmd, AddSpaceMemberResp, AdminSpaceDetail, AdminSpaceMemberDto, AdminSpaceSummary,
13 ListMembersQuery, ListSpacesQuery, RemoveMemberResp, TransferSpaceCmd, UpdateSpaceCmd,
1414};
1515use crate::share::roles::CurrentAdmin;
1616
@@ -175,4 +175,29 @@
175175 let ctx = audit_ctx(&headers, request_id);
176176 let result = service.remove_space_member(&actor, id, uid, &ctx).await?;
177177 Ok(AppResponseData::ok(result))
178}
179#[utoipa::path(
180 post,
181 path = "/admin/spaces/{id}/members",
182 tag = "admin-spaces",
183 description = "管理员将用户加入 Space:已存在记录则重新激活,并同步成员计数。",
184 params(("id" = Uuid, Path, description = "space id")),
185 request_body = AddSpaceMemberCmd,
186 responses(
187 (status = 200, description = "member added", body = AppResponseData<AddSpaceMemberResp>),
188 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
189 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
190 )
191)]
192pub async fn add_space_member(
193 State(service): State<AdminService>,
194 CurrentAdmin(actor): CurrentAdmin,
195 Path(id): Path<Uuid>,
196 headers: HeaderMap,
197 request_id: Option<Extension<RequestId>>,
198 Json(cmd): Json<AddSpaceMemberCmd>,
199) -> Result<AppResponseData<AddSpaceMemberResp>, AdminApiError> {
200 let ctx = audit_ctx(&headers, request_id);
201 let result = service.add_space_member(&actor, id, cmd, &ctx).await?;
202 Ok(AppResponseData::ok(result))
178203}
No newline at end of file
Modified lib/admin/src/handlers/system.rs View lib/admin/src/handlers/system.rs
@@ -3,7 +3,7 @@
33
44use crate::errors::AdminApiError;
55use crate::services::service::AdminService;
6use crate::services::system::SystemStatusDto;
6use crate::services::system::{DashboardDto, SystemConfigDto, SystemStatusDto};
77
88#[utoipa::path(
99 get,
@@ -20,4 +20,36 @@
2020) -> Result<AppResponseData<SystemStatusDto>, AdminApiError> {
2121 let result = service.system_status().await?;
2222 Ok(AppResponseData::ok(result))
23}
24#[utoipa::path(
25 get,
26 path = "/admin/system/config",
27 tag = "admin-system",
28 description = "生效配置快照(脱敏):进程监听、功能开关、检索/导入/Git 限制、存储与数据库/Redis/会话/邮件端点,不含任何密钥。",
29 responses(
30 (status = 200, description = "system config", body = AppResponseData<SystemConfigDto>),
31 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
32 )
33)]
34pub async fn config(
35 State(service): State<AdminService>,
36) -> Result<AppResponseData<SystemConfigDto>, AdminApiError> {
37 let result = service.system_config().await?;
38 Ok(AppResponseData::ok(result))
39}
40#[utoipa::path(
41 get,
42 path = "/admin/dashboard",
43 tag = "admin-system",
44 description = "管理看板聚合:核心计数、14 天增长趋势、活跃会话、最近审计与失败、队列概览。",
45 responses(
46 (status = 200, description = "dashboard", body = AppResponseData<DashboardDto>),
47 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
48 )
49)]
50pub async fn dashboard(
51 State(service): State<AdminService>,
52) -> Result<AppResponseData<DashboardDto>, AdminApiError> {
53 let result = service.dashboard().await?;
54 Ok(AppResponseData::ok(result))
2355}
No newline at end of file
Modified lib/admin/src/handlers/tasks.rs View lib/admin/src/handlers/tasks.rs
@@ -6,7 +6,10 @@
66
77use crate::errors::AdminApiError;
88use crate::services::service::AdminService;
9use crate::services::tasks::{ListTasksQuery, TaskActionResp, TaskDetailDto, TaskPageDto};
9use crate::services::tasks::{
10 BulkTaskCmd, BulkTaskResp, ListTasksQuery, TaskActionResp, TaskDetailDto, TaskPageDto,
11 TaskStatsDto,
12};
1013use crate::share::roles::CurrentAdmin;
1114
1215use super::audit_ctx;
@@ -128,4 +131,44 @@
128131 let ctx = audit_ctx(&headers, request_id);
129132 service.delete_task(&actor, &source, id, &ctx).await?;
130133 Ok(AppResponseData::empty())
134}
135#[utoipa::path(
136 get,
137 path = "/admin/tasks/stats",
138 tag = "admin-tasks",
139 description = "任务统计:各来源的排队/执行中/成功/失败计数,用于任务总览与看板。",
140 responses(
141 (status = 200, description = "task stats", body = AppResponseData<TaskStatsDto>)
142 )
143)]
144pub async fn task_stats(
145 State(service): State<AdminService>,
146) -> Result<AppResponseData<TaskStatsDto>, AdminApiError> {
147 let result = service.task_stats().await?;
148 Ok(AppResponseData::ok(result))
149}
150
151#[utoipa::path(
152 post,
153 path = "/admin/tasks/{source}/bulk",
154 tag = "admin-tasks",
155 description = "批量重试或取消同一来源的任务,单次最多 100 个;逐条返回结果。",
156 params(("source" = String, Path, description = "task source")),
157 request_body = BulkTaskCmd,
158 responses(
159 (status = 200, description = "bulk result", body = AppResponseData<BulkTaskResp>),
160 (status = 400, description = "request failed validation", body = AppResponseData<NoData>)
161 )
162)]
163pub async fn bulk_tasks(
164 State(service): State<AdminService>,
165 CurrentAdmin(actor): CurrentAdmin,
166 Path(source): Path<String>,
167 headers: HeaderMap,
168 request_id: Option<Extension<RequestId>>,
169 axum::Json(cmd): axum::Json<BulkTaskCmd>,
170) -> Result<AppResponseData<BulkTaskResp>, AdminApiError> {
171 let ctx = audit_ctx(&headers, request_id);
172 let result = service.bulk_tasks(&actor, &source, cmd, &ctx).await?;
173 Ok(AppResponseData::ok(result))
131174}
No newline at end of file
Modified lib/admin/src/handlers/users.rs View lib/admin/src/handlers/users.rs
@@ -1,6 +1,7 @@
11use axum::Json;
22use axum::extract::{Extension, Path, Query, State};
33use axum::http::HeaderMap;
4use axum::response::Response;
45use export::pagination::Page;
56use export::rest::{AppResponseData, NoData};
67use export::middleware::RequestId;
@@ -9,12 +10,14 @@
910use crate::errors::AdminApiError;
1011use crate::services::service::AdminService;
1112use crate::services::users::{
12 AdminSuspensionDto, AdminUserDetail, AdminUserSummary, DeleteUserResp, ListUsersQuery,
13 ResetUserPasswordCmd, RevokeSessionsResp, SuspendUserCmd, UpdateUserCmd,
13 AdminSuspensionDto, AdminUserDetail, AdminUserSecurityDto, AdminUserSessionPage,
14 AdminUserSummary, BulkUsersCmd, BulkUsersResp, CreateUserCmd, CreateUserResp, DeleteUserResp,
15 ListUserSessionsQuery, ListUsersQuery, ResetUserPasswordCmd, RevokeSessionsResp, SuspendUserCmd,
16 UpdateUserCmd,
1417};
1518use crate::share::roles::CurrentAdmin;
1619
17use super::audit_ctx;
20use super::{audit_ctx, csv_field, csv_response};
1821
1922#[utoipa::path(
2023 get,
@@ -205,4 +208,186 @@
205208 let ctx = audit_ctx(&headers, request_id);
206209 let result = service.admin_delete_user(&actor, uid, &ctx).await?;
207210 Ok(AppResponseData::ok(result))
211}
212
213#[utoipa::path(
214 post,
215 path = "/admin/users",
216 tag = "admin-users",
217 description = "创建用户(管理员代办):直接写入账号、资料与已激活邮箱;仅超级管理员可操作。",
218 request_body = CreateUserCmd,
219 responses(
220 (status = 200, description = "user created", body = AppResponseData<CreateUserResp>),
221 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
222 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>),
223 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
224 )
225)]
226pub async fn create_user(
227 State(service): State<AdminService>,
228 CurrentAdmin(actor): CurrentAdmin,
229 headers: HeaderMap,
230 request_id: Option<Extension<RequestId>>,
231 Json(cmd): Json<CreateUserCmd>,
232) -> Result<AppResponseData<CreateUserResp>, AdminApiError> {
233 let ctx = audit_ctx(&headers, request_id);
234 let result = service.create_user(&actor, cmd, &ctx).await?;
235 Ok(AppResponseData::ok(result))
236}
237
238#[utoipa::path(
239 post,
240 path = "/admin/users/bulk",
241 tag = "admin-users",
242 description = "批量用户操作:activate/deactivate/restrict/unrestrict/revoke_sessions/suspend/delete,单次最多 100 个目标。",
243 request_body = BulkUsersCmd,
244 responses(
245 (status = 200, description = "bulk result", body = AppResponseData<BulkUsersResp>),
246 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
247 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
248 )
249)]
250pub async fn bulk_users(
251 State(service): State<AdminService>,
252 CurrentAdmin(actor): CurrentAdmin,
253 headers: HeaderMap,
254 request_id: Option<Extension<RequestId>>,
255 Json(cmd): Json<BulkUsersCmd>,
256) -> Result<AppResponseData<BulkUsersResp>, AdminApiError> {
257 let ctx = audit_ctx(&headers, request_id);
258 let result = service.bulk_users(&actor, cmd, &ctx).await?;
259 Ok(AppResponseData::ok(result))
260}
261
262#[utoipa::path(
263 get,
264 path = "/admin/users/export",
265 tag = "admin-users",
266 description = "按当前筛选条件导出用户 CSV(最多 5000 行)。",
267 params(ListUsersQuery),
268 responses(
269 (status = 200, description = "csv export")
270 )
271)]
272pub async fn export_users(
273 State(service): State<AdminService>,
274 Query(query): Query<ListUsersQuery>,
275) -> Result<Response, AdminApiError> {
276 let rows = service.export_users(query).await?;
277 let mut body = String::from(
278 "uid,username,display_name,is_active,is_restricted,prohibit_login,suspended,bot,system,created_at,last_login_at\n",
279 );
280 for row in rows {
281 let line = [
282 csv_field(&row.uid.to_string()),
283 csv_field(&row.username),
284 csv_field(&row.display_name),
285 row.is_active.to_string(),
286 row.is_restricted.to_string(),
287 row.prohibit_login.to_string(),
288 row.suspended.to_string(),
289 row.bot.to_string(),
290 row.system.to_string(),
291 csv_field(&row.created_at.to_string()),
292 csv_field(&row.last_login_at.map(|value| value.to_string()).unwrap_or_default()),
293 ];
294 body.push_str(&line.join(","));
295 body.push('\n');
296 }
297 Ok(csv_response("gitdata-users.csv", body))
298}
299
300#[utoipa::path(
301 get,
302 path = "/admin/users/{uid}/sessions",
303 tag = "admin-users",
304 description = "用户会话列表:可按 kind 过滤、包含已吊销会话,用于会话级踢下线。",
305 params(
306 ("uid" = Uuid, Path, description = "user id"),
307 ListUserSessionsQuery
308 ),
309 responses(
310 (status = 200, description = "session page", body = AppResponseData<AdminUserSessionPage>),
311 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
312 )
313)]
314pub async fn list_user_sessions(
315 State(service): State<AdminService>,
316 Path(uid): Path<Uuid>,
317 Query(query): Query<ListUserSessionsQuery>,
318) -> Result<AppResponseData<AdminUserSessionPage>, AdminApiError> {
319 let result = service.list_user_sessions(uid, query).await?;
320 Ok(AppResponseData::ok(result))
321}
322
323#[utoipa::path(
324 delete,
325 path = "/admin/users/{uid}/sessions/{session_id}",
326 tag = "admin-users",
327 description = "吊销用户单个会话;运维及以上角色可操作。",
328 params(
329 ("uid" = Uuid, Path, description = "user id"),
330 ("session_id" = Uuid, Path, description = "session id")
331 ),
332 responses(
333 (status = 200, description = "session revoked", body = AppResponseData<NoData>),
334 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
335 )
336)]
337pub async fn revoke_user_session(
338 State(service): State<AdminService>,
339 CurrentAdmin(actor): CurrentAdmin,
340 Path((uid, session_id)): Path<(Uuid, Uuid)>,
341 headers: HeaderMap,
342 request_id: Option<Extension<RequestId>>,
343) -> Result<AppResponseData<NoData>, AdminApiError> {
344 let ctx = audit_ctx(&headers, request_id);
345 service
346 .revoke_user_session(&actor, uid, session_id, &ctx)
347 .await?;
348 Ok(AppResponseData::empty())
349}
350
351#[utoipa::path(
352 get,
353 path = "/admin/users/{uid}/security",
354 tag = "admin-users",
355 description = "用户安全概览:二次验证状态、恢复码、活跃会话、SSH Key 与访问密钥数量。",
356 params(("uid" = Uuid, Path, description = "user id")),
357 responses(
358 (status = 200, description = "security overview", body = AppResponseData<AdminUserSecurityDto>),
359 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
360 )
361)]
362pub async fn get_user_security(
363 State(service): State<AdminService>,
364 Path(uid): Path<Uuid>,
365) -> Result<AppResponseData<AdminUserSecurityDto>, AdminApiError> {
366 let result = service.user_security(uid).await?;
367 Ok(AppResponseData::ok(result))
368}
369
370#[utoipa::path(
371 delete,
372 path = "/admin/users/{uid}/two-factor",
373 tag = "admin-users",
374 description = "强制清除用户二次验证(用户遗失设备时使用);仅超级管理员可操作。",
375 params(("uid" = Uuid, Path, description = "user id")),
376 responses(
377 (status = 200, description = "two-factor cleared", body = AppResponseData<NoData>),
378 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
379 )
380)]
381pub async fn clear_user_two_factor(
382 State(service): State<AdminService>,
383 CurrentAdmin(actor): CurrentAdmin,
384 Path(uid): Path<Uuid>,
385 headers: HeaderMap,
386 request_id: Option<Extension<RequestId>>,
387) -> Result<AppResponseData<NoData>, AdminApiError> {
388 let ctx = audit_ctx(&headers, request_id);
389 service
390 .clear_user_two_factor(&actor, uid, &ctx)
391 .await?;
392 Ok(AppResponseData::empty())
208393}
No newline at end of file
Modified lib/admin/src/services/auth.rs View lib/admin/src/services/auth.rs
@@ -589,4 +589,39 @@
589589 );
590590 Ok(Some(row.id))
591591 }
592}
593
594impl AdminService {
595 pub async fn revoke_admin_sessions(
596 &self,
597 actor: &AdminActor,
598 id: Uuid,
599 ctx: &AuditCtx,
600 ) -> Result<(), AdminError> {
601 actor.require(AdminRole::Operator)?;
602 if id != actor.id {
603 actor.require(AdminRole::SuperAdmin)?;
604 }
605 let row = admin_user::Entity::find_by_id(id)
606 .one(self.db().read())
607 .await?
608 .ok_or(AdminError::NotFound)?;
609 let now = OffsetDateTime::now_utc();
610 let next_version = row.passwd_version + 1;
611 let mut am: admin_user::ActiveModel = row.into();
612 am.passwd_version = Set(next_version);
613 am.updated_at = Set(now);
614 am.update(self.db().write()).await?;
615 self.audit(
616 Some(actor),
617 AuditRecord {
618 action: "admin.sessions_revoked",
619 target: Some(("admin_user", id.to_string())),
620 detail: json!({}),
621 },
622 ctx,
623 )
624 .await?;
625 Ok(())
626 }
592627}
No newline at end of file
Added lib/admin/src/services/chat.rs View lib/admin/src/services/chat.rs
@@ -0,0 +1,1690 @@
1use std::collections::HashMap;
2
3use export::pagination::Page;
4use models::admin::admin_user::AdminRole;
5use models::chat::attachments::{self, AttachmentStatus};
6use models::chat::message_edits;
7use models::chat::messages::{self, AuthorType, ChatMessageType};
8use models::chat::outbound_webhooks;
9use models::chat::reactions;
10use models::chat::read_states;
11use models::chat::room_bookmarks;
12use models::chat::room_seq;
13use models::chat::room_user_settings;
14use models::chat::room_webhooks;
15use models::chat::threads;
16use models::chat::user_drafts;
17use models::chat::webhook_deliveries;
18use models::room::room_bans;
19use models::room::room_invites;
20use models::room::room_members::{self, RoomMemberRole};
21use models::room::rooms::{self, RoomKind};
22use models::space::spaces;
23use models::users::user;
24use sea_orm::sea_query::Expr;
25use sea_orm::{
26 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
27 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
28};
29use sea_orm::ExprTrait;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32use time::OffsetDateTime;
33use utoipa::{IntoParams, ToSchema};
34use uuid::Uuid;
35
36use crate::errors::AdminError;
37use crate::services::audit::{AuditCtx, AuditRecord};
38use crate::services::service::AdminService;
39use crate::share::roles::AdminActor;
40
41pub const MAX_CHAT_PAGE: u64 = 200;
42pub const MAX_BULK_CHAT_MESSAGES: usize = 100;
43pub const MAX_CHAT_EXPORT_ROWS: u64 = 10000;
44
45#[derive(Debug, Clone, Deserialize, IntoParams)]
46pub struct ListChatRoomsQuery {
47 pub q: Option<String>,
48 pub space_id: Option<Uuid>,
49 pub kind: Option<String>,
50 pub status: Option<String>,
51 pub visibility: Option<String>,
52 pub limit: Option<u64>,
53 pub offset: Option<u64>,
54}
55
56#[derive(Debug, Clone, Deserialize, IntoParams)]
57pub struct ListChatMessagesQuery {
58 pub room_id: Option<Uuid>,
59 pub author_id: Option<Uuid>,
60 pub status: Option<String>,
61 pub q: Option<String>,
62 pub from: Option<OffsetDateTime>,
63 pub to: Option<OffsetDateTime>,
64 pub limit: Option<u64>,
65 pub offset: Option<u64>,
66}
67
68#[derive(Debug, Clone, Deserialize, IntoParams)]
69pub struct ListChatAttachmentsQuery {
70 pub room_id: Option<Uuid>,
71 pub user_id: Option<Uuid>,
72 pub status: Option<String>,
73 pub q: Option<String>,
74 pub limit: Option<u64>,
75 pub offset: Option<u64>,
76}
77
78#[derive(Debug, Clone, Deserialize, IntoParams)]
79pub struct ListChatMembersQuery {
80 pub status: Option<String>,
81 pub limit: Option<u64>,
82 pub offset: Option<u64>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
86pub struct AdminChatKindCount {
87 pub kind: String,
88 pub count: i64,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
92pub struct AdminChatStatsDto {
93 pub rooms: i64,
94 pub active_rooms: i64,
95 pub private_rooms: i64,
96 pub kinds: Vec<AdminChatKindCount>,
97 pub messages: i64,
98 pub deleted_messages: i64,
99 pub messages_24h: i64,
100 pub threads: i64,
101 pub attachments: i64,
102 pub attachment_bytes: i64,
103 pub pending_attachments: i64,
104 pub members: i64,
105 pub bans: i64,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
109pub struct AdminChatRoomSummary {
110 pub id: Uuid,
111 pub space_id: Option<Uuid>,
112 pub space_name: Option<String>,
113 pub kind: String,
114 pub name: String,
115 pub lower_name: String,
116 pub topic: Option<String>,
117 pub owner_id: Uuid,
118 pub owner_username: Option<String>,
119 pub is_private: bool,
120 pub nsfw: bool,
121 pub is_active: bool,
122 pub members: i64,
123 pub messages: i64,
124 pub last_message_at: Option<OffsetDateTime>,
125 pub created_at: OffsetDateTime,
126 pub updated_at: OffsetDateTime,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
130pub struct AdminChatRoomDetail {
131 pub room: AdminChatRoomSummary,
132 pub deleted_messages: i64,
133 pub threads: i64,
134 pub attachments: i64,
135 pub attachment_bytes: i64,
136 pub bans: i64,
137 pub invites: i64,
138}
139
140#[derive(Debug, Clone, Deserialize, ToSchema)]
141pub struct UpdateChatRoomCmd {
142 #[serde(default)]
143 pub name: Option<String>,
144 #[serde(default)]
145 pub topic: Option<String>,
146 #[serde(default)]
147 pub is_private: Option<bool>,
148 #[serde(default)]
149 pub nsfw: Option<bool>,
150 #[serde(default)]
151 pub is_active: Option<bool>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
155pub struct AdminChatRoomMemberDto {
156 pub user_id: Uuid,
157 pub username: Option<String>,
158 pub display_name: Option<String>,
159 pub role: String,
160 pub nick: Option<String>,
161 pub joined_at: OffsetDateTime,
162 pub left_at: Option<OffsetDateTime>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
166pub struct AdminChatMessageDto {
167 pub id: Uuid,
168 pub room_id: Uuid,
169 pub room_name: Option<String>,
170 pub author_id: Uuid,
171 pub author_username: Option<String>,
172 pub author_display_name: Option<String>,
173 pub author_type: String,
174 pub message_type: String,
175 pub content: String,
176 pub seq: i64,
177 pub flags: i64,
178 pub pinned: bool,
179 pub thread_id: Option<Uuid>,
180 pub reply_to_id: Option<Uuid>,
181 pub attachment_count: i64,
182 pub reaction_count: i64,
183 pub edited_at: Option<OffsetDateTime>,
184 pub deleted_at: Option<OffsetDateTime>,
185 pub created_at: OffsetDateTime,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
189pub struct AdminChatMessageEditDto {
190 pub id: Uuid,
191 pub editor_id: Uuid,
192 pub editor_username: Option<String>,
193 pub content: String,
194 pub created_at: OffsetDateTime,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
198pub struct AdminChatReactionGroupDto {
199 pub emoji: String,
200 pub count: i64,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
204pub struct AdminChatAttachmentDto {
205 pub id: Uuid,
206 pub user_id: Uuid,
207 pub username: Option<String>,
208 pub display_name: Option<String>,
209 pub room_id: Option<Uuid>,
210 pub message_id: Option<Uuid>,
211 pub filename: String,
212 pub description: Option<String>,
213 pub content_type: String,
214 pub size: i64,
215 pub bucket: String,
216 pub s3_key: String,
217 pub status: String,
218 pub created_at: OffsetDateTime,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
222pub struct AdminChatThreadDto {
223 pub id: Uuid,
224 pub name: Option<String>,
225 pub owner_id: Uuid,
226 pub reply_count: i64,
227 pub participant_count: i64,
228 pub archived: bool,
229 pub locked: bool,
230 pub created_at: OffsetDateTime,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
234pub struct AdminChatMessageDetail {
235 pub message: AdminChatMessageDto,
236 pub edits: Vec<AdminChatMessageEditDto>,
237 pub reactions: Vec<AdminChatReactionGroupDto>,
238 pub attachments: Vec<AdminChatAttachmentDto>,
239 pub thread: Option<AdminChatThreadDto>,
240}
241
242#[derive(Debug, Clone, Deserialize, ToSchema)]
243pub struct BulkDeleteChatMessagesCmd {
244 pub message_ids: Vec<Uuid>,
245 #[serde(default)]
246 pub reason: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
250pub struct BulkChatMessageFailure {
251 pub id: Uuid,
252 pub error: String,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
256pub struct BulkDeleteChatMessagesResp {
257 pub succeeded: Vec<Uuid>,
258 pub failed: Vec<BulkChatMessageFailure>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
262pub struct PurgeChatMessagesResp {
263 pub room_id: Uuid,
264 pub messages_deleted: u64,
265 pub attachments_deleted: u64,
266 pub threads_deleted: u64,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
270pub struct DeleteChatRoomResp {
271 pub room_id: Uuid,
272 pub messages_deleted: u64,
273 pub members_deleted: u64,
274 pub attachments_deleted: u64,
275 pub threads_deleted: u64,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
279pub struct DeleteChatAttachmentResp {
280 pub id: Uuid,
281 pub s3_key: String,
282 pub deleted: bool,
283 pub storage_deleted: bool,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
287pub struct RemoveChatMemberResp {
288 pub room_id: Uuid,
289 pub user_id: Uuid,
290 pub removed: bool,
291}
292
293fn room_kind_key(kind: RoomKind) -> &'static str {
294 match kind {
295 RoomKind::GuildRoom => "guild_room",
296 RoomKind::GroupDm => "group_dm",
297 RoomKind::Dm => "dm",
298 RoomKind::Category => "category",
299 RoomKind::Text => "text",
300 RoomKind::Voice => "voice",
301 RoomKind::Stage => "stage",
302 RoomKind::News => "news",
303 RoomKind::Thread => "thread",
304 RoomKind::DirectMessage => "direct_message",
305 }
306}
307
308fn parse_room_kind(raw: &str) -> Option<RoomKind> {
309 match raw.trim().to_ascii_lowercase().as_str() {
310 "guild_room" | "guild" => Some(RoomKind::GuildRoom),
311 "group_dm" => Some(RoomKind::GroupDm),
312 "dm" => Some(RoomKind::Dm),
313 "category" => Some(RoomKind::Category),
314 "text" => Some(RoomKind::Text),
315 "voice" => Some(RoomKind::Voice),
316 "stage" => Some(RoomKind::Stage),
317 "news" => Some(RoomKind::News),
318 "thread" => Some(RoomKind::Thread),
319 "direct_message" => Some(RoomKind::DirectMessage),
320 _ => None,
321 }
322}
323
324fn author_type_key(value: AuthorType) -> &'static str {
325 match value {
326 AuthorType::User => "user",
327 AuthorType::Bot => "bot",
328 AuthorType::Webhook => "webhook",
329 }
330}
331
332fn message_type_key(value: ChatMessageType) -> &'static str {
333 match value {
334 ChatMessageType::Default => "default",
335 ChatMessageType::Reply => "reply",
336 ChatMessageType::ThreadCreated => "thread_created",
337 ChatMessageType::ChannelJoin => "channel_join",
338 ChatMessageType::ChannelLeave => "channel_leave",
339 ChatMessageType::ChannelTopic => "channel_topic",
340 ChatMessageType::ChannelPinnedItem => "channel_pinned_item",
341 ChatMessageType::Reminder => "reminder",
342 ChatMessageType::System => "system",
343 }
344}
345
346fn member_role_key(role: RoomMemberRole) -> &'static str {
347 match role {
348 RoomMemberRole::Member => "member",
349 RoomMemberRole::Moderator => "moderator",
350 RoomMemberRole::Admin => "admin",
351 RoomMemberRole::Owner => "owner",
352 }
353}
354
355fn attachment_status_key(status: AttachmentStatus) -> &'static str {
356 match status {
357 AttachmentStatus::Pending => "pending",
358 AttachmentStatus::Uploaded => "uploaded",
359 }
360}
361
362
363impl AdminService {
364 async fn username_map(
365 &self,
366 ids: &[Uuid],
367 ) -> Result<HashMap<Uuid, (String, String)>, AdminError> {
368 if ids.is_empty() {
369 return Ok(HashMap::new());
370 }
371 let mut unique = ids.to_vec();
372 unique.sort();
373 unique.dedup();
374 let rows = user::Entity::find()
375 .filter(user::Column::Uid.is_in(unique))
376 .all(self.db().read())
377 .await?;
378 Ok(rows
379 .into_iter()
380 .map(|row| (row.uid, (row.username, row.display_name)))
381 .collect())
382 }
383
384 async fn publish_room_event(&self, room_id: Uuid, event: serde_json::Value) {
385 let channel = format!("chat:room:{room_id}");
386 let payload = match serde_json::to_string(&event) {
387 Ok(payload) => payload,
388 Err(_) => return,
389 };
390 if let Err(error) = self.redis().publish(&channel, &payload).await {
391 tracing::warn!(%error, %channel, "admin chat event publish failed");
392 }
393 }
394
395 async fn current_room_seq(&self, room_id: Uuid) -> i64 {
396 room_seq::Entity::find_by_id(room_id)
397 .one(self.db().read())
398 .await
399 .ok()
400 .flatten()
401 .map(|row| row.seq)
402 .unwrap_or(0)
403 }
404
405 pub async fn chat_stats(&self) -> Result<AdminChatStatsDto, AdminError> {
406 let rooms = rooms::Entity::find().count(self.db().read()).await? as i64;
407 let active_rooms = rooms::Entity::find()
408 .filter(rooms::Column::IsActive.eq(true))
409 .count(self.db().read())
410 .await? as i64;
411 let private_rooms = rooms::Entity::find()
412 .filter(rooms::Column::IsPrivate.eq(true))
413 .count(self.db().read())
414 .await? as i64;
415 let kinds = rooms::Entity::find()
416 .select_only()
417 .column(rooms::Column::Kind)
418 .column_as(Expr::col(rooms::Column::Id).count(), "cnt")
419 .group_by(rooms::Column::Kind)
420 .into_tuple::<(RoomKind, i64)>()
421 .all(self.db().read())
422 .await?
423 .into_iter()
424 .map(|(kind, count)| AdminChatKindCount {
425 kind: room_kind_key(kind).to_string(),
426 count,
427 })
428 .collect();
429 let messages = messages::Entity::find().count(self.db().read()).await? as i64;
430 let deleted_messages = messages::Entity::find()
431 .filter(messages::Column::DeletedAt.is_not_null())
432 .count(self.db().read())
433 .await? as i64;
434 let messages_24h = messages::Entity::find()
435 .filter(messages::Column::CreatedAt.gte(OffsetDateTime::now_utc() - time::Duration::hours(24)))
436 .count(self.db().read())
437 .await? as i64;
438 let threads = threads::Entity::find().count(self.db().read()).await? as i64;
439 let attachments = attachments::Entity::find()
440 .count(self.db().read())
441 .await? as i64;
442 let pending_attachments = attachments::Entity::find()
443 .filter(attachments::Column::Status.eq(AttachmentStatus::Pending))
444 .count(self.db().read())
445 .await? as i64;
446 let attachment_bytes = attachments::Entity::find()
447 .select_only()
448 .column_as(Expr::col(attachments::Column::Size).sum(), "total")
449 .into_tuple::<Option<i64>>()
450 .one(self.db().read())
451 .await?
452 .flatten()
453 .unwrap_or(0);
454 let members = room_members::Entity::find()
455 .filter(room_members::Column::LeftAt.is_null())
456 .count(self.db().read())
457 .await? as i64;
458 let bans = room_bans::Entity::find().count(self.db().read()).await? as i64;
459 Ok(AdminChatStatsDto {
460 rooms,
461 active_rooms,
462 private_rooms,
463 kinds,
464 messages,
465 deleted_messages,
466 messages_24h,
467 threads,
468 attachments,
469 attachment_bytes,
470 pending_attachments,
471 members,
472 bans,
473 })
474 }
475
476 async fn room_message_counts(&self, room_ids: &[Uuid]) -> Result<HashMap<Uuid, i64>, AdminError> {
477 if room_ids.is_empty() {
478 return Ok(HashMap::new());
479 }
480 let rows = messages::Entity::find()
481 .select_only()
482 .column(messages::Column::RoomId)
483 .column_as(Expr::col(messages::Column::Id).count(), "cnt")
484 .filter(messages::Column::RoomId.is_in(room_ids.to_vec()))
485 .filter(messages::Column::DeletedAt.is_null())
486 .group_by(messages::Column::RoomId)
487 .into_tuple::<(Uuid, i64)>()
488 .all(self.db().read())
489 .await?;
490 Ok(rows.into_iter().collect())
491 }
492
493 async fn room_member_counts(&self, room_ids: &[Uuid]) -> Result<HashMap<Uuid, i64>, AdminError> {
494 if room_ids.is_empty() {
495 return Ok(HashMap::new());
496 }
497 let rows = room_members::Entity::find()
498 .select_only()
499 .column(room_members::Column::RoomId)
500 .column_as(Expr::col(room_members::Column::UserId).count(), "cnt")
501 .filter(room_members::Column::RoomId.is_in(room_ids.to_vec()))
502 .filter(room_members::Column::LeftAt.is_null())
503 .group_by(room_members::Column::RoomId)
504 .into_tuple::<(Uuid, i64)>()
505 .all(self.db().read())
506 .await?;
507 Ok(rows.into_iter().collect())
508 }
509
510 async fn room_last_message_at(
511 &self,
512 room_ids: &[Uuid],
513 ) -> Result<HashMap<Uuid, OffsetDateTime>, AdminError> {
514 if room_ids.is_empty() {
515 return Ok(HashMap::new());
516 }
517 let rows = messages::Entity::find()
518 .select_only()
519 .column(messages::Column::RoomId)
520 .column_as(Expr::col(messages::Column::CreatedAt).max(), "latest")
521 .filter(messages::Column::RoomId.is_in(room_ids.to_vec()))
522 .filter(messages::Column::DeletedAt.is_null())
523 .group_by(messages::Column::RoomId)
524 .into_tuple::<(Uuid, Option<OffsetDateTime>)>()
525 .all(self.db().read())
526 .await?;
527 Ok(rows
528 .into_iter()
529 .filter_map(|(room_id, latest)| latest.map(|value| (room_id, value)))
530 .collect())
531 }
532
533 async fn room_select(
534 &self,
535 query: &ListChatRoomsQuery,
536 ) -> Result<sea_orm::Select<rooms::Entity>, AdminError> {
537 let mut select = rooms::Entity::find();
538 if let Some(space_id) = query.space_id {
539 select = select.filter(rooms::Column::SpaceId.eq(space_id));
540 }
541 if let Some(kind) = query
542 .kind
543 .as_deref()
544 .map(str::trim)
545 .filter(|value| !value.is_empty())
546 {
547 match parse_room_kind(kind) {
548 Some(parsed) => select = select.filter(rooms::Column::Kind.eq(parsed)),
549 None => {
550 return Err(AdminError::BadRequest(format!("unknown room kind {kind}")));
551 }
552 }
553 }
554 select = match query.status.as_deref() {
555 Some("active") => select.filter(rooms::Column::IsActive.eq(true)),
556 Some("disabled") | Some("inactive") => select.filter(rooms::Column::IsActive.eq(false)),
557 _ => select,
558 };
559 select = match query.visibility.as_deref() {
560 Some("private") => select.filter(rooms::Column::IsPrivate.eq(true)),
561 Some("public") => select.filter(rooms::Column::IsPrivate.eq(false)),
562 _ => select,
563 };
564 if let Some(raw) = query
565 .q
566 .as_deref()
567 .map(str::trim)
568 .filter(|value| !value.is_empty())
569 {
570 let needle = raw.to_ascii_lowercase();
571 select = select.filter(
572 Condition::any()
573 .add(rooms::Column::LowerName.contains(needle))
574 .add(rooms::Column::Name.contains(raw)),
575 );
576 }
577 Ok(select)
578 }
579
580 pub async fn list_chat_rooms(
581 &self,
582 query: ListChatRoomsQuery,
583 ) -> Result<Page<AdminChatRoomSummary>, AdminError> {
584 let limit = query
585 .limit
586 .unwrap_or(50)
587 .clamp(1, MAX_CHAT_PAGE);
588 let offset = query.offset.unwrap_or(0);
589 let select = self.room_select(&query).await?;
590 let total = select.clone().count(self.db().read()).await? as i64;
591 let rows = select
592 .order_by_desc(rooms::Column::UpdatedAt)
593 .limit(limit)
594 .offset(offset)
595 .all(self.db().read())
596 .await?;
597 let items = self.chat_room_summaries(rows).await?;
598 Ok(Page {
599 items,
600 total,
601 limit,
602 offset,
603 })
604 }
605
606 async fn chat_room_summaries(
607 &self,
608 rows: Vec<rooms::Model>,
609 ) -> Result<Vec<AdminChatRoomSummary>, AdminError> {
610 if rows.is_empty() {
611 return Ok(Vec::new());
612 }
613 let room_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
614 let space_ids: Vec<Uuid> = rows.iter().filter_map(|row| row.space_id).collect();
615 let owner_ids: Vec<Uuid> = rows.iter().map(|row| row.owner_id).collect();
616 let (message_counts, member_counts, last_messages, owners, space_names) = tokio::join!(
617 self.room_message_counts(&room_ids),
618 self.room_member_counts(&room_ids),
619 self.room_last_message_at(&room_ids),
620 self.username_map(&owner_ids),
621 self.space_name_map(&space_ids),
622 );
623 let message_counts = message_counts?;
624 let member_counts = member_counts?;
625 let last_messages = last_messages?;
626 let owners = owners?;
627 let space_names = space_names?;
628 Ok(rows
629 .into_iter()
630 .map(|row| AdminChatRoomSummary {
631 id: row.id,
632 space_id: row.space_id,
633 space_name: row.space_id.and_then(|id| space_names.get(&id).cloned()),
634 kind: room_kind_key(row.kind).to_string(),
635 name: row.name.clone(),
636 lower_name: row.lower_name.clone(),
637 topic: row.topic.clone(),
638 owner_id: row.owner_id,
639 owner_username: owners.get(&row.owner_id).map(|(name, _)| name.clone()),
640 is_private: row.is_private,
641 nsfw: row.nsfw,
642 is_active: row.is_active,
643 members: member_counts.get(&row.id).copied().unwrap_or(0),
644 messages: message_counts.get(&row.id).copied().unwrap_or(0),
645 last_message_at: last_messages.get(&row.id).copied(),
646 created_at: row.created_at,
647 updated_at: row.updated_at,
648 })
649 .collect())
650 }
651
652 async fn space_name_map(&self, ids: &[Uuid]) -> Result<HashMap<Uuid, String>, AdminError> {
653 if ids.is_empty() {
654 return Ok(HashMap::new());
655 }
656 let mut unique = ids.to_vec();
657 unique.sort();
658 unique.dedup();
659 let rows = spaces::Entity::find()
660 .filter(spaces::Column::Id.is_in(unique))
661 .all(self.db().read())
662 .await?;
663 Ok(rows.into_iter().map(|row| (row.id, row.name)).collect())
664 }
665
666 pub async fn get_chat_room(&self, room_id: Uuid) -> Result<AdminChatRoomDetail, AdminError> {
667 let row = rooms::Entity::find_by_id(room_id)
668 .one(self.db().read())
669 .await?
670 .ok_or(AdminError::NotFound)?;
671 let summary = self
672 .chat_room_summaries(vec![row])
673 .await?
674 .into_iter()
675 .next()
676 .ok_or(AdminError::NotFound)?;
677 let deleted_messages = messages::Entity::find()
678 .filter(messages::Column::RoomId.eq(room_id))
679 .filter(messages::Column::DeletedAt.is_not_null())
680 .count(self.db().read())
681 .await? as i64;
682 let threads = threads::Entity::find()
683 .filter(threads::Column::RoomId.eq(room_id))
684 .count(self.db().read())
685 .await? as i64;
686 let attachments = attachments::Entity::find()
687 .filter(attachments::Column::RoomId.eq(room_id))
688 .count(self.db().read())
689 .await? as i64;
690 let attachment_bytes = attachments::Entity::find()
691 .filter(attachments::Column::RoomId.eq(room_id))
692 .select_only()
693 .column_as(Expr::col(attachments::Column::Size).sum(), "total")
694 .into_tuple::<Option<i64>>()
695 .one(self.db().read())
696 .await?
697 .flatten()
698 .unwrap_or(0);
699 let bans = room_bans::Entity::find()
700 .filter(room_bans::Column::RoomId.eq(room_id))
701 .count(self.db().read())
702 .await? as i64;
703 let invites = room_invites::Entity::find()
704 .filter(room_invites::Column::RoomId.eq(room_id))
705 .count(self.db().read())
706 .await? as i64;
707 Ok(AdminChatRoomDetail {
708 room: summary,
709 deleted_messages,
710 threads,
711 attachments,
712 attachment_bytes,
713 bans,
714 invites,
715 })
716 }
717
718 pub async fn update_chat_room(
719 &self,
720 actor: &AdminActor,
721 room_id: Uuid,
722 cmd: UpdateChatRoomCmd,
723 ctx: &AuditCtx,
724 ) -> Result<AdminChatRoomDetail, AdminError> {
725 actor.require(AdminRole::Operator)?;
726 let row = rooms::Entity::find_by_id(room_id)
727 .one(self.db().read())
728 .await?
729 .ok_or(AdminError::NotFound)?;
730 let mut changed = serde_json::Map::new();
731 let room_space_id = row.space_id;
732 let mut am: rooms::ActiveModel = row.into();
733 if let Some(name) = cmd.name {
734 let name = name.trim().to_string();
735 if name.is_empty() || name.chars().count() > 100 {
736 return Err(AdminError::BadRequest(
737 "room name must contain 1..=100 characters".to_string(),
738 ));
739 }
740 let lower = name.to_lowercase();
741 let mut conflict = rooms::Entity::find()
742 .filter(rooms::Column::LowerName.eq(lower.clone()))
743 .filter(rooms::Column::Id.ne(room_id));
744 conflict = match room_space_id {
745 Some(space_id) => conflict.filter(rooms::Column::SpaceId.eq(space_id)),
746 None => conflict.filter(rooms::Column::SpaceId.is_null()),
747 };
748 if conflict.count(self.db().read()).await? > 0 {
749 return Err(AdminError::Conflict(
750 "another room in this space already uses that name".to_string(),
751 ));
752 }
753 changed.insert("name".to_string(), json!(name));
754 am.name = Set(name.clone());
755 am.lower_name = Set(lower);
756 }
757 if let Some(topic) = cmd.topic {
758 changed.insert("topic".to_string(), json!(topic));
759 am.topic = Set(Some(topic));
760 }
761 if let Some(value) = cmd.is_private {
762 changed.insert("is_private".to_string(), json!(value));
763 am.is_private = Set(value);
764 }
765 if let Some(value) = cmd.nsfw {
766 changed.insert("nsfw".to_string(), json!(value));
767 am.nsfw = Set(value);
768 }
769 if let Some(value) = cmd.is_active {
770 changed.insert("is_active".to_string(), json!(value));
771 am.is_active = Set(value);
772 }
773 if changed.is_empty() {
774 return self.get_chat_room(room_id).await;
775 }
776 am.updated_at = Set(OffsetDateTime::now_utc());
777 am.update(self.db().write()).await?;
778 self.audit(
779 Some(actor),
780 AuditRecord {
781 action: "chat.room_updated",
782 target: Some(("chat_room", room_id.to_string())),
783 detail: serde_json::Value::Object(changed),
784 },
785 ctx,
786 )
787 .await?;
788 self.publish_room_event(room_id, json!({"kind": "room_updated", "room_id": room_id}))
789 .await;
790 self.get_chat_room(room_id).await
791 }
792
793 pub async fn list_chat_room_members(
794 &self,
795 room_id: Uuid,
796 query: ListChatMembersQuery,
797 ) -> Result<Page<AdminChatRoomMemberDto>, AdminError> {
798 rooms::Entity::find_by_id(room_id)
799 .one(self.db().read())
800 .await?
801 .ok_or(AdminError::NotFound)?;
802 let limit = query.limit.unwrap_or(50).clamp(1, MAX_CHAT_PAGE);
803 let offset = query.offset.unwrap_or(0);
804 let mut select = room_members::Entity::find()
805 .filter(room_members::Column::RoomId.eq(room_id));
806 select = match query.status.as_deref() {
807 Some("active") => select.filter(room_members::Column::LeftAt.is_null()),
808 Some("left") => select.filter(room_members::Column::LeftAt.is_not_null()),
809 _ => select,
810 };
811 let total = select.clone().count(self.db().read()).await? as i64;
812 let rows = select
813 .order_by_asc(room_members::Column::JoinedAt)
814 .limit(limit)
815 .offset(offset)
816 .all(self.db().read())
817 .await?;
818 let user_ids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
819 let users = self.username_map(&user_ids).await?;
820 let items = rows
821 .into_iter()
822 .map(|row| AdminChatRoomMemberDto {
823 user_id: row.user_id,
824 username: users.get(&row.user_id).map(|(name, _)| name.clone()),
825 display_name: users.get(&row.user_id).map(|(_, display)| display.clone()),
826 role: member_role_key(row.role).to_string(),
827 nick: row.nick,
828 joined_at: row.joined_at,
829 left_at: row.left_at,
830 })
831 .collect();
832 Ok(Page {
833 items,
834 total,
835 limit,
836 offset,
837 })
838 }
839
840 pub async fn remove_chat_room_member(
841 &self,
842 actor: &AdminActor,
843 room_id: Uuid,
844 user_id: Uuid,
845 ctx: &AuditCtx,
846 ) -> Result<RemoveChatMemberResp, AdminError> {
847 actor.require(AdminRole::Operator)?;
848 let row = room_members::Entity::find_by_id((room_id, user_id))
849 .one(self.db().read())
850 .await?
851 .ok_or(AdminError::NotFound)?;
852 if row.left_at.is_some() {
853 return Ok(RemoveChatMemberResp {
854 room_id,
855 user_id,
856 removed: false,
857 });
858 }
859 let now = OffsetDateTime::now_utc();
860 let mut am: room_members::ActiveModel = row.into();
861 am.left_at = Set(Some(now));
862 am.updated_at = Set(Some(now));
863 am.update(self.db().write()).await?;
864 self.audit(
865 Some(actor),
866 AuditRecord {
867 action: "chat.member_removed",
868 target: Some(("chat_room", room_id.to_string())),
869 detail: json!({ "user_id": user_id }),
870 },
871 ctx,
872 )
873 .await?;
874 self.publish_room_event(
875 room_id,
876 json!({"kind": "member_left", "room_id": room_id, "user_id": user_id}),
877 )
878 .await;
879 Ok(RemoveChatMemberResp {
880 room_id,
881 user_id,
882 removed: true,
883 })
884 }
885
886 async fn chat_message_select(
887 &self,
888 query: &ListChatMessagesQuery,
889 ) -> Result<sea_orm::Select<messages::Entity>, AdminError> {
890 let mut select = messages::Entity::find();
891 if let Some(room_id) = query.room_id {
892 select = select.filter(messages::Column::RoomId.eq(room_id));
893 }
894 if let Some(author_id) = query.author_id {
895 select = select.filter(messages::Column::AuthorId.eq(author_id));
896 }
897 select = match query.status.as_deref() {
898 Some("deleted") => select.filter(messages::Column::DeletedAt.is_not_null()),
899 Some("active") => select.filter(messages::Column::DeletedAt.is_null()),
900 Some("pinned") => select
901 .filter(messages::Column::DeletedAt.is_null())
902 .filter(messages::Column::Pinned.eq(true)),
903 _ => select,
904 };
905 if let Some(raw) = query
906 .q
907 .as_deref()
908 .map(str::trim)
909 .filter(|value| !value.is_empty())
910 {
911 select = select.filter(messages::Column::Content.contains(raw));
912 }
913 if let Some(from) = query.from {
914 select = select.filter(messages::Column::CreatedAt.gte(from));
915 }
916 if let Some(to) = query.to {
917 select = select.filter(messages::Column::CreatedAt.lte(to));
918 }
919 Ok(select)
920 }
921
922 async fn message_author_map(
923 &self,
924 author_ids: &[Uuid],
925 ) -> Result<HashMap<Uuid, (String, String)>, AdminError> {
926 self.username_map(author_ids).await
927 }
928
929 fn message_dto(
930 row: &messages::Model,
931 room_names: &HashMap<Uuid, String>,
932 authors: &HashMap<Uuid, (String, String)>,
933 attachment_counts: &HashMap<Uuid, i64>,
934 reaction_counts: &HashMap<Uuid, i64>,
935 ) -> AdminChatMessageDto {
936 let author = authors.get(&row.author_id);
937 AdminChatMessageDto {
938 id: row.id,
939 room_id: row.room_id,
940 room_name: room_names.get(&row.room_id).cloned(),
941 author_id: row.author_id,
942 author_username: author.map(|(name, _)| name.clone()),
943 author_display_name: author.map(|(_, display)| display.clone()),
944 author_type: author_type_key(row.author_type).to_string(),
945 message_type: message_type_key(row.message_type).to_string(),
946 content: row.content.clone(),
947 seq: row.seq,
948 flags: row.flags,
949 pinned: row.pinned,
950 thread_id: row.thread_id,
951 reply_to_id: row.reply_to_id,
952 attachment_count: attachment_counts.get(&row.id).copied().unwrap_or(0),
953 reaction_count: reaction_counts.get(&row.id).copied().unwrap_or(0),
954 edited_at: row.edited_at,
955 deleted_at: row.deleted_at,
956 created_at: row.created_at,
957 }
958 }
959
960 async fn message_counter_maps(
961 &self,
962 message_ids: &[Uuid],
963 ) -> Result<(HashMap<Uuid, i64>, HashMap<Uuid, i64>), AdminError> {
964 if message_ids.is_empty() {
965 return Ok((HashMap::new(), HashMap::new()));
966 }
967 let attachments = attachments::Entity::find()
968 .select_only()
969 .column(attachments::Column::MessageId)
970 .column_as(Expr::col(attachments::Column::Id).count(), "cnt")
971 .filter(attachments::Column::MessageId.is_in(message_ids.to_vec()))
972 .group_by(attachments::Column::MessageId)
973 .into_tuple::<(Option<Uuid>, i64)>()
974 .all(self.db().read())
975 .await?
976 .into_iter()
977 .filter_map(|(id, count)| id.map(|id| (id, count)))
978 .collect();
979 let reactions = reactions::Entity::find()
980 .select_only()
981 .column(reactions::Column::MessageId)
982 .column_as(Expr::col(reactions::Column::UserId).count(), "cnt")
983 .filter(reactions::Column::MessageId.is_in(message_ids.to_vec()))
984 .group_by(reactions::Column::MessageId)
985 .into_tuple::<(Uuid, i64)>()
986 .all(self.db().read())
987 .await?
988 .into_iter()
989 .collect();
990 Ok((attachments, reactions))
991 }
992
993 pub async fn list_chat_messages(
994 &self,
995 query: ListChatMessagesQuery,
996 ) -> Result<Page<AdminChatMessageDto>, AdminError> {
997 self.chat_messages_page(query, MAX_CHAT_PAGE).await
998 }
999
1000 async fn chat_messages_page(
1001 &self,
1002 query: ListChatMessagesQuery,
1003 max_limit: u64,
1004 ) -> Result<Page<AdminChatMessageDto>, AdminError> {
1005 let limit = query.limit.unwrap_or(50).clamp(1, max_limit);
1006 let offset = query.offset.unwrap_or(0);
1007 let select = self.chat_message_select(&query).await?;
1008 let total = select.clone().count(self.db().read()).await? as i64;
1009 let rows = select
1010 .order_by_desc(messages::Column::CreatedAt)
1011 .limit(limit)
1012 .offset(offset)
1013 .all(self.db().read())
1014 .await?;
1015 let items = self.chat_message_dtos(rows).await?;
1016 Ok(Page {
1017 items,
1018 total,
1019 limit,
1020 offset,
1021 })
1022 }
1023
1024 async fn chat_message_dtos(
1025 &self,
1026 rows: Vec<messages::Model>,
1027 ) -> Result<Vec<AdminChatMessageDto>, AdminError> {
1028 if rows.is_empty() {
1029 return Ok(Vec::new());
1030 }
1031 let message_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
1032 let room_ids: Vec<Uuid> = rows.iter().map(|row| row.room_id).collect();
1033 let author_ids: Vec<Uuid> = rows.iter().map(|row| row.author_id).collect();
1034 let (authors, room_names, counters) = tokio::join!(
1035 self.message_author_map(&author_ids),
1036 self.room_name_map(&room_ids),
1037 self.message_counter_maps(&message_ids),
1038 );
1039 let authors = authors?;
1040 let room_names = room_names?;
1041 let (attachment_counts, reaction_counts) = counters?;
1042 Ok(rows
1043 .iter()
1044 .map(|row| {
1045 Self::message_dto(row, &room_names, &authors, &attachment_counts, &reaction_counts)
1046 })
1047 .collect())
1048 }
1049
1050 async fn room_name_map(&self, ids: &[Uuid]) -> Result<HashMap<Uuid, String>, AdminError> {
1051 if ids.is_empty() {
1052 return Ok(HashMap::new());
1053 }
1054 let mut unique = ids.to_vec();
1055 unique.sort();
1056 unique.dedup();
1057 let rows = rooms::Entity::find()
1058 .filter(rooms::Column::Id.is_in(unique))
1059 .all(self.db().read())
1060 .await?;
1061 Ok(rows.into_iter().map(|row| (row.id, row.name)).collect())
1062 }
1063
1064 pub async fn get_chat_message(
1065 &self,
1066 message_id: Uuid,
1067 ) -> Result<AdminChatMessageDetail, AdminError> {
1068 let row = messages::Entity::find_by_id(message_id)
1069 .one(self.db().read())
1070 .await?
1071 .ok_or(AdminError::NotFound)?;
1072 let room_id = row.room_id;
1073 let message = self
1074 .chat_message_dtos(vec![row])
1075 .await?
1076 .into_iter()
1077 .next()
1078 .ok_or(AdminError::NotFound)?;
1079 let edits = message_edits::Entity::find()
1080 .filter(message_edits::Column::MessageId.eq(message_id))
1081 .order_by_desc(message_edits::Column::CreatedAt)
1082 .all(self.db().read())
1083 .await?;
1084 let editor_ids: Vec<Uuid> = edits.iter().map(|row| row.editor_id).collect();
1085 let editors = self.username_map(&editor_ids).await?;
1086 let edits = edits
1087 .into_iter()
1088 .map(|row| AdminChatMessageEditDto {
1089 id: row.id,
1090 editor_id: row.editor_id,
1091 editor_username: editors.get(&row.editor_id).map(|(name, _)| name.clone()),
1092 content: row.content,
1093 created_at: row.created_at,
1094 })
1095 .collect();
1096 let reaction_rows = reactions::Entity::find()
1097 .filter(reactions::Column::MessageId.eq(message_id))
1098 .select_only()
1099 .column(reactions::Column::Emoji)
1100 .column_as(Expr::col(reactions::Column::UserId).count(), "cnt")
1101 .group_by(reactions::Column::Emoji)
1102 .into_tuple::<(String, i64)>()
1103 .all(self.db().read())
1104 .await?;
1105 let reactions = reaction_rows
1106 .into_iter()
1107 .map(|(emoji, count)| AdminChatReactionGroupDto { emoji, count })
1108 .collect();
1109 let attachment_rows = attachments::Entity::find()
1110 .filter(attachments::Column::MessageId.eq(message_id))
1111 .order_by_asc(attachments::Column::CreatedAt)
1112 .all(self.db().read())
1113 .await?;
1114 let attachment_user_ids: Vec<Uuid> =
1115 attachment_rows.iter().map(|row| row.user_id).collect();
1116 let attachment_users = self.username_map(&attachment_user_ids).await?;
1117 let attachments = attachment_rows
1118 .into_iter()
1119 .map(|row| AdminChatAttachmentDto {
1120 id: row.id,
1121 user_id: row.user_id,
1122 username: attachment_users
1123 .get(&row.user_id)
1124 .map(|(name, _)| name.clone()),
1125 display_name: attachment_users
1126 .get(&row.user_id)
1127 .map(|(_, display)| display.clone()),
1128 room_id: row.room_id,
1129 message_id: row.message_id,
1130 filename: row.filename,
1131 description: row.description,
1132 content_type: row.content_type,
1133 size: row.size,
1134 bucket: row.bucket,
1135 s3_key: row.s3_key,
1136 status: attachment_status_key(row.status).to_string(),
1137 created_at: row.created_at,
1138 })
1139 .collect();
1140 let thread = threads::Entity::find()
1141 .filter(threads::Column::RootMessageId.eq(message_id))
1142 .one(self.db().read())
1143 .await?
1144 .map(|row| AdminChatThreadDto {
1145 id: row.id,
1146 name: row.name,
1147 owner_id: row.owner_id,
1148 reply_count: row.reply_count,
1149 participant_count: row.participant_count,
1150 archived: row.archived,
1151 locked: row.locked,
1152 created_at: row.created_at,
1153 });
1154 let _ = room_id;
1155 Ok(AdminChatMessageDetail {
1156 message,
1157 edits,
1158 reactions,
1159 attachments,
1160 thread,
1161 })
1162 }
1163
1164 async fn delete_message_dependents(
1165 txn: &impl ConnectionTrait,
1166 message_ids: &[Uuid],
1167 ) -> Result<(), AdminError> {
1168 if message_ids.is_empty() {
1169 return Ok(());
1170 }
1171 message_edits::Entity::delete_many()
1172 .filter(message_edits::Column::MessageId.is_in(message_ids.to_vec()))
1173 .exec(txn)
1174 .await?;
1175 reactions::Entity::delete_many()
1176 .filter(reactions::Column::MessageId.is_in(message_ids.to_vec()))
1177 .exec(txn)
1178 .await?;
1179 models::users::user_saved_messages::Entity::delete_many()
1180 .filter(models::users::user_saved_messages::Column::MessageId.is_in(message_ids.to_vec()))
1181 .exec(txn)
1182 .await?;
1183 models::users::user_recent_mentions::Entity::delete_many()
1184 .filter(models::users::user_recent_mentions::Column::MessageId.is_in(message_ids.to_vec()))
1185 .exec(txn)
1186 .await?;
1187 models::chat::message_reminders::Entity::delete_many()
1188 .filter(models::chat::message_reminders::Column::MessageId.is_in(message_ids.to_vec()))
1189 .exec(txn)
1190 .await?;
1191 Ok(())
1192 }
1193
1194 pub async fn bulk_delete_chat_messages(
1195 &self,
1196 actor: &AdminActor,
1197 cmd: BulkDeleteChatMessagesCmd,
1198 ctx: &AuditCtx,
1199 ) -> Result<BulkDeleteChatMessagesResp, AdminError> {
1200 actor.require(AdminRole::Operator)?;
1201 let mut ids = cmd.message_ids.clone();
1202 ids.sort();
1203 ids.dedup();
1204 if ids.is_empty() || ids.len() > MAX_BULK_CHAT_MESSAGES {
1205 return Err(AdminError::BadRequest(format!(
1206 "message_ids must contain 1..={MAX_BULK_CHAT_MESSAGES} entries"
1207 )));
1208 }
1209 let rows = messages::Entity::find()
1210 .filter(messages::Column::Id.is_in(ids.clone()))
1211 .all(self.db().read())
1212 .await?;
1213 let now = OffsetDateTime::now_utc();
1214 let mut succeeded = Vec::new();
1215 let mut failed = Vec::new();
1216 let mut affected_rooms: Vec<Uuid> = Vec::new();
1217 for message_id in ids {
1218 let Some(row) = rows.iter().find(|row| row.id == message_id) else {
1219 failed.push(BulkChatMessageFailure {
1220 id: message_id,
1221 error: "message not found".to_string(),
1222 });
1223 continue;
1224 };
1225 if row.deleted_at.is_some() {
1226 failed.push(BulkChatMessageFailure {
1227 id: message_id,
1228 error: "already deleted".to_string(),
1229 });
1230 continue;
1231 }
1232 let room_id = row.room_id;
1233 let result = self
1234 .db()
1235 .primary()
1236 .transaction::<_, (), AdminError>({
1237 let message_id = message_id;
1238 move |txn| {
1239 Box::pin(async move {
1240 let ids = vec![message_id];
1241 messages::Entity::update_many()
1242 .filter(messages::Column::Id.eq(message_id))
1243 .col_expr(messages::Column::Content, Expr::value(String::new()))
1244 .col_expr(messages::Column::DeletedAt, Expr::value(now))
1245 .exec(txn)
1246 .await?;
1247 Self::delete_message_dependents(txn, &ids).await?;
1248 Ok(())
1249 })
1250 }
1251 })
1252 .await;
1253 match result {
1254 Ok(()) => {
1255 succeeded.push(message_id);
1256 if !affected_rooms.contains(&room_id) {
1257 affected_rooms.push(room_id);
1258 }
1259 }
1260 Err(error) => failed.push(BulkChatMessageFailure {
1261 id: message_id,
1262 error: error.to_string(),
1263 }),
1264 }
1265 }
1266 for room_id in &affected_rooms {
1267 let seq = self.current_room_seq(*room_id).await;
1268 self.publish_room_event(
1269 *room_id,
1270 json!({
1271 "kind": "messages_deleted",
1272 "room_id": room_id,
1273 "seq": seq,
1274 "message_ids": succeeded,
1275 }),
1276 )
1277 .await;
1278 }
1279 self.audit(
1280 Some(actor),
1281 AuditRecord {
1282 action: "chat.messages_deleted",
1283 target: Some(("chat_message", format!("{} items", succeeded.len()))),
1284 detail: json!({
1285 "succeeded": succeeded,
1286 "failed": failed.iter().map(|item| item.id.to_string()).collect::<Vec<_>>(),
1287 "reason": cmd.reason,
1288 }),
1289 },
1290 ctx,
1291 )
1292 .await?;
1293 Ok(BulkDeleteChatMessagesResp { succeeded, failed })
1294 }
1295
1296 pub async fn purge_chat_room_messages(
1297 &self,
1298 actor: &AdminActor,
1299 room_id: Uuid,
1300 ctx: &AuditCtx,
1301 ) -> Result<PurgeChatMessagesResp, AdminError> {
1302 actor.require(AdminRole::SuperAdmin)?;
1303 rooms::Entity::find_by_id(room_id)
1304 .one(self.db().read())
1305 .await?
1306 .ok_or(AdminError::NotFound)?;
1307 let message_ids: Vec<Uuid> = messages::Entity::find()
1308 .select_only()
1309 .column(messages::Column::Id)
1310 .filter(messages::Column::RoomId.eq(room_id))
1311 .into_tuple::<Uuid>()
1312 .all(self.db().read())
1313 .await?;
1314 let attachment_keys: Vec<String> = attachments::Entity::find()
1315 .select_only()
1316 .column(attachments::Column::S3Key)
1317 .filter(attachments::Column::RoomId.eq(room_id))
1318 .into_tuple::<String>()
1319 .all(self.db().read())
1320 .await?;
1321 let thread_ids: Vec<Uuid> = threads::Entity::find()
1322 .select_only()
1323 .column(threads::Column::Id)
1324 .filter(threads::Column::RoomId.eq(room_id))
1325 .into_tuple::<Uuid>()
1326 .all(self.db().read())
1327 .await?;
1328 let messages_deleted = message_ids.len() as u64;
1329 let attachments_deleted = attachment_keys.len() as u64;
1330 let threads_deleted = thread_ids.len() as u64;
1331 self.db()
1332 .primary()
1333 .transaction::<_, (), AdminError>(move |txn| {
1334 Box::pin(async move {
1335 if !message_ids.is_empty() {
1336 Self::delete_message_dependents(txn, &message_ids).await?;
1337 }
1338 attachments::Entity::delete_many()
1339 .filter(attachments::Column::RoomId.eq(room_id))
1340 .exec(txn)
1341 .await?;
1342 if !thread_ids.is_empty() {
1343 models::chat::thread_members::Entity::delete_many()
1344 .filter(models::chat::thread_members::Column::ThreadId.is_in(thread_ids.clone()))
1345 .exec(txn)
1346 .await?;
1347 threads::Entity::delete_many()
1348 .filter(threads::Column::Id.is_in(thread_ids))
1349 .exec(txn)
1350 .await?;
1351 }
1352 messages::Entity::delete_many()
1353 .filter(messages::Column::RoomId.eq(room_id))
1354 .exec(txn)
1355 .await?;
1356 models::users::user_saved_messages::Entity::delete_many()
1357 .filter(models::users::user_saved_messages::Column::ChannelId.eq(room_id))
1358 .exec(txn)
1359 .await?;
1360 models::users::user_recent_mentions::Entity::delete_many()
1361 .filter(models::users::user_recent_mentions::Column::ChannelId.eq(room_id))
1362 .exec(txn)
1363 .await?;
1364 Ok(())
1365 })
1366 })
1367 .await?;
1368 for key in &attachment_keys {
1369 if let Err(error) = self.storage().client().delete(key).await {
1370 tracing::warn!(%error, %key, "admin chat purge storage delete failed");
1371 }
1372 }
1373 rooms::Entity::update_many()
1374 .filter(rooms::Column::Id.eq(room_id))
1375 .col_expr(rooms::Column::LastMessageId, Expr::value(Option::<Uuid>::None))
1376 .col_expr(rooms::Column::UpdatedAt, Expr::value(OffsetDateTime::now_utc()))
1377 .exec(self.db().write())
1378 .await?;
1379 self.audit(
1380 Some(actor),
1381 AuditRecord {
1382 action: "chat.messages_purged",
1383 target: Some(("chat_room", room_id.to_string())),
1384 detail: json!({
1385 "messages_deleted": messages_deleted,
1386 "attachments_deleted": attachments_deleted,
1387 "threads_deleted": threads_deleted,
1388 }),
1389 },
1390 ctx,
1391 )
1392 .await?;
1393 Ok(PurgeChatMessagesResp {
1394 room_id,
1395 messages_deleted,
1396 attachments_deleted,
1397 threads_deleted,
1398 })
1399 }
1400
1401 pub async fn delete_chat_room(
1402 &self,
1403 actor: &AdminActor,
1404 room_id: Uuid,
1405 ctx: &AuditCtx,
1406 ) -> Result<DeleteChatRoomResp, AdminError> {
1407 actor.require(AdminRole::SuperAdmin)?;
1408 let room = rooms::Entity::find_by_id(room_id)
1409 .one(self.db().read())
1410 .await?
1411 .ok_or(AdminError::NotFound)?;
1412 let child_rooms = rooms::Entity::find()
1413 .filter(rooms::Column::ParentId.eq(room_id))
1414 .count(self.db().read())
1415 .await?;
1416 if child_rooms > 0 {
1417 return Err(AdminError::Conflict(
1418 "room still has child rooms; delete or move them first".to_string(),
1419 ));
1420 }
1421 let message_ids: Vec<Uuid> = messages::Entity::find()
1422 .select_only()
1423 .column(messages::Column::Id)
1424 .filter(messages::Column::RoomId.eq(room_id))
1425 .into_tuple::<Uuid>()
1426 .all(self.db().read())
1427 .await?;
1428 let attachment_keys: Vec<String> = attachments::Entity::find()
1429 .select_only()
1430 .column(attachments::Column::S3Key)
1431 .filter(attachments::Column::RoomId.eq(room_id))
1432 .into_tuple::<String>()
1433 .all(self.db().read())
1434 .await?;
1435 let thread_ids: Vec<Uuid> = threads::Entity::find()
1436 .select_only()
1437 .column(threads::Column::Id)
1438 .filter(threads::Column::RoomId.eq(room_id))
1439 .into_tuple::<Uuid>()
1440 .all(self.db().read())
1441 .await?;
1442 let members_deleted = room_members::Entity::find()
1443 .filter(room_members::Column::RoomId.eq(room_id))
1444 .count(self.db().read())
1445 .await? as u64;
1446 let messages_deleted = message_ids.len() as u64;
1447 let attachments_deleted = attachment_keys.len() as u64;
1448 let threads_deleted = thread_ids.len() as u64;
1449 let room_name = room.name.clone();
1450 self.db()
1451 .primary()
1452 .transaction::<_, (), AdminError>(move |txn| {
1453 Box::pin(async move {
1454 if !message_ids.is_empty() {
1455 Self::delete_message_dependents(txn, &message_ids).await?;
1456 }
1457 attachments::Entity::delete_many()
1458 .filter(attachments::Column::RoomId.eq(room_id))
1459 .exec(txn)
1460 .await?;
1461 if !thread_ids.is_empty() {
1462 models::chat::thread_members::Entity::delete_many()
1463 .filter(
1464 models::chat::thread_members::Column::ThreadId
1465 .is_in(thread_ids.clone()),
1466 )
1467 .exec(txn)
1468 .await?;
1469 threads::Entity::delete_many()
1470 .filter(threads::Column::Id.is_in(thread_ids))
1471 .exec(txn)
1472 .await?;
1473 }
1474 messages::Entity::delete_many()
1475 .filter(messages::Column::RoomId.eq(room_id))
1476 .exec(txn)
1477 .await?;
1478 read_states::Entity::delete_many()
1479 .filter(read_states::Column::RoomId.eq(room_id))
1480 .exec(txn)
1481 .await?;
1482 room_user_settings::Entity::delete_many()
1483 .filter(room_user_settings::Column::RoomId.eq(room_id))
1484 .exec(txn)
1485 .await?;
1486 room_bookmarks::Entity::delete_many()
1487 .filter(room_bookmarks::Column::RoomId.eq(room_id))
1488 .exec(txn)
1489 .await?;
1490 user_drafts::Entity::delete_many()
1491 .filter(user_drafts::Column::RoomId.eq(room_id))
1492 .exec(txn)
1493 .await?;
1494 room_bans::Entity::delete_many()
1495 .filter(room_bans::Column::RoomId.eq(room_id))
1496 .exec(txn)
1497 .await?;
1498 room_invites::Entity::delete_many()
1499 .filter(room_invites::Column::RoomId.eq(room_id))
1500 .exec(txn)
1501 .await?;
1502 room_webhooks::Entity::delete_many()
1503 .filter(room_webhooks::Column::RoomId.eq(room_id))
1504 .exec(txn)
1505 .await?;
1506 outbound_webhooks::Entity::delete_many()
1507 .filter(outbound_webhooks::Column::RoomId.eq(room_id))
1508 .exec(txn)
1509 .await?;
1510 webhook_deliveries::Entity::delete_many()
1511 .filter(webhook_deliveries::Column::RoomId.eq(room_id))
1512 .exec(txn)
1513 .await?;
1514 room_seq::Entity::delete_by_id(room_id).exec(txn).await?;
1515 models::users::user_saved_messages::Entity::delete_many()
1516 .filter(models::users::user_saved_messages::Column::ChannelId.eq(room_id))
1517 .exec(txn)
1518 .await?;
1519 models::users::user_recent_mentions::Entity::delete_many()
1520 .filter(models::users::user_recent_mentions::Column::ChannelId.eq(room_id))
1521 .exec(txn)
1522 .await?;
1523 room_members::Entity::delete_many()
1524 .filter(room_members::Column::RoomId.eq(room_id))
1525 .exec(txn)
1526 .await?;
1527 rooms::Entity::delete_by_id(room_id).exec(txn).await?;
1528 Ok(())
1529 })
1530 })
1531 .await?;
1532 for key in &attachment_keys {
1533 if let Err(error) = self.storage().client().delete(key).await {
1534 tracing::warn!(%error, %key, "admin chat room storage delete failed");
1535 }
1536 }
1537 self.audit(
1538 Some(actor),
1539 AuditRecord {
1540 action: "chat.room_deleted",
1541 target: Some(("chat_room", room_id.to_string())),
1542 detail: json!({
1543 "name": room_name,
1544 "messages_deleted": messages_deleted,
1545 "members_deleted": members_deleted,
1546 "attachments_deleted": attachments_deleted,
1547 "threads_deleted": threads_deleted,
1548 }),
1549 },
1550 ctx,
1551 )
1552 .await?;
1553 Ok(DeleteChatRoomResp {
1554 room_id,
1555 messages_deleted,
1556 members_deleted,
1557 attachments_deleted,
1558 threads_deleted,
1559 })
1560 }
1561
1562 pub async fn list_chat_attachments(
1563 &self,
1564 query: ListChatAttachmentsQuery,
1565 ) -> Result<Page<AdminChatAttachmentDto>, AdminError> {
1566 let limit = query.limit.unwrap_or(50).clamp(1, MAX_CHAT_PAGE);
1567 let offset = query.offset.unwrap_or(0);
1568 let mut select = attachments::Entity::find();
1569 if let Some(room_id) = query.room_id {
1570 select = select.filter(attachments::Column::RoomId.eq(room_id));
1571 }
1572 if let Some(user_id) = query.user_id {
1573 select = select.filter(attachments::Column::UserId.eq(user_id));
1574 }
1575 select = match query.status.as_deref() {
1576 Some("pending") => select.filter(attachments::Column::Status.eq(AttachmentStatus::Pending)),
1577 Some("uploaded") => select.filter(attachments::Column::Status.eq(AttachmentStatus::Uploaded)),
1578 _ => select,
1579 };
1580 if let Some(raw) = query
1581 .q
1582 .as_deref()
1583 .map(str::trim)
1584 .filter(|value| !value.is_empty())
1585 {
1586 select = select.filter(
1587 Condition::any()
1588 .add(attachments::Column::Filename.contains(raw))
1589 .add(attachments::Column::S3Key.contains(raw)),
1590 );
1591 }
1592 let total = select.clone().count(self.db().read()).await? as i64;
1593 let rows = select
1594 .order_by_desc(attachments::Column::CreatedAt)
1595 .limit(limit)
1596 .offset(offset)
1597 .all(self.db().read())
1598 .await?;
1599 let user_ids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
1600 let users = self.username_map(&user_ids).await?;
1601 let items = rows
1602 .into_iter()
1603 .map(|row| AdminChatAttachmentDto {
1604 id: row.id,
1605 user_id: row.user_id,
1606 username: users.get(&row.user_id).map(|(name, _)| name.clone()),
1607 display_name: users.get(&row.user_id).map(|(_, display)| display.clone()),
1608 room_id: row.room_id,
1609 message_id: row.message_id,
1610 filename: row.filename,
1611 description: row.description,
1612 content_type: row.content_type,
1613 size: row.size,
1614 bucket: row.bucket,
1615 s3_key: row.s3_key,
1616 status: attachment_status_key(row.status).to_string(),
1617 created_at: row.created_at,
1618 })
1619 .collect();
1620 Ok(Page {
1621 items,
1622 total,
1623 limit,
1624 offset,
1625 })
1626 }
1627
1628 pub async fn delete_chat_attachment(
1629 &self,
1630 actor: &AdminActor,
1631 attachment_id: Uuid,
1632 ctx: &AuditCtx,
1633 ) -> Result<DeleteChatAttachmentResp, AdminError> {
1634 actor.require(AdminRole::SuperAdmin)?;
1635 let row = attachments::Entity::find_by_id(attachment_id)
1636 .one(self.db().read())
1637 .await?
1638 .ok_or(AdminError::NotFound)?;
1639 let shared = attachments::Entity::find()
1640 .filter(attachments::Column::S3Key.eq(row.s3_key.clone()))
1641 .filter(attachments::Column::Id.ne(row.id))
1642 .count(self.db().read())
1643 .await?;
1644 attachments::Entity::delete_by_id(attachment_id)
1645 .exec(self.db().write())
1646 .await?;
1647 let mut storage_deleted = false;
1648 if shared == 0 {
1649 match self.storage().client().delete(&row.s3_key).await {
1650 Ok(()) => storage_deleted = true,
1651 Err(error) => {
1652 tracing::warn!(%error, key = %row.s3_key, "admin chat attachment storage delete failed");
1653 }
1654 }
1655 }
1656 self.audit(
1657 Some(actor),
1658 AuditRecord {
1659 action: "chat.attachment_deleted",
1660 target: Some(("chat_attachment", attachment_id.to_string())),
1661 detail: json!({
1662 "s3_key": row.s3_key,
1663 "storage_deleted": storage_deleted,
1664 "filename": row.filename,
1665 }),
1666 },
1667 ctx,
1668 )
1669 .await?;
1670 Ok(DeleteChatAttachmentResp {
1671 id: attachment_id,
1672 s3_key: row.s3_key,
1673 deleted: true,
1674 storage_deleted,
1675 })
1676 }
1677
1678 pub async fn export_chat_messages(
1679 &self,
1680 query: ListChatMessagesQuery,
1681 ) -> Result<Vec<AdminChatMessageDto>, AdminError> {
1682 let mut query = query;
1683 query.limit = Some(MAX_CHAT_EXPORT_ROWS);
1684 query.offset = Some(0);
1685 Ok(self
1686 .chat_messages_page(query, MAX_CHAT_EXPORT_ROWS)
1687 .await?
1688 .items)
1689 }
1690}
Modified lib/admin/src/services/logs.rs View lib/admin/src/services/logs.rs
@@ -4,7 +4,10 @@
44use models::repo::pipeline_run::{self, PipelineRunState};
55use models::search::index_job;
66use models::system::system_tasks;
7use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
7use sea_orm::{
8 ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
9 Select,
10};
811use serde::{Deserialize, Serialize};
912use time::OffsetDateTime;
1013use utoipa::{IntoParams, ToSchema};
@@ -16,9 +19,11 @@
1619#[derive(Debug, Clone, Deserialize, IntoParams)]
1720pub struct ListAuditQuery {
1821 pub admin_id: Option<Uuid>,
22 pub admin_username: Option<String>,
1923 pub action: Option<String>,
2024 pub target_type: Option<String>,
2125 pub target_id: Option<String>,
26 pub q: Option<String>,
2227 pub from: Option<OffsetDateTime>,
2328 pub to: Option<OffsetDateTime>,
2429 pub limit: Option<u64>,
@@ -82,42 +87,7 @@
8287 ) -> Result<AuditLogPageDto, AdminError> {
8388 let limit = query.limit.unwrap_or(50).clamp(1, 100);
8489 let offset = query.offset.unwrap_or(0);
85 let mut select = admin_audit_log::Entity::find();
86 if let Some(admin_id) = query.admin_id {
87 select = select.filter(admin_audit_log::Column::AdminId.eq(admin_id));
88 }
89 if let Some(action) = query
90 .action
91 .as_deref()
92 .map(str::trim)
93 .filter(|value| !value.is_empty())
94 {
95 select =
96 select.filter(admin_audit_log::Column::Action.contains(action.to_ascii_lowercase()));
97 }
98 if let Some(target_type) = query
99 .target_type
100 .as_deref()
101 .map(str::trim)
102 .filter(|value| !value.is_empty())
103 {
104 select = select.filter(admin_audit_log::Column::TargetType.eq(target_type));
105 }
106 if let Some(target_id) = query
107 .target_id
108 .as_deref()
109 .map(str::trim)
110 .filter(|value| !value.is_empty())
111 {
112 select = select.filter(admin_audit_log::Column::TargetId.eq(target_id));
113 }
114 if let Some(from) = query.from {
115 select = select.filter(admin_audit_log::Column::CreatedAt.gte(from));
116 }
117 if let Some(to) = query.to {
118 select = select.filter(admin_audit_log::Column::CreatedAt.lte(to));
119 }
120 select = select.order_by_desc(admin_audit_log::Column::CreatedAt);
90 let select = self.audit_select(&query).await?;
12191 let total = select.clone().count(self.db().read()).await? as i64;
12292 let rows = select
12393 .limit(limit)
@@ -297,4 +267,141 @@
297267 offset,
298268 })
299269 }
270}
271
272impl AdminService {
273 async fn audit_select(
274 &self,
275 query: &ListAuditQuery,
276 ) -> Result<Select<admin_audit_log::Entity>, AdminError> {
277 let mut select = admin_audit_log::Entity::find();
278 if let Some(admin_id) = query.admin_id {
279 select = select.filter(admin_audit_log::Column::AdminId.eq(admin_id));
280 }
281 if let Some(username) = query
282 .admin_username
283 .as_deref()
284 .map(str::trim)
285 .filter(|value| !value.is_empty())
286 {
287 select = select.filter(
288 admin_audit_log::Column::AdminUsername.eq(username.to_ascii_lowercase()),
289 );
290 }
291 if let Some(action) = query
292 .action
293 .as_deref()
294 .map(str::trim)
295 .filter(|value| !value.is_empty())
296 {
297 select =
298 select.filter(admin_audit_log::Column::Action.contains(action.to_ascii_lowercase()));
299 }
300 if let Some(target_type) = query
301 .target_type
302 .as_deref()
303 .map(str::trim)
304 .filter(|value| !value.is_empty())
305 {
306 select = select.filter(admin_audit_log::Column::TargetType.eq(target_type));
307 }
308 if let Some(target_id) = query
309 .target_id
310 .as_deref()
311 .map(str::trim)
312 .filter(|value| !value.is_empty())
313 {
314 select = select.filter(admin_audit_log::Column::TargetId.eq(target_id));
315 }
316 if let Some(q) = query
317 .q
318 .as_deref()
319 .map(str::trim)
320 .filter(|value| !value.is_empty())
321 {
322 let needle = q.to_ascii_lowercase();
323 select = select.filter(
324 Condition::any()
325 .add(admin_audit_log::Column::AdminUsername.contains(needle.clone()))
326 .add(admin_audit_log::Column::Action.contains(needle.clone()))
327 .add(admin_audit_log::Column::TargetId.contains(q))
328 .add(sea_orm::sea_query::Expr::cust_with_values(
329 "detail::text ILIKE $1",
330 [format!("%{needle}%")],
331 )),
332 );
333 }
334 if let Some(from) = query.from {
335 select = select.filter(admin_audit_log::Column::CreatedAt.gte(from));
336 }
337 if let Some(to) = query.to {
338 select = select.filter(admin_audit_log::Column::CreatedAt.lte(to));
339 }
340 Ok(select.order_by_desc(admin_audit_log::Column::CreatedAt))
341 }
342
343 pub async fn export_audit_logs(
344 &self,
345 query: ListAuditQuery,
346 ) -> Result<Vec<AuditLogDto>, AdminError> {
347 let rows = self
348 .audit_select(&query)
349 .await?
350 .limit(10000)
351 .all(self.db().read())
352 .await?;
353 Ok(rows
354 .into_iter()
355 .map(|row| AuditLogDto {
356 id: row.id,
357 admin_id: row.admin_id,
358 admin_username: row.admin_username,
359 action: row.action,
360 target_type: row.target_type,
361 target_id: row.target_id,
362 detail: row.detail,
363 ip: row.ip,
364 user_agent: row.user_agent,
365 request_id: row.request_id,
366 created_at: row.created_at,
367 })
368 .collect())
369 }
370
371 pub async fn queue_failed_jobs(
372 &self,
373 name: &str,
374 limit: u64,
375 offset: u64,
376 ) -> Result<JobLogPageDto, AdminError> {
377 let source = match name {
378 "search_index" => Some("search_index"),
379 "import" => Some("import"),
380 "chat_webhook" => Some("chat_webhook"),
381 "pipeline" => Some("pipeline"),
382 "system_tasks" => Some("system_tasks"),
383 "git_sync" | "email" | "deletion" => None,
384 other => {
385 return Err(AdminError::BadRequest(format!(
386 "unknown queue: {other}"
387 )));
388 }
389 };
390 let Some(source) = source else {
391 return Ok(JobLogPageDto {
392 items: Vec::new(),
393 total: 0,
394 limit: limit.clamp(1, 100),
395 offset,
396 });
397 };
398 self.list_job_logs(ListJobLogsQuery {
399 source: Some(source.to_string()),
400 from: None,
401 to: None,
402 limit: Some(limit),
403 offset: Some(offset),
404 })
405 .await
406 }
300407}
No newline at end of file
Modified lib/admin/src/services/mod.rs View lib/admin/src/services/mod.rs
@@ -1,5 +1,6 @@
11pub mod audit;
22pub mod auth;
3pub mod chat;
34pub mod logs;
45pub mod queues;
56pub mod service;
Modified lib/admin/src/services/spaces.rs View lib/admin/src/services/spaces.rs
@@ -3,7 +3,7 @@
33use models::dir::dirs;
44use models::repo::repos;
55use models::room::rooms;
6use models::space::space_members::{self, MemberStatus};
6use models::space::space_members::{self, MemberStatus, MessageNotifications};
77use models::space::spaces;
88use models::users::user;
99use sea_orm::sea_query::Expr;
@@ -48,6 +48,9 @@
4848 pub owner_username: Option<String>,
4949 pub is_private: bool,
5050 pub is_active: bool,
51 pub allow_join: bool,
52 pub allow_invite: bool,
53 pub member_must_enable_2fa: bool,
5154 pub nums_members: i64,
5255 pub created_at: OffsetDateTime,
5356 pub updated_at: OffsetDateTime,
@@ -72,9 +75,30 @@
7275 pub is_private: Option<bool>,
7376 #[serde(default)]
7477 pub is_active: Option<bool>,
78 #[serde(default)]
79 pub allow_join: Option<bool>,
80 #[serde(default)]
81 pub allow_invite: Option<bool>,
82 #[serde(default)]
83 pub member_must_enable_2fa: Option<bool>,
7584}
7685
86#[derive(Debug, Clone, Deserialize, ToSchema)]
87pub struct AddSpaceMemberCmd {
88 pub user_id: Uuid,
89 #[serde(default)]
90 pub alias_name: Option<String>,
91}
92
7793#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
94pub struct AddSpaceMemberResp {
95 pub space_id: Uuid,
96 pub user_id: Uuid,
97 pub status: String,
98 pub added: bool,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
78102pub struct TransferSpaceCmd {
79103 pub new_owner_id: Uuid,
80104}
@@ -173,6 +197,9 @@
173197 owner_username: owners.get(&row.owner_id).cloned(),
174198 is_private: row.is_private,
175199 is_active: row.is_active,
200 allow_join: row.allow_join,
201 allow_invite: row.allow_invite,
202 member_must_enable_2fa: row.member_must_enable_2fa,
176203 nums_members: row.nums_members,
177204 created_at: row.created_at,
178205 updated_at: row.updated_at,
@@ -225,6 +252,9 @@
225252 owner_username: owners.get(&row.owner_id).cloned(),
226253 is_private: row.is_private,
227254 is_active: row.is_active,
255 allow_join: row.allow_join,
256 allow_invite: row.allow_invite,
257 member_must_enable_2fa: row.member_must_enable_2fa,
228258 nums_members: row.nums_members,
229259 created_at: row.created_at,
230260 updated_at: row.updated_at,
@@ -271,6 +301,18 @@
271301 am.is_active = Set(value);
272302 changed.push("is_active".to_string());
273303 }
304 if let Some(value) = cmd.allow_join {
305 am.allow_join = Set(value);
306 changed.push("allow_join".to_string());
307 }
308 if let Some(value) = cmd.allow_invite {
309 am.allow_invite = Set(value);
310 changed.push("allow_invite".to_string());
311 }
312 if let Some(value) = cmd.member_must_enable_2fa {
313 am.member_must_enable_2fa = Set(value);
314 changed.push("member_must_enable_2fa".to_string());
315 }
274316 if changed.is_empty() {
275317 return self.get_space(id).await;
276318 }
@@ -480,4 +522,123 @@
480522 removed: true,
481523 })
482524 }
525}
526
527impl AdminService {
528 pub async fn add_space_member(
529 &self,
530 actor: &AdminActor,
531 space_id: Uuid,
532 cmd: AddSpaceMemberCmd,
533 ctx: &AuditCtx,
534 ) -> Result<AddSpaceMemberResp, AdminError> {
535 actor.require(AdminRole::Operator)?;
536 let space = spaces::Entity::find_by_id(space_id)
537 .one(self.db().read())
538 .await?
539 .ok_or(AdminError::NotFound)?;
540 let target = user::Entity::find_by_id(cmd.user_id)
541 .one(self.db().read())
542 .await?
543 .ok_or_else(|| AdminError::BadRequest("user does not exist".to_string()))?;
544 if space.member_must_enable_2fa && !target.totp_enabled {
545 return Err(AdminError::BadRequest(
546 "space requires members to enable two-factor authentication".to_string(),
547 ));
548 }
549 if let Some(alias) = cmd.alias_name.as_deref()
550 && alias.chars().count() > self.config().space.max_alias_name_len
551 {
552 return Err(AdminError::BadRequest(
553 "alias name is too long".to_string(),
554 ));
555 }
556 let existing = space_members::Entity::find_by_id((space_id, cmd.user_id))
557 .one(self.db().read())
558 .await?;
559 let now = OffsetDateTime::now_utc();
560 match existing {
561 Some(row) if row.status == MemberStatus::Active => Ok(AddSpaceMemberResp {
562 space_id,
563 user_id: cmd.user_id,
564 status: member_status_str(MemberStatus::Active),
565 added: false,
566 }),
567 Some(row) => {
568 let mut am: space_members::ActiveModel = row.into();
569 am.status = Set(MemberStatus::Active);
570 am.left_at = Set(None);
571 am.alias_name = Set(cmd.alias_name.clone());
572 am.updated_at = Set(Some(now));
573 am.update(self.db().write()).await?;
574 spaces::Entity::update_many()
575 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
576 .filter(spaces::Column::Id.eq(space_id))
577 .exec(self.db().write())
578 .await?;
579 self.audit(
580 Some(actor),
581 AuditRecord {
582 action: "space.member_added",
583 target: Some(("space", space_id.to_string())),
584 detail: json!({ "user_id": cmd.user_id, "reactivated": true }),
585 },
586 ctx,
587 )
588 .await?;
589 Ok(AddSpaceMemberResp {
590 space_id,
591 user_id: cmd.user_id,
592 status: member_status_str(MemberStatus::Active),
593 added: true,
594 })
595 }
596 None => {
597 space_members::ActiveModel {
598 space_id: Set(space_id),
599 user_id: Set(cmd.user_id),
600 alias_name: Set(cmd.alias_name.clone()),
601 alias_avatar_url: Set(None),
602 status: Set(MemberStatus::Active),
603 last_active_at: Set(None),
604 left_at: Set(None),
605 invited_at: Set(None),
606 invited_by: Set(None),
607 joined_at: Set(now),
608 created_at: Set(now),
609 updated_at: Set(None),
610 message_notifications: Set(MessageNotifications::default()),
611 muted: Set(false),
612 mute_until: Set(None),
613 mobile_push: Set(false),
614 suppress_everyone: Set(false),
615 suppress_roles: Set(false),
616 hide_muted_channels: Set(false),
617 }
618 .insert(self.db().write())
619 .await?;
620 spaces::Entity::update_many()
621 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
622 .filter(spaces::Column::Id.eq(space_id))
623 .exec(self.db().write())
624 .await?;
625 self.audit(
626 Some(actor),
627 AuditRecord {
628 action: "space.member_added",
629 target: Some(("space", space_id.to_string())),
630 detail: json!({ "user_id": cmd.user_id, "reactivated": false }),
631 },
632 ctx,
633 )
634 .await?;
635 Ok(AddSpaceMemberResp {
636 space_id,
637 user_id: cmd.user_id,
638 status: member_status_str(MemberStatus::Active),
639 added: true,
640 })
641 }
642 }
643 }
483644}
No newline at end of file
Modified lib/admin/src/services/system.rs View lib/admin/src/services/system.rs
@@ -1,21 +1,24 @@
11use std::time::Instant;
22
3use models::admin::admin_user;
3use std::collections::HashMap;
4
5use models::admin::{admin_audit_log, admin_user};
46use models::chat::messages;
57use models::issue::issues;
68use models::repo::repos;
79use models::room::rooms;
810use models::space::spaces;
9use models::users::user;
11use models::users::{user, user_sessions};
1012use sea_orm::{
11 ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, QueryFilter, Statement,
13 ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
14 QuerySelect, Statement,
1215};
1316use serde::{Deserialize, Serialize};
1417use time::OffsetDateTime;
1518use utoipa::ToSchema;
1619
1720use crate::errors::AdminError;
18use crate::services::logs::JobLogDto;
21use crate::services::logs::{AuditLogDto, JobLogDto};
1922use crate::services::queues::QueueSnapshotDto;
2023use crate::services::service::AdminService;
2124
@@ -265,4 +268,374 @@
265268 generated_at: OffsetDateTime::now_utc(),
266269 })
267270 }
268}
No newline at end of file
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
274pub struct HostEntryDto {
275 pub name: String,
276 pub enabled: bool,
277 pub bind_addr: Option<String>,
278 pub port: Option<u16>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
282pub struct AdminEndpointDto {
283 pub enabled: bool,
284 pub bind_addr: String,
285 pub port: u16,
286 pub session_cookie_name: String,
287 pub session_table_name: String,
288 pub cors_allow_origins: Vec<String>,
289 pub cors_allow_credentials: bool,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
293pub struct SearchConfigDto {
294 pub enabled: bool,
295 pub index_prefix: String,
296 pub max_file_bytes: usize,
297 pub batch_size: usize,
298 pub poll_interval_secs: u64,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
302pub struct ImportConfigDto {
303 pub enabled: bool,
304 pub allowed_hosts: Vec<String>,
305 pub allow_insecure_tls: bool,
306 pub max_concurrent_jobs: usize,
307 pub max_repo_bytes: u64,
308 pub max_attempts: i32,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
312pub struct GitConfigDto {
313 pub repo_root: String,
314 pub objects_root: String,
315 pub max_blob_size: u64,
316 pub sync_topic: String,
317 pub sync_concurrency: usize,
318 pub base_url: String,
319 pub graphql_complexity_limit: u64,
320 pub graphql_introspection: bool,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
324pub struct StorageConfigDto {
325 pub bucket: Option<String>,
326 pub region: Option<String>,
327 pub path_style: Option<bool>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
331pub struct DatabaseConfigDto {
332 pub endpoint: Option<String>,
333 pub host: Option<String>,
334 pub port: Option<u16>,
335 pub database: Option<String>,
336 pub ssl_mode: Option<String>,
337 pub max_connections: Option<u32>,
338 pub min_connections: Option<u32>,
339 pub replica_count: usize,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
343pub struct RedisConfigDto {
344 pub endpoint: String,
345 pub mode: String,
346 pub database: Option<u16>,
347 pub pool_size: usize,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
351pub struct SessionConfigDto {
352 pub mode: String,
353 pub cookie_name: Option<String>,
354 pub cookie_domain: Option<String>,
355 pub cookie_secure: Option<bool>,
356 pub cookie_same_site: String,
357 pub lifespan_secs: Option<i64>,
358 pub table_name: Option<String>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
362pub struct EmailConfigDto {
363 pub enabled: bool,
364 pub queue_enabled: bool,
365 pub smtp_host: String,
366 pub smtp_port: u16,
367 pub from_address: Option<String>,
368 pub from_name: Option<String>,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
372pub struct SystemConfigDto {
373 pub version: String,
374 pub hosts: Vec<HostEntryDto>,
375 pub admin: AdminEndpointDto,
376 pub features: FeaturesDto,
377 pub search: SearchConfigDto,
378 pub import: ImportConfigDto,
379 pub git: GitConfigDto,
380 pub storage: StorageConfigDto,
381 pub database: DatabaseConfigDto,
382 pub redis: RedisConfigDto,
383 pub session: SessionConfigDto,
384 pub email: EmailConfigDto,
385 pub generated_at: OffsetDateTime,
386}
387
388fn redact_endpoint(value: &str) -> String {
389 let Some(scheme_end) = value.find("://") else {
390 return value.to_string();
391 };
392 let (scheme, rest) = value.split_at(scheme_end + 3);
393 let end = rest.find(['/', '?']).unwrap_or(rest.len());
394 let authority = &rest[..end];
395 let host = authority.rsplit('@').next().unwrap_or(authority);
396 format!("{scheme}{host}{}", &rest[end..])
397}
398
399impl AdminService {
400 pub async fn system_config(&self) -> Result<SystemConfigDto, AdminError> {
401 let config = self.config();
402 let hosts = vec![
403 HostEntryDto {
404 name: "apihost".to_string(),
405 enabled: config.apihost.enabled(),
406 bind_addr: config.apihost.bind_addr.clone(),
407 port: config.apihost.port,
408 },
409 HostEntryDto {
410 name: "githost".to_string(),
411 enabled: config.githost.enabled(),
412 bind_addr: config.githost.http_bind_addr.map(|value| value.to_string()),
413 port: config.githost.http_port,
414 },
415 ];
416 Ok(SystemConfigDto {
417 version: env!("CARGO_PKG_VERSION").to_string(),
418 hosts,
419 admin: AdminEndpointDto {
420 enabled: config.admin.enabled(),
421 bind_addr: config.admin.bind_addr().to_string(),
422 port: config.admin.port(),
423 session_cookie_name: config.admin.session_cookie_name().to_string(),
424 session_table_name: config.admin.session_table_name().to_string(),
425 cors_allow_origins: config.admin.cors_allow_origins.clone(),
426 cors_allow_credentials: config.admin.cors_allow_credentials(),
427 },
428 features: FeaturesDto {
429 search_enabled: config.search.enabled,
430 import_enabled: config.import.enabled,
431 email_enabled: config.email.enabled(),
432 email_queue_enabled: config.email_queue.enabled(),
433 },
434 search: SearchConfigDto {
435 enabled: config.search.enabled,
436 index_prefix: config.search.index_prefix.clone(),
437 max_file_bytes: config.search.max_file_bytes,
438 batch_size: config.search.batch_size,
439 poll_interval_secs: config.search.poll_interval_secs,
440 },
441 import: ImportConfigDto {
442 enabled: config.import.enabled,
443 allowed_hosts: config.import.allowed_hosts.clone(),
444 allow_insecure_tls: config.import.allow_insecure_tls,
445 max_concurrent_jobs: config.import.max_concurrent_jobs,
446 max_repo_bytes: config.import.max_repo_bytes,
447 max_attempts: config.import.max_attempts,
448 },
449 git: GitConfigDto {
450 repo_root: config.git.repo_root.clone(),
451 objects_root: config.git.objects_root.clone(),
452 max_blob_size: config.git.max_blob_size,
453 sync_topic: config.git.sync.queue_topic.clone(),
454 sync_concurrency: config.git.sync.concurrency,
455 base_url: config.git.base_url.clone(),
456 graphql_complexity_limit: config.git.graphql_complexity_limit,
457 graphql_introspection: config.git.graphql_introspection,
458 },
459 storage: StorageConfigDto {
460 bucket: config.s3.bucket.clone(),
461 region: config.s3.region.clone(),
462 path_style: config.s3.path_style,
463 },
464 database: DatabaseConfigDto {
465 endpoint: config
466 .database
467 .url
468 .as_deref()
469 .map(redact_endpoint),
470 host: config.database.host.clone(),
471 port: config.database.port,
472 database: config.database.database.clone(),
473 ssl_mode: config
474 .database
475 .ssl_mode
476 .as_ref()
477 .map(|mode| mode.as_query_value().to_string()),
478 max_connections: config.database.max_connections,
479 min_connections: config.database.min_connections,
480 replica_count: config.database.replicas.len(),
481 },
482 redis: RedisConfigDto {
483 endpoint: redact_endpoint(config.redis.url()),
484 mode: format!("{:?}", config.redis.mode()).to_ascii_lowercase(),
485 database: config.redis.database,
486 pool_size: config.redis.pool_size(),
487 },
488 session: SessionConfigDto {
489 mode: format!("{:?}", config.session.mode.unwrap_or_default()).to_ascii_lowercase(),
490 cookie_name: config.session.cookie_name.clone(),
491 cookie_domain: config.session.cookie_domain.clone(),
492 cookie_secure: config.session.cookie_secure,
493 cookie_same_site: format!(
494 "{:?}",
495 config.session.cookie_same_site.unwrap_or_default()
496 )
497 .to_ascii_lowercase(),
498 lifespan_secs: config.session.lifespan_secs,
499 table_name: config.session.table_name.clone(),
500 },
501 email: EmailConfigDto {
502 enabled: config.email.enabled(),
503 queue_enabled: config.email_queue.enabled(),
504 smtp_host: config.email.smtp_host().to_string(),
505 smtp_port: config.email.smtp_port(),
506 from_address: config.email.from_address().map(str::to_string),
507 from_name: config.email.from_name().map(str::to_string),
508 },
509 generated_at: OffsetDateTime::now_utc(),
510 })
511 }
512}
513#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
514pub struct TrendPointDto {
515 pub date: String,
516 pub users: i64,
517 pub spaces: i64,
518 pub repos: i64,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
522pub struct DashboardDto {
523 pub counts: CountsDto,
524 pub trend: Vec<TrendPointDto>,
525 pub active_sessions: i64,
526 pub restricted_users: i64,
527 pub recent_audit: Vec<AuditLogDto>,
528 pub recent_failures: Vec<JobLogDto>,
529 pub queues: Vec<QueueSnapshotDto>,
530 pub generated_at: OffsetDateTime,
531}
532
533fn day_key(value: OffsetDateTime) -> String {
534 let date = value.date();
535 format!(
536 "{:04}-{:02}-{:02}",
537 date.year(),
538 u8::from(date.month()),
539 date.day()
540 )
541}
542
543impl AdminService {
544 pub async fn dashboard(&self) -> Result<DashboardDto, AdminError> {
545 let (counts, queues) = tokio::join!(self.counts(), self.queue_snapshots());
546 let counts = counts?;
547 let queues = queues?;
548 let now = OffsetDateTime::now_utc();
549 let since = (now.date() - time::Duration::days(13)).midnight().assume_utc();
550
551 let users = user::Entity::find()
552 .filter(user::Column::CreatedAt.gte(since))
553 .all(self.db().read())
554 .await?;
555 let spaces = spaces::Entity::find()
556 .filter(models::space::spaces::Column::CreatedAt.gte(since))
557 .all(self.db().read())
558 .await?;
559 let repos = repos::Entity::find()
560 .filter(repos::Column::CreatedAt.gte(since))
561 .all(self.db().read())
562 .await?;
563
564 let mut buckets: Vec<TrendPointDto> = (0..14)
565 .map(|offset| TrendPointDto {
566 date: day_key(since + time::Duration::days(offset)),
567 users: 0,
568 spaces: 0,
569 repos: 0,
570 })
571 .collect();
572 let mut index: HashMap<String, usize> = HashMap::new();
573 for (position, bucket) in buckets.iter().enumerate() {
574 index.insert(bucket.date.clone(), position);
575 }
576 for row in &users {
577 if let Some(position) = index.get(&day_key(row.created_at)) {
578 buckets[*position].users += 1;
579 }
580 }
581 for row in &spaces {
582 if let Some(position) = index.get(&day_key(row.created_at)) {
583 buckets[*position].spaces += 1;
584 }
585 }
586 for row in &repos {
587 if let Some(position) = index.get(&day_key(row.created_at)) {
588 buckets[*position].repos += 1;
589 }
590 }
591
592 let active_sessions = user_sessions::Entity::find()
593 .filter(user_sessions::Column::IsRevoked.eq(false))
594 .count(self.db().read())
595 .await? as i64;
596 let restricted_users = user::Entity::find()
597 .filter(user::Column::IsRestricted.eq(true))
598 .count(self.db().read())
599 .await? as i64;
600 let recent_audit = admin_audit_log::Entity::find()
601 .order_by_desc(admin_audit_log::Column::CreatedAt)
602 .limit(10)
603 .all(self.db().read())
604 .await?
605 .into_iter()
606 .map(|row| AuditLogDto {
607 id: row.id,
608 admin_id: row.admin_id,
609 admin_username: row.admin_username,
610 action: row.action,
611 target_type: row.target_type,
612 target_id: row.target_id,
613 detail: row.detail,
614 ip: row.ip,
615 user_agent: row.user_agent,
616 request_id: row.request_id,
617 created_at: row.created_at,
618 })
619 .collect();
620 let recent_failures = self
621 .list_job_logs(crate::services::logs::ListJobLogsQuery {
622 source: None,
623 from: None,
624 to: None,
625 limit: Some(8),
626 offset: Some(0),
627 })
628 .await?
629 .items;
630 Ok(DashboardDto {
631 counts,
632 trend: buckets,
633 active_sessions,
634 restricted_users,
635 recent_audit,
636 recent_failures,
637 queues,
638 generated_at: now,
639 })
640 }
641}
Modified lib/admin/src/services/tasks.rs View lib/admin/src/services/tasks.rs
@@ -6,7 +6,8 @@
66use models::system::system_tasks::{self, TaskStatus};
77use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
88use sea_orm::{
9 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
9 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
10 Set,
1011};
1112use serde::{Deserialize, Serialize};
1213use serde_json::{Value, json};
@@ -959,4 +960,257 @@
959960 .await?;
960961 Ok(())
961962 }
962}
No newline at end of file
963}
964#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
965pub struct TaskSourceStatDto {
966 pub source: String,
967 pub queued: i64,
968 pub running: i64,
969 pub succeeded: i64,
970 pub failed: i64,
971}
972
973#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
974pub struct TaskStatsDto {
975 pub sources: Vec<TaskSourceStatDto>,
976 pub total: i64,
977 pub generated_at: OffsetDateTime,
978}
979
980#[derive(Debug, Clone, Deserialize, ToSchema)]
981pub struct BulkTaskCmd {
982 pub action: String,
983 pub ids: Vec<Uuid>,
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
987pub struct BulkTaskFailure {
988 pub id: Uuid,
989 pub error: String,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
993pub struct BulkTaskResp {
994 pub source: String,
995 pub action: String,
996 pub succeeded: Vec<Uuid>,
997 pub failed: Vec<BulkTaskFailure>,
998}
999
1000impl AdminService {
1001 pub async fn task_stats(&self) -> Result<TaskStatsDto, AdminError> {
1002 let mut sources: Vec<TaskSourceStatDto> = Vec::new();
1003 for source in self.requested_sources(&None) {
1004 let stat = match source {
1005 SOURCE_IMPORT => TaskSourceStatDto {
1006 source: source.to_string(),
1007 queued: job::Entity::find()
1008 .filter(job::Column::State.eq("queued"))
1009 .count(self.db().read())
1010 .await? as i64,
1011 running: job::Entity::find()
1012 .filter(job::Column::State.eq("processing"))
1013 .count(self.db().read())
1014 .await? as i64,
1015 succeeded: job::Entity::find()
1016 .filter(job::Column::State.eq("succeeded"))
1017 .count(self.db().read())
1018 .await? as i64,
1019 failed: job::Entity::find()
1020 .filter(job::Column::State.eq("failed"))
1021 .count(self.db().read())
1022 .await? as i64,
1023 },
1024 SOURCE_SEARCH_INDEX => TaskSourceStatDto {
1025 source: source.to_string(),
1026 queued: index_job::Entity::find()
1027 .filter(index_job::Column::State.eq("queued"))
1028 .count(self.db().read())
1029 .await? as i64,
1030 running: index_job::Entity::find()
1031 .filter(index_job::Column::State.eq("processing"))
1032 .count(self.db().read())
1033 .await? as i64,
1034 succeeded: index_job::Entity::find()
1035 .filter(index_job::Column::State.eq("succeeded"))
1036 .count(self.db().read())
1037 .await? as i64,
1038 failed: index_job::Entity::find()
1039 .filter(index_job::Column::LastError.is_not_null())
1040 .count(self.db().read())
1041 .await? as i64,
1042 },
1043 SOURCE_CHAT_WEBHOOK => TaskSourceStatDto {
1044 source: source.to_string(),
1045 queued: webhook_deliveries::Entity::find()
1046 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
1047 .count(self.db().read())
1048 .await? as i64,
1049 running: webhook_deliveries::Entity::find()
1050 .filter(
1051 webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Delivering),
1052 )
1053 .count(self.db().read())
1054 .await? as i64,
1055 succeeded: webhook_deliveries::Entity::find()
1056 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Done))
1057 .count(self.db().read())
1058 .await? as i64,
1059 failed: webhook_deliveries::Entity::find()
1060 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
1061 .count(self.db().read())
1062 .await? as i64,
1063 },
1064 SOURCE_PIPELINE => TaskSourceStatDto {
1065 source: source.to_string(),
1066 queued: pipeline_run::Entity::find()
1067 .filter(pipeline_run::Column::State.eq(PipelineRunState::Queued))
1068 .count(self.db().read())
1069 .await? as i64,
1070 running: pipeline_run::Entity::find()
1071 .filter(pipeline_run::Column::State.eq(PipelineRunState::Running))
1072 .count(self.db().read())
1073 .await? as i64,
1074 succeeded: pipeline_run::Entity::find()
1075 .filter(pipeline_run::Column::State.eq(PipelineRunState::Success))
1076 .count(self.db().read())
1077 .await? as i64,
1078 failed: pipeline_run::Entity::find()
1079 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
1080 .count(self.db().read())
1081 .await? as i64,
1082 },
1083 SOURCE_DELETION => TaskSourceStatDto {
1084 source: source.to_string(),
1085 queued: user_deletion_requests::Entity::find()
1086 .filter(
1087 user_deletion_requests::Column::Status
1088 .is_in([UserDeletionRequestStatus::Pending, UserDeletionRequestStatus::Scheduled]),
1089 )
1090 .count(self.db().read())
1091 .await? as i64,
1092 running: 0,
1093 succeeded: user_deletion_requests::Entity::find()
1094 .filter(
1095 user_deletion_requests::Column::Status
1096 .eq(UserDeletionRequestStatus::Completed),
1097 )
1098 .count(self.db().read())
1099 .await? as i64,
1100 failed: 0,
1101 },
1102 _ => TaskSourceStatDto {
1103 source: source.to_string(),
1104 queued: system_tasks::Entity::find()
1105 .filter(system_tasks::Column::Status.eq(TaskStatus::Pending))
1106 .count(self.db().read())
1107 .await? as i64,
1108 running: system_tasks::Entity::find()
1109 .filter(system_tasks::Column::Status.eq(TaskStatus::Running))
1110 .count(self.db().read())
1111 .await? as i64,
1112 succeeded: system_tasks::Entity::find()
1113 .filter(system_tasks::Column::Status.eq(TaskStatus::Success))
1114 .count(self.db().read())
1115 .await? as i64,
1116 failed: system_tasks::Entity::find()
1117 .filter(system_tasks::Column::Status.eq(TaskStatus::Failed))
1118 .count(self.db().read())
1119 .await? as i64,
1120 },
1121 };
1122 sources.push(stat);
1123 }
1124 let total = sources
1125 .iter()
1126 .map(|stat| stat.queued + stat.running + stat.succeeded + stat.failed)
1127 .sum();
1128 Ok(TaskStatsDto {
1129 sources,
1130 total,
1131 generated_at: OffsetDateTime::now_utc(),
1132 })
1133 }
1134
1135 pub async fn bulk_tasks(
1136 &self,
1137 actor: &AdminActor,
1138 source: &str,
1139 cmd: BulkTaskCmd,
1140 ctx: &AuditCtx,
1141 ) -> Result<BulkTaskResp, AdminError> {
1142 actor.require(AdminRole::Operator)?;
1143 if ![
1144 SOURCE_IMPORT,
1145 SOURCE_SEARCH_INDEX,
1146 SOURCE_CHAT_WEBHOOK,
1147 SOURCE_PIPELINE,
1148 SOURCE_DELETION,
1149 SOURCE_SYSTEM_TASKS,
1150 ]
1151 .contains(&source)
1152 {
1153 return Err(AdminError::BadRequest(format!(
1154 "unknown task source: {source}"
1155 )));
1156 }
1157 let action = cmd.action.trim().to_ascii_lowercase();
1158 match action.as_str() {
1159 "retry" | "cancel" => {}
1160 other => {
1161 return Err(AdminError::BadRequest(format!(
1162 "unsupported bulk action: {other}"
1163 )));
1164 }
1165 }
1166 if cmd.ids.is_empty() {
1167 return Err(AdminError::BadRequest("ids must not be empty".to_string()));
1168 }
1169 if cmd.ids.len() > 100 {
1170 return Err(AdminError::BadRequest(
1171 "at most 100 ids per request".to_string(),
1172 ));
1173 }
1174 let mut succeeded: Vec<Uuid> = Vec::new();
1175 let mut failed: Vec<BulkTaskFailure> = Vec::new();
1176 for id in cmd.ids {
1177 let outcome = if action == "retry" {
1178 self.retry_task(actor, source, id, ctx).await.map(|_| ())
1179 } else {
1180 self.cancel_task(actor, source, id, ctx).await.map(|_| ())
1181 };
1182 match outcome {
1183 Ok(()) => succeeded.push(id),
1184 Err(AdminError::NotFound) => failed.push(BulkTaskFailure {
1185 id,
1186 error: "not found".to_string(),
1187 }),
1188 Err(error) => failed.push(BulkTaskFailure {
1189 id,
1190 error: error.to_string(),
1191 }),
1192 }
1193 }
1194 self.audit(
1195 Some(actor),
1196 AuditRecord {
1197 action: "task.bulk_action",
1198 target: Some(("task", source.to_string())),
1199 detail: json!({
1200 "action": action,
1201 "source": source,
1202 "succeeded": succeeded.len(),
1203 "failed": failed.len(),
1204 }),
1205 },
1206 ctx,
1207 )
1208 .await?;
1209 Ok(BulkTaskResp {
1210 source: source.to_string(),
1211 action,
1212 succeeded,
1213 failed,
1214 })
1215 }
1216}
Modified lib/admin/src/services/users.rs View lib/admin/src/services/users.rs
@@ -1,6 +1,10 @@
11use std::collections::HashSet;
22
33use export::pagination::Page;
4use auth::share::crypto::{hash_password, validate_password};
5use auth::share::validate::{validate_email, validate_username};
6use models::users::user::ProfileVisibility;
7use models::users::user_sessions::SessionKind;
48use models::users::user_suspensions::{self, SuspensionType};
59use models::users::{
610 user, user_access_keys, user_deletion_requests, user_emails, user_profiles, user_recovery_codes,
@@ -9,8 +13,8 @@
913use models::space::spaces;
1014use sea_orm::sea_query::Expr;
1115use sea_orm::{
12 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, PaginatorTrait,
13 QueryFilter, QueryOrder, QuerySelect, Set,
16 ActiveModelTrait, ColumnTrait, Condition, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait,
17 QueryFilter, QueryOrder, QuerySelect, Select, Set, TransactionTrait,
1418};
1519use serde::{Deserialize, Serialize};
1620use serde_json::json;
@@ -154,47 +158,7 @@
154158 pub async fn list_users(&self, query: ListUsersQuery) -> Result<Page<AdminUserSummary>, AdminError> {
155159 let limit = query.limit.unwrap_or(50).clamp(1, 100);
156160 let offset = query.offset.unwrap_or(0);
157 let mut select = user::Entity::find();
158
159 if let Some(raw) = query
160 .q
161 .as_deref()
162 .map(str::trim)
163 .filter(|value| !value.is_empty())
164 {
165 let needle = raw.to_ascii_lowercase();
166 let mut cond = Condition::any()
167 .add(user::Column::LowerName.contains(needle.clone()))
168 .add(user::Column::Username.contains(needle.clone()))
169 .add(user::Column::DisplayName.contains(raw));
170 let email_uids: Vec<Uuid> = user_emails::Entity::find()
171 .filter(user_emails::Column::LowerEmail.contains(needle))
172 .limit(200)
173 .all(self.db().read())
174 .await?
175 .into_iter()
176 .map(|row| row.user_id)
177 .collect();
178 if !email_uids.is_empty() {
179 cond = cond.add(user::Column::Uid.is_in(email_uids));
180 }
181 select = select.filter(cond);
182 }
183
184 select = match query.status.as_deref() {
185 Some("active") => select.filter(user::Column::IsActive.eq(true)),
186 Some("inactive") => select.filter(user::Column::IsActive.eq(false)),
187 Some("restricted") => select.filter(user::Column::IsRestricted.eq(true)),
188 Some("prohibited") => select.filter(user::Column::ProhibitLogin.eq(true)),
189 _ => select,
190 };
191
192 select = match query.sort.as_deref() {
193 Some("created_at_asc") => select.order_by_asc(user::Column::CreatedAt),
194 Some("last_login") => select.order_by_desc(user::Column::LastLoginAt),
195 _ => select.order_by_desc(user::Column::CreatedAt),
196 };
197
161 let select = self.users_select(&query).await?;
198162 let total = select.clone().count(self.db().read()).await? as i64;
199163 let rows = select
200164 .limit(limit)
@@ -704,4 +668,615 @@
704668
705669fn am_as_model(row: &user::Model) -> user::Model {
706670 row.clone()
671}
672
673#[derive(Debug, Clone, Deserialize, IntoParams)]
674pub struct ListUserSessionsQuery {
675 pub kind: Option<String>,
676 pub include_revoked: Option<bool>,
677 pub limit: Option<u64>,
678 pub offset: Option<u64>,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
682pub struct AdminUserSessionRow {
683 pub id: Uuid,
684 pub kind: String,
685 pub device_id: Option<Uuid>,
686 pub user_agent_hash: Option<String>,
687 pub client_ip_hash: Option<String>,
688 pub app_id: Option<String>,
689 pub last_seen_at: OffsetDateTime,
690 pub created_at: OffsetDateTime,
691 pub expires_at: OffsetDateTime,
692 pub is_revoked: bool,
693 pub revoked_at: Option<OffsetDateTime>,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
697pub struct AdminUserSessionPage {
698 pub items: Vec<AdminUserSessionRow>,
699 pub total: i64,
700 pub limit: u64,
701 pub offset: u64,
702}
703
704#[derive(Debug, Clone, Deserialize, ToSchema)]
705pub struct CreateUserCmd {
706 pub username: String,
707 pub email: String,
708 pub password: String,
709 #[serde(default)]
710 pub display_name: Option<String>,
711 #[serde(default)]
712 pub is_active: Option<bool>,
713 #[serde(default)]
714 pub email_activated: Option<bool>,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
718pub struct CreateUserResp {
719 pub uid: Uuid,
720 pub username: String,
721 pub email: String,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
725pub struct AdminUserSecurityDto {
726 pub totp_enabled: bool,
727 pub recovery_codes_total: i64,
728 pub recovery_codes_used: i64,
729 pub active_sessions: i64,
730 pub ssh_keys: i64,
731 pub access_keys: i64,
732 pub passwd_version: i64,
733 pub password_last_changed_at: Option<OffsetDateTime>,
734}
735
736#[derive(Debug, Clone, Deserialize, ToSchema)]
737pub struct BulkUsersCmd {
738 pub action: String,
739 pub ids: Vec<Uuid>,
740 #[serde(default)]
741 pub reason: Option<String>,
742 #[serde(default)]
743 pub suspension_type: Option<SuspensionType>,
744 #[serde(default)]
745 pub ends_at: Option<OffsetDateTime>,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
749pub struct BulkUserFailure {
750 pub uid: Uuid,
751 pub error: String,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
755pub struct BulkUsersResp {
756 pub action: String,
757 pub succeeded: Vec<Uuid>,
758 pub failed: Vec<BulkUserFailure>,
759}
760
761impl AdminService {
762 async fn users_select(
763 &self,
764 query: &ListUsersQuery,
765 ) -> Result<Select<user::Entity>, AdminError> {
766 let mut select = user::Entity::find();
767 if let Some(raw) = query
768 .q
769 .as_deref()
770 .map(str::trim)
771 .filter(|value| !value.is_empty())
772 {
773 let needle = raw.to_ascii_lowercase();
774 let mut cond = Condition::any()
775 .add(user::Column::LowerName.contains(needle.clone()))
776 .add(user::Column::Username.contains(needle.clone()))
777 .add(user::Column::DisplayName.contains(raw));
778 let email_uids: Vec<Uuid> = user_emails::Entity::find()
779 .filter(user_emails::Column::LowerEmail.contains(needle))
780 .limit(200)
781 .all(self.db().read())
782 .await?
783 .into_iter()
784 .map(|row| row.user_id)
785 .collect();
786 if !email_uids.is_empty() {
787 cond = cond.add(user::Column::Uid.is_in(email_uids));
788 }
789 select = select.filter(cond);
790 }
791 select = match query.status.as_deref() {
792 Some("active") => select.filter(user::Column::IsActive.eq(true)),
793 Some("inactive") => select.filter(user::Column::IsActive.eq(false)),
794 Some("restricted") => select.filter(user::Column::IsRestricted.eq(true)),
795 Some("prohibited") => select.filter(user::Column::ProhibitLogin.eq(true)),
796 _ => select,
797 };
798 select = match query.sort.as_deref() {
799 Some("created_at_asc") => select.order_by_asc(user::Column::CreatedAt),
800 Some("last_login") => select.order_by_desc(user::Column::LastLoginAt),
801 Some("username") => select.order_by_asc(user::Column::LowerName),
802 _ => select.order_by_desc(user::Column::CreatedAt),
803 };
804 Ok(select)
805 }
806
807 pub async fn export_users(
808 &self,
809 query: ListUsersQuery,
810 ) -> Result<Vec<AdminUserSummary>, AdminError> {
811 let rows = self
812 .users_select(&query)
813 .await?
814 .limit(5000)
815 .all(self.db().read())
816 .await?;
817 let ids: Vec<Uuid> = rows.iter().map(|row| row.uid).collect();
818 let suspended = self.active_suspension_ids(&ids).await?;
819 Ok(rows
820 .iter()
821 .map(|row| user_summary(row, suspended.contains(&row.uid)))
822 .collect())
823 }
824
825 pub async fn list_user_sessions(
826 &self,
827 uid: Uuid,
828 query: ListUserSessionsQuery,
829 ) -> Result<AdminUserSessionPage, AdminError> {
830 user::Entity::find_by_id(uid)
831 .one(self.db().read())
832 .await?
833 .ok_or(AdminError::NotFound)?;
834 let limit = query.limit.unwrap_or(50).clamp(1, 200);
835 let offset = query.offset.unwrap_or(0);
836 let mut select = user_sessions::Entity::find().filter(user_sessions::Column::UserId.eq(uid));
837 if let Some(raw) = query
838 .kind
839 .as_deref()
840 .map(str::trim)
841 .filter(|value| !value.is_empty())
842 {
843 let kind = match raw {
844 "web_login" => SessionKind::WebLogin,
845 "bearer_access" => SessionKind::BearerAccess,
846 other => {
847 return Err(AdminError::BadRequest(format!(
848 "unknown session kind: {other}"
849 )));
850 }
851 };
852 select = select.filter(user_sessions::Column::Kind.eq(kind));
853 }
854 if query.include_revoked != Some(true) {
855 select = select.filter(user_sessions::Column::IsRevoked.eq(false));
856 }
857 select = select.order_by_desc(user_sessions::Column::CreatedAt);
858 let total = select.clone().count(self.db().read()).await? as i64;
859 let items = select
860 .limit(limit)
861 .offset(offset)
862 .all(self.db().read())
863 .await?
864 .into_iter()
865 .map(|row| AdminUserSessionRow {
866 id: row.id,
867 kind: format!("{:?}", row.kind).to_ascii_lowercase(),
868 device_id: row.device_id,
869 user_agent_hash: row.user_agent_hash,
870 client_ip_hash: row.client_ip_hash,
871 app_id: row.app_id,
872 last_seen_at: row.last_seen_at,
873 created_at: row.created_at,
874 expires_at: row.expires_at,
875 is_revoked: row.is_revoked,
876 revoked_at: row.revoked_at,
877 })
878 .collect();
879 Ok(AdminUserSessionPage {
880 items,
881 total,
882 limit,
883 offset,
884 })
885 }
886
887 pub async fn revoke_user_session(
888 &self,
889 actor: &AdminActor,
890 uid: Uuid,
891 session_id: Uuid,
892 ctx: &AuditCtx,
893 ) -> Result<(), AdminError> {
894 actor.require(AdminRole::Operator)?;
895 let row = user_sessions::Entity::find_by_id(session_id)
896 .one(self.db().read())
897 .await?
898 .filter(|row| row.user_id == uid)
899 .ok_or(AdminError::NotFound)?;
900 let now = OffsetDateTime::now_utc();
901 if !row.is_revoked {
902 let mut am: user_sessions::ActiveModel = row.into();
903 am.is_revoked = Set(true);
904 am.revoked_at = Set(Some(now));
905 am.updated_at = Set(now);
906 am.update(self.db().write()).await?;
907 }
908 self.audit(
909 Some(actor),
910 AuditRecord {
911 action: "user.session_revoked",
912 target: Some(("user", uid.to_string())),
913 detail: json!({ "session_id": session_id }),
914 },
915 ctx,
916 )
917 .await?;
918 Ok(())
919 }
920
921 pub async fn user_security(&self, uid: Uuid) -> Result<AdminUserSecurityDto, AdminError> {
922 let row = user::Entity::find_by_id(uid)
923 .one(self.db().read())
924 .await?
925 .ok_or(AdminError::NotFound)?;
926 let codes = user_recovery_codes::Entity::find()
927 .filter(user_recovery_codes::Column::UserId.eq(uid))
928 .all(self.db().read())
929 .await?;
930 let active_sessions = user_sessions::Entity::find()
931 .filter(user_sessions::Column::UserId.eq(uid))
932 .filter(user_sessions::Column::IsRevoked.eq(false))
933 .count(self.db().read())
934 .await? as i64;
935 let ssh_keys = user_ssh_keys::Entity::find()
936 .filter(user_ssh_keys::Column::UserId.eq(uid))
937 .count(self.db().read())
938 .await? as i64;
939 let access_keys = user_access_keys::Entity::find()
940 .filter(user_access_keys::Column::UserId.eq(uid))
941 .count(self.db().read())
942 .await? as i64;
943 Ok(AdminUserSecurityDto {
944 totp_enabled: row.totp_enabled,
945 recovery_codes_total: codes.len() as i64,
946 recovery_codes_used: codes.iter().filter(|code| code.used_at.is_some()).count() as i64,
947 active_sessions,
948 ssh_keys,
949 access_keys,
950 passwd_version: row.passwd_version.unwrap_or(1),
951 password_last_changed_at: row.password_last_changed_at,
952 })
953 }
954
955 pub async fn clear_user_two_factor(
956 &self,
957 actor: &AdminActor,
958 uid: Uuid,
959 ctx: &AuditCtx,
960 ) -> Result<(), AdminError> {
961 actor.require(AdminRole::SuperAdmin)?;
962 let row = user::Entity::find_by_id(uid)
963 .one(self.db().write())
964 .await?
965 .ok_or(AdminError::NotFound)?;
966 let now = OffsetDateTime::now_utc();
967 let mut am: user::ActiveModel = row.into();
968 am.totp_enabled = Set(false);
969 am.totp_encrypted_secret = Set(None);
970 am.totp_last_used_at = Set(None);
971 am.totp_last_counter = Set(None);
972 am.updated_at = Set(now);
973 am.update(self.db().write()).await?;
974 user_recovery_codes::Entity::delete_many()
975 .filter(user_recovery_codes::Column::UserId.eq(uid))
976 .exec(self.db().write())
977 .await?;
978 self.audit(
979 Some(actor),
980 AuditRecord {
981 action: "user.two_factor_cleared",
982 target: Some(("user", uid.to_string())),
983 detail: json!({}),
984 },
985 ctx,
986 )
987 .await?;
988 Ok(())
989 }
990
991 pub async fn create_user(
992 &self,
993 actor: &AdminActor,
994 cmd: CreateUserCmd,
995 ctx: &AuditCtx,
996 ) -> Result<CreateUserResp, AdminError> {
997 actor.require(AdminRole::SuperAdmin)?;
998 let username = validate_username(cmd.username.trim())?;
999 let email = validate_email(&cmd.email)?;
1000 validate_password(&cmd.password, &self.config().auth.password_policy)?;
1001 let lower_name = username.to_lowercase();
1002 let lower_email = email.to_lowercase();
1003 let display_name = cmd
1004 .display_name
1005 .map(|value| value.trim().to_string())
1006 .filter(|value| !value.is_empty())
1007 .unwrap_or_else(|| username.clone());
1008 let is_active = cmd.is_active.unwrap_or(true);
1009 let email_activated = cmd.email_activated.unwrap_or(true);
1010
1011 let db = self.db();
1012 let existing = user::Entity::find()
1013 .filter(user::Column::LowerName.eq(&lower_name))
1014 .one(db.read())
1015 .await?;
1016 let email_exists = user_emails::Entity::find()
1017 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
1018 .one(db.read())
1019 .await?;
1020 if existing.is_some() || email_exists.is_some() {
1021 return Err(AdminError::Conflict(
1022 "username or email already exists".to_string(),
1023 ));
1024 }
1025
1026 let password_hash = hash_password(&cmd.password)?;
1027 let uid = Uuid::now_v7();
1028 let now = OffsetDateTime::now_utc();
1029 let username_for_tx = username.clone();
1030 let lower_name_for_tx = lower_name.clone();
1031 let display_name_for_tx = display_name.clone();
1032 let email_for_tx = email.clone();
1033 let lower_email_for_tx = lower_email.clone();
1034 TransactionTrait::transaction(db.primary(), |txn| {
1035 Box::pin(async move {
1036 user::ActiveModel {
1037 uid: Set(uid),
1038 username: Set(username_for_tx),
1039 lower_name: Set(lower_name_for_tx),
1040 display_name: Set(display_name_for_tx),
1041 avatar_url: Set(String::new()),
1042 is_active: Set(is_active),
1043 is_restricted: Set(false),
1044 prohibit_login: Set(false),
1045 visibility: Set(ProfileVisibility::Public),
1046 bot: Set(false),
1047 system: Set(false),
1048 last_login_at: Set(None),
1049 last_login_ip_hash: Set(None),
1050 version: Set(1),
1051 passwd_hash: Set(Some(password_hash)),
1052 password_last_changed_at: Set(Some(now)),
1053 passwd_version: Set(Some(1)),
1054 totp_encrypted_secret: Set(None),
1055 totp_enabled: Set(false),
1056 totp_last_used_at: Set(None),
1057 totp_last_counter: Set(None),
1058 timezone: Set(None),
1059 locale: Set(None),
1060 language: Set(None),
1061 created_at: Set(now),
1062 updated_at: Set(now),
1063 }
1064 .insert(txn)
1065 .await?;
1066 user_profiles::ActiveModel {
1067 user_id: Set(uid),
1068 real_name: Set(None),
1069 bio: Set(None),
1070 pronouns: Set(None),
1071 website: Set(None),
1072 location: Set(None),
1073 company: Set(None),
1074 pronouns_privacy: Set(None),
1075 date_of_birth: Set(None),
1076 banner_url: Set(None),
1077 banner_hash: Set(None),
1078 banner_color: Set(None),
1079 accent_color: Set(None),
1080 timezone_privacy_flags: Set(None),
1081 custom_status_emoji: Set(None),
1082 custom_status_text: Set(None),
1083 custom_status_expires_at: Set(None),
1084 show_email: Set(false),
1085 show_location: Set(false),
1086 show_real_name: Set(false),
1087 show_activity: Set(false),
1088 privacy_agreed_at: Set(None),
1089 created_at: Set(now),
1090 updated_at: Set(now),
1091 }
1092 .insert(txn)
1093 .await?;
1094 user_emails::ActiveModel {
1095 id: Set(Uuid::now_v7()),
1096 user_id: Set(uid),
1097 email: Set(email_for_tx),
1098 lower_email: Set(lower_email_for_tx),
1099 is_activated: Set(email_activated),
1100 is_primary: Set(true),
1101 created_at: Set(now),
1102 updated_at: Set(now),
1103 activated_at: Set(if email_activated { Some(now) } else { None }),
1104 deleted_at: Set(None),
1105 }
1106 .insert(txn)
1107 .await?;
1108 Ok::<(), DbErr>(())
1109 })
1110 })
1111 .await?;
1112
1113 self.audit(
1114 Some(actor),
1115 AuditRecord {
1116 action: "user.created",
1117 target: Some(("user", uid.to_string())),
1118 detail: json!({
1119 "username": username,
1120 "email": email,
1121 "is_active": is_active,
1122 "email_activated": email_activated,
1123 }),
1124 },
1125 ctx,
1126 )
1127 .await?;
1128 Ok(CreateUserResp {
1129 uid,
1130 username,
1131 email,
1132 })
1133 }
1134
1135 pub async fn bulk_users(
1136 &self,
1137 actor: &AdminActor,
1138 cmd: BulkUsersCmd,
1139 ctx: &AuditCtx,
1140 ) -> Result<BulkUsersResp, AdminError> {
1141 actor.require(AdminRole::Operator)?;
1142 let action = cmd.action.trim().to_ascii_lowercase();
1143 match action.as_str() {
1144 "activate" | "deactivate" | "restrict" | "unrestrict" | "revoke_sessions" | "suspend"
1145 | "delete" => {}
1146 other => {
1147 return Err(AdminError::BadRequest(format!(
1148 "unsupported bulk action: {other}"
1149 )));
1150 }
1151 }
1152 if action == "delete" {
1153 actor.require(AdminRole::SuperAdmin)?;
1154 }
1155 let mut seen: HashSet<Uuid> = HashSet::new();
1156 let ids: Vec<Uuid> = cmd
1157 .ids
1158 .into_iter()
1159 .filter(|uid| seen.insert(*uid))
1160 .collect();
1161 if ids.is_empty() {
1162 return Err(AdminError::BadRequest("ids must not be empty".to_string()));
1163 }
1164 if ids.len() > 100 {
1165 return Err(AdminError::BadRequest(
1166 "at most 100 ids per request".to_string(),
1167 ));
1168 }
1169 let mut succeeded: Vec<Uuid> = Vec::new();
1170 let mut failed: Vec<BulkUserFailure> = Vec::new();
1171 for uid in ids {
1172 let outcome = match action.as_str() {
1173 "activate" => self
1174 .update_user(
1175 actor,
1176 uid,
1177 UpdateUserCmd {
1178 display_name: None,
1179 is_active: Some(true),
1180 is_restricted: None,
1181 prohibit_login: None,
1182 },
1183 ctx,
1184 )
1185 .await
1186 .map(|_| ()),
1187 "deactivate" => self
1188 .update_user(
1189 actor,
1190 uid,
1191 UpdateUserCmd {
1192 display_name: None,
1193 is_active: Some(false),
1194 is_restricted: None,
1195 prohibit_login: None,
1196 },
1197 ctx,
1198 )
1199 .await
1200 .map(|_| ()),
1201 "restrict" => self
1202 .update_user(
1203 actor,
1204 uid,
1205 UpdateUserCmd {
1206 display_name: None,
1207 is_active: None,
1208 is_restricted: Some(true),
1209 prohibit_login: None,
1210 },
1211 ctx,
1212 )
1213 .await
1214 .map(|_| ()),
1215 "unrestrict" => self
1216 .update_user(
1217 actor,
1218 uid,
1219 UpdateUserCmd {
1220 display_name: None,
1221 is_active: None,
1222 is_restricted: Some(false),
1223 prohibit_login: None,
1224 },
1225 ctx,
1226 )
1227 .await
1228 .map(|_| ()),
1229 "revoke_sessions" => self
1230 .admin_revoke_user_sessions(actor, uid, ctx)
1231 .await
1232 .map(|_| ()),
1233 "suspend" => {
1234 let suspension_type = cmd.suspension_type.unwrap_or(SuspensionType::Permanent);
1235 self.suspend_user(
1236 actor,
1237 uid,
1238 SuspendUserCmd {
1239 suspension_type,
1240 reason: cmd.reason.clone(),
1241 ends_at: cmd.ends_at,
1242 },
1243 ctx,
1244 )
1245 .await
1246 .map(|_| ())
1247 }
1248 _ => self.admin_delete_user(actor, uid, ctx).await.map(|_| ()),
1249 };
1250 match outcome {
1251 Ok(()) => succeeded.push(uid),
1252 Err(AdminError::NotFound) => failed.push(BulkUserFailure {
1253 uid,
1254 error: "not found".to_string(),
1255 }),
1256 Err(error) => failed.push(BulkUserFailure {
1257 uid,
1258 error: error.to_string(),
1259 }),
1260 }
1261 }
1262 self.audit(
1263 Some(actor),
1264 AuditRecord {
1265 action: "user.bulk_action",
1266 target: None,
1267 detail: json!({
1268 "action": action,
1269 "succeeded": succeeded.len(),
1270 "failed": failed.len(),
1271 }),
1272 },
1273 ctx,
1274 )
1275 .await?;
1276 Ok(BulkUsersResp {
1277 action,
1278 succeeded,
1279 failed,
1280 })
1281 }
7071282}
No newline at end of file
Modified lib/admin/src/openapi.rs View lib/admin/src/openapi.rs
@@ -18,6 +18,7 @@
1818 crate::handlers::admins::update_admin,
1919 crate::handlers::admins::delete_admin,
2020 crate::handlers::admins::reset_admin_password,
21 crate::handlers::admins::revoke_admin_sessions,
2122 crate::handlers::users::list_users,
2223 crate::handlers::users::get_user,
2324 crate::handlers::users::update_user,
@@ -26,6 +27,27 @@
2627 crate::handlers::users::reset_user_password,
2728 crate::handlers::users::revoke_user_sessions,
2829 crate::handlers::users::delete_user,
30 crate::handlers::users::create_user,
31 crate::handlers::users::bulk_users,
32 crate::handlers::users::export_users,
33 crate::handlers::users::list_user_sessions,
34 crate::handlers::users::revoke_user_session,
35 crate::handlers::users::get_user_security,
36 crate::handlers::users::clear_user_two_factor,
37 crate::handlers::chat::stats,
38 crate::handlers::chat::list_rooms,
39 crate::handlers::chat::get_room,
40 crate::handlers::chat::update_room,
41 crate::handlers::chat::delete_room,
42 crate::handlers::chat::list_members,
43 crate::handlers::chat::remove_member,
44 crate::handlers::chat::purge_room,
45 crate::handlers::chat::list_messages,
46 crate::handlers::chat::export_messages,
47 crate::handlers::chat::get_message,
48 crate::handlers::chat::bulk_delete_messages,
49 crate::handlers::chat::list_attachments,
50 crate::handlers::chat::delete_attachment,
2951 crate::handlers::spaces::list_spaces,
3052 crate::handlers::spaces::get_space,
3153 crate::handlers::spaces::update_space,
@@ -33,6 +55,7 @@
3355 crate::handlers::spaces::delete_space,
3456 crate::handlers::spaces::list_space_members,
3557 crate::handlers::spaces::remove_space_member,
58 crate::handlers::spaces::add_space_member,
3659 crate::handlers::storage::overview,
3760 crate::handlers::storage::list_objects,
3861 crate::handlers::storage::download_url,
@@ -40,14 +63,20 @@
4063 crate::handlers::queues::list_queues,
4164 crate::handlers::queues::requeue_failed,
4265 crate::handlers::queues::purge,
66 crate::handlers::queues::failed_jobs,
4367 crate::handlers::tasks::list_tasks,
4468 crate::handlers::tasks::get_task,
4569 crate::handlers::tasks::retry_task,
4670 crate::handlers::tasks::cancel_task,
4771 crate::handlers::tasks::delete_task,
72 crate::handlers::tasks::task_stats,
73 crate::handlers::tasks::bulk_tasks,
4874 crate::handlers::logs::list_audit_logs,
4975 crate::handlers::logs::list_job_logs,
50 crate::handlers::system::status
76 crate::handlers::logs::export_audit_logs,
77 crate::handlers::system::status,
78 crate::handlers::system::config,
79 crate::handlers::system::dashboard
5180 ),
5281 components(schemas(
5382 export::rest::NoData,
@@ -72,12 +101,41 @@
72101 crate::services::users::ResetUserPasswordCmd,
73102 crate::services::users::RevokeSessionsResp,
74103 crate::services::users::DeleteUserResp,
104 crate::services::users::CreateUserCmd,
105 crate::services::users::CreateUserResp,
106 crate::services::users::BulkUsersCmd,
107 crate::services::users::BulkUsersResp,
108 crate::services::users::BulkUserFailure,
109 crate::services::users::AdminUserSessionRow,
110 crate::services::users::AdminUserSessionPage,
111 crate::services::users::AdminUserSecurityDto,
112 crate::services::chat::AdminChatKindCount,
113 crate::services::chat::AdminChatStatsDto,
114 crate::services::chat::AdminChatRoomSummary,
115 crate::services::chat::AdminChatRoomDetail,
116 crate::services::chat::UpdateChatRoomCmd,
117 crate::services::chat::AdminChatRoomMemberDto,
118 crate::services::chat::AdminChatMessageDto,
119 crate::services::chat::AdminChatMessageEditDto,
120 crate::services::chat::AdminChatReactionGroupDto,
121 crate::services::chat::AdminChatAttachmentDto,
122 crate::services::chat::AdminChatThreadDto,
123 crate::services::chat::AdminChatMessageDetail,
124 crate::services::chat::BulkDeleteChatMessagesCmd,
125 crate::services::chat::BulkChatMessageFailure,
126 crate::services::chat::BulkDeleteChatMessagesResp,
127 crate::services::chat::PurgeChatMessagesResp,
128 crate::services::chat::DeleteChatRoomResp,
129 crate::services::chat::DeleteChatAttachmentResp,
130 crate::services::chat::RemoveChatMemberResp,
75131 crate::services::spaces::AdminSpaceSummary,
76132 crate::services::spaces::AdminSpaceDetail,
77133 crate::services::spaces::UpdateSpaceCmd,
78134 crate::services::spaces::TransferSpaceCmd,
79135 crate::services::spaces::AdminSpaceMemberDto,
80136 crate::services::spaces::RemoveMemberResp,
137 crate::services::spaces::AddSpaceMemberCmd,
138 crate::services::spaces::AddSpaceMemberResp,
81139 crate::services::storage::StorageCategoryDto,
82140 crate::services::storage::StorageOverviewDto,
83141 crate::services::storage::StorageObjectDto,
@@ -90,6 +148,11 @@
90148 crate::services::tasks::TaskPageDto,
91149 crate::services::tasks::TaskDetailDto,
92150 crate::services::tasks::TaskActionResp,
151 crate::services::tasks::TaskSourceStatDto,
152 crate::services::tasks::TaskStatsDto,
153 crate::services::tasks::BulkTaskCmd,
154 crate::services::tasks::BulkTaskResp,
155 crate::services::tasks::BulkTaskFailure,
93156 crate::services::logs::AuditLogDto,
94157 crate::services::logs::AuditLogPageDto,
95158 crate::services::logs::JobLogDto,
@@ -99,7 +162,20 @@
99162 crate::services::system::DependenciesDto,
100163 crate::services::system::FeaturesDto,
101164 crate::services::system::CountsDto,
102 crate::services::system::SystemStatusDto
165 crate::services::system::SystemStatusDto,
166 crate::services::system::HostEntryDto,
167 crate::services::system::AdminEndpointDto,
168 crate::services::system::SearchConfigDto,
169 crate::services::system::ImportConfigDto,
170 crate::services::system::GitConfigDto,
171 crate::services::system::StorageConfigDto,
172 crate::services::system::DatabaseConfigDto,
173 crate::services::system::RedisConfigDto,
174 crate::services::system::SessionConfigDto,
175 crate::services::system::EmailConfigDto,
176 crate::services::system::SystemConfigDto,
177 crate::services::system::TrendPointDto,
178 crate::services::system::DashboardDto
103179 ))
104180)]
105181pub struct AdminApiDoc;
Modified lib/admin/src/routes.rs View lib/admin/src/routes.rs
@@ -1,7 +1,7 @@
11use axum::Router;
22use axum::routing::{delete, get, patch, post};
33
4use crate::handlers::{admins, auth, logs, queues, spaces, storage, system, tasks, users};
4use crate::handlers::{admins, auth, chat, logs, queues, spaces, storage, system, tasks, users};
55use crate::services::service::AdminService;
66use crate::share::guard::admin_guard;
77
@@ -27,8 +27,17 @@
2727 "/admin/admins/{id}/reset-password",
2828 post(admins::reset_admin_password),
2929 )
30 .route("/admin/users", get(users::list_users))
3130 .route(
31 "/admin/admins/{id}/revoke-sessions",
32 post(admins::revoke_admin_sessions),
33 )
34 .route(
35 "/admin/users",
36 get(users::list_users).post(users::create_user),
37 )
38 .route("/admin/users/export", get(users::export_users))
39 .route("/admin/users/bulk", post(users::bulk_users))
40 .route(
3241 "/admin/users/{uid}",
3342 get(users::get_user)
3443 .patch(users::update_user)
@@ -47,6 +56,51 @@
4756 "/admin/users/{uid}/revoke-sessions",
4857 post(users::revoke_user_sessions),
4958 )
59 .route(
60 "/admin/users/{uid}/sessions",
61 get(users::list_user_sessions),
62 )
63 .route(
64 "/admin/users/{uid}/sessions/{session_id}",
65 delete(users::revoke_user_session),
66 )
67 .route(
68 "/admin/users/{uid}/security",
69 get(users::get_user_security),
70 )
71 .route(
72 "/admin/users/{uid}/two-factor",
73 delete(users::clear_user_two_factor),
74 )
75 .route("/admin/chat/stats", get(chat::stats))
76 .route("/admin/chat/rooms", get(chat::list_rooms))
77 .route(
78 "/admin/chat/rooms/{id}",
79 get(chat::get_room)
80 .patch(chat::update_room)
81 .delete(chat::delete_room),
82 )
83 .route(
84 "/admin/chat/rooms/{id}/members",
85 get(chat::list_members),
86 )
87 .route(
88 "/admin/chat/rooms/{id}/members/{uid}",
89 delete(chat::remove_member),
90 )
91 .route("/admin/chat/rooms/{id}/purge", post(chat::purge_room))
92 .route("/admin/chat/messages", get(chat::list_messages))
93 .route("/admin/chat/messages/export", get(chat::export_messages))
94 .route(
95 "/admin/chat/messages/bulk-delete",
96 post(chat::bulk_delete_messages),
97 )
98 .route("/admin/chat/messages/{id}", get(chat::get_message))
99 .route("/admin/chat/attachments", get(chat::list_attachments))
100 .route(
101 "/admin/chat/attachments/{id}",
102 delete(chat::delete_attachment),
103 )
50104 .route("/admin/spaces", get(spaces::list_spaces))
51105 .route(
52106 "/admin/spaces/{id}",
@@ -57,7 +111,7 @@
57111 .route("/admin/spaces/{id}/transfer", post(spaces::transfer_space))
58112 .route(
59113 "/admin/spaces/{id}/members",
60 get(spaces::list_space_members),
114 get(spaces::list_space_members).post(spaces::add_space_member),
61115 )
62116 .route(
63117 "/admin/spaces/{id}/members/{uid}",
@@ -70,24 +124,30 @@
70124 )
71125 .route("/admin/storage/download-url", get(storage::download_url))
72126 .route("/admin/queues", get(queues::list_queues))
127 .route("/admin/queues/{name}/failed", get(queues::failed_jobs))
73128 .route(
74129 "/admin/queues/{name}/requeue-failed",
75130 post(queues::requeue_failed),
76131 )
77132 .route("/admin/queues/{name}/purge", post(queues::purge))
78133 .route("/admin/tasks", get(tasks::list_tasks))
134 .route("/admin/tasks/stats", get(tasks::task_stats))
79135 .route(
80136 "/admin/tasks/{source}/{id}",
81137 get(tasks::get_task).delete(tasks::delete_task),
82138 )
139 .route("/admin/tasks/{source}/bulk", post(tasks::bulk_tasks))
83140 .route("/admin/tasks/{source}/{id}/retry", post(tasks::retry_task))
84141 .route(
85142 "/admin/tasks/{source}/{id}/cancel",
86143 post(tasks::cancel_task),
87144 )
88145 .route("/admin/logs/audit", get(logs::list_audit_logs))
146 .route("/admin/logs/audit/export", get(logs::export_audit_logs))
89147 .route("/admin/logs/jobs", get(logs::list_job_logs))
148 .route("/admin/dashboard", get(system::dashboard))
90149 .route("/admin/system/status", get(system::status))
150 .route("/admin/system/config", get(system::config))
91151 .route_layer(axum::middleware::from_fn_with_state(
92152 service.clone(),
93153 admin_guard,