iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
生成式 AI

練習AI系列 第 19

多租戶(Tenant)+命名空間(Namespace)+RBAC 權限

  • 分享至 

  • xImage
  •  

🆕 程式碼

  1. JWT & 權限

src/auth/jwt.js

// src/auth/jwt.js
import jwt from "jsonwebtoken";

const SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
const EXPIRES = "7d";

export function signToken({ userId, role = "viewer", tenant }) {
if (!userId || !tenant) throw new Error("userId / tenant 必填");
return jwt.sign({ sub: userId, role, tenant }, SECRET, { expiresIn: EXPIRES });
}

export function verifyToken(token) {
try {
return jwt.verify(token, SECRET);
} catch {
return null;
}
}

export function requireRole(user, allow = ["viewer"]) {
const order = { viewer: 0, editor: 1, admin: 2 };
const max = Math.max(...allow.map(r => order[r]));
return user && order[user.role] >= Math.min(max, 2);
}

src/api/withAuth.js

// src/api/withAuth.js
import { NextResponse } from "next/server";
import { verifyToken, requireRole } from "../auth/jwt.js";

export function withAuth(handler, allowRoles = ["viewer"]) {
return async (req, ctx) => {
const auth = req.headers.get("authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
const user = token ? verifyToken(token) : null;
if (!user || !requireRole(user, allowRoles)) {
return NextResponse.json({ ok:false, error: "Unauthorized" }, { status: 401 });
}
// 將 user 注入 ctx
ctx.user = user;
return handler(req, ctx);
};
}

app/api/auth/login/route.js

// app/api/auth/login/route.js
import { NextResponse } from "next/server";
import { signToken } from "../../../../src/auth/jwt.js";

export const runtime = "nodejs";

export async function POST(req) {
try {
const { userId, tenant, role = "viewer" } = await req.json();
if (!userId || !tenant) {
return NextResponse.json({ ok:false, error:"userId / tenant 必填" }, { status:400 });
}
const token = signToken({ userId, tenant, role });
return NextResponse.json({ ok:true, token, role, tenant });
} catch (e) {
return NextResponse.json({ ok:false, error: e.message }, { status:500 });
}
}

  1. 多租戶檔案系統

src/utils/tenantfs.js

// src/utils/tenantfs.js
import fs from "fs";
import path from "path";

export function sanitizeName(s="") {
// 僅允許 [a-z0-9-] 與 .md/.txt
if (!/^[a-z0-9
-]+$/i.test(s)) throw new Error("名稱不合法");
return s;
}

export function isAllowedFile(name="") {
return /^[\w-. ]+.(md|txt)$/i.test(name);
}

export function baseDirs() {
const root = process.cwd();
return {
kbRoot: path.join(root, "knowledge"),
idxRoot: path.join(root, "data", "rag"),
};
}

export function ensureTenantNS(tenant, ns) {
const { kbRoot, idxRoot } = baseDirs();
const t = sanitizeName(tenant);
const n = sanitizeName(ns);

const kbDir = path.join(kbRoot, t, n);
const idxDir = path.join(idxRoot, t);
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
if (!fs.existsSync(idxDir)) fs.mkdirSync(idxDir, { recursive: true });
return { kbDir, idxFile: path.join(idxDir, ${n}.index.json) };
}

export function listKBFiles(tenant, ns) {
const { kbDir } = ensureTenantNS(tenant, ns);
return fs.readdirSync(kbDir)
.filter(isAllowedFile)
.map(name => {
const p = path.join(kbDir, name);
const s = fs.statSync(p);
return { name, bytes: s.size, mtime: s.mtimeMs };
}).sort((a,b)=>b.mtime-a.mtime);
}

export function saveKBFile(tenant, ns, filename, buf) {
if (!isAllowedFile(filename)) throw new Error("僅允許 .md/.txt");
const { kbDir } = ensureTenantNS(tenant, ns);
const p = path.join(kbDir, path.basename(filename));
fs.writeFileSync(p, buf);
return p;
}

export function deleteKBFile(tenant, ns, filename) {
const { kbDir } = ensureTenantNS(tenant, ns);
const p = path.join(kbDir, path.basename(filename));
if (fs.existsSync(p)) fs.unlinkSync(p);
}

  1. RAG Store(支援 tenant + ns)

直接覆蓋 src/day16_rag_store.js

// src/day16_rag_store.js
import fs from "fs";
import path from "path";
import { openai } from "./aiClient.js";
import { chunkText, clean } from "./utils/text.js";
import { ensureTenantNS } from "./utils/tenantfs.js";

const EMBED_MODEL = process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small";

async function embedMany(texts=[]) {
if (!texts.length) return [];
const res = await openai.embeddings.create({ model: EMBED_MODEL, input: texts });
return res.data.map(d => d.embedding);
}

function listDocs(dir) {
return fs.readdirSync(dir).filter(f => /.md$|.txt$/i.test(f)).map(f => path.join(dir, f));
}

export async function buildIndex({ tenant, ns, chunkSize=800, overlap=80 }) {
const { kbDir, idxFile } = ensureTenantNS(tenant, ns);
const files = listDocs(kbDir);
if (!files.length) throw new Error("此命名空間沒有 .md/.txt 檔案");

const docs=[];
for (const fp of files) {
const raw = clean(fs.readFileSync(fp,"utf-8"));
const chs = chunkText(raw, chunkSize, overlap);
for (const c of chs) docs.push({ docId: path.basename(fp), chunkId: c.id, text: c.content });
}

const BATCH=64, vecs=[];
for (let i=0;i<docs.length;i+=BATCH) {
vecs.push(...await embedMany(docs.slice(i,i+BATCH).map(d=>d.text)));
}

const index = docs.map((d,i)=>({ id:${d.docId}#${d.chunkId}, docId:d.docId, text:d.text, vector:vecs[i] }));
fs.writeFileSync(idxFile, JSON.stringify({ builtAt: Date.now(), model: EMBED_MODEL, index }, null, 2), "utf-8");
return { idxFile, count:index.length };
}

function cosine(a,b){let dot=0,na=0,nb=0;for(let i=0;i<a.length;i++){dot+=a[i]*b[i];na+=a[i]*a[i];nb+=b[i]*b[i]}return dot/(Math.sqrt(na)*Math.sqrt(nb)+1e-9)}
function loadIndex(tenant, ns){
const { idxFile } = ensureTenantNS(tenant, ns);
if (!fs.existsSync(idxFile)) throw new Error("索引不存在,請先重建");
return JSON.parse(fs.readFileSync(idxFile, "utf-8")).index || [];
}

export async function retrieve({ tenant, ns, query, topK=4 }) {
const index = loadIndex(tenant, ns);
const qv = (await embedMany([query]))[0];
const scored = index.map(it => ({ ...it, score: cosine(qv, it.vector) })).sort((a,b)=>b.score-a.score);
return scored.slice(0, topK);
}

export async function answerWithRAG({ tenant, ns, query, topK=4 }) {
const hits = await retrieve({ tenant, ns, query, topK });
const ctx = hits.map((h,i)=># 片段${i+1}(${h.docId}, score=${h.score.toFixed(3)})\n${h.text}).join("\n\n");
const res = await openai.chat.completions.create({
model:"gpt-4o-mini", temperature:0.2,
messages:[
{ role:"system", content:"你是客服知識庫助理。僅依據提供片段回答;不足請明確說明。" },
{ role:"user", content:問題:${query}\n\n片段:\n${ctx}\n\n請用繁體中文:先結論、再步驟、最後列來源(片段序號與檔名)。 }
]
});
const answer = res.choices?.[0]?.message?.content?.trim() || "目前找不到足夠資訊。";
return { answer, sources: hits.map(h=>({id:h.id, docId:h.docId, score:h.score})) };
}

  1. 多租戶管理 API

app/api/tenants/list/route.js

import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { baseDirs } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";

export const runtime = "nodejs";

export const GET = withAuth(async (_req, ctx) => {
const { kbRoot } = baseDirs();
if (!fs.existsSync(kbRoot)) return NextResponse.json({ ok:true, tenants: [] });
const tenants = fs.readdirSync(kbRoot).filter(d => fs.statSync(path.join(kbRoot,d)).isDirectory());
return NextResponse.json({ ok:true, tenants });
}, ["viewer"]);

app/api/tenants/create/route.js

import { NextResponse } from "next/server";
import { ensureTenantNS } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";

export const runtime = "nodejs";

export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role !== "admin") return NextResponse.json({ ok:false, error:"Admin only" }, { status:403 });
const { tenant, ns="default" } = await req.json();
ensureTenantNS(tenant, ns);
return NextResponse.json({ ok:true });
}, ["admin"]);

app/api/namespaces/list/route.js

import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { baseDirs, sanitizeName } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";

export const runtime = "nodejs";

export const GET = withAuth(async (req, ctx) => {
const tenant = sanitizeName(new URL(req.url).searchParams.get("tenant") || ctx.user.tenant);
const root = path.join(baseDirs().kbRoot, tenant);
if (!fs.existsSync(root)) return NextResponse.json({ ok:true, namespaces: [] });
const namespaces = fs.readdirSync(root).filter(d => fs.statSync(path.join(root,d)).isDirectory());
return NextResponse.json({ ok:true, namespaces });
}, ["viewer"]);

app/api/namespaces/create/route.js

import { NextResponse } from "next/server";
import { ensureTenantNS, sanitizeName } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";

export const runtime = "nodejs";

export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = await req.json();
ensureTenantNS(tenant || ctx.user.tenant, sanitizeName(ns));
return NextResponse.json({ ok:true });
}, ["editor","admin"]);

  1. KB API(全部參數化 tenant/ns + RBAC)

