Add region-based leaderboard support
Some checks failed
CI/CD Pipeline / Unit Tests (push) Failing after 34s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Has been skipped

This commit is contained in:
Wang Zhuoxuan 2026-06-08 15:43:54 +08:00
parent 0317c34099
commit 5e7b7b1cda
24 changed files with 4221 additions and 20 deletions

View File

@ -0,0 +1,15 @@
ALTER TABLE `users` ADD `region_code` varchar(20);--> statement-breakpoint
ALTER TABLE `users` ADD `region_selected_at` datetime;--> statement-breakpoint
ALTER TABLE `users` ADD `region_changed_at` datetime;--> statement-breakpoint
CREATE INDEX `idx_users_region` ON `users` (`region_code`);--> statement-breakpoint
CREATE TABLE `user_region_change_logs` (
`id` char(36) NOT NULL,
`user_id` char(36) NOT NULL,
`from_region_code` varchar(20),
`to_region_code` varchar(20) NOT NULL,
`changed_at` datetime DEFAULT CURRENT_TIMESTAMP,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `user_region_change_logs_id` PRIMARY KEY(`id`)
);--> statement-breakpoint
ALTER TABLE `user_region_change_logs` ADD CONSTRAINT `user_region_change_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_region_change_user_changed` ON `user_region_change_logs` (`user_id`,`changed_at`);

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,13 @@
"when": 1778494900458, "when": 1778494900458,
"tag": "0003_lyrical_carnage", "tag": "0003_lyrical_carnage",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1780903327517,
"tag": "0004_user_region",
"breakpoints": true
} }
] ]
} }

View File

