diff --git a/admin/components/MembersPanel.vue b/admin/components/MembersPanel.vue index 91e7547..ef0cc7a 100644 --- a/admin/components/MembersPanel.vue +++ b/admin/components/MembersPanel.vue @@ -162,9 +162,25 @@ async function createInvite(): Promise { formError.value = ""; try { const url = await invitesApi.create(props.group.id, inviteRole.value); + const token = url.split("token=")[1] ?? ""; + if (token) { + invites.value = [ + { + token, + groupId: props.group.id, + role: inviteRole.value, + expiresAt: Date.now() + 7 * 86400_000, + createdBy: "", + }, + ...invites.value, + ]; + } + copyText(`${window.location.origin}${url}`); + copiedToken.value = token; + window.setTimeout(() => { + copiedToken.value = ""; + }, 2000); await loadInvites(); - copyText(url); - copiedToken.value = ""; } catch (err) { formError.value = err instanceof Error ? err.message : String(err); } finally { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c689fe1..25eb040 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -262,6 +262,7 @@ Filters accept either a single string or an array of strings: | `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds | | `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days | +| `invite:group:{id}` | Token index per group (keeps invite listing consistent) | Permanent | | `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds | | `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run`) | 7 days | | `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent | diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index fc46ede..7fff21d 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -262,6 +262,7 @@ owner(及超级管理员)可在分组的「成员」面板创建一次性邀 | `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 | | `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 | +| `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 | | `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 | | `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run`) | 7 天 | | `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 | diff --git a/src/web/invites.ts b/src/web/invites.ts index 8f638c2..45b1965 100644 --- a/src/web/invites.ts +++ b/src/web/invites.ts @@ -24,9 +24,40 @@ function inviteKey(token: string): string { return `invite:${token}`; } +/** + * Per-group index of invite tokens. Listing pending invites reads this key + * instead of `kv.list({ prefix })`, which is eventually consistent and can + * lag behind a fresh write by minutes — the index keeps listing reliable. + */ +function indexKey(groupId: string): string { + return `invite:group:${groupId}`; +} + +async function readIndex(kv: KVNamespace, groupId: string): Promise { + try { + const raw = await kv.get(indexKey(groupId), "json"); + return Array.isArray(raw) ? raw : []; + } catch (err) { + log.warn({ err, groupId }, "Failed to read invite index"); + return []; + } +} + +async function writeIndex(kv: KVNamespace, groupId: string, tokens: string[]): Promise { + await kv.put(indexKey(groupId), JSON.stringify(tokens)); +} + +async function removeFromIndex(kv: KVNamespace, groupId: string, token: string): Promise { + const tokens = (await readIndex(kv, groupId)).filter((t) => t !== token); + await writeIndex(kv, groupId, tokens); +} + export async function createInvite(kv: KVNamespace, invite: Invite): Promise { const token = generateInviteToken(); await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL }); + const tokens = await readIndex(kv, invite.groupId); + tokens.push(token); + await writeIndex(kv, invite.groupId, tokens); return token; } @@ -36,6 +67,7 @@ export async function getInvite(kv: KVNamespace, token: string): Promise raw.expiresAt) { await kv.delete(inviteKey(token)); + await removeFromIndex(kv, raw.groupId, token); return null; } return raw; @@ -46,18 +78,34 @@ export async function getInvite(kv: KVNamespace, token: string): Promise { + const raw = await kv.get(inviteKey(token), "json"); await kv.delete(inviteKey(token)); + if (raw) await removeFromIndex(kv, raw.groupId, token); } -/** All pending (unexpired) invites of a group. */ -export async function listInvites(kv: KVNamespace, groupId: string): Promise> { +/** All pending (unexpired) invites of a group, via the per-group index. */ +export async function listInvites( + kv: KVNamespace, + groupId: string, +): Promise> { try { - const { keys } = await kv.list({ prefix: "invite:" }); + const tokens = await readIndex(kv, groupId); const out: Array = []; - for (const key of keys) { - const token = key.name.slice("invite:".length); + const stale: string[] = []; + for (const token of tokens) { const invite = await getInvite(kv, token); - if (invite && invite.groupId === groupId) out.push({ ...invite, token }); + if (invite && invite.groupId === groupId) { + out.push({ ...invite, token }); + } else { + stale.push(token); + } + } + if (stale.length > 0) { + await writeIndex( + kv, + groupId, + tokens.filter((t) => !stale.includes(t)), + ); } return out; } catch (err) { @@ -67,7 +115,9 @@ export async function listInvites(kv: KVNamespace, groupId: string): Promise { + const raw = await kv.get(inviteKey(token), "json"); await kv.delete(inviteKey(token)); + if (raw) await removeFromIndex(kv, raw.groupId, token); } /**