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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
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 { 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', () => {
|
||||||
|
|||||||
@ -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 }],
|
||||||
|
|||||||
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'), // 每日经验统计日期。
|
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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// 用户订阅权益与平台购买数据。
|
// 用户订阅权益与平台购买数据。
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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))) {
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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 } };
|
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],
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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 名的金币奖励。 */
|
||||||
|
|||||||
@ -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
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