@ -404,7 +404,14 @@
"nickname": "知识探险家", "nickname": "知识探险家",
"avatarUrl": null, "avatarUrl": null,
"tier": "free", "tier": "free",
"level": 1 "level": 1,
"region": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
}
}, },
"progress": { "progress": {
"hearts": 5, "hearts": 5,
@ -467,6 +474,83 @@
说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。 说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。
#### GET /app/regions
认证:公开接口(无需 JWT
客户端每次启动时同步地区列表到本地缓存。MVP 阶段仅包含中国大陆 31 个一级行政区;数据结构保留 `parentCode` / `level`,后续可扩展到二级地区而不改变协议形状。
响应:
```json
{
"success": true,
"data": {
"version": "2026-06-08.1",
"countryCode": "CN",
"hierarchy": "flat",
"updatedAt": "2026-06-08T00:00:00.000Z",
"regions": [
{
"code": "310000",
"name": "上海市",
"shortName": "上海",
"parentCode": null,
"level": 1,
"sortOrder": 30,
"enabled": true
}
]
},
"error": null
}
```
#### PATCH /users/me/region
认证JWT
用户选择或变更地区。首次选择不受限制;已有地区后,每个北京时间自然月只允许变更一次。
请求:
```json
{
"regionCode": "310000"
}
```
响应:
```json
{
"success": true,
"data": {
"region": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
}
},
"error": null
}
```
同月再次变更:
```json
{
"success": false,
"data": null,
"error": {
"code": "REGION_CHANGE_LIMIT_REACHED",
"message": "每个自然月只能修改一次地区,请下个月再试。"
}
}
```
#### GET /tracks #### GET /tracks
认证JWT 认证JWT
@ -581,8 +665,8 @@
"xpDelta": 10, "xpDelta": 10,
"progress": { "progress": {
"hearts": 5, "hearts": 5,
"dailyAttemptsLeft": 5, "dailyAttemptsLeft": 4,
"highRewardSessionsLeft": 2, "highRewardSessionsLeft": 3,
"highRewardSessionsMax": 3, "highRewardSessionsMax": 3,
"xp": 10, "xp": 10,
"streakDays": 0 "streakDays": 0
@ -603,6 +687,11 @@
`answerState` 取值:`correct`, `wrong` `answerState` 取值:`correct`, `wrong`
资源扣减规则:
- 每次单题提交成功裁决后,`dailyAttemptsLeft` 扣 1重复提交同一题或同一 `submitRequestId` 返回第一次裁决快照,不重复扣减。
- `highRewardSessionsLeft` 按 5 题挑战组消耗;只有本组最后一题触发挑战完成结算后,才会从 3/3 变为 2/3。
#### GET /progress/summary #### GET /progress/summary
认证JWT 认证JWT
@ -626,9 +715,43 @@
#### POST /progress/check-in #### POST /progress/check-in
认证JWT 认证JWT
请求:无
响应:更新后的 `ProgressSummaryDto` 用途:完成当天签到,并返回更新后的进度摘要。
请求 body无。
客户端对接要求:
- 推荐不发送 body也不要设置 `Content-Type`
- 如果客户端网络库要求 JSON body请发送空对象 `{}`,不要发送“带 `Content-Type: application/json` 但 body 为空”的请求。
- 服务端以 UTC 日期判断“当天”。同一 UTC 日期内重复调用不会重复增加 `checkInDays`
成功响应:更新后的 `ProgressSummaryDto`
```json
{
"success": true,
"data": {
"hearts": 5,
"maxHearts": 5,
"nextHeartRestoreAt": null,
"dailyAttemptsLeft": 5,
"dailyAttemptsMax": 5,
"nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
"highRewardSessionsLeft": 3,
"highRewardSessionsMax": 3,
"xp": 0,
"level": 1,
"xpToNextLevel": 100,
"streakDays": 0,
"checkInDays": 1,
"streakProtectedUntil": null,
"activeTrackId": null,
"isSubscribed": false
},
"error": null
}
```
#### GET /leaderboards #### GET /leaderboards
@ -638,8 +761,9 @@
| 参数 | 类型 | 默认 | 说明 | | 参数 | 类型 | 默认 | 说明 |
|------|------|------|------| |------|------|------|------|
| `scope` | `region``topic` | `region` | 排行榜范围 | | `scope` | `region``topic` | `region` | `region` 返回地区榜;`topic` 暂保留原本周 XP 分组榜 |
| `trackId` | string | - | `scope=topic` 时可传 | | `regionCode` | string | 用户已选择地区 | 查看指定地区榜;不传则展示用户已选择地区 |
| `trackId` | string | - | 当前版本预留,不参与筛选 |
| `page` | number | 1 | 页码 | | `page` | number | 1 | 页码 |
| `limit` | number | 20 | 1-100 | | `limit` | number | 20 | 1-100 |
@ -664,6 +788,23 @@
"weekEnd": "2026-05-17", "weekEnd": "2026-05-17",
"nextRefreshAt": "2026-05-18", "nextRefreshAt": "2026-05-18",
"groupId": "week-2026-05-11-group-1", "groupId": "week-2026-05-11-group-1",
"requiresRegionSelection": false,
"selectedRegion": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
},
"viewRegion": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"parentCode": null,
"level": 1,
"sortOrder": 30,
"enabled": true
},
"rewardPreview": [ "rewardPreview": [
{ "rank": 1, "coins": 300 }, { "rank": 1, "coins": 300 },
{ "rank": 2, "coins": 150 }, { "rank": 2, "coins": 150 },
@ -679,7 +820,7 @@
} }
``` ```
> `xp` 为本周累计 XP非全局累计排名基于用户所在 20-30 人分组内。`meta.rewardPreview` 展示各组前 3 名的金币奖励,激励用户冲榜。 > `scope=region` 时,`xp` 为本周累计 XP非全局累计排名基于 `users.region_code` 过滤后的地区榜。用户未选择地区且请求未带 `regionCode` 时,服务端返回空榜并设置 `meta.requiresRegionSelection=true`,客户端应提示用户选择所在地区。`scope=topic` 当前仍保留原本周 XP 分组榜。
#### GET /leaderboards/me #### GET /leaderboards/me

View File

@ -78,6 +78,7 @@ describe('bootstrap-service', () => {
avatarUrl: null, avatarUrl: null,
tier: 'free', tier: 'free',
level: 2, level: 2,
region: null,
}); });
expect(result.wallet).toEqual({ coinsBalance: 260 }); expect(result.wallet).toEqual({ coinsBalance: 260 });
expect(result.inventory.items).toEqual([ expect(result.inventory.items).toEqual([

View File

@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { getRegionsConfig, updateUserRegion } from '../../../services/app/regions-service.js';
import { mockSelectQueue } from '../../helpers/db-mock.js';
function setupUpdate() {
const whereSpy = vi.fn().mockResolvedValue({ affectedRows: 1 });
const setSpy = vi.fn().mockReturnValue({ where: whereSpy });
vi.mocked(db.update).mockReturnValue({ set: setSpy } as never);
return { setSpy, whereSpy };
}
function setupInsert() {
const valuesSpy = vi.fn().mockResolvedValue(undefined);
vi.mocked(db.insert).mockReturnValue({ values: valuesSpy } as never);
return valuesSpy;
}
describe('regions-service', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-08T04:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('返回 31 个一级行政区配置', () => {
const config = getRegionsConfig();
expect(config.countryCode).toBe('CN');
expect(config.regions).toHaveLength(31);
expect(config.regions[0]).toEqual({
code: '110000',
name: '北京市',
shortName: '北京',
parentCode: null,
level: 1,
sortOrder: 10,
enabled: true,
});
});
it('首次选择地区不受每月一次限制', async () => {
mockSelectQueue(vi.mocked(db.select), [[{ regionCode: null, regionSelectedAt: null, regionChangedAt: null }]]);
const { setSpy } = setupUpdate();
const valuesSpy = setupInsert();
const result = await updateUserRegion('user-1', '310000');
expect(result.code).toBe('310000');
expect(result.name).toBe('上海市');
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ regionCode: '310000' }));
expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
fromRegionCode: null,
toRegionCode: '310000',
}));
});
it('同一自然月内再次变更地区时返回友好错误', async () => {
mockSelectQueue(vi.mocked(db.select), [
[{ regionCode: '310000', regionSelectedAt: new Date('2026-06-01T00:00:00.000Z'), regionChangedAt: new Date('2026-06-01T00:00:00.000Z') }],
[{ id: 'log-1' }],
]);
await expect(updateUserRegion('user-1', '440000')).rejects.toMatchObject({
code: 'REGION_CHANGE_LIMIT_REACHED',
message: '每个自然月只能修改一次地区,请下个月再试。',
});
});
});

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js'; import { db } from '../../../db/client.js';
import { weeklySettlement, getUserRank, getLeaderboardMeta } from '../../../services/gamification/leaderboard-service.js'; import { weeklySettlement, getRegionLeaderboard, getUserRank, getUserRegionRank, getLeaderboardMeta } from '../../../services/gamification/leaderboard-service.js';
import { addToWeeklyXp } from '../../../services/progress/xp-service.js'; import { addToWeeklyXp } from '../../../services/progress/xp-service.js';
import { mockSelectQueue } from '../../helpers/db-mock.js'; import { mockSelectQueue } from '../../helpers/db-mock.js';
@ -144,6 +144,42 @@ describe('leaderboard-service', () => {
}); });
}); });
// ── 地区榜 ──────────────────────────────────────────────────────
describe('region leaderboard', () => {
it('按地区返回当前周 XP 排名', async () => {
setupSelectQueue([[
{ userId: 'u1', weeklyXp: 200, nickname: '甲', avatarUrl: null },
{ userId: 'u2', weeklyXp: 100, nickname: '乙', avatarUrl: null },
]]);
const result = await getRegionLeaderboard('310000');
expect(result.items).toHaveLength(2);
expect(result.items[0]).toEqual(expect.objectContaining({
userId: 'u1',
weeklyXp: 200,
rank: 1,
}));
expect(result.pagination.total).toBe(2);
});
it('返回用户在指定地区内的排名', async () => {
setupSelectQueue([
[{ xpEarned: 120 }],
[{ count: 4 }],
]);
const result = await getUserRegionRank('user-1', '310000');
expect(result).toEqual({
rank: 5,
tier: expect.any(String),
weeklyXp: 120,
});
});
});
// ── 周结算 ────────────────────────────────────────────────────── // ── 周结算 ──────────────────────────────────────────────────────
describe('weeklySettlement', () => { describe('weeklySettlement', () => {

View File

@ -295,6 +295,7 @@ describe('challenge-service', () => {
}); });
it('awards XP for a correct answer', async () => { it('awards XP for a correct answer', async () => {
const userAfterAttempt = { ...freeUserRow, dailyAttemptsLeft: 4 };
mockSelectQueue([ mockSelectQueue([
[makeSession()], // session [makeSession()], // session
[], // no existing answer [], // no existing answer
@ -302,13 +303,14 @@ describe('challenge-service', () => {
[], // no previous correct answer for first knowledge card [], // no previous correct answer for first knowledge card
[], // addXp(correct): no existing weekly XP [], // addXp(correct): no existing weekly XP
[], // addXp(correct): no existing leaderboard group [], // addXp(correct): no existing leaderboard group
[freeUserRow], // deductDailyAttempt → getResourceUser
[knowledgeCardRow], // getKnowledgeCard [knowledgeCardRow], // getKnowledgeCard
[{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group [{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group
[freeUserRow], // getResourceUser (getProgressSummary) [userAfterAttempt], // getResourceUser (getProgressSummary)
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
[], // getSubscriptionStatus [], // getSubscriptionStatus
[freeUserRow], // getDailyAttempts [userAfterAttempt], // getDailyAttempts
[{ used: 0, restored: 0 }], // getHighRewardQuota [{ used: 0, restored: 0 }], // getHighRewardQuota
]); ]);
vi.mocked(db.insert).mockReturnValue(mockInsert()); vi.mocked(db.insert).mockReturnValue(mockInsert());
@ -325,6 +327,7 @@ describe('challenge-service', () => {
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }), expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
]), ]),
); );
expect(result.progress.dailyAttemptsLeft).toBe(4);
expect(result.knowledgeCard.id).toBe('card-1'); expect(result.knowledgeCard.id).toBe('card-1');
}); });
@ -398,18 +401,22 @@ describe('challenge-service', () => {
}); });
it('triggers completion settlement on the last question', async () => { it('triggers completion settlement on the last question', async () => {
const userAfterXp = { ...freeUserRow, xpTotal: 150 }; const userAfterAttempt = { ...freeUserRow, dailyAttemptsLeft: 4 };
const userAfterXp = { ...userAfterAttempt, xpTotal: 150 };
mockSelectQueue([ mockSelectQueue([
[makeSession({ answeredCount: 4, correctCount: 4 })], // session [makeSession({ answeredCount: 4, correctCount: 4 })], // session
[], // no existing answer [], // no existing answer
[testQuestion], // question (but we submit q-5) [testQuestion], // question (but we submit q-5)
[], // no previous correct answer for first knowledge card [], // no previous correct answer for first knowledge card
[], // addXp(correct): no existing weekly XP
[], // addXp(correct): no existing leaderboard group
[freeUserRow], // deductDailyAttempt → getResourceUser
// settleCompletedChallenge → getProgressSummary (before) // settleCompletedChallenge → getProgressSummary (before)
[freeUserRow], // getResourceUser [userAfterAttempt], // getResourceUser
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak [{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
[], // getSubscriptionStatus [], // getSubscriptionStatus
[freeUserRow], // getDailyAttempts [userAfterAttempt], // getDailyAttempts
[{ used: 0, restored: 0 }], // getHighRewardQuota [{ used: 0, restored: 0 }], // getHighRewardQuota
[], // no existing daily progress [], // no existing daily progress
// updateChapterProgress // updateChapterProgress
@ -421,6 +428,7 @@ describe('challenge-service', () => {
[{ coinsBalance: 40 }], // current wallet balance [{ coinsBalance: 40 }], // current wallet balance
[{ id: 'daily-1' }], // daily progress row for coin aggregation [{ id: 'daily-1' }], // daily progress row for coin aggregation
[knowledgeCardRow], [knowledgeCardRow],
[{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group
// getProgressSummary (final) // getProgressSummary (final)
[userAfterXp], [userAfterXp],
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],

View File

@ -0,0 +1,27 @@
import Fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { errorHandler } from '../../utils/errors.js';
describe('errorHandler', () => {
it('keeps Fastify request parsing errors as 4xx responses', async () => {
const app = Fastify();
app.setErrorHandler(errorHandler);
app.post('/empty-json', async () => ({ ok: true }));
const response = await app.inject({
method: 'POST',
url: '/empty-json',
headers: { 'content-type': 'application/json' },
payload: '',
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
data: null,
error: {
code: 'FST_ERR_CTP_EMPTY_JSON_BODY',
},
});
});
});

View File

@ -0,0 +1,36 @@
import Fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { registerJsonBodyParser } from '../../utils/json-parser.js';
describe('registerJsonBodyParser', () => {
it('treats an empty application/json body as an empty object', async () => {
const app = Fastify();
registerJsonBodyParser(app);
app.post('/empty-json', async (request) => ({ body: request.body }));
const response = await app.inject({
method: 'POST',
url: '/empty-json',
headers: { 'content-type': 'application/json' },
payload: '',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ body: {} });
});
it('still rejects malformed JSON', async () => {
const app = Fastify();
registerJsonBodyParser(app);
app.post('/bad-json', async () => ({ ok: true }));
const response = await app.inject({
method: 'POST',
url: '/bad-json',
headers: { 'content-type': 'application/json' },
payload: '{',
});
expect(response.statusCode).toBe(400);
});
});

46
src/config/regions.ts Normal file
View File

@ -0,0 +1,46 @@
export interface RegionConfig {
code: string;
name: string;
shortName: string;
parentCode: string | null;
level: number;
sortOrder: number;
enabled: boolean;
}
export const REGION_CONFIG_VERSION = '2026-06-08.1';
// MVP 阶段只下发中国大陆一级行政区;结构保留 parentCode/level 以兼容后续二级地区扩展。
export const REGIONS: readonly RegionConfig[] = Object.freeze([
{ code: '110000', name: '北京市', shortName: '北京', parentCode: null, level: 1, sortOrder: 10, enabled: true },
{ code: '120000', name: '天津市', shortName: '天津', parentCode: null, level: 1, sortOrder: 20, enabled: true },
{ code: '310000', name: '上海市', shortName: '上海', parentCode: null, level: 1, sortOrder: 30, enabled: true },
{ code: '500000', name: '重庆市', shortName: '重庆', parentCode: null, level: 1, sortOrder: 40, enabled: true },
{ code: '130000', name: '河北省', shortName: '河北', parentCode: null, level: 1, sortOrder: 50, enabled: true },
{ code: '140000', name: '山西省', shortName: '山西', parentCode: null, level: 1, sortOrder: 60, enabled: true },
{ code: '150000', name: '内蒙古自治区', shortName: '内蒙古', parentCode: null, level: 1, sortOrder: 70, enabled: true },
{ code: '210000', name: '辽宁省', shortName: '辽宁', parentCode: null, level: 1, sortOrder: 80, enabled: true },
{ code: '220000', name: '吉林省', shortName: '吉林', parentCode: null, level: 1, sortOrder: 90, enabled: true },
{ code: '230000', name: '黑龙江省', shortName: '黑龙江', parentCode: null, level: 1, sortOrder: 100, enabled: true },
{ code: '320000', name: '江苏省', shortName: '江苏', parentCode: null, level: 1, sortOrder: 110, enabled: true },
{ code: '330000', name: '浙江省', shortName: '浙江', parentCode: null, level: 1, sortOrder: 120, enabled: true },
{ code: '340000', name: '安徽省', shortName: '安徽', parentCode: null, level: 1, sortOrder: 130, enabled: true },
{ code: '350000', name: '福建省', shortName: '福建', parentCode: null, level: 1, sortOrder: 140, enabled: true },
{ code: '360000', name: '江西省', shortName: '江西', parentCode: null, level: 1, sortOrder: 150, enabled: true },
{ code: '370000', name: '山东省', shortName: '山东', parentCode: null, level: 1, sortOrder: 160, enabled: true },
{ code: '410000', name: '河南省', shortName: '河南', parentCode: null, level: 1, sortOrder: 170, enabled: true },
{ code: '420000', name: '湖北省', shortName: '湖北', parentCode: null, level: 1, sortOrder: 180, enabled: true },
{ code: '430000', name: '湖南省', shortName: '湖南', parentCode: null, level: 1, sortOrder: 190, enabled: true },
{ code: '440000', name: '广东省', shortName: '广东', parentCode: null, level: 1, sortOrder: 200, enabled: true },
{ code: '450000', name: '广西壮族自治区', shortName: '广西', parentCode: null, level: 1, sortOrder: 210, enabled: true },
{ code: '460000', name: '海南省', shortName: '海南', parentCode: null, level: 1, sortOrder: 220, enabled: true },
{ code: '510000', name: '四川省', shortName: '四川', parentCode: null, level: 1, sortOrder: 230, enabled: true },
{ code: '520000', name: '贵州省', shortName: '贵州', parentCode: null, level: 1, sortOrder: 240, enabled: true },
{ code: '530000', name: '云南省', shortName: '云南', parentCode: null, level: 1, sortOrder: 250, enabled: true },
{ code: '540000', name: '西藏自治区', shortName: '西藏', parentCode: null, level: 1, sortOrder: 260, enabled: true },
{ code: '610000', name: '陕西省', shortName: '陕西', parentCode: null, level: 1, sortOrder: 270, enabled: true },
{ code: '620000', name: '甘肃省', shortName: '甘肃', parentCode: null, level: 1, sortOrder: 280, enabled: true },
{ code: '630000', name: '青海省', shortName: '青海', parentCode: null, level: 1, sortOrder: 290, enabled: true },
{ code: '640000', name: '宁夏回族自治区', shortName: '宁夏', parentCode: null, level: 1, sortOrder: 300, enabled: true },
{ code: '650000', name: '新疆维吾尔自治区', shortName: '新疆', parentCode: null, level: 1, sortOrder: 310, enabled: true },
]);

View File

@ -37,6 +37,9 @@ export const users = mysqlTable('users', {
dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。 dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。 currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。
activeTrackId: varchar('active_track_id', { length: 50 }), // 当前学习路径。 activeTrackId: varchar('active_track_id', { length: 50 }), // 当前学习路径。
regionCode: varchar('region_code', { length: 20 }), // 用户选择的地区编码,用于地区榜归属。
regionSelectedAt: datetime('region_selected_at'), // 最近一次选择地区的时间。
regionChangedAt: datetime('region_changed_at'), // 最近一次地区变更时间,用于每自然月一次的限制。
dailyAttemptsLeft: smallint('daily_attempts_left').default(5), // 当日剩余挑战次数。 dailyAttemptsLeft: smallint('daily_attempts_left').default(5), // 当日剩余挑战次数。
dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。 dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。
checkInDays: int('check_in_days').default(0), // 累计签到天数。 checkInDays: int('check_in_days').default(0), // 累计签到天数。
@ -46,6 +49,7 @@ export const users = mysqlTable('users', {
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。 updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), // 更新时间。
}, (table) => [ }, (table) => [
uniqueIndex('uk_auth').on(table.authType, table.authId), uniqueIndex('uk_auth').on(table.authType, table.authId),
index('idx_users_region').on(table.regionCode),
]); ]);
// ── Categories ───────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────
@ -432,6 +436,21 @@ export const userWeeklyXp = mysqlTable('user_weekly_xp', {
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
]); ]);
// ── User Region History ────────────────────────────────────────────
// 用户地区变更记录,用于审计每自然月一次的限制判断。
export const userRegionChangeLogs = mysqlTable('user_region_change_logs', {
id: char('id', { length: 36 }).primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
fromRegionCode: varchar('from_region_code', { length: 20 }),
toRegionCode: varchar('to_region_code', { length: 20 }).notNull(),
changedAt: datetime('changed_at').default(sql`CURRENT_TIMESTAMP`),
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
}, (table) => [
index('idx_region_change_user_changed').on(table.userId, table.changedAt),
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
]);
// ── Subscriptions ────────────────────────────────────────────────── // ── Subscriptions ──────────────────────────────────────────────────
// 用户订阅权益与平台购买数据。 // 用户订阅权益与平台购买数据。

View File

@ -6,6 +6,7 @@ import jwt from '@fastify/jwt';
import { config } from './utils/config.js'; import { config } from './utils/config.js';
import { errorHandler } from './utils/errors.js'; import { errorHandler } from './utils/errors.js';
import { registerJsonBodyParser } from './utils/json-parser.js';
import authMiddleware from './middleware/auth.js'; import authMiddleware from './middleware/auth.js';
import adminAuthMiddleware from './middleware/admin-auth.js'; import adminAuthMiddleware from './middleware/admin-auth.js';
import requestLogger from './middleware/request-logger.js'; import requestLogger from './middleware/request-logger.js';
@ -31,6 +32,8 @@ async function main(): Promise<void> {
}, },
}); });
registerJsonBodyParser(app);
// ── Plugins ────────────────────────────────────────────────────── // ── Plugins ──────────────────────────────────────────────────────
await app.register(helmet); await app.register(helmet);

View File

@ -19,6 +19,7 @@ async function authMiddleware(app: FastifyInstance): Promise<void> {
'/v1/auth/phone', '/v1/auth/phone',
'/v1/auth/refresh', '/v1/auth/refresh',
'/v1/auth/providers', '/v1/auth/providers',
'/v1/app/regions',
]; ];
if (publicPaths.some((p) => request.url.startsWith(p))) { if (publicPaths.some((p) => request.url.startsWith(p))) {

View File

@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { getBootstrap } from '../services/app/bootstrap-service.js'; import { getBootstrap } from '../services/app/bootstrap-service.js';
import { getRegionsConfig, updateUserRegion } from '../services/app/regions-service.js';
import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js'; import { getThemeTrackById, getThemeTracks } from '../services/learning/tracks-service.js';
import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js'; import { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js';
import { import {
@ -33,9 +34,14 @@ const preferencesSchema = z.object({
activeTrackId: z.string().min(1).max(50), activeTrackId: z.string().min(1).max(50),
}); });
const userRegionSchema = z.object({
regionCode: z.string().regex(/^\d{6}$/),
});
const leaderboardQuerySchema = z.object({ const leaderboardQuerySchema = z.object({
scope: z.enum(['region', 'topic']).default('region'), scope: z.enum(['region', 'topic']).default('region'),
trackId: z.string().optional(), trackId: z.string().optional(),
regionCode: z.string().regex(/^\d{6}$/).optional(),
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
}); });
@ -72,6 +78,11 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
return { success: true, data, error: null }; return { success: true, data, error: null };
}); });
app.get('/app/regions', async () => {
const data = getRegionsConfig();
return { success: true, data, error: null };
});
app.get('/tracks', async (request) => { app.get('/tracks', async (request) => {
const data = await getThemeTracks(getUserId(request)); const data = await getThemeTracks(getUserId(request));
return { success: true, data, error: null }; return { success: true, data, error: null };
@ -117,6 +128,13 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
return { success: true, data, error: null }; return { success: true, data, error: null };
}); });
app.patch('/users/me/region', async (request) => {
const parsed = userRegionSchema.safeParse(request.body);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const region = await updateUserRegion(getUserId(request), parsed.data.regionCode);
return { success: true, data: { region }, error: null };
});
app.post('/progress/check-in', async (request) => { app.post('/progress/check-in', async (request) => {
const data = await checkIn(getUserId(request)); const data = await checkIn(getUserId(request));
return { success: true, data, error: null }; return { success: true, data, error: null };
@ -156,6 +174,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
getUserId(request), getUserId(request),
parsed.data.scope, parsed.data.scope,
parsed.data.trackId, parsed.data.trackId,
parsed.data.regionCode,
parsed.data.page, parsed.data.page,
parsed.data.limit, parsed.data.limit,
); );
@ -165,7 +184,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
app.get('/leaderboards/me', async (request) => { app.get('/leaderboards/me', async (request) => {
const parsed = leaderboardQuerySchema.safeParse(request.query); const parsed = leaderboardQuerySchema.safeParse(request.query);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message); if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId); const data = await getClientLeaderboardMe(getUserId(request), parsed.data.scope, parsed.data.trackId, parsed.data.regionCode);
return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null }; return { success: true, data: data?.entry ?? null, meta: data?.meta ?? null, error: null };
}); });

View File

@ -8,6 +8,7 @@ import { sendPhoneCode, loginWithPhone } from '../services/auth/phone.js';
import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js';
import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { linkGuestAccount } from '../services/auth/account-link-service.js';
import { NotFoundError } from '../utils/errors.js'; import { NotFoundError } from '../utils/errors.js';
import { toUserRegionDto } from '../services/app/regions-service.js';
import { config } from '../utils/config.js'; import { config } from '../utils/config.js';
const guestLoginSchema = z.object({ const guestLoginSchema = z.object({
@ -266,6 +267,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
heartsRemaining: user.heartsRemaining ?? 5, heartsRemaining: user.heartsRemaining ?? 5,
dailyXpEarned: user.dailyXpEarned ?? 0, dailyXpEarned: user.dailyXpEarned ?? 0,
dailyXpGoal: user.dailyXpGoal ?? 50, dailyXpGoal: user.dailyXpGoal ?? 50,
region: toUserRegionDto(user.regionCode, user.regionSelectedAt, user.regionChangedAt),
}, },
error: null, error: null,
}); });

