feat(invite): 租户成员二维码邀请(可复用团队码,扫码关注即入组)
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注 即自动加入租户,并收到「✅ 已加入团队【X】」被动回复。与登录二维码同一微信机制, scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。 - store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、 同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。 - handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。 - 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信 用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位 邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。 - desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { TopBar } from "./shell/TopBar";
|
|||||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||||
import { ApprovalBar } from "./shell/ApprovalBar";
|
import { ApprovalBar } from "./shell/ApprovalBar";
|
||||||
import { SpaceMembers } from "./shell/SpaceMembers";
|
import { SpaceMembers } from "./shell/SpaceMembers";
|
||||||
|
import { InviteMembers } from "./shell/InviteMembers";
|
||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
@@ -46,6 +47,7 @@ export default function App() {
|
|||||||
const [run, setRun] = useState<RunState>(emptyRun);
|
const [run, setRun] = useState<RunState>(emptyRun);
|
||||||
const [cmdOpen, setCmdOpen] = useState(false);
|
const [cmdOpen, setCmdOpen] = useState(false);
|
||||||
const [membersOpen, setMembersOpen] = useState(false);
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const execCloseRef = useRef<(() => void) | null>(null);
|
const execCloseRef = useRef<(() => void) | null>(null);
|
||||||
@@ -326,7 +328,7 @@ export default function App() {
|
|||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||||
<ApprovalBar run={run} />
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
@@ -357,6 +359,7 @@ export default function App() {
|
|||||||
canManage={space?.role === "owner" || space?.role === "admin"}
|
canManage={space?.role === "owner" || space?.role === "admin"}
|
||||||
selfUserId={user?.id ?? ""}
|
selfUserId={user?.id ?? ""}
|
||||||
/>
|
/>
|
||||||
|
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -271,6 +271,47 @@ export const setSpaceMemberRole = (spaceId: string, userId: string, role: string
|
|||||||
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
||||||
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
||||||
|
|
||||||
|
// ---- 租户成员「二维码邀请」(可复用团队码;扫码关注即入组)----
|
||||||
|
export interface TenantInvite {
|
||||||
|
id: string;
|
||||||
|
role: string; // member/viewer/admin
|
||||||
|
qr_image: string; // 微信二维码图 URL
|
||||||
|
expires_at: string;
|
||||||
|
max_uses: number; // 0=不限
|
||||||
|
used_count: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvites(): Promise<TenantInvite[]> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, { headers: bearer() }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `invites failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const d = (await res.json()) as { invites?: TenantInvite[] };
|
||||||
|
return d.invites ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvite(role: string, expiresDays: number, maxUses: number): Promise<TenantInvite> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...bearer() },
|
||||||
|
body: JSON.stringify({ role, expires_days: expiresDays, max_uses: maxUses }),
|
||||||
|
}));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { invite?: TenantInvite; error?: string };
|
||||||
|
if (!res.ok || !d.invite) throw new Error(d.error ?? `create invite failed: ${res.status}`);
|
||||||
|
return d.invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInvite(id: string): Promise<void> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/invites/${id}`, { method: "DELETE", headers: bearer() }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `revoke failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||||
export interface UsageDay {
|
export interface UsageDay {
|
||||||
day: string; // YYYYMMDD
|
day: string; // YYYYMMDD
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { QrCode, Trash2, Loader2 } from "lucide-react";
|
||||||
|
import { Dialog, Button, Select, Badge, useToast } from "../ui";
|
||||||
|
import { listInvites, createInvite, revokeInvite, type TenantInvite } from "../lib/api";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = { admin: "管理员", member: "成员", viewer: "只读" };
|
||||||
|
|
||||||
|
// InviteMembers 租户成员「二维码邀请」弹窗:owner/admin 生成可复用团队码,
|
||||||
|
// 成员用微信扫码关注即自动入组(后端 WxMPEvent inv_ 分支)。三道闸:有效期 + 人数 + 撤销。
|
||||||
|
// 作用于当前活跃租户(后端取 ctx 租户,前端不传 id)。
|
||||||
|
export function InviteMembers({ open, onClose, tenantName }: { open: boolean; onClose: () => void; tenantName: string }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [rows, setRows] = useState<TenantInvite[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [role, setRole] = useState("member");
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const [maxUses, setMaxUses] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [fresh, setFresh] = useState<TenantInvite | null>(null); // 刚生成的,置顶大图展示
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setRows(await listInvites());
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFresh(null);
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const inv = await createInvite(role, days, maxUses);
|
||||||
|
setFresh(inv);
|
||||||
|
toast.push("success", "已生成邀请二维码");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (inv: TenantInvite) => {
|
||||||
|
if (!window.confirm("撤销这张邀请码?已加入的成员不受影响,但此码将无法再扫码加入。")) return;
|
||||||
|
try {
|
||||||
|
await revokeInvite(inv.id);
|
||||||
|
if (fresh?.id === inv.id) setFresh(null);
|
||||||
|
toast.push("success", "已撤销");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usesLabel = (inv: TenantInvite) => (inv.max_uses > 0 ? `${inv.used_count}/${inv.max_uses} 人` : `${inv.used_count} 人(不限)`);
|
||||||
|
const expLabel = (iso: string) => new Date(iso).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 到期";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} title={`邀请成员 · ${tenantName}`}>
|
||||||
|
{/* 生成表单 */}
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
角色
|
||||||
|
<Select className="mt-1 h-8 w-20 text-xs" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="viewer">只读</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
有效期
|
||||||
|
<Select className="mt-1 h-8 w-20 text-xs" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||||
|
<option value={1}>1 天</option>
|
||||||
|
<option value={7}>7 天</option>
|
||||||
|
<option value={30}>30 天</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-slate-500">
|
||||||
|
人数上限
|
||||||
|
<Select className="mt-1 h-8 w-24 text-xs" value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))}>
|
||||||
|
<option value={0}>不限</option>
|
||||||
|
<option value={1}>1 人</option>
|
||||||
|
<option value={5}>5 人</option>
|
||||||
|
<option value={20}>20 人</option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" size="sm" icon={busy ? Loader2 : QrCode} onClick={generate} disabled={busy}>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 刚生成的码:大图展示,便于当场扫 */}
|
||||||
|
{fresh && (
|
||||||
|
<div className="mb-3 flex flex-col items-center rounded-lg border border-brand/40 bg-ink-800/60 p-3">
|
||||||
|
{fresh.qr_image ? (
|
||||||
|
<img src={fresh.qr_image} alt="邀请二维码" className="h-44 w-44 rounded bg-white p-1" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-44 w-44 items-center justify-center text-xs text-slate-500">二维码加载中…</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-center text-[11px] text-slate-400">
|
||||||
|
让对方用<b className="text-slate-200">微信扫一扫</b>关注公众号即自动加入
|
||||||
|
<br />
|
||||||
|
身份:{ROLE_LABEL[fresh.role] ?? fresh.role} · {usesLabel(fresh)} · {expLabel(fresh.expires_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 有效邀请码列表 */}
|
||||||
|
<div className="mb-1 text-[11px] text-slate-500">有效邀请码</div>
|
||||||
|
<ul className="max-h-56 space-y-1 overflow-auto">
|
||||||
|
{loading && <li className="px-1 py-2 text-[11px] text-slate-500">加载中…</li>}
|
||||||
|
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600">还没有邀请码,生成一张发给团队。</li>}
|
||||||
|
{rows.map((inv) => (
|
||||||
|
<li key={inv.id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
|
||||||
|
<button onClick={() => setFresh(inv)} title="查看二维码" className="shrink-0 text-slate-500 transition hover:text-brand-400">
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-200">
|
||||||
|
<Badge tone={inv.role === "admin" ? "accent" : inv.role === "viewer" ? "warn" : "neutral"}>{ROLE_LABEL[inv.role] ?? inv.role}</Badge>
|
||||||
|
<span className="text-[11px] text-slate-400">{usesLabel(inv)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[10px] text-slate-500">{expLabel(inv.expires_at)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(inv)} className="text-slate-600 transition hover:text-danger" title="撤销">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog } from "lucide-react";
|
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog, QrCode } from "lucide-react";
|
||||||
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
import { isMacDesktop } from "../lib/desktop";
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
@@ -124,7 +124,7 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; onManageMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, onInviteMembers, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; onManageMembers?: () => void; onInviteMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +154,15 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
|
|||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
||||||
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
||||||
|
{(tenantRole === "owner" || tenantRole === "admin") && (
|
||||||
|
<button
|
||||||
|
onClick={onInviteMembers}
|
||||||
|
title="邀请成员加入本租户(微信扫码)"
|
||||||
|
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{space?.space && space.space.kind !== "personal" && (
|
{space?.space && space.space.kind !== "personal" && (
|
||||||
<button
|
<button
|
||||||
onClick={onManageMembers}
|
onClick={onManageMembers}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 租户成员「二维码邀请」(可复用团队码)。owner/admin 生成一张微信带参二维码发给团队,
|
||||||
|
// 成员扫码关注/识别即自动入组(见 wechat_login.go 的 WxMPEvent inv_ 分支)。
|
||||||
|
//
|
||||||
|
// 挂在 tenant_self.go 旁,作用于当前活跃租户;建/撤销须 ≥admin(路由 RequireTenantRole 把守)。
|
||||||
|
|
||||||
|
const (
|
||||||
|
inviteScenePrefix = "inv_" // 二维码 scene 前缀,区分邀请扫码 vs 登录扫码
|
||||||
|
inviteMaxDays = 30 // 微信临时二维码有效期上限
|
||||||
|
inviteDefaultDays = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTenantInvite: POST /api/v1/tenants/current/invites {role?, expires_days?, max_uses?}
|
||||||
|
// 建邀请码并调微信出二维码,返回含二维码图 URL 的邀请记录。
|
||||||
|
func (h *Handler) CreateTenantInvite(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
ExpiresDays int `json:"expires_days"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&b)
|
||||||
|
|
||||||
|
role := b.Role
|
||||||
|
if role == "" {
|
||||||
|
role = store.RoleMember
|
||||||
|
}
|
||||||
|
if role == store.RoleOwner || !store.ValidRole(role) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法角色(二维码不能邀请为 owner)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := b.ExpiresDays
|
||||||
|
if days <= 0 {
|
||||||
|
days = inviteDefaultDays
|
||||||
|
}
|
||||||
|
if days > inviteMaxDays {
|
||||||
|
days = inviteMaxDays
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
cfg := h.loadWechatMP(ctx)
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信未配置,无法生成邀请二维码"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.accessToken(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "微信暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := h.db.CreateInvite(ctx, tenantID(c), userID(c), role, time.Now().AddDate(0, 0, days), b.MaxUses)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qr, err := cfg.CreateSceneQR(ctx, token, inviteScenePrefix+inv.Token, days*86400)
|
||||||
|
if err != nil {
|
||||||
|
// 二维码没建成 → 撤销这条,别在列表里留一条无图的死码。
|
||||||
|
_ = h.db.RevokeInvite(ctx, tenantID(c), inv.ID)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SetInviteQR(ctx, inv.ID, qr); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv.QRImage = qr
|
||||||
|
c.JSON(http.StatusOK, gin.H{"invite": inv})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenantInvites: GET /api/v1/tenants/current/invites —— 本租户的有效邀请码(含已用/上限/有效期)。
|
||||||
|
func (h *Handler) ListTenantInvites(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"invites": h.db.ListInvites(c.Request.Context(), tenantID(c), true)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTenantInvite: DELETE /api/v1/tenants/current/invites/:id —— 撤销一张邀请码。
|
||||||
|
func (h *Handler) RevokeTenantInvite(c *gin.Context) {
|
||||||
|
if err := h.db.RevokeInvite(c.Request.Context(), tenantID(c), c.Param("id")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -161,6 +162,14 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邀请扫码:scene 带 inv_ 前缀 → 找/建用户 + 入组 + 被动回复回执。独立于 PC 登录,
|
||||||
|
// 且不需要 access_token(回执走被动回复)。放在最前,避免被登录/欢迎逻辑抢先。
|
||||||
|
if scene := ev.Scene(); strings.HasPrefix(scene, inviteScenePrefix) {
|
||||||
|
c.Header("Content-Type", "application/xml")
|
||||||
|
c.String(http.StatusOK, h.handleInviteScan(ctx, ev, strings.TrimPrefix(scene, inviteScenePrefix)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 登录扫码(关注扫码 / 已关注再扫)→ 授权对应 ticket,让 PC 端轮询登录。
|
// 登录扫码(关注扫码 / 已关注再扫)→ 授权对应 ticket,让 PC 端轮询登录。
|
||||||
if ev.IsLoginScan() {
|
if ev.IsLoginScan() {
|
||||||
h.authorizeWxLogin(ctx, ev)
|
h.authorizeWxLogin(ctx, ev)
|
||||||
@@ -182,6 +191,34 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
|
|||||||
c.String(http.StatusOK, "success")
|
c.String(http.StatusOK, "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInviteScan 处理邀请扫码:按 openid 找/建用户 → 兑换邀请令牌入组 → 返回被动回复 XML。
|
||||||
|
// 回执走被动回复(无需 access_token),无论成败都要回一段合法响应体给微信。
|
||||||
|
func (h *Handler) handleInviteScan(ctx context.Context, ev *wechat.Event, token string) string {
|
||||||
|
openID := ev.FromUserName
|
||||||
|
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
||||||
|
if err != nil {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
if u == nil { // 新用户:建号 + 个人租户(与登录一致),随后再入被邀请的租户
|
||||||
|
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[wxinvite] 建微信用户失败 openid=%s: %v", openID, err)
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
||||||
|
log.Printf("[wxinvite] 建默认租户失败 uid=%s: %v", u.ID, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, ok := h.db.RedeemInvite(ctx, token, u.ID)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if !ok {
|
||||||
|
return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName,
|
||||||
|
"邀请链接已失效或人数已满,请向邀请人重新获取。", now)
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("✅ 已加入团队【%s】\n在桌面端用微信扫码登录即可共享团队积分。", name)
|
||||||
|
return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, msg, now)
|
||||||
|
}
|
||||||
|
|
||||||
// authorizeWxLogin 处理登录扫码事件:按 openid 找/建用户并置 ticket 已授权。
|
// authorizeWxLogin 处理登录扫码事件:按 openid 找/建用户并置 ticket 已授权。
|
||||||
// 只做副作用(Redis/DB),不写 HTTP 响应——响应由调用方按事件类型统一决定。
|
// 只做副作用(Redis/DB),不写 HTTP 响应——响应由调用方按事件类型统一决定。
|
||||||
func (h *Handler) authorizeWxLogin(ctx context.Context, ev *wechat.Event) {
|
func (h *Handler) authorizeWxLogin(ctx context.Context, ev *wechat.Event) {
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
|
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
|
||||||
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
|
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
|
||||||
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
|
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
|
||||||
|
// 二维码邀请(可复用团队码):看 ≥viewer,建/撤销 ≥admin + 审计。扫码入组走 /wx/mp/callback。
|
||||||
|
p.GET("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleViewer), h.ListTenantInvites)
|
||||||
|
p.POST("/tenants/current/invites", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.CreateTenantInvite)
|
||||||
|
p.DELETE("/tenants/current/invites/:id", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.RevokeTenantInvite)
|
||||||
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
||||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 租户成员「二维码邀请」(可复用团队码):owner/admin 生成一张微信带参二维码,
|
||||||
|
// 发给团队,成员扫码关注/识别即自动入组。与登录二维码同一微信机制,区别只在
|
||||||
|
// scene 指向邀请令牌、扫码后干的是「入组」而非「授权 PC 登录」。
|
||||||
|
//
|
||||||
|
// 三道安全闸:有效期(ExpiresAt) + 可撤销(Status) + 最大人数(MaxUses)。
|
||||||
|
// 令牌 Token 随机不可枚举——它就是二维码里的 scene,泄露即等于把加入权发出去。
|
||||||
|
//
|
||||||
|
// 不标 isTenantScoped:与 TenantMember 一样是基础设施表,租户过滤在查询里显式做;
|
||||||
|
// 且扫码入组发生在微信回调(无请求 ctx 租户),本就不能依赖插件自动注入。
|
||||||
|
|
||||||
|
// TenantInvite 一张可复用的租户邀请码。
|
||||||
|
type TenantInvite struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;index" json:"tenant_id"`
|
||||||
|
Token string `gorm:"size:64;uniqueIndex" json:"-"` // = 二维码 scene;已在图里,不必回前端
|
||||||
|
Role string `gorm:"size:16" json:"role"` // 入组角色(member/viewer/admin;禁 owner)
|
||||||
|
InviterID string `gorm:"size:64" json:"-"` // 建码人(审计)
|
||||||
|
QRImage string `gorm:"size:255" json:"qr_image"` // 微信二维码图 URL(showqrcode)
|
||||||
|
ExpiresAt time.Time `gorm:"index" json:"expires_at"` // 过期时间(对齐微信临时二维码,≤30 天)
|
||||||
|
MaxUses int `gorm:"default:0" json:"max_uses"` // 0 = 不限人数
|
||||||
|
UsedCount int `gorm:"default:0" json:"used_count"` // 已成功加入的人数(同一人重复扫不重复计)
|
||||||
|
Status string `gorm:"size:16;default:active" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TenantInvite) TableName() string { return "sundynix_tenant_invite" }
|
||||||
|
|
||||||
|
const (
|
||||||
|
InviteActive = "active"
|
||||||
|
InviteRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newInviteToken 生成不可枚举的邀请令牌(16 字节 → 32 hex)。
|
||||||
|
func newInviteToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvite 建一张邀请码(QRImage 由 handler 拿到微信二维码后回填 SetInviteQR)。
|
||||||
|
func (p *Postgres) CreateInvite(ctx context.Context, tenantID, inviterID, role string, expiresAt time.Time, maxUses int) (*TenantInvite, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
role = RoleMember
|
||||||
|
}
|
||||||
|
if !ValidRole(role) || role == RoleOwner {
|
||||||
|
return nil, errors.New("非法角色(二维码不能邀请为 owner)")
|
||||||
|
}
|
||||||
|
if maxUses < 0 {
|
||||||
|
maxUses = 0
|
||||||
|
}
|
||||||
|
inv := &TenantInvite{
|
||||||
|
TenantID: tenantID, Token: newInviteToken(), Role: role, InviterID: inviterID,
|
||||||
|
ExpiresAt: expiresAt, MaxUses: maxUses, Status: InviteActive,
|
||||||
|
}
|
||||||
|
if err := p.db.WithContext(ctx).Create(inv).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInviteQR 回填二维码图 URL(建码后拿到微信二维码再写)。
|
||||||
|
func (p *Postgres) SetInviteQR(ctx context.Context, id, qrImage string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Model(&TenantInvite{}).Where("id = ?", id).Update("qr_image", qrImage).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInviteByToken 按令牌取邀请码(含已撤销/过期,校验交给调用方)。
|
||||||
|
func (p *Postgres) GetInviteByToken(ctx context.Context, token string) *TenantInvite {
|
||||||
|
if p.db == nil || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inv TenantInvite
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).Where("token = ?", token).First(&inv).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &inv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvites 列出某租户的邀请码(新在前)。onlyActive 时只列未撤销的。
|
||||||
|
func (p *Postgres) ListInvites(ctx context.Context, tenantID string, onlyActive bool) []TenantInvite {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q := p.db.WithContext(ctx).Where("tenant_id = ?", tenantID)
|
||||||
|
if onlyActive {
|
||||||
|
q = q.Where("status = ?", InviteActive)
|
||||||
|
}
|
||||||
|
var out []TenantInvite
|
||||||
|
q.Order("created_at desc").Limit(100).Find(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeInvite 撤销一张邀请码(限本租户,防越权撤别家的)。
|
||||||
|
func (p *Postgres) RevokeInvite(ctx context.Context, tenantID, id string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
res := p.db.WithContext(ctx).Model(&TenantInvite{}).
|
||||||
|
Where("id = ? AND tenant_id = ?", id, tenantID).Update("status", InviteRevoked)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.New("邀请码不存在或不属于本租户")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemInvite 扫码入组:校验令牌 → 把 userID 加入其租户(幂等,复活已移除者)。
|
||||||
|
// 返回加入的租户名(供回执文案)与是否成功。令牌无效/过期/超次/撤销 → ok=false(回调静默)。
|
||||||
|
// UsedCount 只对「首次加入」计数:同一人重复扫(subscribe→之后 SCAN)不重复消耗名额。
|
||||||
|
func (p *Postgres) RedeemInvite(ctx context.Context, token, userID string) (tenantName string, ok bool) {
|
||||||
|
if p.db == nil || userID == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
inv := p.GetInviteByToken(ctx, token)
|
||||||
|
if inv == nil || inv.Status != InviteActive {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !inv.ExpiresAt.IsZero() && time.Now().After(inv.ExpiresAt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sctx := WithoutTenant(ctx) // 入组的目标租户与请求 ctx 无关,一律显式、旁路插件
|
||||||
|
var name string
|
||||||
|
p.db.WithContext(sctx).Model(&Tenant{}).Where("id = ?", inv.TenantID).Select("name").Scan(&name)
|
||||||
|
|
||||||
|
// 已是活跃成员 → 幂等成功,不再计数、不改角色(避免重复扫把人降/升级)。
|
||||||
|
var existing TenantMember
|
||||||
|
err := p.db.WithContext(sctx).Where("tenant_id = ? AND user_id = ?", inv.TenantID, userID).First(&existing).Error
|
||||||
|
if err == nil && existing.Status == "active" {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次加入前再查一次名额(软闸:极端并发下可能轻微超一两个,对邀请链接可接受)。
|
||||||
|
if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等入组(复活已移除者):命中唯一约束则置 active + 本码角色。
|
||||||
|
if err := p.db.WithContext(sctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{"role": inv.Role, "status": "active", "updated_at": time.Now()}),
|
||||||
|
}).Create(&TenantMember{TenantID: inv.TenantID, UserID: userID, Role: inv.Role, Status: "active"}).Error; err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名额 +1(原子自增,避免读改写丢更新)。
|
||||||
|
p.db.WithContext(sctx).Model(&TenantInvite{}).Where("id = ?", inv.ID).
|
||||||
|
UpdateColumn("used_count", gorm.Expr("used_count + 1"))
|
||||||
|
|
||||||
|
// 若目标租户已启用全员空间,新成员自动纳入(与 AddMemberByEmail 一致)。
|
||||||
|
p.autoJoinTenantSpace(sctx, inv.TenantID, userID, inv.Role)
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 邀请码 = 把「加入权」发出去。这组测试钉死:正常入组、重复扫不重复消耗名额、
|
||||||
|
// 过期/撤销/超人数一律拒绝——每一条错了都是「陌生人白嫖共享租户积分」的口子。
|
||||||
|
|
||||||
|
func mkUser(t *testing.T, p *Postgres, openid string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := p.CreateWechatUser(context.Background(), openid, "微信用户_"+openid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_HappyAndIdempotent(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
inv, err := p.CreateInvite(ctx, "t1", "inviter", RoleMember, time.Now().Add(time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("建码失败: %v", err)
|
||||||
|
}
|
||||||
|
uid := mkUser(t, p, "o-1")
|
||||||
|
|
||||||
|
name, ok := p.RedeemInvite(ctx, inv.Token, uid)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("正常令牌应入组成功")
|
||||||
|
}
|
||||||
|
if name != "T-t1" {
|
||||||
|
t.Fatalf("应回租户名 T-t1,得 %q", name)
|
||||||
|
}
|
||||||
|
if r := p.MemberRole(ctx, "t1", uid); r != RoleMember {
|
||||||
|
t.Fatalf("入组后应为 member,得 %q", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一人重复扫:仍成功但不重复消耗名额。
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, uid); !ok {
|
||||||
|
t.Fatal("重复扫应幂等成功")
|
||||||
|
}
|
||||||
|
got := p.GetInviteByToken(ctx, inv.Token)
|
||||||
|
if got.UsedCount != 1 {
|
||||||
|
t.Fatalf("同一人重复扫,UsedCount 应仍为 1,得 %d", got.UsedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_ExpiredAndRevoked(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
expired, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(-time.Minute), 0)
|
||||||
|
if _, ok := p.RedeemInvite(ctx, expired.Token, mkUser(t, p, "o-exp")); ok {
|
||||||
|
t.Fatal("过期码不该能入组")
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(time.Hour), 0)
|
||||||
|
if err := p.RevokeInvite(ctx, "t1", revoked.ID); err != nil {
|
||||||
|
t.Fatalf("撤销失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, revoked.Token, mkUser(t, p, "o-rev")); ok {
|
||||||
|
t.Fatal("已撤销码不该能入组")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_MaxUses(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
ctx := context.Background()
|
||||||
|
inv, _ := p.CreateInvite(ctx, "t1", "inv", RoleViewer, time.Now().Add(time.Hour), 2)
|
||||||
|
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u1")); !ok {
|
||||||
|
t.Fatal("第 1 人应成功")
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u2")); !ok {
|
||||||
|
t.Fatal("第 2 人应成功")
|
||||||
|
}
|
||||||
|
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u3")); ok {
|
||||||
|
t.Fatal("第 3 人应被名额闸拦下")
|
||||||
|
}
|
||||||
|
if got := p.GetInviteByToken(ctx, inv.Token); got.UsedCount != 2 {
|
||||||
|
t.Fatalf("UsedCount 应为 2,得 %d", got.UsedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedeemInvite_RejectsOwnerRole(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
if _, err := p.CreateInvite(context.Background(), "t1", "inv", RoleOwner, time.Now().Add(time.Hour), 0); err == nil {
|
||||||
|
t.Fatal("二维码不该能生成 owner 邀请")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
migrateLegacyIntIDs(db)
|
migrateLegacyIntIDs(db)
|
||||||
migrateDocLinkToID(db)
|
migrateDocLinkToID(db)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func newTestStore(t *testing.T) *Postgres {
|
|||||||
sqlDB.SetMaxOpenConns(1) // :memory: 每连接一个库,锁死单连接才共享同一份数据
|
sqlDB.SetMaxOpenConns(1) // :memory: 每连接一个库,锁死单连接才共享同一份数据
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
&User{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &CreditLedger{}, &PaymentOrder{},
|
||||||
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
||||||
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
||||||
&KB{}, // 租户作用域模型,验证隔离插件
|
&KB{}, // 租户作用域模型,验证隔离插件
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ func (p *Postgres) GetUserByWechatOpenID(ctx context.Context, openID string) (*U
|
|||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWechatUser 为首次微信登录的用户建号(无邮箱/密码)。name 是展示名(微信昵称或默认)。
|
// CreateWechatUser 为首次微信登录的用户建号(无密码)。name 是展示名(微信昵称或默认)。
|
||||||
|
//
|
||||||
|
// 邮箱填一个合成的、按 openid 唯一的占位值(wx-<openid>@wx.local):User.Email 上有整列
|
||||||
|
// 唯一索引,微信用户若都留空邮箱,第二个就会撞唯一约束建号失败。合成邮箱唯一且一望即知非真实,
|
||||||
|
// 既绕开冲突又不动索引(改成部分唯一索引需删存量索引,无法在无库可见时稳妥进行)。
|
||||||
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
|
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
return nil, errStoreDisabled
|
return nil, errStoreDisabled
|
||||||
@@ -82,7 +86,7 @@ func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*
|
|||||||
if openID == "" {
|
if openID == "" {
|
||||||
return nil, errors.New("openid 必填")
|
return nil, errors.New("openid 必填")
|
||||||
}
|
}
|
||||||
u := &User{WechatOpenID: openID, Name: name}
|
u := &User{WechatOpenID: openID, Name: name, Email: "wx-" + openID + "@wx.local"}
|
||||||
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
|
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 微信首次登录建号:无邮箱、按 openid 可查回。
|
// 微信首次登录建号:合成占位邮箱(按 openid 唯一,避免多个微信用户空邮箱撞唯一约束)、按 openid 可查回。
|
||||||
func TestCreateAndGetWechatUser(t *testing.T) {
|
func TestCreateAndGetWechatUser(t *testing.T) {
|
||||||
p := newTestStore(t)
|
p := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -19,8 +19,13 @@ func TestCreateAndGetWechatUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("建号失败: %v", err)
|
t.Fatalf("建号失败: %v", err)
|
||||||
}
|
}
|
||||||
if u.Email != "" {
|
if u.Email != "wx-openid-x@wx.local" {
|
||||||
t.Fatalf("微信用户不该有邮箱,得 %q", u.Email)
|
t.Fatalf("微信用户应有按 openid 合成的占位邮箱,得 %q", u.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二个微信用户不能撞唯一约束(此前空邮箱会冲突——邀请功能会批量建微信用户)。
|
||||||
|
if _, err := p.CreateWechatUser(ctx, "openid-y", "微信用户2"); err != nil {
|
||||||
|
t.Fatalf("第二个微信用户建号不该冲突: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
|
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
|
||||||
|
|||||||
@@ -149,9 +149,15 @@ func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error
|
|||||||
return r.AccessToken, r.ExpiresIn, nil
|
return r.AccessToken, r.ExpiresIn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
|
// CreateLoginQR 用带参数「临时」二维码承载登录 ticket。登录与邀请共用底层 CreateSceneQR。
|
||||||
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
|
|
||||||
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||||||
|
return c.CreateSceneQR(ctx, accessToken, scene, expireSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSceneQR 建一张带参数「临时」二维码,scene 为任意字符串(≤64 字符)。
|
||||||
|
// 登录用它承载 ticket、邀请用它承载 inv_<token>。expireSec 为二维码有效期。
|
||||||
|
// 返回可直接 <img> 展示的二维码图 URL。
|
||||||
|
func (c Config) CreateSceneQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
"expire_seconds": expireSec,
|
"expire_seconds": expireSec,
|
||||||
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
|
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
|
||||||
|
|||||||
Reference in New Issue
Block a user