Add managed content and keyboard usage insights
Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
This commit is contained in:
@@ -68,6 +68,11 @@ import com.osglab.account.features.credits.routes.creditRoutes
|
||||
import com.osglab.account.features.credits.services.CreditOperations
|
||||
import com.osglab.account.features.credits.services.CreditService
|
||||
import com.osglab.account.features.credits.services.ReferralRewardConfig
|
||||
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
|
||||
import com.osglab.account.features.content.repositories.ContentRepository
|
||||
import com.osglab.account.features.content.repositories.ExposedContentRepository
|
||||
import com.osglab.account.features.content.routes.contentRoutes
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
|
||||
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
|
||||
import com.osglab.account.features.gateway.GatewaySettings
|
||||
@@ -328,6 +333,7 @@ fun Application.module() {
|
||||
analyticsRoutes(koin.get())
|
||||
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
|
||||
integrityRoutes(koin.get())
|
||||
contentRoutes(koin.get())
|
||||
}
|
||||
if (appConfig.admin.enabled) {
|
||||
adminWebRoutes(appConfig)
|
||||
@@ -342,6 +348,7 @@ fun Application.module() {
|
||||
grantService = koin.get(),
|
||||
operatorService = koin.get(),
|
||||
auditService = koin.get(),
|
||||
contentService = koin.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -417,6 +424,8 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
||||
single { AdminUsersService(get()) }
|
||||
single { AdminGrantService(get()) }
|
||||
single<ContentRepository> { ExposedContentRepository(get()) }
|
||||
single { ContentService(get()) }
|
||||
single<AppleJwksProvider> {
|
||||
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
|
||||
}
|
||||
@@ -486,12 +495,18 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
single<TrialCreditGranter> {
|
||||
TrialCreditGranter { accountId ->
|
||||
get<CreditService>().grantSignupTrial(
|
||||
userId = accountId,
|
||||
credits = config.credits.signupTrial,
|
||||
idempotencyKey = "internal:signup-trial:$accountId",
|
||||
)
|
||||
val creditService = get<CreditService>()
|
||||
object : TrialCreditGranter {
|
||||
override suspend fun grant(accountId: UUID) {
|
||||
creditService.grantSignupTrial(
|
||||
userId = accountId,
|
||||
credits = config.credits.signupTrial,
|
||||
idempotencyKey = signupTrialIdempotencyKey(accountId),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun wasGranted(accountId: UUID): Boolean =
|
||||
creditService.hasSignupTrial(accountId)
|
||||
}
|
||||
}
|
||||
single {
|
||||
@@ -510,8 +525,8 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
|
||||
single<AccountProvisioner> {
|
||||
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (deviceCheckToken != null && !granted) {
|
||||
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (trial.shouldRestrictAccount) {
|
||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||
accountId,
|
||||
java.time.Instant.now(),
|
||||
|
||||
@@ -111,6 +111,11 @@ enum class AdminAuditAction {
|
||||
OPERATOR_CREDENTIALS_RESET,
|
||||
OPERATOR_SESSIONS_REVOKED,
|
||||
MANUAL_CREDIT_GRANTED,
|
||||
CONTENT_SKILL_CREATED,
|
||||
CONTENT_SKILL_UPDATED,
|
||||
CONTENT_SKILL_ENABLED,
|
||||
CONTENT_SKILL_DISABLED,
|
||||
CONTENT_HINT_PACK_PUBLISHED,
|
||||
}
|
||||
|
||||
enum class AdminAuditOutcome {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.osglab.account.features.admin.routes
|
||||
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.models.UpdateHintPackRequest
|
||||
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.services.ContentErrorCode
|
||||
import com.osglab.account.features.content.services.ContentException
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.put
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal fun Route.adminContentRoutes(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
service: ContentService,
|
||||
) {
|
||||
route("/content") {
|
||||
get("/skills") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
call.respond(service.adminSkills())
|
||||
}
|
||||
|
||||
post("/skills") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val request = call.receiveContentRequest<CreateOfficialSkillRequest>() ?: return@post
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
HttpStatusCode.Created,
|
||||
service.createSkill(principal, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
put("/skills/{id}") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@put
|
||||
val id = call.parameters["id"] ?: return@put call.respondContentValidationError()
|
||||
val request = call.receiveContentRequest<UpdateOfficialSkillRequest>() ?: return@put
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
service.updateSkill(principal, id, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post("/skills/{id}/enable") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
service.setSkillEnabled(principal, id, enabled = true, call.request.header("X-Request-ID"))
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
post("/skills/{id}/disable") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
service.setSkillEnabled(principal, id, enabled = false, call.request.header("X-Request-ID"))
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
get("/hints/{locale}") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
val locale = call.parameters["locale"] ?: return@get call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
call.respond(service.adminHintPack(locale))
|
||||
}
|
||||
}
|
||||
|
||||
put("/hints/{locale}") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@put
|
||||
val locale = call.parameters["locale"] ?: return@put call.respondContentValidationError()
|
||||
val request = call.receiveContentRequest<UpdateHintPackRequest>() ?: return@put
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
service.putHintPack(principal, locale, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireContentReader(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
) = requireRole(config, sessions, setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT))
|
||||
|
||||
private suspend fun ApplicationCall.requireContentEditor(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
val principal = requireMutationPrincipal(config, sessions) ?: return null
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
respond(HttpStatusCode.Forbidden, ContentAdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return null
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Any> ApplicationCall.receiveContentRequest(): T? =
|
||||
try {
|
||||
receive<T>()
|
||||
} catch (_: BadRequestException) {
|
||||
respondContentValidationError()
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondContentError(block: suspend () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (exception: ContentException) {
|
||||
val status = when (exception.code) {
|
||||
ContentErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
|
||||
ContentErrorCode.CONTENT_SKILL_NOT_FOUND,
|
||||
ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND,
|
||||
-> HttpStatusCode.NotFound
|
||||
ContentErrorCode.CONTENT_SKILL_CONFLICT -> HttpStatusCode.Conflict
|
||||
}
|
||||
respond(status, ContentAdminErrorResponse(exception.code.name))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondContentValidationError() {
|
||||
respond(HttpStatusCode.BadRequest, ContentAdminErrorResponse(ContentErrorCode.VALIDATION_ERROR.name))
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ContentAdminErrorResponse(val code: String)
|
||||
@@ -45,6 +45,7 @@ import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -91,6 +92,7 @@ fun Route.adminApiRoutes(
|
||||
grantService: AdminGrantService,
|
||||
operatorService: AdminOperatorService,
|
||||
auditService: AdminAuditService,
|
||||
contentService: ContentService? = null,
|
||||
clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
route("/v1/admin") {
|
||||
@@ -166,6 +168,8 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
contentService?.let { adminContentRoutes(config, sessionService, it) }
|
||||
|
||||
get("/overview") {
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
@@ -814,7 +818,7 @@ private suspend fun ApplicationCall.requirePrincipal(
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireRole(
|
||||
internal suspend fun ApplicationCall.requireRole(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
allowedRoles: Set<AdminRole>,
|
||||
@@ -827,7 +831,7 @@ private suspend fun ApplicationCall.requireRole(
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
internal suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
@@ -949,7 +953,7 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
||||
activeUsers = overview.activeUsers,
|
||||
newUsers = overview.registrations,
|
||||
totalCreditBalance = overview.totalCreditBalance,
|
||||
creditsGranted = overview.issuedCredits,
|
||||
creditsGranted = overview.grantedCredits,
|
||||
creditsUsed = overview.consumedCredits,
|
||||
trend = registrationTrend.map {
|
||||
AdminTrendResponse(
|
||||
|
||||
+22
@@ -97,6 +97,27 @@ data class AdminAnalyticsGuardrailsDto(
|
||||
val creditBlockedUsers: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminAnalyticsKeyboardUsageDto(
|
||||
val activeUsers: Long,
|
||||
val activationToInput: AdminAnalyticsRateDto,
|
||||
val chineseActiveUsers: Long,
|
||||
val englishActiveUsers: Long,
|
||||
val bilingualActiveUsers: Long,
|
||||
val totalCharacters: Long,
|
||||
val chineseCharacters: Long,
|
||||
val englishCharacters: Long,
|
||||
val otherCharacters: Long,
|
||||
val chineseSharePercent: Double?,
|
||||
val englishSharePercent: Double?,
|
||||
val inputSessions: Long,
|
||||
val averageCharactersPerInputSession: Double?,
|
||||
val chineseOnlySessions: Long,
|
||||
val englishOnlySessions: Long,
|
||||
val mixedLanguageSessions: Long,
|
||||
val otherOnlySessions: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminProductAnalyticsDto(
|
||||
val period: AdminAnalyticsPeriodDto,
|
||||
@@ -108,6 +129,7 @@ data class AdminProductAnalyticsDto(
|
||||
val growthFunnel: List<AdminAnalyticsFunnelStepDto>,
|
||||
val retention: List<AdminAnalyticsCohortDto>,
|
||||
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
|
||||
val keyboardUsage: AdminAnalyticsKeyboardUsageDto,
|
||||
val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
|
||||
val guardrails: AdminAnalyticsGuardrailsDto,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ data class AdminOverviewDto(
|
||||
val registrations: Long,
|
||||
val activeUsers: Long,
|
||||
val totalCreditBalance: Long,
|
||||
val issuedCredits: Long,
|
||||
val grantedCredits: Long,
|
||||
val consumedCredits: Long,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ data class AdminRegistrationPointDto(
|
||||
@Serializable
|
||||
data class AdminCreditFlowPointDto(
|
||||
val date: String,
|
||||
val issuedCredits: Long,
|
||||
val grantedCredits: Long,
|
||||
val consumedCredits: Long,
|
||||
)
|
||||
|
||||
|
||||
+109
@@ -74,6 +74,23 @@ data class AdminAnalyticsGuardrailRow(
|
||||
val creditBlockedUsers: Long,
|
||||
)
|
||||
|
||||
data class AdminAnalyticsKeyboardUsageRow(
|
||||
val activeUsers: Long,
|
||||
val keyboardUsers: Long,
|
||||
val chineseActiveUsers: Long,
|
||||
val englishActiveUsers: Long,
|
||||
val bilingualActiveUsers: Long,
|
||||
val totalCharacters: Long,
|
||||
val chineseCharacters: Long,
|
||||
val englishCharacters: Long,
|
||||
val otherCharacters: Long,
|
||||
val inputSessions: Long,
|
||||
val chineseOnlySessions: Long,
|
||||
val englishOnlySessions: Long,
|
||||
val mixedLanguageSessions: Long,
|
||||
val otherOnlySessions: Long,
|
||||
)
|
||||
|
||||
data class AdminAnalyticsGrowthFunnelRow(
|
||||
val opened: Long,
|
||||
val registered: Long,
|
||||
@@ -100,6 +117,7 @@ data class AdminProductAnalyticsSnapshot(
|
||||
val growthFunnel: AdminAnalyticsGrowthFunnelRow,
|
||||
val retention: List<AdminAnalyticsCohortRow>,
|
||||
val features: List<AdminAnalyticsFeatureRow>,
|
||||
val keyboardUsage: AdminAnalyticsKeyboardUsageRow,
|
||||
val referrals: AdminAnalyticsReferralRow,
|
||||
val guardrails: AdminAnalyticsGuardrailRow,
|
||||
)
|
||||
@@ -145,6 +163,7 @@ class ExposedAdminProductAnalyticsRepository(
|
||||
growthFunnel = loadGrowthFunnel(range),
|
||||
retention = loadRetention(range),
|
||||
features = loadFeatures(range),
|
||||
keyboardUsage = loadKeyboardUsage(range),
|
||||
referrals = loadReferrals(range),
|
||||
guardrails = loadGuardrails(range),
|
||||
)
|
||||
@@ -626,6 +645,96 @@ class ExposedAdminProductAnalyticsRepository(
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadKeyboardUsage(range: AdminAnalyticsWindow): AdminAnalyticsKeyboardUsageRow =
|
||||
querySingle(
|
||||
"""
|
||||
WITH identity_usage AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
CONCAT('a:', i.account_id),
|
||||
CONCAT('i:', s.installation_hash)
|
||||
) AS identity_key,
|
||||
SUM(s.chinese_character_count) AS chinese_characters,
|
||||
SUM(s.english_character_count) AS english_characters,
|
||||
SUM(s.other_character_count) AS other_characters,
|
||||
SUM(s.input_session_count) AS input_sessions,
|
||||
SUM(s.chinese_only_session_count) AS chinese_only_sessions,
|
||||
SUM(s.english_only_session_count) AS english_only_sessions,
|
||||
SUM(s.mixed_language_session_count) AS mixed_language_sessions,
|
||||
SUM(s.other_only_session_count) AS other_only_sessions
|
||||
FROM keyboard_usage_daily_summaries s
|
||||
JOIN product_analytics_installations i
|
||||
ON i.installation_hash = s.installation_hash
|
||||
WHERE s.summary_date >= DATE(?) AND s.summary_date < DATE(?)
|
||||
GROUP BY identity_key
|
||||
),
|
||||
activated AS (
|
||||
SELECT DISTINCT
|
||||
COALESCE(
|
||||
CONCAT('a:', i.account_id),
|
||||
CONCAT('i:', e.installation_hash)
|
||||
) AS identity_key
|
||||
FROM product_analytics_events e
|
||||
JOIN product_analytics_installations i
|
||||
ON i.installation_hash = e.installation_hash
|
||||
WHERE e.event_name = 'KEYBOARD_ACTIVATED'
|
||||
AND e.occurred_at >= DATE(?) AND e.occurred_at < DATE(?)
|
||||
),
|
||||
keyboard_population AS (
|
||||
SELECT identity_key FROM identity_usage
|
||||
UNION
|
||||
SELECT identity_key FROM activated
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM identity_usage) AS active_users,
|
||||
(SELECT COUNT(*) FROM keyboard_population) AS keyboard_users,
|
||||
COALESCE(SUM(CASE WHEN chinese_characters > 0 THEN 1 ELSE 0 END), 0)
|
||||
AS chinese_active_users,
|
||||
COALESCE(SUM(CASE WHEN english_characters > 0 THEN 1 ELSE 0 END), 0)
|
||||
AS english_active_users,
|
||||
COALESCE(
|
||||
SUM(
|
||||
CASE
|
||||
WHEN chinese_characters > 0 AND english_characters > 0
|
||||
THEN 1 ELSE 0
|
||||
END
|
||||
),
|
||||
0
|
||||
) AS bilingual_active_users,
|
||||
COALESCE(
|
||||
SUM(chinese_characters + english_characters + other_characters),
|
||||
0
|
||||
) AS total_characters,
|
||||
COALESCE(SUM(chinese_characters), 0) AS chinese_characters,
|
||||
COALESCE(SUM(english_characters), 0) AS english_characters,
|
||||
COALESCE(SUM(other_characters), 0) AS other_characters,
|
||||
COALESCE(SUM(input_sessions), 0) AS input_sessions,
|
||||
COALESCE(SUM(chinese_only_sessions), 0) AS chinese_only_sessions,
|
||||
COALESCE(SUM(english_only_sessions), 0) AS english_only_sessions,
|
||||
COALESCE(SUM(mixed_language_sessions), 0) AS mixed_language_sessions,
|
||||
COALESCE(SUM(other_only_sessions), 0) AS other_only_sessions
|
||||
FROM identity_usage
|
||||
""",
|
||||
range.arguments(repetitions = 2),
|
||||
) {
|
||||
AdminAnalyticsKeyboardUsageRow(
|
||||
activeUsers = it.exactLong("active_users"),
|
||||
keyboardUsers = it.exactLong("keyboard_users"),
|
||||
chineseActiveUsers = it.exactLong("chinese_active_users"),
|
||||
englishActiveUsers = it.exactLong("english_active_users"),
|
||||
bilingualActiveUsers = it.exactLong("bilingual_active_users"),
|
||||
totalCharacters = it.exactLong("total_characters"),
|
||||
chineseCharacters = it.exactLong("chinese_characters"),
|
||||
englishCharacters = it.exactLong("english_characters"),
|
||||
otherCharacters = it.exactLong("other_characters"),
|
||||
inputSessions = it.exactLong("input_sessions"),
|
||||
chineseOnlySessions = it.exactLong("chinese_only_sessions"),
|
||||
englishOnlySessions = it.exactLong("english_only_sessions"),
|
||||
mixedLanguageSessions = it.exactLong("mixed_language_sessions"),
|
||||
otherOnlySessions = it.exactLong("other_only_sessions"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow =
|
||||
querySingle(
|
||||
valueEventsCte() +
|
||||
|
||||
+20
-14
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.stats.models.AdminOverviewDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import org.jetbrains.exposed.v1.core.IColumnType
|
||||
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
@@ -25,7 +26,7 @@ data class AdminStatsRange(
|
||||
data class AdminStatsSnapshot(
|
||||
val overview: AdminOverviewDto,
|
||||
val registrationsByDate: Map<LocalDate, Long>,
|
||||
val issuedCreditsByDate: Map<LocalDate, Long>,
|
||||
val grantedCreditsByDate: Map<LocalDate, Long>,
|
||||
val consumedCreditsByDate: Map<LocalDate, Long>,
|
||||
val referralFunnel: AdminReferralFunnelDto,
|
||||
val referralRanking: List<AdminReferralRankDto>,
|
||||
@@ -39,7 +40,7 @@ fun interface AdminStatsRepository {
|
||||
internal data class AdminStatsAggregates(
|
||||
val overview: AdminOverviewDto,
|
||||
val registrationsByDate: Map<LocalDate, Long>,
|
||||
val issuedCreditsByDate: Map<LocalDate, Long>,
|
||||
val grantedCreditsByDate: Map<LocalDate, Long>,
|
||||
val consumedCreditsByDate: Map<LocalDate, Long>,
|
||||
val referralFunnel: AdminReferralFunnelDto,
|
||||
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
|
||||
@@ -73,17 +74,14 @@ class ExposedAdminStatsRepository(
|
||||
""",
|
||||
range,
|
||||
),
|
||||
issuedCreditsByDate = loadDailyAggregates(
|
||||
grantedCreditsByDate = loadDailyAggregates(
|
||||
"""
|
||||
SELECT DATE(created_at) AS aggregate_date,
|
||||
COALESCE(SUM(amount_delta), 0) AS aggregate_value
|
||||
FROM credit_ledger
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND amount_delta > 0
|
||||
AND entry_type IN (
|
||||
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
|
||||
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
|
||||
)
|
||||
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
|
||||
GROUP BY DATE(created_at)
|
||||
""",
|
||||
range,
|
||||
@@ -128,11 +126,8 @@ class ExposedAdminStatsRepository(
|
||||
FROM credit_ledger
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND amount_delta > 0
|
||||
AND entry_type IN (
|
||||
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
|
||||
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
|
||||
)
|
||||
) AS issued_credits,
|
||||
AND entry_type IN ($GRANTED_CREDIT_ENTRY_TYPES_SQL)
|
||||
) AS granted_credits,
|
||||
(
|
||||
SELECT COALESCE(SUM(charged_credits), 0)
|
||||
FROM credit_usage_records
|
||||
@@ -146,7 +141,7 @@ class ExposedAdminStatsRepository(
|
||||
registrations = result.exactLong("registrations"),
|
||||
activeUsers = result.exactLong("active_users"),
|
||||
totalCreditBalance = result.exactLong("total_credit_balance"),
|
||||
issuedCredits = result.exactLong("issued_credits"),
|
||||
grantedCredits = result.exactLong("granted_credits"),
|
||||
consumedCredits = result.exactLong("consumed_credits"),
|
||||
)
|
||||
}
|
||||
@@ -280,7 +275,7 @@ internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSna
|
||||
AdminStatsSnapshot(
|
||||
overview = aggregates.overview,
|
||||
registrationsByDate = aggregates.registrationsByDate,
|
||||
issuedCreditsByDate = aggregates.issuedCreditsByDate,
|
||||
grantedCreditsByDate = aggregates.grantedCreditsByDate,
|
||||
consumedCreditsByDate = aggregates.consumedCreditsByDate,
|
||||
referralFunnel = aggregates.referralFunnel,
|
||||
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
|
||||
@@ -332,3 +327,14 @@ private fun ResultSet.exactLong(column: String): Long =
|
||||
internal fun BigDecimal.toExactLong(): Long = longValueExact()
|
||||
|
||||
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
|
||||
|
||||
internal val GRANTED_CREDIT_ENTRY_TYPES: Set<LedgerEntryType> = setOf(
|
||||
LedgerEntryType.SIGNUP_TRIAL,
|
||||
LedgerEntryType.MANUAL_GRANT,
|
||||
LedgerEntryType.REFERRAL_INVITER,
|
||||
LedgerEntryType.REFERRAL_INVITEE,
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
)
|
||||
|
||||
private val GRANTED_CREDIT_ENTRY_TYPES_SQL =
|
||||
GRANTED_CREDIT_ENTRY_TYPES.joinToString(", ") { "'${it.name}'" }
|
||||
|
||||
+34
@@ -8,6 +8,7 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFeatureUsage
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsNorthStarDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsPeriodDto
|
||||
@@ -42,6 +43,8 @@ class AdminProductAnalyticsService(
|
||||
)
|
||||
val referrals = snapshot.referrals
|
||||
val growth = snapshot.growthFunnel
|
||||
val keyboard = snapshot.keyboardUsage
|
||||
val classifiedKeyboardCharacters = keyboard.chineseCharacters + keyboard.englishCharacters
|
||||
return AdminProductAnalyticsDto(
|
||||
period = AdminAnalyticsPeriodDto(from.toString(), until.toString()),
|
||||
northStar = AdminAnalyticsNorthStarDto(
|
||||
@@ -126,6 +129,37 @@ class AdminProductAnalyticsService(
|
||||
successes = it.successes,
|
||||
)
|
||||
},
|
||||
keyboardUsage = AdminAnalyticsKeyboardUsageDto(
|
||||
activeUsers = keyboard.activeUsers,
|
||||
activationToInput = AdminAnalyticsCountRow(
|
||||
keyboard.activeUsers,
|
||||
keyboard.keyboardUsers,
|
||||
).toRate(),
|
||||
chineseActiveUsers = keyboard.chineseActiveUsers,
|
||||
englishActiveUsers = keyboard.englishActiveUsers,
|
||||
bilingualActiveUsers = keyboard.bilingualActiveUsers,
|
||||
totalCharacters = keyboard.totalCharacters,
|
||||
chineseCharacters = keyboard.chineseCharacters,
|
||||
englishCharacters = keyboard.englishCharacters,
|
||||
otherCharacters = keyboard.otherCharacters,
|
||||
chineseSharePercent = percentage(
|
||||
keyboard.chineseCharacters,
|
||||
classifiedKeyboardCharacters,
|
||||
),
|
||||
englishSharePercent = percentage(
|
||||
keyboard.englishCharacters,
|
||||
classifiedKeyboardCharacters,
|
||||
),
|
||||
inputSessions = keyboard.inputSessions,
|
||||
averageCharactersPerInputSession = ratio(
|
||||
keyboard.totalCharacters,
|
||||
keyboard.inputSessions,
|
||||
),
|
||||
chineseOnlySessions = keyboard.chineseOnlySessions,
|
||||
englishOnlySessions = keyboard.englishOnlySessions,
|
||||
mixedLanguageSessions = keyboard.mixedLanguageSessions,
|
||||
otherOnlySessions = keyboard.otherOnlySessions,
|
||||
),
|
||||
referralFunnel = listOf(
|
||||
AdminAnalyticsFunnelStepDto("发起分享", referrals.shared),
|
||||
AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened),
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ class AdminStatsService(
|
||||
creditFlow = dates.map { date ->
|
||||
AdminCreditFlowPointDto(
|
||||
date = date.toString(),
|
||||
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
|
||||
grantedCredits = snapshot.grantedCreditsByDate[date] ?: 0,
|
||||
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.osglab.account.features.analytics.domain
|
||||
import com.osglab.account.common.errors.ApiException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -105,9 +106,44 @@ data class AnalyticsIngestResult(
|
||||
val replayed: Int,
|
||||
)
|
||||
|
||||
data class KeyboardUsageSummary(
|
||||
val clientSummaryId: UUID,
|
||||
val summaryDate: LocalDate,
|
||||
val chineseCharacterCount: Long,
|
||||
val englishCharacterCount: Long,
|
||||
val otherCharacterCount: Long,
|
||||
val inputSessionCount: Long,
|
||||
val chineseOnlySessionCount: Long,
|
||||
val englishOnlySessionCount: Long,
|
||||
val mixedLanguageSessionCount: Long,
|
||||
val otherOnlySessionCount: Long,
|
||||
val appVersion: String?,
|
||||
val osVersion: String?,
|
||||
val payloadHash: String,
|
||||
) {
|
||||
override fun toString(): String = "KeyboardUsageSummary([REDACTED])"
|
||||
}
|
||||
|
||||
data class KeyboardUsageBatch(
|
||||
val installationHash: String,
|
||||
val accountId: UUID?,
|
||||
val summaries: List<KeyboardUsageSummary>,
|
||||
val receivedAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"KeyboardUsageBatch(installationHash=[REDACTED], accountId=[REDACTED], summaries=${summaries.size})"
|
||||
}
|
||||
|
||||
class AnalyticsEventTimeException :
|
||||
ApiException(
|
||||
status = HttpStatusCode.UnprocessableEntity,
|
||||
code = "event_time_invalid",
|
||||
message = "An event timestamp is outside the accepted range",
|
||||
)
|
||||
|
||||
class KeyboardUsageDateException :
|
||||
ApiException(
|
||||
status = HttpStatusCode.UnprocessableEntity,
|
||||
code = "summary_date_invalid",
|
||||
message = "A keyboard usage summary date is outside the accepted range",
|
||||
)
|
||||
|
||||
@@ -36,6 +36,33 @@ data class AnalyticsEventRequest(
|
||||
override fun toString(): String = "AnalyticsEventRequest([REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KeyboardUsageBatchRequest(
|
||||
val installationId: String,
|
||||
val summaries: List<KeyboardUsageSummaryRequest>,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"KeyboardUsageBatchRequest(installationId=[REDACTED], summaries=[REDACTED size=${summaries.size}])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KeyboardUsageSummaryRequest(
|
||||
val clientSummaryId: String,
|
||||
val summaryDate: String,
|
||||
val chineseCharacterCount: Long,
|
||||
val englishCharacterCount: Long,
|
||||
val otherCharacterCount: Long,
|
||||
val inputSessionCount: Long,
|
||||
val chineseOnlySessionCount: Long,
|
||||
val englishOnlySessionCount: Long,
|
||||
val mixedLanguageSessionCount: Long,
|
||||
val otherOnlySessionCount: Long,
|
||||
val appVersion: String? = null,
|
||||
val osVersion: String? = null,
|
||||
) {
|
||||
override fun toString(): String = "KeyboardUsageSummaryRequest([REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AnalyticsIngestResponse(
|
||||
val accepted: Int,
|
||||
|
||||
+157
-30
@@ -12,12 +12,17 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsFeature
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
|
||||
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.exposed.v1.exceptions.ExposedSQLException
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.core.plus
|
||||
import org.jetbrains.exposed.v1.javatime.date
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
@@ -27,17 +32,37 @@ import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.sql.SQLException
|
||||
|
||||
interface AnalyticsRepository {
|
||||
suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult
|
||||
suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult
|
||||
suspend fun recordInvitePageOpen(occurredAt: Instant)
|
||||
suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int
|
||||
suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int
|
||||
}
|
||||
|
||||
class ExposedAnalyticsRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : AnalyticsRepository {
|
||||
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
|
||||
require(limit in 1..10_000)
|
||||
return databaseFactory.query {
|
||||
val ids = KeyboardUsageSummaries
|
||||
.selectAll()
|
||||
.where { KeyboardUsageSummaries.summaryDate less before }
|
||||
.limit(limit)
|
||||
.map { it[KeyboardUsageSummaries.id] }
|
||||
if (ids.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
KeyboardUsageSummaries.deleteWhere { KeyboardUsageSummaries.id inList ids }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
|
||||
require(limit in 1..10_000)
|
||||
return databaseFactory.query {
|
||||
@@ -79,36 +104,8 @@ class ExposedAnalyticsRepository(
|
||||
}
|
||||
|
||||
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult =
|
||||
databaseFactory.query {
|
||||
AnalyticsInstallations.insertIgnore {
|
||||
it[installationHash] = batch.installationHash
|
||||
it[accountId] = batch.accountId?.toString()
|
||||
it[createdAt] = batch.receivedAt
|
||||
it[updatedAt] = batch.receivedAt
|
||||
}
|
||||
|
||||
val installation = AnalyticsInstallations
|
||||
.selectAll()
|
||||
.where { AnalyticsInstallations.installationHash eq batch.installationHash }
|
||||
.forUpdate()
|
||||
.single()
|
||||
val linkedAccount = installation[AnalyticsInstallations.accountId]
|
||||
when {
|
||||
batch.accountId == null -> Unit
|
||||
linkedAccount == null -> AnalyticsInstallations.update({
|
||||
AnalyticsInstallations.installationHash eq batch.installationHash
|
||||
}) {
|
||||
it[accountId] = batch.accountId.toString()
|
||||
it[updatedAt] = batch.receivedAt
|
||||
}
|
||||
linkedAccount != batch.accountId.toString() ->
|
||||
throw ConflictException("Installation is linked to another account")
|
||||
}
|
||||
AnalyticsInstallations.update({
|
||||
AnalyticsInstallations.installationHash eq batch.installationHash
|
||||
}) {
|
||||
it[updatedAt] = batch.receivedAt
|
||||
}
|
||||
ingestTransaction {
|
||||
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
|
||||
|
||||
var accepted = 0
|
||||
var replayed = 0
|
||||
@@ -133,6 +130,83 @@ class ExposedAnalyticsRepository(
|
||||
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
|
||||
}
|
||||
|
||||
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult =
|
||||
ingestTransaction {
|
||||
linkInstallation(batch.installationHash, batch.accountId?.toString(), batch.receivedAt)
|
||||
|
||||
var accepted = 0
|
||||
var replayed = 0
|
||||
batch.summaries.forEach { summary ->
|
||||
val existingPayloadHash = KeyboardUsageSummaries
|
||||
.selectAll()
|
||||
.where {
|
||||
(KeyboardUsageSummaries.installationHash eq batch.installationHash) and (
|
||||
(KeyboardUsageSummaries.clientSummaryId eq summary.clientSummaryId.toString()) or
|
||||
(KeyboardUsageSummaries.summaryDate eq summary.summaryDate)
|
||||
)
|
||||
}
|
||||
.singleOrNull()
|
||||
?.get(KeyboardUsageSummaries.payloadHash)
|
||||
when {
|
||||
existingPayloadHash == null -> {
|
||||
insertKeyboardUsageSummary(batch, summary)
|
||||
accepted += 1
|
||||
}
|
||||
existingPayloadHash == summary.payloadHash -> replayed += 1
|
||||
else -> throw ConflictException("Keyboard usage summary conflicts with an existing date or ID")
|
||||
}
|
||||
}
|
||||
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
|
||||
}
|
||||
|
||||
private suspend fun <T> ingestTransaction(block: suspend () -> T): T {
|
||||
repeat(MAX_INGEST_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
return databaseFactory.query(block)
|
||||
} catch (exception: ExposedSQLException) {
|
||||
if (!exception.isDeadlock() || attempt == MAX_INGEST_ATTEMPTS - 1) throw exception
|
||||
delay(DEADLOCK_RETRY_DELAY_MILLIS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
error("Unreachable analytics transaction retry state")
|
||||
}
|
||||
|
||||
private fun linkInstallation(
|
||||
installationHash: String,
|
||||
accountId: String?,
|
||||
receivedAt: Instant,
|
||||
) {
|
||||
AnalyticsInstallations.insertIgnore {
|
||||
it[AnalyticsInstallations.installationHash] = installationHash
|
||||
it[AnalyticsInstallations.accountId] = accountId
|
||||
it[createdAt] = receivedAt
|
||||
it[updatedAt] = receivedAt
|
||||
}
|
||||
|
||||
val installation = AnalyticsInstallations
|
||||
.selectAll()
|
||||
.where { AnalyticsInstallations.installationHash eq installationHash }
|
||||
.forUpdate()
|
||||
.single()
|
||||
val linkedAccount = installation[AnalyticsInstallations.accountId]
|
||||
when {
|
||||
accountId == null -> Unit
|
||||
linkedAccount == null -> AnalyticsInstallations.update({
|
||||
AnalyticsInstallations.installationHash eq installationHash
|
||||
}) {
|
||||
it[AnalyticsInstallations.accountId] = accountId
|
||||
it[updatedAt] = receivedAt
|
||||
}
|
||||
linkedAccount != accountId ->
|
||||
throw ConflictException("Installation is linked to another account")
|
||||
}
|
||||
AnalyticsInstallations.update({
|
||||
AnalyticsInstallations.installationHash eq installationHash
|
||||
}) {
|
||||
it[updatedAt] = receivedAt
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertEvent(batch: AnalyticsBatch, event: AnalyticsEvent) {
|
||||
AnalyticsEvents.insert {
|
||||
it[installationHash] = batch.installationHash
|
||||
@@ -151,6 +225,29 @@ class ExposedAnalyticsRepository(
|
||||
it[receivedAt] = batch.receivedAt
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertKeyboardUsageSummary(
|
||||
batch: KeyboardUsageBatch,
|
||||
summary: KeyboardUsageSummary,
|
||||
) {
|
||||
KeyboardUsageSummaries.insert {
|
||||
it[installationHash] = batch.installationHash
|
||||
it[clientSummaryId] = summary.clientSummaryId.toString()
|
||||
it[summaryDate] = summary.summaryDate
|
||||
it[chineseCharacterCount] = summary.chineseCharacterCount
|
||||
it[englishCharacterCount] = summary.englishCharacterCount
|
||||
it[otherCharacterCount] = summary.otherCharacterCount
|
||||
it[inputSessionCount] = summary.inputSessionCount
|
||||
it[chineseOnlySessionCount] = summary.chineseOnlySessionCount
|
||||
it[englishOnlySessionCount] = summary.englishOnlySessionCount
|
||||
it[mixedLanguageSessionCount] = summary.mixedLanguageSessionCount
|
||||
it[otherOnlySessionCount] = summary.otherOnlySessionCount
|
||||
it[appVersion] = summary.appVersion
|
||||
it[osVersion] = summary.osVersion
|
||||
it[payloadHash] = summary.payloadHash
|
||||
it[receivedAt] = batch.receivedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object AnalyticsInstallations : Table("product_analytics_installations") {
|
||||
@@ -185,6 +282,27 @@ private object AnalyticsEvents : Table("product_analytics_events") {
|
||||
override val primaryKey = PrimaryKey(installationHash, clientEventId)
|
||||
}
|
||||
|
||||
private object KeyboardUsageSummaries : Table("keyboard_usage_daily_summaries") {
|
||||
val id = long("id").autoIncrement()
|
||||
val installationHash = char("installation_hash", 64)
|
||||
val clientSummaryId = char("client_summary_id", 36)
|
||||
val summaryDate = date("summary_date")
|
||||
val chineseCharacterCount = long("chinese_character_count")
|
||||
val englishCharacterCount = long("english_character_count")
|
||||
val otherCharacterCount = long("other_character_count")
|
||||
val inputSessionCount = long("input_session_count")
|
||||
val chineseOnlySessionCount = long("chinese_only_session_count")
|
||||
val englishOnlySessionCount = long("english_only_session_count")
|
||||
val mixedLanguageSessionCount = long("mixed_language_session_count")
|
||||
val otherOnlySessionCount = long("other_only_session_count")
|
||||
val appVersion = varchar("app_version", 32).nullable()
|
||||
val osVersion = varchar("os_version", 32).nullable()
|
||||
val payloadHash = char("payload_hash", 64)
|
||||
val receivedAt = timestamp("received_at")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object AnalyticsDailyCounters : Table("product_analytics_daily_counters") {
|
||||
val counterDate = date("counter_date")
|
||||
val counterName = varchar("counter_name", 32)
|
||||
@@ -195,3 +313,12 @@ private object AnalyticsDailyCounters : Table("product_analytics_daily_counters"
|
||||
}
|
||||
|
||||
private const val INVITE_PAGE_OPENED = "INVITE_PAGE_OPENED"
|
||||
private const val MAX_INGEST_ATTEMPTS = 4
|
||||
private const val DEADLOCK_RETRY_DELAY_MILLIS = 10L
|
||||
|
||||
private fun ExposedSQLException.isDeadlock(): Boolean =
|
||||
generateSequence<Throwable>(this) { it.cause }
|
||||
.filterIsInstance<SQLException>()
|
||||
.any { it.sqlState == "40001" || it.errorCode == MYSQL_DEADLOCK_ERROR_CODE }
|
||||
|
||||
private const val MYSQL_DEADLOCK_ERROR_CODE = 1213
|
||||
|
||||
@@ -5,9 +5,11 @@ import com.osglab.account.common.security.SESSION_AUTH_NAME
|
||||
import com.osglab.account.common.errors.InvalidRequestException
|
||||
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||
import com.osglab.account.features.analytics.models.AnalyticsIngestResponse
|
||||
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
|
||||
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.auth.principal
|
||||
import io.ktor.server.request.header
|
||||
@@ -21,25 +23,37 @@ import kotlinx.serialization.json.Json
|
||||
fun Route.analyticsRoutes(service: AnalyticsService) {
|
||||
authenticate(SESSION_AUTH_NAME, optional = true) {
|
||||
post("/v1/analytics/events") {
|
||||
val declaredLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
|
||||
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
|
||||
throw InvalidRequestException("Analytics request body is too large")
|
||||
}
|
||||
val body = call.receiveText()
|
||||
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
|
||||
throw InvalidRequestException("Analytics request body is too large")
|
||||
}
|
||||
val request = try {
|
||||
ANALYTICS_JSON.decodeFromString<AnalyticsBatchRequest>(body)
|
||||
} catch (_: SerializationException) {
|
||||
throw InvalidRequestException("Analytics request body is invalid")
|
||||
}
|
||||
val request = call.receiveAnalyticsBody<AnalyticsBatchRequest>()
|
||||
val result = service.ingest(
|
||||
accountId = call.principal<AccountPrincipal>()?.userId,
|
||||
request = request,
|
||||
)
|
||||
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
|
||||
}
|
||||
post("/v1/analytics/keyboard-usage") {
|
||||
val request = call.receiveAnalyticsBody<KeyboardUsageBatchRequest>()
|
||||
val result = service.ingestKeyboardUsage(
|
||||
accountId = call.principal<AccountPrincipal>()?.userId,
|
||||
request = request,
|
||||
)
|
||||
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> ApplicationCall.receiveAnalyticsBody(): T {
|
||||
val declaredLength = request.header(HttpHeaders.ContentLength)?.toLongOrNull()
|
||||
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
|
||||
throw InvalidRequestException("Analytics request body is too large")
|
||||
}
|
||||
val body = receiveText()
|
||||
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
|
||||
throw InvalidRequestException("Analytics request body is too large")
|
||||
}
|
||||
return try {
|
||||
ANALYTICS_JSON.decodeFromString<T>(body)
|
||||
} catch (_: SerializationException) {
|
||||
throw InvalidRequestException("Analytics request body is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+133
-3
@@ -9,13 +9,20 @@ import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
|
||||
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
|
||||
import com.osglab.account.features.analytics.domain.KeyboardUsageSummary
|
||||
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
|
||||
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
|
||||
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -24,6 +31,11 @@ interface AnalyticsService {
|
||||
accountId: UUID?,
|
||||
request: AnalyticsBatchRequest,
|
||||
): AnalyticsIngestResult
|
||||
|
||||
suspend fun ingestKeyboardUsage(
|
||||
accountId: UUID?,
|
||||
request: KeyboardUsageBatchRequest,
|
||||
): AnalyticsIngestResult
|
||||
}
|
||||
|
||||
class DefaultAnalyticsService(
|
||||
@@ -50,6 +62,89 @@ class DefaultAnalyticsService(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun ingestKeyboardUsage(
|
||||
accountId: UUID?,
|
||||
request: KeyboardUsageBatchRequest,
|
||||
): AnalyticsIngestResult {
|
||||
if (request.summaries.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) {
|
||||
throw InvalidRequestException("summaries must contain between 1 and 50 items")
|
||||
}
|
||||
val installationId = parseUuid(request.installationId, "installationId")
|
||||
val now = clock.instant()
|
||||
val today = now.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val summaries = request.summaries.map { validateAndMap(it, today) }
|
||||
if (summaries.map(KeyboardUsageSummary::summaryDate).toSet().size != summaries.size) {
|
||||
throw InvalidRequestException("summaries must contain at most one item per UTC date")
|
||||
}
|
||||
return repository.ingestKeyboardUsage(
|
||||
KeyboardUsageBatch(
|
||||
installationHash = installationId.toString().sha256Hex(),
|
||||
accountId = accountId,
|
||||
summaries = summaries,
|
||||
receivedAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateAndMap(
|
||||
request: KeyboardUsageSummaryRequest,
|
||||
today: LocalDate,
|
||||
): KeyboardUsageSummary {
|
||||
val clientSummaryId = parseUuid(request.clientSummaryId, "clientSummaryId")
|
||||
val summaryDate = parseSummaryDate(request.summaryDate)
|
||||
if (summaryDate.isBefore(today.minusDays(MAX_SUMMARY_AGE_DAYS)) || !summaryDate.isBefore(today)) {
|
||||
throw KeyboardUsageDateException()
|
||||
}
|
||||
validateReleaseIdentifier(request.appVersion, "appVersion")
|
||||
validateReleaseIdentifier(request.osVersion, "osVersion")
|
||||
validateKeyboardUsageCounts(request)
|
||||
return KeyboardUsageSummary(
|
||||
clientSummaryId = clientSummaryId,
|
||||
summaryDate = summaryDate,
|
||||
chineseCharacterCount = request.chineseCharacterCount,
|
||||
englishCharacterCount = request.englishCharacterCount,
|
||||
otherCharacterCount = request.otherCharacterCount,
|
||||
inputSessionCount = request.inputSessionCount,
|
||||
chineseOnlySessionCount = request.chineseOnlySessionCount,
|
||||
englishOnlySessionCount = request.englishOnlySessionCount,
|
||||
mixedLanguageSessionCount = request.mixedLanguageSessionCount,
|
||||
otherOnlySessionCount = request.otherOnlySessionCount,
|
||||
appVersion = request.appVersion,
|
||||
osVersion = request.osVersion,
|
||||
payloadHash = keyboardUsagePayloadHash(request, clientSummaryId, summaryDate),
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateKeyboardUsageCounts(request: KeyboardUsageSummaryRequest) {
|
||||
val characterCounts = listOf(
|
||||
request.chineseCharacterCount,
|
||||
request.englishCharacterCount,
|
||||
request.otherCharacterCount,
|
||||
)
|
||||
val partitionedSessionCounts = listOf(
|
||||
request.chineseOnlySessionCount,
|
||||
request.englishOnlySessionCount,
|
||||
request.mixedLanguageSessionCount,
|
||||
request.otherOnlySessionCount,
|
||||
)
|
||||
if (characterCounts.any { it !in 0..MAX_DAILY_CHARACTER_COUNT }) {
|
||||
throw InvalidRequestException("character counts must be between 0 and 1000000")
|
||||
}
|
||||
if (
|
||||
request.inputSessionCount !in 1..MAX_DAILY_SESSION_COUNT ||
|
||||
partitionedSessionCounts.any { it !in 0..MAX_DAILY_SESSION_COUNT }
|
||||
) {
|
||||
throw InvalidRequestException("session counts must be between 0 and 100000")
|
||||
}
|
||||
val totalCharacters = characterCounts.sum()
|
||||
if (totalCharacters == 0L || totalCharacters < request.inputSessionCount) {
|
||||
throw InvalidRequestException("each input session must contain a committed character")
|
||||
}
|
||||
if (partitionedSessionCounts.sum() != request.inputSessionCount) {
|
||||
throw InvalidRequestException("language session counts must equal inputSessionCount")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateAndMap(request: AnalyticsEventRequest, now: Instant): AnalyticsEvent {
|
||||
val clientEventId = parseUuid(request.clientEventId, "clientEventId")
|
||||
val occurredAt = parseOccurredAt(request.occurredAt)
|
||||
@@ -175,6 +270,13 @@ class DefaultAnalyticsService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSummaryDate(value: String): LocalDate =
|
||||
try {
|
||||
LocalDate.parse(value)
|
||||
} catch (_: DateTimeParseException) {
|
||||
throw InvalidRequestException("summaryDate must be a UTC ISO-8601 date")
|
||||
}
|
||||
|
||||
private fun validateReleaseIdentifier(value: String?, field: String) {
|
||||
if (value != null && !RELEASE_IDENTIFIER.matches(value)) {
|
||||
throw InvalidRequestException("$field must be a 1 to 32 character release identifier")
|
||||
@@ -210,6 +312,25 @@ class DefaultAnalyticsService(
|
||||
event.osVersion,
|
||||
).joinToString(separator = "\u0000") { it ?: "" }.sha256Hex()
|
||||
|
||||
private fun keyboardUsagePayloadHash(
|
||||
summary: KeyboardUsageSummaryRequest,
|
||||
clientSummaryId: UUID,
|
||||
summaryDate: LocalDate,
|
||||
): String = listOf(
|
||||
clientSummaryId.toString(),
|
||||
summaryDate.toString(),
|
||||
summary.chineseCharacterCount,
|
||||
summary.englishCharacterCount,
|
||||
summary.otherCharacterCount,
|
||||
summary.inputSessionCount,
|
||||
summary.chineseOnlySessionCount,
|
||||
summary.englishOnlySessionCount,
|
||||
summary.mixedLanguageSessionCount,
|
||||
summary.otherOnlySessionCount,
|
||||
summary.appVersion,
|
||||
summary.osVersion,
|
||||
).joinToString(separator = "\u0000") { it?.toString() ?: "" }.sha256Hex()
|
||||
|
||||
private fun String.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
@@ -218,6 +339,9 @@ class DefaultAnalyticsService(
|
||||
private companion object {
|
||||
const val MIN_BATCH_SIZE = 1
|
||||
const val MAX_BATCH_SIZE = 50
|
||||
const val MAX_SUMMARY_AGE_DAYS = 35L
|
||||
const val MAX_DAILY_CHARACTER_COUNT = 1_000_000L
|
||||
const val MAX_DAILY_SESSION_COUNT = 100_000L
|
||||
val MAX_EVENT_AGE: Duration = Duration.ofDays(35)
|
||||
val MAX_FUTURE_SKEW: Duration = Duration.ofMinutes(5)
|
||||
val UUID_PATTERN =
|
||||
@@ -235,11 +359,17 @@ class AnalyticsMaintenanceService(
|
||||
require(!anonymousRetention.isNegative && !anonymousRetention.isZero)
|
||||
}
|
||||
|
||||
suspend fun purgeStaleAnonymousInstallations(): Int =
|
||||
repository.purgeAnonymousInstallations(
|
||||
before = clock.instant().minus(anonymousRetention),
|
||||
suspend fun purgeStaleAnonymousInstallations(): Int {
|
||||
val retentionCutoff = clock.instant().minus(anonymousRetention)
|
||||
repository.purgeKeyboardUsageSummaries(
|
||||
before = retentionCutoff.atZone(ZoneOffset.UTC).toLocalDate(),
|
||||
limit = PURGE_BATCH_SIZE,
|
||||
)
|
||||
return repository.purgeAnonymousInstallations(
|
||||
before = retentionCutoff,
|
||||
limit = PURGE_BATCH_SIZE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PURGE_BATCH_SIZE = 1_000
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.osglab.account.features.content.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class SkillLocalizationDto(
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val prompt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillLocalizationsDto(
|
||||
@SerialName("zh-Hans")
|
||||
val zhHans: SkillLocalizationDto,
|
||||
val en: SkillLocalizationDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OfficialSkillDto(
|
||||
val id: String,
|
||||
val systemImage: String,
|
||||
val sortOrder: Int,
|
||||
val kind: String = "transform",
|
||||
val thinkingEnabled: Boolean,
|
||||
val localizations: SkillLocalizationsDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminOfficialSkillDto(
|
||||
val id: String,
|
||||
val systemImage: String,
|
||||
val sortOrder: Int,
|
||||
val kind: String = "transform",
|
||||
val thinkingEnabled: Boolean,
|
||||
val enabled: Boolean,
|
||||
val localizations: SkillLocalizationsDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateOfficialSkillRequest(
|
||||
val id: String,
|
||||
val systemImage: String,
|
||||
val sortOrder: Int,
|
||||
val thinkingEnabled: Boolean,
|
||||
val localizations: SkillLocalizationsDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateOfficialSkillRequest(
|
||||
val systemImage: String,
|
||||
val sortOrder: Int,
|
||||
val thinkingEnabled: Boolean,
|
||||
val localizations: SkillLocalizationsDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillCatalogResponse(
|
||||
val schemaVersion: Int = 1,
|
||||
val revision: Long,
|
||||
val generatedAt: String? = null,
|
||||
val skills: List<OfficialSkillDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminSkillCatalogResponse(
|
||||
val revision: Long,
|
||||
val generatedAt: String? = null,
|
||||
val skills: List<AdminOfficialSkillDto>,
|
||||
)
|
||||
|
||||
data class OfficialSkillRecord(
|
||||
val id: String,
|
||||
val systemImage: String,
|
||||
val sortOrder: Int,
|
||||
val thinkingEnabled: Boolean,
|
||||
val enabled: Boolean,
|
||||
val localizations: SkillLocalizationsDto,
|
||||
)
|
||||
|
||||
data class SkillCatalogRecord(
|
||||
val revision: Long,
|
||||
val generatedAt: Instant?,
|
||||
val skills: List<OfficialSkillRecord>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AIHintCardDto(
|
||||
val id: String,
|
||||
val displayText: String? = null,
|
||||
val text: String? = null,
|
||||
val prompt: String,
|
||||
val category: String = "general",
|
||||
val priority: Int = 50,
|
||||
val source: String = "remote",
|
||||
val locale: String,
|
||||
val conditions: List<String> = emptyList(),
|
||||
val metadata: JsonObject? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AIHintPackResponse(
|
||||
val locale: String,
|
||||
val generatedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
val version: Int,
|
||||
val cards: List<AIHintCardDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AIHintManifestResponse(
|
||||
val generatedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
val intervalHours: Int? = null,
|
||||
val locales: List<String> = emptyList(),
|
||||
val files: Map<String, String?> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminHintPackResponse(
|
||||
val locale: String,
|
||||
val generatedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
val intervalHours: Int? = null,
|
||||
val version: Int,
|
||||
val cards: List<AIHintCardDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateHintPackRequest(
|
||||
val generatedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
val intervalHours: Int? = null,
|
||||
val cards: List<AIHintCardDto>,
|
||||
)
|
||||
|
||||
data class HintPackRecord(
|
||||
val locale: String,
|
||||
val generatedAt: Instant?,
|
||||
val expiresAt: Instant?,
|
||||
val intervalHours: Int?,
|
||||
val version: Int,
|
||||
val cardsJson: String,
|
||||
)
|
||||
|
||||
enum class ContentMutationResult {
|
||||
SUCCESS,
|
||||
NOT_FOUND,
|
||||
CONFLICT,
|
||||
LIMIT_EXCEEDED,
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
package com.osglab.account.features.content.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
|
||||
import com.osglab.account.features.content.models.ContentMutationResult
|
||||
import com.osglab.account.features.content.models.HintPackRecord
|
||||
import com.osglab.account.features.content.models.OfficialSkillRecord
|
||||
import com.osglab.account.features.content.models.SkillCatalogRecord
|
||||
import com.osglab.account.features.content.models.SkillLocalizationDto
|
||||
import com.osglab.account.features.content.models.SkillLocalizationsDto
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.time.Instant
|
||||
|
||||
internal object OfficialContentCatalogTable : Table("official_content_catalog") {
|
||||
val id = integer("id")
|
||||
val revision = long("revision")
|
||||
val generatedAt = timestamp("generated_at").nullable()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object OfficialSkillsTable : Table("official_skills") {
|
||||
val id = varchar("id", 128)
|
||||
val systemImage = varchar("system_image", 128)
|
||||
val sortOrder = integer("sort_order")
|
||||
val thinkingEnabled = bool("thinking_enabled")
|
||||
val enabled = bool("enabled")
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object OfficialSkillLocalizationsTable : Table("official_skill_localizations") {
|
||||
val skillId = varchar("skill_id", 128)
|
||||
val locale = varchar("locale", 16)
|
||||
val name = varchar("name", 120)
|
||||
val summary = varchar("summary", 500)
|
||||
val prompt = text("prompt")
|
||||
override val primaryKey = PrimaryKey(skillId, locale)
|
||||
}
|
||||
|
||||
internal object OfficialHintPacksTable : Table("official_hint_packs") {
|
||||
val locale = varchar("locale", 8)
|
||||
val generatedAt = timestamp("generated_at").nullable()
|
||||
val expiresAt = timestamp("expires_at").nullable()
|
||||
val intervalHours = integer("interval_hours").nullable()
|
||||
val version = integer("version")
|
||||
val cardsJson = text("cards_json")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(locale)
|
||||
}
|
||||
|
||||
interface ContentRepository {
|
||||
suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord
|
||||
|
||||
suspend fun createSkill(
|
||||
skill: OfficialSkillRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult
|
||||
|
||||
suspend fun updateSkill(
|
||||
skill: OfficialSkillRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult
|
||||
|
||||
suspend fun setSkillEnabled(
|
||||
id: String,
|
||||
enabled: Boolean,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult
|
||||
|
||||
suspend fun listHintPacks(): List<HintPackRecord>
|
||||
suspend fun getHintPack(locale: String): HintPackRecord?
|
||||
|
||||
suspend fun putHintPack(
|
||||
pack: HintPackRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): HintPackRecord
|
||||
}
|
||||
|
||||
class ExposedContentRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : ContentRepository {
|
||||
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
|
||||
databaseFactory.query {
|
||||
val catalog = catalogRow()
|
||||
val statement = OfficialSkillsTable.selectAll()
|
||||
if (enabledOnly) {
|
||||
statement.where { OfficialSkillsTable.enabled eq true }
|
||||
}
|
||||
val skills = statement
|
||||
.orderBy(
|
||||
OfficialSkillsTable.sortOrder to SortOrder.ASC,
|
||||
OfficialSkillsTable.id to SortOrder.ASC,
|
||||
)
|
||||
.map { row -> row.toSkillRecord(localizations(row[OfficialSkillsTable.id])) }
|
||||
SkillCatalogRecord(
|
||||
revision = catalog[OfficialContentCatalogTable.revision],
|
||||
generatedAt = catalog[OfficialContentCatalogTable.generatedAt],
|
||||
skills = skills,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createSkill(
|
||||
skill: OfficialSkillRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult = databaseFactory.query {
|
||||
lockCatalog()
|
||||
val exists = OfficialSkillsTable.selectAll()
|
||||
.where { OfficialSkillsTable.id eq skill.id }
|
||||
.limit(1)
|
||||
.any()
|
||||
val result = if (exists) {
|
||||
ContentMutationResult.CONFLICT
|
||||
} else {
|
||||
OfficialSkillsTable.insert {
|
||||
it[id] = skill.id
|
||||
it[systemImage] = skill.systemImage
|
||||
it[sortOrder] = skill.sortOrder
|
||||
it[thinkingEnabled] = skill.thinkingEnabled
|
||||
it[enabled] = skill.enabled
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
insertLocalizations(skill)
|
||||
incrementRevision(now)
|
||||
ContentMutationResult.SUCCESS
|
||||
}
|
||||
insertAudit(audit.withOutcome(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun updateSkill(
|
||||
skill: OfficialSkillRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult = databaseFactory.query {
|
||||
lockCatalog()
|
||||
val exists = OfficialSkillsTable.selectAll()
|
||||
.where { OfficialSkillsTable.id eq skill.id }
|
||||
.forUpdate()
|
||||
.singleOrNull() != null
|
||||
val result = if (!exists) {
|
||||
ContentMutationResult.NOT_FOUND
|
||||
} else {
|
||||
OfficialSkillsTable.update({ OfficialSkillsTable.id eq skill.id }) {
|
||||
it[systemImage] = skill.systemImage
|
||||
it[sortOrder] = skill.sortOrder
|
||||
it[thinkingEnabled] = skill.thinkingEnabled
|
||||
it[updatedAt] = now
|
||||
}
|
||||
updateLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
|
||||
updateLocalization(skill.id, EN, skill.localizations.en)
|
||||
incrementRevision(now)
|
||||
ContentMutationResult.SUCCESS
|
||||
}
|
||||
insertAudit(audit.withOutcome(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun setSkillEnabled(
|
||||
id: String,
|
||||
enabled: Boolean,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): ContentMutationResult = databaseFactory.query {
|
||||
lockCatalog()
|
||||
val row = OfficialSkillsTable.selectAll()
|
||||
.where { OfficialSkillsTable.id eq id }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
val result = when {
|
||||
row == null -> ContentMutationResult.NOT_FOUND
|
||||
enabled &&
|
||||
!row[OfficialSkillsTable.enabled] &&
|
||||
enabledSkillCount() >= MAXIMUM_ENABLED_SKILLS -> ContentMutationResult.LIMIT_EXCEEDED
|
||||
else -> {
|
||||
OfficialSkillsTable.update({ OfficialSkillsTable.id eq id }) {
|
||||
it[OfficialSkillsTable.enabled] = enabled
|
||||
it[updatedAt] = now
|
||||
}
|
||||
incrementRevision(now)
|
||||
ContentMutationResult.SUCCESS
|
||||
}
|
||||
}
|
||||
insertAudit(audit.withOutcome(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun listHintPacks(): List<HintPackRecord> =
|
||||
databaseFactory.query {
|
||||
OfficialHintPacksTable.selectAll()
|
||||
.orderBy(OfficialHintPacksTable.locale to SortOrder.ASC)
|
||||
.map(ResultRow::toHintPackRecord)
|
||||
}
|
||||
|
||||
override suspend fun getHintPack(locale: String): HintPackRecord? =
|
||||
databaseFactory.query {
|
||||
OfficialHintPacksTable.selectAll()
|
||||
.where { OfficialHintPacksTable.locale eq locale }
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?.toHintPackRecord()
|
||||
}
|
||||
|
||||
override suspend fun putHintPack(
|
||||
pack: HintPackRecord,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): HintPackRecord = databaseFactory.query {
|
||||
// The singleton lock makes the initial version=1 insert race-free.
|
||||
lockCatalog()
|
||||
val current = OfficialHintPacksTable.selectAll()
|
||||
.where { OfficialHintPacksTable.locale eq pack.locale }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
val next = pack.copy(version = (current?.get(OfficialHintPacksTable.version) ?: 0) + 1)
|
||||
if (current == null) {
|
||||
OfficialHintPacksTable.insert {
|
||||
it[locale] = next.locale
|
||||
it[generatedAt] = next.generatedAt
|
||||
it[expiresAt] = next.expiresAt
|
||||
it[intervalHours] = next.intervalHours
|
||||
it[version] = next.version
|
||||
it[cardsJson] = next.cardsJson
|
||||
it[updatedAt] = now
|
||||
}
|
||||
} else {
|
||||
OfficialHintPacksTable.update({ OfficialHintPacksTable.locale eq next.locale }) {
|
||||
it[generatedAt] = next.generatedAt
|
||||
it[expiresAt] = next.expiresAt
|
||||
it[intervalHours] = next.intervalHours
|
||||
it[version] = next.version
|
||||
it[cardsJson] = next.cardsJson
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
insertAudit(audit.copy(outcome = AdminAuditOutcome.SUCCESS))
|
||||
next
|
||||
}
|
||||
|
||||
private fun catalogRow(): ResultRow =
|
||||
OfficialContentCatalogTable.selectAll()
|
||||
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
|
||||
.single()
|
||||
|
||||
private fun lockCatalog(): ResultRow =
|
||||
OfficialContentCatalogTable.selectAll()
|
||||
.where { OfficialContentCatalogTable.id eq CATALOG_ID }
|
||||
.forUpdate()
|
||||
.single()
|
||||
|
||||
private fun incrementRevision(now: Instant) {
|
||||
val current = catalogRow()[OfficialContentCatalogTable.revision]
|
||||
OfficialContentCatalogTable.update({ OfficialContentCatalogTable.id eq CATALOG_ID }) {
|
||||
it[revision] = current + 1
|
||||
it[generatedAt] = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun enabledSkillCount(): Long =
|
||||
OfficialSkillsTable.selectAll()
|
||||
.where { OfficialSkillsTable.enabled eq true }
|
||||
.count()
|
||||
|
||||
private fun localizations(skillId: String): SkillLocalizationsDto {
|
||||
val rows = OfficialSkillLocalizationsTable.selectAll()
|
||||
.where { OfficialSkillLocalizationsTable.skillId eq skillId }
|
||||
.associateBy { it[OfficialSkillLocalizationsTable.locale] }
|
||||
return SkillLocalizationsDto(
|
||||
zhHans = requireNotNull(rows[ZH_HANS]).toLocalization(),
|
||||
en = requireNotNull(rows[EN]).toLocalization(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun insertLocalizations(skill: OfficialSkillRecord) {
|
||||
insertLocalization(skill.id, ZH_HANS, skill.localizations.zhHans)
|
||||
insertLocalization(skill.id, EN, skill.localizations.en)
|
||||
}
|
||||
|
||||
private fun insertLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
|
||||
OfficialSkillLocalizationsTable.insert {
|
||||
it[OfficialSkillLocalizationsTable.skillId] = skillId
|
||||
it[OfficialSkillLocalizationsTable.locale] = locale
|
||||
it[name] = value.name
|
||||
it[summary] = value.summary
|
||||
it[prompt] = value.prompt
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLocalization(skillId: String, locale: String, value: SkillLocalizationDto) {
|
||||
OfficialSkillLocalizationsTable.update({
|
||||
(OfficialSkillLocalizationsTable.skillId eq skillId) and
|
||||
(OfficialSkillLocalizationsTable.locale eq locale)
|
||||
}) {
|
||||
it[name] = value.name
|
||||
it[summary] = value.summary
|
||||
it[prompt] = value.prompt
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAudit(event: NewAdminAuditEvent) {
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = event.id.toString()
|
||||
it[actorOperatorId] = event.actorOperatorId?.toString()
|
||||
it[action] = event.action.name
|
||||
it[outcome] = event.outcome.name
|
||||
it[targetType] = event.targetType
|
||||
it[targetId] = event.targetId
|
||||
it[requestId] = event.requestId
|
||||
it[occurredAt] = event.occurredAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultRow.toSkillRecord(localizations: SkillLocalizationsDto): OfficialSkillRecord =
|
||||
OfficialSkillRecord(
|
||||
id = this[OfficialSkillsTable.id],
|
||||
systemImage = this[OfficialSkillsTable.systemImage],
|
||||
sortOrder = this[OfficialSkillsTable.sortOrder],
|
||||
thinkingEnabled = this[OfficialSkillsTable.thinkingEnabled],
|
||||
enabled = this[OfficialSkillsTable.enabled],
|
||||
localizations = localizations,
|
||||
)
|
||||
|
||||
private fun ResultRow.toLocalization(): SkillLocalizationDto =
|
||||
SkillLocalizationDto(
|
||||
name = this[OfficialSkillLocalizationsTable.name],
|
||||
summary = this[OfficialSkillLocalizationsTable.summary],
|
||||
prompt = this[OfficialSkillLocalizationsTable.prompt],
|
||||
)
|
||||
|
||||
private fun ResultRow.toHintPackRecord(): HintPackRecord =
|
||||
HintPackRecord(
|
||||
locale = this[OfficialHintPacksTable.locale],
|
||||
generatedAt = this[OfficialHintPacksTable.generatedAt],
|
||||
expiresAt = this[OfficialHintPacksTable.expiresAt],
|
||||
intervalHours = this[OfficialHintPacksTable.intervalHours],
|
||||
version = this[OfficialHintPacksTable.version],
|
||||
cardsJson = this[OfficialHintPacksTable.cardsJson],
|
||||
)
|
||||
|
||||
private fun NewAdminAuditEvent.withOutcome(result: ContentMutationResult): NewAdminAuditEvent =
|
||||
copy(
|
||||
outcome = if (result == ContentMutationResult.SUCCESS) {
|
||||
AdminAuditOutcome.SUCCESS
|
||||
} else {
|
||||
AdminAuditOutcome.DENIED
|
||||
},
|
||||
)
|
||||
|
||||
private const val CATALOG_ID = 1
|
||||
private const val MAXIMUM_ENABLED_SKILLS = 100L
|
||||
private const val ZH_HANS = "zh-Hans"
|
||||
private const val EN = "en"
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.osglab.account.features.content.routes
|
||||
|
||||
import com.osglab.account.features.content.models.AIHintManifestResponse
|
||||
import com.osglab.account.features.content.services.ContentErrorCode
|
||||
import com.osglab.account.features.content.services.ContentException
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun Route.contentRoutes(service: ContentService) {
|
||||
route("/v1/content") {
|
||||
get("/skills") {
|
||||
val catalog = service.publicSkills()
|
||||
call.respondCacheable(etag = "skills-${catalog.revision}") {
|
||||
call.respond(catalog)
|
||||
}
|
||||
}
|
||||
|
||||
get("/hints/manifest") {
|
||||
call.respondHintManifest(service)
|
||||
}
|
||||
|
||||
get("/hints/{locale}") {
|
||||
val locale = call.parameters["locale"]
|
||||
call.respondHintPack(service, locale)
|
||||
}
|
||||
}
|
||||
|
||||
get("/hints/manifest.json") {
|
||||
call.respondHintManifest(service)
|
||||
}
|
||||
|
||||
get("/hints/{fileName}") {
|
||||
val fileName = call.parameters["fileName"].orEmpty()
|
||||
val locale = LEGACY_HINT_FILE.matchEntire(fileName)?.groupValues?.get(1)
|
||||
call.respondHintPack(service, locale)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondHintManifest(service: ContentService) {
|
||||
val manifest = service.hintManifest()
|
||||
respondCacheable(etag = manifest.etag()) {
|
||||
respond(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondHintPack(
|
||||
service: ContentService,
|
||||
locale: String?,
|
||||
) {
|
||||
if (locale !in SUPPORTED_HINT_LOCALES) {
|
||||
respond(HttpStatusCode.NotFound)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val pack = service.publicHintPack(requireNotNull(locale))
|
||||
respondCacheable(etag = "hints-$locale-${pack.version}") {
|
||||
respond(pack)
|
||||
}
|
||||
} catch (exception: ContentException) {
|
||||
if (exception.code == ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND) {
|
||||
respond(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondCacheable(
|
||||
etag: String,
|
||||
body: suspend () -> Unit,
|
||||
) {
|
||||
val quotedEtag = """"$etag""""
|
||||
response.header(HttpHeaders.ETag, quotedEtag)
|
||||
response.header(HttpHeaders.CacheControl, "public,max-age=300")
|
||||
if (request.header(HttpHeaders.IfNoneMatch).matchesEtag(quotedEtag)) {
|
||||
respond(HttpStatusCode.NotModified)
|
||||
} else {
|
||||
body()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.matchesEtag(etag: String): Boolean =
|
||||
this?.split(',')?.any { candidate ->
|
||||
val normalized = candidate.trim().removePrefix("W/")
|
||||
normalized == "*" || normalized == etag
|
||||
} == true
|
||||
|
||||
private fun AIHintManifestResponse.etag(): String {
|
||||
val bytes = PUBLIC_JSON.encodeToString(this).toByteArray(Charsets.UTF_8)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return "hints-manifest-${digest.take(12).joinToString("") { "%02x".format(it.toInt() and 0xff) }}"
|
||||
}
|
||||
|
||||
private val PUBLIC_JSON = Json {
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
|
||||
private val LEGACY_HINT_FILE = Regex("""hints-(zh|en)\.json""")
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.osglab.account.features.content.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.content.models.AIHintCardDto
|
||||
import com.osglab.account.features.content.models.AIHintManifestResponse
|
||||
import com.osglab.account.features.content.models.AIHintPackResponse
|
||||
import com.osglab.account.features.content.models.AdminHintPackResponse
|
||||
import com.osglab.account.features.content.models.AdminOfficialSkillDto
|
||||
import com.osglab.account.features.content.models.AdminSkillCatalogResponse
|
||||
import com.osglab.account.features.content.models.ContentMutationResult
|
||||
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.models.HintPackRecord
|
||||
import com.osglab.account.features.content.models.OfficialSkillDto
|
||||
import com.osglab.account.features.content.models.OfficialSkillRecord
|
||||
import com.osglab.account.features.content.models.SkillCatalogResponse
|
||||
import com.osglab.account.features.content.models.SkillLocalizationDto
|
||||
import com.osglab.account.features.content.models.SkillLocalizationsDto
|
||||
import com.osglab.account.features.content.models.UpdateHintPackRequest
|
||||
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.repositories.ContentRepository
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
|
||||
enum class ContentErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
CONTENT_SKILL_NOT_FOUND,
|
||||
CONTENT_SKILL_CONFLICT,
|
||||
CONTENT_HINT_PACK_NOT_FOUND,
|
||||
}
|
||||
|
||||
class ContentException(
|
||||
val code: ContentErrorCode,
|
||||
) : RuntimeException(code.name)
|
||||
|
||||
class ContentService(
|
||||
private val repository: ContentRepository,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun publicSkills(): SkillCatalogResponse {
|
||||
val catalog = repository.getSkillCatalog(enabledOnly = true)
|
||||
check(catalog.skills.size <= MAXIMUM_ENABLED_SKILLS) {
|
||||
"Official Skill catalog exceeds the client maximum"
|
||||
}
|
||||
return SkillCatalogResponse(
|
||||
revision = catalog.revision,
|
||||
generatedAt = catalog.generatedAt?.toString(),
|
||||
skills = catalog.skills.map(OfficialSkillRecord::toPublicDto),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun adminSkills(): AdminSkillCatalogResponse {
|
||||
val catalog = repository.getSkillCatalog(enabledOnly = false)
|
||||
return AdminSkillCatalogResponse(
|
||||
revision = catalog.revision,
|
||||
generatedAt = catalog.generatedAt?.toString(),
|
||||
skills = catalog.skills.map(OfficialSkillRecord::toAdminDto),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun createSkill(
|
||||
actor: AdminPrincipal,
|
||||
request: CreateOfficialSkillRequest,
|
||||
requestId: String?,
|
||||
): AdminOfficialSkillDto {
|
||||
validateSkill(
|
||||
id = request.id,
|
||||
systemImage = request.systemImage,
|
||||
sortOrder = request.sortOrder,
|
||||
localizations = request.localizations,
|
||||
)
|
||||
val now = clock.instant()
|
||||
val skill = OfficialSkillRecord(
|
||||
id = request.id,
|
||||
systemImage = request.systemImage.trim(),
|
||||
sortOrder = request.sortOrder,
|
||||
thinkingEnabled = request.thinkingEnabled,
|
||||
enabled = false,
|
||||
localizations = request.localizations.trimmed(),
|
||||
)
|
||||
repository.createSkill(
|
||||
skill,
|
||||
now,
|
||||
audit(actor, AdminAuditAction.CONTENT_SKILL_CREATED, "OFFICIAL_SKILL", skill.id, requestId, now),
|
||||
).throwOnFailure()
|
||||
return skill.toAdminDto()
|
||||
}
|
||||
|
||||
suspend fun updateSkill(
|
||||
actor: AdminPrincipal,
|
||||
id: String,
|
||||
request: UpdateOfficialSkillRequest,
|
||||
requestId: String?,
|
||||
): AdminOfficialSkillDto {
|
||||
validateSkill(id, request.systemImage, request.sortOrder, request.localizations)
|
||||
val now = clock.instant()
|
||||
val skill = OfficialSkillRecord(
|
||||
id = id,
|
||||
systemImage = request.systemImage.trim(),
|
||||
sortOrder = request.sortOrder,
|
||||
thinkingEnabled = request.thinkingEnabled,
|
||||
enabled = false,
|
||||
localizations = request.localizations.trimmed(),
|
||||
)
|
||||
repository.updateSkill(
|
||||
skill,
|
||||
now,
|
||||
audit(actor, AdminAuditAction.CONTENT_SKILL_UPDATED, "OFFICIAL_SKILL", id, requestId, now),
|
||||
).throwOnFailure()
|
||||
val stored = repository.getSkillCatalog(enabledOnly = false).skills.first { it.id == id }
|
||||
return stored.toAdminDto()
|
||||
}
|
||||
|
||||
suspend fun setSkillEnabled(
|
||||
actor: AdminPrincipal,
|
||||
id: String,
|
||||
enabled: Boolean,
|
||||
requestId: String?,
|
||||
) {
|
||||
validateSkillId(id)
|
||||
val now = clock.instant()
|
||||
repository.setSkillEnabled(
|
||||
id = id,
|
||||
enabled = enabled,
|
||||
now = now,
|
||||
audit = audit(
|
||||
actor,
|
||||
if (enabled) AdminAuditAction.CONTENT_SKILL_ENABLED else AdminAuditAction.CONTENT_SKILL_DISABLED,
|
||||
"OFFICIAL_SKILL",
|
||||
id,
|
||||
requestId,
|
||||
now,
|
||||
),
|
||||
).throwOnFailure()
|
||||
}
|
||||
|
||||
suspend fun hintManifest(): AIHintManifestResponse {
|
||||
val packs = repository.listHintPacks()
|
||||
return AIHintManifestResponse(
|
||||
generatedAt = packs.mapNotNull(HintPackRecord::generatedAt).maxOrNull()?.toString(),
|
||||
expiresAt = packs.mapNotNull(HintPackRecord::expiresAt).minOrNull()?.toString(),
|
||||
intervalHours = packs.mapNotNull(HintPackRecord::intervalHours).minOrNull(),
|
||||
locales = packs.map(HintPackRecord::locale),
|
||||
files = packs.associate { it.locale to "/v1/content/hints/${it.locale}" },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun publicHintPack(locale: String): AIHintPackResponse {
|
||||
validateHintLocale(locale)
|
||||
return repository.getHintPack(locale)?.toPublicDto()
|
||||
?: throw ContentException(ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND)
|
||||
}
|
||||
|
||||
suspend fun adminHintPack(locale: String): AdminHintPackResponse {
|
||||
validateHintLocale(locale)
|
||||
return repository.getHintPack(locale)?.toAdminDto()
|
||||
?: AdminHintPackResponse(locale = locale, version = 0, cards = emptyList())
|
||||
}
|
||||
|
||||
suspend fun putHintPack(
|
||||
actor: AdminPrincipal,
|
||||
locale: String,
|
||||
request: UpdateHintPackRequest,
|
||||
requestId: String?,
|
||||
): AdminHintPackResponse {
|
||||
validateHintLocale(locale)
|
||||
val generatedAt = parseInstant(request.generatedAt)
|
||||
val expiresAt = parseInstant(request.expiresAt)
|
||||
if (generatedAt != null && expiresAt != null && !expiresAt.isAfter(generatedAt)) {
|
||||
invalid()
|
||||
}
|
||||
if (request.intervalHours != null && request.intervalHours !in 1..168) invalid()
|
||||
validateCards(locale, request.cards)
|
||||
val cardsJson = CONTENT_JSON.encodeToString(
|
||||
ListSerializer(AIHintCardDto.serializer()),
|
||||
request.cards,
|
||||
)
|
||||
if (cardsJson.length > MAX_HINT_PACK_CHARACTERS) invalid()
|
||||
val now = clock.instant()
|
||||
val stored = repository.putHintPack(
|
||||
HintPackRecord(
|
||||
locale = locale,
|
||||
generatedAt = generatedAt,
|
||||
expiresAt = expiresAt,
|
||||
intervalHours = request.intervalHours,
|
||||
version = 0,
|
||||
cardsJson = cardsJson,
|
||||
),
|
||||
now,
|
||||
audit(
|
||||
actor,
|
||||
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
|
||||
"OFFICIAL_HINT_PACK",
|
||||
locale,
|
||||
requestId,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return stored.toAdminDto()
|
||||
}
|
||||
|
||||
private fun validateSkill(
|
||||
id: String,
|
||||
systemImage: String,
|
||||
sortOrder: Int,
|
||||
localizations: SkillLocalizationsDto,
|
||||
) {
|
||||
validateSkillId(id)
|
||||
if (systemImage.trim().length !in 1..MAX_SYSTEM_IMAGE_CHARACTERS) invalid()
|
||||
if (sortOrder !in 0..100_000) invalid()
|
||||
validateLocalization(localizations.zhHans)
|
||||
validateLocalization(localizations.en)
|
||||
}
|
||||
|
||||
private fun validateSkillId(id: String) {
|
||||
if (!SKILL_ID.matches(id)) invalid()
|
||||
}
|
||||
|
||||
private fun validateHintLocale(locale: String) {
|
||||
if (locale !in SUPPORTED_HINT_LOCALES) invalid()
|
||||
}
|
||||
|
||||
private fun validateLocalization(value: SkillLocalizationDto) {
|
||||
if (value.name.trim().length !in 1..MAX_SKILL_NAME_CHARACTERS) invalid()
|
||||
if (value.summary.trim().length !in 1..MAX_SKILL_SUMMARY_CHARACTERS) invalid()
|
||||
if (value.prompt.trim().length !in 1..MAX_SKILL_PROMPT_CHARACTERS) invalid()
|
||||
}
|
||||
|
||||
private fun validateCards(locale: String, cards: List<AIHintCardDto>) {
|
||||
if (cards.size > MAX_HINT_CARDS || cards.map { it.id }.toSet().size != cards.size) invalid()
|
||||
cards.forEach { card ->
|
||||
if (card.id.trim().length !in 1..128) invalid()
|
||||
if (card.locale != locale) invalid()
|
||||
if (card.displayText.isNullOrBlank() && card.text.isNullOrBlank()) invalid()
|
||||
if ((card.displayText?.length ?: 0) > 500 || (card.text?.length ?: 0) > 500) invalid()
|
||||
if (card.prompt.trim().length !in 1..MAX_HINT_PROMPT_CHARACTERS) invalid()
|
||||
if (card.category.trim().length !in 1..64 || card.source.trim().length !in 1..64) invalid()
|
||||
if (card.priority !in -10_000..10_000) invalid()
|
||||
if (card.conditions.size > 20 || card.conditions.any { it.length !in 1..64 }) invalid()
|
||||
if ((card.metadata?.toString()?.length ?: 0) > MAX_METADATA_CHARACTERS) invalid()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseInstant(value: String?): Instant? =
|
||||
value?.let {
|
||||
runCatching { Instant.parse(it) }.getOrElse { invalid() }
|
||||
}
|
||||
|
||||
private fun ContentMutationResult.throwOnFailure() {
|
||||
when (this) {
|
||||
ContentMutationResult.SUCCESS -> Unit
|
||||
ContentMutationResult.NOT_FOUND ->
|
||||
throw ContentException(ContentErrorCode.CONTENT_SKILL_NOT_FOUND)
|
||||
ContentMutationResult.CONFLICT ->
|
||||
throw ContentException(ContentErrorCode.CONTENT_SKILL_CONFLICT)
|
||||
ContentMutationResult.LIMIT_EXCEEDED ->
|
||||
throw ContentException(ContentErrorCode.VALIDATION_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun OfficialSkillRecord.toPublicDto(): OfficialSkillDto =
|
||||
OfficialSkillDto(
|
||||
id = id,
|
||||
systemImage = systemImage,
|
||||
sortOrder = sortOrder,
|
||||
thinkingEnabled = thinkingEnabled,
|
||||
localizations = localizations,
|
||||
)
|
||||
|
||||
private fun OfficialSkillRecord.toAdminDto(): AdminOfficialSkillDto =
|
||||
AdminOfficialSkillDto(
|
||||
id = id,
|
||||
systemImage = systemImage,
|
||||
sortOrder = sortOrder,
|
||||
thinkingEnabled = thinkingEnabled,
|
||||
enabled = enabled,
|
||||
localizations = localizations,
|
||||
)
|
||||
|
||||
private fun HintPackRecord.toPublicDto(): AIHintPackResponse =
|
||||
AIHintPackResponse(
|
||||
locale = locale,
|
||||
generatedAt = generatedAt?.toString(),
|
||||
expiresAt = expiresAt?.toString(),
|
||||
version = version,
|
||||
cards = decodeCards(),
|
||||
)
|
||||
|
||||
private fun HintPackRecord.toAdminDto(): AdminHintPackResponse =
|
||||
AdminHintPackResponse(
|
||||
locale = locale,
|
||||
generatedAt = generatedAt?.toString(),
|
||||
expiresAt = expiresAt?.toString(),
|
||||
intervalHours = intervalHours,
|
||||
version = version,
|
||||
cards = decodeCards(),
|
||||
)
|
||||
|
||||
private fun HintPackRecord.decodeCards(): List<AIHintCardDto> =
|
||||
CONTENT_JSON.decodeFromString(ListSerializer(AIHintCardDto.serializer()), cardsJson)
|
||||
|
||||
private fun SkillLocalizationsDto.trimmed(): SkillLocalizationsDto =
|
||||
SkillLocalizationsDto(zhHans.trimmed(), en.trimmed())
|
||||
|
||||
private fun SkillLocalizationDto.trimmed(): SkillLocalizationDto =
|
||||
SkillLocalizationDto(name.trim(), summary.trim(), prompt.trim())
|
||||
|
||||
private fun audit(
|
||||
actor: AdminPrincipal,
|
||||
action: AdminAuditAction,
|
||||
targetType: String,
|
||||
targetId: String,
|
||||
requestId: String?,
|
||||
now: Instant,
|
||||
): NewAdminAuditEvent = NewAdminAuditEvent(
|
||||
actorOperatorId = actor.operatorId,
|
||||
action = action,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = targetType,
|
||||
targetId = targetId,
|
||||
requestId = requestId,
|
||||
occurredAt = now,
|
||||
)
|
||||
|
||||
private fun invalid(): Nothing = throw ContentException(ContentErrorCode.VALIDATION_ERROR)
|
||||
|
||||
private val CONTENT_JSON = Json {
|
||||
ignoreUnknownKeys = false
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val SKILL_ID = Regex("""official\.[a-z0-9._-]{1,91}""")
|
||||
private val SUPPORTED_HINT_LOCALES = setOf("zh", "en")
|
||||
private const val MAX_SYSTEM_IMAGE_CHARACTERS = 100
|
||||
private const val MAXIMUM_ENABLED_SKILLS = 100
|
||||
private const val MAX_SKILL_NAME_CHARACTERS = 40
|
||||
private const val MAX_SKILL_SUMMARY_CHARACTERS = 200
|
||||
private const val MAX_SKILL_PROMPT_CHARACTERS = 6_000
|
||||
private const val MAX_HINT_PROMPT_CHARACTERS = 16_000
|
||||
private const val MAX_HINT_CARDS = 500
|
||||
private const val MAX_METADATA_CHARACTERS = 8_000
|
||||
private const val MAX_HINT_PACK_CHARACTERS = 1_000_000
|
||||
@@ -127,6 +127,12 @@ class CreditService(
|
||||
return transactions.inTransaction { it.credits.listLedgerEntries(userId, limit) }
|
||||
}
|
||||
|
||||
suspend fun hasSignupTrial(userId: UUID): Boolean =
|
||||
transactions.inTransaction { unit ->
|
||||
unit.credits.findLedgerEntry(userId, signupTrialIdempotencyKey(userId))
|
||||
?.type == LedgerEntryType.SIGNUP_TRIAL
|
||||
}
|
||||
|
||||
override suspend fun getReservation(
|
||||
userId: UUID,
|
||||
reservationId: UUID,
|
||||
@@ -740,3 +746,6 @@ class CreditService(
|
||||
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun signupTrialIdempotencyKey(userId: UUID): String =
|
||||
"internal:signup-trial:$userId"
|
||||
|
||||
@@ -307,6 +307,12 @@ interface DeviceCheckTrialClaimRepository {
|
||||
|
||||
fun interface TrialCreditGranter {
|
||||
suspend fun grant(accountId: UUID)
|
||||
|
||||
/**
|
||||
* DeviceCheck tokens are ephemeral and cannot identify a previous claim.
|
||||
* The immutable credit ledger is the authoritative account-level record.
|
||||
*/
|
||||
suspend fun wasGranted(accountId: UUID): Boolean = false
|
||||
}
|
||||
|
||||
interface DeviceCheckTrialMutex {
|
||||
@@ -341,8 +347,19 @@ private object LocalDeviceCheckTrialMutex : DeviceCheckTrialMutex {
|
||||
override suspend fun <T> withLock(block: suspend () -> T): T = block()
|
||||
}
|
||||
|
||||
enum class SignupTrialClaimResult {
|
||||
GRANTED,
|
||||
ALREADY_GRANTED,
|
||||
INELIGIBLE,
|
||||
SKIPPED,
|
||||
;
|
||||
|
||||
val shouldRestrictAccount: Boolean
|
||||
get() = this == INELIGIBLE
|
||||
}
|
||||
|
||||
fun interface SignupTrialClaimService {
|
||||
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean
|
||||
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): SignupTrialClaimResult
|
||||
}
|
||||
|
||||
class DeviceCheckTrialService(
|
||||
@@ -353,35 +370,47 @@ class DeviceCheckTrialService(
|
||||
private val mutex: DeviceCheckTrialMutex = LocalDeviceCheckTrialMutex,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) : SignupTrialClaimService {
|
||||
override suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean {
|
||||
if (deviceToken.isNullOrBlank()) return false
|
||||
override suspend fun claimAndGrant(
|
||||
accountId: UUID,
|
||||
deviceToken: String?,
|
||||
): SignupTrialClaimResult {
|
||||
if (creditGranter.wasGranted(accountId)) {
|
||||
return SignupTrialClaimResult.ALREADY_GRANTED
|
||||
}
|
||||
if (deviceToken.isNullOrBlank()) return SignupTrialClaimResult.SKIPPED
|
||||
val tokenHash = sha256Hex(deviceToken)
|
||||
val owned = when (val result = repository.begin(tokenHash, accountId, clock.instant())) {
|
||||
is BeginTrialClaim.Owned -> result.claim
|
||||
BeginTrialClaim.ClaimedByAnotherAccount -> return false
|
||||
BeginTrialClaim.ClaimedByAnotherAccount -> return SignupTrialClaimResult.INELIGIBLE
|
||||
}
|
||||
return try {
|
||||
mutex.withLock {
|
||||
if (creditGranter.wasGranted(accountId)) {
|
||||
return@withLock SignupTrialClaimResult.ALREADY_GRANTED
|
||||
}
|
||||
completeOwnedClaim(owned, deviceToken)
|
||||
}
|
||||
} catch (exception: DeviceCheckRejectedException) {
|
||||
repository.transition(tokenHash, TrialClaimStatus.REJECTED, clock.instant())
|
||||
false
|
||||
SignupTrialClaimResult.INELIGIBLE
|
||||
} catch (exception: DeviceCheckUnavailableException) {
|
||||
if (policy == IntegrityPolicy.ENFORCE) {
|
||||
throw ExternalServiceUnavailableException("DeviceCheck")
|
||||
}
|
||||
false
|
||||
SignupTrialClaimResult.SKIPPED
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun completeOwnedClaim(claim: TrialClaim, deviceToken: String): Boolean {
|
||||
private suspend fun completeOwnedClaim(
|
||||
claim: TrialClaim,
|
||||
deviceToken: String,
|
||||
): SignupTrialClaimResult {
|
||||
when (claim.status) {
|
||||
TrialClaimStatus.COMPLETED -> return true
|
||||
TrialClaimStatus.REJECTED -> return false
|
||||
TrialClaimStatus.COMPLETED -> return SignupTrialClaimResult.ALREADY_GRANTED
|
||||
TrialClaimStatus.REJECTED -> return SignupTrialClaimResult.INELIGIBLE
|
||||
TrialClaimStatus.APPLE_MARKED -> {
|
||||
grantAndComplete(claim)
|
||||
return true
|
||||
return SignupTrialClaimResult.GRANTED
|
||||
}
|
||||
TrialClaimStatus.RESERVED -> Unit
|
||||
}
|
||||
@@ -392,7 +421,7 @@ class DeviceCheckTrialService(
|
||||
}
|
||||
if (state.bit0) {
|
||||
repository.transition(claim.tokenHash, TrialClaimStatus.REJECTED, clock.instant())
|
||||
return false
|
||||
return SignupTrialClaimResult.INELIGIBLE
|
||||
}
|
||||
|
||||
// Apple has no compare-and-set API. Marking first is intentionally conservative:
|
||||
@@ -407,7 +436,7 @@ class DeviceCheckTrialService(
|
||||
}
|
||||
repository.transition(claim.tokenHash, TrialClaimStatus.APPLE_MARKED, clock.instant())
|
||||
grantAndComplete(claim)
|
||||
return true
|
||||
return SignupTrialClaimResult.GRANTED
|
||||
}
|
||||
|
||||
private suspend fun grantAndComplete(claim: TrialClaim) {
|
||||
|
||||
Reference in New Issue
Block a user