Enhance ledger operations and referral lifecycle
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
@@ -115,7 +115,6 @@ import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||
import com.osglab.account.features.inviteweb.InviteOpenRecorder
|
||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||
import com.osglab.account.features.referrals.domain.ReferralException
|
||||
import com.osglab.account.features.referrals.routes.referralRoutes
|
||||
import com.osglab.account.features.referrals.services.ReferralOperations
|
||||
import com.osglab.account.features.referrals.services.ReferralService
|
||||
@@ -312,7 +311,7 @@ fun Application.module() {
|
||||
rateLimit(ACCOUNT_RATE_LIMIT) {
|
||||
accountRoutes(koin.get())
|
||||
creditRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
|
||||
storeKitRoutes(koin.get())
|
||||
}
|
||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||
@@ -519,13 +518,8 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
get<AccountService>().seedDisplayName(accountId, displayName)
|
||||
try {
|
||||
get<ReferralOperations>().getOrCreateCode(accountId)
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (_: ReferralException) {
|
||||
// Referral eligibility must not make account sign-in unavailable.
|
||||
}
|
||||
// Referral provisioning is intentionally handled by /v1/referrals/me
|
||||
// after authentication so referral storage can never block sign-in.
|
||||
}
|
||||
}
|
||||
single {
|
||||
@@ -585,10 +579,9 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
val transactions = get<BillingTransactionRunner>()
|
||||
ReferralLookupPort { code ->
|
||||
transactions.inTransaction { unit ->
|
||||
val referralCode = unit.referrals.findCode(code)
|
||||
referralCode?.campaignId
|
||||
?.let(unit.referrals::findCampaign)
|
||||
?.isActive(Instant.now()) == true
|
||||
// Invitation codes are permanent account identifiers. Campaign
|
||||
// availability is evaluated only when an invitee redeems one.
|
||||
unit.referrals.findCode(code) != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,15 @@ import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminReferralSort
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsageType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
@@ -41,6 +44,7 @@ import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
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 io.ktor.http.Cookie
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -651,21 +655,61 @@ private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
|
||||
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
|
||||
requireCreatedAtSort()
|
||||
requireQueryParameters(
|
||||
setOf(
|
||||
"cursor",
|
||||
"limit",
|
||||
"from",
|
||||
"until",
|
||||
"type",
|
||||
"entryType",
|
||||
"usageType",
|
||||
"referenceId",
|
||||
"sort",
|
||||
"order",
|
||||
),
|
||||
)
|
||||
val type = request.queryParameters["type"]?.let {
|
||||
when (it) {
|
||||
"reserve" -> AdminLedgerType.RESERVE
|
||||
"settle" -> AdminLedgerType.SETTLE
|
||||
"refund" -> AdminLedgerType.REFUND
|
||||
"grant" -> AdminLedgerType.GRANT
|
||||
"adjustment" -> AdminLedgerType.ADJUSTMENT
|
||||
else -> throw IllegalArgumentException("Invalid ledger type")
|
||||
}
|
||||
}
|
||||
val entryType = request.queryParameters["entryType"]?.let {
|
||||
runCatching { LedgerEntryType.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid ledger entry type")
|
||||
}
|
||||
val usageType = request.queryParameters["usageType"]?.let {
|
||||
when (it) {
|
||||
"polish" -> AdminUsageType.POLISH
|
||||
"asr" -> AdminUsageType.ASR
|
||||
"ai" -> AdminUsageType.AI
|
||||
"agent" -> AdminUsageType.AGENT
|
||||
"hotword" -> AdminUsageType.HOTWORD
|
||||
else -> throw IllegalArgumentException("Invalid ledger usage type")
|
||||
}
|
||||
}
|
||||
val referenceId = request.queryParameters["referenceId"]?.let {
|
||||
runCatching { UUID.fromString(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid ledger reference ID")
|
||||
}
|
||||
val sort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"createdAt" -> AdminLedgerSort.CREATED_AT
|
||||
"amount" -> AdminLedgerSort.AMOUNT
|
||||
else -> throw IllegalArgumentException("Invalid ledger sort")
|
||||
}
|
||||
} ?: AdminLedgerSort.CREATED_AT
|
||||
return AdminLedgerQuery(
|
||||
time = adminTimeFilter(),
|
||||
type = type,
|
||||
entryType = entryType,
|
||||
usageType = usageType,
|
||||
referenceId = referenceId,
|
||||
sort = sort,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
@@ -962,21 +1006,34 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||
AdminLedgerResponse(
|
||||
entryId = id,
|
||||
userId = userId,
|
||||
type = when (type) {
|
||||
"USAGE_RESERVE" -> "reserve"
|
||||
"USAGE_SETTLE" -> "settle"
|
||||
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
|
||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
|
||||
else -> "adjustment"
|
||||
},
|
||||
type = entryType.toAdminLedgerType(),
|
||||
entryType = entryType.name,
|
||||
amount = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
reasonCode = type,
|
||||
reasonCode = entryType.name,
|
||||
referenceId = referenceId,
|
||||
usageType = usageType,
|
||||
details = details,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun LedgerEntryType.toAdminLedgerType(): String =
|
||||
when (this) {
|
||||
LedgerEntryType.USAGE_RESERVE -> "reserve"
|
||||
LedgerEntryType.USAGE_SETTLE -> "settle"
|
||||
LedgerEntryType.USAGE_RELEASE,
|
||||
LedgerEntryType.USAGE_REFUND,
|
||||
-> "refund"
|
||||
|
||||
LedgerEntryType.SIGNUP_TRIAL,
|
||||
LedgerEntryType.MANUAL_GRANT,
|
||||
LedgerEntryType.REFERRAL_INVITER,
|
||||
LedgerEntryType.REFERRAL_INVITEE,
|
||||
LedgerEntryType.STOREKIT_PURCHASE,
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
-> "grant"
|
||||
}
|
||||
|
||||
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
|
||||
AdminOperatorResponse(
|
||||
operatorId = id.toString(),
|
||||
@@ -1162,10 +1219,13 @@ private data class AdminLedgerResponse(
|
||||
val entryId: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val entryType: String,
|
||||
val amount: Long,
|
||||
val balanceAfter: Long,
|
||||
val reasonCode: String,
|
||||
val referenceId: String?,
|
||||
val usageType: String?,
|
||||
val details: AdminLedgerDetailsDto?,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.admin.users.models
|
||||
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -27,12 +28,28 @@ data class AdminUserPageDto(
|
||||
data class AdminUserLedgerEntryDto(
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val entryType: LedgerEntryType,
|
||||
val amountDelta: Long,
|
||||
val balanceAfter: Long,
|
||||
val referenceId: String?,
|
||||
val createdAt: String,
|
||||
val usageType: String? = null,
|
||||
val details: AdminLedgerDetailsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminLedgerDetailsDto(
|
||||
val kind: String,
|
||||
val reason: String? = null,
|
||||
val operatorName: String? = null,
|
||||
val productId: String? = null,
|
||||
val transactionId: String? = null,
|
||||
val originalTransactionId: String? = null,
|
||||
val environment: String? = null,
|
||||
val purchasedAt: String? = null,
|
||||
val role: String? = null,
|
||||
val relatedUserId: String? = null,
|
||||
val reservationId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+337
-82
@@ -4,6 +4,7 @@ import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
@@ -19,11 +20,15 @@ import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.innerJoin
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.neq
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
@@ -34,9 +39,18 @@ data class AdminUserCursor(
|
||||
)
|
||||
|
||||
data class AdminUserLedgerCursor(
|
||||
val createdAt: Instant,
|
||||
val sort: AdminLedgerSort,
|
||||
val createdAt: Instant? = null,
|
||||
val amount: Long? = null,
|
||||
val ledgerEntryId: UUID,
|
||||
)
|
||||
) {
|
||||
init {
|
||||
require(
|
||||
(sort == AdminLedgerSort.CREATED_AT && createdAt != null && amount == null) ||
|
||||
(sort == AdminLedgerSort.AMOUNT && amount != null && createdAt == null),
|
||||
) { "Ledger cursor value does not match its sort" }
|
||||
}
|
||||
}
|
||||
|
||||
enum class AdminUserStatus {
|
||||
ACTIVE,
|
||||
@@ -65,12 +79,30 @@ enum class AdminLedgerType(
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
),
|
||||
),
|
||||
ADJUSTMENT(emptySet()),
|
||||
}
|
||||
|
||||
enum class AdminLedgerSort {
|
||||
CREATED_AT,
|
||||
AMOUNT,
|
||||
}
|
||||
|
||||
enum class AdminUsageType(
|
||||
internal val databaseValue: String,
|
||||
) {
|
||||
POLISH("POLISH"),
|
||||
ASR("ASR"),
|
||||
AI("AI"),
|
||||
AGENT("AGENT"),
|
||||
HOTWORD("HOTWORD"),
|
||||
}
|
||||
|
||||
data class AdminLedgerQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val type: AdminLedgerType? = null,
|
||||
val entryType: LedgerEntryType? = null,
|
||||
val usageType: AdminUsageType? = null,
|
||||
val referenceId: UUID? = null,
|
||||
val sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
|
||||
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
|
||||
@@ -217,13 +249,19 @@ class ExposedAdminUsersRepository(
|
||||
)
|
||||
}
|
||||
.sortedBy(AdminUsageAggregateDto::kind)
|
||||
val recentLedger = support.ledger.filter { it.userId == userId }
|
||||
val recentLedgerRows = support.ledger.filter { it.userId == userId }
|
||||
.sortedWith(
|
||||
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
|
||||
.thenByDescending { it.id.toString() },
|
||||
)
|
||||
.take(ledgerLimit)
|
||||
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
|
||||
val recentTrace = loadLedgerTrace(recentLedgerRows)
|
||||
val recentLedger = recentLedgerRows.map { row ->
|
||||
row.toDto(
|
||||
usageType = support.ledgerUsageTypes[row.referenceId],
|
||||
details = recentTrace.detailsFor(row),
|
||||
)
|
||||
}
|
||||
AdminUserDetailDto(
|
||||
summary = account.toSummary(support),
|
||||
referralCode = findReferralCode(userId),
|
||||
@@ -245,44 +283,7 @@ class ExposedAdminUsersRepository(
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
|
||||
val statement = AdminUsersCreditLedgerTable.selectAll()
|
||||
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
|
||||
}
|
||||
query.type?.let { type ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
|
||||
}
|
||||
if (cursor != null) {
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val ledger = statement.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to sortOrder,
|
||||
AdminUsersCreditLedgerTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toUserLedgerRow)
|
||||
val usageTypes = loadLedgerUsageTypes(ledger)
|
||||
ledger.map { it.toDto(usageTypes[it.referenceId]) }
|
||||
loadLedgerEntries(userId, limit, cursor, query)
|
||||
}
|
||||
|
||||
override suspend fun listLatestLedger(
|
||||
@@ -290,43 +291,120 @@ class ExposedAdminUsersRepository(
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
|
||||
val statement = AdminUsersCreditLedgerTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
|
||||
}
|
||||
query.type?.let { type ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
|
||||
}
|
||||
if (cursor != null) {
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
loadLedgerEntries(null, limit, cursor, query)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLedgerEntries(
|
||||
userId: UUID?,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> {
|
||||
val statement = if (query.usageType == null) {
|
||||
AdminUsersCreditLedgerTable.selectAll()
|
||||
} else {
|
||||
AdminUsersCreditLedgerTable
|
||||
.innerJoin(
|
||||
otherTable = AdminUsersProviderRequestsTable,
|
||||
onColumn = { referenceId },
|
||||
otherColumn = { reservationId },
|
||||
)
|
||||
.select(AdminUsersCreditLedgerTable.columns)
|
||||
}
|
||||
userId?.let { id ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.userId eq id.toString() }
|
||||
}
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
|
||||
}
|
||||
query.type?.let { type ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
|
||||
}
|
||||
query.entryType?.let { entryType ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType eq entryType }
|
||||
}
|
||||
query.referenceId?.let { referenceId ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.referenceId eq referenceId.toString() }
|
||||
}
|
||||
query.usageType?.let { usageType ->
|
||||
statement.andWhere {
|
||||
if (usageType == AdminUsageType.HOTWORD) {
|
||||
AdminUsersProviderRequestsTable.requestSource eq usageType.databaseValue
|
||||
} else {
|
||||
(
|
||||
AdminUsersProviderRequestsTable.requestSource.isNull() or
|
||||
(AdminUsersProviderRequestsTable.requestSource neq AdminUsageType.HOTWORD.databaseValue)
|
||||
) and
|
||||
(AdminUsersProviderRequestsTable.capability eq usageType.databaseValue)
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val ledger = statement.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to sortOrder,
|
||||
}
|
||||
cursor?.let {
|
||||
require(it.sort == query.sort) { "Ledger cursor sort does not match query" }
|
||||
statement.andWhere { ledgerAfterCursor(it, query) }
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val sortColumn = when (query.sort) {
|
||||
AdminLedgerSort.CREATED_AT -> AdminUsersCreditLedgerTable.createdAt
|
||||
AdminLedgerSort.AMOUNT -> AdminUsersCreditLedgerTable.amountDelta
|
||||
}
|
||||
val ledger = statement
|
||||
.orderBy(
|
||||
sortColumn to sortOrder,
|
||||
AdminUsersCreditLedgerTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toUserLedgerRow)
|
||||
val usageTypes = loadLedgerUsageTypes(ledger)
|
||||
ledger.map { it.toDto(usageTypes[it.referenceId]) }
|
||||
.limit(limit)
|
||||
.map(ResultRow::toUserLedgerRow)
|
||||
val usageTypes = loadLedgerUsageTypes(ledger)
|
||||
val trace = loadLedgerTrace(ledger)
|
||||
return ledger.map { row ->
|
||||
row.toDto(
|
||||
usageType = usageTypes[row.referenceId],
|
||||
details = trace.detailsFor(row),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ledgerAfterCursor(
|
||||
cursor: AdminUserLedgerCursor,
|
||||
query: AdminLedgerQuery,
|
||||
) = when (query.sort) {
|
||||
AdminLedgerSort.CREATED_AT -> {
|
||||
val value = requireNotNull(cursor.createdAt)
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.createdAt greater value) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq value) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.createdAt less value) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq value) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AdminLedgerSort.AMOUNT -> {
|
||||
val value = requireNotNull(cursor.amount)
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.amountDelta greater value) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.amountDelta eq value) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.amountDelta less value) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.amountDelta eq value) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,13 +479,165 @@ private fun loadLedgerUsageTypes(ledger: List<UserLedgerRow>): Map<UUID, String>
|
||||
}
|
||||
.associate { row ->
|
||||
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
|
||||
(
|
||||
row[AdminUsersProviderRequestsTable.requestSource]
|
||||
?: row[AdminUsersProviderRequestsTable.capability]
|
||||
).lowercase()
|
||||
if (
|
||||
row[AdminUsersProviderRequestsTable.requestSource] ==
|
||||
AdminUsageType.HOTWORD.databaseValue
|
||||
) {
|
||||
AdminUsageType.HOTWORD.name.lowercase()
|
||||
} else {
|
||||
row[AdminUsersProviderRequestsTable.capability].lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ManualGrantTrace(
|
||||
val reason: String,
|
||||
val operatorName: String,
|
||||
)
|
||||
|
||||
private data class StoreKitTrace(
|
||||
val productId: String,
|
||||
val transactionId: String,
|
||||
val originalTransactionId: String,
|
||||
val environment: String,
|
||||
val purchasedAt: Instant,
|
||||
)
|
||||
|
||||
private data class ReferralTrace(
|
||||
val inviterUserId: UUID,
|
||||
val inviteeUserId: UUID,
|
||||
)
|
||||
|
||||
private data class LedgerTrace(
|
||||
val manualGrants: Map<UUID, ManualGrantTrace>,
|
||||
val storeKitPurchases: Map<UUID, StoreKitTrace>,
|
||||
val referrals: Map<UUID, ReferralTrace>,
|
||||
) {
|
||||
fun detailsFor(row: UserLedgerRow): AdminLedgerDetailsDto? =
|
||||
when (row.type) {
|
||||
LedgerEntryType.MANUAL_GRANT -> manualGrants[row.id]?.let {
|
||||
AdminLedgerDetailsDto(
|
||||
kind = "manualGrant",
|
||||
reason = it.reason,
|
||||
operatorName = it.operatorName,
|
||||
)
|
||||
}
|
||||
|
||||
LedgerEntryType.STOREKIT_PURCHASE -> storeKitPurchases[row.id]?.let {
|
||||
AdminLedgerDetailsDto(
|
||||
kind = "storeKit",
|
||||
productId = it.productId,
|
||||
transactionId = it.transactionId,
|
||||
originalTransactionId = it.originalTransactionId,
|
||||
environment = it.environment,
|
||||
purchasedAt = it.purchasedAt.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
LedgerEntryType.REFERRAL_INVITER,
|
||||
LedgerEntryType.REFERRAL_INVITEE,
|
||||
-> row.referenceId?.let(referrals::get)?.let { referral ->
|
||||
val inviter = row.type == LedgerEntryType.REFERRAL_INVITER
|
||||
AdminLedgerDetailsDto(
|
||||
kind = "referral",
|
||||
role = if (inviter) "inviter" else "invitee",
|
||||
relatedUserId = (
|
||||
if (inviter) referral.inviteeUserId else referral.inviterUserId
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
LedgerEntryType.USAGE_RESERVE,
|
||||
LedgerEntryType.USAGE_SETTLE,
|
||||
LedgerEntryType.USAGE_RELEASE,
|
||||
LedgerEntryType.USAGE_REFUND,
|
||||
-> row.referenceId?.let {
|
||||
AdminLedgerDetailsDto(
|
||||
kind = "usage",
|
||||
reservationId = it.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
LedgerEntryType.SIGNUP_TRIAL,
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLedgerTrace(ledger: List<UserLedgerRow>): LedgerTrace {
|
||||
if (ledger.isEmpty()) return LedgerTrace(emptyMap(), emptyMap(), emptyMap())
|
||||
val ledgerIds = ledger.map { it.id.toString() }
|
||||
val manualGrants = AdminUsersAdminCreditGrantsTable
|
||||
.innerJoin(
|
||||
otherTable = AdminUsersAdminOperatorsTable,
|
||||
onColumn = { operatorId },
|
||||
otherColumn = { id },
|
||||
)
|
||||
.select(
|
||||
AdminUsersAdminCreditGrantsTable.ledgerEntryId,
|
||||
AdminUsersAdminCreditGrantsTable.reason,
|
||||
AdminUsersAdminOperatorsTable.username,
|
||||
)
|
||||
.where { AdminUsersAdminCreditGrantsTable.ledgerEntryId inList ledgerIds }
|
||||
.associate { row ->
|
||||
UUID.fromString(row[AdminUsersAdminCreditGrantsTable.ledgerEntryId]) to
|
||||
ManualGrantTrace(
|
||||
reason = row[AdminUsersAdminCreditGrantsTable.reason],
|
||||
operatorName = row[AdminUsersAdminOperatorsTable.username],
|
||||
)
|
||||
}
|
||||
val storeKitPurchases = AdminUsersStoreKitCreditPurchasesTable
|
||||
.select(
|
||||
AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId,
|
||||
AdminUsersStoreKitCreditPurchasesTable.productId,
|
||||
AdminUsersStoreKitCreditPurchasesTable.transactionId,
|
||||
AdminUsersStoreKitCreditPurchasesTable.originalTransactionId,
|
||||
AdminUsersStoreKitCreditPurchasesTable.environment,
|
||||
AdminUsersStoreKitCreditPurchasesTable.purchasedAt,
|
||||
)
|
||||
.where { AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId inList ledgerIds }
|
||||
.associate { row ->
|
||||
UUID.fromString(row[AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId]) to
|
||||
StoreKitTrace(
|
||||
productId = row[AdminUsersStoreKitCreditPurchasesTable.productId],
|
||||
transactionId = row[AdminUsersStoreKitCreditPurchasesTable.transactionId],
|
||||
originalTransactionId =
|
||||
row[AdminUsersStoreKitCreditPurchasesTable.originalTransactionId],
|
||||
environment = row[AdminUsersStoreKitCreditPurchasesTable.environment],
|
||||
purchasedAt = row[AdminUsersStoreKitCreditPurchasesTable.purchasedAt],
|
||||
)
|
||||
}
|
||||
val referralIds = ledger.asSequence()
|
||||
.filter {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE
|
||||
}
|
||||
.mapNotNull(UserLedgerRow::referenceId)
|
||||
.map(UUID::toString)
|
||||
.distinct()
|
||||
.toList()
|
||||
val referrals = if (referralIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
AdminUsersReferralBindingsTable.select(
|
||||
AdminUsersReferralBindingsTable.id,
|
||||
AdminUsersReferralBindingsTable.inviterUserId,
|
||||
AdminUsersReferralBindingsTable.inviteeUserId,
|
||||
)
|
||||
.where { AdminUsersReferralBindingsTable.id inList referralIds }
|
||||
.associate { row ->
|
||||
UUID.fromString(row[AdminUsersReferralBindingsTable.id]) to
|
||||
ReferralTrace(
|
||||
inviterUserId =
|
||||
UUID.fromString(row[AdminUsersReferralBindingsTable.inviterUserId]),
|
||||
inviteeUserId =
|
||||
UUID.fromString(row[AdminUsersReferralBindingsTable.inviteeUserId]),
|
||||
)
|
||||
}
|
||||
}
|
||||
return LedgerTrace(manualGrants, storeKitPurchases, referrals)
|
||||
}
|
||||
|
||||
private fun findReferralCode(userId: UUID): String? =
|
||||
AdminUsersReferralCodesTable.selectAll()
|
||||
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
|
||||
@@ -473,6 +703,26 @@ private object AdminUsersProviderRequestsTable : Table("provider_requests") {
|
||||
override val primaryKey = PrimaryKey(accountId, requestId)
|
||||
}
|
||||
|
||||
private object AdminUsersAdminCreditGrantsTable : Table("admin_credit_grants") {
|
||||
val operatorId = varchar("operator_id", 36)
|
||||
val reason = varchar("reason", 500)
|
||||
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||
}
|
||||
|
||||
private object AdminUsersAdminOperatorsTable : Table("admin_operators") {
|
||||
val id = varchar("id", 36)
|
||||
val username = varchar("username", 64)
|
||||
}
|
||||
|
||||
private object AdminUsersStoreKitCreditPurchasesTable : Table("storekit_credit_purchases") {
|
||||
val transactionId = varchar("transaction_id", 64)
|
||||
val originalTransactionId = varchar("original_transaction_id", 64)
|
||||
val productId = varchar("product_id", 128)
|
||||
val environment = varchar("environment", 16)
|
||||
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||
val purchasedAt = timestamp("purchased_at")
|
||||
}
|
||||
|
||||
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
|
||||
val userId = varchar("user_id", 36)
|
||||
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
|
||||
@@ -484,6 +734,7 @@ private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
|
||||
}
|
||||
|
||||
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
|
||||
val id = varchar("id", 36)
|
||||
val inviterUserId = varchar("inviter_user_id", 36)
|
||||
val inviteeUserId = varchar("invitee_user_id", 36)
|
||||
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
|
||||
@@ -508,14 +759,18 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
|
||||
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
|
||||
)
|
||||
|
||||
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
|
||||
private fun UserLedgerRow.toDto(
|
||||
usageType: String?,
|
||||
details: AdminLedgerDetailsDto?,
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = type.name,
|
||||
entryType = type,
|
||||
amountDelta = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
referenceId = referenceId?.toString(),
|
||||
usageType = usageType,
|
||||
details = details,
|
||||
createdAt = createdAt.toString(),
|
||||
)
|
||||
|
||||
|
||||
+56
-12
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserPageDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
|
||||
@@ -106,7 +107,9 @@ class AdminUsersService(
|
||||
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
|
||||
): AdminUserLedgerPageDto {
|
||||
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
|
||||
val decodedCursor = cursor?.let {
|
||||
AdminUserLedgerCursorCodec.decode(it, query.sort, query.order)
|
||||
}
|
||||
val results = load(limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
@@ -114,9 +117,14 @@ class AdminUsersService(
|
||||
val last = items.last()
|
||||
AdminUserLedgerCursorCodec.encode(
|
||||
AdminUserLedgerCursor(
|
||||
createdAt = Instant.parse(last.createdAt),
|
||||
sort = query.sort,
|
||||
createdAt = last.createdAt.takeIf {
|
||||
query.sort == AdminLedgerSort.CREATED_AT
|
||||
}?.let(Instant::parse),
|
||||
amount = last.amountDelta.takeIf { query.sort == AdminLedgerSort.AMOUNT },
|
||||
ledgerEntryId = UUID.fromString(last.id),
|
||||
),
|
||||
query.sort,
|
||||
query.order,
|
||||
)
|
||||
} else {
|
||||
@@ -173,13 +181,26 @@ internal object AdminUserCursorCodec {
|
||||
internal object AdminUserLedgerCursorCodec {
|
||||
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
|
||||
|
||||
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
|
||||
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
|
||||
fun encode(
|
||||
cursor: AdminUserLedgerCursor,
|
||||
sort: AdminLedgerSort,
|
||||
order: AdminSortOrder,
|
||||
): String {
|
||||
require(cursor.sort == sort) { INVALID_CURSOR_MESSAGE }
|
||||
val sortValue = when (sort) {
|
||||
AdminLedgerSort.CREATED_AT -> requireNotNull(cursor.createdAt).toString()
|
||||
AdminLedgerSort.AMOUNT -> requireNotNull(cursor.amount).toString()
|
||||
}
|
||||
val value = "v2|${sort.name}|${order.name}|$sortValue|${cursor.ledgerEntryId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
|
||||
fun decode(
|
||||
value: String,
|
||||
expectedSort: AdminLedgerSort,
|
||||
expectedOrder: AdminSortOrder,
|
||||
): AdminUserLedgerCursor {
|
||||
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
|
||||
return try {
|
||||
val decoded = String(
|
||||
@@ -187,13 +208,36 @@ internal object AdminUserLedgerCursorCodec {
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 4)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminUserLedgerCursor(
|
||||
createdAt = Instant.parse(parts[2]),
|
||||
ledgerEntryId = UUID.fromString(parts[3]),
|
||||
)
|
||||
when (parts.firstOrNull()) {
|
||||
"v1" -> {
|
||||
require(expectedSort == AdminLedgerSort.CREATED_AT)
|
||||
require(parts.size == 4)
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminUserLedgerCursor(
|
||||
sort = AdminLedgerSort.CREATED_AT,
|
||||
createdAt = Instant.parse(parts[2]),
|
||||
ledgerEntryId = UUID.fromString(parts[3]),
|
||||
)
|
||||
}
|
||||
|
||||
"v2" -> {
|
||||
require(parts.size == 5)
|
||||
require(parts[1] == expectedSort.name)
|
||||
require(parts[2] == expectedOrder.name)
|
||||
AdminUserLedgerCursor(
|
||||
sort = expectedSort,
|
||||
createdAt = parts[3].takeIf {
|
||||
expectedSort == AdminLedgerSort.CREATED_AT
|
||||
}?.let(Instant::parse),
|
||||
amount = parts[3].takeIf {
|
||||
expectedSort == AdminLedgerSort.AMOUNT
|
||||
}?.toLong(),
|
||||
ledgerEntryId = UUID.fromString(parts[4]),
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException(INVALID_CURSOR_MESSAGE)
|
||||
}
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
|
||||
}
|
||||
|
||||
+61
-29
@@ -26,7 +26,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
@@ -158,6 +157,14 @@ private object ReferralCodes : Table("referral_codes") {
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object ReferralOwnerCodes : Table("referral_owner_codes") {
|
||||
val ownerUserId = varchar("owner_user_id", 36)
|
||||
val codeId = varchar("code_id", 36)
|
||||
val createdAt = timestamp("created_at")
|
||||
|
||||
override val primaryKey = PrimaryKey(ownerUserId)
|
||||
}
|
||||
|
||||
private object ReferralBindings : Table("referral_bindings") {
|
||||
val id = varchar("id", 36)
|
||||
val inviterUserId = varchar("inviter_user_id", 36)
|
||||
@@ -465,28 +472,63 @@ private object ExposedCreditsRepository : CreditsRepository {
|
||||
}
|
||||
|
||||
private object ExposedReferralsRepository : ReferralsRepository {
|
||||
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? {
|
||||
val query = ReferralCodes
|
||||
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
|
||||
ReferralOwnerCodes
|
||||
.innerJoin(
|
||||
otherTable = ReferralCodes,
|
||||
onColumn = { codeId },
|
||||
otherColumn = { id },
|
||||
)
|
||||
.selectAll()
|
||||
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() }
|
||||
return if (campaignId == null) {
|
||||
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull()
|
||||
} else {
|
||||
query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull()
|
||||
}?.toReferralCode()
|
||||
}
|
||||
|
||||
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
|
||||
ReferralCodes
|
||||
.selectAll()
|
||||
.where {
|
||||
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and
|
||||
(ReferralCodes.campaignId eq campaignId.toString())
|
||||
}
|
||||
.forUpdate()
|
||||
.where { ReferralOwnerCodes.ownerUserId eq ownerUserId.toString() }
|
||||
.singleOrNull()
|
||||
?.toReferralCode()
|
||||
|
||||
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
|
||||
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
|
||||
val inserted = ReferralCodes.insertIgnore {
|
||||
it[id] = candidate.id.toString()
|
||||
it[ownerUserId] = candidate.ownerUserId.toString()
|
||||
it[ownerIdentityFingerprint] = candidate.ownerIdentityFingerprint
|
||||
it[campaignId] = (candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
|
||||
it[code] = candidate.code
|
||||
it[createdAt] = candidate.createdAt
|
||||
}.insertedCount == 1
|
||||
val storedCode = if (inserted) {
|
||||
candidate
|
||||
} else {
|
||||
ReferralCodes
|
||||
.selectAll()
|
||||
.where {
|
||||
(ReferralCodes.ownerUserId eq candidate.ownerUserId.toString()) and
|
||||
(
|
||||
ReferralCodes.campaignId eq
|
||||
(candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
|
||||
)
|
||||
}
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
?.toReferralCode()
|
||||
?: return null
|
||||
}
|
||||
ReferralOwnerCodes.insertIgnore {
|
||||
it[ownerUserId] = candidate.ownerUserId.toString()
|
||||
it[codeId] = storedCode.id.toString()
|
||||
it[createdAt] = storedCode.createdAt
|
||||
}
|
||||
return ReferralOwnerCodes
|
||||
.innerJoin(
|
||||
otherTable = ReferralCodes,
|
||||
onColumn = { codeId },
|
||||
otherColumn = { id },
|
||||
)
|
||||
.selectAll()
|
||||
.where { ReferralOwnerCodes.ownerUserId eq candidate.ownerUserId.toString() }
|
||||
.forUpdate()
|
||||
.single()
|
||||
.toReferralCode()
|
||||
}
|
||||
|
||||
override fun findCode(code: String): ReferralCode? =
|
||||
ReferralCodes
|
||||
.selectAll()
|
||||
@@ -494,16 +536,6 @@ private object ExposedReferralsRepository : ReferralsRepository {
|
||||
.singleOrNull()
|
||||
?.toReferralCode()
|
||||
|
||||
override fun insertCodeIfAbsent(code: ReferralCode): Boolean =
|
||||
ReferralCodes.insertIgnore {
|
||||
it[id] = code.id.toString()
|
||||
it[ownerUserId] = code.ownerUserId.toString()
|
||||
it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint
|
||||
it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
|
||||
it[ReferralCodes.code] = code.code
|
||||
it[createdAt] = code.createdAt
|
||||
}.insertedCount == 1
|
||||
|
||||
override fun findCampaign(id: UUID): ReferralCampaign? =
|
||||
ReferralCampaigns
|
||||
.selectAll()
|
||||
|
||||
+10
-14
@@ -299,7 +299,16 @@ class ExposedGatewayRepository(
|
||||
): ComplimentaryRequestClaim? = databaseFactory.query {
|
||||
val now = clock.instant()
|
||||
val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL)
|
||||
val inserted = ComplimentaryRequestsTable.insertIgnore {
|
||||
val reclaimed = ComplimentaryRequestsTable.update({
|
||||
complimentaryKey(accountId, purpose, capability) and
|
||||
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
|
||||
(ComplimentaryRequestsTable.expiresAt lessEq now)
|
||||
}) {
|
||||
it[ComplimentaryRequestsTable.requestId] = requestId
|
||||
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
|
||||
it[updatedAt] = now
|
||||
} == 1
|
||||
val inserted = !reclaimed && ComplimentaryRequestsTable.insertIgnore {
|
||||
it[ComplimentaryRequestsTable.accountId] = accountId
|
||||
it[ComplimentaryRequestsTable.purpose] = purpose.name
|
||||
it[ComplimentaryRequestsTable.capability] = capability.name
|
||||
@@ -309,19 +318,6 @@ class ExposedGatewayRepository(
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}.insertedCount == 1
|
||||
val reclaimed = if (!inserted) {
|
||||
ComplimentaryRequestsTable.update({
|
||||
complimentaryKey(accountId, purpose, capability) and
|
||||
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
|
||||
(ComplimentaryRequestsTable.expiresAt lessEq now)
|
||||
}) {
|
||||
it[ComplimentaryRequestsTable.requestId] = requestId
|
||||
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
|
||||
it[updatedAt] = now
|
||||
} == 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (!inserted && !reclaimed) return@query null
|
||||
ComplimentaryRequestClaim(accountId, purpose, capability, requestId)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import java.util.Base64
|
||||
/**
|
||||
* Read-only boundary used by the public page to verify a referral code.
|
||||
*
|
||||
* Implementations must preserve case, apply campaign validity rules, use a bounded database query,
|
||||
* and never log the code.
|
||||
* Implementations must preserve case, treat issued codes as permanent, use a bounded database
|
||||
* query, and never log the code.
|
||||
*/
|
||||
fun interface ReferralLookupPort {
|
||||
suspend fun isValid(code: String): Boolean
|
||||
@@ -193,7 +193,7 @@ private suspend fun ApplicationCall.respondAasa(aasa: String) {
|
||||
|
||||
private suspend fun ApplicationCall.respondInvalidInvitation() {
|
||||
respondText(
|
||||
text = "邀请链接无效或已失效 / This invitation link is invalid or expired",
|
||||
text = "邀请链接无效 / This invitation link is invalid",
|
||||
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
|
||||
status = HttpStatusCode.NotFound,
|
||||
)
|
||||
|
||||
@@ -14,12 +14,14 @@ data class BindReferralRequest(
|
||||
@Serializable
|
||||
data class ReferralCodeDto(
|
||||
val code: String,
|
||||
val inviteUrl: String,
|
||||
val campaignId: String?,
|
||||
val createdAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(value: ReferralCode) = ReferralCodeDto(
|
||||
fun fromDomain(value: ReferralCode, inviteBaseUrl: String) = ReferralCodeDto(
|
||||
code = value.code,
|
||||
inviteUrl = "${inviteBaseUrl.trimEnd('/')}/${value.code}",
|
||||
campaignId = value.campaignId?.toString(),
|
||||
createdAt = value.createdAt.toString(),
|
||||
)
|
||||
@@ -66,12 +68,12 @@ data class ReferralCampaignDto(
|
||||
|
||||
@Serializable
|
||||
data class ReferralProfileDto(
|
||||
val code: ReferralCodeDto?,
|
||||
val code: ReferralCodeDto,
|
||||
val binding: ReferralBindingDto?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(value: ReferralProfile) = ReferralProfileDto(
|
||||
code = value.code?.let(ReferralCodeDto::fromDomain),
|
||||
fun fromDomain(value: ReferralProfile, inviteBaseUrl: String) = ReferralProfileDto(
|
||||
code = ReferralCodeDto.fromDomain(value.code, inviteBaseUrl),
|
||||
binding = value.binding?.let(ReferralBindingDto::fromDomain),
|
||||
)
|
||||
}
|
||||
|
||||
+6
-4
@@ -8,14 +8,16 @@ import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface ReferralsRepository {
|
||||
fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode?
|
||||
fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode?
|
||||
|
||||
fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode?
|
||||
/**
|
||||
* Atomically returns the account's existing permanent code or claims [candidate].
|
||||
* Returns null only when the candidate code collided and the caller should retry.
|
||||
*/
|
||||
fun claimPermanentCode(candidate: ReferralCode): ReferralCode?
|
||||
|
||||
fun findCode(code: String): ReferralCode?
|
||||
|
||||
fun insertCodeIfAbsent(code: ReferralCode): Boolean
|
||||
|
||||
fun findCampaign(id: UUID): ReferralCampaign?
|
||||
|
||||
fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
|
||||
|
||||
@@ -26,13 +26,14 @@ import java.util.UUID
|
||||
|
||||
class ReferralRouteInstaller(
|
||||
private val service: ReferralOperations,
|
||||
private val inviteBaseUrl: String,
|
||||
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||
) {
|
||||
fun install(parent: Route) {
|
||||
parent.route("/v1/referrals") {
|
||||
get("/me") {
|
||||
call.referralCall(authenticatedUser) { userId ->
|
||||
ReferralProfileDto.fromDomain(service.getProfile(userId))
|
||||
ReferralProfileDto.fromDomain(service.getProfile(userId), inviteBaseUrl)
|
||||
}
|
||||
}
|
||||
get {
|
||||
@@ -58,7 +59,7 @@ class ReferralRouteInstaller(
|
||||
}
|
||||
post("/code") {
|
||||
call.referralCall(authenticatedUser) { userId ->
|
||||
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId))
|
||||
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId), inviteBaseUrl)
|
||||
}
|
||||
}
|
||||
post("/bind") {
|
||||
@@ -73,9 +74,10 @@ class ReferralRouteInstaller(
|
||||
|
||||
fun Route.referralRoutes(
|
||||
service: ReferralOperations,
|
||||
inviteBaseUrl: String,
|
||||
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||
) {
|
||||
ReferralRouteInstaller(service, authenticatedUser).install(this)
|
||||
ReferralRouteInstaller(service, inviteBaseUrl, authenticatedUser).install(this)
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.referralCall(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.referrals.services
|
||||
|
||||
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
|
||||
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
|
||||
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
|
||||
import com.osglab.account.features.referrals.domain.ReferralBinding
|
||||
@@ -34,13 +35,13 @@ typealias ReferralRiskIdentity = ReferralRiskAssessment
|
||||
typealias ReferralRiskProvider = ReferralRiskPort
|
||||
|
||||
data class ReferralProfile(
|
||||
val code: ReferralCode?,
|
||||
val code: ReferralCode,
|
||||
val binding: ReferralBinding?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Public referral boundary. Binding and code creation remain transactionally
|
||||
* consistent even when callers retry after a timeout.
|
||||
* Public referral boundary. Each account receives one permanent code, while the
|
||||
* active campaign is selected only when an invitee redeems that code.
|
||||
*/
|
||||
interface ReferralOperations {
|
||||
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
|
||||
@@ -76,19 +77,22 @@ class ReferralService(
|
||||
}
|
||||
|
||||
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
|
||||
transactions.inTransaction { unit ->
|
||||
unit.referrals.findPermanentCodeByOwner(ownerUserId)
|
||||
}?.let { return it }
|
||||
|
||||
val ownerIdentity = requireEligibleIdentity(ownerUserId)
|
||||
return transactions.inTransaction { unit ->
|
||||
val now = clock.instant()
|
||||
val campaign = if (campaignId == null) {
|
||||
unit.referrals.listActiveCampaigns(now).firstOrNull()
|
||||
?: throw ReferralNotFound("No active referral campaign exists")
|
||||
unit.referrals.findPermanentCodeByOwner(ownerUserId)?.let {
|
||||
return@inTransaction it
|
||||
}
|
||||
val storageCampaignId = if (campaignId == null) {
|
||||
DEFAULT_REFERRAL_CAMPAIGN_ID
|
||||
} else {
|
||||
unit.referrals.findCampaign(campaignId)
|
||||
?: throw ReferralNotFound("Referral campaign does not exist")
|
||||
}
|
||||
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
|
||||
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
|
||||
return@inTransaction it
|
||||
campaignId
|
||||
}
|
||||
repeat(MAX_CODE_ATTEMPTS) {
|
||||
val candidate = ReferralCode(
|
||||
@@ -97,15 +101,14 @@ class ReferralService(
|
||||
ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
|
||||
code = codeGenerator.generate(),
|
||||
createdAt = now,
|
||||
campaignId = campaign.id,
|
||||
// The campaign column is retained for historical compatibility only.
|
||||
// A referral code now belongs to the account for its entire lifetime.
|
||||
campaignId = storageCampaignId,
|
||||
)
|
||||
if (candidate.code.length < 20) {
|
||||
throw IllegalStateException("Invite code generator must provide at least 120 bits")
|
||||
}
|
||||
if (unit.referrals.insertCodeIfAbsent(candidate)) {
|
||||
return@inTransaction candidate
|
||||
}
|
||||
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
|
||||
unit.referrals.claimPermanentCode(candidate)?.let {
|
||||
return@inTransaction it
|
||||
}
|
||||
}
|
||||
@@ -123,7 +126,11 @@ class ReferralService(
|
||||
else throw ReferralConflict("This account is already bound to another inviter")
|
||||
}
|
||||
if (existing != null) return existing
|
||||
val referralCode = transactions.inTransaction { unit ->
|
||||
unit.referrals.findCode(code)
|
||||
} ?: throw ReferralNotFound("Referral code does not exist")
|
||||
val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
|
||||
requireEligibleIdentity(referralCode.ownerUserId)
|
||||
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
|
||||
?: throw ReferralNotFound("Registration time is unavailable")
|
||||
val now = clock.instant()
|
||||
@@ -136,15 +143,11 @@ class ReferralService(
|
||||
}
|
||||
val referralCode = unit.referrals.findCode(code)
|
||||
?: throw ReferralNotFound("Referral code does not exist")
|
||||
val campaign = referralCode.campaignId
|
||||
?.let(unit.referrals::findCampaign)
|
||||
if (campaign != null && !campaign.isActive(now)) {
|
||||
throw ReferralNotFound("Referral campaign is not active")
|
||||
}
|
||||
val effectiveWindow = campaign
|
||||
?.bindingWindowSeconds
|
||||
?.let(Duration::ofSeconds)
|
||||
?: bindingWindow
|
||||
// Campaigns define the reward policy at redemption time; they no longer
|
||||
// define the lifetime of the account's permanent invitation code.
|
||||
val campaign = unit.referrals.listActiveCampaigns(now).firstOrNull()
|
||||
?: throw ReferralNotFound("No active referral campaign exists")
|
||||
val effectiveWindow = Duration.ofSeconds(campaign.bindingWindowSeconds)
|
||||
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
|
||||
throw ReferralWindowExpired()
|
||||
}
|
||||
@@ -164,7 +167,7 @@ class ReferralService(
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
campaignId = referralCode.campaignId,
|
||||
campaignId = campaign.id,
|
||||
)
|
||||
if (unit.referrals.insertBindingIfAbsent(binding)) {
|
||||
binding
|
||||
|
||||
Reference in New Issue
Block a user