Add managed content and keyboard usage insights
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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: "已拒绝",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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("热词");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'@'%';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
+22
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+109
@@ -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() +
|
||||
|
||||
+20
-14
@@ -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}'" }
|
||||
|
||||
+34
@@ -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),
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+157
-30
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+133
-3
@@ -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,
|
||||
}
|
||||
+369
@@ -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""")
|
||||
|
||||
+40
@@ -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),
|
||||
|
||||
+57
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+17
-4
@@ -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(
|
||||
|
||||
+87
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+158
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user