From 454ba8ddc578490791c4647694b269e375ea5708 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:17:15 +0800 Subject: [PATCH] Migrate AI Hint feed generation Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients. --- .env.example | 6 + admin-web/src/api/client.ts | 25 ++ admin-web/src/api/types.ts | 42 ++- .../src/features/content/content-page.tsx | 5 +- .../features/content/hint-auto-section.tsx | 316 ++++++++++++++++++ admin-web/src/test/content.test.tsx | 40 +++ compose.yaml | 5 + deploy/smoke/runtime-grants.sql | 4 + docs/HINT_FEED_MIGRATION.md | 42 +++ docs/mysql-minimum-privileges.sql | 4 + docs/openapi.yaml | 145 ++++++++ .../kotlin/com/osglab/account/Application.kt | 37 ++ .../com/osglab/account/config/AppConfig.kt | 21 ++ .../features/admin/models/AdminModels.kt | 2 + .../admin/routes/AdminContentRoutes.kt | 50 +++ .../features/admin/routes/AdminRoutes.kt | 6 +- .../content/feed/HintFeedGenerationLock.kt | 29 ++ .../features/content/feed/HintFeedModels.kt | 94 ++++++ .../content/feed/HintFeedRepository.kt | 173 ++++++++++ .../content/feed/HintFeedScheduler.kt | 25 ++ .../features/content/feed/HintFeedService.kt | 269 +++++++++++++++ .../features/content/feed/HintFeedSupport.kt | 109 ++++++ .../feed/sources/BaselineHintSource.kt | 110 ++++++ .../feed/sources/GoogleFeedHintSource.kt | 112 +++++++ .../content/feed/sources/HintFeedSource.kt | 22 ++ .../content/feed/sources/HolidayHintSource.kt | 186 +++++++++++ .../content/feed/sources/TopHubHintSource.kt | 247 ++++++++++++++ .../content/feed/sources/WeatherHintSource.kt | 91 +++++ .../features/content/models/ContentModels.kt | 1 + .../content/repositories/ContentRepository.kt | 30 +- .../content/services/ContentService.kt | 65 ++++ src/main/resources/application.yaml | 4 + .../migration/V24__hint_feed_generation.sql | 56 ++++ .../config/DeploymentConsistencyTest.kt | 11 + .../content/InMemoryContentRepository.kt | 13 + .../content/feed/HintFeedPolicyTest.kt | 77 +++++ .../content/feed/HintFeedServiceTest.kt | 166 +++++++++ .../feed/sources/HintFeedSourcesTest.kt | 105 ++++++ .../ContentRepositoryIntegrationTest.kt | 33 +- .../content/routes/ContentRoutesTest.kt | 70 ++++ 40 files changed, 2838 insertions(+), 10 deletions(-) create mode 100644 admin-web/src/features/content/hint-auto-section.tsx create mode 100644 docs/HINT_FEED_MIGRATION.md create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedGenerationLock.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedModels.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedScheduler.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/HintFeedSupport.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/BaselineHintSource.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/GoogleFeedHintSource.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/HintFeedSource.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/HolidayHintSource.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/TopHubHintSource.kt create mode 100644 src/main/kotlin/com/osglab/account/features/content/feed/sources/WeatherHintSource.kt create mode 100644 src/main/resources/db/migration/V24__hint_feed_generation.sql create mode 100644 src/test/kotlin/com/osglab/account/features/content/feed/HintFeedPolicyTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/content/feed/HintFeedServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/content/feed/sources/HintFeedSourcesTest.kt diff --git a/.env.example b/.env.example index 58d9544..b4525cc 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,12 @@ ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=replace-with-random-base32-secret ADMIN_SESSION_HOURS=8 ADMIN_MAXIMUM_MANUAL_GRANT=100000 +# AI Hint Feed runs in this service without changing the legacy key.osglab.com deployment. +HINT_FEED_ENABLED=false +HINT_FEED_ZONE_ID=UTC +# Optional paid fallback. Keep provider keys in environment-backed secret storage. +TOPHUB_API_KEY= + # Apple identifiers are not secrets, but use the values from your own developer account. APPLE_TEAM_ID=replace-with-apple-team-id APPLE_KEY_ID=replace-with-apple-key-id diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index ad72afd..f2b5787 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -4,6 +4,9 @@ import type { AdminOperatorProvisioning, AdminSecuritySummary, AdminHintPack, + HintFeedGenerationResponse, + HintFeedGenerationStatus, + HintFeedSettings, AdminLoginResponse, AuditQuery, AuditLogEntry, @@ -25,6 +28,7 @@ import type { UsersQuery, UserSummary, UpdateHintPackRequest, + UpdateHintFeedSettingsRequest, UpdateOfficialSkillRequest, } from "./types"; @@ -82,6 +86,9 @@ function safeMessage(status: number, code?: string): string { CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill", CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在", CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布", + HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新", + HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求", + HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用", RATE_LIMITED: "操作过于频繁,请稍后再试", }; if (code && messages[code]) return messages[code]; @@ -228,6 +235,24 @@ export const adminApi = { body: JSON.stringify(payload), }), + hintFeedSettings: () => + request("/content/hints/generation/settings"), + + updateHintFeedSettings: (payload: UpdateHintFeedSettingsRequest) => + request("/content/hints/generation/settings", { + method: "PUT", + body: JSON.stringify(payload), + }), + + hintFeedStatus: () => + request("/content/hints/generation/status"), + + regenerateHintFeed: () => + request("/content/hints/generation/regenerate", { + method: "POST", + signal: AbortSignal.timeout(130_000), + }), + users: (value: string | UsersQuery = "", legacyCursor?: string) => { const params = typeof value === "string" diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index f53b193..f056c97 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -16,7 +16,9 @@ export type AdminAuditAction = | "CONTENT_SKILL_UPDATED" | "CONTENT_SKILL_ENABLED" | "CONTENT_SKILL_DISABLED" - | "CONTENT_HINT_PACK_PUBLISHED"; + | "CONTENT_HINT_PACK_PUBLISHED" + | "CONTENT_HINT_FEED_SETTINGS_UPDATED" + | "CONTENT_HINT_FEED_GENERATED"; export interface SkillLocalization { name: string; @@ -82,6 +84,44 @@ export interface UpdateHintPackRequest { cards: AIHintCard[]; } +export interface HintFeedSettings { + enabled: boolean; + topHubApiKeyConfigured: boolean; + generationIntervalHours: number; + holidayCountriesZh: string; + holidayCountriesEn: string; + weatherCitiesZh: string; + weatherCitiesEn: string; + googleTrendsGeos: string; +} + +export type UpdateHintFeedSettingsRequest = Omit< + HintFeedSettings, + "enabled" | "topHubApiKeyConfigured" +>; + +export interface HintFeedGenerationStatus { + enabled: boolean; + outcome: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED"; + intervalHours: number; + lastStartedAt?: string; + lastCompletedAt?: string; + lastErrorCode?: string; + nextScheduledAt?: string; + topHubApiKeyConfigured: boolean; + zhVersion?: number; + zhCardCount?: number; + enVersion?: number; + enCardCount?: number; +} + +export interface HintFeedGenerationResponse { + generationId: string; + generatedAt: string; + zh: { version: number; cardCount: number }; + en: { version: number; cardCount: number }; +} + export interface CursorPageQuery { cursor?: string; limit?: number; diff --git a/admin-web/src/features/content/content-page.tsx b/admin-web/src/features/content/content-page.tsx index c78e017..6f14880 100644 --- a/admin-web/src/features/content/content-page.tsx +++ b/admin-web/src/features/content/content-page.tsx @@ -29,6 +29,7 @@ import { Textarea, } from "../../components/primitives"; import { useAuth } from "../auth/auth-context"; +import { HintAutoSection } from "./hint-auto-section"; type HintLocale = "zh" | "en"; @@ -128,7 +129,7 @@ export function ContentPage() { setEditingSkill("new")}> @@ -212,6 +213,8 @@ export function ContentPage() { )} + +