以下五個 route 使用 動態路由:app/api/kb/[tenant]/[ns]/...

upload

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { saveKBFile } from "../../../../../src/utils/tenantfs.js";

export const runtime = "nodejs";

export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const form = await req.formData();
const file = form.get("file");
const name = (form.get("name") || file?.name || "").trim();
if (!file || !name) return NextResponse.json({ ok:false, error:"缺少檔案或檔名" }, { status:400 });
const buf = Buffer.from(await file.arrayBuffer());
saveKBFile(tenant, ns, name, buf);
return NextResponse.json({ ok:true, name });
}, ["editor","admin"]);

list

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { listKBFiles } from "../../../../../src/utils/tenantfs.js";

export const runtime = "nodejs";
export const GET = withAuth(async (req, ctx) => {
const { tenant, ns } = ctx.params;
const files = listKBFiles(tenant, ns);
return NextResponse.json({ ok:true, files });
}, ["viewer","editor","admin"]);

delete

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { deleteKBFile } from "../../../../../src/utils/tenantfs.js";

export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const { name } = await req.json();
if (!name) return NextResponse.json({ ok:false, error:"name 必填" }, { status:400 });
deleteKBFile(tenant, ns, name);
return NextResponse.json({ ok:true });
}, ["editor","admin"]);