View File

@ -7,6 +7,7 @@ import { getShopCatalog } from '../shop/shop-service.js';
import { getClientSubscription } from '../subscription/subscription-api-service.js'; import { getClientSubscription } from '../subscription/subscription-api-service.js';
import { getCoinBalance } from '../gamification/coin-service.js'; import { getCoinBalance } from '../gamification/coin-service.js';
import { getClientInventory } from '../gamification/inventory-service.js'; import { getClientInventory } from '../gamification/inventory-service.js';
import { toUserRegionDto } from './regions-service.js';
import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js'; import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
export async function getBootstrap(userId: string): Promise<BootstrapDto> { export async function getBootstrap(userId: string): Promise<BootstrapDto> {
@ -17,6 +18,9 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
tier: users.tier, tier: users.tier,
xpTotal: users.xpTotal, xpTotal: users.xpTotal,
regionCode: users.regionCode,
regionSelectedAt: users.regionSelectedAt,
regionChangedAt: users.regionChangedAt,
}) })
.from(users) .from(users)
.where(eq(users.id, userId)) .where(eq(users.id, userId))
@ -41,6 +45,7 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
avatarUrl: user?.avatarUrl ?? null, avatarUrl: user?.avatarUrl ?? null,
tier: (user?.tier ?? 'free') as SubscriptionTier, tier: (user?.tier ?? 'free') as SubscriptionTier,
level, level,
region: toUserRegionDto(user?.regionCode, user?.regionSelectedAt, user?.regionChangedAt),
}, },
progress, progress,
tracks, tracks,

