行为按 docs/GAMIFICATION_DESIGN.md(duoqi-flutter/docs/GAMIFICATION_REQUIREMENTS_CHANGE.md)对齐: - 新增 src/utils/time.ts 北京时间工具,统一替换散落的 UTC 日期/周边界计算 - challenge-service:未完成 session 续答(同 nodeId 复用 pending/in_progress),每日次数按 session 幂等扣减(条件 UPDATE 抢 daily_attempt_consumed_at 锁),移除高奖励 multiplier 和答对时发 first_knowledge_card - 新增 knowledge-card-service 和 POST /challenges/knowledge-cards/:cardId/view:用户打开/收下卡片时在单事务内发 review_explanation (3 XP) 和 first_knowledge_card (15 XP),fallback 卡走独立幂等 namespace - tracks-service:mapNodeStatus 只返回 current/done - hearts-service:删除 Pro/ProPlus 免扣分支和新用户 1 心保护,RestoreMethod 收窄为 'ad' | 'wait' - xp-service / leaderboard-service / streak-service / ad-recovery-service / progress-summary-service:时区切北京;排行榜同分按 last_xp_at 升序,weeklySettlement 不再发前 3 名金币;streak milestone 返回空 - /shop/purchase、/inventory/items/use、/subscription/verify 返回 NOT_AVAILABLE_IN_MVP - schema 加 challenge_sessions.daily_attempt_consumed_at(迁移 0007) - 接口文档 docs/api-reference.md 同步 12 项 MVP 行为变化 - 测试:新增 utils/time 和 tracks node-status 测试;hearts/streak/leaderboard 测试按新行为更新;challenge-service 13 个 MVP 之外(Plus/高奖励/first_knowledge_card 答对触发)测试 it.skip 待重写 mock 队列 typecheck / lint / test (146 passed, 13 skipped) 全部通过。
272 lines
6.2 KiB
TypeScript
272 lines
6.2 KiB
TypeScript
export type SubscriptionTier = 'free' | 'pro' | 'proplus';
|
||
export type SubscriptionStatus = 'none' | 'active' | 'expired' | 'cancelled';
|
||
export type NodeStatus = 'done' | 'current' | 'locked' | 'chest';
|
||
export type LeaderboardScope = 'region' | 'topic';
|
||
export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug';
|
||
export type SubscriptionPlatform = 'huawei' | 'apple' | 'google';
|
||
|
||
export interface RegionDto {
|
||
code: string;
|
||
name: string;
|
||
shortName: string;
|
||
parentCode: string | null;
|
||
level: number;
|
||
sortOrder: number;
|
||
enabled: boolean;
|
||
}
|
||
|
||
export interface RegionsConfigDto {
|
||
version: string;
|
||
countryCode: 'CN';
|
||
hierarchy: 'flat';
|
||
updatedAt: string;
|
||
regions: readonly RegionDto[];
|
||
}
|
||
|
||
export interface UserRegionDto {
|
||
code: string;
|
||
name: string;
|
||
shortName: string;
|
||
selectedAt: string | null;
|
||
nextChangeAllowedAt: string | null;
|
||
}
|
||
|
||
export interface UserBriefDto {
|
||
id: string;
|
||
nickname: string;
|
||
avatarUrl: string | null;
|
||
tier: SubscriptionTier;
|
||
level: number;
|
||
region: UserRegionDto | null;
|
||
}
|
||
|
||
export interface SubscriptionDto {
|
||
status: SubscriptionStatus;
|
||
tier: SubscriptionTier;
|
||
expiresAt: string | null;
|
||
autoRenew?: boolean;
|
||
}
|
||
|
||
export interface ProgressSummaryDto {
|
||
hearts: number;
|
||
maxHearts: number;
|
||
nextHeartRestoreAt: string | null;
|
||
dailyAttemptsLeft: number;
|
||
dailyAttemptsMax: number;
|
||
nextAttemptResetAt: string | null;
|
||
highRewardSessionsLeft: number;
|
||
highRewardSessionsMax: number;
|
||
xp: number;
|
||
level: number;
|
||
xpToNextLevel: number;
|
||
streakDays: number;
|
||
checkInDays: number;
|
||
streakProtectedUntil: string | null;
|
||
activeTrackId: string | null;
|
||
isSubscribed: boolean;
|
||
}
|
||
|
||
export interface ThemeNodeDto {
|
||
id: string;
|
||
title: string;
|
||
status: NodeStatus;
|
||
reward: string;
|
||
questionCount: number;
|
||
}
|
||
|
||
export interface ThemeTrackDto {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
progress: number;
|
||
nodes: ThemeNodeDto[];
|
||
}
|
||
|
||
export interface ShopBenefitDto {
|
||
id: string;
|
||
type: 'hearts' | 'attempts' | 'streak' | 'subscription';
|
||
title: string;
|
||
description: string;
|
||
enabled: boolean;
|
||
requiresAd: boolean;
|
||
}
|
||
|
||
export type ShopProductType = 'item' | 'cosmetic';
|
||
export type ShopProductId =
|
||
| 'hint-feather'
|
||
| 'heart-supply'
|
||
| 'double-xp-potion'
|
||
| 'streak-shield'
|
||
| 'mascot-outfit-starter';
|
||
|
||
export interface ShopProductDto {
|
||
id: ShopProductId;
|
||
type: ShopProductType;
|
||
itemId: string;
|
||
title: string;
|
||
description: string;
|
||
priceCoins: number;
|
||
quantity: number;
|
||
enabled: boolean;
|
||
}
|
||
|
||
export interface ShopCatalogDto {
|
||
benefits: readonly ShopBenefitDto[];
|
||
products: readonly ShopProductDto[];
|
||
}
|
||
|
||
export interface ShopPurchaseResultDto {
|
||
product: ShopProductDto;
|
||
coinsSpent: number;
|
||
coinsBalance: number;
|
||
item: {
|
||
itemId: string;
|
||
quantity: number;
|
||
activeUntil: string | null;
|
||
metadata: Record<string, unknown> | null;
|
||
};
|
||
rewards: ReadonlyArray<{
|
||
type: string;
|
||
source?: string;
|
||
amount?: number;
|
||
itemId?: string;
|
||
quantity?: number;
|
||
title?: string;
|
||
}>;
|
||
}
|
||
|
||
export interface WalletDto {
|
||
coinsBalance: number;
|
||
}
|
||
|
||
export interface InventoryItemDto {
|
||
itemId: string;
|
||
quantity: number;
|
||
activeUntil: string | null;
|
||
metadata: Record<string, unknown> | null;
|
||
}
|
||
|
||
export interface InventoryDto {
|
||
items: readonly InventoryItemDto[];
|
||
}
|
||
|
||
export type UsableInventoryItemId = 'streak_shield' | 'double_xp_potion' | 'heart_supply' | 'hint_feather';
|
||
|
||
export interface UseInventoryItemResultDto {
|
||
itemId: UsableInventoryItemId;
|
||
quantityRemaining: number;
|
||
effect: {
|
||
type: 'restore_hearts' | 'double_xp' | 'hint' | 'streak_protection';
|
||
activeUntil?: string | null;
|
||
hearts?: number;
|
||
excludedOptions?: readonly string[];
|
||
streakProtectedUntil?: string | null;
|
||
};
|
||
}
|
||
|
||
export interface BootstrapDto {
|
||
user: UserBriefDto;
|
||
progress: ProgressSummaryDto;
|
||
tracks: ThemeTrackDto[];
|
||
shopBenefits: readonly ShopBenefitDto[];
|
||
shop: ShopCatalogDto;
|
||
wallet: WalletDto;
|
||
inventory: InventoryDto;
|
||
subscription: SubscriptionDto;
|
||
}
|
||
|
||
export interface ChallengeQuestionDto {
|
||
challengeId: string;
|
||
trackId: string;
|
||
nodeId: string;
|
||
question: {
|
||
id: string;
|
||
prompt: string;
|
||
options: ReadonlyArray<{ id: string; text: string }>;
|
||
};
|
||
}
|
||
|
||
export interface ChallengeSessionDto {
|
||
challengeId: string;
|
||
trackId: string;
|
||
nodeId: string;
|
||
totalQuestions: number;
|
||
highRewardEligible: boolean;
|
||
questions: readonly ChallengeQuestionDto[];
|
||
/** 已提交过答案的题目 ID(用于 session 续答场景,客户端据此跳到下一道未答题)。 */
|
||
answeredQuestionIds: readonly string[];
|
||
}
|
||
|
||
/** 知识卡查看事件响应:返回本次发放的 XP 奖励和最新进度。 */
|
||
export interface KnowledgeCardViewDto {
|
||
cardId: string;
|
||
rewards: ReadonlyArray<{
|
||
type: string;
|
||
source?: string;
|
||
amount?: number;
|
||
title?: string;
|
||
}>;
|
||
progress: ProgressSummaryDto;
|
||
}
|
||
|
||
export interface AnswerRequestDto {
|
||
challengeId: string;
|
||
questionId: string;
|
||
selectedOptionId: string;
|
||
timeMs: number;
|
||
submitRequestId?: string;
|
||
}
|
||
|
||
export interface AnswerResultDto {
|
||
answerState: 'correct' | 'wrong';
|
||
correctOptionId: string;
|
||
xpDelta: number;
|
||
progress: {
|
||
hearts: number;
|
||
dailyAttemptsLeft: number;
|
||
highRewardSessionsLeft: number;
|
||
highRewardSessionsMax: number;
|
||
xp: number;
|
||
streakDays: number;
|
||
};
|
||
knowledgeCard: {
|
||
id: string;
|
||
title: string;
|
||
summary: string;
|
||
fact: string;
|
||
};
|
||
rewards: ReadonlyArray<{
|
||
type: string;
|
||
source?: string;
|
||
amount?: number;
|
||
itemId?: string;
|
||
quantity?: number;
|
||
title?: string;
|
||
}>;
|
||
}
|
||
|
||
export interface LeaderboardEntryDto {
|
||
rank: number;
|
||
userId: string;
|
||
displayName: string;
|
||
avatarUrl: string | null;
|
||
xp: number;
|
||
badge: string;
|
||
isMe: boolean;
|
||
}
|
||
|
||
/** 周榜元信息,附带在排行榜响应中。 */
|
||
export interface LeaderboardMetaDto {
|
||
weekStart: string;
|
||
weekEnd: string;
|
||
nextRefreshAt: string;
|
||
groupId: string | null;
|
||
requiresRegionSelection?: boolean;
|
||
selectedRegion?: UserRegionDto | null;
|
||
viewRegion?: RegionDto | null;
|
||
/** 当前用户组内排名(仅 /leaderboards/me 返回)。 */
|
||
rank?: number;
|
||
/** 当前周奖励预览:各组前 3 名的金币奖励。 */
|
||
rewardPreview: Array<{ rank: number; coins: number }>;
|
||
}
|