diff --git a/admin-web/src/features/content/hint-auto-section.tsx b/admin-web/src/features/content/hint-auto-section.tsx new file mode 100644 index 0000000..f2a3fea --- /dev/null +++ b/admin-web/src/features/content/hint-auto-section.tsx @@ -0,0 +1,316 @@ +import { Play, RefreshCw, Save, Settings2 } 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 { + HintFeedGenerationStatus, + HintFeedSettings, + UpdateHintFeedSettingsRequest, +} from "../../api/types"; +import { + Badge, + Button, + Card, + Input, + LoadingState, + Textarea, +} from "../../components/primitives"; + +interface HintAutoSectionProps { + canEdit: boolean; +} + +export function HintAutoSection({ canEdit }: HintAutoSectionProps) { + const [settings, setSettings] = useState(); + const [status, setStatus] = useState(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [generating, setGenerating] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [nextSettings, nextStatus] = await Promise.all([ + adminApi.hintFeedSettings(), + adminApi.hintFeedStatus(), + ]); + setSettings(nextSettings); + setStatus(nextStatus); + } catch (error) { + toast.error(message(error, "Hint 自动生成状态加载失败")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function save(event: FormEvent) { + event.preventDefault(); + if (!settings) return; + setSaving(true); + try { + const payload: UpdateHintFeedSettingsRequest = { + generationIntervalHours: settings.generationIntervalHours, + holidayCountriesZh: settings.holidayCountriesZh, + holidayCountriesEn: settings.holidayCountriesEn, + weatherCitiesZh: settings.weatherCitiesZh, + weatherCitiesEn: settings.weatherCitiesEn, + googleTrendsGeos: settings.googleTrendsGeos, + }; + setSettings(await adminApi.updateHintFeedSettings(payload)); + setStatus(await adminApi.hintFeedStatus()); + toast.success("Hint 自动生成配置已保存"); + } catch (error) { + toast.error(message(error, "Hint 自动生成配置保存失败")); + } finally { + setSaving(false); + } + } + + async function regenerate() { + if ( + !window.confirm( + "将立即抓取外部数据,并原子覆盖 zh/en 提示包。旧版本会保留到新一代全部生成成功。是否继续?", + ) + ) { + return; + } + setGenerating(true); + try { + const result = await adminApi.regenerateHintFeed(); + toast.success( + `生成完成:zh ${result.zh.cardCount} 条,en ${result.en.cardCount} 条`, + ); + await load(); + } catch (error) { + toast.error(message(error, "Hint 提示包生成失败,旧版本仍保持可用")); + } finally { + setGenerating(false); + } + } + + if (loading || !settings || !status) { + return ; + } + + return ( +
+
+
+

+ Hint 自动生成 +

+

+ 从 TopHub、Google Trends、Google News、节日和天气来源生成双语提示包。 + 任一来源失败不会影响其他来源,整代生成失败时继续提供旧版本。 +

+
+
+ + {canEdit ? ( + + ) : null} +
+
+ +
+ + + + +
+ + +
void save(event)}> +
+ +

生成设置

+ + TopHub Key {settings.topHubApiKeyConfigured ? "已配置" : "未配置"} + +
+ +
+ + + setSettings({ + ...settings, + generationIntervalHours: Number(event.target.value), + }) + } + /> + + + + setSettings({ ...settings, holidayCountriesZh: event.target.value }) + } + /> + + + + setSettings({ ...settings, holidayCountriesEn: event.target.value }) + } + /> + +
+ + + + setSettings({ ...settings, googleTrendsGeos: event.target.value }) + } + /> + + +
+ +