Add region-based leaderboard support
This commit is contained in:
parent
0317c34099
commit
5e7b7b1cda
15
db/migrations/0004_user_region.sql
Normal file
15
db/migrations/0004_user_region.sql
Normal 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`);
|
||||
3361
db/migrations/meta/0004_snapshot.json
Normal file
3361
db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,13 @@
|
||||
"when": 1778494900458,
|
||||
"tag": "0003_lyrical_carnage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1780903327517,
|
||||
"tag": "0004_user_region",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -404,7 +404,14 @@
|
||||
"nickname": "知识探险家",
|
||||
"avatarUrl": null,
|
||||
"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": {
|
||||
"hearts": 5,
|
||||
@ -467,6 +474,83 @@
|
||||
|
||||
说明:`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
|
||||
|
||||
认证:JWT
|
||||
@ -581,8 +665,8 @@
|
||||
"xpDelta": 10,
|
||||
"progress": {
|
||||
"hearts": 5,
|
||||
"dailyAttemptsLeft": 5,
|
||||
"highRewardSessionsLeft": 2,
|
||||
"dailyAttemptsLeft": 4,
|
||||
"highRewardSessionsLeft": 3,
|
||||
"highRewardSessionsMax": 3,
|
||||
"xp": 10,
|
||||
"streakDays": 0
|
||||
@ -603,6 +687,11 @@
|
||||
|
||||
`answerState` 取值:`correct`, `wrong`。
|
||||
|
||||
资源扣减规则:
|
||||
|
||||
- 每次单题提交成功裁决后,`dailyAttemptsLeft` 扣 1;重复提交同一题或同一 `submitRequestId` 返回第一次裁决快照,不重复扣减。
|
||||
- `highRewardSessionsLeft` 按 5 题挑战组消耗;只有本组最后一题触发挑战完成结算后,才会从 3/3 变为 2/3。
|
||||
|
||||
#### GET /progress/summary
|
||||
|
||||
认证:JWT
|
||||
@ -626,9 +715,43 @@
|
||||
#### POST /progress/check-in
|
||||
|
||||
认证: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
|
||||
|
||||
@ -638,8 +761,9 @@
|
||||
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `scope` | `region` 或 `topic` | `region` | 排行榜范围 |
|
||||
| `trackId` | string | - | `scope=topic` 时可传 |
|
||||
| `scope` | `region` 或 `topic` | `region` | `region` 返回地区榜;`topic` 暂保留原本周 XP 分组榜 |
|
||||
| `regionCode` | string | 用户已选择地区 | 查看指定地区榜;不传则展示用户已选择地区 |
|
||||
| `trackId` | string | - | 当前版本预留,不参与筛选 |
|
||||
| `page` | number | 1 | 页码 |
|
||||
| `limit` | number | 20 | 1-100 |
|
||||
|
||||
@ -664,6 +788,23 @@
|
||||
"weekEnd": "2026-05-17",
|
||||
"nextRefreshAt": "2026-05-18",
|
||||
"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": [
|
||||
{ "rank": 1, "coins": 300 },
|
||||
{ "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
|
||||
|
||||
|
||||
@ -78,6 +78,7 @@ describe('bootstrap-service', () => {
|
||||
avatarUrl: null,
|
||||
tier: 'free',
|
||||
level: 2,
|
||||
region: null,
|
||||
});
|
||||
expect(result.wallet).toEqual({ coinsBalance: 260 });
|
||||
expect(result.inventory.items).toEqual([
|
||||
|
||||
74
src/__tests__/services/app/regions-service.test.ts
Normal file
74
src/__tests__/services/app/regions-service.test.ts
Normal 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: '每个自然月只能修改一次地区,请下个月再试。',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 { 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', () => {
|
||||
|
||||
@ -295,6 +295,7 @@ describe('challenge-service', () => {
|
||||
});
|
||||
|
||||
it('awards XP for a correct answer', async () => {
|
||||
const userAfterAttempt = { ...freeUserRow, dailyAttemptsLeft: 4 };
|
||||
mockSelectQueue([
|
||||
[makeSession()], // session
|
||||
[], // no existing answer
|
||||
@ -302,13 +303,14 @@ describe('challenge-service', () => {
|
||||
[], // no previous correct answer for first knowledge card
|
||||
[], // addXp(correct): no existing weekly XP
|
||||
[], // addXp(correct): no existing leaderboard group
|
||||
[freeUserRow], // deductDailyAttempt → getResourceUser
|
||||
[knowledgeCardRow], // getKnowledgeCard
|
||||
[{ 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
|
||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[freeUserRow], // getDailyAttempts
|
||||
[userAfterAttempt], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
]);
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert());
|
||||
@ -325,6 +327,7 @@ describe('challenge-service', () => {
|
||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||
]),
|
||||
);
|
||||
expect(result.progress.dailyAttemptsLeft).toBe(4);
|
||||
expect(result.knowledgeCard.id).toBe('card-1');
|
||||
});
|
||||
|
||||
@ -398,18 +401,22 @@ describe('challenge-service', () => {
|
||||
});
|
||||
|
||||
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([
|
||||
[makeSession({ answeredCount: 4, correctCount: 4 })], // session
|
||||
[], // no existing answer
|
||||
[testQuestion], // question (but we submit q-5)
|
||||
[], // 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)
|
||||
[freeUserRow], // getResourceUser
|
||||
[userAfterAttempt], // getResourceUser
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
|
||||
[{ checkInDays: 2, lastCheckInDate: new Date().toISOString() }], // calculateStreak
|
||||
[], // getSubscriptionStatus
|
||||
[freeUserRow], // getDailyAttempts
|
||||
[userAfterAttempt], // getDailyAttempts
|
||||
[{ used: 0, restored: 0 }], // getHighRewardQuota
|
||||
[], // no existing daily progress
|
||||
// updateChapterProgress
|
||||
@ -421,6 +428,7 @@ describe('challenge-service', () => {
|
||||
[{ coinsBalance: 40 }], // current wallet balance
|
||||
[{ id: 'daily-1' }], // daily progress row for coin aggregation
|
||||
[knowledgeCardRow],
|
||||
[{ groupId: 'week-2026-05-11-group-1' }], // addXp(first card): reuse weekly group
|
||||
// getProgressSummary (final)
|
||||
[userAfterXp],
|
||||
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }],
|
||||
|
||||
27
src/__tests__/utils/errors.test.ts
Normal file
27
src/__tests__/utils/errors.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
36
src/__tests__/utils/json-parser.test.ts
Normal file
36
src/__tests__/utils/json-parser.test.ts
Normal 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
46
src/config/regions.ts
Normal 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 },
|
||||
]);
|
||||
@ -37,6 +37,9 @@ export const users = mysqlTable('users', {
|
||||
dailyXpDate: date('daily_xp_date'), // 每日经验统计日期。
|
||||
currentTheme: varchar('current_theme', { length: 20 }).default('inkTeal'), // 当前界面主题。
|
||||
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), // 当日剩余挑战次数。
|
||||
dailyAttemptsDate: date('daily_attempts_date'), // 每日挑战次数统计日期。
|
||||
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`), // 更新时间。
|
||||
}, (table) => [
|
||||
uniqueIndex('uk_auth').on(table.authType, table.authId),
|
||||
index('idx_users_region').on(table.regionCode),
|
||||
]);
|
||||
|
||||
// ── Categories ─────────────────────────────────────────────────────
|
||||
@ -432,6 +436,21 @@ export const userWeeklyXp = mysqlTable('user_weekly_xp', {
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
// 用户订阅权益与平台购买数据。
|
||||
|
||||
@ -6,6 +6,7 @@ import jwt from '@fastify/jwt';
|
||||
|
||||
import { config } from './utils/config.js';
|
||||
import { errorHandler } from './utils/errors.js';
|
||||
import { registerJsonBodyParser } from './utils/json-parser.js';
|
||||
import authMiddleware from './middleware/auth.js';
|
||||
import adminAuthMiddleware from './middleware/admin-auth.js';
|
||||
import requestLogger from './middleware/request-logger.js';
|
||||
@ -31,6 +32,8 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
registerJsonBodyParser(app);
|
||||
|
||||
// ── Plugins ──────────────────────────────────────────────────────
|
||||
|
||||
await app.register(helmet);
|
||||
|
||||
@ -19,6 +19,7 @@ async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||
'/v1/auth/phone',
|
||||
'/v1/auth/refresh',
|
||||
'/v1/auth/providers',
|
||||
'/v1/app/regions',
|
||||
];
|
||||
|
||||
if (publicPaths.some((p) => request.url.startsWith(p))) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
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 { getNextChallenge, submitChallengeAnswer } from '../services/learning/challenge-service.js';
|
||||
import {
|
||||
@ -33,9 +34,14 @@ const preferencesSchema = z.object({
|
||||
activeTrackId: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
const userRegionSchema = z.object({
|
||||
regionCode: z.string().regex(/^\d{6}$/),
|
||||
});
|
||||
|
||||
const leaderboardQuerySchema = z.object({
|
||||
scope: z.enum(['region', 'topic']).default('region'),
|
||||
trackId: z.string().optional(),
|
||||
regionCode: z.string().regex(/^\d{6}$/).optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
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 };
|
||||
});
|
||||
|
||||
app.get('/app/regions', async () => {
|
||||
const data = getRegionsConfig();
|
||||
return { success: true, data, error: null };
|
||||
});
|
||||
|
||||
app.get('/tracks', async (request) => {
|
||||
const data = await getThemeTracks(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
@ -117,6 +128,13 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
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) => {
|
||||
const data = await checkIn(getUserId(request));
|
||||
return { success: true, data, error: null };
|
||||
@ -156,6 +174,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
getUserId(request),
|
||||
parsed.data.scope,
|
||||
parsed.data.trackId,
|
||||
parsed.data.regionCode,
|
||||
parsed.data.page,
|
||||
parsed.data.limit,
|
||||
);
|
||||
@ -165,7 +184,7 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/leaderboards/me', async (request) => {
|
||||
const parsed = leaderboardQuerySchema.safeParse(request.query);
|
||||
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 };
|
||||
});
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import { sendPhoneCode, loginWithPhone } from '../services/auth/phone.js';
|
||||
import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js';
|
||||
import { linkGuestAccount } from '../services/auth/account-link-service.js';
|
||||
import { NotFoundError } from '../utils/errors.js';
|
||||
import { toUserRegionDto } from '../services/app/regions-service.js';
|
||||
import { config } from '../utils/config.js';
|
||||
|
||||
const guestLoginSchema = z.object({
|
||||
@ -266,6 +267,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
heartsRemaining: user.heartsRemaining ?? 5,
|
||||
dailyXpEarned: user.dailyXpEarned ?? 0,
|
||||
dailyXpGoal: user.dailyXpGoal ?? 50,
|
||||
region: toUserRegionDto(user.regionCode, user.regionSelectedAt, user.regionChangedAt),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ import { getShopCatalog } from '../shop/shop-service.js';
|
||||
import { getClientSubscription } from '../subscription/subscription-api-service.js';
|
||||
import { getCoinBalance } from '../gamification/coin-service.js';
|
||||
import { getClientInventory } from '../gamification/inventory-service.js';
|
||||
import { toUserRegionDto } from './regions-service.js';
|
||||
import type { BootstrapDto, SubscriptionTier } from '../../types/app-api.js';
|
||||
|
||||
export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
@ -17,6 +18,9 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
avatarUrl: users.avatarUrl,
|
||||
tier: users.tier,
|
||||
xpTotal: users.xpTotal,
|
||||
regionCode: users.regionCode,
|
||||
regionSelectedAt: users.regionSelectedAt,
|
||||
regionChangedAt: users.regionChangedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
@ -41,6 +45,7 @@ export async function getBootstrap(userId: string): Promise<BootstrapDto> {
|
||||
avatarUrl: user?.avatarUrl ?? null,
|
||||
tier: (user?.tier ?? 'free') as SubscriptionTier,
|
||||
level,
|
||||
region: toUserRegionDto(user?.regionCode, user?.regionSelectedAt, user?.regionChangedAt),
|
||||
},
|
||||
progress,
|
||||
tracks,
|
||||
|
||||
159
src/services/app/regions-service.ts
Normal file
159
src/services/app/regions-service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -109,6 +109,44 @@ export async function getLeaderboard(userId: string, _tier?: string, page = 1, l
|
||||
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 比自己高的用户数量,得出组内排名。
|
||||
@ -141,6 +179,31 @@ export async function getUserRank(userId: string): Promise<{ rank: number; tier:
|
||||
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。 */
|
||||
const TOP_REWARD_COINS: ReadonlyMap<number, number> = new Map([
|
||||
[1, 300],
|
||||
|
||||
@ -444,8 +444,9 @@ export async function submitChallengeAnswer(
|
||||
if (!heartResult.success && heartResult.remaining === 0) {
|
||||
throw new ValidationError('红心已用完,请等待恢复或观看广告');
|
||||
}
|
||||
await deductDailyAttempt(userId);
|
||||
}
|
||||
// 每题成功裁决后消耗 1 次今日答题次数;幂等重复提交会在前面直接返回快照,不会重复扣减。
|
||||
await deductDailyAttempt(userId);
|
||||
|
||||
const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession;
|
||||
const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions);
|
||||
|
||||
@ -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 { db } from '../../db/client.js';
|
||||
import { users } from '../../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { AppError } from '../../utils/errors.js';
|
||||
import { findRegionByCode, getUserSelectedRegion } from '../app/regions-service.js';
|
||||
|
||||
function getBadge(rank: number): string {
|
||||
if (rank === 1) return '榜首';
|
||||
@ -13,8 +15,9 @@ function getBadge(rank: number): string {
|
||||
|
||||
export async function getClientLeaderboard(
|
||||
userId: string,
|
||||
_scope: LeaderboardScope,
|
||||
scope: LeaderboardScope,
|
||||
_trackId: string | undefined,
|
||||
regionCode: string | undefined,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<{
|
||||
@ -22,6 +25,51 @@ export async function getClientLeaderboard(
|
||||
meta: LeaderboardMetaDto;
|
||||
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([
|
||||
getLeaderboard(userId, undefined, page, limit),
|
||||
getLeaderboardMeta(userId),
|
||||
@ -44,9 +92,75 @@ export async function getClientLeaderboard(
|
||||
|
||||
export async function getClientLeaderboardMe(
|
||||
userId: string,
|
||||
_scope: LeaderboardScope,
|
||||
scope: LeaderboardScope,
|
||||
_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([
|
||||
getUserRank(userId),
|
||||
getLeaderboardMeta(userId),
|
||||
|
||||
@ -5,12 +5,39 @@ 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 {
|
||||
@ -220,6 +247,9 @@ export interface LeaderboardMetaDto {
|
||||
weekEnd: string;
|
||||
nextRefreshAt: string;
|
||||
groupId: string | null;
|
||||
requiresRegionSelection?: boolean;
|
||||
selectedRegion?: UserRegionDto | null;
|
||||
viewRegion?: RegionDto | null;
|
||||
/** 当前用户组内排名(仅 /leaderboards/me 返回)。 */
|
||||
rank?: number;
|
||||
/** 当前周奖励预览:各组前 3 名的金币奖励。 */
|
||||
|
||||
@ -77,6 +77,19 @@ export function errorHandler(
|
||||
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
|
||||
_request.log.error({ err: error }, 'Unhandled error');
|
||||
|
||||
|
||||
20
src/utils/json-parser.ts
Normal file
20
src/utils/json-parser.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
/**
|
||||
* 兼容移动端空 POST:部分客户端会带 `Content-Type: application/json`
|
||||
* 但不发送 body。Fastify 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user