Add managed content and keyboard usage insights
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
This commit is contained in:
Rocky
2026-08-21 13:34:03 +08:00
parent b5212dcdc2
commit d0abe27623
55 changed files with 4690 additions and 111 deletions
+41
View File
@@ -3,6 +3,7 @@ import type {
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminHintPack,
AdminLoginResponse,
AuditQuery,
AuditLogEntry,
@@ -14,12 +15,17 @@ import type {
Overview,
PageResult,
ProductAnalyticsOverview,
CreateOfficialSkillRequest,
OfficialSkill,
OfficialSkillCatalog,
ReferralsQuery,
ReferralOverview,
SessionResponse,
UserDetail,
UsersQuery,
UserSummary,
UpdateHintPackRequest,
UpdateOfficialSkillRequest,
} from "./types";
const API_BASE = "/v1/admin";
@@ -73,6 +79,9 @@ function safeMessage(status: number, code?: string): string {
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill",
CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在",
CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
@@ -187,6 +196,38 @@ export const adminApi = {
productAnalytics: (range: string) =>
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
contentSkills: () => request<OfficialSkillCatalog>("/content/skills"),
createContentSkill: (payload: CreateOfficialSkillRequest) =>
request<OfficialSkill>("/content/skills", {
method: "POST",
body: JSON.stringify(payload),
}),
updateContentSkill: (id: string, payload: UpdateOfficialSkillRequest) =>
request<OfficialSkill>(`/content/skills/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
setContentSkillEnabled: (id: string, enabled: boolean) =>
request<void>(
`/content/skills/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
contentHintPack: (locale: "zh" | "en") =>
request<AdminHintPack>(`/content/hints/${locale}`),
updateContentHintPack: (
locale: "zh" | "en",
payload: UpdateHintPackRequest,
) =>
request<AdminHintPack>(`/content/hints/${locale}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
const params =
typeof value === "string"
+89 -1
View File
@@ -11,7 +11,76 @@ export type AdminAuditAction =
| "OPERATOR_UNLOCKED"
| "OPERATOR_CREDENTIALS_RESET"
| "OPERATOR_SESSIONS_REVOKED"
| "MANUAL_CREDIT_GRANTED";
| "MANUAL_CREDIT_GRANTED"
| "CONTENT_SKILL_CREATED"
| "CONTENT_SKILL_UPDATED"
| "CONTENT_SKILL_ENABLED"
| "CONTENT_SKILL_DISABLED"
| "CONTENT_HINT_PACK_PUBLISHED";
export interface SkillLocalization {
name: string;
summary: string;
prompt: string;
}
export interface OfficialSkill {
id: string;
systemImage: string;
sortOrder: number;
kind: "transform";
thinkingEnabled: boolean;
enabled: boolean;
localizations: {
"zh-Hans": SkillLocalization;
en: SkillLocalization;
};
}
export interface OfficialSkillCatalog {
revision: number;
generatedAt?: string;
skills: OfficialSkill[];
}
export type CreateOfficialSkillRequest = Omit<
OfficialSkill,
"kind" | "enabled"
>;
export type UpdateOfficialSkillRequest = Omit<
CreateOfficialSkillRequest,
"id"
>;
export interface AIHintCard {
id: string;
displayText?: string;
text?: string;
prompt: string;
category: string;
priority: number;
source: string;
locale: "zh" | "en";
conditions: string[];
metadata?: Record<string, unknown>;
}
export interface AdminHintPack {
locale: "zh" | "en";
generatedAt?: string;
expiresAt?: string;
intervalHours?: number;
version: number;
cards: AIHintCard[];
}
export interface UpdateHintPackRequest {
generatedAt?: string;
expiresAt?: string;
intervalHours?: number;
cards: AIHintCard[];
}
export interface CursorPageQuery {
cursor?: string;
@@ -176,6 +245,25 @@ export interface ProductAnalyticsOverview {
growthFunnel: FunnelStep[];
retention: AnalyticsCohort[];
aiFeatures: AnalyticsFeatureUsage[];
keyboardUsage: {
activeUsers: number;
activationToInput: AnalyticsRate;
chineseActiveUsers: number;
englishActiveUsers: number;
bilingualActiveUsers: number;
totalCharacters: number;
chineseCharacters: number;
englishCharacters: number;
otherCharacters: number;
chineseSharePercent?: number;
englishSharePercent?: number;
inputSessions: number;
averageCharactersPerInputSession?: number;
chineseOnlySessions: number;
englishOnlySessions: number;
mixedLanguageSessions: number;
otherOnlySessions: number;
};
referralFunnel: FunnelStep[];
guardrails: {
clientAiSuccessRate: AnalyticsRate;
+14
View File
@@ -5,6 +5,7 @@ import {
ChevronRight,
Coins,
GitBranch,
LibraryBig,
LogOut,
Menu,
Moon,
@@ -65,6 +66,11 @@ const CreditsPage = lazy(() =>
default: module.CreditsPage,
})),
);
const ContentPage = lazy(() =>
import("./features/content/content-page").then((module) => ({
default: module.ContentPage,
})),
);
const AuditPage = lazy(() =>
import("./features/audit/audit-page").then((module) => ({
default: module.AuditPage,
@@ -123,6 +129,13 @@ const navigation: NavItem[] = [
icon: Coins,
roles: supportRoles,
},
{
path: "/content",
label: "内容管理",
description: "Skill 与 Hint",
icon: LibraryBig,
roles: supportRoles,
},
{
path: "/audit",
label: "审计日志",
@@ -204,6 +217,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
<>
<Route path="/users" element={<UsersPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/content" element={<ContentPage />} />
</>
) : null}
{role === "SUPER_ADMIN" ? (
@@ -4,6 +4,7 @@ import {
BrainCircuit,
ChartNoAxesCombined,
Gauge,
Keyboard,
Repeat2,
Sparkles,
Target,
@@ -264,6 +265,74 @@ export function AnalyticsPage() {
</Card>
</section>
<section className="grid gap-6 xl:grid-cols-2" aria-label="键盘输入统计">
<Card className="overflow-hidden">
<SectionHeader
title="键盘中英文输入"
description="仅统计 OSGKeyboard 本地聚合后的手动提交字符,不包含输入内容"
icon={Keyboard}
/>
<ComparisonBarChart
items={[
{
label: "中文",
value: data.keyboardUsage.chineseCharacters,
hint: `占中英文 ${optionalPercent(data.keyboardUsage.chineseSharePercent)}`,
},
{
label: "英文",
value: data.keyboardUsage.englishCharacters,
hint: `占中英文 ${optionalPercent(data.keyboardUsage.englishSharePercent)}`,
},
{
label: "其他",
value: data.keyboardUsage.otherCharacters,
hint: "数字、标点与 Emoji",
},
]}
primaryLabel="提交字符数"
primaryTone="primary"
/>
<MetricRows
rows={[
["输入活跃用户", formatNumber(data.keyboardUsage.activeUsers)],
["激活到输入转化", rateLabel(data.keyboardUsage.activationToInput)],
["中文活跃用户", formatNumber(data.keyboardUsage.chineseActiveUsers)],
["英文活跃用户", formatNumber(data.keyboardUsage.englishActiveUsers)],
["中英双语用户", formatNumber(data.keyboardUsage.bilingualActiveUsers)],
["总提交字符", formatNumber(data.keyboardUsage.totalCharacters)],
]}
/>
</Card>
<Card className="overflow-hidden">
<SectionHeader
title="输入会话结构"
description="一次键盘激活期间至少提交一个字符才计为输入会话"
icon={Activity}
/>
<ComparisonBarChart
items={[
{ label: "仅中文", value: data.keyboardUsage.chineseOnlySessions },
{ label: "仅英文", value: data.keyboardUsage.englishOnlySessions },
{ label: "中英混合", value: data.keyboardUsage.mixedLanguageSessions },
{ label: "仅其他", value: data.keyboardUsage.otherOnlySessions },
]}
primaryLabel="输入会话"
primaryTone="success"
/>
<MetricRows
rows={[
["输入会话总数", formatNumber(data.keyboardUsage.inputSessions)],
[
"平均每会话字符",
optionalDecimal(data.keyboardUsage.averageCharactersPerInputSession),
],
]}
/>
</Card>
</section>
<section className="grid gap-6 xl:grid-cols-3">
<Card className="overflow-hidden">
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
@@ -0,0 +1,507 @@
import { FileJson, Pencil, Plus, Power, RefreshCw, Sparkles } from "lucide-react";
import {
useCallback,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
AdminHintPack,
CreateOfficialSkillRequest,
OfficialSkill,
OfficialSkillCatalog,
SkillLocalization,
UpdateHintPackRequest,
} from "../../api/types";
import {
Badge,
Button,
Card,
Dialog,
EmptyState,
ErrorState,
Input,
LoadingState,
PageHeader,
Textarea,
} from "../../components/primitives";
import { useAuth } from "../auth/auth-context";
type HintLocale = "zh" | "en";
export function ContentPage() {
const { auth } = useAuth();
const canEdit = auth.status === "authenticated" && auth.role === "SUPER_ADMIN";
const [catalog, setCatalog] = useState<OfficialSkillCatalog>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
const [editingSkill, setEditingSkill] = useState<OfficialSkill | "new">();
const [busySkillId, setBusySkillId] = useState<string>();
const [locale, setLocale] = useState<HintLocale>("zh");
const [hintText, setHintText] = useState("");
const [hintVersion, setHintVersion] = useState(0);
const [hintLoading, setHintLoading] = useState(true);
const [hintSaving, setHintSaving] = useState(false);
const loadSkills = useCallback(async () => {
setError(undefined);
try {
setCatalog(await adminApi.contentSkills());
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
const loadHint = useCallback(async (nextLocale: HintLocale) => {
setHintLoading(true);
try {
const pack = await adminApi.contentHintPack(nextLocale);
setHintVersion(pack.version);
setHintText(formatHintPack(pack));
} catch (requestError) {
toast.error(errorMessage(requestError, "Hint pack 加载失败"));
} finally {
setHintLoading(false);
}
}, []);
useEffect(() => {
void loadSkills();
}, [loadSkills]);
useEffect(() => {
void loadHint(locale);
}, [loadHint, locale]);
async function setSkillEnabled(skill: OfficialSkill) {
setBusySkillId(skill.id);
try {
await adminApi.setContentSkillEnabled(skill.id, !skill.enabled);
toast.success(skill.enabled ? "Skill 已停用" : "Skill 已启用");
await loadSkills();
} catch (requestError) {
toast.error(errorMessage(requestError, "Skill 状态更新失败"));
} finally {
setBusySkillId(undefined);
}
}
async function saveHint() {
let payload: UpdateHintPackRequest;
try {
const parsed = JSON.parse(hintText) as Partial<UpdateHintPackRequest>;
if (!Array.isArray(parsed.cards)) throw new Error("cards 必须是数组");
payload = {
generatedAt: optionalString(parsed.generatedAt),
expiresAt: optionalString(parsed.expiresAt),
intervalHours:
parsed.intervalHours === undefined ? undefined : Number(parsed.intervalHours),
cards: parsed.cards,
};
} catch (parseError) {
toast.error(parseError instanceof Error ? parseError.message : "JSON 格式无效");
return;
}
setHintSaving(true);
try {
const saved = await adminApi.updateContentHintPack(locale, payload);
setHintVersion(saved.version);
setHintText(formatHintPack(saved));
toast.success(`${locale} Hint pack 已发布`);
} catch (requestError) {
toast.error(errorMessage(requestError, "Hint pack 保存失败"));
} finally {
setHintSaving(false);
}
}
if (error) return <ErrorState error={error} retry={() => void loadSkills()} />;
if (loading || !catalog) return <LoadingState label="加载内容管理" />;
return (
<div className="space-y-8">
<PageHeader
eyebrow="Official Content"
title="内容管理"
description="维护客户端官方 Skill 目录与 zh/en AI Hint 发布包。每次保存都会立即发布并写入审计。"
actions={
canEdit ? (
<Button onClick={() => setEditingSkill("new")}>
<Plus className="size-4" aria-hidden />
Skill
</Button>
) : (
<Badge tone="info">访</Badge>
)
}
/>
<section aria-labelledby="skill-heading" className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="skill-heading" className="text-xl font-bold">
Skill
</h2>
<p className="mt-1 text-sm text-muted">
revision {catalog.revision} · {catalog.skills.length}
</p>
</div>
<Button variant="secondary" onClick={() => void loadSkills()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
</div>
{catalog.skills.length === 0 ? (
<Card>
<EmptyState title="暂无官方 Skill" description="新增后可单独启用发布。" />
</Card>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{catalog.skills.map((skill) => (
<Card key={skill.id} className="p-5">
<div className="flex items-start gap-4">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary-soft text-primary">
<Sparkles className="size-5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{skill.localizations["zh-Hans"].name}</h3>
<Badge tone={skill.enabled ? "success" : "neutral"}>
{skill.enabled ? "已启用" : "已停用"}
</Badge>
{skill.thinkingEnabled ? <Badge tone="violet"></Badge> : null}
</div>
<p className="mt-1 text-sm text-muted">
{skill.localizations["zh-Hans"].summary}
</p>
<p className="mt-3 break-all font-mono text-[11px] text-muted">
{skill.id} · {skill.systemImage} · {skill.sortOrder}
</p>
{canEdit ? (
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => setEditingSkill(skill)}
>
<Pencil className="size-3.5" aria-hidden />
</Button>
<Button
size="sm"
variant={skill.enabled ? "danger" : "primary"}
loading={busySkillId === skill.id}
onClick={() => void setSkillEnabled(skill)}
>
<Power className="size-3.5" aria-hidden />
{skill.enabled ? "停用" : "启用"}
</Button>
</div>
) : null}
</div>
</div>
</Card>
))}
</div>
)}
</section>
<section aria-labelledby="hint-heading" className="space-y-4">
<div>
<h2 id="hint-heading" className="text-xl font-bold">
AI Hint packs
</h2>
<p className="mt-1 text-sm text-muted">
JSON AIHintPack version
</p>
</div>
<Card className="overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-4">
{(["zh", "en"] as HintLocale[]).map((item) => (
<Button
key={item}
size="sm"
variant={locale === item ? "primary" : "secondary"}
onClick={() => setLocale(item)}
aria-pressed={locale === item}
>
{item}
</Button>
))}
<Badge className="ml-auto" tone="info">
version {hintVersion}
</Badge>
</div>
<div className="p-4 sm:p-6">
{hintLoading ? (
<LoadingState label={`加载 ${locale} Hint pack`} />
) : (
<>
<label className="block text-sm font-semibold" htmlFor="hint-pack-json">
<span className="mb-2 flex items-center gap-2">
<FileJson className="size-4 text-primary" aria-hidden />
{locale} JSON
</span>
<Textarea
id="hint-pack-json"
className="min-h-[420px] font-mono text-xs leading-5"
value={hintText}
onChange={(event) => setHintText(event.target.value)}
readOnly={!canEdit}
spellCheck={false}
/>
</label>
{canEdit ? (
<div className="mt-4 flex justify-end">
<Button loading={hintSaving} onClick={() => void saveHint()}>
</Button>
</div>
) : null}
</>
)}
</div>
</Card>
</section>
<SkillDialog
skill={editingSkill}
onClose={() => setEditingSkill(undefined)}
onSaved={async () => {
setEditingSkill(undefined);
await loadSkills();
}}
/>
</div>
);
}
function SkillDialog({
skill,
onClose,
onSaved,
}: {
skill?: OfficialSkill | "new";
onClose: () => void;
onSaved: () => Promise<void>;
}) {
const [form, setForm] = useState<CreateOfficialSkillRequest>(() => emptySkill());
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(skill && skill !== "new" ? skillToRequest(skill) : emptySkill());
}, [skill]);
async function submit(event: FormEvent) {
event.preventDefault();
setSaving(true);
try {
if (skill === "new") {
await adminApi.createContentSkill(form);
} else if (skill) {
const { id: _id, ...payload } = form;
await adminApi.updateContentSkill(skill.id, payload);
}
toast.success(skill === "new" ? "Skill 已创建(默认停用)" : "Skill 已更新");
await onSaved();
} catch (requestError) {
toast.error(errorMessage(requestError, "Skill 保存失败"));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={Boolean(skill)}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={skill === "new" ? "新增官方 Skill" : "编辑官方 Skill"}
description="ID 创建后不可修改;新 Skill 默认停用,确认内容后再启用。"
preventClose={saving}
className="max-w-3xl"
>
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Skill ID">
<Input
required
pattern={"official\\.[a-z0-9._-]+"}
minLength={10}
maxLength={100}
value={form.id}
disabled={skill !== "new"}
onChange={(event) => setForm({ ...form, id: event.target.value })}
placeholder="official.polish"
/>
</Field>
<Field label="SF Symbol">
<Input
required
minLength={1}
maxLength={100}
value={form.systemImage}
onChange={(event) => setForm({ ...form, systemImage: event.target.value })}
placeholder="wand.and.sparkles"
/>
</Field>
<Field label="排序">
<Input
required
type="number"
min={0}
max={100000}
value={form.sortOrder}
onChange={(event) =>
setForm({ ...form, sortOrder: Number(event.target.value) })
}
/>
</Field>
<label className="flex min-h-11 items-center gap-3 self-end rounded-xl border border-border px-4 text-sm">
<input
type="checkbox"
checked={form.thinkingEnabled}
onChange={(event) =>
setForm({ ...form, thinkingEnabled: event.target.checked })
}
/>
</label>
</div>
<LocalizationFields
title="简体中文"
value={form.localizations["zh-Hans"]}
onChange={(value) =>
setForm({
...form,
localizations: { ...form.localizations, "zh-Hans": value },
})
}
/>
<LocalizationFields
title="English"
value={form.localizations.en}
onChange={(value) =>
setForm({
...form,
localizations: { ...form.localizations, en: value },
})
}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
</Button>
<Button type="submit" loading={saving}>
</Button>
</div>
</form>
</Dialog>
);
}
function LocalizationFields({
title,
value,
onChange,
}: {
title: string;
value: SkillLocalization;
onChange: (value: SkillLocalization) => void;
}) {
return (
<fieldset className="space-y-3 rounded-2xl border border-border p-4">
<legend className="px-2 text-sm font-semibold">{title}</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="名称">
<Input
required
minLength={1}
maxLength={40}
value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</Field>
<Field label="摘要">
<Input
required
minLength={1}
maxLength={200}
value={value.summary}
onChange={(event) => onChange({ ...value, summary: event.target.value })}
/>
</Field>
</div>
<Field label="Prompt">
<Textarea
required
minLength={1}
maxLength={6000}
value={value.prompt}
onChange={(event) => onChange({ ...value, prompt: event.target.value })}
/>
</Field>
</fieldset>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-sm font-medium">
<span className="mb-1.5 block">{label}</span>
{children}
</label>
);
}
function emptySkill(): CreateOfficialSkillRequest {
return {
id: "official.",
systemImage: "sparkles",
sortOrder: 0,
thinkingEnabled: false,
localizations: {
"zh-Hans": { name: "", summary: "", prompt: "" },
en: { name: "", summary: "", prompt: "" },
},
};
}
function skillToRequest(skill: OfficialSkill): CreateOfficialSkillRequest {
return {
id: skill.id,
systemImage: skill.systemImage,
sortOrder: skill.sortOrder,
thinkingEnabled: skill.thinkingEnabled,
localizations: skill.localizations,
};
}
function formatHintPack(pack: AdminHintPack): string {
return JSON.stringify(
{
generatedAt: pack.generatedAt,
expiresAt: pack.expiresAt,
intervalHours: pack.intervalHours,
cards: pack.cards,
},
null,
2,
);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
+1 -1
View File
@@ -250,7 +250,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
options={[
{ value: "", label: "全部状态" },
{ value: "active", label: "正常" },
{ value: "suspended", label: "已暂停" },
{ value: "suspended", label: "反滥用受限" },
]}
onChange={setStatus}
/>
+1 -1
View File
@@ -35,7 +35,7 @@ export function escapeHtml(value: unknown): string {
export function statusLabel(status: string): string {
const labels: Record<string, string> = {
active: "正常",
suspended: "已停用",
suspended: "反滥用受限",
closed: "已关闭",
success: "成功",
rejected: "已拒绝",
+17
View File
@@ -57,6 +57,23 @@ describe("adminApi", () => {
expect(request.credentials).toBe("include");
});
it("内容发布请求携带 CSRF 并编码 Skill ID", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, { status: 204 }),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-content");
await adminApi.setContentSkillEnabled("official.skill/with space", true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/content/skills/official.skill%2Fwith%20space/enable",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-content");
expect(request.method).toBe("POST");
});
it("登录请求不依赖已有会话 CSRF", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
+121
View File
@@ -0,0 +1,121 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type { AdminRole, OfficialSkillCatalog } from "../api/types";
import { App } from "../app";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.location.hash = "";
});
describe("内容管理", () => {
it("SUPPORT 可查看但不能修改 Skill 与 Hint", async () => {
mockSession("SUPPORT");
mockContent();
window.location.hash = "#/content";
render(<App />);
expect(await screen.findByRole("heading", { name: "内容管理" })).toBeTruthy();
expect(screen.getByRole("link", { name: /内容管理/ })).toBeTruthy();
expect(await screen.findByText("润色")).toBeTruthy();
expect(screen.getByText("只读访问")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新增 Skill" })).toBeNull();
const editor = await screen.findByLabelText("zh JSON");
expect(editor).toHaveProperty("readOnly", true);
expect(screen.queryByRole("button", { name: "保存并立即发布" })).toBeNull();
});
it("SUPER_ADMIN 可启停官方 Skill", async () => {
mockSession("SUPER_ADMIN");
mockContent();
const mutation = vi
.spyOn(adminApi, "setContentSkillEnabled")
.mockResolvedValue(undefined);
window.location.hash = "#/content";
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "停用" }));
await waitFor(() => {
expect(mutation).toHaveBeenCalledWith("official.polish", false);
});
expect(screen.getByRole("button", { name: "新增 Skill" })).toBeTruthy();
});
it("Skill 编辑器约束与客户端边界一致", async () => {
mockSession("SUPER_ADMIN");
mockContent();
window.location.hash = "#/content";
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "新增 Skill" }));
expect(screen.getByLabelText("Skill ID").getAttribute("maxlength")).toBe("100");
expect(screen.getByLabelText("SF Symbol").getAttribute("maxlength")).toBe("100");
const sortOrder = screen.getByLabelText("排序");
expect(sortOrder.getAttribute("min")).toBe("0");
expect(sortOrder.getAttribute("max")).toBe("100000");
screen.getAllByLabelText("名称").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("40");
});
screen.getAllByLabelText("摘要").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("200");
});
screen.getAllByLabelText("Prompt").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("6000");
});
});
});
function mockSession(role: AdminRole) {
vi.spyOn(adminApi, "session").mockResolvedValue({
authenticated: true,
operatorName: "owner",
role,
});
}
function mockContent() {
vi.spyOn(adminApi, "contentSkills").mockResolvedValue(catalog());
vi.spyOn(adminApi, "contentHintPack").mockResolvedValue({
locale: "zh",
generatedAt: "2026-08-21T04:00:00Z",
intervalHours: 12,
version: 2,
cards: [],
});
}
function catalog(): OfficialSkillCatalog {
return {
revision: 3,
generatedAt: "2026-08-21T04:00:00Z",
skills: [
{
id: "official.polish",
systemImage: "wand.and.sparkles",
sortOrder: 10,
kind: "transform",
thinkingEnabled: false,
enabled: true,
localizations: {
"zh-Hans": {
name: "润色",
summary: "优化表达",
prompt: "请润色",
},
en: {
name: "Polish",
summary: "Improve wording",
prompt: "Please polish",
},
},
},
],
};
}
+4
View File
@@ -32,6 +32,10 @@ describe("format helpers", () => {
expect(statusLabel("custom")).toBe("custom");
});
it("反滥用限制不会误显示为账户停用", () => {
expect(statusLabel("suspended")).toBe("反滥用受限");
});
it("消费类型使用清晰的中文标签", () => {
expect(usageTypeLabel("polish")).toBe("润色");
expect(usageTypeLabel("hotword")).toBe("热词");
+22
View File
@@ -229,6 +229,9 @@ describe("React 管理页面", () => {
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
expect(screen.getByText("2026-08-01")).toBeTruthy();
expect(screen.getByText("文字润色")).toBeTruthy();
expect(screen.getByText("键盘中英文输入")).toBeTruthy();
expect(screen.getByText("中文活跃用户")).toBeTruthy();
expect(screen.getByText("中英混合")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
});
@@ -331,6 +334,25 @@ function analyticsOverview(): ProductAnalyticsOverview {
aiFeatures: [
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
],
keyboardUsage: {
activeUsers: 40,
activationToInput: rate(40, 50, 80),
chineseActiveUsers: 30,
englishActiveUsers: 20,
bilingualActiveUsers: 10,
totalCharacters: 12_000,
chineseCharacters: 7_000,
englishCharacters: 4_000,
otherCharacters: 1_000,
chineseSharePercent: 63.6,
englishSharePercent: 36.4,
inputSessions: 200,
averageCharactersPerInputSession: 60,
chineseOnlySessions: 100,
englishOnlySessions: 60,
mixedLanguageSessions: 30,
otherOnlySessions: 10,
},
referralFunnel: [
{ label: "发起分享", count: 20 },
{ label: "完成奖励", count: 5 },
+13
View File
@@ -9,6 +9,7 @@ GRANT SELECT ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%'
GRANT SELECT ON osg_account_smoke.referral_campaigns TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.referral_owner_codes TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%';
@@ -32,6 +33,11 @@ GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtim
GRANT SELECT ON osg_account_smoke.product_analytics_installations TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.product_analytics_daily_counters TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.keyboard_usage_daily_summaries TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.official_content_catalog TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.official_skills TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.official_skill_localizations TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.official_hint_packs TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
@@ -41,6 +47,7 @@ GRANT INSERT, UPDATE ON osg_account_smoke.credit_accounts TO 'osg_smoke_runtime'
GRANT INSERT, UPDATE ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%';
GRANT UPDATE ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.referral_owner_codes TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%';
@@ -67,3 +74,9 @@ GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installation
GRANT INSERT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.product_analytics_daily_counters
TO 'osg_smoke_runtime'@'%';
GRANT INSERT, DELETE ON osg_account_smoke.keyboard_usage_daily_summaries
TO 'osg_smoke_runtime'@'%';
GRANT UPDATE ON osg_account_smoke.official_content_catalog TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.official_skills TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.official_skill_localizations TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.official_hint_packs TO 'osg_smoke_runtime'@'%';
+3
View File
@@ -7,6 +7,9 @@
- Product analytics installations linked to the account and all of their events
are deleted by database cascade. The service stores only the digest of a
random installation UUID and never stores user content in analytics events.
- Keyboard usage contains daily Chinese, English, other-character and
input-session counters only. It never contains text or keystrokes and is
purged after 90 days regardless of account linkage.
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
and time-limited anti-abuse tombstones remain after deletion where required to
prevent replay, preserve financial integrity, and stop repeated trial abuse.
+45
View File
@@ -69,6 +69,51 @@ Managed client success events are excluded from this total.
`successful AI requests / distinct value-active users` for the selected period.
## Keyboard input usage
Keyboard input metrics use finalized UTC-day summaries produced on-device.
They describe manually committed OSGKeyboard text only and are independent
from AI value events, billing and referral qualification.
### Keyboard input active users
Distinct account identities, falling back to pseudonymous installations, with
at least one accepted keyboard usage summary in the selected UTC-date window.
### Activation-to-input conversion
Keyboard input active users divided by distinct identities with either a
`KEYBOARD_ACTIVATED` event or an accepted keyboard usage summary in the same
UTC-date window. Including summary-only identities prevents missing activation
telemetry from producing rates above 100%.
### Chinese, English and bilingual active users
- Chinese active: at least one committed Han-script character.
- English active: at least one committed Latin letter.
- Bilingual active: both Chinese and English counts are non-zero.
These populations overlap and must not be summed.
### Character volume and language share
Character volume is the sum of client-classified Chinese, English and other
committed characters. Chinese and English share use only classified language
characters as the denominator:
- Chinese share: `Chinese / (Chinese + English)`.
- English share: `English / (Chinese + English)`.
Both shares are unavailable when the denominator is zero. Other characters
remain visible in total volume but do not dilute the language split.
### Input sessions
An input session is a keyboard activation containing at least one manually
committed character. Chinese-only, English-only, mixed-language and other-only
session counts form a complete partition. Average characters per input session
is `total committed characters / input sessions`.
## Retention
The cohort date is the UTC date of a user's first successful AI feature.
+37 -1
View File
@@ -8,7 +8,7 @@ from billing, provider execution and immutable credit ledgers.
Never send or persist:
- audio or audio-derived content;
- keyboard input, prompts, context, transcripts or model output;
- raw keyboard input, keystrokes, prompts, context, transcripts or model output;
- Apple subjects, email addresses, names, tokens, API keys or provider
credentials;
- arbitrary property names or free-form text.
@@ -18,6 +18,8 @@ the identifier supplied by the client. Event DTO string representations are
redacted. Product analytics rows linked to an account are deleted with that
account. Anonymous installations that never link to an account are retained for
90 days and are eligible for scheduled deletion in a later operational job.
Per-installation keyboard usage summaries are retained for at most 90 days
regardless of account linkage.
## Ingestion API
@@ -40,6 +42,38 @@ account. Anonymous installations that never link to an account are retained for
The successful response reports `accepted` and `replayed` event counts. It does
not return account or installation identifiers.
## Keyboard usage summaries
`POST /v1/analytics/keyboard-usage` accepts 1 to 50 finalized UTC-day
summaries. Authentication, installation hashing, account linking, atomic
batching and replay semantics match the event ingestion API.
- Language classification and counting happen on-device.
- Only text manually committed by OSGKeyboard is counted. Voice transcripts,
pasted text, AI-generated output and text entered with another keyboard are
excluded.
- Chinese counts Unicode characters classified with the Han script. English
counts Latin letters. Digits, punctuation and Emoji are counted as `other`.
- Pinyin composition keystrokes are not counted as English; only the final
committed text is classified.
- An input session is one keyboard activation containing at least one
committed character.
- Chinese-only sessions contain Chinese but no English; English-only sessions
contain English but no Chinese; mixed sessions contain both. Other
characters may occur in any of those sessions. Other-only sessions contain
neither Chinese nor English.
- The four language-session counters must sum to `inputSessionCount`, and the
total committed character count must be at least the session count.
- Each installation may submit only one immutable summary per UTC date.
- Accepted dates are from 35 days ago through yesterday. Current-day partial
summaries are rejected.
- Raw text, per-keystroke events, surrounding context, host application
identifiers and free-form properties are never accepted.
The client should keep counters and the finalized outbox in the shared App
Group so either the containing app or keyboard extension can deliver them.
Retries must reuse the stored `clientSummaryId`.
## Event catalog
### Lifecycle events
@@ -186,6 +220,8 @@ The admin analytics endpoints combine:
- settled credit usage for managed AI counts and credit consumption;
- accounts, StoreKit and referrals for registration, monetization and referral
outcomes.
- daily keyboard summaries for privacy-minimized Chinese and English input
activity, character volume and input sessions.
Metric formulas are defined in
[`ANALYTICS_METRICS_DICTIONARY.md`](ANALYTICS_METRICS_DICTIONARY.md).
+13
View File
@@ -21,6 +21,7 @@ GRANT SELECT ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.
GRANT SELECT ON osg_account.referral_campaigns TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_owner_codes TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
@@ -44,6 +45,11 @@ GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'
GRANT SELECT ON osg_account.product_analytics_installations TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.product_analytics_daily_counters TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.keyboard_usage_daily_summaries TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.official_content_catalog TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.official_skills TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.official_skill_localizations TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.official_hint_packs TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
@@ -53,6 +59,7 @@ GRANT INSERT, UPDATE ON osg_account.credit_accounts TO 'osg_account_runtime'@'10
GRANT INSERT, UPDATE ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%';
GRANT UPDATE ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.referral_owner_codes TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
@@ -81,6 +88,12 @@ GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations
GRANT INSERT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.product_analytics_daily_counters
TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, DELETE ON osg_account.keyboard_usage_daily_summaries
TO 'osg_account_runtime'@'10.20.%';
GRANT UPDATE ON osg_account.official_content_catalog TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.official_skills TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.official_skill_localizations TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.official_hint_packs TO 'osg_account_runtime'@'10.20.%';
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
+512
View File
@@ -100,6 +100,33 @@ paths:
"409": { $ref: "#/components/responses/Error" }
"422": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
/v1/analytics/keyboard-usage:
post:
security:
- {}
- bearerAuth: []
summary: Idempotently accept privacy-minimized daily keyboard usage summaries
description: |
Accepts finalized UTC-day counters for text manually committed by
OSGKeyboard. Language classification happens on-device. Raw text,
keystrokes, surrounding context, host application identifiers, voice
transcripts and AI output are never accepted. The current UTC date is
not accepted because daily summaries are immutable once submitted.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/KeyboardUsageBatchRequest" }
responses:
"200":
description: Atomic batch acceptance and replay counts
content:
application/json:
schema: { $ref: "#/components/schemas/ProductAnalyticsBatchResponse" }
"400": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
"422": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
/v1/account:
get:
summary: Return the account profile
@@ -477,6 +504,100 @@ paths:
responses:
"101": { description: WebSocket upgrade }
default: { $ref: "#/components/responses/GatewayError" }
/v1/content/skills:
get:
security: []
summary: Return the enabled official Skill catalog
parameters:
- name: If-None-Match
in: header
schema: { type: string }
responses:
"200":
description: Versioned official Skill catalog
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string, const: "public,max-age=300" } }
content:
application/json:
schema: { $ref: "#/components/schemas/OfficialSkillCatalog" }
"304": { description: The caller already has the current revision }
/v1/content/hints/manifest:
get:
security: []
summary: Return the published AI Hint pack manifest
parameters:
- name: If-None-Match
in: header
schema: { type: string }
responses:
"200":
description: Published locale manifest
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string, const: "public,max-age=300" } }
content:
application/json:
schema: { $ref: "#/components/schemas/AIHintManifest" }
"304": { description: The caller already has the current manifest }
/v1/content/hints/{locale}:
get:
security: []
summary: Return a published AI Hint pack
parameters:
- $ref: "#/components/parameters/HintLocale"
- name: If-None-Match
in: header
schema: { type: string }
responses:
"200":
description: Published AI Hint pack
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string, const: "public,max-age=300" } }
content:
application/json:
schema: { $ref: "#/components/schemas/AIHintPack" }
"304": { description: The caller already has this pack version }
"404": { description: Locale is unsupported or has not been published }
/hints/manifest.json:
get:
security: []
summary: Return the AI Hint manifest at the legacy file path
parameters:
- name: If-None-Match
in: header
schema: { type: string }
responses:
"200":
description: Same payload and cache validators as /v1/content/hints/manifest
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string, const: "public,max-age=300" } }
content:
application/json:
schema: { $ref: "#/components/schemas/AIHintManifest" }
"304": { description: The caller already has the current manifest }
/hints/hints-{locale}.json:
get:
security: []
summary: Return an AI Hint pack at the legacy file path
parameters:
- $ref: "#/components/parameters/HintLocale"
- name: If-None-Match
in: header
schema: { type: string }
responses:
"200":
description: Same payload and cache validators as /v1/content/hints/{locale}
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string, const: "public,max-age=300" } }
content:
application/json:
schema: { $ref: "#/components/schemas/AIHintPack" }
"304": { description: The caller already has this pack version }
"404": { description: Locale is unsupported or has not been published }
/.well-known/apple-app-site-association:
get:
servers:
@@ -522,6 +643,125 @@ paths:
"200": { description: Bilingual HTML landing page }
"404": { description: Invalid, unknown, or expired invitation }
"503": { description: Invitation lookup is temporarily unavailable }
/v1/admin/content/skills:
get:
security:
- adminMtls: []
adminSession: []
summary: List all official Skills including disabled entries
responses:
"200":
description: Administrative Skill catalog
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOfficialSkillCatalog" }
"403": { description: SUPER_ADMIN or SUPPORT role is required }
post:
security:
- adminMtls: []
adminSession: []
summary: Create a disabled official Skill
parameters:
- $ref: "#/components/parameters/AdminCsrf"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateOfficialSkillRequest" }
responses:
"201":
description: Skill created
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOfficialSkill" }
"400": { description: Request is invalid }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
"409": { description: Skill ID already exists }
/v1/admin/content/skills/{id}:
put:
security:
- adminMtls: []
adminSession: []
summary: Update an official Skill and increment catalog revision
parameters:
- $ref: "#/components/parameters/OfficialSkillId"
- $ref: "#/components/parameters/AdminCsrf"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UpdateOfficialSkillRequest" }
responses:
"200":
description: Skill updated
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOfficialSkill" }
"400": { description: Request is invalid }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
"404": { description: Skill was not found }
/v1/admin/content/skills/{id}/enable:
post:
security:
- adminMtls: []
adminSession: []
summary: Enable an official Skill and increment catalog revision
parameters:
- $ref: "#/components/parameters/OfficialSkillId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Skill enabled }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
"404": { description: Skill was not found }
/v1/admin/content/skills/{id}/disable:
post:
security:
- adminMtls: []
adminSession: []
summary: Disable an official Skill and increment catalog revision
parameters:
- $ref: "#/components/parameters/OfficialSkillId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Skill disabled }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
"404": { description: Skill was not found }
/v1/admin/content/hints/{locale}:
get:
security:
- adminMtls: []
adminSession: []
summary: Return a locale Hint pack for editing
parameters:
- $ref: "#/components/parameters/HintLocale"
responses:
"200":
description: Existing pack or an empty version-zero editor document
content:
application/json:
schema: { $ref: "#/components/schemas/AdminAIHintPack" }
"403": { description: SUPER_ADMIN or SUPPORT role is required }
put:
security:
- adminMtls: []
adminSession: []
summary: Immediately publish a locale Hint pack and increment its version
parameters:
- $ref: "#/components/parameters/HintLocale"
- $ref: "#/components/parameters/AdminCsrf"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UpdateAIHintPackRequest" }
responses:
"200":
description: Published pack
content:
application/json:
schema: { $ref: "#/components/schemas/AdminAIHintPack" }
"400": { description: Pack is invalid }
"403": { description: SUPER_ADMIN role and valid CSRF are required }
/v1/admin/auth/session:
get:
security:
@@ -941,6 +1181,11 @@ paths:
- OPERATOR_CREDENTIALS_RESET
- OPERATOR_SESSIONS_REVOKED
- MANUAL_CREDIT_GRANTED
- CONTENT_SKILL_CREATED
- CONTENT_SKILL_UPDATED
- CONTENT_SKILL_ENABLED
- CONTENT_SKILL_DISABLED
- CONTENT_HINT_PACK_PUBLISHED
- name: result
in: query
schema: { type: string, enum: [success, rejected] }
@@ -982,6 +1227,19 @@ components:
in: header
required: true
schema: { type: string, minLength: 8, maxLength: 128 }
OfficialSkillId:
name: id
in: path
required: true
schema:
type: string
maxLength: 100
pattern: "^official\\.[a-z0-9._-]+$"
HintLocale:
name: locale
in: path
required: true
schema: { type: string, enum: [zh, en] }
AdminCsrf:
name: X-CSRF-Token
in: header
@@ -1125,6 +1383,55 @@ components:
minItems: 1
maxItems: 50
items: { $ref: "#/components/schemas/ProductAnalyticsEvent" }
KeyboardUsageSummary:
type: object
additionalProperties: false
required:
- clientSummaryId
- summaryDate
- chineseCharacterCount
- englishCharacterCount
- otherCharacterCount
- inputSessionCount
- chineseOnlySessionCount
- englishOnlySessionCount
- mixedLanguageSessionCount
- otherOnlySessionCount
properties:
clientSummaryId: { type: string, format: uuid }
summaryDate:
type: string
format: date
description: Finalized UTC date; accepted from 35 days ago through yesterday.
chineseCharacterCount: { type: integer, format: int64, minimum: 0, maximum: 1000000 }
englishCharacterCount: { type: integer, format: int64, minimum: 0, maximum: 1000000 }
otherCharacterCount: { type: integer, format: int64, minimum: 0, maximum: 1000000 }
inputSessionCount: { type: integer, format: int64, minimum: 1, maximum: 100000 }
chineseOnlySessionCount: { type: integer, format: int64, minimum: 0, maximum: 100000 }
englishOnlySessionCount: { type: integer, format: int64, minimum: 0, maximum: 100000 }
mixedLanguageSessionCount: { type: integer, format: int64, minimum: 0, maximum: 100000 }
otherOnlySessionCount: { type: integer, format: int64, minimum: 0, maximum: 100000 }
appVersion:
type: string
minLength: 1
maxLength: 32
pattern: "^[A-Za-z0-9._+-]+$"
osVersion:
type: string
minLength: 1
maxLength: 32
pattern: "^[A-Za-z0-9._+-]+$"
KeyboardUsageBatchRequest:
type: object
additionalProperties: false
required: [installationId, summaries]
properties:
installationId: { type: string, format: uuid }
summaries:
type: array
minItems: 1
maxItems: 50
items: { $ref: "#/components/schemas/KeyboardUsageSummary" }
ProductAnalyticsBatchResponse:
type: object
additionalProperties: false
@@ -1132,6 +1439,172 @@ components:
properties:
accepted: { type: integer, minimum: 0, maximum: 50 }
replayed: { type: integer, minimum: 0, maximum: 50 }
SkillLocalization:
type: object
additionalProperties: false
required: [name, summary, prompt]
properties:
name: { type: string, minLength: 1, maxLength: 40 }
summary: { type: string, minLength: 1, maxLength: 200 }
prompt: { type: string, minLength: 1, maxLength: 6000 }
SkillLocalizations:
type: object
additionalProperties: false
required: [zh-Hans, en]
properties:
zh-Hans: { $ref: "#/components/schemas/SkillLocalization" }
en: { $ref: "#/components/schemas/SkillLocalization" }
OfficialSkill:
type: object
additionalProperties: false
required: [id, systemImage, sortOrder, kind, thinkingEnabled, localizations]
properties:
id:
type: string
maxLength: 100
pattern: "^official\\.[a-z0-9._-]+$"
systemImage: { type: string, minLength: 1, maxLength: 100 }
sortOrder: { type: integer, minimum: 0, maximum: 100000 }
kind: { type: string, const: transform }
thinkingEnabled: { type: boolean }
localizations: { $ref: "#/components/schemas/SkillLocalizations" }
AdminOfficialSkill:
type: object
additionalProperties: false
required: [id, systemImage, sortOrder, kind, thinkingEnabled, enabled, localizations]
properties:
id:
type: string
maxLength: 100
pattern: "^official\\.[a-z0-9._-]+$"
systemImage: { type: string, minLength: 1, maxLength: 100 }
sortOrder: { type: integer, minimum: 0, maximum: 100000 }
kind: { type: string, const: transform }
thinkingEnabled: { type: boolean }
enabled: { type: boolean }
localizations: { $ref: "#/components/schemas/SkillLocalizations" }
OfficialSkillCatalog:
type: object
additionalProperties: false
required: [schemaVersion, revision, skills]
properties:
schemaVersion: { type: integer, const: 1 }
revision: { type: integer, format: int64, minimum: 0 }
generatedAt: { type: string, format: date-time }
skills:
type: array
maxItems: 100
items: { $ref: "#/components/schemas/OfficialSkill" }
AdminOfficialSkillCatalog:
type: object
additionalProperties: false
required: [revision, skills]
properties:
revision: { type: integer, format: int64, minimum: 0 }
generatedAt: { type: string, format: date-time }
skills:
type: array
items: { $ref: "#/components/schemas/AdminOfficialSkill" }
CreateOfficialSkillRequest:
type: object
additionalProperties: false
required: [id, systemImage, sortOrder, thinkingEnabled, localizations]
properties:
id:
type: string
maxLength: 100
pattern: "^official\\.[a-z0-9._-]+$"
systemImage: { type: string, minLength: 1, maxLength: 100 }
sortOrder: { type: integer, minimum: 0, maximum: 100000 }
thinkingEnabled: { type: boolean }
localizations: { $ref: "#/components/schemas/SkillLocalizations" }
UpdateOfficialSkillRequest:
type: object
additionalProperties: false
required: [systemImage, sortOrder, thinkingEnabled, localizations]
properties:
systemImage: { type: string, minLength: 1, maxLength: 100 }
sortOrder: { type: integer, minimum: 0, maximum: 100000 }
thinkingEnabled: { type: boolean }
localizations: { $ref: "#/components/schemas/SkillLocalizations" }
AIHintCard:
type: object
additionalProperties: false
required: [id, prompt, category, priority, source, locale, conditions]
anyOf:
- required: [displayText]
- required: [text]
properties:
id: { type: string, minLength: 1, maxLength: 128 }
displayText: { type: string, minLength: 1, maxLength: 500 }
text: { type: string, minLength: 1, maxLength: 500 }
prompt: { type: string, minLength: 1, maxLength: 16000 }
category: { type: string, minLength: 1, maxLength: 64 }
priority: { type: integer, minimum: -10000, maximum: 10000 }
source: { type: string, minLength: 1, maxLength: 64 }
locale: { type: string, enum: [zh, en] }
conditions:
type: array
maxItems: 20
items: { type: string, minLength: 1, maxLength: 64 }
metadata:
type: object
additionalProperties: true
AIHintPack:
type: object
additionalProperties: false
required: [locale, version, cards]
properties:
locale: { type: string, enum: [zh, en] }
generatedAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
version: { type: integer, minimum: 1 }
cards:
type: array
maxItems: 500
items: { $ref: "#/components/schemas/AIHintCard" }
AdminAIHintPack:
type: object
additionalProperties: false
required: [locale, version, cards]
properties:
locale: { type: string, enum: [zh, en] }
generatedAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
intervalHours: { type: integer, minimum: 1, maximum: 168 }
version: { type: integer, minimum: 0 }
cards:
type: array
maxItems: 500
items: { $ref: "#/components/schemas/AIHintCard" }
UpdateAIHintPackRequest:
type: object
additionalProperties: false
required: [cards]
properties:
generatedAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
intervalHours: { type: integer, minimum: 1, maximum: 168 }
cards:
type: array
maxItems: 500
items: { $ref: "#/components/schemas/AIHintCard" }
AIHintManifest:
type: object
additionalProperties: false
required: [locales, files]
properties:
generatedAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
intervalHours: { type: integer, minimum: 1, maximum: 168 }
locales:
type: array
uniqueItems: true
items: { type: string, enum: [zh, en] }
files:
type: object
additionalProperties:
type: [string, "null"]
AdminSessionState:
type: object
additionalProperties: false
@@ -1286,6 +1759,42 @@ components:
executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] }
users: { type: integer, format: int64, minimum: 0 }
successes: { type: integer, format: int64, minimum: 0 }
AdminAnalyticsKeyboardUsage:
type: object
additionalProperties: false
required:
- activeUsers
- activationToInput
- chineseActiveUsers
- englishActiveUsers
- bilingualActiveUsers
- totalCharacters
- chineseCharacters
- englishCharacters
- otherCharacters
- inputSessions
- chineseOnlySessions
- englishOnlySessions
- mixedLanguageSessions
- otherOnlySessions
properties:
activeUsers: { type: integer, format: int64, minimum: 0 }
activationToInput: { $ref: "#/components/schemas/AdminAnalyticsRate" }
chineseActiveUsers: { type: integer, format: int64, minimum: 0 }
englishActiveUsers: { type: integer, format: int64, minimum: 0 }
bilingualActiveUsers: { type: integer, format: int64, minimum: 0 }
totalCharacters: { type: integer, format: int64, minimum: 0 }
chineseCharacters: { type: integer, format: int64, minimum: 0 }
englishCharacters: { type: integer, format: int64, minimum: 0 }
otherCharacters: { type: integer, format: int64, minimum: 0 }
chineseSharePercent: { type: ["number", "null"], minimum: 0, maximum: 100 }
englishSharePercent: { type: ["number", "null"], minimum: 0, maximum: 100 }
inputSessions: { type: integer, format: int64, minimum: 0 }
averageCharactersPerInputSession: { type: ["number", "null"], minimum: 0 }
chineseOnlySessions: { type: integer, format: int64, minimum: 0 }
englishOnlySessions: { type: integer, format: int64, minimum: 0 }
mixedLanguageSessions: { type: integer, format: int64, minimum: 0 }
otherOnlySessions: { type: integer, format: int64, minimum: 0 }
AdminProductAnalytics:
type: object
additionalProperties: false
@@ -1299,6 +1808,7 @@ components:
- growthFunnel
- retention
- aiFeatures
- keyboardUsage
- referralFunnel
- guardrails
properties:
@@ -1370,6 +1880,8 @@ components:
aiFeatures:
type: array
items: { $ref: "#/components/schemas/AdminAnalyticsFeatureUsage" }
keyboardUsage:
$ref: "#/components/schemas/AdminAnalyticsKeyboardUsage"
referralFunnel:
type: array
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
@@ -68,6 +68,11 @@ import com.osglab.account.features.credits.routes.creditRoutes
import com.osglab.account.features.credits.services.CreditOperations
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.content.repositories.ContentRepository
import com.osglab.account.features.content.repositories.ExposedContentRepository
import com.osglab.account.features.content.routes.contentRoutes
import com.osglab.account.features.content.services.ContentService
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
@@ -328,6 +333,7 @@ fun Application.module() {
analyticsRoutes(koin.get())
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
integrityRoutes(koin.get())
contentRoutes(koin.get())
}
if (appConfig.admin.enabled) {
adminWebRoutes(appConfig)
@@ -342,6 +348,7 @@ fun Application.module() {
grantService = koin.get(),
operatorService = koin.get(),
auditService = koin.get(),
contentService = koin.get(),
)
}
}
@@ -417,6 +424,8 @@ fun accountServerModule(config: AppConfig): Module = module {
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
single { AdminUsersService(get()) }
single { AdminGrantService(get()) }
single<ContentRepository> { ExposedContentRepository(get()) }
single { ContentService(get()) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
@@ -486,12 +495,18 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = "internal:signup-trial:$accountId",
)
val creditService = get<CreditService>()
object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
creditService.grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = signupTrialIdempotencyKey(accountId),
)
}
override suspend fun wasGranted(accountId: UUID): Boolean =
creditService.hasSignupTrial(accountId)
}
}
single {
@@ -510,8 +525,8 @@ fun accountServerModule(config: AppConfig): Module = module {
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (trial.shouldRestrictAccount) {
get<AuthRepository>().restrictAccountForAntiAbuse(
accountId,
java.time.Instant.now(),
@@ -111,6 +111,11 @@ enum class AdminAuditAction {
OPERATOR_CREDENTIALS_RESET,
OPERATOR_SESSIONS_REVOKED,
MANUAL_CREDIT_GRANTED,
CONTENT_SKILL_CREATED,
CONTENT_SKILL_UPDATED,
CONTENT_SKILL_ENABLED,
CONTENT_SKILL_DISABLED,
CONTENT_HINT_PACK_PUBLISHED,
}
enum class AdminAuditOutcome {
@@ -0,0 +1,144 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
import com.osglab.account.features.content.services.ContentErrorCode
import com.osglab.account.features.content.services.ContentException
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.request.header
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
internal fun Route.adminContentRoutes(
config: AppConfig,
sessions: AdminSessionService,
service: ContentService,
) {
route("/content") {
get("/skills") {
if (call.requireContentReader(config, sessions) == null) return@get
call.respond(service.adminSkills())
}
post("/skills") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val request = call.receiveContentRequest<CreateOfficialSkillRequest>() ?: return@post
call.respondContentError {
call.respond(
HttpStatusCode.Created,
service.createSkill(principal, request, call.request.header("X-Request-ID")),
)
}
}
put("/skills/{id}") {
val principal = call.requireContentEditor(config, sessions) ?: return@put
val id = call.parameters["id"] ?: return@put call.respondContentValidationError()
val request = call.receiveContentRequest<UpdateOfficialSkillRequest>() ?: return@put
call.respondContentError {
call.respond(
service.updateSkill(principal, id, request, call.request.header("X-Request-ID")),
)
}
}
post("/skills/{id}/enable") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
call.respondContentError {
service.setSkillEnabled(principal, id, enabled = true, call.request.header("X-Request-ID"))
call.respond(HttpStatusCode.NoContent)
}
}
post("/skills/{id}/disable") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
call.respondContentError {
service.setSkillEnabled(principal, id, enabled = false, call.request.header("X-Request-ID"))
call.respond(HttpStatusCode.NoContent)
}
}
get("/hints/{locale}") {
if (call.requireContentReader(config, sessions) == null) return@get
val locale = call.parameters["locale"] ?: return@get call.respondContentValidationError()
call.respondContentError {
call.respond(service.adminHintPack(locale))
}
}
put("/hints/{locale}") {
val principal = call.requireContentEditor(config, sessions) ?: return@put
val locale = call.parameters["locale"] ?: return@put call.respondContentValidationError()
val request = call.receiveContentRequest<UpdateHintPackRequest>() ?: return@put
call.respondContentError {
call.respond(
service.putHintPack(principal, locale, request, call.request.header("X-Request-ID")),
)
}
}
}
}
private suspend fun ApplicationCall.requireContentReader(
config: AppConfig,
sessions: AdminSessionService,
) = requireRole(config, sessions, setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT))
private suspend fun ApplicationCall.requireContentEditor(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
val principal = requireMutationPrincipal(config, sessions) ?: return null
if (principal.role != AdminRole.SUPER_ADMIN) {
respond(HttpStatusCode.Forbidden, ContentAdminErrorResponse("INSUFFICIENT_PERMISSION"))
return null
}
return principal
}
private suspend inline fun <reified T : Any> ApplicationCall.receiveContentRequest(): T? =
try {
receive<T>()
} catch (_: BadRequestException) {
respondContentValidationError()
null
}
private suspend fun ApplicationCall.respondContentError(block: suspend () -> Unit) {
try {
block()
} catch (exception: ContentException) {
val status = when (exception.code) {
ContentErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
ContentErrorCode.CONTENT_SKILL_NOT_FOUND,
ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND,
-> HttpStatusCode.NotFound
ContentErrorCode.CONTENT_SKILL_CONFLICT -> HttpStatusCode.Conflict
}
respond(status, ContentAdminErrorResponse(exception.code.name))
}
}
private suspend fun ApplicationCall.respondContentValidationError() {
respond(HttpStatusCode.BadRequest, ContentAdminErrorResponse(ContentErrorCode.VALIDATION_ERROR.name))
}
@Serializable
private data class ContentAdminErrorResponse(val code: String)
@@ -45,6 +45,7 @@ import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -91,6 +92,7 @@ fun Route.adminApiRoutes(
grantService: AdminGrantService,
operatorService: AdminOperatorService,
auditService: AdminAuditService,
contentService: ContentService? = null,
clock: Clock = Clock.systemUTC(),
) {
route("/v1/admin") {
@@ -166,6 +168,8 @@ fun Route.adminApiRoutes(
}
}
contentService?.let { adminContentRoutes(config, sessionService, it) }
get("/overview") {
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
@@ -814,7 +818,7 @@ private suspend fun ApplicationCall.requirePrincipal(
return principal
}
private suspend fun ApplicationCall.requireRole(
internal suspend fun ApplicationCall.requireRole(
config: AppConfig,
sessions: AdminSessionService,
allowedRoles: Set<AdminRole>,
@@ -827,7 +831,7 @@ private suspend fun ApplicationCall.requireRole(
return principal
}
private suspend fun ApplicationCall.requireMutationPrincipal(
internal suspend fun ApplicationCall.requireMutationPrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
@@ -949,7 +953,7 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
activeUsers = overview.activeUsers,
newUsers = overview.registrations,
totalCreditBalance = overview.totalCreditBalance,
creditsGranted = overview.issuedCredits,
creditsGranted = overview.grantedCredits,
creditsUsed = overview.consumedCredits,
trend = registrationTrend.map {
AdminTrendResponse(
@@ -97,6 +97,27 @@ data class AdminAnalyticsGuardrailsDto(
val creditBlockedUsers: Long,
)
@Serializable
data class AdminAnalyticsKeyboardUsageDto(
val activeUsers: Long,
val activationToInput: AdminAnalyticsRateDto,
val chineseActiveUsers: Long,
val englishActiveUsers: Long,
val bilingualActiveUsers: Long,
val totalCharacters: Long,
val chineseCharacters: Long,
val englishCharacters: Long,
val otherCharacters: Long,
val chineseSharePercent: Double?,
val englishSharePercent: Double?,
val inputSessions: Long,
val averageCharactersPerInputSession: Double?,
val chineseOnlySessions: Long,
val englishOnlySessions: Long,
val mixedLanguageSessions: Long,
val otherOnlySessions: Long,
)
@Serializable
data class AdminProductAnalyticsDto(
val period: AdminAnalyticsPeriodDto,
@@ -108,6 +129,7 @@ data class AdminProductAnalyticsDto(
val growthFunnel: List<AdminAnalyticsFunnelStepDto>,
val retention: List<AdminAnalyticsCohortDto>,
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
val keyboardUsage: AdminAnalyticsKeyboardUsageDto,
val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
val guardrails: AdminAnalyticsGuardrailsDto,
)
@@ -14,7 +14,7 @@ data class AdminOverviewDto(
val registrations: Long,
val activeUsers: Long,
val totalCreditBalance: Long,
val issuedCredits: Long,
val grantedCredits: Long,
val consumedCredits: Long,
)
@@ -27,7 +27,7 @@ data class AdminRegistrationPointDto(
@Serializable
data class AdminCreditFlowPointDto(
val date: String,
val issuedCredits: Long,
val grantedCredits: Long,
val consumedCredits: Long,
)
@@ -74,6 +74,23 @@ data class AdminAnalyticsGuardrailRow(
val creditBlockedUsers: Long,
)
data class AdminAnalyticsKeyboardUsageRow(
val activeUsers: Long,
val keyboardUsers: Long,
val chineseActiveUsers: Long,
val englishActiveUsers: Long,
val bilingualActiveUsers: Long,
val totalCharacters: Long,
val chineseCharacters: Long,
val englishCharacters: Long,
val otherCharacters: Long,
val inputSessions: Long,
val chineseOnlySessions: Long,
val englishOnlySessions: Long,
val mixedLanguageSessions: Long,
val otherOnlySessions: Long,
)
data class AdminAnalyticsGrowthFunnelRow(
val opened: Long,
val registered: Long,
@@ -100,6 +117,7 @@ data class AdminProductAnalyticsSnapshot(
val growthFunnel: AdminAnalyticsGrowthFunnelRow,
val retention: List<AdminAnalyticsCohortRow>,
val features: List<AdminAnalyticsFeatureRow>,
val keyboardUsage: AdminAnalyticsKeyboardUsageRow,
val referrals: AdminAnalyticsReferralRow,
val guardrails: AdminAnalyticsGuardrailRow,
)
@@ -145,6 +163,7 @@ class ExposedAdminProductAnalyticsRepository(
growthFunnel = loadGrowthFunnel(range),
retention = loadRetention(range),
features = loadFeatures(range),
keyboardUsage = loadKeyboardUsage(range),
referrals = loadReferrals(range),
guardrails = loadGuardrails(range),
)
@@ -626,6 +645,96 @@ class ExposedAdminProductAnalyticsRepository(
)
}
private fun loadKeyboardUsage(range: AdminAnalyticsWindow): AdminAnalyticsKeyboardUsageRow =
querySingle(
"""
WITH identity_usage AS (
SELECT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', s.installation_hash)
) AS identity_key,
SUM(s.chinese_character_count) AS chinese_characters,
SUM(s.english_character_count) AS english_characters,
SUM(s.other_character_count) AS other_characters,
SUM(s.input_session_count) AS input_sessions,
SUM(s.chinese_only_session_count) AS chinese_only_sessions,
SUM(s.english_only_session_count) AS english_only_sessions,
SUM(s.mixed_language_session_count) AS mixed_language_sessions,
SUM(s.other_only_session_count) AS other_only_sessions
FROM keyboard_usage_daily_summaries s
JOIN product_analytics_installations i
ON i.installation_hash = s.installation_hash
WHERE s.summary_date >= DATE(?) AND s.summary_date < DATE(?)
GROUP BY identity_key
),
activated AS (
SELECT DISTINCT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', e.installation_hash)
) AS identity_key
FROM product_analytics_events e
JOIN product_analytics_installations i
ON i.installation_hash = e.installation_hash
WHERE e.event_name = 'KEYBOARD_ACTIVATED'
AND e.occurred_at >= DATE(?) AND e.occurred_at < DATE(?)
),
keyboard_population AS (
SELECT identity_key FROM identity_usage
UNION
SELECT identity_key FROM activated
)
SELECT
(SELECT COUNT(*) FROM identity_usage) AS active_users,
(SELECT COUNT(*) FROM keyboard_population) AS keyboard_users,
COALESCE(SUM(CASE WHEN chinese_characters > 0 THEN 1 ELSE 0 END), 0)
AS chinese_active_users,
COALESCE(SUM(CASE WHEN english_characters > 0 THEN 1 ELSE 0 END), 0)
AS english_active_users,
COALESCE(
SUM(
CASE
WHEN chinese_characters > 0 AND english_characters > 0
THEN 1 ELSE 0
END
),
0
) AS bilingual_active_users,
COALESCE(
SUM(chinese_characters + english_characters + other_characters),
0
) AS total_characters,
COALESCE(SUM(chinese_characters), 0) AS chinese_characters,
COALESCE(SUM(english_characters), 0) AS english_characters,
COALESCE(SUM(other_characters), 0) AS other_characters,
COALESCE(SUM(input_sessions), 0) AS input_sessions,
COALESCE(SUM(chinese_only_sessions), 0) AS chinese_only_sessions,
COALESCE(SUM(english_only_sessions), 0) AS english_only_sessions,
COALESCE(SUM(mixed_language_sessions), 0) AS mixed_language_sessions,
COALESCE(SUM(other_only_sessions), 0) AS other_only_sessions
FROM identity_usage
""",
range.arguments(repetitions = 2),
) {
AdminAnalyticsKeyboardUsageRow(
activeUsers = it.exactLong("active_users"),
keyboardUsers = it.exactLong("keyboard_users"),
chineseActiveUsers = it.exactLong("chinese_active_users"),
englishActiveUsers = it.exactLong("english_active_users"),
bilingualActiveUsers = it.exactLong("bilingual_active_users"),
totalCharacters = it.exactLong("total_characters"),
chineseCharacters = it.exactLong("chinese_characters"),
englishCharacters = it.exactLong("english_characters"),
otherCharacters = it.exactLong("other_characters"),
inputSessions = it.exactLong("input_sessions"),
chineseOnlySessions = it.exactLong("chinese_only_sessions"),
englishOnlySessions = it.exactLong("english_only_sessions"),
mixedLanguageSessions = it.exactLong("mixed_language_sessions"),
otherOnlySessions = it.exactLong("other_only_sessions"),
)
}
private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow =
querySingle(
valueEventsCte() +
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import org.jetbrains.exposed.v1.core.IColumnType
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
@@ -25,7 +26,7 @@ data class AdminStatsRange(
data class AdminStatsSnapshot(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val grantedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
@@ -39,7 +40,7 @@ fun interface AdminStatsRepository {
internal data class AdminStatsAggregates(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val grantedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
@@ -73,17 +74,14 @@ class ExposedAdminStatsRepository(
""",
range,
),
issuedCreditsByDate = loadDailyAggregates(
grantedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(amount_delta), 0) AS aggregate_value
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
GROUP BY DATE(created_at)
""",
range,
@@ -128,11 +126,8 @@ class ExposedAdminStatsRepository(
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
) AS issued_credits,
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
) AS granted_credits,
(
SELECT COALESCE(SUM(charged_credits), 0)
FROM credit_usage_records
@@ -146,7 +141,7 @@ class ExposedAdminStatsRepository(
registrations = result.exactLong("registrations"),
activeUsers = result.exactLong("active_users"),
totalCreditBalance = result.exactLong("total_credit_balance"),
issuedCredits = result.exactLong("issued_credits"),
grantedCredits = result.exactLong("granted_credits"),
consumedCredits = result.exactLong("consumed_credits"),
)
}
@@ -280,7 +275,7 @@ internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSna
AdminStatsSnapshot(
overview = aggregates.overview,
registrationsByDate = aggregates.registrationsByDate,
issuedCreditsByDate = aggregates.issuedCreditsByDate,
grantedCreditsByDate = aggregates.grantedCreditsByDate,
consumedCreditsByDate = aggregates.consumedCreditsByDate,
referralFunnel = aggregates.referralFunnel,
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
@@ -332,3 +327,14 @@ private fun ResultSet.exactLong(column: String): Long =
internal fun BigDecimal.toExactLong(): Long = longValueExact()
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
internal val GRANTED_CREDIT_ENTRY_TYPES: Set<LedgerEntryType> = setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.SUBSCRIPTION_GRANT,
)
private val GRANTED_CREDIT_ENTRY_TYPES_SQL =
GRANTED_CREDIT_ENTRY_TYPES.joinToString(", ") { "'${it.name}'" }
@@ -8,6 +8,7 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFeatureUsage
import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsNorthStarDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsPeriodDto
@@ -42,6 +43,8 @@ class AdminProductAnalyticsService(
)
val referrals = snapshot.referrals
val growth = snapshot.growthFunnel
val keyboard = snapshot.keyboardUsage
val classifiedKeyboardCharacters = keyboard.chineseCharacters + keyboard.englishCharacters
return AdminProductAnalyticsDto(
period = AdminAnalyticsPeriodDto(from.toString(), until.toString()),
northStar = AdminAnalyticsNorthStarDto(
@@ -126,6 +129,37 @@ class AdminProductAnalyticsService(
successes = it.successes,
)
},
keyboardUsage = AdminAnalyticsKeyboardUsageDto(
activeUsers = keyboard.activeUsers,
activationToInput = AdminAnalyticsCountRow(
keyboard.activeUsers,
keyboard.keyboardUsers,
).toRate(),
chineseActiveUsers = keyboard.chineseActiveUsers,
englishActiveUsers = keyboard.englishActiveUsers,
bilingualActiveUsers = keyboard.bilingualActiveUsers,
totalCharacters = keyboard.totalCharacters,
chineseCharacters = keyboard.chineseCharacters,
englishCharacters = keyboard.englishCharacters,
otherCharacters = keyboard.otherCharacters,
chineseSharePercent = percentage(
keyboard.chineseCharacters,
classifiedKeyboardCharacters,
),
englishSharePercent = percentage(
keyboard.englishCharacters,
classifiedKeyboardCharacters,
),
inputSessions = keyboard.inputSessions,
averageCharactersPerInputSession = ratio(
keyboard.totalCharacters,
keyboard.inputSessions,
),
chineseOnlySessions = keyboard.chineseOnlySessions,
englishOnlySessions = keyboard.englishOnlySessions,
mixedLanguageSessions = keyboard.mixedLanguageSessions,
otherOnlySessions = keyboard.otherOnlySessions,
),
referralFunnel = listOf(
AdminAnalyticsFunnelStepDto("发起分享", referrals.shared),
AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened),
@@ -45,7 +45,7 @@ class AdminStatsService(
creditFlow = dates.map { date ->
AdminCreditFlowPointDto(
date = date.toString(),
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
grantedCredits = snapshot.grantedCreditsByDate[date] ?: 0,
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
)
},
@@ -3,6 +3,7 @@ package com.osglab.account.features.analytics.domain
import com.osglab.account.common.errors.ApiException
import io.ktor.http.HttpStatusCode
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
import kotlinx.serialization.Serializable
@@ -105,9 +106,44 @@ data class AnalyticsIngestResult(
val replayed: Int,
)
data class KeyboardUsageSummary(
val clientSummaryId: UUID,
val summaryDate: LocalDate,
val chineseCharacterCount: Long,
val englishCharacterCount: Long,
val otherCharacterCount: Long,
val inputSessionCount: Long,
val chineseOnlySessionCount: Long,
val englishOnlySessionCount: Long,
val mixedLanguageSessionCount: Long,
val otherOnlySessionCount: Long,
val appVersion: String?,
val osVersion: String?,
val payloadHash: String,
) {
override fun toString(): String = "KeyboardUsageSummary([REDACTED])"
}
data class KeyboardUsageBatch(
val installationHash: String,
val accountId: UUID?,
val summaries: List<KeyboardUsageSummary>,
val receivedAt: Instant,
) {
override fun toString(): String =
"KeyboardUsageBatch(installationHash=[REDACTED], accountId=[REDACTED], summaries=${summaries.size})"
}
class AnalyticsEventTimeException :
ApiException(
status = HttpStatusCode.UnprocessableEntity,
code = "event_time_invalid",
message = "An event timestamp is outside the accepted range",
)
class KeyboardUsageDateException :
ApiException(
status = HttpStatusCode.UnprocessableEntity,
code = "summary_date_invalid",
message = "A keyboard usage summary date is outside the accepted range",
)
@@ -36,6 +36,33 @@ data class AnalyticsEventRequest(
override fun toString(): String = "AnalyticsEventRequest([REDACTED])"
}
@Serializable
data class KeyboardUsageBatchRequest(
val installationId: String,
val summaries: List<KeyboardUsageSummaryRequest>,
) {
override fun toString(): String =
"KeyboardUsageBatchRequest(installationId=[REDACTED], summaries=[REDACTED size=${summaries.size}])"
}
@Serializable
data class KeyboardUsageSummaryRequest(
val clientSummaryId: String,
val summaryDate: String,
val chineseCharacterCount: Long,
val englishCharacterCount: Long,
val otherCharacterCount: Long,
val inputSessionCount: Long,
val chineseOnlySessionCount: Long,
val englishOnlySessionCount: Long,
val mixedLanguageSessionCount: Long,
val otherOnlySessionCount: Long,
val appVersion: String? = null,
val osVersion: String? = null,
) {
override fun toString(): String = "KeyboardUsageSummaryRequest([REDACTED])"
}
@Serializable
data class AnalyticsIngestResponse(
val accepted: Int,
@@ -12,12 +12,17 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
import kotlinx.coroutines.delay
import org.jetbrains.exposed.v1.exceptions.ExposedSQLException
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.core.plus
import org.jetbrains.exposed.v1.javatime.date
import org.jetbrains.exposed.v1.javatime.timestamp
@@ -27,17 +32,37 @@ import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.sql.SQLException
interface AnalyticsRepository {
suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult
suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult
suspend fun recordInvitePageOpen(occurredAt: Instant)
suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int
suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int
}
class ExposedAnalyticsRepository(
private val databaseFactory: DatabaseFactory,
) : AnalyticsRepository {
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
require(limit in 1..10_000)
return databaseFactory.query {
val ids = KeyboardUsageSummaries
.selectAll()
.where { KeyboardUsageSummaries.summaryDate less before }
.limit(limit)
.map { it[KeyboardUsageSummaries.id] }
if (ids.isEmpty()) {
0
} else {
KeyboardUsageSummaries.deleteWhere { KeyboardUsageSummaries.id inList ids }
}
}
}
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
require(limit in 1..10_000)
return databaseFactory.query {
@@ -79,36 +104,8 @@ class ExposedAnalyticsRepository(
}
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult =
databaseFactory.query {
AnalyticsInstallations.insertIgnore {
it[installationHash] = batch.installationHash
it[accountId] = batch.accountId?.toString()
it[createdAt] = batch.receivedAt
it[updatedAt] = batch.receivedAt
}
val installation = AnalyticsInstallations
.selectAll()
.where { AnalyticsInstallations.installationHash eq batch.installationHash }
.forUpdate()
.single()
val linkedAccount = installation[AnalyticsInstallations.accountId]
when {
batch.accountId == null -> Unit
linkedAccount == null -> AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq batch.installationHash
}) {
it[accountId] = batch.accountId.toString()
it[updatedAt] = batch.receivedAt
}
linkedAccount != batch.accountId.toString() ->
throw ConflictException("Installation is linked to another account")
}
AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq batch.installationHash
}) {
it[updatedAt] = batch.receivedAt
}
ingestTransaction {
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
var accepted = 0
var replayed = 0
@@ -133,6 +130,83 @@ class ExposedAnalyticsRepository(
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
}
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult =
ingestTransaction {
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
var accepted = 0
var replayed = 0
batch.summaries.forEach { summary ->
val existingPayloadHash = KeyboardUsageSummaries
.selectAll()
.where {
(KeyboardUsageSummaries.installationHash eq batch.installationHash) and (
(KeyboardUsageSummaries.clientSummaryId eq summary.clientSummaryId.toString()) or
(KeyboardUsageSummaries.summaryDate eq summary.summaryDate)
)
}
.singleOrNull()
?.get(KeyboardUsageSummaries.payloadHash)
when {
existingPayloadHash == null -> {
insertKeyboardUsageSummary(batch, summary)
accepted += 1
}
existingPayloadHash == summary.payloadHash -> replayed += 1
else -> throw ConflictException("Keyboard usage summary conflicts with an existing date or ID")
}
}
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
}
private suspend fun <T> ingestTransaction(block: suspend () -> T): T {
repeat(MAX_INGEST_ATTEMPTS) { attempt ->
try {
return databaseFactory.query(block)
} catch (exception: ExposedSQLException) {
if (!exception.isDeadlock() || attempt == MAX_INGEST_ATTEMPTS - 1) throw exception
delay(DEADLOCK_RETRY_DELAY_MILLIS * (attempt + 1))
}
}
error("Unreachable analytics transaction retry state")
}
private fun linkInstallation(
installationHash: String,
accountId: String?,
receivedAt: Instant,
) {
AnalyticsInstallations.insertIgnore {
it[AnalyticsInstallations.installationHash] = installationHash
it[AnalyticsInstallations.accountId] = accountId
it[createdAt] = receivedAt
it[updatedAt] = receivedAt
}
val installation = AnalyticsInstallations
.selectAll()
.where { AnalyticsInstallations.installationHash eq installationHash }
.forUpdate()
.single()
val linkedAccount = installation[AnalyticsInstallations.accountId]
when {
accountId == null -> Unit
linkedAccount == null -> AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq installationHash
}) {
it[AnalyticsInstallations.accountId] = accountId
it[updatedAt] = receivedAt
}
linkedAccount != accountId ->
throw ConflictException("Installation is linked to another account")
}
AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq installationHash
}) {
it[updatedAt] = receivedAt
}
}
private fun insertEvent(batch: AnalyticsBatch, event: AnalyticsEvent) {
AnalyticsEvents.insert {
it[installationHash] = batch.installationHash
@@ -151,6 +225,29 @@ class ExposedAnalyticsRepository(
it[receivedAt] = batch.receivedAt
}
}
private fun insertKeyboardUsageSummary(
batch: KeyboardUsageBatch,
summary: KeyboardUsageSummary,
) {
KeyboardUsageSummaries.insert {
it[installationHash] = batch.installationHash
it[clientSummaryId] = summary.clientSummaryId.toString()
it[summaryDate] = summary.summaryDate
it[chineseCharacterCount] = summary.chineseCharacterCount
it[englishCharacterCount] = summary.englishCharacterCount
it[otherCharacterCount] = summary.otherCharacterCount
it[inputSessionCount] = summary.inputSessionCount
it[chineseOnlySessionCount] = summary.chineseOnlySessionCount
it[englishOnlySessionCount] = summary.englishOnlySessionCount
it[mixedLanguageSessionCount] = summary.mixedLanguageSessionCount
it[otherOnlySessionCount] = summary.otherOnlySessionCount
it[appVersion] = summary.appVersion
it[osVersion] = summary.osVersion
it[payloadHash] = summary.payloadHash
it[receivedAt] = batch.receivedAt
}
}
}
private object AnalyticsInstallations : Table("product_analytics_installations") {
@@ -185,6 +282,27 @@ private object AnalyticsEvents : Table("product_analytics_events") {
override val primaryKey = PrimaryKey(installationHash, clientEventId)
}
private object KeyboardUsageSummaries : Table("keyboard_usage_daily_summaries") {
val id = long("id").autoIncrement()
val installationHash = char("installation_hash", 64)
val clientSummaryId = char("client_summary_id", 36)
val summaryDate = date("summary_date")
val chineseCharacterCount = long("chinese_character_count")
val englishCharacterCount = long("english_character_count")
val otherCharacterCount = long("other_character_count")
val inputSessionCount = long("input_session_count")
val chineseOnlySessionCount = long("chinese_only_session_count")
val englishOnlySessionCount = long("english_only_session_count")
val mixedLanguageSessionCount = long("mixed_language_session_count")
val otherOnlySessionCount = long("other_only_session_count")
val appVersion = varchar("app_version", 32).nullable()
val osVersion = varchar("os_version", 32).nullable()
val payloadHash = char("payload_hash", 64)
val receivedAt = timestamp("received_at")
override val primaryKey = PrimaryKey(id)
}
private object AnalyticsDailyCounters : Table("product_analytics_daily_counters") {
val counterDate = date("counter_date")
val counterName = varchar("counter_name", 32)
@@ -195,3 +313,12 @@ private object AnalyticsDailyCounters : Table("product_analytics_daily_counters"
}
private const val INVITE_PAGE_OPENED = "INVITE_PAGE_OPENED"
private const val MAX_INGEST_ATTEMPTS = 4
private const val DEADLOCK_RETRY_DELAY_MILLIS = 10L
private fun ExposedSQLException.isDeadlock(): Boolean =
generateSequence<Throwable>(this) { it.cause }
.filterIsInstance<SQLException>()
.any { it.sqlState == "40001" || it.errorCode == MYSQL_DEADLOCK_ERROR_CODE }
private const val MYSQL_DEADLOCK_ERROR_CODE = 1213
@@ -5,9 +5,11 @@ import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsIngestResponse
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.services.AnalyticsService
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.header
@@ -21,25 +23,37 @@ import kotlinx.serialization.json.Json
fun Route.analyticsRoutes(service: AnalyticsService) {
authenticate(SESSION_AUTH_NAME, optional = true) {
post("/v1/analytics/events") {
val declaredLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val body = call.receiveText()
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val request = try {
ANALYTICS_JSON.decodeFromString<AnalyticsBatchRequest>(body)
} catch (_: SerializationException) {
throw InvalidRequestException("Analytics request body is invalid")
}
val request = call.receiveAnalyticsBody<AnalyticsBatchRequest>()
val result = service.ingest(
accountId = call.principal<AccountPrincipal>()?.userId,
request = request,
)
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
}
post("/v1/analytics/keyboard-usage") {
val request = call.receiveAnalyticsBody<KeyboardUsageBatchRequest>()
val result = service.ingestKeyboardUsage(
accountId = call.principal<AccountPrincipal>()?.userId,
request = request,
)
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
}
}
}
private suspend inline fun <reified T> ApplicationCall.receiveAnalyticsBody(): T {
val declaredLength = request.header(HttpHeaders.ContentLength)?.toLongOrNull()
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val body = receiveText()
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
return try {
ANALYTICS_JSON.decodeFromString<T>(body)
} catch (_: SerializationException) {
throw InvalidRequestException("Analytics request body is invalid")
}
}
@@ -9,13 +9,20 @@ import com.osglab.account.features.analytics.domain.AnalyticsEventType
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
import java.util.UUID
@@ -24,6 +31,11 @@ interface AnalyticsService {
accountId: UUID?,
request: AnalyticsBatchRequest,
): AnalyticsIngestResult
suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult
}
class DefaultAnalyticsService(
@@ -50,6 +62,89 @@ class DefaultAnalyticsService(
)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult {
if (request.summaries.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) {
throw InvalidRequestException("summaries must contain between 1 and 50 items")
}
val installationId = parseUuid(request.installationId, "installationId")
val now = clock.instant()
val today = now.atZone(ZoneOffset.UTC).toLocalDate()
val summaries = request.summaries.map { validateAndMap(it, today) }
if (summaries.map(KeyboardUsageSummary::summaryDate).toSet().size != summaries.size) {
throw InvalidRequestException("summaries must contain at most one item per UTC date")
}
return repository.ingestKeyboardUsage(
KeyboardUsageBatch(
installationHash = installationId.toString().sha256Hex(),
accountId = accountId,
summaries = summaries,
receivedAt = now,
)
)
}
private fun validateAndMap(
request: KeyboardUsageSummaryRequest,
today: LocalDate,
): KeyboardUsageSummary {
val clientSummaryId = parseUuid(request.clientSummaryId, "clientSummaryId")
val summaryDate = parseSummaryDate(request.summaryDate)
if (summaryDate.isBefore(today.minusDays(MAX_SUMMARY_AGE_DAYS)) || !summaryDate.isBefore(today)) {
throw KeyboardUsageDateException()
}
validateReleaseIdentifier(request.appVersion, "appVersion")
validateReleaseIdentifier(request.osVersion, "osVersion")
validateKeyboardUsageCounts(request)
return KeyboardUsageSummary(
clientSummaryId = clientSummaryId,
summaryDate = summaryDate,
chineseCharacterCount = request.chineseCharacterCount,
englishCharacterCount = request.englishCharacterCount,
otherCharacterCount = request.otherCharacterCount,
inputSessionCount = request.inputSessionCount,
chineseOnlySessionCount = request.chineseOnlySessionCount,
englishOnlySessionCount = request.englishOnlySessionCount,
mixedLanguageSessionCount = request.mixedLanguageSessionCount,
otherOnlySessionCount = request.otherOnlySessionCount,
appVersion = request.appVersion,
osVersion = request.osVersion,
payloadHash = keyboardUsagePayloadHash(request, clientSummaryId, summaryDate),
)
}
private fun validateKeyboardUsageCounts(request: KeyboardUsageSummaryRequest) {
val characterCounts = listOf(
request.chineseCharacterCount,
request.englishCharacterCount,
request.otherCharacterCount,
)
val partitionedSessionCounts = listOf(
request.chineseOnlySessionCount,
request.englishOnlySessionCount,
request.mixedLanguageSessionCount,
request.otherOnlySessionCount,
)
if (characterCounts.any { it !in 0..MAX_DAILY_CHARACTER_COUNT }) {
throw InvalidRequestException("character counts must be between 0 and 1000000")
}
if (
request.inputSessionCount !in 1..MAX_DAILY_SESSION_COUNT ||
partitionedSessionCounts.any { it !in 0..MAX_DAILY_SESSION_COUNT }
) {
throw InvalidRequestException("session counts must be between 0 and 100000")
}
val totalCharacters = characterCounts.sum()
if (totalCharacters == 0L || totalCharacters < request.inputSessionCount) {
throw InvalidRequestException("each input session must contain a committed character")
}
if (partitionedSessionCounts.sum() != request.inputSessionCount) {
throw InvalidRequestException("language session counts must equal inputSessionCount")
}
}
private fun validateAndMap(request: AnalyticsEventRequest, now: Instant): AnalyticsEvent {
val clientEventId = parseUuid(request.clientEventId, "clientEventId")
val occurredAt = parseOccurredAt(request.occurredAt)
@@ -175,6 +270,13 @@ class DefaultAnalyticsService(
}
}
private fun parseSummaryDate(value: String): LocalDate =
try {
LocalDate.parse(value)
} catch (_: DateTimeParseException) {
throw InvalidRequestException("summaryDate must be a UTC ISO-8601 date")
}
private fun validateReleaseIdentifier(value: String?, field: String) {
if (value != null && !RELEASE_IDENTIFIER.matches(value)) {
throw InvalidRequestException("$field must be a 1 to 32 character release identifier")
@@ -210,6 +312,25 @@ class DefaultAnalyticsService(
event.osVersion,
).joinToString(separator = "\u0000") { it ?: "" }.sha256Hex()
private fun keyboardUsagePayloadHash(
summary: KeyboardUsageSummaryRequest,
clientSummaryId: UUID,
summaryDate: LocalDate,
): String = listOf(
clientSummaryId.toString(),
summaryDate.toString(),
summary.chineseCharacterCount,
summary.englishCharacterCount,
summary.otherCharacterCount,
summary.inputSessionCount,
summary.chineseOnlySessionCount,
summary.englishOnlySessionCount,
summary.mixedLanguageSessionCount,
summary.otherOnlySessionCount,
summary.appVersion,
summary.osVersion,
).joinToString(separator = "\u0000") { it?.toString() ?: "" }.sha256Hex()
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
@@ -218,6 +339,9 @@ class DefaultAnalyticsService(
private companion object {
const val MIN_BATCH_SIZE = 1
const val MAX_BATCH_SIZE = 50
const val MAX_SUMMARY_AGE_DAYS = 35L
const val MAX_DAILY_CHARACTER_COUNT = 1_000_000L
const val MAX_DAILY_SESSION_COUNT = 100_000L
val MAX_EVENT_AGE: Duration = Duration.ofDays(35)
val MAX_FUTURE_SKEW: Duration = Duration.ofMinutes(5)
val UUID_PATTERN =
@@ -235,11 +359,17 @@ class AnalyticsMaintenanceService(
require(!anonymousRetention.isNegative && !anonymousRetention.isZero)
}
suspend fun purgeStaleAnonymousInstallations(): Int =
repository.purgeAnonymousInstallations(
before = clock.instant().minus(anonymousRetention),
suspend fun purgeStaleAnonymousInstallations(): Int {
val retentionCutoff = clock.instant().minus(anonymousRetention)
repository.purgeKeyboardUsageSummaries(
before = retentionCutoff.atZone(ZoneOffset.UTC).toLocalDate(),
limit = PURGE_BATCH_SIZE,
)
return repository.purgeAnonymousInstallations(
before = retentionCutoff,
limit = PURGE_BATCH_SIZE,
)
}
private companion object {
const val PURGE_BATCH_SIZE = 1_000
@@ -0,0 +1,154 @@
package com.osglab.account.features.content.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import java.time.Instant
@Serializable
data class SkillLocalizationDto(
val name: String,
val summary: String,
val prompt: String,
)
@Serializable
data class SkillLocalizationsDto(
@SerialName("zh-Hans")
val zhHans: SkillLocalizationDto,
val en: SkillLocalizationDto,
)
@Serializable
data class OfficialSkillDto(
val id: String,
val systemImage: String,
val sortOrder: Int,
val kind: String = "transform",
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class AdminOfficialSkillDto(
val id: String,
val systemImage: String,
val sortOrder: Int,
val kind: String = "transform",
val thinkingEnabled: Boolean,
val enabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class CreateOfficialSkillRequest(
val id: String,
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class UpdateOfficialSkillRequest(
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class SkillCatalogResponse(
val schemaVersion: Int = 1,
val revision: Long,
val generatedAt: String? = null,
val skills: List<OfficialSkillDto>,
)
@Serializable
data class AdminSkillCatalogResponse(
val revision: Long,
val generatedAt: String? = null,
val skills: List<AdminOfficialSkillDto>,
)
data class OfficialSkillRecord(
val id: String,
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val enabled: Boolean,
val localizations: SkillLocalizationsDto,
)
data class SkillCatalogRecord(
val revision: Long,
val generatedAt: Instant?,
val skills: List<OfficialSkillRecord>,
)
@Serializable
data class AIHintCardDto(
val id: String,
val displayText: String? = null,
val text: String? = null,
val prompt: String,
val category: String = "general",
val priority: Int = 50,
val source: String = "remote",
val locale: String,
val conditions: List<String> = emptyList(),
val metadata: JsonObject? = null,
)
@Serializable
data class AIHintPackResponse(
val locale: String,
val generatedAt: String? = null,
val expiresAt: String? = null,
val version: Int,
val cards: List<AIHintCardDto>,
)
@Serializable
data class AIHintManifestResponse(
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val locales: List<String> = emptyList(),
val files: Map<String, String?> = emptyMap(),
)
@Serializable
data class AdminHintPackResponse(
val locale: String,
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val version: Int,
val cards: List<AIHintCardDto>,
)
@Serializable
data class UpdateHintPackRequest(
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val cards: List<AIHintCardDto>,
)
data class HintPackRecord(
val locale: String,
val generatedAt: Instant?,
val expiresAt: Instant?,
val intervalHours: Int?,
val version: Int,
val cardsJson: String,
)
enum class ContentMutationResult {
SUCCESS,
NOT_FOUND,
CONFLICT,
LIMIT_EXCEEDED,
}
@@ -0,0 +1,369 @@
package com.osglab.account.features.content.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogRecord
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
internal object OfficialContentCatalogTable : Table("official_content_catalog") {
val id = integer("id")
val revision = long("revision")
val generatedAt = timestamp("generated_at").nullable()
override val primaryKey = PrimaryKey(id)
}
internal object OfficialSkillsTable : Table("official_skills") {
val id = varchar("id", 128)
val systemImage = varchar("system_image", 128)
val sortOrder = integer("sort_order")
val thinkingEnabled = bool("thinking_enabled")
val enabled = bool("enabled")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object OfficialSkillLocalizationsTable : Table("official_skill_localizations") {
val skillId = varchar("skill_id", 128)
val locale = varchar("locale", 16)
val name = varchar("name", 120)
val summary = varchar("summary", 500)
val prompt = text("prompt")
override val primaryKey = PrimaryKey(skillId, locale)
}
internal object OfficialHintPacksTable : Table("official_hint_packs") {
val locale = varchar("locale", 8)
val generatedAt = timestamp("generated_at").nullable()
val expiresAt = timestamp("expires_at").nullable()
val intervalHours = integer("interval_hours").nullable()
val version = integer("version")
val cardsJson = text("cards_json")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(locale)
}
interface ContentRepository {
suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord
suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun listHintPacks(): List<HintPackRecord>
suspend fun getHintPack(locale: String): HintPackRecord?
suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord
}
class ExposedContentRepository(
private val databaseFactory: DatabaseFactory,
) : ContentRepository {
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
databaseFactory.query {
val catalog = catalogRow()
val statement = OfficialSkillsTable.selectAll()
if (enabledOnly) {
statement.where { OfficialSkillsTable.enabled eq true }
}
val skills = statement
.orderBy(
OfficialSkillsTable.sortOrder to SortOrder.ASC,
OfficialSkillsTable.id to SortOrder.ASC,
)
.map { row -> row.toSkillRecord(localizations(row[OfficialSkillsTable.id])) }
SkillCatalogRecord(
revision = catalog[OfficialContentCatalogTable.revision],
generatedAt = catalog[OfficialContentCatalogTable.generatedAt],
skills = skills,
)
}
override suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val exists = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq skill.id }
.limit(1)
.any()
val result = if (exists) {
ContentMutationResult.CONFLICT
} else {
OfficialSkillsTable.insert {
it[id] = skill.id
it[systemImage] = skill.systemImage
it[sortOrder] = skill.sortOrder
it[thinkingEnabled] = skill.thinkingEnabled
it[enabled] = skill.enabled
it[createdAt] = now
it[updatedAt] = now
}
insertLocalizations(skill)
incrementRevision(now)
ContentMutationResult.SUCCESS
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val exists = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq skill.id }
.forUpdate()
.singleOrNull() != null
val result = if (!exists) {
ContentMutationResult.NOT_FOUND
} else {
OfficialSkillsTable.update({ OfficialSkillsTable.id eq skill.id }) {
it[systemImage] = skill.systemImage
it[sortOrder] = skill.sortOrder
it[thinkingEnabled] = skill.thinkingEnabled
it[updatedAt] = now
}
updateLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
updateLocalization(skill.id, EN, skill.localizations.en)
incrementRevision(now)
ContentMutationResult.SUCCESS
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val row = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq id }
.forUpdate()
.singleOrNull()
val result = when {
row == null -> ContentMutationResult.NOT_FOUND
enabled &&
!row[OfficialSkillsTable.enabled] &&
enabledSkillCount() >= MAXIMUM_ENABLED_SKILLS -> ContentMutationResult.LIMIT_EXCEEDED
else -> {
OfficialSkillsTable.update({ OfficialSkillsTable.id eq id }) {
it[OfficialSkillsTable.enabled] = enabled
it[updatedAt] = now
}
incrementRevision(now)
ContentMutationResult.SUCCESS
}
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun listHintPacks(): List<HintPackRecord> =
databaseFactory.query {
OfficialHintPacksTable.selectAll()
.orderBy(OfficialHintPacksTable.locale to SortOrder.ASC)
.map(ResultRow::toHintPackRecord)
}
override suspend fun getHintPack(locale: String): HintPackRecord? =
databaseFactory.query {
OfficialHintPacksTable.selectAll()
.where { OfficialHintPacksTable.locale eq locale }
.limit(1)
.singleOrNull()
?.toHintPackRecord()
}
override suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord = databaseFactory.query {
// The singleton lock makes the initial version=1 insert race-free.
lockCatalog()
val current = OfficialHintPacksTable.selectAll()
.where { OfficialHintPacksTable.locale eq pack.locale }
.forUpdate()
.singleOrNull()
val next = pack.copy(version = (current?.get(OfficialHintPacksTable.version) ?: 0) + 1)
if (current == null) {
OfficialHintPacksTable.insert {
it[locale] = next.locale
it[generatedAt] = next.generatedAt
it[expiresAt] = next.expiresAt
it[intervalHours] = next.intervalHours
it[version] = next.version
it[cardsJson] = next.cardsJson
it[updatedAt] = now
}
} else {
OfficialHintPacksTable.update({ OfficialHintPacksTable.locale eq next.locale }) {
it[generatedAt] = next.generatedAt
it[expiresAt] = next.expiresAt
it[intervalHours] = next.intervalHours
it[version] = next.version
it[cardsJson] = next.cardsJson
it[updatedAt] = now
}
}
insertAudit(audit.copy(outcome = AdminAuditOutcome.SUCCESS))
next
}
private fun catalogRow(): ResultRow =
OfficialContentCatalogTable.selectAll()
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
.single()
private fun lockCatalog(): ResultRow =
OfficialContentCatalogTable.selectAll()
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
.forUpdate()
.single()
private fun incrementRevision(now: Instant) {
val current = catalogRow()[OfficialContentCatalogTable.revision]
OfficialContentCatalogTable.update({ OfficialContentCatalogTable.id eq CATALOG_ID }) {
it[revision] = current + 1
it[generatedAt] = now
}
}
private fun enabledSkillCount(): Long =
OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.enabled eq true }
.count()
private fun localizations(skillId: String): SkillLocalizationsDto {
val rows = OfficialSkillLocalizationsTable.selectAll()
.where { OfficialSkillLocalizationsTable.skillId eq skillId }
.associateBy { it[OfficialSkillLocalizationsTable.locale] }
return SkillLocalizationsDto(
zhHans = requireNotNull(rows[ZH_HANS]).toLocalization(),
en = requireNotNull(rows[EN]).toLocalization(),
)
}
private fun insertLocalizations(skill: OfficialSkillRecord) {
insertLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
insertLocalization(skill.id, EN, skill.localizations.en)
}
private fun insertLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
OfficialSkillLocalizationsTable.insert {
it[OfficialSkillLocalizationsTable.skillId] = skillId
it[OfficialSkillLocalizationsTable.locale] = locale
it[name] = value.name
it[summary] = value.summary
it[prompt] = value.prompt
}
}
private fun updateLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
OfficialSkillLocalizationsTable.update({
(OfficialSkillLocalizationsTable.skillId eq skillId) and
(OfficialSkillLocalizationsTable.locale eq locale)
}) {
it[name] = value.name
it[summary] = value.summary
it[prompt] = value.prompt
}
}
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
}
private fun ResultRow.toSkillRecord(localizations: SkillLocalizationsDto): OfficialSkillRecord =
OfficialSkillRecord(
id = this[OfficialSkillsTable.id],
systemImage = this[OfficialSkillsTable.systemImage],
sortOrder = this[OfficialSkillsTable.sortOrder],
thinkingEnabled = this[OfficialSkillsTable.thinkingEnabled],
enabled = this[OfficialSkillsTable.enabled],
localizations = localizations,
)
private fun ResultRow.toLocalization(): SkillLocalizationDto =
SkillLocalizationDto(
name = this[OfficialSkillLocalizationsTable.name],
summary = this[OfficialSkillLocalizationsTable.summary],
prompt = this[OfficialSkillLocalizationsTable.prompt],
)
private fun ResultRow.toHintPackRecord(): HintPackRecord =
HintPackRecord(
locale = this[OfficialHintPacksTable.locale],
generatedAt = this[OfficialHintPacksTable.generatedAt],
expiresAt = this[OfficialHintPacksTable.expiresAt],
intervalHours = this[OfficialHintPacksTable.intervalHours],
version = this[OfficialHintPacksTable.version],
cardsJson = this[OfficialHintPacksTable.cardsJson],
)
private fun NewAdminAuditEvent.withOutcome(result: ContentMutationResult): NewAdminAuditEvent =
copy(
outcome = if (result == ContentMutationResult.SUCCESS) {
AdminAuditOutcome.SUCCESS
} else {
AdminAuditOutcome.DENIED
},
)
private const val CATALOG_ID = 1
private const val MAXIMUM_ENABLED_SKILLS = 100L
private const val ZH_HANS = "zh-Hans"
private const val EN = "en"
@@ -0,0 +1,111 @@
package com.osglab.account.features.content.routes
import com.osglab.account.features.content.models.AIHintManifestResponse
import com.osglab.account.features.content.services.ContentErrorCode
import com.osglab.account.features.content.services.ContentException
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.header
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.security.MessageDigest
fun Route.contentRoutes(service: ContentService) {
route("/v1/content") {
get("/skills") {
val catalog = service.publicSkills()
call.respondCacheable(etag = "skills-${catalog.revision}") {
call.respond(catalog)
}
}
get("/hints/manifest") {
call.respondHintManifest(service)
}
get("/hints/{locale}") {
val locale = call.parameters["locale"]
call.respondHintPack(service, locale)
}
}
get("/hints/manifest.json") {
call.respondHintManifest(service)
}
get("/hints/{fileName}") {
val fileName = call.parameters["fileName"].orEmpty()
val locale = LEGACY_HINT_FILE.matchEntire(fileName)?.groupValues?.get(1)
call.respondHintPack(service, locale)
}
}
private suspend fun ApplicationCall.respondHintManifest(service: ContentService) {
val manifest = service.hintManifest()
respondCacheable(etag = manifest.etag()) {
respond(manifest)
}
}
private suspend fun ApplicationCall.respondHintPack(
service: ContentService,
locale: String?,
) {
if (locale !in SUPPORTED_HINT_LOCALES) {
respond(HttpStatusCode.NotFound)
return
}
try {
val pack = service.publicHintPack(requireNotNull(locale))
respondCacheable(etag = "hints-$locale-${pack.version}") {
respond(pack)
}
} catch (exception: ContentException) {
if (exception.code == ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND) {
respond(HttpStatusCode.NotFound)
} else {
throw exception
}
}
}
private suspend fun ApplicationCall.respondCacheable(
etag: String,
body: suspend () -> Unit,
) {
val quotedEtag = """"$etag""""
response.header(HttpHeaders.ETag, quotedEtag)
response.header(HttpHeaders.CacheControl, "public,max-age=300")
if (request.header(HttpHeaders.IfNoneMatch).matchesEtag(quotedEtag)) {
respond(HttpStatusCode.NotModified)
} else {
body()
}
}
private fun String?.matchesEtag(etag: String): Boolean =
this?.split(',')?.any { candidate ->
val normalized = candidate.trim().removePrefix("W/")
normalized == "*" || normalized == etag
} == true
private fun AIHintManifestResponse.etag(): String {
val bytes = PUBLIC_JSON.encodeToString(this).toByteArray(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return "hints-manifest-${digest.take(12).joinToString("") { "%02x".format(it.toInt() and 0xff) }}"
}
private val PUBLIC_JSON = Json {
explicitNulls = false
encodeDefaults = true
}
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
private val LEGACY_HINT_FILE = Regex("""hints-(zh|en)\.json""")
@@ -0,0 +1,348 @@
package com.osglab.account.features.content.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintManifestResponse
import com.osglab.account.features.content.models.AIHintPackResponse
import com.osglab.account.features.content.models.AdminHintPackResponse
import com.osglab.account.features.content.models.AdminOfficialSkillDto
import com.osglab.account.features.content.models.AdminSkillCatalogResponse
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillDto
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogResponse
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
import com.osglab.account.features.content.repositories.ContentRepository
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Clock
import java.time.Instant
enum class ContentErrorCode {
VALIDATION_ERROR,
CONTENT_SKILL_NOT_FOUND,
CONTENT_SKILL_CONFLICT,
CONTENT_HINT_PACK_NOT_FOUND,
}
class ContentException(
val code: ContentErrorCode,
) : RuntimeException(code.name)
class ContentService(
private val repository: ContentRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun publicSkills(): SkillCatalogResponse {
val catalog = repository.getSkillCatalog(enabledOnly = true)
check(catalog.skills.size <= MAXIMUM_ENABLED_SKILLS) {
"Official Skill catalog exceeds the client maximum"
}
return SkillCatalogResponse(
revision = catalog.revision,
generatedAt = catalog.generatedAt?.toString(),
skills = catalog.skills.map(OfficialSkillRecord::toPublicDto),
)
}
suspend fun adminSkills(): AdminSkillCatalogResponse {
val catalog = repository.getSkillCatalog(enabledOnly = false)
return AdminSkillCatalogResponse(
revision = catalog.revision,
generatedAt = catalog.generatedAt?.toString(),
skills = catalog.skills.map(OfficialSkillRecord::toAdminDto),
)
}
suspend fun createSkill(
actor: AdminPrincipal,
request: CreateOfficialSkillRequest,
requestId: String?,
): AdminOfficialSkillDto {
validateSkill(
id = request.id,
systemImage = request.systemImage,
sortOrder = request.sortOrder,
localizations = request.localizations,
)
val now = clock.instant()
val skill = OfficialSkillRecord(
id = request.id,
systemImage = request.systemImage.trim(),
sortOrder = request.sortOrder,
thinkingEnabled = request.thinkingEnabled,
enabled = false,
localizations = request.localizations.trimmed(),
)
repository.createSkill(
skill,
now,
audit(actor, AdminAuditAction.CONTENT_SKILL_CREATED, "OFFICIAL_SKILL", skill.id, requestId, now),
).throwOnFailure()
return skill.toAdminDto()
}
suspend fun updateSkill(
actor: AdminPrincipal,
id: String,
request: UpdateOfficialSkillRequest,
requestId: String?,
): AdminOfficialSkillDto {
validateSkill(id, request.systemImage, request.sortOrder, request.localizations)
val now = clock.instant()
val skill = OfficialSkillRecord(
id = id,
systemImage = request.systemImage.trim(),
sortOrder = request.sortOrder,
thinkingEnabled = request.thinkingEnabled,
enabled = false,
localizations = request.localizations.trimmed(),
)
repository.updateSkill(
skill,
now,
audit(actor, AdminAuditAction.CONTENT_SKILL_UPDATED, "OFFICIAL_SKILL", id, requestId, now),
).throwOnFailure()
val stored = repository.getSkillCatalog(enabledOnly = false).skills.first { it.id == id }
return stored.toAdminDto()
}
suspend fun setSkillEnabled(
actor: AdminPrincipal,
id: String,
enabled: Boolean,
requestId: String?,
) {
validateSkillId(id)
val now = clock.instant()
repository.setSkillEnabled(
id = id,
enabled = enabled,
now = now,
audit = audit(
actor,
if (enabled) AdminAuditAction.CONTENT_SKILL_ENABLED else AdminAuditAction.CONTENT_SKILL_DISABLED,
"OFFICIAL_SKILL",
id,
requestId,
now,
),
).throwOnFailure()
}
suspend fun hintManifest(): AIHintManifestResponse {
val packs = repository.listHintPacks()
return AIHintManifestResponse(
generatedAt = packs.mapNotNull(HintPackRecord::generatedAt).maxOrNull()?.toString(),
expiresAt = packs.mapNotNull(HintPackRecord::expiresAt).minOrNull()?.toString(),
intervalHours = packs.mapNotNull(HintPackRecord::intervalHours).minOrNull(),
locales = packs.map(HintPackRecord::locale),
files = packs.associate { it.locale to "/v1/content/hints/${it.locale}" },
)
}
suspend fun publicHintPack(locale: String): AIHintPackResponse {
validateHintLocale(locale)
return repository.getHintPack(locale)?.toPublicDto()
?: throw ContentException(ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND)
}
suspend fun adminHintPack(locale: String): AdminHintPackResponse {
validateHintLocale(locale)
return repository.getHintPack(locale)?.toAdminDto()
?: AdminHintPackResponse(locale = locale, version = 0, cards = emptyList())
}
suspend fun putHintPack(
actor: AdminPrincipal,
locale: String,
request: UpdateHintPackRequest,
requestId: String?,
): AdminHintPackResponse {
validateHintLocale(locale)
val generatedAt = parseInstant(request.generatedAt)
val expiresAt = parseInstant(request.expiresAt)
if (generatedAt != null && expiresAt != null && !expiresAt.isAfter(generatedAt)) {
invalid()
}
if (request.intervalHours != null && request.intervalHours !in 1..168) invalid()
validateCards(locale, request.cards)
val cardsJson = CONTENT_JSON.encodeToString(
ListSerializer(AIHintCardDto.serializer()),
request.cards,
)
if (cardsJson.length > MAX_HINT_PACK_CHARACTERS) invalid()
val now = clock.instant()
val stored = repository.putHintPack(
HintPackRecord(
locale = locale,
generatedAt = generatedAt,
expiresAt = expiresAt,
intervalHours = request.intervalHours,
version = 0,
cardsJson = cardsJson,
),
now,
audit(
actor,
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
"OFFICIAL_HINT_PACK",
locale,
requestId,
now,
),
)
return stored.toAdminDto()
}
private fun validateSkill(
id: String,
systemImage: String,
sortOrder: Int,
localizations: SkillLocalizationsDto,
) {
validateSkillId(id)
if (systemImage.trim().length !in 1..MAX_SYSTEM_IMAGE_CHARACTERS) invalid()
if (sortOrder !in 0..100_000) invalid()
validateLocalization(localizations.zhHans)
validateLocalization(localizations.en)
}
private fun validateSkillId(id: String) {
if (!SKILL_ID.matches(id)) invalid()
}
private fun validateHintLocale(locale: String) {
if (locale !in SUPPORTED_HINT_LOCALES) invalid()
}
private fun validateLocalization(value: SkillLocalizationDto) {
if (value.name.trim().length !in 1..MAX_SKILL_NAME_CHARACTERS) invalid()
if (value.summary.trim().length !in 1..MAX_SKILL_SUMMARY_CHARACTERS) invalid()
if (value.prompt.trim().length !in 1..MAX_SKILL_PROMPT_CHARACTERS) invalid()
}
private fun validateCards(locale: String, cards: List<AIHintCardDto>) {
if (cards.size > MAX_HINT_CARDS || cards.map { it.id }.toSet().size != cards.size) invalid()
cards.forEach { card ->
if (card.id.trim().length !in 1..128) invalid()
if (card.locale != locale) invalid()
if (card.displayText.isNullOrBlank() && card.text.isNullOrBlank()) invalid()
if ((card.displayText?.length ?: 0) > 500 || (card.text?.length ?: 0) > 500) invalid()
if (card.prompt.trim().length !in 1..MAX_HINT_PROMPT_CHARACTERS) invalid()
if (card.category.trim().length !in 1..64 || card.source.trim().length !in 1..64) invalid()
if (card.priority !in -10_000..10_000) invalid()
if (card.conditions.size > 20 || card.conditions.any { it.length !in 1..64 }) invalid()
if ((card.metadata?.toString()?.length ?: 0) > MAX_METADATA_CHARACTERS) invalid()
}
}
private fun parseInstant(value: String?): Instant? =
value?.let {
runCatching { Instant.parse(it) }.getOrElse { invalid() }
}
private fun ContentMutationResult.throwOnFailure() {
when (this) {
ContentMutationResult.SUCCESS -> Unit
ContentMutationResult.NOT_FOUND ->
throw ContentException(ContentErrorCode.CONTENT_SKILL_NOT_FOUND)
ContentMutationResult.CONFLICT ->
throw ContentException(ContentErrorCode.CONTENT_SKILL_CONFLICT)
ContentMutationResult.LIMIT_EXCEEDED ->
throw ContentException(ContentErrorCode.VALIDATION_ERROR)
}
}
}
private fun OfficialSkillRecord.toPublicDto(): OfficialSkillDto =
OfficialSkillDto(
id = id,
systemImage = systemImage,
sortOrder = sortOrder,
thinkingEnabled = thinkingEnabled,
localizations = localizations,
)
private fun OfficialSkillRecord.toAdminDto(): AdminOfficialSkillDto =
AdminOfficialSkillDto(
id = id,
systemImage = systemImage,
sortOrder = sortOrder,
thinkingEnabled = thinkingEnabled,
enabled = enabled,
localizations = localizations,
)
private fun HintPackRecord.toPublicDto(): AIHintPackResponse =
AIHintPackResponse(
locale = locale,
generatedAt = generatedAt?.toString(),
expiresAt = expiresAt?.toString(),
version = version,
cards = decodeCards(),
)
private fun HintPackRecord.toAdminDto(): AdminHintPackResponse =
AdminHintPackResponse(
locale = locale,
generatedAt = generatedAt?.toString(),
expiresAt = expiresAt?.toString(),
intervalHours = intervalHours,
version = version,
cards = decodeCards(),
)
private fun HintPackRecord.decodeCards(): List<AIHintCardDto> =
CONTENT_JSON.decodeFromString(ListSerializer(AIHintCardDto.serializer()), cardsJson)
private fun SkillLocalizationsDto.trimmed(): SkillLocalizationsDto =
SkillLocalizationsDto(zhHans.trimmed(), en.trimmed())
private fun SkillLocalizationDto.trimmed(): SkillLocalizationDto =
SkillLocalizationDto(name.trim(), summary.trim(), prompt.trim())
private fun audit(
actor: AdminPrincipal,
action: AdminAuditAction,
targetType: String,
targetId: String,
requestId: String?,
now: Instant,
): NewAdminAuditEvent = NewAdminAuditEvent(
actorOperatorId = actor.operatorId,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = targetType,
targetId = targetId,
requestId = requestId,
occurredAt = now,
)
private fun invalid(): Nothing = throw ContentException(ContentErrorCode.VALIDATION_ERROR)
private val CONTENT_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
encodeDefaults = true
}
private val SKILL_ID = Regex("""official\.[a-z0-9._-]{1,91}""")
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
private const val MAX_SYSTEM_IMAGE_CHARACTERS = 100
private const val MAXIMUM_ENABLED_SKILLS = 100
private const val MAX_SKILL_NAME_CHARACTERS = 40
private const val MAX_SKILL_SUMMARY_CHARACTERS = 200
private const val MAX_SKILL_PROMPT_CHARACTERS = 6_000
private const val MAX_HINT_PROMPT_CHARACTERS = 16_000
private const val MAX_HINT_CARDS = 500
private const val MAX_METADATA_CHARACTERS = 8_000
private const val MAX_HINT_PACK_CHARACTERS = 1_000_000
@@ -127,6 +127,12 @@ class CreditService(
return transactions.inTransaction { it.credits.listLedgerEntries(userId, limit) }
}
suspend fun hasSignupTrial(userId: UUID): Boolean =
transactions.inTransaction { unit ->
unit.credits.findLedgerEntry(userId, signupTrialIdempotencyKey(userId))
?.type == LedgerEntryType.SIGNUP_TRIAL
}
override suspend fun getReservation(
userId: UUID,
reservationId: UUID,
@@ -740,3 +746,6 @@ class CreditService(
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
internal fun signupTrialIdempotencyKey(userId: UUID): String =
"internal:signup-trial:$userId"
@@ -307,6 +307,12 @@ interface DeviceCheckTrialClaimRepository {
fun interface TrialCreditGranter {
suspend fun grant(accountId: UUID)
/**
* DeviceCheck tokens are ephemeral and cannot identify a previous claim.
* The immutable credit ledger is the authoritative account-level record.
*/
suspend fun wasGranted(accountId: UUID): Boolean = false
}
interface DeviceCheckTrialMutex {
@@ -341,8 +347,19 @@ private object LocalDeviceCheckTrialMutex : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T = block()
}
enum class SignupTrialClaimResult {
GRANTED,
ALREADY_GRANTED,
INELIGIBLE,
SKIPPED,
;
val shouldRestrictAccount: Boolean
get() = this == INELIGIBLE
}
fun interface SignupTrialClaimService {
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): SignupTrialClaimResult
}
class DeviceCheckTrialService(
@@ -353,35 +370,47 @@ class DeviceCheckTrialService(
private val mutex: DeviceCheckTrialMutex = LocalDeviceCheckTrialMutex,
private val clock: Clock = Clock.systemUTC(),
) : SignupTrialClaimService {
override suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean {
if (deviceToken.isNullOrBlank()) return false
override suspend fun claimAndGrant(
accountId: UUID,
deviceToken: String?,
): SignupTrialClaimResult {
if (creditGranter.wasGranted(accountId)) {
return SignupTrialClaimResult.ALREADY_GRANTED
}
if (deviceToken.isNullOrBlank()) return SignupTrialClaimResult.SKIPPED
val tokenHash = sha256Hex(deviceToken)
val owned = when (val result = repository.begin(tokenHash, accountId, clock.instant())) {
is BeginTrialClaim.Owned -> result.claim
BeginTrialClaim.ClaimedByAnotherAccount -> return false
BeginTrialClaim.ClaimedByAnotherAccount -> return SignupTrialClaimResult.INELIGIBLE
}
return try {
mutex.withLock {
if (creditGranter.wasGranted(accountId)) {
return@withLock SignupTrialClaimResult.ALREADY_GRANTED
}
completeOwnedClaim(owned, deviceToken)
}
} catch (exception: DeviceCheckRejectedException) {
repository.transition(tokenHash, TrialClaimStatus.REJECTED, clock.instant())
false
SignupTrialClaimResult.INELIGIBLE
} catch (exception: DeviceCheckUnavailableException) {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException("DeviceCheck")
}
false
SignupTrialClaimResult.SKIPPED
}
}
private suspend fun completeOwnedClaim(claim: TrialClaim, deviceToken: String): Boolean {
private suspend fun completeOwnedClaim(
claim: TrialClaim,
deviceToken: String,
): SignupTrialClaimResult {
when (claim.status) {
TrialClaimStatus.COMPLETED -> return true
TrialClaimStatus.REJECTED -> return false
TrialClaimStatus.COMPLETED -> return SignupTrialClaimResult.ALREADY_GRANTED
TrialClaimStatus.REJECTED -> return SignupTrialClaimResult.INELIGIBLE
TrialClaimStatus.APPLE_MARKED -> {
grantAndComplete(claim)
return true
return SignupTrialClaimResult.GRANTED
}
TrialClaimStatus.RESERVED -> Unit
}
@@ -392,7 +421,7 @@ class DeviceCheckTrialService(
}
if (state.bit0) {
repository.transition(claim.tokenHash, TrialClaimStatus.REJECTED, clock.instant())
return false
return SignupTrialClaimResult.INELIGIBLE
}
// Apple has no compare-and-set API. Marking first is intentionally conservative:
@@ -407,7 +436,7 @@ class DeviceCheckTrialService(
}
repository.transition(claim.tokenHash, TrialClaimStatus.APPLE_MARKED, clock.instant())
grantAndComplete(claim)
return true
return SignupTrialClaimResult.GRANTED
}
private suspend fun grantAndComplete(claim: TrialClaim) {
@@ -0,0 +1,14 @@
-- Accounts with an immutable signup-trial grant were incorrectly restricted
-- when a later sign-in supplied a fresh ephemeral DeviceCheck token.
UPDATE accounts AS account
SET
account.anti_abuse_restricted = FALSE,
account.updated_at = CURRENT_TIMESTAMP(6)
WHERE account.anti_abuse_restricted = TRUE
AND EXISTS (
SELECT 1
FROM credit_ledger AS ledger
WHERE ledger.user_id = account.id
AND ledger.entry_type = 'SIGNUP_TRIAL'
AND ledger.idempotency_key = CONCAT('internal:signup-trial:', account.id)
);
@@ -0,0 +1,55 @@
-- Official content is immediately published. The singleton catalog row
-- serializes revision changes and also protects first-write hint versions.
CREATE TABLE official_content_catalog (
id TINYINT UNSIGNED NOT NULL,
revision BIGINT UNSIGNED NOT NULL DEFAULT 0,
generated_at DATETIME(6) NULL,
PRIMARY KEY (id),
CONSTRAINT chk_official_content_catalog_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO official_content_catalog (id, revision, generated_at)
VALUES (1, 0, NULL);
CREATE TABLE official_skills (
id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
system_image VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
sort_order INT NOT NULL,
thinking_enabled BOOLEAN NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_official_skills_enabled_sort (enabled, sort_order, id),
CONSTRAINT chk_official_skills_id CHECK (id LIKE 'official.%')
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE official_skill_localizations (
skill_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
locale VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
name VARCHAR(120) NOT NULL,
summary VARCHAR(500) NOT NULL,
prompt TEXT NOT NULL,
PRIMARY KEY (skill_id, locale),
CONSTRAINT fk_official_skill_localizations_skill
FOREIGN KEY (skill_id) REFERENCES official_skills (id) ON DELETE CASCADE,
CONSTRAINT chk_official_skill_localizations_locale
CHECK (locale IN ('zh-Hans', 'en'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE official_hint_packs (
locale VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
generated_at DATETIME(6) NULL,
expires_at DATETIME(6) NULL,
interval_hours INT NULL,
version INT UNSIGNED NOT NULL,
cards_json MEDIUMTEXT NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (locale),
CONSTRAINT chk_official_hint_packs_locale CHECK (locale IN ('zh', 'en')),
CONSTRAINT chk_official_hint_packs_interval
CHECK (interval_hours IS NULL OR interval_hours BETWEEN 1 AND 168),
CONSTRAINT chk_official_hint_packs_version CHECK (version >= 1),
CONSTRAINT chk_official_hint_packs_expiry
CHECK (expires_at IS NULL OR generated_at IS NULL OR expires_at > generated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,51 @@
-- Privacy-minimized daily counters for text manually committed by OSGKeyboard.
-- Raw text, key sequences, surrounding context and host application identifiers
-- are intentionally absent.
CREATE TABLE keyboard_usage_daily_summaries (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
client_summary_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
summary_date DATE NOT NULL,
chinese_character_count BIGINT UNSIGNED NOT NULL,
english_character_count BIGINT UNSIGNED NOT NULL,
other_character_count BIGINT UNSIGNED NOT NULL,
input_session_count BIGINT UNSIGNED NOT NULL,
chinese_only_session_count BIGINT UNSIGNED NOT NULL,
english_only_session_count BIGINT UNSIGNED NOT NULL,
mixed_language_session_count BIGINT UNSIGNED NOT NULL,
other_only_session_count BIGINT UNSIGNED NOT NULL,
app_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
os_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
received_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_keyboard_usage_installation_id (installation_hash, client_summary_id),
UNIQUE KEY uq_keyboard_usage_installation_date (installation_hash, summary_date),
INDEX ix_keyboard_usage_summary_date (summary_date),
CONSTRAINT fk_keyboard_usage_installation
FOREIGN KEY (installation_hash)
REFERENCES product_analytics_installations (installation_hash)
ON DELETE CASCADE,
CONSTRAINT chk_keyboard_usage_chinese_count
CHECK (chinese_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_english_count
CHECK (english_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_other_count
CHECK (other_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_session_count
CHECK (input_session_count BETWEEN 1 AND 100000),
CONSTRAINT chk_keyboard_usage_session_partitions CHECK (
chinese_only_session_count <= 100000
AND english_only_session_count <= 100000
AND mixed_language_session_count <= 100000
AND other_only_session_count <= 100000
AND chinese_only_session_count
+ english_only_session_count
+ mixed_language_session_count
+ other_only_session_count = input_session_count
),
CONSTRAINT chk_keyboard_usage_characters CHECK (
chinese_character_count + english_character_count + other_character_count
>= input_session_count
)
) ENGINE = InnoDB;
@@ -39,6 +39,40 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("official content contract migration and runtime grants stay aligned") {
val openApi = root.read("docs/openapi.yaml")
val migration = root.read(
"src/main/resources/db/migration/V22__official_content_management.sql",
)
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
migration shouldContain "CREATE TABLE official_content_catalog"
migration shouldContain "CREATE TABLE official_skills"
migration shouldContain "CREATE TABLE official_skill_localizations"
migration shouldContain "CREATE TABLE official_hint_packs"
migration shouldContain "locale IN ('zh', 'en')"
openApi shouldContain "schemaVersion: { type: integer, const: 1 }"
openApi shouldContain "pattern: \"^official\\\\."
openApi shouldContain "Cache-Control: { schema: { type: string, const: \"public,max-age=300\" } }"
val skillSchemas = openApi
.substringAfter(" SkillLocalization:")
.substringBefore(" AIHintCard:")
skillSchemas shouldContain "name: { type: string, minLength: 1, maxLength: 40 }"
skillSchemas shouldContain "summary: { type: string, minLength: 1, maxLength: 200 }"
skillSchemas shouldContain "prompt: { type: string, minLength: 1, maxLength: 6000 }"
skillSchemas shouldContain "systemImage: { type: string, minLength: 1, maxLength: 100 }"
skillSchemas shouldContain "sortOrder: { type: integer, minimum: 0, maximum: 100000 }"
skillSchemas shouldContain "maxItems: 100"
listOf(privileges, smokePrivileges).forEach { grants ->
grants shouldContain "SELECT ON osg_account"
grants shouldContain "official_content_catalog"
grants shouldContain "official_skills"
grants shouldContain "official_skill_localizations"
grants shouldContain "official_hint_packs"
}
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
@@ -165,7 +199,7 @@ class DeploymentConsistencyTest : FunSpec({
val openApi = root.read("docs/openapi.yaml")
val eventSchema = openApi
.substringAfter(" ProductAnalyticsEvent:")
.substringBefore(" AdminSessionState:")
.substringBefore(" SkillLocalization:")
eventSchema shouldContain "additionalProperties: false"
eventSchema shouldContain "AI_FEATURE_SUCCEEDED"
eventSchema shouldContain "INSUFFICIENT_CREDITS"
@@ -174,6 +208,26 @@ class DeploymentConsistencyTest : FunSpec({
eventSchema shouldNotContain "audio"
eventSchema shouldNotContain "modelOutput"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminProductAnalytics\" }"
val keyboardMigration = root.read(
"src/main/resources/db/migration/V23__keyboard_usage_daily_summaries.sql",
)
keyboardMigration shouldContain "keyboard_usage_daily_summaries"
keyboardMigration shouldContain
"UNIQUE KEY uq_keyboard_usage_installation_date (installation_hash, summary_date)"
keyboardMigration shouldContain "ON DELETE CASCADE"
keyboardMigration shouldNotContain "input_text"
keyboardMigration shouldNotContain "host_application"
val keyboardSchema = openApi
.substringAfter(" KeyboardUsageSummary:")
.substringBefore(" ProductAnalyticsBatchResponse:")
keyboardSchema shouldContain "additionalProperties: false"
keyboardSchema shouldContain "chineseCharacterCount"
keyboardSchema shouldContain "mixedLanguageSessionCount"
keyboardSchema shouldNotContain "userText"
keyboardSchema shouldNotContain "keystrokes"
keyboardSchema shouldNotContain "hostApplication"
}
test("production Compose reuses private MySQL and hardens the application container") {
@@ -301,6 +355,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/account",
"/v1/apple/events",
"/v1/analytics/events",
"/v1/analytics/keyboard-usage",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
@@ -323,6 +378,16 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/v1/content/skills",
"/v1/content/hints/manifest",
"/v1/content/hints/{locale}",
"/hints/manifest.json",
"/hints/hints-{locale}.json",
"/v1/admin/content/skills",
"/v1/admin/content/skills/{id}",
"/v1/admin/content/skills/{id}/enable",
"/v1/admin/content/skills/{id}/disable",
"/v1/admin/content/hints/{locale}",
"/v1/admin/auth/session",
"/v1/admin/auth/login",
"/v1/admin/auth/logout",
@@ -51,17 +51,16 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..17)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
.findFirst()
.orElseThrow()
val migrationTables = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { MIGRATION_FILE.matches(it.fileName.toString()) }
.flatMap { migration ->
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
.stream()
}
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
}
.toList()
}
.toSet()
val grantedTables = GRANTED_TABLE.findAll(grants)
.map { it.groupValues[1] }
@@ -93,3 +92,4 @@ private fun Path.read(relativePath: String): String =
private val CREATE_TABLE = Regex("""CREATE TABLE\s+([a-z0-9_]+)""", RegexOption.IGNORE_CASE)
private val GRANTED_TABLE = Regex("""ON osg_account_smoke\.([a-z0-9_]+)""")
private val MIGRATION_FILE = Regex("""V\d+__.+\.sql""")
@@ -7,6 +7,7 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountR
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
@@ -42,6 +43,10 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe 20.0
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
result.keyboardUsage.activationToInput.percent shouldBe 80.0
result.keyboardUsage.chineseSharePercent shouldBe 60.0
result.keyboardUsage.englishSharePercent shouldBe 40.0
result.keyboardUsage.averageCharactersPerInputSession shouldBe 12.0
result.retention.first().d1?.percent shouldBe 50.0
result.retention.first().d7?.percent shouldBe 30.0
result.retention.first().d30 shouldBe null
@@ -63,6 +68,22 @@ class AdminProductAnalyticsServiceTest : FunSpec({
periodActiveUsers = 0,
activation24h = AdminAnalyticsCountRow(0, 0),
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 0,
keyboardUsers = 0,
chineseActiveUsers = 0,
englishActiveUsers = 0,
bilingualActiveUsers = 0,
totalCharacters = 0,
chineseCharacters = 0,
englishCharacters = 0,
otherCharacters = 0,
inputSessions = 0,
chineseOnlySessions = 0,
englishOnlySessions = 0,
mixedLanguageSessions = 0,
otherOnlySessions = 0,
),
)
val service = AdminProductAnalyticsService(
object : AdminProductAnalyticsRepository {
@@ -84,6 +105,9 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe null
result.activity.successfulRequestsPerActiveUser shouldBe null
result.consumption.averageCreditsPerManagedRequest shouldBe null
result.keyboardUsage.activationToInput.percent shouldBe null
result.keyboardUsage.chineseSharePercent shouldBe null
result.keyboardUsage.averageCharactersPerInputSession shouldBe null
}
})
@@ -130,6 +154,22 @@ private fun snapshot(): AdminProductAnalyticsSnapshot =
features = listOf(
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 40,
keyboardUsers = 50,
chineseActiveUsers = 30,
englishActiveUsers = 20,
bilingualActiveUsers = 10,
totalCharacters = 1_200,
chineseCharacters = 600,
englishCharacters = 400,
otherCharacters = 200,
inputSessions = 100,
chineseOnlySessions = 50,
englishOnlySessions = 30,
mixedLanguageSessions = 15,
otherOnlySessions = 5,
),
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
guardrails = AdminAnalyticsGuardrailRow(
clientSuccess = AdminAnalyticsCountRow(90, 100),
@@ -81,6 +81,19 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
analytics.retention.shouldBeEmpty()
analytics.features.shouldBeEmpty()
analytics.consumption.totalCredits shouldBeExactly 0
analytics.keyboardUsage.activeUsers shouldBeExactly 0
analytics.keyboardUsage.totalCharacters shouldBeExactly 0
factory.query { seedAdminCreditStats() }
val populatedStats = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
populatedStats.overview.grantedCredits shouldBeExactly 100
populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100
factory.query { seedProductAnalytics() }
val populated = ExposedAdminProductAnalyticsRepository(factory).load(
@@ -106,6 +119,11 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
populated.successfulAiRequests shouldBeExactly 2
populated.features.single().successes shouldBeExactly 2
populated.retention.single().d1 shouldBeExactly 1
populated.keyboardUsage.activeUsers shouldBeExactly 1
populated.keyboardUsage.keyboardUsers shouldBeExactly 1
populated.keyboardUsage.chineseCharacters shouldBeExactly 100
populated.keyboardUsage.englishCharacters shouldBeExactly 50
populated.keyboardUsage.inputSessions shouldBeExactly 4
}
} finally {
factory.close()
@@ -117,6 +135,29 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
private fun seedAdminCreditStats() {
TransactionManager.current().exec(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES
(
'50000000-0000-0000-0000-000000000001',
'50000000-0000-0000-0000-000000000000',
'SIGNUP_TRIAL', 100, 100,
'stats-signup-trial', NULL, '2026-08-11 00:00:00.000000'
),
(
'50000000-0000-0000-0000-000000000002',
'50000000-0000-0000-0000-000000000000',
'STOREKIT_PURCHASE', 6000, 6100,
'stats-storekit-purchase', NULL, '2026-08-11 00:01:00.000000'
)
""".trimIndent(),
)
}
private fun seedProductAnalytics() {
TransactionManager.current().exec(
"""
@@ -164,4 +205,20 @@ private fun seedProductAnalytics() {
""".trimIndent(),
)
}
TransactionManager.current().exec(
"""
INSERT INTO keyboard_usage_daily_summaries (
installation_hash, client_summary_id, summary_date,
chinese_character_count, english_character_count, other_character_count,
input_session_count, chinese_only_session_count, english_only_session_count,
mixed_language_session_count, other_only_session_count,
app_version, os_version, payload_hash, received_at
) VALUES (
'${"a".repeat(64)}', '50000000-0000-0000-0000-000000000001', '2026-08-11',
100, 50, 10,
4, 2, 1, 1, 0,
'1.0', '18.6', '${"4".repeat(64)}', '2026-08-12 00:10:01.000000'
)
""".trimIndent(),
)
}
@@ -9,11 +9,13 @@ import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.GRANTED_CREDIT_ENTRY_TYPES
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
@@ -41,11 +43,11 @@ class AdminStatsRepositoryTest : FunSpec({
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
grantedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
grantedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
@@ -104,7 +106,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
@@ -151,6 +153,17 @@ class AdminStatsRepositoryTest : FunSpec({
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("StoreKit purchases are excluded from granted credit statistics") {
GRANTED_CREDIT_ENTRY_TYPES shouldBe setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.SUBSCRIPTION_GRANT,
)
GRANTED_CREDIT_ENTRY_TYPES.contains(LedgerEntryType.STOREKIT_PURCHASE) shouldBe false
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
@@ -167,7 +180,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
referralRanking = listOf(
@@ -8,6 +8,8 @@ import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import io.kotest.assertions.throwables.shouldThrow
@@ -27,7 +29,7 @@ import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class AnalyticsRepositoryIntegrationTest : FunSpec({
test("V16 repository links accounts replays atomically and cascades account deletion") {
test("analytics repository links accounts replays atomically and cascades account deletion") {
withAnalyticsDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T01:00:00Z")
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
@@ -57,6 +59,30 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
installationCount(config, installationId.sha256Hex()) shouldBe 1
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
eventCount(config) shouldBe 1
val keyboardUsage = KeyboardUsageBatchRequest(
installationId = installationId,
summaries = listOf(keyboardSummary()),
)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(0, 1)
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
keyboardUsage.copy(
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000002",
chineseCharacterCount = 101,
),
),
),
)
}
keyboardSummaryCount(config) shouldBe 1
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-08-19"),
limit = 100,
) shouldBe 0
repository.recordInvitePageOpen(now)
repository.recordInvitePageOpen(now.plusSeconds(30))
scalarInt(
@@ -82,6 +108,31 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
}
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
val concurrentKeyboardUsage = KeyboardUsageBatchRequest(
installationId = concurrentRequest.installationId,
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000099",
),
),
)
val concurrentKeyboardResults = coroutineScope {
List(8) {
async { service.ingestKeyboardUsage(null, concurrentKeyboardUsage) }
}.awaitAll()
}
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
markKeyboardSummaryDate(
config,
"50000000-0000-0000-0000-000000000099",
java.time.LocalDate.parse("2026-05-20"),
)
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-05-22"),
limit = 100,
) shouldBe 1
keyboardSummaryCount(config) shouldBe 1
markInstallationUpdatedAt(
config,
concurrentRequest.installationId.sha256Hex(),
@@ -114,6 +165,7 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
deleteAccount(config, accountId)
installationCount(config, installationId.sha256Hex()) shouldBe 0
eventCount(config) shouldBe 0
keyboardSummaryCount(config) shouldBe 0
}
}
})
@@ -129,6 +181,21 @@ private fun event(
surface = surface,
)
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = "50000000-0000-0000-0000-000000000001",
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.0",
osVersion = "18.6",
)
private suspend fun withAnalyticsDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
@@ -197,6 +264,9 @@ private fun installationCount(config: DatabaseConfig, hash: String): Int =
private fun eventCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
private fun keyboardSummaryCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM keyboard_usage_daily_summaries")
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
@@ -246,6 +316,22 @@ private fun markInstallationUpdatedAt(
}
}
private fun markKeyboardSummaryDate(
config: DatabaseConfig,
clientSummaryId: String,
summaryDate: java.time.LocalDate,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"UPDATE keyboard_usage_daily_summaries SET summary_date = ? WHERE client_summary_id = ?"
).use { statement ->
statement.setObject(1, summaryDate)
statement.setString(2, clientSummaryId)
statement.executeUpdate() shouldBe 1
}
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
@@ -6,7 +6,9 @@ import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.routes.analyticsRoutes
import com.osglab.account.features.analytics.services.AnalyticsService
import io.kotest.matchers.shouldBe
@@ -61,6 +63,10 @@ class AnalyticsRoutesTest {
contentType(ContentType.Application.Json)
setBody(validBody())
}
val keyboardUsage = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody())
}
anonymous.status shouldBe HttpStatusCode.OK
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
@@ -68,7 +74,9 @@ class AnalyticsRoutesTest {
authenticated.bodyAsText() shouldNotContain accountId.toString()
authenticated.bodyAsText() shouldNotContain "installationId"
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
service.accountIds shouldBe listOf(null, accountId)
keyboardUsage.status shouldBe HttpStatusCode.OK
keyboardUsage.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
service.accountIds shouldBe listOf(null, accountId, null)
}
@Test
@@ -106,6 +114,18 @@ class AnalyticsRoutesTest {
conflict.bodyAsText() shouldContain """"code":"conflict""""
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
val forbiddenKeyboardContent = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace("\"summaryDate\"", "\"userText\":\"forbidden\",\"summaryDate\""))
}
val invalidSummaryDate = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
}
forbiddenKeyboardContent.status shouldBe HttpStatusCode.BadRequest
invalidSummaryDate.status shouldBe HttpStatusCode.UnprocessableEntity
invalidSummaryDate.bodyAsText() shouldContain """"code":"summary_date_invalid""""
}
private fun validBody(): String =
@@ -122,6 +142,26 @@ class AnalyticsRoutesTest {
}
""".trimIndent()
private fun validKeyboardUsageBody(): String =
"""
{
"installationId":"$INSTALLATION_ID",
"summaries":[{
"clientSummaryId":"50000000-0000-0000-0000-000000000001",
"summaryDate":"2026-08-19",
"chineseCharacterCount":100,
"englishCharacterCount":50,
"otherCharacterCount":10,
"inputSessionCount":4,
"chineseOnlySessionCount":2,
"englishOnlySessionCount":1,
"mixedLanguageSessionCount":1,
"otherOnlySessionCount":0,
"appVersion":"1.0"
}]
}
""".trimIndent()
private companion object {
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
@@ -139,6 +179,14 @@ private class RecordingAnalyticsService : AnalyticsService {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.summaries.size, replayed = 0)
}
}
private class ErrorAnalyticsService : AnalyticsService {
@@ -150,4 +198,12 @@ private class ErrorAnalyticsService : AnalyticsService {
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
else -> AnalyticsIngestResult(request.events.size, 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult = when (request.installationId) {
"10000000-0000-0000-0000-000000000003" -> throw KeyboardUsageDateException()
else -> AnalyticsIngestResult(request.summaries.size, 0)
}
}
@@ -12,8 +12,12 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
@@ -24,6 +28,7 @@ import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
@@ -222,6 +227,99 @@ class AnalyticsServiceTest {
repository.eventCount shouldBe eventCountBeforeConflict
}
@Test
fun `keyboard usage accepts privacy minimized daily summaries and replays idempotently`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val request = keyboardBatch(keyboardSummary())
service.ingestKeyboardUsage(null, request) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
repository.lastKeyboardBatch?.installationHash shouldBe installationId.sha256Hex()
repository.lastKeyboardBatch.toString() shouldNotContain installationId
request.toString() shouldNotContain request.summaries.single().clientSummaryId
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
KeyboardUsageBatchRequest(
installationId,
listOf(
keyboardSummary().copy(
clientSummaryId = uuid(43),
summaryDate = "2026-08-18",
),
keyboardSummary().copy(
clientSummaryId = uuid(42),
chineseCharacterCount = 101,
),
),
),
)
}
repository.keyboardSummaryCount shouldBe 1
}
@Test
fun `keyboard usage validates date count partitions and batch uniqueness before persistence`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
listOf(
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-08-20")),
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-07-15")),
).forEach { invalid ->
shouldThrow<KeyboardUsageDateException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "summary_date_invalid"
}
listOf(
KeyboardUsageBatchRequest(installationId, emptyList()),
keyboardBatch(keyboardSummary().copy(chineseCharacterCount = -1)),
keyboardBatch(keyboardSummary().copy(englishCharacterCount = 1_000_001)),
keyboardBatch(keyboardSummary().copy(inputSessionCount = 0)),
keyboardBatch(keyboardSummary().copy(otherOnlySessionCount = 1)),
keyboardBatch(
keyboardSummary().copy(
chineseCharacterCount = 0,
englishCharacterCount = 0,
otherCharacterCount = 0,
),
),
KeyboardUsageBatchRequest(
installationId,
listOf(keyboardSummary(), keyboardSummary().copy(clientSummaryId = uuid(43))),
),
).forEach { invalid ->
shouldThrow<InvalidRequestException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "invalid_request"
}
repository.keyboardSummaryCount shouldBe 0
}
@Test
fun `keyboard usage accepts inclusive oldest date and maximum counters`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val maximum = keyboardSummary().copy(
summaryDate = "2026-07-16",
chineseCharacterCount = 1_000_000,
englishCharacterCount = 1_000_000,
otherCharacterCount = 1_000_000,
inputSessionCount = 100_000,
chineseOnlySessionCount = 100_000,
englishOnlySessionCount = 0,
mixedLanguageSessionCount = 0,
)
service(repository).ingestKeyboardUsage(null, keyboardBatch(maximum)) shouldBe
AnalyticsIngestResult(1, 0)
}
@Test
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
kotlinx.coroutines.runBlocking {
@@ -235,6 +333,8 @@ class AnalyticsServiceTest {
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
repository.lastPurgeLimit shouldBe 1_000
repository.lastKeyboardPurgeBefore shouldBe LocalDate.parse("2026-05-22")
repository.lastKeyboardPurgeLimit shouldBe 1_000
}
private fun service(repository: AnalyticsRepository) =
@@ -248,6 +348,25 @@ class AnalyticsServiceTest {
events: List<AnalyticsEventRequest>,
) = AnalyticsBatchRequest(installationId = installationId, events = events)
private fun keyboardBatch(
summary: KeyboardUsageSummaryRequest,
) = KeyboardUsageBatchRequest(installationId, listOf(summary))
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = uuid(41),
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.2.3",
osVersion = "18.6",
)
private fun firstOpen() = event(
id = uuid(1),
type = AnalyticsEventType.FIRST_OPEN,
@@ -293,15 +412,30 @@ class AnalyticsServiceTest {
private class InMemoryAnalyticsRepository : AnalyticsRepository {
private val linkedAccounts = mutableMapOf<String, UUID?>()
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsById = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsByDate = mutableMapOf<Pair<String, LocalDate>, String>()
var lastBatch: AnalyticsBatch? = null
private set
var lastKeyboardBatch: KeyboardUsageBatch? = null
private set
val eventCount: Int get() = payloads.size
val keyboardSummaryCount: Int get() = keyboardPayloadsById.size
var lastPurgeBefore: Instant? = null
private set
var lastPurgeLimit: Int? = null
private set
var lastKeyboardPurgeBefore: LocalDate? = null
private set
var lastKeyboardPurgeLimit: Int? = null
private set
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
lastKeyboardPurgeBefore = before
lastKeyboardPurgeLimit = limit
return 0
}
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
lastPurgeBefore = before
lastPurgeLimit = limit
@@ -343,6 +477,48 @@ private class InMemoryAnalyticsRepository : AnalyticsRepository {
lastBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult {
val accountsCopy = linkedAccounts.toMutableMap()
val idPayloadsCopy = keyboardPayloadsById.toMutableMap()
val datePayloadsCopy = keyboardPayloadsByDate.toMutableMap()
val existingAccount = accountsCopy[batch.installationHash]
if (batch.installationHash !in accountsCopy) {
accountsCopy[batch.installationHash] = batch.accountId
} else if (batch.accountId != null) {
when {
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
existingAccount != batch.accountId ->
throw ConflictException("Installation is linked to another account")
}
}
var accepted = 0
var replayed = 0
batch.summaries.forEach { summary ->
val idKey = batch.installationHash to summary.clientSummaryId
val dateKey = batch.installationHash to summary.summaryDate
val existingHashes = setOfNotNull(idPayloadsCopy[idKey], datePayloadsCopy[dateKey])
when {
existingHashes.isEmpty() -> {
idPayloadsCopy[idKey] = summary.payloadHash
datePayloadsCopy[dateKey] = summary.payloadHash
accepted += 1
}
existingHashes.size == 1 && existingHashes.single() == summary.payloadHash ->
replayed += 1
else -> throw ConflictException("Keyboard usage summary conflict")
}
}
linkedAccounts.clear()
linkedAccounts.putAll(accountsCopy)
keyboardPayloadsById.clear()
keyboardPayloadsById.putAll(idPayloadsCopy)
keyboardPayloadsByDate.clear()
keyboardPayloadsByDate.putAll(datePayloadsCopy)
lastKeyboardBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
}
private fun String.sha256Hex(): String =
@@ -0,0 +1,85 @@
package com.osglab.account.features.content
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogRecord
import com.osglab.account.features.content.repositories.ContentRepository
import java.time.Instant
internal class InMemoryContentRepository : ContentRepository {
private val skills = linkedMapOf<String, OfficialSkillRecord>()
private val hints = linkedMapOf<String, HintPackRecord>()
val audits = mutableListOf<NewAdminAuditEvent>()
var revision = 0L
var generatedAt: Instant? = null
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
SkillCatalogRecord(
revision = revision,
generatedAt = generatedAt,
skills = skills.values
.filter { !enabledOnly || it.enabled }
.sortedWith(compareBy(OfficialSkillRecord::sortOrder, OfficialSkillRecord::id)),
)
override suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
if (skills.containsKey(skill.id)) return ContentMutationResult.CONFLICT
skills[skill.id] = skill
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[skill.id] ?: return ContentMutationResult.NOT_FOUND
skills[skill.id] = skill.copy(enabled = current.enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[id] ?: return ContentMutationResult.NOT_FOUND
if (enabled && !current.enabled && skills.values.count(OfficialSkillRecord::enabled) >= 100) {
return ContentMutationResult.LIMIT_EXCEEDED
}
skills[id] = current.copy(enabled = enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun listHintPacks(): List<HintPackRecord> =
hints.values.sortedBy(HintPackRecord::locale)
override suspend fun getHintPack(locale: String): HintPackRecord? = hints[locale]
override suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord {
val stored = pack.copy(version = (hints[pack.locale]?.version ?: 0) + 1)
hints[pack.locale] = stored
audits += audit
return stored
}
private fun publish(now: Instant, audit: NewAdminAuditEvent) {
revision += 1
generatedAt = now
audits += audit
}
}
@@ -0,0 +1,158 @@
package com.osglab.account.features.content.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class ContentRepositoryIntegrationTest : FunSpec({
test("skill revision publication and audit are atomic") {
withContentRepositories { first, _, admin ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val skill = officialSkill()
first.createSkill(skill, now, audit(AdminAuditAction.CONTENT_SKILL_CREATED, skill.id, now))
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 1
skills shouldBe emptyList()
}
first.setSkillEnabled(
skill.id,
enabled = true,
now = now.plusSeconds(1),
audit = audit(AdminAuditAction.CONTENT_SKILL_ENABLED, skill.id, now.plusSeconds(1)),
)
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 2
generatedAt shouldBe now.plusSeconds(1)
skills.single().localizations.en.name shouldBe "Polish"
}
admin.listAudit(10).map { it.action }.toSet() shouldBe setOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
}
test("concurrent hint publication assigns monotonic versions") {
withContentRepositories { first, second, _ ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val versions = coroutineScope {
listOf(first, second).mapIndexed { index, repository ->
async {
repository.putHintPack(
HintPackRecord(
locale = "zh",
generatedAt = now,
expiresAt = null,
intervalHours = 12,
version = 0,
cardsJson = "[]",
),
now.plusSeconds(index.toLong()),
audit(
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
"zh",
now.plusSeconds(index.toLong()),
),
).version
}
}.awaitAll()
}
versions shouldContainExactlyInAnyOrder listOf(1, 2)
first.getHintPack("zh")?.version shouldBe 2
}
}
})
private suspend fun withContentRepositories(
block: suspend (ExposedContentRepository, ExposedContentRepository, ExposedAdminRepository) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
ContentMySqlContainer("mysql:8.4")
.withDatabaseName("osg_content_repository_test")
.withUsername("test")
.withPassword("test")
.also(ContentMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(
ExposedContentRepository(firstFactory),
ExposedContentRepository(secondFactory),
ExposedAdminRepository(firstFactory),
)
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun officialSkill() = OfficialSkillRecord(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
enabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "请润色"),
SkillLocalizationDto("Polish", "Improve wording", "Please polish"),
),
)
private fun audit(
action: AdminAuditAction,
targetId: String,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = null,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = if (action == AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED) {
"OFFICIAL_HINT_PACK"
} else {
"OFFICIAL_SKILL"
},
targetId = targetId,
occurredAt = now,
)
private class ContentMySqlContainer(image: String) :
MySQLContainer<ContentMySqlContainer>(image)
@@ -0,0 +1,235 @@
package com.osglab.account.features.content.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.routes.adminContentRoutes
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.services.ContentService
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.test.Test
class ContentRoutesTest {
@Test
fun `public skills are anonymous cacheable and support conditional requests`() = testApplication {
val service = seededContentService()
application {
installContentJson()
routing { contentRoutes(service) }
}
val first = client.get("/v1/content/skills")
val etag = first.headers[HttpHeaders.ETag]
first.status shouldBe HttpStatusCode.OK
first.headers[HttpHeaders.CacheControl] shouldBe "public,max-age=300"
first.bodyAsText() shouldContain """"schemaVersion":1"""
first.bodyAsText() shouldContain """"id":"official.polish""""
val cached = client.get("/v1/content/skills") {
header(HttpHeaders.IfNoneMatch, requireNotNull(etag))
}
cached.status shouldBe HttpStatusCode.NotModified
cached.headers[HttpHeaders.ETag] shouldBe etag
}
@Test
fun `public hint manifest and packs preserve client fields and etags`() = testApplication {
val service = ContentService(InMemoryContentRepository())
service.putHintPack(
principal(AdminRole.SUPER_ADMIN),
"en",
UpdateHintPackRequest(
generatedAt = "2026-08-21T04:00:00Z",
expiresAt = "2026-08-22T04:00:00Z",
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-brief",
text = "Daily brief",
prompt = "Summarize today's news",
category = "daily",
priority = 80,
source = "official",
locale = "en",
conditions = listOf("idle"),
),
),
),
null,
)
application {
installContentJson()
routing { contentRoutes(service) }
}
val manifest = client.get("/v1/content/hints/manifest")
val legacyManifest = client.get("/hints/manifest.json")
manifest.status shouldBe HttpStatusCode.OK
manifest.bodyAsText() shouldContain """"locales":["en"]"""
manifest.bodyAsText() shouldContain """"en":"/v1/content/hints/en""""
legacyManifest.bodyAsText() shouldBe manifest.bodyAsText()
legacyManifest.headers[HttpHeaders.ETag] shouldBe manifest.headers[HttpHeaders.ETag]
legacyManifest.headers[HttpHeaders.CacheControl] shouldBe manifest.headers[HttpHeaders.CacheControl]
val pack = client.get("/v1/content/hints/en")
val legacyPack = client.get("/hints/hints-en.json")
val etag = requireNotNull(pack.headers[HttpHeaders.ETag])
pack.status shouldBe HttpStatusCode.OK
pack.bodyAsText() shouldContain """"version":1"""
pack.bodyAsText() shouldContain """"text":"Daily brief""""
legacyPack.bodyAsText() shouldBe pack.bodyAsText()
legacyPack.headers[HttpHeaders.ETag] shouldBe etag
legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl]
client.get("/v1/content/hints/en") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/hints/hints-en.json") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/v1/content/hints/fr").status shouldBe HttpStatusCode.NotFound
client.get("/hints/hints-fr.json").status shouldBe HttpStatusCode.NotFound
}
@Test
fun `only super admin can mutate content with valid csrf`() = testApplication {
val service = ContentService(InMemoryContentRepository())
val sessions = sessionService(AdminRole.SUPPORT)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessions, service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Forbidden
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `super admin mutation publishes through admin route`() = testApplication {
val repository = InMemoryContentRepository()
val service = ContentService(repository)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessionService(AdminRole.SUPER_ADMIN), service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Created
response.bodyAsText() shouldContain """"enabled":false"""
repository.revision shouldBe 1
}
}
private fun io.ktor.server.application.Application.installContentJson() {
install(ContentNegotiation) {
json(
Json {
explicitNulls = false
encodeDefaults = true
},
)
}
}
private suspend fun seededContentService(): ContentService {
val service = ContentService(InMemoryContentRepository())
val actor = principal(AdminRole.SUPER_ADMIN)
service.createSkill(
actor,
CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 1,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "润色文本"),
SkillLocalizationDto("Polish", "Improve wording", "Polish text"),
),
),
null,
)
service.setSkillEnabled(actor, "official.polish", true, null)
return service
}
private fun sessionService(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns principal(role)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns principal(role)
}
private fun principal(role: AdminRole) = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "operator",
role = role,
)
private fun adminConfig() = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { admin } returns AdminConfig(mtlsRequired = false)
}
private const val SKILL_BODY = """
{
"id": "official.polish",
"systemImage": "wand.and.sparkles",
"sortOrder": 10,
"thinkingEnabled": false,
"localizations": {
"zh-Hans": {"name": "润色", "summary": "优化表达", "prompt": "请润色"},
"en": {"name": "Polish", "summary": "Improve wording", "prompt": "Please polish"}
}
}
"""
@@ -0,0 +1,222 @@
package com.osglab.account.features.content.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class ContentServiceTest : FunSpec({
val now = Instant.parse("2026-08-21T04:00:00Z")
test("skill mutations increment revision and disabled skills stay private") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
service.createSkill(actor(), skillRequest(), "request-create")
service.adminSkills().run {
revision shouldBe 1
skills.single().enabled shouldBe false
}
service.publicSkills().skills shouldBe emptyList()
service.setSkillEnabled(actor(), "official.polish", true, "request-enable")
service.publicSkills().run {
revision shouldBe 2
generatedAt shouldBe now.toString()
skills.single().run {
id shouldBe "official.polish"
kind shouldBe "transform"
localizations.zhHans.name shouldBe "润色"
}
}
repository.audits.map { it.action } shouldBe listOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
test("public Skill DTO accepts exact client maxima and rejects every overflow") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val maximumId = "official." + "." + "a".repeat(89) + "-"
val maximumLocalization = SkillLocalizationDto(
name = "n".repeat(40),
summary = "s".repeat(200),
prompt = "p".repeat(6_000),
)
val maximum = CreateOfficialSkillRequest(
id = maximumId,
systemImage = "i".repeat(100),
sortOrder = 100_000,
thinkingEnabled = true,
localizations = SkillLocalizationsDto(maximumLocalization, maximumLocalization),
)
service.createSkill(actor(), maximum, "maximum")
service.setSkillEnabled(actor(), maximumId, true, "maximum-enable")
val catalog = service.publicSkills()
catalog.schemaVersion shouldBe 1
catalog.revision shouldBe 2
catalog.skills.single().run {
id shouldBe maximumId
systemImage.length shouldBe 100
sortOrder shouldBe 100_000
kind shouldBe "transform"
thinkingEnabled shouldBe true
localizations.zhHans shouldBe maximumLocalization
localizations.en shouldBe maximumLocalization
}
val catalogJson = CONTRACT_JSON.encodeToJsonElement(
com.osglab.account.features.content.models.SkillCatalogResponse.serializer(),
catalog,
).jsonObject
catalogJson.keys shouldBe setOf("schemaVersion", "revision", "generatedAt", "skills")
val skillJson = catalogJson.getValue("skills").jsonArray.single().jsonObject
skillJson.keys shouldBe setOf(
"id",
"systemImage",
"sortOrder",
"kind",
"thinkingEnabled",
"localizations",
)
skillJson.getValue("localizations").jsonObject.keys shouldBe setOf("zh-Hans", "en")
val invalidRequests = listOf(
maximum.copy(id = "official." + "a".repeat(92)),
maximum.copy(systemImage = "i".repeat(101)),
maximum.copy(sortOrder = -1),
maximum.copy(sortOrder = 100_001),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(name = "n".repeat(41)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(summary = "s".repeat(201)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(prompt = "p".repeat(6_001)),
),
),
)
invalidRequests.forEach { request ->
shouldThrow<ContentException> {
runBlocking { service.createSkill(actor(), request, "overflow") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
}
test("hint publishing validates locale and increments each locale version") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val request = UpdateHintPackRequest(
generatedAt = now.toString(),
expiresAt = now.plusSeconds(3_600).toString(),
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-1",
text = "今日热点",
prompt = "请概括今日热点",
category = "daily",
priority = 80,
source = "official",
locale = "zh",
),
),
)
service.putHintPack(actor(), "zh", request, "hint-1").version shouldBe 1
service.putHintPack(actor(), "zh", request, "hint-2").version shouldBe 2
service.publicHintPack("zh").run {
version shouldBe 2
cards.single().text shouldBe "今日热点"
}
service.hintManifest().run {
locales shouldBe listOf("zh")
intervalHours shouldBe 12
files shouldBe mapOf("zh" to "/v1/content/hints/zh")
}
shouldThrow<ContentException> {
runBlocking { service.putHintPack(actor(), "fr", request, "invalid") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
test("client contract rejects non-official IDs and mismatched card locales") {
val service = ContentService(
InMemoryContentRepository(),
Clock.fixed(now, ZoneOffset.UTC),
)
shouldThrow<ContentException> {
runBlocking {
service.createSkill(actor(), skillRequest().copy(id = "custom.polish"), null)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
shouldThrow<ContentException> {
runBlocking {
service.putHintPack(
actor(),
"en",
UpdateHintPackRequest(
cards = listOf(
AIHintCardDto(
id = "wrong-locale",
displayText = "提示",
prompt = "prompt",
locale = "zh",
),
),
),
null,
)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
})
private fun actor() = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
private fun skillRequest() = CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
zhHans = SkillLocalizationDto("润色", "优化表达", "请润色以下文本"),
en = SkillLocalizationDto("Polish", "Improve wording", "Polish the following text"),
),
)
private val CONTRACT_JSON = Json {
encodeDefaults = true
explicitNulls = true
}
@@ -13,6 +13,7 @@ import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.externalIdempotencyKey
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
@@ -107,6 +108,17 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("signup trial ledger is the authoritative account-level claim record") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.hasSignupTrial(userId) shouldBe false
service.grantSignupTrial(userId, 100, signupTrialIdempotencyKey(userId))
service.hasSignupTrial(userId) shouldBe true
}
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -79,7 +79,10 @@ class DeviceCheckTest : FunSpec({
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse()
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32)),
) shouldBe SignupTrialClaimResult.INELIGIBLE
updates shouldBe 0
grants shouldBe 0
repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED
@@ -109,11 +112,63 @@ class DeviceCheckTest : FunSpec({
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 }))
.shouldBeTrue()
.shouldBe(SignupTrialClaimResult.GRANTED)
(events.indexOf("apple") < events.indexOf("credits")) shouldBe true
events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED")
}
test("repeat sign-in with a fresh ephemeral token keeps an already granted account eligible") {
val accountId = UUID.randomUUID()
val grantedAccounts = mutableSetOf<UUID>()
var appleBit = false
var queries = 0
val service = DeviceCheckTrialService(
repository = InMemoryTrialRepository(),
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery {
queries++
return DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null))
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
appleBit = bit0
}
},
creditGranter = object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
grantedAccounts += accountId
}
override suspend fun wasGranted(accountId: UUID): Boolean =
accountId in grantedAccounts
},
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 6 }),
) shouldBe SignupTrialClaimResult.GRANTED
val queriesAfterGrant = queries
val repeated = service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
repeated shouldBe SignupTrialClaimResult.ALREADY_GRANTED
repeated.shouldRestrictAccount.shouldBeFalse()
queries shouldBe queriesAfterGrant
grantedAccounts shouldBe setOf(accountId)
}
test("only an ineligible trial result restricts an account") {
SignupTrialClaimResult.GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.ALREADY_GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.SKIPPED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.INELIGIBLE.shouldRestrictAccount.shouldBeTrue()
}
test("monitor skips a trial while enforce fails closed on Apple outage") {
val unavailable = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
@@ -130,7 +185,7 @@ class DeviceCheckTest : FunSpec({
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.MONITOR,
).claimAndGrant(accountId, token).shouldBeFalse()
).claimAndGrant(accountId, token) shouldBe SignupTrialClaimResult.SKIPPED
shouldThrow<ExternalServiceUnavailableException> {
DeviceCheckTrialService(
@@ -176,7 +231,8 @@ class DeviceCheckTest : FunSpec({
}.awaitAll()
}
results.count { it } shouldBe 1
results.count { it == SignupTrialClaimResult.GRANTED } shouldBe 1
results.count { it == SignupTrialClaimResult.INELIGIBLE } shouldBe 1
grants shouldBe 1
}