import type { ApiToken, ApiTokenCreateResult, BatchResult, Endpoint, EndpointCreateRequest, EndpointCreateResult, EndpointListQuery, EndpointPatchRequest, GroupDetail, GroupFailed, GroupSummary, ImportItem, MeInfo, MessageDetail, MessageSummary, Overview, PageResult, RegistrationSettings, RegistrationUpdateRequest, RuntimeSettings, } from "./types"; import { ApiError } from "./http"; const SESSION_KEY = "nixmsg_admin_mock_session"; const now = () => Date.now(); function randomId(prefix: string): string { const hex = Math.random().toString(16).slice(2, 10); return `${prefix}_${hex}`; } function randomPassword(len = 16): string { const chars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let out = ""; for (let i = 0; i < len; i++) { out += chars[Math.floor(Math.random() * chars.length)]; } return out; } function clone(v: T): T { return JSON.parse(JSON.stringify(v)) as T; } const endpoints: Endpoint[] = [ { id: "device-1", name: "gate", remark: "lobby tablet", source: "admin", enabled: true, online: true, online_since_ms: now() - 3_600_000, offline_since_ms: null, talk_password_set: false, default_delay_ms: 0, created_at_ms: now() - 86_400_000, login_locked: false, session_issued_at_ms: now() - 3_600_000, session_used_at_ms: now() - 60_000, }, { id: "device-2", name: "warehouse", remark: "", source: "self", enabled: true, online: false, online_since_ms: null, offline_since_ms: now() - 7200_000, talk_password_set: true, default_delay_ms: 5000, created_at_ms: now() - 172_800_000, login_locked: true, }, { id: "ops-bot", name: "ops-bot", remark: "admin-provisioned", source: "admin", enabled: false, online: false, online_since_ms: null, offline_since_ms: now() - 10_000_000, talk_password_set: false, default_delay_ms: 0, created_at_ms: now() - 259_200_000, login_locked: false, }, ]; const registration: RegistrationSettings = { enabled: false, code: "demo-reg-code-01", updated_at_ms: now() - 86_400_000, }; const tokens: ApiToken[] = [ { id: "1", name: "ops", enabled: true, created_at_ms: now() - 86_400_000, last_used_at_ms: now() - 3_600_000, }, ]; let nextTokenId = 2; interface GroupRow { id: string; name: string; owner_id: string; created_at_ms: number; members: { id: string; joined_at_ms: number }[]; } const groups: GroupRow[] = [ { id: "g_demo0001", name: "team-a", owner_id: "device-1", created_at_ms: now() - 50_000_000, members: [ { id: "device-1", joined_at_ms: now() - 50_000_000 }, { id: "device-2", joined_at_ms: now() - 40_000_000 }, { id: "ops-bot", joined_at_ms: now() - 30_000_000 }, ], }, ]; const messages: MessageDetail[] = [ { seq: 1, id: "018f000000000000000000000001", sender_id: "device-1", dest_kind: "endpoint", dest_id: "device-2", state: "completed", reason: "", send_at_ms: now() - 3_600_000, created_at_ms: now() - 3_601_000, meta: {}, content_type: "text/plain", deliveries: [ { endpoint_id: "device-2", state: "accepted", reason: "", attempts: 1, pushed_at_ms: now() - 3_599_000, updated_at_ms: now() - 3_598_000, }, { endpoint_id: "ops-bot", state: "rejected", reason: "disabled", attempts: 1, pushed_at_ms: now() - 3_599_000, updated_at_ms: now() - 3_598_000, }, ], next_cursor: "", }, { seq: 2, id: "018f000000000000000000000002", sender_id: "ops-bot", dest_kind: "group", dest_id: "g_demo0001", state: "dispatched", reason: "", send_at_ms: now() - 600_000, created_at_ms: now() - 601_000, meta: {}, content_type: "application/json", deliveries: [ { endpoint_id: "device-1", state: "accepted", reason: "", attempts: 1, pushed_at_ms: now() - 599_000, updated_at_ms: now() - 598_000, }, { endpoint_id: "device-2", state: "pending", reason: "", attempts: 2, pushed_at_ms: now() - 590_000, updated_at_ms: now() - 580_000, }, ], next_cursor: "", }, { seq: 3, id: "018f000000000000000000000003", sender_id: "device-2", dest_kind: "endpoint", dest_id: "device-1", state: "scheduled", reason: "", send_at_ms: now() + 3_600_000, created_at_ms: now() - 100_000, meta: {}, content_type: "text/plain", deliveries: [], next_cursor: "", }, ]; const settings: RuntimeSettings = { listen: ":7443", admin_listen: "", session_idle_days: 30, record_retention_days: 7, idempotency_hours: 24, receipt_retention_days: 7, sqlite_synchronous: "FULL", limits: { max_body_bytes: 262144, max_meta_bytes: 4096, max_frame_bytes: 786432, max_ttl_seconds: 2592000, max_schedule_seconds: 31536000, max_group_members: 1000, grace_seconds: 60, ack_timeout_seconds: 300, delivery_window: 32, receipt_window: 64, requests_per_second: 50, max_pending_per_sender: 10000, max_pending_per_receiver: 10000, }, }; const startedAt = now(); function requireSession(): void { if (sessionStorage.getItem(SESSION_KEY) !== "1") { throw new ApiError("unauthorized", "未登录", 401); } } function paginate(items: T[], cursor?: string, limit = 50): PageResult { const offset = cursor ? Number.parseInt(cursor, 10) || 0 : 0; const size = Math.min(Math.max(limit, 1), 200); const slice = items.slice(offset, offset + size); const next = offset + size < items.length ? String(offset + size) : ""; return { items: clone(slice), next_cursor: next, total: items.length }; } function endpointName(id: string): string { return endpoints.find((e) => e.id === id)?.name ?? ""; } function toGroupSummary(g: GroupRow): GroupSummary { return { id: g.id, name: g.name, owner_id: g.owner_id, member_count: g.members.length, created_at_ms: g.created_at_ms, }; } function toMessageSummary(m: MessageDetail): MessageSummary { const counts = { pending: 0, accepted: 0, recalled: 0, expired: 0, dropped: 0, rejected: 0 }; for (const d of m.deliveries) { const key = d.state as keyof typeof counts; if (key in counts) { counts[key] += 1; } } return { seq: m.seq, id: m.id, sender_id: m.sender_id, dest_kind: m.dest_kind, dest_id: m.dest_id, state: m.state as MessageSummary["state"], reason: m.reason, send_at_ms: m.send_at_ms, created_at_ms: m.created_at_ms, keep: false, receipt: true, content_type: m.content_type, delivery_counts: counts, }; } export const mockApi = { async login(username: string, password: string): Promise<{ username: string }> { if (username !== "admin" || password !== "adminpassword") { throw new ApiError("unauthorized", "用户名或密码错误", 401); } sessionStorage.setItem(SESSION_KEY, "1"); return { username: "admin" }; }, async logout(): Promise> { sessionStorage.removeItem(SESSION_KEY); return {}; }, async me(): Promise { requireSession(); return { username: "admin", auth: "cookie" }; }, async changePassword(oldPassword: string, newPassword: string): Promise> { requireSession(); if (oldPassword !== "adminpassword") { throw new ApiError("unauthorized", "原密码错误", 401); } if (newPassword.length < 12) { throw new ApiError("bad_request", "新密码至少 12 位", 400); } return {}; }, async overview(): Promise { requireSession(); return { version: "0.1.0-mock", endpoints_total: endpoints.length, endpoints_online: endpoints.filter((e) => e.online).length, endpoints_disabled: endpoints.filter((e) => !e.enabled).length, endpoints_self: endpoints.filter((e) => e.source === "self").length, groups_total: groups.length, messages_pending: messages.filter((m) => m.state === "dispatched").length, messages_scheduled: messages.filter((m) => m.state === "scheduled").length, uptime_ms: now() - startedAt, }; }, async listEndpoints(q: EndpointListQuery = {}): Promise> { requireSession(); let list = [...endpoints]; if (q.source) { list = list.filter((e) => e.source === q.source); } if (q.online === true || q.online === false) { list = list.filter((e) => e.online === q.online); } if (q.enabled === true || q.enabled === false) { list = list.filter((e) => e.enabled === q.enabled); } if (q.query) { const needle = q.query.toLowerCase(); list = list.filter( (e) => e.id.toLowerCase().includes(needle) || e.name.toLowerCase().includes(needle), ); } return paginate(list, q.cursor, q.limit); }, async createEndpoint(body: EndpointCreateRequest): Promise { requireSession(); const id = (body.id || "").trim() || randomId("e"); if (endpoints.some((e) => e.id === id)) { throw new ApiError("id_taken", "编号已占用", 409); } const provided = (body.login_password || "").trim(); const loginPassword = provided || randomPassword(); endpoints.unshift({ id, name: body.name, remark: body.remark ?? "", source: "admin", enabled: true, online: false, online_since_ms: null, offline_since_ms: now(), talk_password_set: Boolean(body.talk_password), default_delay_ms: (body.default_delay_seconds ?? 0) * 1000, created_at_ms: now(), login_locked: false, }); return provided ? { id } : { id, login_password: loginPassword }; }, async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> { requireSession(); const lines = csvText.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim()); if (lines.length < 2) { throw new ApiError("bad_request", "CSV 校验失败", 400, { errors: [{ line: 1, reason: "表头不正确" }], }); } const header = lines[0].split(",").map((s) => s.trim()); const expected = ["id", "name", "login_password", "talk_password", "default_delay_seconds", "remark"]; if (expected.some((h, i) => header[i] !== h)) { throw new ApiError("bad_request", "CSV 校验失败", 400, { errors: [{ line: 1, reason: "?????" }], }); } const errors: { line: number; reason: string }[] = []; const rows: ImportItem[] = []; for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(","); const id = (cols[0] || "").trim() || randomId("e"); const name = (cols[1] || "").trim(); if (!name) { errors.push({ line: i + 1, reason: "名称不能为空" }); continue; } if (endpoints.some((e) => e.id === id) || rows.some((r) => r.id === id)) { errors.push({ line: i + 1, reason: "编号占用" }); continue; } rows.push({ id, name, login_password: (cols[2] || "").trim() || randomPassword(), }); } if (errors.length) { throw new ApiError("bad_request", "CSV 校验失败", 400, { errors }); } for (const r of rows) { endpoints.unshift({ id: r.id, name: r.name, remark: "", source: "admin", enabled: true, online: false, online_since_ms: null, offline_since_ms: now(), talk_password_set: false, default_delay_ms: 0, created_at_ms: now(), login_locked: false, }); } return { items: rows }; }, async batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise { requireSession(); const ok_ids: string[] = []; const failed: { id: string; code: string }[] = []; for (const id of ids) { const idx = endpoints.findIndex((e) => e.id === id); if (idx < 0) { failed.push({ id, code: "not_found" }); continue; } if (action === "delete") { endpoints.splice(idx, 1); } else if (action === "disable") { endpoints[idx].enabled = false; } else { endpoints[idx].enabled = true; } ok_ids.push(id); } return { ok_ids, failed }; }, async getEndpoint(id: string): Promise { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } return clone(e); }, async patchEndpoint(id: string, body: EndpointPatchRequest): Promise { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } if (body.name !== undefined) e.name = body.name; if (body.remark !== undefined) e.remark = body.remark; if (body.enabled !== undefined) e.enabled = body.enabled; if (body.default_delay_seconds !== undefined) { e.default_delay_ms = body.default_delay_seconds * 1000; } return clone(e); }, async deleteEndpoint(id: string): Promise> { requireSession(); const idx = endpoints.findIndex((e) => e.id === id); if (idx < 0) { throw new ApiError("not_found", "端不存在", 404); } endpoints.splice(idx, 1); return {}; }, async kickEndpoint(id: string): Promise<{ kicked: boolean }> { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } const kicked = e.online; if (kicked) { e.online = false; e.online_since_ms = null; e.offline_since_ms = now(); } return { kicked }; }, async resetLoginPassword(id: string, loginPassword?: string): Promise<{ login_password: string }> { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } return { login_password: (loginPassword || "").trim() || randomPassword() }; }, async setTalkPassword(id: string, talkPassword: string): Promise<{ talk_password_set: boolean }> { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } e.talk_password_set = talkPassword.length > 0; return { talk_password_set: e.talk_password_set }; }, async unlockEndpoint(id: string): Promise> { requireSession(); const e = endpoints.find((x) => x.id === id); if (!e) { throw new ApiError("not_found", "端不存在", 404); } e.login_locked = false; return {}; }, async getRegistration(): Promise { requireSession(); return clone(registration); }, async updateRegistration(body: RegistrationUpdateRequest): Promise { requireSession(); if (body.enabled !== undefined) registration.enabled = body.enabled; if (body.generate) { registration.code = randomPassword(16); } else if (body.code !== undefined) { if (body.code.length < 8 || body.code.length > 64) { throw new ApiError("bad_request", "安全码须为 8–64 字符", 400); } registration.code = body.code; } registration.updated_at_ms = now(); return clone(registration); }, async listTokens(): Promise> { requireSession(); return { items: clone(tokens), next_cursor: "", total: tokens.length }; }, async createToken(name: string): Promise { requireSession(); const item: ApiToken = { id: String(nextTokenId++), name, enabled: true, created_at_ms: now(), last_used_at_ms: null, }; tokens.unshift(item); return { id: item.id, name: item.name, token: `nxm_${randomPassword(32)}`, created_at_ms: item.created_at_ms, }; }, async patchToken(id: string, body: { name?: string; enabled?: boolean }): Promise { requireSession(); const t = tokens.find((x) => x.id === id); if (!t) { throw new ApiError("not_found", "令牌不存在", 404); } if (body.name !== undefined) t.name = body.name; if (body.enabled !== undefined) t.enabled = body.enabled; return clone(t); }, async deleteToken(id: string): Promise> { requireSession(); const idx = tokens.findIndex((x) => x.id === id); if (idx < 0) { throw new ApiError("not_found", "令牌不存在", 404); } tokens.splice(idx, 1); return {}; }, async listGroups(query?: string, cursor?: string, limit?: number): Promise> { requireSession(); let list = groups.map(toGroupSummary); if (query) { const n = query.toLowerCase(); list = list.filter((g) => g.name.toLowerCase().includes(n)); } return paginate(list, cursor, limit); }, async createGroup(body: { id?: string; name: string; owner_id: string; member_ids?: string[]; }): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> { requireSession(); const id = (body.id || "").trim() || randomId("g"); const failed: GroupFailed[] = []; const memberIds = new Set([body.owner_id, ...(body.member_ids ?? [])]); const members: GroupRow["members"] = []; for (const mid of memberIds) { if (!endpoints.some((e) => e.id === mid)) { failed.push({ id: mid, code: "not_found" }); continue; } members.push({ id: mid, joined_at_ms: now() }); } if (!members.some((m) => m.id === body.owner_id)) { throw new ApiError("bad_request", "群主必须存在", 400); } groups.unshift({ id, name: body.name, owner_id: body.owner_id, created_at_ms: now(), members, }); return { id, name: body.name, owner_id: body.owner_id, failed }; }, async getGroup(id: string, cursor?: string, limit?: number): Promise { requireSession(); const g = groups.find((x) => x.id === id); if (!g) { throw new ApiError("not_found", "群不存在", 404); } const all = g.members.map((m) => { const ep = endpoints.find((e) => e.id === m.id); return { id: m.id, name: ep?.name ?? endpointName(m.id), online: ep?.online ?? false, joined_at_ms: m.joined_at_ms, }; }); const page = paginate(all, cursor, limit); return { id: g.id, name: g.name, owner_id: g.owner_id, created_at_ms: g.created_at_ms, member_total: all.length, members: page.items, next_cursor: page.next_cursor, }; }, async renameGroup(id: string, name: string): Promise { requireSession(); const g = groups.find((x) => x.id === id); if (!g) { throw new ApiError("not_found", "群不存在", 404); } g.name = name; return toGroupSummary(g); }, async deleteGroup(id: string): Promise> { requireSession(); const idx = groups.findIndex((x) => x.id === id); if (idx < 0) { throw new ApiError("not_found", "群不存在", 404); } groups.splice(idx, 1); return {}; }, async addGroupMembers(id: string, memberIds: string[]): Promise<{ failed: GroupFailed[] }> { requireSession(); const g = groups.find((x) => x.id === id); if (!g) { throw new ApiError("not_found", "群不存在", 404); } const failed: GroupFailed[] = []; for (const mid of memberIds) { if (!endpoints.some((e) => e.id === mid)) { failed.push({ id: mid, code: "not_found" }); continue; } if (!g.members.some((m) => m.id === mid)) { g.members.push({ id: mid, joined_at_ms: now() }); } } return { failed }; }, async removeGroupMember(id: string, endpointId: string): Promise> { requireSession(); const g = groups.find((x) => x.id === id); if (!g) { throw new ApiError("not_found", "群不存在", 404); } g.members = g.members.filter((m) => m.id !== endpointId); return {}; }, async transferGroup(id: string, endpointId: string): Promise<{ owner_id: string }> { requireSession(); const g = groups.find((x) => x.id === id); if (!g) { throw new ApiError("not_found", "群不存在", 404); } if (!g.members.some((m) => m.id === endpointId)) { throw new ApiError("bad_request", "新群主须为成员", 400); } g.owner_id = endpointId; return { owner_id: endpointId }; }, async listMessages(q: { cursor?: string; limit?: number; sender_id?: string; endpoint_id?: string; group_id?: string; state?: string; from_ms?: number; to_ms?: number; }): Promise> { requireSession(); let list = messages.map(toMessageSummary); if (q.sender_id) list = list.filter((m) => m.sender_id === q.sender_id); if (q.group_id) list = list.filter((m) => m.dest_kind === "group" && m.dest_id === q.group_id); if (q.endpoint_id) { list = list.filter((m) => { const detail = messages.find((x) => x.seq === m.seq); return detail?.deliveries.some((d) => d.endpoint_id === q.endpoint_id); }); } if (q.state) list = list.filter((m) => m.state === q.state); if (q.from_ms != null) list = list.filter((m) => m.created_at_ms >= q.from_ms!); if (q.to_ms != null) list = list.filter((m) => m.created_at_ms <= q.to_ms!); return paginate(list, q.cursor, q.limit); }, async getMessage(seq: number, cursor?: string, limit?: number): Promise { requireSession(); const m = messages.find((x) => x.seq === seq); if (!m) { throw new ApiError("not_found", "消息不存在", 404); } const page = paginate(m.deliveries, cursor, limit ?? 200); return { ...clone(m), deliveries: page.items, next_cursor: page.next_cursor, }; }, async getSettings(): Promise { requireSession(); return clone(settings); }, _resetSession(): void { sessionStorage.removeItem(SESSION_KEY); }, _setSession(): void { sessionStorage.setItem(SESSION_KEY, "1"); }, };