View File

@ -0,0 +1,159 @@
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { db } from '../../db/client.js';
import { userRegionChangeLogs, users } from '../../db/schema.js';
import { REGION_CONFIG_VERSION, REGIONS, type RegionConfig } from '../../config/regions.js';
import { AppError, ConflictError } from '../../utils/errors.js';
import type { RegionDto, RegionsConfigDto, UserRegionDto } from '../../types/app-api.js';
const REGIONS_UPDATED_AT = '2026-06-08T00:00:00.000Z';
function toRegionDto(region: RegionConfig): RegionDto {
return {
code: region.code,
name: region.name,
shortName: region.shortName,
parentCode: region.parentCode,
level: region.level,
sortOrder: region.sortOrder,
enabled: region.enabled,
};
}
export function getRegionsConfig(): RegionsConfigDto {
return {
version: REGION_CONFIG_VERSION,
countryCode: 'CN',
hierarchy: 'flat',
updatedAt: REGIONS_UPDATED_AT,
regions: REGIONS.map(toRegionDto),
};
}
export function findRegionByCode(regionCode: string | null | undefined): RegionDto | null {
if (!regionCode) return null;
const region = REGIONS.find((item) => item.code === regionCode && item.enabled);
return region ? toRegionDto(region) : null;
}
function getShanghaiMonthRange(now = new Date()): { start: Date; end: Date } {
const shanghaiNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
const year = shanghaiNow.getUTCFullYear();
const month = shanghaiNow.getUTCMonth();
// 自然月限制按中国用户预期的北京时间计算,存库仍使用 UTC 时间。
const start = new Date(Date.UTC(year, month, 1) - 8 * 60 * 60 * 1000);
const end = new Date(Date.UTC(year, month + 1, 1) - 8 * 60 * 60 * 1000);
return { start, end };
}
export function getNextRegionChangeAllowedAt(changedAt: Date | string | null | undefined): string | null {
if (!changedAt) return null;
const date = new Date(changedAt);
const shanghaiDate = new Date(date.getTime() + 8 * 60 * 60 * 1000);
const year = shanghaiDate.getUTCFullYear();
const month = shanghaiDate.getUTCMonth();
const nextMonthStartUtc = new Date(Date.UTC(year, month + 1, 1) - 8 * 60 * 60 * 1000);
return nextMonthStartUtc.toISOString();
}
export function toUserRegionDto(
regionCode: string | null | undefined,
selectedAt: Date | string | null | undefined,
changedAt: Date | string | null | undefined,
): UserRegionDto | null {
const region = findRegionByCode(regionCode);
if (!region) return null;
return {
code: region.code,
name: region.name,
shortName: region.shortName,
selectedAt: selectedAt ? new Date(selectedAt).toISOString() : null,
nextChangeAllowedAt: getNextRegionChangeAllowedAt(changedAt),
};
}
export async function getUserSelectedRegion(userId: string): Promise<UserRegionDto | null> {
const [user] = await db
.select({
regionCode: users.regionCode,
regionSelectedAt: users.regionSelectedAt,
regionChangedAt: users.regionChangedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return toUserRegionDto(user?.regionCode, user?.regionSelectedAt, user?.regionChangedAt);
}
export async function updateUserRegion(userId: string, regionCode: string): Promise<UserRegionDto> {
const targetRegion = findRegionByCode(regionCode);
if (!targetRegion) {
throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION');
}
const [user] = await db
.select({
regionCode: users.regionCode,
regionSelectedAt: users.regionSelectedAt,
regionChangedAt: users.regionChangedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
throw new AppError('User not found', 404, 'NOT_FOUND');
}
if (user.regionCode === regionCode) {
return toUserRegionDto(user.regionCode, user.regionSelectedAt, user.regionChangedAt)!;
}
const now = new Date();
const { start, end } = getShanghaiMonthRange(now);
// 首次选择不受限制;已有地区后,每个北京时间自然月只允许一次变更。
if (user.regionCode) {
const [changeLog] = await db
.select({ id: userRegionChangeLogs.id })
.from(userRegionChangeLogs)
.where(and(
eq(userRegionChangeLogs.userId, userId),
gte(userRegionChangeLogs.changedAt, start),
lt(userRegionChangeLogs.changedAt, end),
))
.limit(1);
if (changeLog) {
throw new ConflictError('每个自然月只能修改一次地区,请下个月再试。', 'REGION_CHANGE_LIMIT_REACHED');
}
}
await db
.update(users)
.set({
regionCode,
regionSelectedAt: sql`NOW()`,
regionChangedAt: sql`NOW()`,
})
.where(eq(users.id, userId));
await db
.insert(userRegionChangeLogs)
.values({
id: uuid(),
userId,
fromRegionCode: user.regionCode ?? null,
toRegionCode: regionCode,
changedAt: sql`NOW()`,
});
return {
code: targetRegion.code,
name: targetRegion.name,
shortName: targetRegion.shortName,
selectedAt: now.toISOString(),
nextChangeAllowedAt: getNextRegionChangeAllowedAt(now),
};
}

View File

@ -109,6 +109,44 @@ export async function getLeaderboard(userId: string, _tier?: string, page = 1, l
return { items, pagination: { total, page, limit } }; return { items, pagination: { total, page, limit } };
} }
/**
*
* 使 regionCode
*/
export async function getRegionLeaderboard(regionCode: string, page = 1, limit = 20): Promise<{
items: LeaderboardEntry[];
pagination: { total: number; page: number; limit: number };
}> {
const { weekStart } = getCurrentWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const offset = (page - 1) * limit;
const allEntries = await db
.select({
userId: userWeeklyXp.userId,
weeklyXp: userWeeklyXp.xpEarned,
nickname: users.nickname,
avatarUrl: users.avatarUrl,
})
.from(userWeeklyXp)
.innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode}`)
.orderBy(desc(userWeeklyXp.xpEarned))
.limit(1000);
const total = allEntries.length;
const items: LeaderboardEntry[] = allEntries.slice(offset, offset + limit).map((entry, i) => ({
userId: entry.userId,
nickname: entry.nickname ?? null,
avatarUrl: entry.avatarUrl ?? null,
weeklyXp: entry.weeklyXp ?? 0,
rank: offset + i + 1,
tier: getTierForRank(offset + i + 1),
}));
return { items, pagination: { total, page, limit } };
}
/** /**
* *
* XP * XP
@ -141,6 +179,31 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier:
return { rank, tier: getTierForRank(rank), weeklyXp: userXp }; return { rank, tier: getTierForRank(rank), weeklyXp: userXp };
} }
/** 获取用户在指定地区当前周排行榜中的排名。 */
export async function getUserRegionRank(userId: string, regionCode: string): Promise<{ rank: number; tier: string; weeklyXp: number } | null> {
const { weekStart } = getCurrentWeekRange();
const weekStartStr = weekStart.toISOString().slice(0, 10);
const [userRow] = await db
.select({ xpEarned: userWeeklyXp.xpEarned })
.from(userWeeklyXp)
.innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(sql`${userWeeklyXp.userId} = ${userId} AND CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode}`)
.limit(1);
if (!userRow) return null;
const userXp = userRow.xpEarned ?? 0;
const [higher] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(userWeeklyXp)
.innerJoin(users, eq(userWeeklyXp.userId, users.id))
.where(sql`CAST(${userWeeklyXp.weekStart} AS CHAR) = ${weekStartStr} AND ${users.regionCode} = ${regionCode} AND COALESCE(${userWeeklyXp.xpEarned}, 0) > ${userXp}`);
const rank = Number(higher?.count ?? 0) + 1;
return { rank, tier: getTierForRank(rank), weeklyXp: userXp };
}
/** 前三名奖励金币配置:第 1 名 300第 2 名 150第 3 名 50。 */ /** 前三名奖励金币配置:第 1 名 300第 2 名 150第 3 名 50。 */
const TOP_REWARD_COINS: ReadonlyMap<number, number> = new Map([ const TOP_REWARD_COINS: ReadonlyMap<number, number> = new Map([
[1, 300], [1, 300],

View File

@ -444,8 +444,9 @@ export async function submitChallengeAnswer(
if (!heartResult.success && heartResult.remaining === 0) { if (!heartResult.success && heartResult.remaining === 0) {
throw new ValidationError('红心已用完,请等待恢复或观看广告'); throw new ValidationError('红心已用完,请等待恢复或观看广告');
} }
await deductDailyAttempt(userId);
} }
// 每题成功裁决后消耗 1 次今日答题次数;幂等重复提交会在前面直接返回快照,不会重复扣减。
await deductDailyAttempt(userId);
const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession; const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession;
const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions); const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions);

View File

@ -1,8 +1,10 @@
import { getLeaderboard, getLeaderboardMeta, getUserRank } from '../gamification/leaderboard-service.js'; import { getLeaderboard, getLeaderboardMeta, getRegionLeaderboard, getUserRank, getUserRegionRank } from '../gamification/leaderboard-service.js';
import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js'; import type { LeaderboardEntryDto, LeaderboardMetaDto, LeaderboardScope } from '../../types/app-api.js';
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { users } from '../../db/schema.js'; import { users } from '../../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { AppError } from '../../utils/errors.js';
import { findRegionByCode, getUserSelectedRegion } from '../app/regions-service.js';
function getBadge(rank: number): string { function getBadge(rank: number): string {
if (rank === 1) return '榜首'; if (rank === 1) return '榜首';
@ -13,8 +15,9 @@ function getBadge(rank: number): string {
export async function getClientLeaderboard( export async function getClientLeaderboard(
userId: string, userId: string,
_scope: LeaderboardScope, scope: LeaderboardScope,
_trackId: string | undefined, _trackId: string | undefined,
regionCode: string | undefined,
page: number, page: number,
limit: number, limit: number,
): Promise<{ ): Promise<{
@ -22,6 +25,51 @@ export async function getClientLeaderboard(
meta: LeaderboardMetaDto; meta: LeaderboardMetaDto;
pagination: { total: number; page: number; limit: number }; pagination: { total: number; page: number; limit: number };
}> { }> {
if (scope === 'region') {
const [selectedRegion, baseMeta] = await Promise.all([
getUserSelectedRegion(userId),
getLeaderboardMeta(userId),
]);
const viewRegion = findRegionByCode(regionCode ?? selectedRegion?.code);
if (regionCode && !viewRegion) {
throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION');
}
if (!viewRegion) {
return {
items: [],
meta: {
...baseMeta,
requiresRegionSelection: true,
selectedRegion,
viewRegion: null,
},
pagination: { total: 0, page, limit },
};
}
const data = await getRegionLeaderboard(viewRegion.code, page, limit);
return {
items: data.items.map((entry) => ({
rank: entry.rank,
userId: entry.userId,
displayName: entry.userId === userId ? '你' : (entry.nickname ?? '知识探险家'),
avatarUrl: entry.avatarUrl,
xp: entry.weeklyXp,
badge: getBadge(entry.rank),
isMe: entry.userId === userId,
})),
meta: {
...baseMeta,
requiresRegionSelection: false,
selectedRegion,
viewRegion,
},
pagination: data.pagination,
};
}
const [data, meta] = await Promise.all([ const [data, meta] = await Promise.all([
getLeaderboard(userId, undefined, page, limit), getLeaderboard(userId, undefined, page, limit),
getLeaderboardMeta(userId), getLeaderboardMeta(userId),
@ -44,9 +92,75 @@ export async function getClientLeaderboard(
export async function getClientLeaderboardMe( export async function getClientLeaderboardMe(
userId: string, userId: string,
_scope: LeaderboardScope, scope: LeaderboardScope,
_trackId: string | undefined, _trackId: string | undefined,
): Promise<{ entry: LeaderboardEntryDto; meta: LeaderboardMetaDto } | null> { regionCode: string | undefined,
): Promise<{ entry: LeaderboardEntryDto | null; meta: LeaderboardMetaDto } | null> {
if (scope === 'region') {
const [selectedRegion, baseMeta] = await Promise.all([
getUserSelectedRegion(userId),
getLeaderboardMeta(userId),
]);
const viewRegion = findRegionByCode(regionCode ?? selectedRegion?.code);
if (regionCode && !viewRegion) {
throw new AppError('请选择有效的地区。', 400, 'INVALID_REGION');
}
if (!viewRegion) {
return {
entry: null,
meta: {
...baseMeta,
requiresRegionSelection: true,
selectedRegion,
viewRegion: null,
},
};
}
const rank = await getUserRegionRank(userId, viewRegion.code);
if (!rank) {
return {
entry: null,
meta: {
...baseMeta,
requiresRegionSelection: false,
selectedRegion,
viewRegion,
},
};
}
const [user] = await db
.select({
nickname: users.nickname,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
return {
entry: {
rank: rank.rank,
userId,
displayName: user?.nickname ?? '你',
avatarUrl: user?.avatarUrl ?? null,
xp: rank.weeklyXp,
badge: getBadge(rank.rank),
isMe: true,
},
meta: {
...baseMeta,
rank: rank.rank,
requiresRegionSelection: false,
selectedRegion,
viewRegion,
},
};
}
const [rank, meta] = await Promise.all([ const [rank, meta] = await Promise.all([
getUserRank(userId), getUserRank(userId),
getLeaderboardMeta(userId), getLeaderboardMeta(userId),

View File

@ -5,12 +5,39 @@ export type LeaderboardScope = 'region' | 'topic';
export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug'; export type RewardSource = 'ad' | 'check_in' | 'subscription' | 'admin_grant' | 'debug';
export type SubscriptionPlatform = 'huawei' | 'apple' | 'google'; 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 { export interface UserBriefDto {
id: string; id: string;
nickname: string; nickname: string;
avatarUrl: string | null; avatarUrl: string | null;
tier: SubscriptionTier; tier: SubscriptionTier;
level: number; level: number;
region: UserRegionDto | null;
} }
export interface SubscriptionDto { export interface SubscriptionDto {
@ -220,6 +247,9 @@ export interface LeaderboardMetaDto {
weekEnd: string; weekEnd: string;
nextRefreshAt: string; nextRefreshAt: string;
groupId: string | null; groupId: string | null;
requiresRegionSelection?: boolean;
selectedRegion?: UserRegionDto | null;
viewRegion?: RegionDto | null;
/** 当前用户组内排名(仅 /leaderboards/me 返回)。 */ /** 当前用户组内排名(仅 /leaderboards/me 返回)。 */
rank?: number; rank?: number;
/** 当前周奖励预览:各组前 3 名的金币奖励。 */ /** 当前周奖励预览:各组前 3 名的金币奖励。 */

View File

@ -77,6 +77,19 @@ export function errorHandler(
return; return;
} }
const httpError = error as Error & { statusCode?: number; code?: string };
if (typeof httpError.statusCode === 'number' && httpError.statusCode >= 400 && httpError.statusCode < 500) {
reply.status(httpError.statusCode).send({
success: false,
data: null,
error: {
code: httpError.code ?? 'BAD_REQUEST',
message: httpError.message,
},
});
return;
}
// Unexpected errors — log full details for debugging // Unexpected errors — log full details for debugging
_request.log.error({ err: error }, 'Unhandled error'); _request.log.error({ err: error }, 'Unhandled error');

20
src/utils/json-parser.ts Normal file
View File

@ -0,0 +1,20 @@
import type { FastifyInstance } from 'fastify';
/**
* POST `Content-Type: application/json`
* bodyFastify 5 JSON
* body POST JSON `{}`
*/
export function registerJsonBodyParser(app: FastifyInstance): void {
const defaultJsonParser = app.getDefaultJsonParser('error', 'ignore');
app.removeContentTypeParser('application/json');
app.addContentTypeParser<string>('application/json', { parseAs: 'string' }, (request, body, done) => {
if (body.length === 0) {
done(null, {});
return;
}
defaultJsonParser(request, body, done);
});
}