reindex

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { buildIndex } from "../../../../../src/day16_rag_store.js";

export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const out = await buildIndex({ tenant, ns });
return NextResponse.json({ ok:true, ...out });
}, ["editor","admin"]);

ask

import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { answerWithRAG } from "../../../../../src/day16_rag_store.js";

export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
const { tenant, ns } = ctx.params;
const { q } = await req.json();
if (!q || !q.trim()) return NextResponse.json({ ok:false, error:"q 必填" }, { status:400 });
const { answer, sources } = await answerWithRAG({ tenant, ns, query: q, topK: 4 });
return NextResponse.json({ ok:true, answer, sources });
}, ["viewer","editor","admin"]);

  1. /studio(登入 + Tenant/Namespace 切換)

直接覆蓋 app/studio/page.tsx

"use client";
import { useEffect, useState } from "react";

type FileRow = { name:string; bytes:number; mtime:number };
type Src = { id:string; docId:string; score:number };

export default function Studio() {
// Auth
const [token, setToken] = useState<string|undefined>();
const [tenant, setTenant] = useState("");
const [role, setRole] = useState<"viewer"|"editor"|"admin">("viewer");
// Tenants / Namespaces
const [tenants, setTenants] = useState<string[]>([]);
const [nsList, setNsList] = useState<string[]>([]);
const [ns, setNs] = useState("faq");

// KB
const [files, setFiles] = useState<FileRow[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [uploading, setUploading] = useState(false);
const [reindexing, setReindexing] = useState(false);

// QA
const [askQ, setAskQ] = useState("");
const [answer, setAnswer] = useState("");
const [sources, setSources] = useState<Src[]>([]);
const [asking, setAsking] = useState(false);

const [err, setErr] = useState("");

function authHeaders() {
return token ? { Authorization: Bearer ${token} } : {};
}

async function login(userId: string, tenantName: string, roleSel: string) {
setErr(""); setAnswer("");
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "Content-Type":"application/json" },
body: JSON.stringify({ userId, tenant: tenantName, role: roleSel })
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setToken(j.token); setTenant(j.tenant); setRole(j.role);
localStorage.setItem("studio_token", j.token);
}

async function fetchTenants() {
const r = await fetch("/api/tenants/list", { headers: { ...authHeaders() }});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setTenants(j.tenants || []);
}

async function fetchNamespaces(t = tenant) {
if (!t) return;
const r = await fetch(/api/namespaces/list?tenant=${t}, { headers: { ...authHeaders() }});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setNsList(j.namespaces || []);
if ((j.namespaces || []).length && !j.namespaces.includes(ns)) setNs(j.namespaces[0]);
}

async function refreshKB() {
if (!tenant || !ns) return;
setLoadingList(true);
try {
const r = await fetch(/api/kb/${tenant}/${ns}/list, { headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
setFiles(j.files || []);
} catch(e:any){ setErr(e.message); }
finally { setLoadingList(false); }
}

useEffect(()=> {
const tk = localStorage.getItem("studio_token") || "";
if (tk) setToken(tk);
}, []);
useEffect(()=> { if (token) fetchTenants().catch(e=>setErr(e.message)); }, [token]);
useEffect(()=> { if (tenant && token) fetchNamespaces().catch(e=>setErr(e.message)); }, [tenant, token]);
useEffect(()=> { if (tenant && ns && token) refreshKB(); }, [tenant, ns, token]);

async function upload(e: React.FormEvent) {
e.preventDefault(); if (!tenant || !ns) return;
const form = e.currentTarget; const fd = new FormData(form);
setUploading(true); setErr("");
try {
const r = await fetch(/api/kb/${tenant}/${ns}/upload, { method:"POST", body: fd, headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
form.reset(); await refreshKB();
} catch(e:any){ setErr(e.message); }
finally { setUploading(false); }
}

async function del(name:string){
if (!confirm(刪除 ${name} ?)) return;
const r = await fetch(/api/kb/${tenant}/${ns}/delete, { method:"POST", headers: { "Content-Type":"application/json", ...authHeaders() }, body: JSON.stringify({ name }) });
const j = await r.json(); if (!j.ok) return setErr(j.error);
refreshKB();
}

async function reindex() {
setReindexing(true); setErr("");
try {
const r = await fetch(/api/kb/${tenant}/${ns}/reindex, { method:"POST", headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
alert(索引完成:${j.count} 片段);
} catch(e:any){ setErr(e.message); }
finally { setReindexing(false); }
}

async function ask() {
setAsking(true); setErr(""); setAnswer(""); setSources([]);
try {
const r = await fetch(/api/kb/${tenant}/${ns}/ask, {
method:"POST",
headers: { "Content-Type":"application/json", ...authHeaders() },
body: JSON.stringify({ q: askQ })
});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
setAnswer(j.answer); setSources(j.sources || []);
} catch(e:any){ setErr(e.message); }
finally { setAsking(false); }
}

return (

    <div className="navbar bg-base-100 rounded-box shadow">
      <div className="flex-1 px-2 font-bold">Day 18|RAG Studio(多租戶/RBAC)</div>
      {!token ? (
        <form className="flex gap-2 px-2"
          onSubmit={async (e)=>{ e.preventDefault();
            const f = e.currentTarget as HTMLFormElement;
            const userId = (f.elements.namedItem("userId") as HTMLInputElement).value;
            const t = (f.elements.namedItem("tenant") as HTMLInputElement).value;
            const role = (f.elements.namedItem("role") as HTMLSelectElement).value;
            try { await login(userId, t, role); } catch(e:any){ setErr(e.message); }
          }}>
          <input name="userId" className="input input-bordered input-sm" placeholder="userId" required />
          <input name="tenant" className="input input-bordered input-sm" placeholder="tenant(如 acme)" required />
          <select name="role" className="select select-bordered select-sm">
            <option>viewer</option><option>editor</option><option>admin</option>
          </select>
          <button className="btn btn-primary btn-sm" type="submit">登入</button>
        </form>
      ) : (
        <div className="flex items-center gap-2 px-2">
          <span className="badge">{tenant}</span>
          <span className="badge badge-outline">{role}</span>
          <button className="btn btn-ghost btn-sm" onClick={()=>{ localStorage.removeItem("studio_token"); setToken(undefined); }}>登出</button>
        </div>
      )}
    </div>

    {err && <div className="alert alert-error"><span>{err}</span></div>}

    {token && (
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
        {/* 左:管理 */}
        <div className="card bg-base-100 shadow">
          <div className="card-body space-y-4">
            <div className="flex items-center gap-2">
              <select className="select select-bordered" value={tenant} onChange={e=>setTenant(e.target.value)}>
                <option value="">選擇 Tenant</option>
                {tenants.map(t=><option key={t} value={t}>{t}</option>)}
              </select>
              <select className="select select-bordered" value={ns} onChange={e=>setNs(e.target.value)}>
                <option value="">選擇 Namespace</option>
                {nsList.map(x=><option key={x} value={x}>{x}</option>)}
              </select>
              {(role !== "viewer") && (
                <button className={`btn btn-outline btn-sm ${reindexing?"btn-disabled":""}`} onClick={reindex}>
                  {reindexing ? "索引中..." : "重建索引"}
                </button>
              )}
            </div>

            {(role !== "viewer") && (
              <form className="flex items-center gap-3" onSubmit={upload}>
                <input type="file" name="file" accept=".md,.txt" className="file-input file-input-bordered" required />
                <input type="text" name="name" placeholder="儲存檔名(例:refund.md)" className="input input-bordered" required />
                <button className={`btn btn-primary ${uploading?"btn-disabled":""}`} type="submit">
                  {uploading ? "上傳中..." : "上傳"}
                </button>
              </form>
            )}

            <div className="overflow-x-auto">
              <table className="table">
                <thead><tr><th>檔名</th><th>大小</th><th>修改時間</th><th></th></tr></thead>
                <tbody>
                  {loadingList ? (
                    <tr><td colSpan={4}>讀取中...</td></tr>
                  ) : files.length===0 ? (
                    <tr><td colSpan={4}>尚無資料</td></tr>
                  ) : files.map(f=>(
                    <tr key={f.name}>
                      <td>{f.name}</td>
                      <td>{(f.bytes/1024).toFixed(1)} KB</td>
                      <td>{new Date(f.mtime).toLocaleString()}</td>
                      <td>
                        {(role !== "viewer") && (
                          <button className="btn btn-ghost btn-xs" onClick={()=>del(f.name)}>刪除</button>
                        )}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </div>

        {/* 右:問答 */}
        <div className="card bg-base-100 shadow">
          <div className="card-body space-y-3">
            <h2 className="card-title">RAG 問答</h2>
            <textarea className="textarea textarea-bordered h-32" placeholder="輸入你的問題"
              value={askQ} onChange={e=>setAskQ(e.target.value)} />
            <button className={`btn btn-primary ${asking?"btn-disabled":""}`} onClick={ask}>
              {asking ? "查詢中..." : "詢問"}
            </button>
            {answer && (
              <div className="prose max-w-none">
                <h3>回答</h3>
                <div className="whitespace-pre-wrap">{answer}</div>
                <h3>來源</h3>
                <ul className="list-disc pl-6">
                  {sources.map((s,i)=><li key={i}>{s.docId}({s.score.toFixed(2)})</li>)}
                </ul>
              </div>
            )}
          </div>
        </div>
      </div>
    )}

  </div>
</div>

);
}

  1. package.json(新增 Scripts)
    {
    "scripts": {
    "day18:dev": "next dev",
    "day18:mk-tenant": "curl -s -X POST http://localhost:3000/api/tenants/create -H 'Content-Type: application/json' -d '{"tenant":"acme","ns":"faq"}'",
    "day18:reindex": "curl -s -X POST http://localhost:3000/api/kb/acme/faq/reindex -H 'Authorization: Bearer '"
    }
    }

上一篇
RAG 知識庫 Studio(Next.js API + DaisyUI 後台)
下一篇
引用高亮(Grounded Answers)
系列文
練習AI24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言