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

Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
This commit is contained in:
Rocky
2026-08-21 13:34:03 +08:00
parent b5212dcdc2
commit d0abe27623
55 changed files with 4690 additions and 111 deletions
@@ -68,6 +68,11 @@ import com.osglab.account.features.credits.routes.creditRoutes
import com.osglab.account.features.credits.services.CreditOperations
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.content.repositories.ContentRepository
import com.osglab.account.features.content.repositories.ExposedContentRepository
import com.osglab.account.features.content.routes.contentRoutes
import com.osglab.account.features.content.services.ContentService
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
@@ -328,6 +333,7 @@ fun Application.module() {
analyticsRoutes(koin.get())
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
integrityRoutes(koin.get())
contentRoutes(koin.get())
}
if (appConfig.admin.enabled) {
adminWebRoutes(appConfig)
@@ -342,6 +348,7 @@ fun Application.module() {
grantService = koin.get(),
operatorService = koin.get(),
auditService = koin.get(),
contentService = koin.get(),
)
}
}
@@ -417,6 +424,8 @@ fun accountServerModule(config: AppConfig): Module = module {
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
single { AdminUsersService(get()) }
single { AdminGrantService(get()) }
single<ContentRepository> { ExposedContentRepository(get()) }
single { ContentService(get()) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
@@ -486,12 +495,18 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = "internal:signup-trial:$accountId",
)
val creditService = get<CreditService>()
object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
creditService.grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = signupTrialIdempotencyKey(accountId),
)
}
override suspend fun wasGranted(accountId: UUID): Boolean =
creditService.hasSignupTrial(accountId)
}
}
single {
@@ -510,8 +525,8 @@ fun accountServerModule(config: AppConfig): Module = module {
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (trial.shouldRestrictAccount) {
get<AuthRepository>().restrictAccountForAntiAbuse(
accountId,
java.time.Instant.now(),
@@ -111,6 +111,11 @@ enum class AdminAuditAction {
OPERATOR_CREDENTIALS_RESET,
OPERATOR_SESSIONS_REVOKED,
MANUAL_CREDIT_GRANTED,
CONTENT_SKILL_CREATED,
CONTENT_SKILL_UPDATED,
CONTENT_SKILL_ENABLED,
CONTENT_SKILL_DISABLED,
CONTENT_HINT_PACK_PUBLISHED,
}
enum class AdminAuditOutcome {
@@ -0,0 +1,144 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
import com.osglab.account.features.content.services.ContentErrorCode
import com.osglab.account.features.content.services.ContentException
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.request.header
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
internal fun Route.adminContentRoutes(
config: AppConfig,
sessions: AdminSessionService,
service: ContentService,
) {
route("/content") {
get("/skills") {
if (call.requireContentReader(config, sessions) == null) return@get
call.respond(service.adminSkills())
}
post("/skills") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val request = call.receiveContentRequest<CreateOfficialSkillRequest>() ?: return@post
call.respondContentError {
call.respond(
HttpStatusCode.Created,
service.createSkill(principal, request, call.request.header("X-Request-ID")),
)
}
}
put("/skills/{id}") {
val principal = call.requireContentEditor(config, sessions) ?: return@put
val id = call.parameters["id"] ?: return@put call.respondContentValidationError()
val request = call.receiveContentRequest<UpdateOfficialSkillRequest>() ?: return@put
call.respondContentError {
call.respond(
service.updateSkill(principal, id, request, call.request.header("X-Request-ID")),
)
}
}
post("/skills/{id}/enable") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
call.respondContentError {
service.setSkillEnabled(principal, id, enabled = true, call.request.header("X-Request-ID"))
call.respond(HttpStatusCode.NoContent)
}
}
post("/skills/{id}/disable") {
val principal = call.requireContentEditor(config, sessions) ?: return@post
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
call.respondContentError {
service.setSkillEnabled(principal, id, enabled = false, call.request.header("X-Request-ID"))
call.respond(HttpStatusCode.NoContent)
}
}
get("/hints/{locale}") {
if (call.requireContentReader(config, sessions) == null) return@get
val locale = call.parameters["locale"] ?: return@get call.respondContentValidationError()
call.respondContentError {
call.respond(service.adminHintPack(locale))
}
}
put("/hints/{locale}") {
val principal = call.requireContentEditor(config, sessions) ?: return@put
val locale = call.parameters["locale"] ?: return@put call.respondContentValidationError()
val request = call.receiveContentRequest<UpdateHintPackRequest>() ?: return@put
call.respondContentError {
call.respond(
service.putHintPack(principal, locale, request, call.request.header("X-Request-ID")),
)
}
}
}
}
private suspend fun ApplicationCall.requireContentReader(
config: AppConfig,
sessions: AdminSessionService,
) = requireRole(config, sessions, setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT))
private suspend fun ApplicationCall.requireContentEditor(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
val principal = requireMutationPrincipal(config, sessions) ?: return null
if (principal.role != AdminRole.SUPER_ADMIN) {
respond(HttpStatusCode.Forbidden, ContentAdminErrorResponse("INSUFFICIENT_PERMISSION"))
return null
}
return principal
}
private suspend inline fun <reified T : Any> ApplicationCall.receiveContentRequest(): T? =
try {
receive<T>()
} catch (_: BadRequestException) {
respondContentValidationError()
null
}
private suspend fun ApplicationCall.respondContentError(block: suspend () -> Unit) {
try {
block()
} catch (exception: ContentException) {
val status = when (exception.code) {
ContentErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
ContentErrorCode.CONTENT_SKILL_NOT_FOUND,
ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND,
-> HttpStatusCode.NotFound
ContentErrorCode.CONTENT_SKILL_CONFLICT -> HttpStatusCode.Conflict
}
respond(status, ContentAdminErrorResponse(exception.code.name))
}
}
private suspend fun ApplicationCall.respondContentValidationError() {
respond(HttpStatusCode.BadRequest, ContentAdminErrorResponse(ContentErrorCode.VALIDATION_ERROR.name))
}
@Serializable
private data class ContentAdminErrorResponse(val code: String)
@@ -45,6 +45,7 @@ import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -91,6 +92,7 @@ fun Route.adminApiRoutes(
grantService: AdminGrantService,
operatorService: AdminOperatorService,
auditService: AdminAuditService,
contentService: ContentService? = null,
clock: Clock = Clock.systemUTC(),
) {
route("/v1/admin") {
@@ -166,6 +168,8 @@ fun Route.adminApiRoutes(
}
}
contentService?.let { adminContentRoutes(config, sessionService, it) }
get("/overview") {
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
@@ -814,7 +818,7 @@ private suspend fun ApplicationCall.requirePrincipal(
return principal
}
private suspend fun ApplicationCall.requireRole(
internal suspend fun ApplicationCall.requireRole(
config: AppConfig,
sessions: AdminSessionService,
allowedRoles: Set<AdminRole>,
@@ -827,7 +831,7 @@ private suspend fun ApplicationCall.requireRole(
return principal
}
private suspend fun ApplicationCall.requireMutationPrincipal(
internal suspend fun ApplicationCall.requireMutationPrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
@@ -949,7 +953,7 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
activeUsers = overview.activeUsers,
newUsers = overview.registrations,
totalCreditBalance = overview.totalCreditBalance,
creditsGranted = overview.issuedCredits,
creditsGranted = overview.grantedCredits,
creditsUsed = overview.consumedCredits,
trend = registrationTrend.map {
AdminTrendResponse(
@@ -97,6 +97,27 @@ data class AdminAnalyticsGuardrailsDto(
val creditBlockedUsers: Long,
)
@Serializable
data class AdminAnalyticsKeyboardUsageDto(
val activeUsers: Long,
val activationToInput: AdminAnalyticsRateDto,
val chineseActiveUsers: Long,
val englishActiveUsers: Long,
val bilingualActiveUsers: Long,
val totalCharacters: Long,
val chineseCharacters: Long,
val englishCharacters: Long,
val otherCharacters: Long,
val chineseSharePercent: Double?,
val englishSharePercent: Double?,
val inputSessions: Long,
val averageCharactersPerInputSession: Double?,
val chineseOnlySessions: Long,
val englishOnlySessions: Long,
val mixedLanguageSessions: Long,
val otherOnlySessions: Long,
)
@Serializable
data class AdminProductAnalyticsDto(
val period: AdminAnalyticsPeriodDto,
@@ -108,6 +129,7 @@ data class AdminProductAnalyticsDto(
val growthFunnel: List<AdminAnalyticsFunnelStepDto>,
val retention: List<AdminAnalyticsCohortDto>,
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
val keyboardUsage: AdminAnalyticsKeyboardUsageDto,
val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
val guardrails: AdminAnalyticsGuardrailsDto,
)
@@ -14,7 +14,7 @@ data class AdminOverviewDto(
val registrations: Long,
val activeUsers: Long,
val totalCreditBalance: Long,
val issuedCredits: Long,
val grantedCredits: Long,
val consumedCredits: Long,
)
@@ -27,7 +27,7 @@ data class AdminRegistrationPointDto(
@Serializable
data class AdminCreditFlowPointDto(
val date: String,
val issuedCredits: Long,
val grantedCredits: Long,
val consumedCredits: Long,
)
@@ -74,6 +74,23 @@ data class AdminAnalyticsGuardrailRow(
val creditBlockedUsers: Long,
)
data class AdminAnalyticsKeyboardUsageRow(
val activeUsers: Long,
val keyboardUsers: Long,
val chineseActiveUsers: Long,
val englishActiveUsers: Long,
val bilingualActiveUsers: Long,
val totalCharacters: Long,
val chineseCharacters: Long,
val englishCharacters: Long,
val otherCharacters: Long,
val inputSessions: Long,
val chineseOnlySessions: Long,
val englishOnlySessions: Long,
val mixedLanguageSessions: Long,
val otherOnlySessions: Long,
)
data class AdminAnalyticsGrowthFunnelRow(
val opened: Long,
val registered: Long,
@@ -100,6 +117,7 @@ data class AdminProductAnalyticsSnapshot(
val growthFunnel: AdminAnalyticsGrowthFunnelRow,
val retention: List<AdminAnalyticsCohortRow>,
val features: List<AdminAnalyticsFeatureRow>,
val keyboardUsage: AdminAnalyticsKeyboardUsageRow,
val referrals: AdminAnalyticsReferralRow,
val guardrails: AdminAnalyticsGuardrailRow,
)
@@ -145,6 +163,7 @@ class ExposedAdminProductAnalyticsRepository(
growthFunnel = loadGrowthFunnel(range),
retention = loadRetention(range),
features = loadFeatures(range),
keyboardUsage = loadKeyboardUsage(range),
referrals = loadReferrals(range),
guardrails = loadGuardrails(range),
)
@@ -626,6 +645,96 @@ class ExposedAdminProductAnalyticsRepository(
)
}
private fun loadKeyboardUsage(range: AdminAnalyticsWindow): AdminAnalyticsKeyboardUsageRow =
querySingle(
"""
WITH identity_usage AS (
SELECT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', s.installation_hash)
) AS identity_key,
SUM(s.chinese_character_count) AS chinese_characters,
SUM(s.english_character_count) AS english_characters,
SUM(s.other_character_count) AS other_characters,
SUM(s.input_session_count) AS input_sessions,
SUM(s.chinese_only_session_count) AS chinese_only_sessions,
SUM(s.english_only_session_count) AS english_only_sessions,
SUM(s.mixed_language_session_count) AS mixed_language_sessions,
SUM(s.other_only_session_count) AS other_only_sessions
FROM keyboard_usage_daily_summaries s
JOIN product_analytics_installations i
ON i.installation_hash = s.installation_hash
WHERE s.summary_date >= DATE(?) AND s.summary_date < DATE(?)
GROUP BY identity_key
),
activated AS (
SELECT DISTINCT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', e.installation_hash)
) AS identity_key
FROM product_analytics_events e
JOIN product_analytics_installations i
ON i.installation_hash = e.installation_hash
WHERE e.event_name = 'KEYBOARD_ACTIVATED'
AND e.occurred_at >= DATE(?) AND e.occurred_at < DATE(?)
),
keyboard_population AS (
SELECT identity_key FROM identity_usage
UNION
SELECT identity_key FROM activated
)
SELECT
(SELECT COUNT(*) FROM identity_usage) AS active_users,
(SELECT COUNT(*) FROM keyboard_population) AS keyboard_users,
COALESCE(SUM(CASE WHEN chinese_characters > 0 THEN 1 ELSE 0 END), 0)
AS chinese_active_users,
COALESCE(SUM(CASE WHEN english_characters > 0 THEN 1 ELSE 0 END), 0)
AS english_active_users,
COALESCE(
SUM(
CASE
WHEN chinese_characters > 0 AND english_characters > 0
THEN 1 ELSE 0
END
),
0
) AS bilingual_active_users,
COALESCE(
SUM(chinese_characters + english_characters + other_characters),
0
) AS total_characters,
COALESCE(SUM(chinese_characters), 0) AS chinese_characters,
COALESCE(SUM(english_characters), 0) AS english_characters,
COALESCE(SUM(other_characters), 0) AS other_characters,
COALESCE(SUM(input_sessions), 0) AS input_sessions,
COALESCE(SUM(chinese_only_sessions), 0) AS chinese_only_sessions,
COALESCE(SUM(english_only_sessions), 0) AS english_only_sessions,
COALESCE(SUM(mixed_language_sessions), 0) AS mixed_language_sessions,
COALESCE(SUM(other_only_sessions), 0) AS other_only_sessions
FROM identity_usage
""",
range.arguments(repetitions = 2),
) {
AdminAnalyticsKeyboardUsageRow(
activeUsers = it.exactLong("active_users"),
keyboardUsers = it.exactLong("keyboard_users"),
chineseActiveUsers = it.exactLong("chinese_active_users"),
englishActiveUsers = it.exactLong("english_active_users"),
bilingualActiveUsers = it.exactLong("bilingual_active_users"),
totalCharacters = it.exactLong("total_characters"),
chineseCharacters = it.exactLong("chinese_characters"),
englishCharacters = it.exactLong("english_characters"),
otherCharacters = it.exactLong("other_characters"),
inputSessions = it.exactLong("input_sessions"),
chineseOnlySessions = it.exactLong("chinese_only_sessions"),
englishOnlySessions = it.exactLong("english_only_sessions"),
mixedLanguageSessions = it.exactLong("mixed_language_sessions"),
otherOnlySessions = it.exactLong("other_only_sessions"),
)
}
private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow =
querySingle(
valueEventsCte() +
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import org.jetbrains.exposed.v1.core.IColumnType
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
@@ -25,7 +26,7 @@ data class AdminStatsRange(
data class AdminStatsSnapshot(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val grantedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
@@ -39,7 +40,7 @@ fun interface AdminStatsRepository {
internal data class AdminStatsAggregates(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val grantedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
@@ -73,17 +74,14 @@ class ExposedAdminStatsRepository(
""",
range,
),
issuedCreditsByDate = loadDailyAggregates(
grantedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(amount_delta), 0) AS aggregate_value
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
GROUP BY DATE(created_at)
""",
range,
@@ -128,11 +126,8 @@ class ExposedAdminStatsRepository(
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
) AS issued_credits,
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
) AS granted_credits,
(
SELECT COALESCE(SUM(charged_credits), 0)
FROM credit_usage_records
@@ -146,7 +141,7 @@ class ExposedAdminStatsRepository(
registrations = result.exactLong("registrations"),
activeUsers = result.exactLong("active_users"),
totalCreditBalance = result.exactLong("total_credit_balance"),
issuedCredits = result.exactLong("issued_credits"),
grantedCredits = result.exactLong("granted_credits"),
consumedCredits = result.exactLong("consumed_credits"),
)
}
@@ -280,7 +275,7 @@ internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSna
AdminStatsSnapshot(
overview = aggregates.overview,
registrationsByDate = aggregates.registrationsByDate,
issuedCreditsByDate = aggregates.issuedCreditsByDate,
grantedCreditsByDate = aggregates.grantedCreditsByDate,
consumedCreditsByDate = aggregates.consumedCreditsByDate,
referralFunnel = aggregates.referralFunnel,
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
@@ -332,3 +327,14 @@ private fun ResultSet.exactLong(column: String): Long =
internal fun BigDecimal.toExactLong(): Long = longValueExact()
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
internal val GRANTED_CREDIT_ENTRY_TYPES: Set<LedgerEntryType> = setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.SUBSCRIPTION_GRANT,
)
private val GRANTED_CREDIT_ENTRY_TYPES_SQL =
GRANTED_CREDIT_ENTRY_TYPES.joinToString(", ") { "'${it.name}'" }
@@ -8,6 +8,7 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFeatureUsage
import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsNorthStarDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsPeriodDto
@@ -42,6 +43,8 @@ class AdminProductAnalyticsService(
)
val referrals = snapshot.referrals
val growth = snapshot.growthFunnel
val keyboard = snapshot.keyboardUsage
val classifiedKeyboardCharacters = keyboard.chineseCharacters + keyboard.englishCharacters
return AdminProductAnalyticsDto(
period = AdminAnalyticsPeriodDto(from.toString(), until.toString()),
northStar = AdminAnalyticsNorthStarDto(
@@ -126,6 +129,37 @@ class AdminProductAnalyticsService(
successes = it.successes,
)
},
keyboardUsage = AdminAnalyticsKeyboardUsageDto(
activeUsers = keyboard.activeUsers,
activationToInput = AdminAnalyticsCountRow(
keyboard.activeUsers,
keyboard.keyboardUsers,
).toRate(),
chineseActiveUsers = keyboard.chineseActiveUsers,
englishActiveUsers = keyboard.englishActiveUsers,
bilingualActiveUsers = keyboard.bilingualActiveUsers,
totalCharacters = keyboard.totalCharacters,
chineseCharacters = keyboard.chineseCharacters,
englishCharacters = keyboard.englishCharacters,
otherCharacters = keyboard.otherCharacters,
chineseSharePercent = percentage(
keyboard.chineseCharacters,
classifiedKeyboardCharacters,
),
englishSharePercent = percentage(
keyboard.englishCharacters,
classifiedKeyboardCharacters,
),
inputSessions = keyboard.inputSessions,
averageCharactersPerInputSession = ratio(
keyboard.totalCharacters,
keyboard.inputSessions,
),
chineseOnlySessions = keyboard.chineseOnlySessions,
englishOnlySessions = keyboard.englishOnlySessions,
mixedLanguageSessions = keyboard.mixedLanguageSessions,
otherOnlySessions = keyboard.otherOnlySessions,
),
referralFunnel = listOf(
AdminAnalyticsFunnelStepDto("发起分享", referrals.shared),
AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened),
@@ -45,7 +45,7 @@ class AdminStatsService(
creditFlow = dates.map { date ->
AdminCreditFlowPointDto(
date = date.toString(),
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
grantedCredits = snapshot.grantedCreditsByDate[date] ?: 0,
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
)
},
@@ -3,6 +3,7 @@ package com.osglab.account.features.analytics.domain
import com.osglab.account.common.errors.ApiException
import io.ktor.http.HttpStatusCode
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
import kotlinx.serialization.Serializable
@@ -105,9 +106,44 @@ data class AnalyticsIngestResult(
val replayed: Int,
)
data class KeyboardUsageSummary(
val clientSummaryId: UUID,
val summaryDate: LocalDate,
val chineseCharacterCount: Long,
val englishCharacterCount: Long,
val otherCharacterCount: Long,
val inputSessionCount: Long,
val chineseOnlySessionCount: Long,
val englishOnlySessionCount: Long,
val mixedLanguageSessionCount: Long,
val otherOnlySessionCount: Long,
val appVersion: String?,
val osVersion: String?,
val payloadHash: String,
) {
override fun toString(): String = "KeyboardUsageSummary([REDACTED])"
}
data class KeyboardUsageBatch(
val installationHash: String,
val accountId: UUID?,
val summaries: List<KeyboardUsageSummary>,
val receivedAt: Instant,
) {
override fun toString(): String =
"KeyboardUsageBatch(installationHash=[REDACTED], accountId=[REDACTED], summaries=${summaries.size})"
}
class AnalyticsEventTimeException :
ApiException(
status = HttpStatusCode.UnprocessableEntity,
code = "event_time_invalid",
message = "An event timestamp is outside the accepted range",
)
class KeyboardUsageDateException :
ApiException(
status = HttpStatusCode.UnprocessableEntity,
code = "summary_date_invalid",
message = "A keyboard usage summary date is outside the accepted range",
)
@@ -36,6 +36,33 @@ data class AnalyticsEventRequest(
override fun toString(): String = "AnalyticsEventRequest([REDACTED])"
}
@Serializable
data class KeyboardUsageBatchRequest(
val installationId: String,
val summaries: List<KeyboardUsageSummaryRequest>,
) {
override fun toString(): String =
"KeyboardUsageBatchRequest(installationId=[REDACTED], summaries=[REDACTED size=${summaries.size}])"
}
@Serializable
data class KeyboardUsageSummaryRequest(
val clientSummaryId: String,
val summaryDate: String,
val chineseCharacterCount: Long,
val englishCharacterCount: Long,
val otherCharacterCount: Long,
val inputSessionCount: Long,
val chineseOnlySessionCount: Long,
val englishOnlySessionCount: Long,
val mixedLanguageSessionCount: Long,
val otherOnlySessionCount: Long,
val appVersion: String? = null,
val osVersion: String? = null,
) {
override fun toString(): String = "KeyboardUsageSummaryRequest([REDACTED])"
}
@Serializable
data class AnalyticsIngestResponse(
val accepted: Int,
@@ -12,12 +12,17 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
import kotlinx.coroutines.delay
import org.jetbrains.exposed.v1.exceptions.ExposedSQLException
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.core.plus
import org.jetbrains.exposed.v1.javatime.date
import org.jetbrains.exposed.v1.javatime.timestamp
@@ -27,17 +32,37 @@ import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.sql.SQLException
interface AnalyticsRepository {
suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult
suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult
suspend fun recordInvitePageOpen(occurredAt: Instant)
suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int
suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int
}
class ExposedAnalyticsRepository(
private val databaseFactory: DatabaseFactory,
) : AnalyticsRepository {
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
require(limit in 1..10_000)
return databaseFactory.query {
val ids = KeyboardUsageSummaries
.selectAll()
.where { KeyboardUsageSummaries.summaryDate less before }
.limit(limit)
.map { it[KeyboardUsageSummaries.id] }
if (ids.isEmpty()) {
0
} else {
KeyboardUsageSummaries.deleteWhere { KeyboardUsageSummaries.id inList ids }
}
}
}
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
require(limit in 1..10_000)
return databaseFactory.query {
@@ -79,36 +104,8 @@ class ExposedAnalyticsRepository(
}
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult =
databaseFactory.query {
AnalyticsInstallations.insertIgnore {
it[installationHash] = batch.installationHash
it[accountId] = batch.accountId?.toString()
it[createdAt] = batch.receivedAt
it[updatedAt] = batch.receivedAt
}
val installation = AnalyticsInstallations
.selectAll()
.where { AnalyticsInstallations.installationHash eq batch.installationHash }
.forUpdate()
.single()
val linkedAccount = installation[AnalyticsInstallations.accountId]
when {
batch.accountId == null -> Unit
linkedAccount == null -> AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq batch.installationHash
}) {
it[accountId] = batch.accountId.toString()
it[updatedAt] = batch.receivedAt
}
linkedAccount != batch.accountId.toString() ->
throw ConflictException("Installation is linked to another account")
}
AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq batch.installationHash
}) {
it[updatedAt] = batch.receivedAt
}
ingestTransaction {
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
var accepted = 0
var replayed = 0
@@ -133,6 +130,83 @@ class ExposedAnalyticsRepository(
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
}
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult =
ingestTransaction {
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
var accepted = 0
var replayed = 0
batch.summaries.forEach { summary ->
val existingPayloadHash = KeyboardUsageSummaries
.selectAll()
.where {
(KeyboardUsageSummaries.installationHash eq batch.installationHash) and (
(KeyboardUsageSummaries.clientSummaryId eq summary.clientSummaryId.toString()) or
(KeyboardUsageSummaries.summaryDate eq summary.summaryDate)
)
}
.singleOrNull()
?.get(KeyboardUsageSummaries.payloadHash)
when {
existingPayloadHash == null -> {
insertKeyboardUsageSummary(batch, summary)
accepted += 1
}
existingPayloadHash == summary.payloadHash -> replayed += 1
else -> throw ConflictException("Keyboard usage summary conflicts with an existing date or ID")
}
}
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
}
private suspend fun <T> ingestTransaction(block: suspend () -> T): T {
repeat(MAX_INGEST_ATTEMPTS) { attempt ->
try {
return databaseFactory.query(block)
} catch (exception: ExposedSQLException) {
if (!exception.isDeadlock() || attempt == MAX_INGEST_ATTEMPTS - 1) throw exception
delay(DEADLOCK_RETRY_DELAY_MILLIS * (attempt + 1))
}
}
error("Unreachable analytics transaction retry state")
}
private fun linkInstallation(
installationHash: String,
accountId: String?,
receivedAt: Instant,
) {
AnalyticsInstallations.insertIgnore {
it[AnalyticsInstallations.installationHash] = installationHash
it[AnalyticsInstallations.accountId] = accountId
it[createdAt] = receivedAt
it[updatedAt] = receivedAt
}
val installation = AnalyticsInstallations
.selectAll()
.where { AnalyticsInstallations.installationHash eq installationHash }
.forUpdate()
.single()
val linkedAccount = installation[AnalyticsInstallations.accountId]
when {
accountId == null -> Unit
linkedAccount == null -> AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq installationHash
}) {
it[AnalyticsInstallations.accountId] = accountId
it[updatedAt] = receivedAt
}
linkedAccount != accountId ->
throw ConflictException("Installation is linked to another account")
}
AnalyticsInstallations.update({
AnalyticsInstallations.installationHash eq installationHash
}) {
it[updatedAt] = receivedAt
}
}
private fun insertEvent(batch: AnalyticsBatch, event: AnalyticsEvent) {
AnalyticsEvents.insert {
it[installationHash] = batch.installationHash
@@ -151,6 +225,29 @@ class ExposedAnalyticsRepository(
it[receivedAt] = batch.receivedAt
}
}
private fun insertKeyboardUsageSummary(
batch: KeyboardUsageBatch,
summary: KeyboardUsageSummary,
) {
KeyboardUsageSummaries.insert {
it[installationHash] = batch.installationHash
it[clientSummaryId] = summary.clientSummaryId.toString()
it[summaryDate] = summary.summaryDate
it[chineseCharacterCount] = summary.chineseCharacterCount
it[englishCharacterCount] = summary.englishCharacterCount
it[otherCharacterCount] = summary.otherCharacterCount
it[inputSessionCount] = summary.inputSessionCount
it[chineseOnlySessionCount] = summary.chineseOnlySessionCount
it[englishOnlySessionCount] = summary.englishOnlySessionCount
it[mixedLanguageSessionCount] = summary.mixedLanguageSessionCount
it[otherOnlySessionCount] = summary.otherOnlySessionCount
it[appVersion] = summary.appVersion
it[osVersion] = summary.osVersion
it[payloadHash] = summary.payloadHash
it[receivedAt] = batch.receivedAt
}
}
}
private object AnalyticsInstallations : Table("product_analytics_installations") {
@@ -185,6 +282,27 @@ private object AnalyticsEvents : Table("product_analytics_events") {
override val primaryKey = PrimaryKey(installationHash, clientEventId)
}
private object KeyboardUsageSummaries : Table("keyboard_usage_daily_summaries") {
val id = long("id").autoIncrement()
val installationHash = char("installation_hash", 64)
val clientSummaryId = char("client_summary_id", 36)
val summaryDate = date("summary_date")
val chineseCharacterCount = long("chinese_character_count")
val englishCharacterCount = long("english_character_count")
val otherCharacterCount = long("other_character_count")
val inputSessionCount = long("input_session_count")
val chineseOnlySessionCount = long("chinese_only_session_count")
val englishOnlySessionCount = long("english_only_session_count")
val mixedLanguageSessionCount = long("mixed_language_session_count")
val otherOnlySessionCount = long("other_only_session_count")
val appVersion = varchar("app_version", 32).nullable()
val osVersion = varchar("os_version", 32).nullable()
val payloadHash = char("payload_hash", 64)
val receivedAt = timestamp("received_at")
override val primaryKey = PrimaryKey(id)
}
private object AnalyticsDailyCounters : Table("product_analytics_daily_counters") {
val counterDate = date("counter_date")
val counterName = varchar("counter_name", 32)
@@ -195,3 +313,12 @@ private object AnalyticsDailyCounters : Table("product_analytics_daily_counters"
}
private const val INVITE_PAGE_OPENED = "INVITE_PAGE_OPENED"
private const val MAX_INGEST_ATTEMPTS = 4
private const val DEADLOCK_RETRY_DELAY_MILLIS = 10L
private fun ExposedSQLException.isDeadlock(): Boolean =
generateSequence<Throwable>(this) { it.cause }
.filterIsInstance<SQLException>()
.any { it.sqlState == "40001" || it.errorCode == MYSQL_DEADLOCK_ERROR_CODE }
private const val MYSQL_DEADLOCK_ERROR_CODE = 1213
@@ -5,9 +5,11 @@ import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsIngestResponse
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.services.AnalyticsService
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.header
@@ -21,25 +23,37 @@ import kotlinx.serialization.json.Json
fun Route.analyticsRoutes(service: AnalyticsService) {
authenticate(SESSION_AUTH_NAME, optional = true) {
post("/v1/analytics/events") {
val declaredLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val body = call.receiveText()
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val request = try {
ANALYTICS_JSON.decodeFromString<AnalyticsBatchRequest>(body)
} catch (_: SerializationException) {
throw InvalidRequestException("Analytics request body is invalid")
}
val request = call.receiveAnalyticsBody<AnalyticsBatchRequest>()
val result = service.ingest(
accountId = call.principal<AccountPrincipal>()?.userId,
request = request,
)
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
}
post("/v1/analytics/keyboard-usage") {
val request = call.receiveAnalyticsBody<KeyboardUsageBatchRequest>()
val result = service.ingestKeyboardUsage(
accountId = call.principal<AccountPrincipal>()?.userId,
request = request,
)
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
}
}
}
private suspend inline fun <reified T> ApplicationCall.receiveAnalyticsBody(): T {
val declaredLength = request.header(HttpHeaders.ContentLength)?.toLongOrNull()
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
val body = receiveText()
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
throw InvalidRequestException("Analytics request body is too large")
}
return try {
ANALYTICS_JSON.decodeFromString<T>(body)
} catch (_: SerializationException) {
throw InvalidRequestException("Analytics request body is invalid")
}
}
@@ -9,13 +9,20 @@ import com.osglab.account.features.analytics.domain.AnalyticsEventType
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
import java.util.UUID
@@ -24,6 +31,11 @@ interface AnalyticsService {
accountId: UUID?,
request: AnalyticsBatchRequest,
): AnalyticsIngestResult
suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult
}
class DefaultAnalyticsService(
@@ -50,6 +62,89 @@ class DefaultAnalyticsService(
)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult {
if (request.summaries.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) {
throw InvalidRequestException("summaries must contain between 1 and 50 items")
}
val installationId = parseUuid(request.installationId, "installationId")
val now = clock.instant()
val today = now.atZone(ZoneOffset.UTC).toLocalDate()
val summaries = request.summaries.map { validateAndMap(it, today) }
if (summaries.map(KeyboardUsageSummary::summaryDate).toSet().size != summaries.size) {
throw InvalidRequestException("summaries must contain at most one item per UTC date")
}
return repository.ingestKeyboardUsage(
KeyboardUsageBatch(
installationHash = installationId.toString().sha256Hex(),
accountId = accountId,
summaries = summaries,
receivedAt = now,
)
)
}
private fun validateAndMap(
request: KeyboardUsageSummaryRequest,
today: LocalDate,
): KeyboardUsageSummary {
val clientSummaryId = parseUuid(request.clientSummaryId, "clientSummaryId")
val summaryDate = parseSummaryDate(request.summaryDate)
if (summaryDate.isBefore(today.minusDays(MAX_SUMMARY_AGE_DAYS)) || !summaryDate.isBefore(today)) {
throw KeyboardUsageDateException()
}
validateReleaseIdentifier(request.appVersion, "appVersion")
validateReleaseIdentifier(request.osVersion, "osVersion")
validateKeyboardUsageCounts(request)
return KeyboardUsageSummary(
clientSummaryId = clientSummaryId,
summaryDate = summaryDate,
chineseCharacterCount = request.chineseCharacterCount,
englishCharacterCount = request.englishCharacterCount,
otherCharacterCount = request.otherCharacterCount,
inputSessionCount = request.inputSessionCount,
chineseOnlySessionCount = request.chineseOnlySessionCount,
englishOnlySessionCount = request.englishOnlySessionCount,
mixedLanguageSessionCount = request.mixedLanguageSessionCount,
otherOnlySessionCount = request.otherOnlySessionCount,
appVersion = request.appVersion,
osVersion = request.osVersion,
payloadHash = keyboardUsagePayloadHash(request, clientSummaryId, summaryDate),
)
}
private fun validateKeyboardUsageCounts(request: KeyboardUsageSummaryRequest) {
val characterCounts = listOf(
request.chineseCharacterCount,
request.englishCharacterCount,
request.otherCharacterCount,
)
val partitionedSessionCounts = listOf(
request.chineseOnlySessionCount,
request.englishOnlySessionCount,
request.mixedLanguageSessionCount,
request.otherOnlySessionCount,
)
if (characterCounts.any { it !in 0..MAX_DAILY_CHARACTER_COUNT }) {
throw InvalidRequestException("character counts must be between 0 and 1000000")
}
if (
request.inputSessionCount !in 1..MAX_DAILY_SESSION_COUNT ||
partitionedSessionCounts.any { it !in 0..MAX_DAILY_SESSION_COUNT }
) {
throw InvalidRequestException("session counts must be between 0 and 100000")
}
val totalCharacters = characterCounts.sum()
if (totalCharacters == 0L || totalCharacters < request.inputSessionCount) {
throw InvalidRequestException("each input session must contain a committed character")
}
if (partitionedSessionCounts.sum() != request.inputSessionCount) {
throw InvalidRequestException("language session counts must equal inputSessionCount")
}
}
private fun validateAndMap(request: AnalyticsEventRequest, now: Instant): AnalyticsEvent {
val clientEventId = parseUuid(request.clientEventId, "clientEventId")
val occurredAt = parseOccurredAt(request.occurredAt)
@@ -175,6 +270,13 @@ class DefaultAnalyticsService(
}
}
private fun parseSummaryDate(value: String): LocalDate =
try {
LocalDate.parse(value)
} catch (_: DateTimeParseException) {
throw InvalidRequestException("summaryDate must be a UTC ISO-8601 date")
}
private fun validateReleaseIdentifier(value: String?, field: String) {
if (value != null && !RELEASE_IDENTIFIER.matches(value)) {
throw InvalidRequestException("$field must be a 1 to 32 character release identifier")
@@ -210,6 +312,25 @@ class DefaultAnalyticsService(
event.osVersion,
).joinToString(separator = "\u0000") { it ?: "" }.sha256Hex()
private fun keyboardUsagePayloadHash(
summary: KeyboardUsageSummaryRequest,
clientSummaryId: UUID,
summaryDate: LocalDate,
): String = listOf(
clientSummaryId.toString(),
summaryDate.toString(),
summary.chineseCharacterCount,
summary.englishCharacterCount,
summary.otherCharacterCount,
summary.inputSessionCount,
summary.chineseOnlySessionCount,
summary.englishOnlySessionCount,
summary.mixedLanguageSessionCount,
summary.otherOnlySessionCount,
summary.appVersion,
summary.osVersion,
).joinToString(separator = "\u0000") { it?.toString() ?: "" }.sha256Hex()
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
@@ -218,6 +339,9 @@ class DefaultAnalyticsService(
private companion object {
const val MIN_BATCH_SIZE = 1
const val MAX_BATCH_SIZE = 50
const val MAX_SUMMARY_AGE_DAYS = 35L
const val MAX_DAILY_CHARACTER_COUNT = 1_000_000L
const val MAX_DAILY_SESSION_COUNT = 100_000L
val MAX_EVENT_AGE: Duration = Duration.ofDays(35)
val MAX_FUTURE_SKEW: Duration = Duration.ofMinutes(5)
val UUID_PATTERN =
@@ -235,11 +359,17 @@ class AnalyticsMaintenanceService(
require(!anonymousRetention.isNegative && !anonymousRetention.isZero)
}
suspend fun purgeStaleAnonymousInstallations(): Int =
repository.purgeAnonymousInstallations(
before = clock.instant().minus(anonymousRetention),
suspend fun purgeStaleAnonymousInstallations(): Int {
val retentionCutoff = clock.instant().minus(anonymousRetention)
repository.purgeKeyboardUsageSummaries(
before = retentionCutoff.atZone(ZoneOffset.UTC).toLocalDate(),
limit = PURGE_BATCH_SIZE,
)
return repository.purgeAnonymousInstallations(
before = retentionCutoff,
limit = PURGE_BATCH_SIZE,
)
}
private companion object {
const val PURGE_BATCH_SIZE = 1_000
@@ -0,0 +1,154 @@
package com.osglab.account.features.content.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import java.time.Instant
@Serializable
data class SkillLocalizationDto(
val name: String,
val summary: String,
val prompt: String,
)
@Serializable
data class SkillLocalizationsDto(
@SerialName("zh-Hans")
val zhHans: SkillLocalizationDto,
val en: SkillLocalizationDto,
)
@Serializable
data class OfficialSkillDto(
val id: String,
val systemImage: String,
val sortOrder: Int,
val kind: String = "transform",
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class AdminOfficialSkillDto(
val id: String,
val systemImage: String,
val sortOrder: Int,
val kind: String = "transform",
val thinkingEnabled: Boolean,
val enabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class CreateOfficialSkillRequest(
val id: String,
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class UpdateOfficialSkillRequest(
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val localizations: SkillLocalizationsDto,
)
@Serializable
data class SkillCatalogResponse(
val schemaVersion: Int = 1,
val revision: Long,
val generatedAt: String? = null,
val skills: List<OfficialSkillDto>,
)
@Serializable
data class AdminSkillCatalogResponse(
val revision: Long,
val generatedAt: String? = null,
val skills: List<AdminOfficialSkillDto>,
)
data class OfficialSkillRecord(
val id: String,
val systemImage: String,
val sortOrder: Int,
val thinkingEnabled: Boolean,
val enabled: Boolean,
val localizations: SkillLocalizationsDto,
)
data class SkillCatalogRecord(
val revision: Long,
val generatedAt: Instant?,
val skills: List<OfficialSkillRecord>,
)
@Serializable
data class AIHintCardDto(
val id: String,
val displayText: String? = null,
val text: String? = null,
val prompt: String,
val category: String = "general",
val priority: Int = 50,
val source: String = "remote",
val locale: String,
val conditions: List<String> = emptyList(),
val metadata: JsonObject? = null,
)
@Serializable
data class AIHintPackResponse(
val locale: String,
val generatedAt: String? = null,
val expiresAt: String? = null,
val version: Int,
val cards: List<AIHintCardDto>,
)
@Serializable
data class AIHintManifestResponse(
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val locales: List<String> = emptyList(),
val files: Map<String, String?> = emptyMap(),
)
@Serializable
data class AdminHintPackResponse(
val locale: String,
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val version: Int,
val cards: List<AIHintCardDto>,
)
@Serializable
data class UpdateHintPackRequest(
val generatedAt: String? = null,
val expiresAt: String? = null,
val intervalHours: Int? = null,
val cards: List<AIHintCardDto>,
)
data class HintPackRecord(
val locale: String,
val generatedAt: Instant?,
val expiresAt: Instant?,
val intervalHours: Int?,
val version: Int,
val cardsJson: String,
)
enum class ContentMutationResult {
SUCCESS,
NOT_FOUND,
CONFLICT,
LIMIT_EXCEEDED,
}
@@ -0,0 +1,369 @@
package com.osglab.account.features.content.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogRecord
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
internal object OfficialContentCatalogTable : Table("official_content_catalog") {
val id = integer("id")
val revision = long("revision")
val generatedAt = timestamp("generated_at").nullable()
override val primaryKey = PrimaryKey(id)
}
internal object OfficialSkillsTable : Table("official_skills") {
val id = varchar("id", 128)
val systemImage = varchar("system_image", 128)
val sortOrder = integer("sort_order")
val thinkingEnabled = bool("thinking_enabled")
val enabled = bool("enabled")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object OfficialSkillLocalizationsTable : Table("official_skill_localizations") {
val skillId = varchar("skill_id", 128)
val locale = varchar("locale", 16)
val name = varchar("name", 120)
val summary = varchar("summary", 500)
val prompt = text("prompt")
override val primaryKey = PrimaryKey(skillId, locale)
}
internal object OfficialHintPacksTable : Table("official_hint_packs") {
val locale = varchar("locale", 8)
val generatedAt = timestamp("generated_at").nullable()
val expiresAt = timestamp("expires_at").nullable()
val intervalHours = integer("interval_hours").nullable()
val version = integer("version")
val cardsJson = text("cards_json")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(locale)
}
interface ContentRepository {
suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord
suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult
suspend fun listHintPacks(): List<HintPackRecord>
suspend fun getHintPack(locale: String): HintPackRecord?
suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord
}
class ExposedContentRepository(
private val databaseFactory: DatabaseFactory,
) : ContentRepository {
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
databaseFactory.query {
val catalog = catalogRow()
val statement = OfficialSkillsTable.selectAll()
if (enabledOnly) {
statement.where { OfficialSkillsTable.enabled eq true }
}
val skills = statement
.orderBy(
OfficialSkillsTable.sortOrder to SortOrder.ASC,
OfficialSkillsTable.id to SortOrder.ASC,
)
.map { row -> row.toSkillRecord(localizations(row[OfficialSkillsTable.id])) }
SkillCatalogRecord(
revision = catalog[OfficialContentCatalogTable.revision],
generatedAt = catalog[OfficialContentCatalogTable.generatedAt],
skills = skills,
)
}
override suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val exists = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq skill.id }
.limit(1)
.any()
val result = if (exists) {
ContentMutationResult.CONFLICT
} else {
OfficialSkillsTable.insert {
it[id] = skill.id
it[systemImage] = skill.systemImage
it[sortOrder] = skill.sortOrder
it[thinkingEnabled] = skill.thinkingEnabled
it[enabled] = skill.enabled
it[createdAt] = now
it[updatedAt] = now
}
insertLocalizations(skill)
incrementRevision(now)
ContentMutationResult.SUCCESS
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val exists = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq skill.id }
.forUpdate()
.singleOrNull() != null
val result = if (!exists) {
ContentMutationResult.NOT_FOUND
} else {
OfficialSkillsTable.update({ OfficialSkillsTable.id eq skill.id }) {
it[systemImage] = skill.systemImage
it[sortOrder] = skill.sortOrder
it[thinkingEnabled] = skill.thinkingEnabled
it[updatedAt] = now
}
updateLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
updateLocalization(skill.id, EN, skill.localizations.en)
incrementRevision(now)
ContentMutationResult.SUCCESS
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult = databaseFactory.query {
lockCatalog()
val row = OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.id eq id }
.forUpdate()
.singleOrNull()
val result = when {
row == null -> ContentMutationResult.NOT_FOUND
enabled &&
!row[OfficialSkillsTable.enabled] &&
enabledSkillCount() >= MAXIMUM_ENABLED_SKILLS -> ContentMutationResult.LIMIT_EXCEEDED
else -> {
OfficialSkillsTable.update({ OfficialSkillsTable.id eq id }) {
it[OfficialSkillsTable.enabled] = enabled
it[updatedAt] = now
}
incrementRevision(now)
ContentMutationResult.SUCCESS
}
}
insertAudit(audit.withOutcome(result))
result
}
override suspend fun listHintPacks(): List<HintPackRecord> =
databaseFactory.query {
OfficialHintPacksTable.selectAll()
.orderBy(OfficialHintPacksTable.locale to SortOrder.ASC)
.map(ResultRow::toHintPackRecord)
}
override suspend fun getHintPack(locale: String): HintPackRecord? =
databaseFactory.query {
OfficialHintPacksTable.selectAll()
.where { OfficialHintPacksTable.locale eq locale }
.limit(1)
.singleOrNull()
?.toHintPackRecord()
}
override suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord = databaseFactory.query {
// The singleton lock makes the initial version=1 insert race-free.
lockCatalog()
val current = OfficialHintPacksTable.selectAll()
.where { OfficialHintPacksTable.locale eq pack.locale }
.forUpdate()
.singleOrNull()
val next = pack.copy(version = (current?.get(OfficialHintPacksTable.version) ?: 0) + 1)
if (current == null) {
OfficialHintPacksTable.insert {
it[locale] = next.locale
it[generatedAt] = next.generatedAt
it[expiresAt] = next.expiresAt
it[intervalHours] = next.intervalHours
it[version] = next.version
it[cardsJson] = next.cardsJson
it[updatedAt] = now
}
} else {
OfficialHintPacksTable.update({ OfficialHintPacksTable.locale eq next.locale }) {
it[generatedAt] = next.generatedAt
it[expiresAt] = next.expiresAt
it[intervalHours] = next.intervalHours
it[version] = next.version
it[cardsJson] = next.cardsJson
it[updatedAt] = now
}
}
insertAudit(audit.copy(outcome = AdminAuditOutcome.SUCCESS))
next
}
private fun catalogRow(): ResultRow =
OfficialContentCatalogTable.selectAll()
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
.single()
private fun lockCatalog(): ResultRow =
OfficialContentCatalogTable.selectAll()
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
.forUpdate()
.single()
private fun incrementRevision(now: Instant) {
val current = catalogRow()[OfficialContentCatalogTable.revision]
OfficialContentCatalogTable.update({ OfficialContentCatalogTable.id eq CATALOG_ID }) {
it[revision] = current + 1
it[generatedAt] = now
}
}
private fun enabledSkillCount(): Long =
OfficialSkillsTable.selectAll()
.where { OfficialSkillsTable.enabled eq true }
.count()
private fun localizations(skillId: String): SkillLocalizationsDto {
val rows = OfficialSkillLocalizationsTable.selectAll()
.where { OfficialSkillLocalizationsTable.skillId eq skillId }
.associateBy { it[OfficialSkillLocalizationsTable.locale] }
return SkillLocalizationsDto(
zhHans = requireNotNull(rows[ZH_HANS]).toLocalization(),
en = requireNotNull(rows[EN]).toLocalization(),
)
}
private fun insertLocalizations(skill: OfficialSkillRecord) {
insertLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
insertLocalization(skill.id, EN, skill.localizations.en)
}
private fun insertLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
OfficialSkillLocalizationsTable.insert {
it[OfficialSkillLocalizationsTable.skillId] = skillId
it[OfficialSkillLocalizationsTable.locale] = locale
it[name] = value.name
it[summary] = value.summary
it[prompt] = value.prompt
}
}
private fun updateLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
OfficialSkillLocalizationsTable.update({
(OfficialSkillLocalizationsTable.skillId eq skillId) and
(OfficialSkillLocalizationsTable.locale eq locale)
}) {
it[name] = value.name
it[summary] = value.summary
it[prompt] = value.prompt
}
}
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
}
private fun ResultRow.toSkillRecord(localizations: SkillLocalizationsDto): OfficialSkillRecord =
OfficialSkillRecord(
id = this[OfficialSkillsTable.id],
systemImage = this[OfficialSkillsTable.systemImage],
sortOrder = this[OfficialSkillsTable.sortOrder],
thinkingEnabled = this[OfficialSkillsTable.thinkingEnabled],
enabled = this[OfficialSkillsTable.enabled],
localizations = localizations,
)
private fun ResultRow.toLocalization(): SkillLocalizationDto =
SkillLocalizationDto(
name = this[OfficialSkillLocalizationsTable.name],
summary = this[OfficialSkillLocalizationsTable.summary],
prompt = this[OfficialSkillLocalizationsTable.prompt],
)
private fun ResultRow.toHintPackRecord(): HintPackRecord =
HintPackRecord(
locale = this[OfficialHintPacksTable.locale],
generatedAt = this[OfficialHintPacksTable.generatedAt],
expiresAt = this[OfficialHintPacksTable.expiresAt],
intervalHours = this[OfficialHintPacksTable.intervalHours],
version = this[OfficialHintPacksTable.version],
cardsJson = this[OfficialHintPacksTable.cardsJson],
)
private fun NewAdminAuditEvent.withOutcome(result: ContentMutationResult): NewAdminAuditEvent =
copy(
outcome = if (result == ContentMutationResult.SUCCESS) {
AdminAuditOutcome.SUCCESS
} else {
AdminAuditOutcome.DENIED
},
)
private const val CATALOG_ID = 1
private const val MAXIMUM_ENABLED_SKILLS = 100L
private const val ZH_HANS = "zh-Hans"
private const val EN = "en"
@@ -0,0 +1,111 @@
package com.osglab.account.features.content.routes
import com.osglab.account.features.content.models.AIHintManifestResponse
import com.osglab.account.features.content.services.ContentErrorCode
import com.osglab.account.features.content.services.ContentException
import com.osglab.account.features.content.services.ContentService
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.header
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.security.MessageDigest
fun Route.contentRoutes(service: ContentService) {
route("/v1/content") {
get("/skills") {
val catalog = service.publicSkills()
call.respondCacheable(etag = "skills-${catalog.revision}") {
call.respond(catalog)
}
}
get("/hints/manifest") {
call.respondHintManifest(service)
}
get("/hints/{locale}") {
val locale = call.parameters["locale"]
call.respondHintPack(service, locale)
}
}
get("/hints/manifest.json") {
call.respondHintManifest(service)
}
get("/hints/{fileName}") {
val fileName = call.parameters["fileName"].orEmpty()
val locale = LEGACY_HINT_FILE.matchEntire(fileName)?.groupValues?.get(1)
call.respondHintPack(service, locale)
}
}
private suspend fun ApplicationCall.respondHintManifest(service: ContentService) {
val manifest = service.hintManifest()
respondCacheable(etag = manifest.etag()) {
respond(manifest)
}
}
private suspend fun ApplicationCall.respondHintPack(
service: ContentService,
locale: String?,
) {
if (locale !in SUPPORTED_HINT_LOCALES) {
respond(HttpStatusCode.NotFound)
return
}
try {
val pack = service.publicHintPack(requireNotNull(locale))
respondCacheable(etag = "hints-$locale-${pack.version}") {
respond(pack)
}
} catch (exception: ContentException) {
if (exception.code == ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND) {
respond(HttpStatusCode.NotFound)
} else {
throw exception
}
}
}
private suspend fun ApplicationCall.respondCacheable(
etag: String,
body: suspend () -> Unit,
) {
val quotedEtag = """"$etag""""
response.header(HttpHeaders.ETag, quotedEtag)
response.header(HttpHeaders.CacheControl, "public,max-age=300")
if (request.header(HttpHeaders.IfNoneMatch).matchesEtag(quotedEtag)) {
respond(HttpStatusCode.NotModified)
} else {
body()
}
}
private fun String?.matchesEtag(etag: String): Boolean =
this?.split(',')?.any { candidate ->
val normalized = candidate.trim().removePrefix("W/")
normalized == "*" || normalized == etag
} == true
private fun AIHintManifestResponse.etag(): String {
val bytes = PUBLIC_JSON.encodeToString(this).toByteArray(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return "hints-manifest-${digest.take(12).joinToString("") { "%02x".format(it.toInt() and 0xff) }}"
}
private val PUBLIC_JSON = Json {
explicitNulls = false
encodeDefaults = true
}
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
private val LEGACY_HINT_FILE = Regex("""hints-(zh|en)\.json""")
@@ -0,0 +1,348 @@
package com.osglab.account.features.content.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintManifestResponse
import com.osglab.account.features.content.models.AIHintPackResponse
import com.osglab.account.features.content.models.AdminHintPackResponse
import com.osglab.account.features.content.models.AdminOfficialSkillDto
import com.osglab.account.features.content.models.AdminSkillCatalogResponse
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillDto
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogResponse
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
import com.osglab.account.features.content.repositories.ContentRepository
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Clock
import java.time.Instant
enum class ContentErrorCode {
VALIDATION_ERROR,
CONTENT_SKILL_NOT_FOUND,
CONTENT_SKILL_CONFLICT,
CONTENT_HINT_PACK_NOT_FOUND,
}
class ContentException(
val code: ContentErrorCode,
) : RuntimeException(code.name)
class ContentService(
private val repository: ContentRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun publicSkills(): SkillCatalogResponse {
val catalog = repository.getSkillCatalog(enabledOnly = true)
check(catalog.skills.size <= MAXIMUM_ENABLED_SKILLS) {
"Official Skill catalog exceeds the client maximum"
}
return SkillCatalogResponse(
revision = catalog.revision,
generatedAt = catalog.generatedAt?.toString(),
skills = catalog.skills.map(OfficialSkillRecord::toPublicDto),
)
}
suspend fun adminSkills(): AdminSkillCatalogResponse {
val catalog = repository.getSkillCatalog(enabledOnly = false)
return AdminSkillCatalogResponse(
revision = catalog.revision,
generatedAt = catalog.generatedAt?.toString(),
skills = catalog.skills.map(OfficialSkillRecord::toAdminDto),
)
}
suspend fun createSkill(
actor: AdminPrincipal,
request: CreateOfficialSkillRequest,
requestId: String?,
): AdminOfficialSkillDto {
validateSkill(
id = request.id,
systemImage = request.systemImage,
sortOrder = request.sortOrder,
localizations = request.localizations,
)
val now = clock.instant()
val skill = OfficialSkillRecord(
id = request.id,
systemImage = request.systemImage.trim(),
sortOrder = request.sortOrder,
thinkingEnabled = request.thinkingEnabled,
enabled = false,
localizations = request.localizations.trimmed(),
)
repository.createSkill(
skill,
now,
audit(actor, AdminAuditAction.CONTENT_SKILL_CREATED, "OFFICIAL_SKILL", skill.id, requestId, now),
).throwOnFailure()
return skill.toAdminDto()
}
suspend fun updateSkill(
actor: AdminPrincipal,
id: String,
request: UpdateOfficialSkillRequest,
requestId: String?,
): AdminOfficialSkillDto {
validateSkill(id, request.systemImage, request.sortOrder, request.localizations)
val now = clock.instant()
val skill = OfficialSkillRecord(
id = id,
systemImage = request.systemImage.trim(),
sortOrder = request.sortOrder,
thinkingEnabled = request.thinkingEnabled,
enabled = false,
localizations = request.localizations.trimmed(),
)
repository.updateSkill(
skill,
now,
audit(actor, AdminAuditAction.CONTENT_SKILL_UPDATED, "OFFICIAL_SKILL", id, requestId, now),
).throwOnFailure()
val stored = repository.getSkillCatalog(enabledOnly = false).skills.first { it.id == id }
return stored.toAdminDto()
}
suspend fun setSkillEnabled(
actor: AdminPrincipal,
id: String,
enabled: Boolean,
requestId: String?,
) {
validateSkillId(id)
val now = clock.instant()
repository.setSkillEnabled(
id = id,
enabled = enabled,
now = now,
audit = audit(
actor,
if (enabled) AdminAuditAction.CONTENT_SKILL_ENABLED else AdminAuditAction.CONTENT_SKILL_DISABLED,
"OFFICIAL_SKILL",
id,
requestId,
now,
),
).throwOnFailure()
}
suspend fun hintManifest(): AIHintManifestResponse {
val packs = repository.listHintPacks()
return AIHintManifestResponse(
generatedAt = packs.mapNotNull(HintPackRecord::generatedAt).maxOrNull()?.toString(),
expiresAt = packs.mapNotNull(HintPackRecord::expiresAt).minOrNull()?.toString(),
intervalHours = packs.mapNotNull(HintPackRecord::intervalHours).minOrNull(),
locales = packs.map(HintPackRecord::locale),
files = packs.associate { it.locale to "/v1/content/hints/${it.locale}" },
)
}
suspend fun publicHintPack(locale: String): AIHintPackResponse {
validateHintLocale(locale)
return repository.getHintPack(locale)?.toPublicDto()
?: throw ContentException(ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND)
}
suspend fun adminHintPack(locale: String): AdminHintPackResponse {
validateHintLocale(locale)
return repository.getHintPack(locale)?.toAdminDto()
?: AdminHintPackResponse(locale = locale, version = 0, cards = emptyList())
}
suspend fun putHintPack(
actor: AdminPrincipal,
locale: String,
request: UpdateHintPackRequest,
requestId: String?,
): AdminHintPackResponse {
validateHintLocale(locale)
val generatedAt = parseInstant(request.generatedAt)
val expiresAt = parseInstant(request.expiresAt)
if (generatedAt != null && expiresAt != null && !expiresAt.isAfter(generatedAt)) {
invalid()
}
if (request.intervalHours != null && request.intervalHours !in 1..168) invalid()
validateCards(locale, request.cards)
val cardsJson = CONTENT_JSON.encodeToString(
ListSerializer(AIHintCardDto.serializer()),
request.cards,
)
if (cardsJson.length > MAX_HINT_PACK_CHARACTERS) invalid()
val now = clock.instant()
val stored = repository.putHintPack(
HintPackRecord(
locale = locale,
generatedAt = generatedAt,
expiresAt = expiresAt,
intervalHours = request.intervalHours,
version = 0,
cardsJson = cardsJson,
),
now,
audit(
actor,
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
"OFFICIAL_HINT_PACK",
locale,
requestId,
now,
),
)
return stored.toAdminDto()
}
private fun validateSkill(
id: String,
systemImage: String,
sortOrder: Int,
localizations: SkillLocalizationsDto,
) {
validateSkillId(id)
if (systemImage.trim().length !in 1..MAX_SYSTEM_IMAGE_CHARACTERS) invalid()
if (sortOrder !in 0..100_000) invalid()
validateLocalization(localizations.zhHans)
validateLocalization(localizations.en)
}
private fun validateSkillId(id: String) {
if (!SKILL_ID.matches(id)) invalid()
}
private fun validateHintLocale(locale: String) {
if (locale !in SUPPORTED_HINT_LOCALES) invalid()
}
private fun validateLocalization(value: SkillLocalizationDto) {
if (value.name.trim().length !in 1..MAX_SKILL_NAME_CHARACTERS) invalid()
if (value.summary.trim().length !in 1..MAX_SKILL_SUMMARY_CHARACTERS) invalid()
if (value.prompt.trim().length !in 1..MAX_SKILL_PROMPT_CHARACTERS) invalid()
}
private fun validateCards(locale: String, cards: List<AIHintCardDto>) {
if (cards.size > MAX_HINT_CARDS || cards.map { it.id }.toSet().size != cards.size) invalid()
cards.forEach { card ->
if (card.id.trim().length !in 1..128) invalid()
if (card.locale != locale) invalid()
if (card.displayText.isNullOrBlank() && card.text.isNullOrBlank()) invalid()
if ((card.displayText?.length ?: 0) > 500 || (card.text?.length ?: 0) > 500) invalid()
if (card.prompt.trim().length !in 1..MAX_HINT_PROMPT_CHARACTERS) invalid()
if (card.category.trim().length !in 1..64 || card.source.trim().length !in 1..64) invalid()
if (card.priority !in -10_000..10_000) invalid()
if (card.conditions.size > 20 || card.conditions.any { it.length !in 1..64 }) invalid()
if ((card.metadata?.toString()?.length ?: 0) > MAX_METADATA_CHARACTERS) invalid()
}
}
private fun parseInstant(value: String?): Instant? =
value?.let {
runCatching { Instant.parse(it) }.getOrElse { invalid() }
}
private fun ContentMutationResult.throwOnFailure() {
when (this) {
ContentMutationResult.SUCCESS -> Unit
ContentMutationResult.NOT_FOUND ->
throw ContentException(ContentErrorCode.CONTENT_SKILL_NOT_FOUND)
ContentMutationResult.CONFLICT ->
throw ContentException(ContentErrorCode.CONTENT_SKILL_CONFLICT)
ContentMutationResult.LIMIT_EXCEEDED ->
throw ContentException(ContentErrorCode.VALIDATION_ERROR)
}
}
}
private fun OfficialSkillRecord.toPublicDto(): OfficialSkillDto =
OfficialSkillDto(
id = id,
systemImage = systemImage,
sortOrder = sortOrder,
thinkingEnabled = thinkingEnabled,
localizations = localizations,
)
private fun OfficialSkillRecord.toAdminDto(): AdminOfficialSkillDto =
AdminOfficialSkillDto(
id = id,
systemImage = systemImage,
sortOrder = sortOrder,
thinkingEnabled = thinkingEnabled,
enabled = enabled,
localizations = localizations,
)
private fun HintPackRecord.toPublicDto(): AIHintPackResponse =
AIHintPackResponse(
locale = locale,
generatedAt = generatedAt?.toString(),
expiresAt = expiresAt?.toString(),
version = version,
cards = decodeCards(),
)
private fun HintPackRecord.toAdminDto(): AdminHintPackResponse =
AdminHintPackResponse(
locale = locale,
generatedAt = generatedAt?.toString(),
expiresAt = expiresAt?.toString(),
intervalHours = intervalHours,
version = version,
cards = decodeCards(),
)
private fun HintPackRecord.decodeCards(): List<AIHintCardDto> =
CONTENT_JSON.decodeFromString(ListSerializer(AIHintCardDto.serializer()), cardsJson)
private fun SkillLocalizationsDto.trimmed(): SkillLocalizationsDto =
SkillLocalizationsDto(zhHans.trimmed(), en.trimmed())
private fun SkillLocalizationDto.trimmed(): SkillLocalizationDto =
SkillLocalizationDto(name.trim(), summary.trim(), prompt.trim())
private fun audit(
actor: AdminPrincipal,
action: AdminAuditAction,
targetType: String,
targetId: String,
requestId: String?,
now: Instant,
): NewAdminAuditEvent = NewAdminAuditEvent(
actorOperatorId = actor.operatorId,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = targetType,
targetId = targetId,
requestId = requestId,
occurredAt = now,
)
private fun invalid(): Nothing = throw ContentException(ContentErrorCode.VALIDATION_ERROR)
private val CONTENT_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
encodeDefaults = true
}
private val SKILL_ID = Regex("""official\.[a-z0-9._-]{1,91}""")
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
private const val MAX_SYSTEM_IMAGE_CHARACTERS = 100
private const val MAXIMUM_ENABLED_SKILLS = 100
private const val MAX_SKILL_NAME_CHARACTERS = 40
private const val MAX_SKILL_SUMMARY_CHARACTERS = 200
private const val MAX_SKILL_PROMPT_CHARACTERS = 6_000
private const val MAX_HINT_PROMPT_CHARACTERS = 16_000
private const val MAX_HINT_CARDS = 500
private const val MAX_METADATA_CHARACTERS = 8_000
private const val MAX_HINT_PACK_CHARACTERS = 1_000_000
@@ -127,6 +127,12 @@ class CreditService(
return transactions.inTransaction { it.credits.listLedgerEntries(userId, limit) }
}
suspend fun hasSignupTrial(userId: UUID): Boolean =
transactions.inTransaction { unit ->
unit.credits.findLedgerEntry(userId, signupTrialIdempotencyKey(userId))
?.type == LedgerEntryType.SIGNUP_TRIAL
}
override suspend fun getReservation(
userId: UUID,
reservationId: UUID,
@@ -740,3 +746,6 @@ class CreditService(
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
internal fun signupTrialIdempotencyKey(userId: UUID): String =
"internal:signup-trial:$userId"
@@ -307,6 +307,12 @@ interface DeviceCheckTrialClaimRepository {
fun interface TrialCreditGranter {
suspend fun grant(accountId: UUID)
/**
* DeviceCheck tokens are ephemeral and cannot identify a previous claim.
* The immutable credit ledger is the authoritative account-level record.
*/
suspend fun wasGranted(accountId: UUID): Boolean = false
}
interface DeviceCheckTrialMutex {
@@ -341,8 +347,19 @@ private object LocalDeviceCheckTrialMutex : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T = block()
}
enum class SignupTrialClaimResult {
GRANTED,
ALREADY_GRANTED,
INELIGIBLE,
SKIPPED,
;
val shouldRestrictAccount: Boolean
get() = this == INELIGIBLE
}
fun interface SignupTrialClaimService {
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): SignupTrialClaimResult
}
class DeviceCheckTrialService(
@@ -353,35 +370,47 @@ class DeviceCheckTrialService(
private val mutex: DeviceCheckTrialMutex = LocalDeviceCheckTrialMutex,
private val clock: Clock = Clock.systemUTC(),
) : SignupTrialClaimService {
override suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean {
if (deviceToken.isNullOrBlank()) return false
override suspend fun claimAndGrant(
accountId: UUID,
deviceToken: String?,
): SignupTrialClaimResult {
if (creditGranter.wasGranted(accountId)) {
return SignupTrialClaimResult.ALREADY_GRANTED
}
if (deviceToken.isNullOrBlank()) return SignupTrialClaimResult.SKIPPED
val tokenHash = sha256Hex(deviceToken)
val owned = when (val result = repository.begin(tokenHash, accountId, clock.instant())) {
is BeginTrialClaim.Owned -> result.claim
BeginTrialClaim.ClaimedByAnotherAccount -> return false
BeginTrialClaim.ClaimedByAnotherAccount -> return SignupTrialClaimResult.INELIGIBLE
}
return try {
mutex.withLock {
if (creditGranter.wasGranted(accountId)) {
return@withLock SignupTrialClaimResult.ALREADY_GRANTED
}
completeOwnedClaim(owned, deviceToken)
}
} catch (exception: DeviceCheckRejectedException) {
repository.transition(tokenHash, TrialClaimStatus.REJECTED, clock.instant())
false
SignupTrialClaimResult.INELIGIBLE
} catch (exception: DeviceCheckUnavailableException) {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException("DeviceCheck")
}
false
SignupTrialClaimResult.SKIPPED
}
}
private suspend fun completeOwnedClaim(claim: TrialClaim, deviceToken: String): Boolean {
private suspend fun completeOwnedClaim(
claim: TrialClaim,
deviceToken: String,
): SignupTrialClaimResult {
when (claim.status) {
TrialClaimStatus.COMPLETED -> return true
TrialClaimStatus.REJECTED -> return false
TrialClaimStatus.COMPLETED -> return SignupTrialClaimResult.ALREADY_GRANTED
TrialClaimStatus.REJECTED -> return SignupTrialClaimResult.INELIGIBLE
TrialClaimStatus.APPLE_MARKED -> {
grantAndComplete(claim)
return true
return SignupTrialClaimResult.GRANTED
}
TrialClaimStatus.RESERVED -> Unit
}
@@ -392,7 +421,7 @@ class DeviceCheckTrialService(
}
if (state.bit0) {
repository.transition(claim.tokenHash, TrialClaimStatus.REJECTED, clock.instant())
return false
return SignupTrialClaimResult.INELIGIBLE
}
// Apple has no compare-and-set API. Marking first is intentionally conservative:
@@ -407,7 +436,7 @@ class DeviceCheckTrialService(
}
repository.transition(claim.tokenHash, TrialClaimStatus.APPLE_MARKED, clock.instant())
grantAndComplete(claim)
return true
return SignupTrialClaimResult.GRANTED
}
private suspend fun grantAndComplete(claim: TrialClaim) {
@@ -0,0 +1,14 @@
-- Accounts with an immutable signup-trial grant were incorrectly restricted
-- when a later sign-in supplied a fresh ephemeral DeviceCheck token.
UPDATE accounts AS account
SET
account.anti_abuse_restricted = FALSE,
account.updated_at = CURRENT_TIMESTAMP(6)
WHERE account.anti_abuse_restricted = TRUE
AND EXISTS (
SELECT 1
FROM credit_ledger AS ledger
WHERE ledger.user_id = account.id
AND ledger.entry_type = 'SIGNUP_TRIAL'
AND ledger.idempotency_key = CONCAT('internal:signup-trial:', account.id)
);
@@ -0,0 +1,55 @@
-- Official content is immediately published. The singleton catalog row
-- serializes revision changes and also protects first-write hint versions.
CREATE TABLE official_content_catalog (
id TINYINT UNSIGNED NOT NULL,
revision BIGINT UNSIGNED NOT NULL DEFAULT 0,
generated_at DATETIME(6) NULL,
PRIMARY KEY (id),
CONSTRAINT chk_official_content_catalog_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO official_content_catalog (id, revision, generated_at)
VALUES (1, 0, NULL);
CREATE TABLE official_skills (
id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
system_image VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
sort_order INT NOT NULL,
thinking_enabled BOOLEAN NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_official_skills_enabled_sort (enabled, sort_order, id),
CONSTRAINT chk_official_skills_id CHECK (id LIKE 'official.%')
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE official_skill_localizations (
skill_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
locale VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
name VARCHAR(120) NOT NULL,
summary VARCHAR(500) NOT NULL,
prompt TEXT NOT NULL,
PRIMARY KEY (skill_id, locale),
CONSTRAINT fk_official_skill_localizations_skill
FOREIGN KEY (skill_id) REFERENCES official_skills (id) ON DELETE CASCADE,
CONSTRAINT chk_official_skill_localizations_locale
CHECK (locale IN ('zh-Hans', 'en'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE official_hint_packs (
locale VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
generated_at DATETIME(6) NULL,
expires_at DATETIME(6) NULL,
interval_hours INT NULL,
version INT UNSIGNED NOT NULL,
cards_json MEDIUMTEXT NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (locale),
CONSTRAINT chk_official_hint_packs_locale CHECK (locale IN ('zh', 'en')),
CONSTRAINT chk_official_hint_packs_interval
CHECK (interval_hours IS NULL OR interval_hours BETWEEN 1 AND 168),
CONSTRAINT chk_official_hint_packs_version CHECK (version >= 1),
CONSTRAINT chk_official_hint_packs_expiry
CHECK (expires_at IS NULL OR generated_at IS NULL OR expires_at > generated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,51 @@
-- Privacy-minimized daily counters for text manually committed by OSGKeyboard.
-- Raw text, key sequences, surrounding context and host application identifiers
-- are intentionally absent.
CREATE TABLE keyboard_usage_daily_summaries (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
client_summary_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
summary_date DATE NOT NULL,
chinese_character_count BIGINT UNSIGNED NOT NULL,
english_character_count BIGINT UNSIGNED NOT NULL,
other_character_count BIGINT UNSIGNED NOT NULL,
input_session_count BIGINT UNSIGNED NOT NULL,
chinese_only_session_count BIGINT UNSIGNED NOT NULL,
english_only_session_count BIGINT UNSIGNED NOT NULL,
mixed_language_session_count BIGINT UNSIGNED NOT NULL,
other_only_session_count BIGINT UNSIGNED NOT NULL,
app_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
os_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
received_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_keyboard_usage_installation_id (installation_hash, client_summary_id),
UNIQUE KEY uq_keyboard_usage_installation_date (installation_hash, summary_date),
INDEX ix_keyboard_usage_summary_date (summary_date),
CONSTRAINT fk_keyboard_usage_installation
FOREIGN KEY (installation_hash)
REFERENCES product_analytics_installations (installation_hash)
ON DELETE CASCADE,
CONSTRAINT chk_keyboard_usage_chinese_count
CHECK (chinese_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_english_count
CHECK (english_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_other_count
CHECK (other_character_count <= 1000000),
CONSTRAINT chk_keyboard_usage_session_count
CHECK (input_session_count BETWEEN 1 AND 100000),
CONSTRAINT chk_keyboard_usage_session_partitions CHECK (
chinese_only_session_count <= 100000
AND english_only_session_count <= 100000
AND mixed_language_session_count <= 100000
AND other_only_session_count <= 100000
AND chinese_only_session_count
+ english_only_session_count
+ mixed_language_session_count
+ other_only_session_count = input_session_count
),
CONSTRAINT chk_keyboard_usage_characters CHECK (
chinese_character_count + english_character_count + other_character_count
>= input_session_count
)
) ENGINE = InnoDB;
@@ -39,6 +39,40 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("official content contract migration and runtime grants stay aligned") {
val openApi = root.read("docs/openapi.yaml")
val migration = root.read(
"src/main/resources/db/migration/V22__official_content_management.sql",
)
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
migration shouldContain "CREATE TABLE official_content_catalog"
migration shouldContain "CREATE TABLE official_skills"
migration shouldContain "CREATE TABLE official_skill_localizations"
migration shouldContain "CREATE TABLE official_hint_packs"
migration shouldContain "locale IN ('zh', 'en')"
openApi shouldContain "schemaVersion: { type: integer, const: 1 }"
openApi shouldContain "pattern: \"^official\\\\."
openApi shouldContain "Cache-Control: { schema: { type: string, const: \"public,max-age=300\" } }"
val skillSchemas = openApi
.substringAfter(" SkillLocalization:")
.substringBefore(" AIHintCard:")
skillSchemas shouldContain "name: { type: string, minLength: 1, maxLength: 40 }"
skillSchemas shouldContain "summary: { type: string, minLength: 1, maxLength: 200 }"
skillSchemas shouldContain "prompt: { type: string, minLength: 1, maxLength: 6000 }"
skillSchemas shouldContain "systemImage: { type: string, minLength: 1, maxLength: 100 }"
skillSchemas shouldContain "sortOrder: { type: integer, minimum: 0, maximum: 100000 }"
skillSchemas shouldContain "maxItems: 100"
listOf(privileges, smokePrivileges).forEach { grants ->
grants shouldContain "SELECT ON osg_account"
grants shouldContain "official_content_catalog"
grants shouldContain "official_skills"
grants shouldContain "official_skill_localizations"
grants shouldContain "official_hint_packs"
}
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
@@ -165,7 +199,7 @@ class DeploymentConsistencyTest : FunSpec({
val openApi = root.read("docs/openapi.yaml")
val eventSchema = openApi
.substringAfter(" ProductAnalyticsEvent:")
.substringBefore(" AdminSessionState:")
.substringBefore(" SkillLocalization:")
eventSchema shouldContain "additionalProperties: false"
eventSchema shouldContain "AI_FEATURE_SUCCEEDED"
eventSchema shouldContain "INSUFFICIENT_CREDITS"
@@ -174,6 +208,26 @@ class DeploymentConsistencyTest : FunSpec({
eventSchema shouldNotContain "audio"
eventSchema shouldNotContain "modelOutput"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminProductAnalytics\" }"
val keyboardMigration = root.read(
"src/main/resources/db/migration/V23__keyboard_usage_daily_summaries.sql",
)
keyboardMigration shouldContain "keyboard_usage_daily_summaries"
keyboardMigration shouldContain
"UNIQUE KEY uq_keyboard_usage_installation_date (installation_hash, summary_date)"
keyboardMigration shouldContain "ON DELETE CASCADE"
keyboardMigration shouldNotContain "input_text"
keyboardMigration shouldNotContain "host_application"
val keyboardSchema = openApi
.substringAfter(" KeyboardUsageSummary:")
.substringBefore(" ProductAnalyticsBatchResponse:")
keyboardSchema shouldContain "additionalProperties: false"
keyboardSchema shouldContain "chineseCharacterCount"
keyboardSchema shouldContain "mixedLanguageSessionCount"
keyboardSchema shouldNotContain "userText"
keyboardSchema shouldNotContain "keystrokes"
keyboardSchema shouldNotContain "hostApplication"
}
test("production Compose reuses private MySQL and hardens the application container") {
@@ -301,6 +355,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/account",
"/v1/apple/events",
"/v1/analytics/events",
"/v1/analytics/keyboard-usage",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
@@ -323,6 +378,16 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/v1/content/skills",
"/v1/content/hints/manifest",
"/v1/content/hints/{locale}",
"/hints/manifest.json",
"/hints/hints-{locale}.json",
"/v1/admin/content/skills",
"/v1/admin/content/skills/{id}",
"/v1/admin/content/skills/{id}/enable",
"/v1/admin/content/skills/{id}/disable",
"/v1/admin/content/hints/{locale}",
"/v1/admin/auth/session",
"/v1/admin/auth/login",
"/v1/admin/auth/logout",
@@ -51,17 +51,16 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..17)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
.findFirst()
.orElseThrow()
val migrationTables = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { MIGRATION_FILE.matches(it.fileName.toString()) }
.flatMap { migration ->
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
.stream()
}
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
}
.toList()
}
.toSet()
val grantedTables = GRANTED_TABLE.findAll(grants)
.map { it.groupValues[1] }
@@ -93,3 +92,4 @@ private fun Path.read(relativePath: String): String =
private val CREATE_TABLE = Regex("""CREATE TABLE\s+([a-z0-9_]+)""", RegexOption.IGNORE_CASE)
private val GRANTED_TABLE = Regex("""ON osg_account_smoke\.([a-z0-9_]+)""")
private val MIGRATION_FILE = Regex("""V\d+__.+\.sql""")
@@ -7,6 +7,7 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountR
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
@@ -42,6 +43,10 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe 20.0
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
result.keyboardUsage.activationToInput.percent shouldBe 80.0
result.keyboardUsage.chineseSharePercent shouldBe 60.0
result.keyboardUsage.englishSharePercent shouldBe 40.0
result.keyboardUsage.averageCharactersPerInputSession shouldBe 12.0
result.retention.first().d1?.percent shouldBe 50.0
result.retention.first().d7?.percent shouldBe 30.0
result.retention.first().d30 shouldBe null
@@ -63,6 +68,22 @@ class AdminProductAnalyticsServiceTest : FunSpec({
periodActiveUsers = 0,
activation24h = AdminAnalyticsCountRow(0, 0),
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 0,
keyboardUsers = 0,
chineseActiveUsers = 0,
englishActiveUsers = 0,
bilingualActiveUsers = 0,
totalCharacters = 0,
chineseCharacters = 0,
englishCharacters = 0,
otherCharacters = 0,
inputSessions = 0,
chineseOnlySessions = 0,
englishOnlySessions = 0,
mixedLanguageSessions = 0,
otherOnlySessions = 0,
),
)
val service = AdminProductAnalyticsService(
object : AdminProductAnalyticsRepository {
@@ -84,6 +105,9 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe null
result.activity.successfulRequestsPerActiveUser shouldBe null
result.consumption.averageCreditsPerManagedRequest shouldBe null
result.keyboardUsage.activationToInput.percent shouldBe null
result.keyboardUsage.chineseSharePercent shouldBe null
result.keyboardUsage.averageCharactersPerInputSession shouldBe null
}
})
@@ -130,6 +154,22 @@ private fun snapshot(): AdminProductAnalyticsSnapshot =
features = listOf(
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 40,
keyboardUsers = 50,
chineseActiveUsers = 30,
englishActiveUsers = 20,
bilingualActiveUsers = 10,
totalCharacters = 1_200,
chineseCharacters = 600,
englishCharacters = 400,
otherCharacters = 200,
inputSessions = 100,
chineseOnlySessions = 50,
englishOnlySessions = 30,
mixedLanguageSessions = 15,
otherOnlySessions = 5,
),
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
guardrails = AdminAnalyticsGuardrailRow(
clientSuccess = AdminAnalyticsCountRow(90, 100),
@@ -81,6 +81,19 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
analytics.retention.shouldBeEmpty()
analytics.features.shouldBeEmpty()
analytics.consumption.totalCredits shouldBeExactly 0
analytics.keyboardUsage.activeUsers shouldBeExactly 0
analytics.keyboardUsage.totalCharacters shouldBeExactly 0
factory.query { seedAdminCreditStats() }
val populatedStats = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
populatedStats.overview.grantedCredits shouldBeExactly 100
populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100
factory.query { seedProductAnalytics() }
val populated = ExposedAdminProductAnalyticsRepository(factory).load(
@@ -106,6 +119,11 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
populated.successfulAiRequests shouldBeExactly 2
populated.features.single().successes shouldBeExactly 2
populated.retention.single().d1 shouldBeExactly 1
populated.keyboardUsage.activeUsers shouldBeExactly 1
populated.keyboardUsage.keyboardUsers shouldBeExactly 1
populated.keyboardUsage.chineseCharacters shouldBeExactly 100
populated.keyboardUsage.englishCharacters shouldBeExactly 50
populated.keyboardUsage.inputSessions shouldBeExactly 4
}
} finally {
factory.close()
@@ -117,6 +135,29 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
private fun seedAdminCreditStats() {
TransactionManager.current().exec(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES
(
'50000000-0000-0000-0000-000000000001',
'50000000-0000-0000-0000-000000000000',
'SIGNUP_TRIAL', 100, 100,
'stats-signup-trial', NULL, '2026-08-11 00:00:00.000000'
),
(
'50000000-0000-0000-0000-000000000002',
'50000000-0000-0000-0000-000000000000',
'STOREKIT_PURCHASE', 6000, 6100,
'stats-storekit-purchase', NULL, '2026-08-11 00:01:00.000000'
)
""".trimIndent(),
)
}
private fun seedProductAnalytics() {
TransactionManager.current().exec(
"""
@@ -164,4 +205,20 @@ private fun seedProductAnalytics() {
""".trimIndent(),
)
}
TransactionManager.current().exec(
"""
INSERT INTO keyboard_usage_daily_summaries (
installation_hash, client_summary_id, summary_date,
chinese_character_count, english_character_count, other_character_count,
input_session_count, chinese_only_session_count, english_only_session_count,
mixed_language_session_count, other_only_session_count,
app_version, os_version, payload_hash, received_at
) VALUES (
'${"a".repeat(64)}', '50000000-0000-0000-0000-000000000001', '2026-08-11',
100, 50, 10,
4, 2, 1, 1, 0,
'1.0', '18.6', '${"4".repeat(64)}', '2026-08-12 00:10:01.000000'
)
""".trimIndent(),
)
}
@@ -9,11 +9,13 @@ import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.GRANTED_CREDIT_ENTRY_TYPES
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
@@ -41,11 +43,11 @@ class AdminStatsRepositoryTest : FunSpec({
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
grantedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
grantedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
@@ -104,7 +106,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
@@ -151,6 +153,17 @@ class AdminStatsRepositoryTest : FunSpec({
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("StoreKit purchases are excluded from granted credit statistics") {
GRANTED_CREDIT_ENTRY_TYPES shouldBe setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.SUBSCRIPTION_GRANT,
)
GRANTED_CREDIT_ENTRY_TYPES.contains(LedgerEntryType.STOREKIT_PURCHASE) shouldBe false
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
@@ -167,7 +180,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
referralRanking = listOf(
@@ -8,6 +8,8 @@ import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import io.kotest.assertions.throwables.shouldThrow
@@ -27,7 +29,7 @@ import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class AnalyticsRepositoryIntegrationTest : FunSpec({
test("V16 repository links accounts replays atomically and cascades account deletion") {
test("analytics repository links accounts replays atomically and cascades account deletion") {
withAnalyticsDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T01:00:00Z")
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
@@ -57,6 +59,30 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
installationCount(config, installationId.sha256Hex()) shouldBe 1
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
eventCount(config) shouldBe 1
val keyboardUsage = KeyboardUsageBatchRequest(
installationId = installationId,
summaries = listOf(keyboardSummary()),
)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(0, 1)
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
keyboardUsage.copy(
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000002",
chineseCharacterCount = 101,
),
),
),
)
}
keyboardSummaryCount(config) shouldBe 1
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-08-19"),
limit = 100,
) shouldBe 0
repository.recordInvitePageOpen(now)
repository.recordInvitePageOpen(now.plusSeconds(30))
scalarInt(
@@ -82,6 +108,31 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
}
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
val concurrentKeyboardUsage = KeyboardUsageBatchRequest(
installationId = concurrentRequest.installationId,
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000099",
),
),
)
val concurrentKeyboardResults = coroutineScope {
List(8) {
async { service.ingestKeyboardUsage(null, concurrentKeyboardUsage) }
}.awaitAll()
}
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
markKeyboardSummaryDate(
config,
"50000000-0000-0000-0000-000000000099",
java.time.LocalDate.parse("2026-05-20"),
)
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-05-22"),
limit = 100,
) shouldBe 1
keyboardSummaryCount(config) shouldBe 1
markInstallationUpdatedAt(
config,
concurrentRequest.installationId.sha256Hex(),
@@ -114,6 +165,7 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
deleteAccount(config, accountId)
installationCount(config, installationId.sha256Hex()) shouldBe 0
eventCount(config) shouldBe 0
keyboardSummaryCount(config) shouldBe 0
}
}
})
@@ -129,6 +181,21 @@ private fun event(
surface = surface,
)
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = "50000000-0000-0000-0000-000000000001",
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.0",
osVersion = "18.6",
)
private suspend fun withAnalyticsDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
@@ -197,6 +264,9 @@ private fun installationCount(config: DatabaseConfig, hash: String): Int =
private fun eventCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
private fun keyboardSummaryCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM keyboard_usage_daily_summaries")
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
@@ -246,6 +316,22 @@ private fun markInstallationUpdatedAt(
}
}
private fun markKeyboardSummaryDate(
config: DatabaseConfig,
clientSummaryId: String,
summaryDate: java.time.LocalDate,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"UPDATE keyboard_usage_daily_summaries SET summary_date = ? WHERE client_summary_id = ?"
).use { statement ->
statement.setObject(1, summaryDate)
statement.setString(2, clientSummaryId)
statement.executeUpdate() shouldBe 1
}
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
@@ -6,7 +6,9 @@ import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.routes.analyticsRoutes
import com.osglab.account.features.analytics.services.AnalyticsService
import io.kotest.matchers.shouldBe
@@ -61,6 +63,10 @@ class AnalyticsRoutesTest {
contentType(ContentType.Application.Json)
setBody(validBody())
}
val keyboardUsage = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody())
}
anonymous.status shouldBe HttpStatusCode.OK
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
@@ -68,7 +74,9 @@ class AnalyticsRoutesTest {
authenticated.bodyAsText() shouldNotContain accountId.toString()
authenticated.bodyAsText() shouldNotContain "installationId"
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
service.accountIds shouldBe listOf(null, accountId)
keyboardUsage.status shouldBe HttpStatusCode.OK
keyboardUsage.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
service.accountIds shouldBe listOf(null, accountId, null)
}
@Test
@@ -106,6 +114,18 @@ class AnalyticsRoutesTest {
conflict.bodyAsText() shouldContain """"code":"conflict""""
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
val forbiddenKeyboardContent = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace("\"summaryDate\"", "\"userText\":\"forbidden\",\"summaryDate\""))
}
val invalidSummaryDate = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
}
forbiddenKeyboardContent.status shouldBe HttpStatusCode.BadRequest
invalidSummaryDate.status shouldBe HttpStatusCode.UnprocessableEntity
invalidSummaryDate.bodyAsText() shouldContain """"code":"summary_date_invalid""""
}
private fun validBody(): String =
@@ -122,6 +142,26 @@ class AnalyticsRoutesTest {
}
""".trimIndent()
private fun validKeyboardUsageBody(): String =
"""
{
"installationId":"$INSTALLATION_ID",
"summaries":[{
"clientSummaryId":"50000000-0000-0000-0000-000000000001",
"summaryDate":"2026-08-19",
"chineseCharacterCount":100,
"englishCharacterCount":50,
"otherCharacterCount":10,
"inputSessionCount":4,
"chineseOnlySessionCount":2,
"englishOnlySessionCount":1,
"mixedLanguageSessionCount":1,
"otherOnlySessionCount":0,
"appVersion":"1.0"
}]
}
""".trimIndent()
private companion object {
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
@@ -139,6 +179,14 @@ private class RecordingAnalyticsService : AnalyticsService {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.summaries.size, replayed = 0)
}
}
private class ErrorAnalyticsService : AnalyticsService {
@@ -150,4 +198,12 @@ private class ErrorAnalyticsService : AnalyticsService {
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
else -> AnalyticsIngestResult(request.events.size, 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult = when (request.installationId) {
"10000000-0000-0000-0000-000000000003" -> throw KeyboardUsageDateException()
else -> AnalyticsIngestResult(request.summaries.size, 0)
}
}
@@ -12,8 +12,12 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
@@ -24,6 +28,7 @@ import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
@@ -222,6 +227,99 @@ class AnalyticsServiceTest {
repository.eventCount shouldBe eventCountBeforeConflict
}
@Test
fun `keyboard usage accepts privacy minimized daily summaries and replays idempotently`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val request = keyboardBatch(keyboardSummary())
service.ingestKeyboardUsage(null, request) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
repository.lastKeyboardBatch?.installationHash shouldBe installationId.sha256Hex()
repository.lastKeyboardBatch.toString() shouldNotContain installationId
request.toString() shouldNotContain request.summaries.single().clientSummaryId
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
KeyboardUsageBatchRequest(
installationId,
listOf(
keyboardSummary().copy(
clientSummaryId = uuid(43),
summaryDate = "2026-08-18",
),
keyboardSummary().copy(
clientSummaryId = uuid(42),
chineseCharacterCount = 101,
),
),
),
)
}
repository.keyboardSummaryCount shouldBe 1
}
@Test
fun `keyboard usage validates date count partitions and batch uniqueness before persistence`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
listOf(
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-08-20")),
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-07-15")),
).forEach { invalid ->
shouldThrow<KeyboardUsageDateException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "summary_date_invalid"
}
listOf(
KeyboardUsageBatchRequest(installationId, emptyList()),
keyboardBatch(keyboardSummary().copy(chineseCharacterCount = -1)),
keyboardBatch(keyboardSummary().copy(englishCharacterCount = 1_000_001)),
keyboardBatch(keyboardSummary().copy(inputSessionCount = 0)),
keyboardBatch(keyboardSummary().copy(otherOnlySessionCount = 1)),
keyboardBatch(
keyboardSummary().copy(
chineseCharacterCount = 0,
englishCharacterCount = 0,
otherCharacterCount = 0,
),
),
KeyboardUsageBatchRequest(
installationId,
listOf(keyboardSummary(), keyboardSummary().copy(clientSummaryId = uuid(43))),
),
).forEach { invalid ->
shouldThrow<InvalidRequestException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "invalid_request"
}
repository.keyboardSummaryCount shouldBe 0
}
@Test
fun `keyboard usage accepts inclusive oldest date and maximum counters`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val maximum = keyboardSummary().copy(
summaryDate = "2026-07-16",
chineseCharacterCount = 1_000_000,
englishCharacterCount = 1_000_000,
otherCharacterCount = 1_000_000,
inputSessionCount = 100_000,
chineseOnlySessionCount = 100_000,
englishOnlySessionCount = 0,
mixedLanguageSessionCount = 0,
)
service(repository).ingestKeyboardUsage(null, keyboardBatch(maximum)) shouldBe
AnalyticsIngestResult(1, 0)
}
@Test
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
kotlinx.coroutines.runBlocking {
@@ -235,6 +333,8 @@ class AnalyticsServiceTest {
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
repository.lastPurgeLimit shouldBe 1_000
repository.lastKeyboardPurgeBefore shouldBe LocalDate.parse("2026-05-22")
repository.lastKeyboardPurgeLimit shouldBe 1_000
}
private fun service(repository: AnalyticsRepository) =
@@ -248,6 +348,25 @@ class AnalyticsServiceTest {
events: List<AnalyticsEventRequest>,
) = AnalyticsBatchRequest(installationId = installationId, events = events)
private fun keyboardBatch(
summary: KeyboardUsageSummaryRequest,
) = KeyboardUsageBatchRequest(installationId, listOf(summary))
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = uuid(41),
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.2.3",
osVersion = "18.6",
)
private fun firstOpen() = event(
id = uuid(1),
type = AnalyticsEventType.FIRST_OPEN,
@@ -293,15 +412,30 @@ class AnalyticsServiceTest {
private class InMemoryAnalyticsRepository : AnalyticsRepository {
private val linkedAccounts = mutableMapOf<String, UUID?>()
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsById = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsByDate = mutableMapOf<Pair<String, LocalDate>, String>()
var lastBatch: AnalyticsBatch? = null
private set
var lastKeyboardBatch: KeyboardUsageBatch? = null
private set
val eventCount: Int get() = payloads.size
val keyboardSummaryCount: Int get() = keyboardPayloadsById.size
var lastPurgeBefore: Instant? = null
private set
var lastPurgeLimit: Int? = null
private set
var lastKeyboardPurgeBefore: LocalDate? = null
private set
var lastKeyboardPurgeLimit: Int? = null
private set
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
lastKeyboardPurgeBefore = before
lastKeyboardPurgeLimit = limit
return 0
}
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
lastPurgeBefore = before
lastPurgeLimit = limit
@@ -343,6 +477,48 @@ private class InMemoryAnalyticsRepository : AnalyticsRepository {
lastBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult {
val accountsCopy = linkedAccounts.toMutableMap()
val idPayloadsCopy = keyboardPayloadsById.toMutableMap()
val datePayloadsCopy = keyboardPayloadsByDate.toMutableMap()
val existingAccount = accountsCopy[batch.installationHash]
if (batch.installationHash !in accountsCopy) {
accountsCopy[batch.installationHash] = batch.accountId
} else if (batch.accountId != null) {
when {
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
existingAccount != batch.accountId ->
throw ConflictException("Installation is linked to another account")
}
}
var accepted = 0
var replayed = 0
batch.summaries.forEach { summary ->
val idKey = batch.installationHash to summary.clientSummaryId
val dateKey = batch.installationHash to summary.summaryDate
val existingHashes = setOfNotNull(idPayloadsCopy[idKey], datePayloadsCopy[dateKey])
when {
existingHashes.isEmpty() -> {
idPayloadsCopy[idKey] = summary.payloadHash
datePayloadsCopy[dateKey] = summary.payloadHash
accepted += 1
}
existingHashes.size == 1 && existingHashes.single() == summary.payloadHash ->
replayed += 1
else -> throw ConflictException("Keyboard usage summary conflict")
}
}
linkedAccounts.clear()
linkedAccounts.putAll(accountsCopy)
keyboardPayloadsById.clear()
keyboardPayloadsById.putAll(idPayloadsCopy)
keyboardPayloadsByDate.clear()
keyboardPayloadsByDate.putAll(datePayloadsCopy)
lastKeyboardBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
}
private fun String.sha256Hex(): String =
@@ -0,0 +1,85 @@
package com.osglab.account.features.content
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogRecord
import com.osglab.account.features.content.repositories.ContentRepository
import java.time.Instant
internal class InMemoryContentRepository : ContentRepository {
private val skills = linkedMapOf<String, OfficialSkillRecord>()
private val hints = linkedMapOf<String, HintPackRecord>()
val audits = mutableListOf<NewAdminAuditEvent>()
var revision = 0L
var generatedAt: Instant? = null
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
SkillCatalogRecord(
revision = revision,
generatedAt = generatedAt,
skills = skills.values
.filter { !enabledOnly || it.enabled }
.sortedWith(compareBy(OfficialSkillRecord::sortOrder, OfficialSkillRecord::id)),
)
override suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
if (skills.containsKey(skill.id)) return ContentMutationResult.CONFLICT
skills[skill.id] = skill
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[skill.id] ?: return ContentMutationResult.NOT_FOUND
skills[skill.id] = skill.copy(enabled = current.enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[id] ?: return ContentMutationResult.NOT_FOUND
if (enabled && !current.enabled && skills.values.count(OfficialSkillRecord::enabled) >= 100) {
return ContentMutationResult.LIMIT_EXCEEDED
}
skills[id] = current.copy(enabled = enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun listHintPacks(): List<HintPackRecord> =
hints.values.sortedBy(HintPackRecord::locale)
override suspend fun getHintPack(locale: String): HintPackRecord? = hints[locale]
override suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord {
val stored = pack.copy(version = (hints[pack.locale]?.version ?: 0) + 1)
hints[pack.locale] = stored
audits += audit
return stored
}
private fun publish(now: Instant, audit: NewAdminAuditEvent) {
revision += 1
generatedAt = now
audits += audit
}
}
@@ -0,0 +1,158 @@
package com.osglab.account.features.content.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class ContentRepositoryIntegrationTest : FunSpec({
test("skill revision publication and audit are atomic") {
withContentRepositories { first, _, admin ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val skill = officialSkill()
first.createSkill(skill, now, audit(AdminAuditAction.CONTENT_SKILL_CREATED, skill.id, now))
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 1
skills shouldBe emptyList()
}
first.setSkillEnabled(
skill.id,
enabled = true,
now = now.plusSeconds(1),
audit = audit(AdminAuditAction.CONTENT_SKILL_ENABLED, skill.id, now.plusSeconds(1)),
)
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 2
generatedAt shouldBe now.plusSeconds(1)
skills.single().localizations.en.name shouldBe "Polish"
}
admin.listAudit(10).map { it.action }.toSet() shouldBe setOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
}
test("concurrent hint publication assigns monotonic versions") {
withContentRepositories { first, second, _ ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val versions = coroutineScope {
listOf(first, second).mapIndexed { index, repository ->
async {
repository.putHintPack(
HintPackRecord(
locale = "zh",
generatedAt = now,
expiresAt = null,
intervalHours = 12,
version = 0,
cardsJson = "[]",
),
now.plusSeconds(index.toLong()),
audit(
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
"zh",
now.plusSeconds(index.toLong()),
),
).version
}
}.awaitAll()
}
versions shouldContainExactlyInAnyOrder listOf(1, 2)
first.getHintPack("zh")?.version shouldBe 2
}
}
})
private suspend fun withContentRepositories(
block: suspend (ExposedContentRepository, ExposedContentRepository, ExposedAdminRepository) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
ContentMySqlContainer("mysql:8.4")
.withDatabaseName("osg_content_repository_test")
.withUsername("test")
.withPassword("test")
.also(ContentMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(
ExposedContentRepository(firstFactory),
ExposedContentRepository(secondFactory),
ExposedAdminRepository(firstFactory),
)
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun officialSkill() = OfficialSkillRecord(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
enabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "请润色"),
SkillLocalizationDto("Polish", "Improve wording", "Please polish"),
),
)
private fun audit(
action: AdminAuditAction,
targetId: String,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = null,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = if (action == AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED) {
"OFFICIAL_HINT_PACK"
} else {
"OFFICIAL_SKILL"
},
targetId = targetId,
occurredAt = now,
)
private class ContentMySqlContainer(image: String) :
MySQLContainer<ContentMySqlContainer>(image)
@@ -0,0 +1,235 @@
package com.osglab.account.features.content.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.routes.adminContentRoutes
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.services.ContentService
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.test.Test
class ContentRoutesTest {
@Test
fun `public skills are anonymous cacheable and support conditional requests`() = testApplication {
val service = seededContentService()
application {
installContentJson()
routing { contentRoutes(service) }
}
val first = client.get("/v1/content/skills")
val etag = first.headers[HttpHeaders.ETag]
first.status shouldBe HttpStatusCode.OK
first.headers[HttpHeaders.CacheControl] shouldBe "public,max-age=300"
first.bodyAsText() shouldContain """"schemaVersion":1"""
first.bodyAsText() shouldContain """"id":"official.polish""""
val cached = client.get("/v1/content/skills") {
header(HttpHeaders.IfNoneMatch, requireNotNull(etag))
}
cached.status shouldBe HttpStatusCode.NotModified
cached.headers[HttpHeaders.ETag] shouldBe etag
}
@Test
fun `public hint manifest and packs preserve client fields and etags`() = testApplication {
val service = ContentService(InMemoryContentRepository())
service.putHintPack(
principal(AdminRole.SUPER_ADMIN),
"en",
UpdateHintPackRequest(
generatedAt = "2026-08-21T04:00:00Z",
expiresAt = "2026-08-22T04:00:00Z",
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-brief",
text = "Daily brief",
prompt = "Summarize today's news",
category = "daily",
priority = 80,
source = "official",
locale = "en",
conditions = listOf("idle"),
),
),
),
null,
)
application {
installContentJson()
routing { contentRoutes(service) }
}
val manifest = client.get("/v1/content/hints/manifest")
val legacyManifest = client.get("/hints/manifest.json")
manifest.status shouldBe HttpStatusCode.OK
manifest.bodyAsText() shouldContain """"locales":["en"]"""
manifest.bodyAsText() shouldContain """"en":"/v1/content/hints/en""""
legacyManifest.bodyAsText() shouldBe manifest.bodyAsText()
legacyManifest.headers[HttpHeaders.ETag] shouldBe manifest.headers[HttpHeaders.ETag]
legacyManifest.headers[HttpHeaders.CacheControl] shouldBe manifest.headers[HttpHeaders.CacheControl]
val pack = client.get("/v1/content/hints/en")
val legacyPack = client.get("/hints/hints-en.json")
val etag = requireNotNull(pack.headers[HttpHeaders.ETag])
pack.status shouldBe HttpStatusCode.OK
pack.bodyAsText() shouldContain """"version":1"""
pack.bodyAsText() shouldContain """"text":"Daily brief""""
legacyPack.bodyAsText() shouldBe pack.bodyAsText()
legacyPack.headers[HttpHeaders.ETag] shouldBe etag
legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl]
client.get("/v1/content/hints/en") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/hints/hints-en.json") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/v1/content/hints/fr").status shouldBe HttpStatusCode.NotFound
client.get("/hints/hints-fr.json").status shouldBe HttpStatusCode.NotFound
}
@Test
fun `only super admin can mutate content with valid csrf`() = testApplication {
val service = ContentService(InMemoryContentRepository())
val sessions = sessionService(AdminRole.SUPPORT)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessions, service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Forbidden
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `super admin mutation publishes through admin route`() = testApplication {
val repository = InMemoryContentRepository()
val service = ContentService(repository)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessionService(AdminRole.SUPER_ADMIN), service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Created
response.bodyAsText() shouldContain """"enabled":false"""
repository.revision shouldBe 1
}
}
private fun io.ktor.server.application.Application.installContentJson() {
install(ContentNegotiation) {
json(
Json {
explicitNulls = false
encodeDefaults = true
},
)
}
}
private suspend fun seededContentService(): ContentService {
val service = ContentService(InMemoryContentRepository())
val actor = principal(AdminRole.SUPER_ADMIN)
service.createSkill(
actor,
CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 1,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "润色文本"),
SkillLocalizationDto("Polish", "Improve wording", "Polish text"),
),
),
null,
)
service.setSkillEnabled(actor, "official.polish", true, null)
return service
}
private fun sessionService(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns principal(role)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns principal(role)
}
private fun principal(role: AdminRole) = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "operator",
role = role,
)
private fun adminConfig() = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { admin } returns AdminConfig(mtlsRequired = false)
}
private const val SKILL_BODY = """
{
"id": "official.polish",
"systemImage": "wand.and.sparkles",
"sortOrder": 10,
"thinkingEnabled": false,
"localizations": {
"zh-Hans": {"name": "润色", "summary": "优化表达", "prompt": "请润色"},
"en": {"name": "Polish", "summary": "Improve wording", "prompt": "Please polish"}
}
}
"""
@@ -0,0 +1,222 @@
package com.osglab.account.features.content.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class ContentServiceTest : FunSpec({
val now = Instant.parse("2026-08-21T04:00:00Z")
test("skill mutations increment revision and disabled skills stay private") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
service.createSkill(actor(), skillRequest(), "request-create")
service.adminSkills().run {
revision shouldBe 1
skills.single().enabled shouldBe false
}
service.publicSkills().skills shouldBe emptyList()
service.setSkillEnabled(actor(), "official.polish", true, "request-enable")
service.publicSkills().run {
revision shouldBe 2
generatedAt shouldBe now.toString()
skills.single().run {
id shouldBe "official.polish"
kind shouldBe "transform"
localizations.zhHans.name shouldBe "润色"
}
}
repository.audits.map { it.action } shouldBe listOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
test("public Skill DTO accepts exact client maxima and rejects every overflow") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val maximumId = "official." + "." + "a".repeat(89) + "-"
val maximumLocalization = SkillLocalizationDto(
name = "n".repeat(40),
summary = "s".repeat(200),
prompt = "p".repeat(6_000),
)
val maximum = CreateOfficialSkillRequest(
id = maximumId,
systemImage = "i".repeat(100),
sortOrder = 100_000,
thinkingEnabled = true,
localizations = SkillLocalizationsDto(maximumLocalization, maximumLocalization),
)
service.createSkill(actor(), maximum, "maximum")
service.setSkillEnabled(actor(), maximumId, true, "maximum-enable")
val catalog = service.publicSkills()
catalog.schemaVersion shouldBe 1
catalog.revision shouldBe 2
catalog.skills.single().run {
id shouldBe maximumId
systemImage.length shouldBe 100
sortOrder shouldBe 100_000
kind shouldBe "transform"
thinkingEnabled shouldBe true
localizations.zhHans shouldBe maximumLocalization
localizations.en shouldBe maximumLocalization
}
val catalogJson = CONTRACT_JSON.encodeToJsonElement(
com.osglab.account.features.content.models.SkillCatalogResponse.serializer(),
catalog,
).jsonObject
catalogJson.keys shouldBe setOf("schemaVersion", "revision", "generatedAt", "skills")
val skillJson = catalogJson.getValue("skills").jsonArray.single().jsonObject
skillJson.keys shouldBe setOf(
"id",
"systemImage",
"sortOrder",
"kind",
"thinkingEnabled",
"localizations",
)
skillJson.getValue("localizations").jsonObject.keys shouldBe setOf("zh-Hans", "en")
val invalidRequests = listOf(
maximum.copy(id = "official." + "a".repeat(92)),
maximum.copy(systemImage = "i".repeat(101)),
maximum.copy(sortOrder = -1),
maximum.copy(sortOrder = 100_001),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(name = "n".repeat(41)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(summary = "s".repeat(201)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(prompt = "p".repeat(6_001)),
),
),
)
invalidRequests.forEach { request ->
shouldThrow<ContentException> {
runBlocking { service.createSkill(actor(), request, "overflow") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
}
test("hint publishing validates locale and increments each locale version") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val request = UpdateHintPackRequest(
generatedAt = now.toString(),
expiresAt = now.plusSeconds(3_600).toString(),
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-1",
text = "今日热点",
prompt = "请概括今日热点",
category = "daily",
priority = 80,
source = "official",
locale = "zh",
),
),
)
service.putHintPack(actor(), "zh", request, "hint-1").version shouldBe 1
service.putHintPack(actor(), "zh", request, "hint-2").version shouldBe 2
service.publicHintPack("zh").run {
version shouldBe 2
cards.single().text shouldBe "今日热点"
}
service.hintManifest().run {
locales shouldBe listOf("zh")
intervalHours shouldBe 12
files shouldBe mapOf("zh" to "/v1/content/hints/zh")
}
shouldThrow<ContentException> {
runBlocking { service.putHintPack(actor(), "fr", request, "invalid") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
test("client contract rejects non-official IDs and mismatched card locales") {
val service = ContentService(
InMemoryContentRepository(),
Clock.fixed(now, ZoneOffset.UTC),
)
shouldThrow<ContentException> {
runBlocking {
service.createSkill(actor(), skillRequest().copy(id = "custom.polish"), null)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
shouldThrow<ContentException> {
runBlocking {
service.putHintPack(
actor(),
"en",
UpdateHintPackRequest(
cards = listOf(
AIHintCardDto(
id = "wrong-locale",
displayText = "提示",
prompt = "prompt",
locale = "zh",
),
),
),
null,
)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
})
private fun actor() = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
private fun skillRequest() = CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
zhHans = SkillLocalizationDto("润色", "优化表达", "请润色以下文本"),
en = SkillLocalizationDto("Polish", "Improve wording", "Polish the following text"),
),
)
private val CONTRACT_JSON = Json {
encodeDefaults = true
explicitNulls = true
}
@@ -13,6 +13,7 @@ import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.externalIdempotencyKey
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
@@ -107,6 +108,17 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("signup trial ledger is the authoritative account-level claim record") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.hasSignupTrial(userId) shouldBe false
service.grantSignupTrial(userId, 100, signupTrialIdempotencyKey(userId))
service.hasSignupTrial(userId) shouldBe true
}
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -79,7 +79,10 @@ class DeviceCheckTest : FunSpec({
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse()
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32)),
) shouldBe SignupTrialClaimResult.INELIGIBLE
updates shouldBe 0
grants shouldBe 0
repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED
@@ -109,11 +112,63 @@ class DeviceCheckTest : FunSpec({
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 }))
.shouldBeTrue()
.shouldBe(SignupTrialClaimResult.GRANTED)
(events.indexOf("apple") < events.indexOf("credits")) shouldBe true
events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED")
}
test("repeat sign-in with a fresh ephemeral token keeps an already granted account eligible") {
val accountId = UUID.randomUUID()
val grantedAccounts = mutableSetOf<UUID>()
var appleBit = false
var queries = 0
val service = DeviceCheckTrialService(
repository = InMemoryTrialRepository(),
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery {
queries++
return DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null))
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
appleBit = bit0
}
},
creditGranter = object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
grantedAccounts += accountId
}
override suspend fun wasGranted(accountId: UUID): Boolean =
accountId in grantedAccounts
},
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 6 }),
) shouldBe SignupTrialClaimResult.GRANTED
val queriesAfterGrant = queries
val repeated = service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
repeated shouldBe SignupTrialClaimResult.ALREADY_GRANTED
repeated.shouldRestrictAccount.shouldBeFalse()
queries shouldBe queriesAfterGrant
grantedAccounts shouldBe setOf(accountId)
}
test("only an ineligible trial result restricts an account") {
SignupTrialClaimResult.GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.ALREADY_GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.SKIPPED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.INELIGIBLE.shouldRestrictAccount.shouldBeTrue()
}
test("monitor skips a trial while enforce fails closed on Apple outage") {
val unavailable = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
@@ -130,7 +185,7 @@ class DeviceCheckTest : FunSpec({
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.MONITOR,
).claimAndGrant(accountId, token).shouldBeFalse()
).claimAndGrant(accountId, token) shouldBe SignupTrialClaimResult.SKIPPED
shouldThrow<ExternalServiceUnavailableException> {
DeviceCheckTrialService(
@@ -176,7 +231,8 @@ class DeviceCheckTest : FunSpec({
}.awaitAll()
}
results.count { it } shouldBe 1
results.count { it == SignupTrialClaimResult.GRANTED } shouldBe 1
results.count { it == SignupTrialClaimResult.INELIGIBLE } shouldBe 1
grants shouldBe 1
}