@@ -79,7 +114,7 @@
name="timeout_seconds"
type="number"
min="1"
- max="60"
+ max="120"
value="12"
required
/>
@@ -100,8 +135,7 @@
Lead Scoring / Cấu hình
Cấu hình AI
- Quản lý model, prompt và thời gian chờ của AI Platform. Omni sở hữu quy tắc và trọng số;
- thông tin xác thực nhà cung cấp, gồm V-Key, do AIHub quản lý và không nhập tại đây.
+ Chọn nhà cung cấp, nhập URL nếu dùng OpenAI-compatible và chọn model cho Lead Scoring.
diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts
new file mode 100644
index 0000000..b48402d
--- /dev/null
+++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/ai-profiles.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from 'vitest';
+import { backend } from '$lib/server/backend';
+import { actions } from './+page.server';
+
+vi.mock('$lib/server/backend', () => ({ backend: vi.fn(), backendJson: vi.fn() }));
+
+const backendMock = vi.mocked(backend);
+type ActionEvent = Parameters
>[0];
+
+describe('AI profile provider selection', () => {
+ it('sends only a selected provider and profile settings to backend', async () => {
+ const form = new FormData();
+ form.set('version', 'profile-v2');
+ form.set('provider', 'openai_compatible');
+ form.set('base_url', 'http://127.0.0.1:8317');
+ form.set('model', 'gemini-3.5-flash-lite');
+ form.set('system_prompt', 'Only use provided evidence.');
+ form.set('timeout_seconds', '30');
+ const event = {
+ request: { formData: async () => form },
+ } as unknown as ActionEvent;
+ backendMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
+ const action = actions.default;
+ if (!action) throw new Error('AI profile action missing');
+ expect(await action(event)).toMatchObject({ success: true });
+ expect(backendMock).toHaveBeenCalledWith(
+ event,
+ '/v1/admin/ai-profiles',
+ expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({
+ version: 'profile-v2',
+ provider: 'openai_compatible',
+ base_url: 'http://127.0.0.1:8317',
+ model: 'gemini-3.5-flash-lite',
+ system_prompt: 'Only use provided evidence.',
+ timeout_seconds: 30,
+ }),
+ }),
+ );
+ expect(backendMock.mock.calls[0][2]?.body).not.toContain('api_key');
+ });
+});
diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts
new file mode 100644
index 0000000..d632d38
--- /dev/null
+++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts
@@ -0,0 +1,64 @@
+import { fail } from '@sveltejs/kit';
+import { backend, clearAdminSession } from '$lib/server/backend';
+import {
+ fixtureRunnerConfigured,
+ FixtureRunnerNotConfigured,
+ submitScoringFixture,
+} from '$lib/server/fixture-runner';
+import type { components } from '$lib/generated/api';
+import type { Actions, PageServerLoad } from './$types';
+
+type RunSummary = components['schemas']['RunSummary'];
+
+export const load: PageServerLoad = async () => ({
+ fixtureRunnerConfigured: fixtureRunnerConfigured(),
+});
+
+export const actions: Actions = {
+ default: async (event) => {
+ event.setHeaders({ 'cache-control': 'no-store' });
+ const adminSession = await backend(event, '/v1/admin/me');
+ if (adminSession.status === 401) {
+ clearAdminSession(event);
+ return fail(401, { message: 'Phiên quản trị đã hết hạn. Hãy đăng nhập lại.' });
+ }
+ if (!adminSession.ok) {
+ return fail(502, { message: 'Không thể xác minh phiên quản trị.' });
+ }
+ if (!fixtureRunnerConfigured()) {
+ return fail(503, {
+ message: 'Chưa có mã truy cập cho mẫu này trên server.',
+ });
+ }
+
+ let response: Response;
+ try {
+ response = await submitScoringFixture(event);
+ } catch (error) {
+ if (error instanceof FixtureRunnerNotConfigured) {
+ return fail(503, {
+ message: 'Chưa có mã truy cập cho mẫu này trên server.',
+ });
+ }
+ return fail(502, { message: 'Không gửi được dữ liệu mẫu.' });
+ }
+ if (response.status === 401) {
+ return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' });
+ }
+ if (!response.ok) {
+ return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' });
+ }
+
+ try {
+ const run = (await response.json()) as RunSummary;
+ return {
+ success: true,
+ run_id: run.run_id,
+ status: run.status,
+ message: 'Đã gửi dữ liệu mẫu.',
+ };
+ } catch {
+ return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' });
+ }
+ },
+};
diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte
new file mode 100644
index 0000000..5cb5d1e
--- /dev/null
+++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte
@@ -0,0 +1,48 @@
+
+
+Thử quy tắc · AI Platform
+
+
+
+ Dữ liệu mẫu
+
+
+ {#if !data.fixtureRunnerConfigured}
+
+ Chưa có mã truy cập cho mẫu này trên server.
+
+ {/if}
+ {#if form?.message}
+
+ {form.message}
+
+ {/if}
+ {#if form?.success && form.run_id}
+
+ Trạng thái: {form.status}.
+ Xem lần chấm điểm
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts
new file mode 100644
index 0000000..b961565
--- /dev/null
+++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { backend, clearAdminSession } from '$lib/server/backend';
+import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner';
+import { actions, load } from './+page.server';
+
+vi.mock('$lib/server/backend', () => ({
+ backend: vi.fn(),
+ clearAdminSession: vi.fn(),
+}));
+vi.mock('$lib/server/fixture-runner', () => ({
+ fixtureRunnerConfigured: vi.fn(),
+ submitScoringFixture: vi.fn(),
+ FixtureRunnerNotConfigured: class extends Error {},
+}));
+
+const backendMock = vi.mocked(backend);
+const configuredMock = vi.mocked(fixtureRunnerConfigured);
+const submitMock = vi.mocked(submitScoringFixture);
+const clearSessionMock = vi.mocked(clearAdminSession);
+const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
+
+type ActionEvent = Parameters>[0];
+
+function eventFor(): { event: ActionEvent; setHeaders: ReturnType } {
+ const setHeaders = vi.fn();
+ return { event: { setHeaders } as unknown as ActionEvent, setHeaders };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ configuredMock.mockReturnValue(true);
+ backendMock.mockResolvedValue(new Response('{}', { status: 200 }));
+});
+
+describe('fixture runner page', () => {
+ it('shows whether a server-only fixture key is configured', async () => {
+ configuredMock.mockReturnValue(false);
+ const page = await load({} as Parameters[0]);
+ expect(page).toEqual({ fixtureRunnerConfigured: false });
+ });
+
+ it('submits the fixture only after checking the admin session', async () => {
+ submitMock.mockResolvedValueOnce(
+ Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
+ );
+ const action = actions.default;
+ if (!action) throw new Error('fixture action missing');
+ const { event, setHeaders } = eventFor();
+ const result = await action(event);
+ expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' });
+ expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
+ expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
+ expect(submitMock).toHaveBeenCalledWith(event);
+ expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
+ submitMock.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('rejects an expired admin session without submitting', async () => {
+ backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
+ const action = actions.default;
+ if (!action) throw new Error('fixture action missing');
+ const { event } = eventFor();
+ const result = await action(event);
+ expect(result).toMatchObject({ status: 401 });
+ expect(clearSessionMock).toHaveBeenCalledWith(event);
+ expect(submitMock).not.toHaveBeenCalled();
+ });
+
+ it('rejects an unconfigured fixture key without submitting', async () => {
+ configuredMock.mockReturnValue(false);
+ const action = actions.default;
+ if (!action) throw new Error('fixture action missing');
+ const result = await action(eventFor().event);
+ expect(result).toMatchObject({ status: 503 });
+ expect(submitMock).not.toHaveBeenCalled();
+ });
+
+ it('does not expose a rejected tenant key in the response', async () => {
+ submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
+ const action = actions.default;
+ if (!action) throw new Error('fixture action missing');
+ const result = await action(eventFor().event);
+ expect(result).toMatchObject({ status: 502 });
+ expect(JSON.stringify(result)).not.toContain('Bearer');
+ });
+});
diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts
new file mode 100644
index 0000000..2aa29cd
--- /dev/null
+++ b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts
@@ -0,0 +1,84 @@
+import { error, fail } from '@sveltejs/kit';
+import { backendJson } from '$lib/server/backend';
+import type { components } from '$lib/generated/api';
+import type { Actions, PageServerLoad } from './$types';
+
+type TenantKeySummary = components['schemas']['TenantKeySummary'];
+type TenantKeyList = components['schemas']['TenantKeyList'];
+type TenantKeySecret = components['schemas']['TenantKeySecret'];
+
+export const load: PageServerLoad = async (event) => {
+ const { response, data } = await backendJson(event, '/v1/admin/tenant-keys');
+ if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.');
+ return { tenants: data.tenants };
+};
+
+function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string {
+ if (status === 409 && action === 'create')
+ return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.';
+ if (status === 404) return 'Không tìm thấy tenant key.';
+ if (status === 422) return 'Tenant ID không hợp lệ.';
+ if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.';
+ return 'Không thể cập nhật tenant key. Vui lòng thử lại.';
+}
+
+export const actions: Actions = {
+ create: async (event) => {
+ event.setHeaders({ 'cache-control': 'no-store' });
+ const form = await event.request.formData();
+ const tenant_id = String(form.get('tenant_id') ?? '').trim();
+ if (!tenant_id || tenant_id.length > 128) {
+ return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' });
+ }
+ const { response, data } = await backendJson(
+ event,
+ '/v1/admin/tenant-keys',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ tenant_id }),
+ },
+ );
+ if (!response.ok || !data) {
+ return fail(response.status, { message: mutationMessage(response.status, 'create') });
+ }
+ return {
+ success: true,
+ message: 'Đã tạo tenant key.',
+ tenant_id: data.tenant_id,
+ tenant_key: data.tenant_key,
+ };
+ },
+ rotate: async (event) => {
+ event.setHeaders({ 'cache-control': 'no-store' });
+ const form = await event.request.formData();
+ const tenant_id = String(form.get('tenant_id') ?? '');
+ if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
+ const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const;
+ const { response, data } = await backendJson(event, path, {
+ method: 'POST',
+ });
+ if (!response.ok || !data) {
+ return fail(response.status, { message: mutationMessage(response.status, 'rotate') });
+ }
+ return {
+ success: true,
+ message: `Đã xoay vòng key cho ${data.tenant_id}.`,
+ tenant_id: data.tenant_id,
+ tenant_key: data.tenant_key,
+ };
+ },
+ revoke: async (event) => {
+ const form = await event.request.formData();
+ const tenant_id = String(form.get('tenant_id') ?? '');
+ if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
+ const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const;
+ const { response, data } = await backendJson(event, path, {
+ method: 'DELETE',
+ });
+ if (!response.ok || !data) {
+ return fail(response.status, { message: mutationMessage(response.status, 'revoke') });
+ }
+ return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` };
+ },
+};
diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte
new file mode 100644
index 0000000..2947155
--- /dev/null
+++ b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte
@@ -0,0 +1,159 @@
+
+
+Mã truy cập · AI Platform
+
+
+{#if form?.message}
+
+ {form.message}
+
+{/if}
+
+{#if form?.tenant_key}
+
+ Sao chép mã vừa tạo
+
+ Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới.
+
+
+ {form.tenant_key}
+ copyKey(form.tenant_key)}>
+ Sao chép mã
+
+
+ {#if copied}Đã sao chép mã.
{/if}
+ {#if copyError}
+ Chọn và sao chép mã thủ công.
+
{/if}
+
+{/if}
+
+
+
+
+ Tạo mã truy cập
+ Dành cho tổ chức chưa có mã.
+
+
+
+
+
+
+
+
+ Tổ chức đã có mã
+
+
+ {#if data.tenants.length === 0}
+ Chưa có tổ chức nào được cấp mã.
+ {:else}
+
+ {#each data.tenants as tenant (tenant.tenant_id)}
+
+
+ {tenant.tenant_id}
+
+ {tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'}
+
+
+
+ Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
+ tenant.updated_at,
+ ).toLocaleString('vi-VN')}
+
+
+
+ {#if tenant.active}
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts
new file mode 100644
index 0000000..93acaaf
--- /dev/null
+++ b/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { backend, backendJson } from '$lib/server/backend';
+import { actions } from './+page.server';
+
+type TenantKeyRequestEvent = Parameters>[0];
+
+vi.mock('$lib/server/backend', () => ({
+ backend: vi.fn(),
+ backendJson: vi.fn(),
+}));
+
+function eventFor(values: Record): {
+ event: TenantKeyRequestEvent;
+ setHeaders: ReturnType;
+} {
+ const setHeaders = vi.fn();
+ return {
+ event: {
+ request: new Request('http://localhost/platform/tenant-keys', {
+ method: 'POST',
+ body: new URLSearchParams(values),
+ }),
+ setHeaders,
+ } as unknown as TenantKeyRequestEvent,
+ setHeaders,
+ };
+}
+
+const backendJsonMock = vi.mocked(backendJson);
+const backendMock = vi.mocked(backend);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('tenant key server actions', () => {
+ it('returns a newly-created key only in the action result', async () => {
+ backendJsonMock.mockResolvedValueOnce({
+ response: { ok: true, status: 201 } as Response,
+ data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' },
+ });
+ const action = actions.create;
+ if (!action) throw new Error('create action missing');
+ const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
+ const result = await action(event);
+ expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' });
+ expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
+ expect(backendJsonMock).toHaveBeenCalledWith(
+ expect.anything(),
+ '/v1/admin/tenant-keys',
+ expect.objectContaining({ method: 'POST' }),
+ );
+ });
+
+ it('rotates a key through the Admin API and returns the new value once', async () => {
+ backendJsonMock.mockResolvedValueOnce({
+ response: { ok: true, status: 200 } as Response,
+ data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' },
+ });
+ const action = actions.rotate;
+ if (!action) throw new Error('rotate action missing');
+ const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
+ const result = await action(event);
+ expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' });
+ expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
+ expect(backendJsonMock).toHaveBeenCalledWith(
+ expect.anything(),
+ '/v1/admin/tenant-keys/tenant-a/rotate',
+ { method: 'POST' },
+ );
+ });
+
+ it('reports an existing tenant conflict without returning a key', async () => {
+ backendJsonMock.mockResolvedValueOnce({
+ response: { ok: false, status: 409 } as Response,
+ data: null,
+ });
+ const action = actions.create;
+ if (!action) throw new Error('create action missing');
+ const result = await action(eventFor({ tenant_id: 'tenant-a' }).event);
+ expect(result).toMatchObject({
+ status: 409,
+ data: { message: expect.stringContaining('đã có key') },
+ });
+ });
+
+ it('sends revoke only through the backend server helper', async () => {
+ backendJsonMock.mockResolvedValueOnce({
+ response: { ok: true, status: 200 } as Response,
+ data: {
+ tenant_id: 'tenant/a',
+ active: false,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-02T00:00:00Z',
+ },
+ });
+ const action = actions.revoke;
+ if (!action) throw new Error('revoke action missing');
+ const result = await action(eventFor({ tenant_id: 'tenant/a' }).event);
+ expect(result).toMatchObject({ success: true });
+ expect(backendJsonMock).toHaveBeenCalledWith(
+ expect.anything(),
+ '/v1/admin/tenant-keys/tenant%2Fa',
+ { method: 'DELETE' },
+ );
+ expect(backendMock).not.toHaveBeenCalled();
+ });
+});