Compare commits
7 Commits
main
...
refactor-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968a672fbd | ||
|
|
3f14230e15 | ||
|
|
b6b32f6676 | ||
|
|
a3341b6a3b | ||
|
|
fe52ab1696 | ||
|
|
9119737f11 | ||
|
|
b5365751bb |
@@ -1,11 +0,0 @@
|
|||||||
node_modules
|
|
||||||
client/node_modules
|
|
||||||
client/dist
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
logs
|
|
||||||
generated
|
|
||||||
redis-data
|
|
||||||
test
|
|
||||||
docs
|
|
||||||
@@ -15,6 +15,11 @@ ADMIN_BOOTSTRAP_ENABLED=false
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
ADMIN_NICKNAME=
|
ADMIN_NICKNAME=
|
||||||
|
TEST_ADMIN_AUTO_PROVISION=true
|
||||||
|
TEST_ADMIN_USERNAME=admin
|
||||||
|
TEST_ADMIN_PASSWORD=
|
||||||
|
TEST_ADMIN_NICKNAME=测试管理员
|
||||||
|
TEST_LAB_ENABLED=false
|
||||||
|
|
||||||
# Local storage mode
|
# Local storage mode
|
||||||
USE_MEMORY_STORAGE=true
|
USE_MEMORY_STORAGE=true
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ADMIN_BOOTSTRAP_ENABLED=false
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
ADMIN_NICKNAME=
|
ADMIN_NICKNAME=
|
||||||
|
TEST_LAB_ENABLED=false
|
||||||
|
|
||||||
# Persistent storage
|
# Persistent storage
|
||||||
USE_MEMORY_STORAGE=false
|
USE_MEMORY_STORAGE=false
|
||||||
@@ -35,14 +36,6 @@ EMAIL_SECURE=true
|
|||||||
EMAIL_USER=
|
EMAIL_USER=
|
||||||
EMAIL_PASS=
|
EMAIL_PASS=
|
||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
MAIL_PROVIDER=
|
|
||||||
NOVAMAILIO_MAIL_API_BASE=
|
|
||||||
NOVAMAILIO_MAIL_CREDENTIAL=
|
|
||||||
NOVAMAILIO_MAIL_FROM_NAME=
|
|
||||||
NOVAMAILIO_MAIL_FINGERPRINT=
|
|
||||||
NOVAMAILIO_MAIL_ORIGIN=
|
|
||||||
NOVAMAILIO_MAIL_REFERER=
|
|
||||||
NOVAMAILIO_MAIL_USER_AGENT=
|
|
||||||
|
|
||||||
# Zulip
|
# Zulip
|
||||||
ZULIP_CONFIG_MODE=dynamic
|
ZULIP_CONFIG_MODE=dynamic
|
||||||
|
|||||||
128
DEPLOYMENT.md
128
DEPLOYMENT.md
@@ -1,113 +1,47 @@
|
|||||||
# WhaleTown End V2 部署
|
# WhaleTown V2 后端部署
|
||||||
|
|
||||||
本文档覆盖 NestJS API、原生 WebSocket 服务和 React 管理端的单机部署。示例域名和目录与 `deploy/nginx` 中的模板一致,可按实际环境替换。
|
## 1. 生产配置
|
||||||
|
|
||||||
## 1. 环境要求
|
```bash
|
||||||
|
cp .env.production.example .env
|
||||||
|
openssl rand -hex 32 # 分别用于 JWT_SECRET、ADMIN_TOKEN_SECRET 和 ZULIP_API_KEY_ENCRYPTION_KEY
|
||||||
|
```
|
||||||
|
|
||||||
- Node.js 20 或更高版本
|
必须填写 MySQL、Redis 和三个随机密钥。REST API 使用 `3000` 端口,原生 WebSocket 使用 `3001` 端口。不要把两个端口配成相同值。
|
||||||
- pnpm 9
|
|
||||||
- MySQL 8 和 Redis 7
|
|
||||||
- PM2
|
|
||||||
- Nginx
|
|
||||||
- Python 3(仅皮肤生成功能需要)
|
|
||||||
|
|
||||||
生产目录默认为 `/var/www/whale-town-end-v2`。所有命令均在该目录执行。
|
## 2. 数据库
|
||||||
|
|
||||||
## 2. 安装与配置
|
部署前先备份现有数据库。v2 不会自动修改表结构(`synchronize: false`)。根据目标库的现有结构审核并执行仓库内的增量 SQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u <user> -p <database> < src/core/db/player_assets/create-player-assets-tables.sql
|
||||||
|
mysql -u <user> -p <database> < src/core/db/user_wallets/create-user-wallets-tables.sql
|
||||||
|
mysql -u <user> -p <database> < src/business/notice/migrations/create-notices-table.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 构建与启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
cp .env.production.example .env
|
|
||||||
chmod 600 .env
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑 `.env` 并至少完成以下配置:
|
|
||||||
|
|
||||||
- 为 `JWT_SECRET` 和 `ADMIN_TOKEN_SECRET` 设置独立的随机值。
|
|
||||||
- 完整设置 `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD` 和 `DB_NAME`,避免服务回退到内存存储。
|
|
||||||
- 完整设置 Redis 连接信息。
|
|
||||||
- 保持 REST API 使用 `PORT=3000`,聊天 WebSocket 使用 `WEBSOCKET_PORT=3001`。
|
|
||||||
- 使用 Zulip 时设置机器人凭据和至少 32 字节的 `ZULIP_API_KEY_ENCRYPTION_KEY`,并将 `ZULIP_DEGRADED_MODE_ENABLED` 设为 `false`。
|
|
||||||
- 不使用 Zulip 时可将 `ZULIP_DEGRADED_MODE_ENABLED` 设为 `true` 并留空 Zulip 凭据;此时 Zulip 集成和 API Key 加密存取功能不可用。
|
|
||||||
|
|
||||||
可分别生成随机密钥:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
不要把 `.env`、生成的密钥或数据库备份提交到 Git。
|
|
||||||
|
|
||||||
## 3. 构建
|
|
||||||
|
|
||||||
构建后端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
VITE_API_BASE_URL=https://whaletownend.xinghangee.icu pnpm --filter whale-town-admin run build
|
||||||
|
|
||||||
配置并构建管理端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp client/.env.example client/.env.local
|
|
||||||
pnpm --filter whale-town-admin run build
|
|
||||||
```
|
|
||||||
|
|
||||||
确认 `client/.env.local` 中的 `VITE_API_BASE_URL` 指向实际后端 HTTPS 地址。该值在构建时写入管理端产物,修改后需要重新构建。
|
|
||||||
|
|
||||||
## 4. 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js
|
pm2 start ecosystem.config.js
|
||||||
pm2 save
|
pm2 save
|
||||||
```
|
```
|
||||||
|
|
||||||
服务使用仓库根目录作为工作目录,并从根目录的 `.env` 加载运行配置。查看状态和日志:
|
## 4. 反向代理
|
||||||
|
|
||||||
|
`deploy/nginx/whaletownend-v2.conf.example` 将 REST 转发到 `3000`,将 `/game` WebSocket 转发到 `3001`。管理端可使用 `deploy/nginx/whaletown-admin-v2.conf.example` 作为独立静态站点。
|
||||||
|
|
||||||
|
启用 HTTPS 后验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm2 status
|
curl https://whaletownend.xinghangee.icu/
|
||||||
pm2 logs whale-town-end-v2
|
curl https://whaletownend.xinghangee.icu/api-docs
|
||||||
|
curl --http1.1 -i \
|
||||||
|
-H 'Connection: Upgrade' \
|
||||||
|
-H 'Upgrade: websocket' \
|
||||||
|
-H 'Sec-WebSocket-Version: 13' \
|
||||||
|
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||||
|
https://whaletownend.xinghangee.icu/game
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 配置 Nginx
|
|
||||||
|
|
||||||
安装后端和管理端模板:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/nginx/whaletownend-v2.conf.example /etc/nginx/conf.d/whaletownend-v2.conf
|
|
||||||
sudo cp deploy/nginx/whaletown-admin-v2.conf.example /etc/nginx/conf.d/whaletown-admin-v2.conf
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
后端模板将 REST API 转发到 `3000`,将 `/game` 转发到独立的聊天 WebSocket 端口 `3001`,并为 `/location-broadcast` 和 `/ws/notice` 保留 REST 端口上的 WebSocket Upgrade。上线前还需在 Nginx 或上游代理配置 TLS。
|
|
||||||
|
|
||||||
## 6. 验收
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --fail https://whaletownend.xinghangee.icu/
|
|
||||||
curl --fail https://whaletownend.xinghangee.icu/health
|
|
||||||
curl --fail https://whaletownend.xinghangee.icu/api-docs
|
|
||||||
```
|
|
||||||
|
|
||||||
根接口应返回 `version: 2.0.0`,健康接口应返回 `status: ok`。还应分别验证以下 WebSocket 地址能够完成 `101 Switching Protocols`:
|
|
||||||
|
|
||||||
- `wss://whaletownend.xinghangee.icu/game`
|
|
||||||
- `wss://whaletownend.xinghangee.icu/location-broadcast`
|
|
||||||
- `wss://whaletownend.xinghangee.icu/ws/notice`
|
|
||||||
|
|
||||||
最后使用管理端和游戏客户端完成登录、刷新令牌、世界聊天、位置同步和通知的冒烟测试。
|
|
||||||
|
|
||||||
## 7. 更新与回滚
|
|
||||||
|
|
||||||
更新前备份 `.env` 和数据库,然后执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull --ff-only
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm run build
|
|
||||||
pnpm --filter whale-town-admin run build
|
|
||||||
pm2 reload whale-town-end-v2
|
|
||||||
```
|
|
||||||
|
|
||||||
出现问题时切回上一已验证提交,重新安装锁定依赖并构建,然后执行 `pm2 reload whale-town-end-v2`。数据库结构变更必须使用对应版本的迁移或备份恢复方案,不能只回滚应用代码。
|
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -1,47 +0,0 @@
|
|||||||
FROM node:22-bookworm-slim AS build
|
|
||||||
|
|
||||||
RUN npm install --global pnpm@9.15.4
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
||||||
COPY client/package.json ./client/package.json
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY nest-cli.json tsconfig.json tsconfig.build.json ./
|
|
||||||
COPY src ./src
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runtime
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV SKIN_GENERATION_PYTHON=/opt/skin-generation-venv/bin/python
|
|
||||||
ENV PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple
|
|
||||||
|
|
||||||
RUN sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list.d/debian.sources \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install --yes --no-install-recommends ca-certificates python3 python3-venv \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN npm install --global pnpm@9.15.4
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY requirements-skin-generation.txt ./
|
|
||||||
RUN python3 -m venv /opt/skin-generation-venv \
|
|
||||||
&& /opt/skin-generation-venv/bin/pip install --no-cache-dir --requirement requirements-skin-generation.txt
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
||||||
COPY client/package.json ./client/package.json
|
|
||||||
RUN pnpm install --prod --frozen-lockfile && pnpm store prune
|
|
||||||
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
COPY scripts ./scripts
|
|
||||||
|
|
||||||
RUN mkdir -p config generated/account-assets generated/skins logs redis-data \
|
|
||||||
&& chown -R node:node /app
|
|
||||||
|
|
||||||
USER node
|
|
||||||
EXPOSE 3000 3001
|
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=4 \
|
|
||||||
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
|
||||||
|
|
||||||
CMD ["node", "dist/main.js"]
|
|
||||||
@@ -27,9 +27,9 @@ pnpm run build
|
|||||||
pnpm run start:prod
|
pnpm run start:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
启动前至少需要在 `.env` 中设置随机的 `JWT_SECRET` 和 `ADMIN_TOKEN_SECRET`。启用 Zulip 时还必须设置 `ZULIP_API_KEY_ENCRYPTION_KEY`;若 `ZULIP_DEGRADED_MODE_ENABLED=true`,可以不配置 Zulip 凭据和加密密钥,但 Zulip 集成及 API Key 加密存取功能将不可用。生产环境请从 `.env.production.example` 开始配置,不要直接使用示例值。
|
启动前至少需要在 `.env` 中设置随机的 `JWT_SECRET`、`ADMIN_TOKEN_SECRET` 和 `ZULIP_API_KEY_ENCRYPTION_KEY`。生产环境请从 `.env.production.example` 开始配置,不要直接使用示例值。
|
||||||
|
|
||||||
API 默认监听 `3000` 端口,Swagger 地址为 `/api-docs`。
|
REST API 默认监听 `3000` 端口,原生 WebSocket 默认监听 `3001` 端口并使用 `/game` 路径,Swagger 地址为 `/api-docs`。生产环境的反向代理需要分别转发这两个端口。
|
||||||
|
|
||||||
## 管理端
|
## 管理端
|
||||||
|
|
||||||
@@ -39,10 +39,6 @@ pnpm --filter whale-town-admin run build
|
|||||||
|
|
||||||
管理端的 API 地址通过 `client/.env.local` 中的 `VITE_API_BASE_URL` 配置。
|
管理端的 API 地址通过 `client/.env.local` 中的 `VITE_API_BASE_URL` 配置。
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
生产部署、Nginx、PM2、验收和回滚步骤见 [DEPLOYMENT.md](DEPLOYMENT.md)。
|
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
仓库不包含 `.env`、访问令牌、SSH 私钥、数据库文件、Redis 数据、日志或生成资产。敏感配置必须通过部署环境注入。
|
仓库不包含 `.env`、访问令牌、SSH 私钥、数据库文件、Redis 数据、日志或生成资产。敏感配置必须通过部署环境注入。
|
||||||
|
|||||||
@@ -17,32 +17,6 @@ server {
|
|||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /location-broadcast {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /ws/notice {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
numpy==2.0.2
|
|
||||||
openai==2.14.0
|
|
||||||
Pillow==11.3.0
|
|
||||||
@@ -30,6 +30,8 @@ import { UserWalletsModule } from './core/db/user_wallets/user_wallets.module';
|
|||||||
import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module';
|
import { UserProfilesModule } from './core/db/user_profiles/user_profiles.module';
|
||||||
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
|
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
|
||||||
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
|
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
|
||||||
|
import { SocialModule } from './business/social/social.module';
|
||||||
|
import { TasksModule } from './business/tasks/tasks.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
||||||
@@ -87,6 +89,7 @@ function isDatabaseConfigured(): boolean {
|
|||||||
UserProfilesModule.forRoot(),
|
UserProfilesModule.forRoot(),
|
||||||
PlayerAssetsModule.forRoot(),
|
PlayerAssetsModule.forRoot(),
|
||||||
UserWalletsModule.forRoot(),
|
UserWalletsModule.forRoot(),
|
||||||
|
TasksModule.forRoot(),
|
||||||
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
||||||
ZulipAccountsModule.forRoot(),
|
ZulipAccountsModule.forRoot(),
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
@@ -106,6 +109,7 @@ function isDatabaseConfigured(): boolean {
|
|||||||
CafeCompanionModule,
|
CafeCompanionModule,
|
||||||
CourseResourcesModule,
|
CourseResourcesModule,
|
||||||
RankingsModule,
|
RankingsModule,
|
||||||
|
SocialModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import { AdminOperationLogMemoryService } from './admin_operation_log_memory.ser
|
|||||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||||
|
import { TestLabController } from './test_lab.controller';
|
||||||
|
import { TestLabService } from './test_lab.service';
|
||||||
|
import { TestLabGuard } from './test_lab.guard';
|
||||||
|
import { ChatGatewayModule } from '../../gateway/chat/chat.gateway.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据库配置是否完整
|
* 检查数据库配置是否完整
|
||||||
@@ -55,6 +61,9 @@ function isDatabaseConfigured(): boolean {
|
|||||||
LoggerModule,
|
LoggerModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SessionCoreModule,
|
SessionCoreModule,
|
||||||
|
AuthModule,
|
||||||
|
LoginCoreModule,
|
||||||
|
ChatGatewayModule,
|
||||||
UserProfilesModule,
|
UserProfilesModule,
|
||||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||||
// 注册AdminOperationLog实体
|
// 注册AdminOperationLog实体
|
||||||
@@ -63,7 +72,8 @@ function isDatabaseConfigured(): boolean {
|
|||||||
controllers: [
|
controllers: [
|
||||||
AdminController,
|
AdminController,
|
||||||
AdminDatabaseController,
|
AdminDatabaseController,
|
||||||
AdminOperationLogController
|
AdminOperationLogController,
|
||||||
|
TestLabController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminService,
|
AdminService,
|
||||||
@@ -75,12 +85,15 @@ function isDatabaseConfigured(): boolean {
|
|||||||
: AdminOperationLogMemoryService,
|
: AdminOperationLogMemoryService,
|
||||||
},
|
},
|
||||||
AdminDatabaseExceptionFilter,
|
AdminDatabaseExceptionFilter,
|
||||||
AdminOperationLogInterceptor
|
AdminOperationLogInterceptor,
|
||||||
|
TestLabService,
|
||||||
|
TestLabGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AdminService,
|
AdminService,
|
||||||
DatabaseManagementService,
|
DatabaseManagementService,
|
||||||
AdminOperationLogService
|
AdminOperationLogService,
|
||||||
|
TestLabService,
|
||||||
], // 导出服务供其他模块使用
|
], // 导出服务供其他模块使用
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
67
src/business/admin/test_lab.controller.ts
Normal file
67
src/business/admin/test_lab.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { TestLabService } from './test_lab.service';
|
||||||
|
import { CreateTestLabActorDto, TestLabMessageDto, TestLabRoomPolicyDto, TestLabSocialActionDto, UpdateTestLabActorDto } from './test_lab.dto';
|
||||||
|
import { Req } from '@nestjs/common';
|
||||||
|
import { TestLabGuard, TestLabRequest } from './test_lab.guard';
|
||||||
|
|
||||||
|
@ApiTags('admin-test-lab')
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@UseGuards(TestLabGuard)
|
||||||
|
@Controller('admin/test-lab')
|
||||||
|
export class TestLabController {
|
||||||
|
constructor(private readonly testLab: TestLabService) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@ApiOperation({ summary: '获取测试实验室状态' })
|
||||||
|
async status() {
|
||||||
|
return { success: true, data: await this.testLab.status() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('actors')
|
||||||
|
@ApiOperation({ summary: '创建并上线测试假人' })
|
||||||
|
async create(@Body(new ValidationPipe({ transform: true })) dto: CreateTestLabActorDto, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.createActor(dto, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('actors/:userId')
|
||||||
|
@ApiOperation({ summary: '更新测试假人的在线、位置或外观' })
|
||||||
|
async update(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: UpdateTestLabActorDto, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.updateActor(userId, dto, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('actors/:userId/room-policy')
|
||||||
|
@ApiOperation({ summary: '设置测试假人房间访问策略' })
|
||||||
|
async roomPolicy(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabRoomPolicyDto, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.setRoomPolicy(userId, dto, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('actors/:userId/messages')
|
||||||
|
@ApiOperation({ summary: '以测试假人身份发送公共消息或私聊' })
|
||||||
|
async message(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabMessageDto, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.sendMessage(userId, dto, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('actors/:userId/social')
|
||||||
|
@ApiOperation({ summary: '以测试假人身份执行好友或拉黑操作' })
|
||||||
|
async social(@Param('userId') userId: string, @Body(new ValidationPipe({ transform: true })) dto: TestLabSocialActionDto, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.socialAction(userId, dto, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('actors/:userId')
|
||||||
|
@ApiOperation({ summary: '删除单个测试假人及其测试数据' })
|
||||||
|
async remove(@Param('userId') userId: string, @Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.removeActor(userId, this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('actors')
|
||||||
|
@ApiOperation({ summary: '清空全部测试假人及关联测试数据' })
|
||||||
|
async clear(@Req() request: TestLabRequest) {
|
||||||
|
return { success: true, data: await this.testLab.clear(this.admin(request)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private admin(request: TestLabRequest) {
|
||||||
|
if (!request.admin) throw new Error('管理员身份缺失');
|
||||||
|
return request.admin;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/business/admin/test_lab.dto.ts
Normal file
92
src/business/admin/test_lab.dto.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, Length, Matches, Max, Min, ValidateIf } from 'class-validator';
|
||||||
|
import { MALL_SKIN_ITEMS } from '../mall/mall_catalog';
|
||||||
|
|
||||||
|
export const TEST_LAB_MAP_IDS = ['whale_port', 'work_zone', 'whale_cafe'] as const;
|
||||||
|
export type TestLabMapId = (typeof TEST_LAB_MAP_IDS)[number];
|
||||||
|
export const TEST_LAB_SKIN_IDS = MALL_SKIN_ITEMS.map((item) => item.skinId).filter((skinId): skinId is string => Boolean(skinId));
|
||||||
|
const TEST_LAB_NICKNAME_PATTERN = /^[\u4E00-\u9FFF A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
|
export class CreateTestLabActorDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(1, 32)
|
||||||
|
@Matches(TEST_LAB_NICKNAME_PATTERN, { message: '昵称仅支持中文、字母、数字、空格、下划线和连字符' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@IsIn(TEST_LAB_MAP_IDS)
|
||||||
|
mapId: TestLabMapId;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-8192)
|
||||||
|
@Max(8192)
|
||||||
|
x: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-8192)
|
||||||
|
@Max(8192)
|
||||||
|
y: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TEST_LAB_SKIN_IDS)
|
||||||
|
skinId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateTestLabActorDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
online?: boolean;
|
||||||
|
|
||||||
|
@ValidateIf((value) => value.mapId !== undefined)
|
||||||
|
@IsIn(TEST_LAB_MAP_IDS)
|
||||||
|
mapId?: TestLabMapId;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-8192)
|
||||||
|
@Max(8192)
|
||||||
|
x?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-8192)
|
||||||
|
@Max(8192)
|
||||||
|
y?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TEST_LAB_SKIN_IDS)
|
||||||
|
skinId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestLabMessageDto {
|
||||||
|
@IsIn(['local', 'global', 'private'])
|
||||||
|
scope: 'local' | 'global' | 'private';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(1, 1000)
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@ValidateIf((value) => value.scope === 'private')
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
targetUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestLabSocialActionDto {
|
||||||
|
@IsIn(['friend_request', 'friend_accept', 'friend_reject', 'block'])
|
||||||
|
action: 'friend_request' | 'friend_accept' | 'friend_reject' | 'block';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
targetUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestLabRoomPolicyDto {
|
||||||
|
@IsIn(['friends', 'public', 'closed'])
|
||||||
|
roomVisitPolicy: 'friends' | 'public' | 'closed';
|
||||||
|
}
|
||||||
73
src/business/admin/test_lab.guard.ts
Normal file
73
src/business/admin/test_lab.guard.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { AdminAuthPayload, AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||||
|
import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service';
|
||||||
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
|
|
||||||
|
export interface TestLabRequest extends Request {
|
||||||
|
admin?: AdminAuthPayload;
|
||||||
|
user?: JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsersLookup = {
|
||||||
|
findOne(id: bigint): Promise<Users>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试实验室既可由旧管理端 token 调用,也可由游戏内管理员的登录 token 调用。
|
||||||
|
* 两条路径最终都会回查当前用户 role,避免客户端角色字段或旧 token 被单独信任。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TestLabGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly adminCore: AdminCoreService,
|
||||||
|
private readonly loginCore: LoginCoreService,
|
||||||
|
@Inject('UsersService') private readonly users: UsersLookup,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<TestLabRequest>();
|
||||||
|
const token = this.extractBearerToken(request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = this.adminCore.verifyToken(token);
|
||||||
|
await this.assertCurrentAdministrator(admin.adminId);
|
||||||
|
request.admin = admin;
|
||||||
|
return true;
|
||||||
|
} catch (_adminTokenError) {
|
||||||
|
// 继续尝试游戏登录 token;两类 token 使用不同签名,不会相互放行。
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await this.loginCore.verifyToken(token, 'access');
|
||||||
|
if (user.role !== 9) throw new ForbiddenException('仅管理员可使用测试实验室');
|
||||||
|
await this.assertCurrentAdministrator(user.sub);
|
||||||
|
request.user = user;
|
||||||
|
request.admin = {
|
||||||
|
adminId: user.sub,
|
||||||
|
username: user.username,
|
||||||
|
role: 9,
|
||||||
|
iat: user.iat || Math.floor(Date.now() / 1000),
|
||||||
|
exp: user.exp || Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
throw new UnauthorizedException('需要有效的管理员登录凭据');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBearerToken(request: Request): string {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
if (!authorization || Array.isArray(authorization)) throw new UnauthorizedException('缺少Authorization头');
|
||||||
|
const [scheme, token] = authorization.split(' ');
|
||||||
|
if (scheme !== 'Bearer' || !token) throw new UnauthorizedException('Authorization格式错误');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCurrentAdministrator(userId: string): Promise<void> {
|
||||||
|
if (!/^\d+$/.test(userId)) throw new UnauthorizedException('管理员身份无效');
|
||||||
|
const user = await this.users.findOne(BigInt(userId));
|
||||||
|
if (user.role !== 9) throw new ForbiddenException('仅管理员可使用测试实验室');
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/business/admin/test_lab.service.ts
Normal file
281
src/business/admin/test_lab.service.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { AccountProfileService } from '../auth/account_profile.service';
|
||||||
|
import { SocialService } from '../social/social.service';
|
||||||
|
import { ChatWebSocketGateway } from '../../gateway/chat/chat.gateway';
|
||||||
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
|
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||||
|
import {
|
||||||
|
clearTestLabPresences,
|
||||||
|
getAllTestLabPresences,
|
||||||
|
getTestLabPresence,
|
||||||
|
TestLabPresence,
|
||||||
|
} from './test_lab_presence.registry';
|
||||||
|
import { CreateTestLabActorDto, TestLabMessageDto, TestLabRoomPolicyDto, TestLabSocialActionDto, UpdateTestLabActorDto } from './test_lab.dto';
|
||||||
|
|
||||||
|
type UserStore = {
|
||||||
|
create(input: Partial<Users> & { username: string; nickname: string }): Promise<Users>;
|
||||||
|
findOne(id: bigint): Promise<Users>;
|
||||||
|
findAll(limit: number, offset: number): Promise<Users[]>;
|
||||||
|
remove(id: bigint): Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminActor = { adminId: string; username: string };
|
||||||
|
|
||||||
|
const MAX_TEST_ACTORS = 20;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TestLabService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(TestLabService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@Inject('UsersService') private readonly users: UserStore,
|
||||||
|
private readonly accountProfiles: AccountProfileService,
|
||||||
|
private readonly social: SocialService,
|
||||||
|
private readonly gateway: ChatWebSocketGateway,
|
||||||
|
private readonly audit: AdminOperationLogService,
|
||||||
|
@Optional() private readonly dataSource?: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (this.isTestEnvironment()) {
|
||||||
|
await this.clearTestAccounts('startup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.isTestEnvironment() && this.config.get<string>('TEST_LAB_ENABLED', 'false') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
this.assertEnabled();
|
||||||
|
const actors = await this.listActorsInternal();
|
||||||
|
return {
|
||||||
|
enabled: this.isEnabled(),
|
||||||
|
environment: this.config.get<string>('NODE_ENV', 'development'),
|
||||||
|
maxActors: MAX_TEST_ACTORS,
|
||||||
|
actors,
|
||||||
|
onlinePlayers: this.gateway.getOnlineWorldPlayers(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createActor(dto: CreateTestLabActorDto, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const actors = await this.listActorsInternal();
|
||||||
|
if (actors.length >= MAX_TEST_ACTORS) throw new BadRequestException(`最多创建 ${MAX_TEST_ACTORS} 个测试假人`);
|
||||||
|
|
||||||
|
const nickname = dto.nickname.trim();
|
||||||
|
const user = await this.users.create({
|
||||||
|
username: `test_actor_${randomUUID().replace(/-/g, '').slice(0, 18)}`,
|
||||||
|
nickname,
|
||||||
|
role: 1,
|
||||||
|
email_verified: true,
|
||||||
|
is_test_account: true,
|
||||||
|
});
|
||||||
|
await this.accountProfiles.ensureProfile(user.id, dto.skinId || 'classic_whale');
|
||||||
|
await this.accountProfiles.updateAccountProfile(user.id, { settings: { room_visit_policy: 'public' } });
|
||||||
|
|
||||||
|
const presence: TestLabPresence = {
|
||||||
|
userId: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
mapId: dto.mapId,
|
||||||
|
x: dto.x,
|
||||||
|
y: dto.y,
|
||||||
|
skinId: dto.skinId || 'classic_whale',
|
||||||
|
avatarId: 'default',
|
||||||
|
online: true,
|
||||||
|
};
|
||||||
|
this.gateway.setTestLabPresence(presence);
|
||||||
|
await this.record(admin, 'CREATE', 'test_lab_actor', user.id.toString(), '创建测试假人');
|
||||||
|
return await this.serializeActor(user, presence);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateActor(userId: string, dto: UpdateTestLabActorDto, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const user = await this.requireActor(userId);
|
||||||
|
const current = getTestLabPresence(user.id) || this.offlinePresence(user);
|
||||||
|
const next: TestLabPresence = {
|
||||||
|
...current,
|
||||||
|
online: dto.online ?? current.online,
|
||||||
|
mapId: dto.mapId ?? current.mapId,
|
||||||
|
x: dto.x ?? current.x,
|
||||||
|
y: dto.y ?? current.y,
|
||||||
|
skinId: dto.skinId ?? current.skinId,
|
||||||
|
};
|
||||||
|
this.gateway.setTestLabPresence(next);
|
||||||
|
await this.record(admin, 'UPDATE', 'test_lab_actor', userId, '更新测试假人状态');
|
||||||
|
return await this.serializeActor(user, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRoomPolicy(userId: string, dto: TestLabRoomPolicyDto, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const user = await this.requireActor(userId);
|
||||||
|
await this.accountProfiles.updateAccountProfile(user.id, { settings: { room_visit_policy: dto.roomVisitPolicy } });
|
||||||
|
await this.record(admin, 'UPDATE', 'test_lab_actor', userId, '更新测试假人房间访问策略');
|
||||||
|
return await this.serializeActor(user, getTestLabPresence(user.id) || this.offlinePresence(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(userId: string, dto: TestLabMessageDto, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const actor = await this.requireOnlineActor(userId);
|
||||||
|
const content = dto.content.trim();
|
||||||
|
if (dto.scope === 'private') {
|
||||||
|
const targetId = this.requireOnlineTarget(dto.targetUserId || '');
|
||||||
|
await this.social.sendTestDirectMessage(actor.id, BigInt(targetId), content);
|
||||||
|
} else {
|
||||||
|
this.gateway.broadcastTestLabChat(getTestLabPresence(actor.id) as TestLabPresence, content, dto.scope);
|
||||||
|
}
|
||||||
|
await this.record(admin, 'CREATE', 'test_lab_message', userId, `测试假人发送${dto.scope === 'private' ? '私聊' : '公共消息'}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async socialAction(userId: string, dto: TestLabSocialActionDto, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const actor = await this.requireOnlineActor(userId);
|
||||||
|
const targetId = this.requireOnlineTarget(dto.targetUserId);
|
||||||
|
const target = BigInt(targetId);
|
||||||
|
let result: unknown;
|
||||||
|
if (dto.action === 'friend_request') result = await this.social.createTestFriendRequest(actor.id, target);
|
||||||
|
else if (dto.action === 'friend_accept') result = await this.social.respondToTestFriendRequest(actor.id, target, true);
|
||||||
|
else if (dto.action === 'friend_reject') result = await this.social.respondToTestFriendRequest(actor.id, target, false);
|
||||||
|
else result = await this.social.blockUser(actor.id, target);
|
||||||
|
await this.record(admin, 'UPDATE', 'test_lab_social', userId, `测试假人执行${dto.action}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeActor(userId: string, admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const user = await this.requireActor(userId);
|
||||||
|
this.gateway.removeTestLabActor(user.id.toString());
|
||||||
|
await this.removeAccounts([user]);
|
||||||
|
await this.record(admin, 'DELETE', 'test_lab_actor', userId, '删除测试假人');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(admin: AdminActor) {
|
||||||
|
this.assertEnabled();
|
||||||
|
const count = await this.clearTestAccounts('manual');
|
||||||
|
await this.record(admin, 'DELETE', 'test_lab_actor', undefined, `清空 ${count} 个测试假人`);
|
||||||
|
return { success: true, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTestEnvironment(): boolean {
|
||||||
|
const environment = this.config.get<string>('NODE_ENV', 'development');
|
||||||
|
return environment === 'development' || environment === 'test';
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertEnabled(): void {
|
||||||
|
if (!this.isEnabled()) throw new ForbiddenException('测试实验室仅在已启用的开发/测试环境可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listActorsInternal() {
|
||||||
|
const users = await this.users.findAll(MAX_TEST_ACTORS + 20, 0);
|
||||||
|
return Promise.all(users
|
||||||
|
.filter((user) => user.is_test_account === true)
|
||||||
|
.map((user) => this.serializeActor(user, getTestLabPresence(user.id) || this.offlinePresence(user))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireActor(userId: string): Promise<Users> {
|
||||||
|
if (!/^\d+$/.test(userId)) throw new BadRequestException('测试假人用户 ID 无效');
|
||||||
|
const user = await this.users.findOne(BigInt(userId));
|
||||||
|
if (user.is_test_account !== true) throw new ForbiddenException('目标不是测试实验室账号');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireOnlineActor(userId: string): Promise<Users> {
|
||||||
|
const actor = await this.requireActor(userId);
|
||||||
|
if (!getTestLabPresence(actor.id)?.online) throw new BadRequestException('测试假人当前不在线');
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOnlineTarget(userId: string): string {
|
||||||
|
if (!/^\d+$/.test(userId)) throw new BadRequestException('目标用户 ID 无效');
|
||||||
|
if (!this.gateway.getOnlineWorldPlayers().some((player) => player.userId === userId)) {
|
||||||
|
throw new BadRequestException('目标玩家当前不在线');
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private offlinePresence(user: Users): TestLabPresence {
|
||||||
|
return {
|
||||||
|
userId: user.id.toString(), username: user.username, nickname: user.nickname,
|
||||||
|
mapId: 'whale_port', x: 1280, y: 960, skinId: 'classic_whale', avatarId: 'default', online: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async serializeActor(user: Users, presence: TestLabPresence) {
|
||||||
|
const account = await this.accountProfiles.getAccountProfile(user.id);
|
||||||
|
const configuredPolicy = account.profile.settings?.room_visit_policy;
|
||||||
|
const roomVisitPolicy = configuredPolicy === 'friends' || configuredPolicy === 'public' || configuredPolicy === 'closed'
|
||||||
|
? configuredPolicy
|
||||||
|
: 'friends';
|
||||||
|
return {
|
||||||
|
userId: user.id.toString(), username: user.username, nickname: user.nickname,
|
||||||
|
mapId: presence.mapId, x: presence.x, y: presence.y,
|
||||||
|
skinId: presence.skinId, avatarId: presence.avatarId, online: presence.online, roomVisitPolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearTestAccounts(reason: 'startup' | 'manual'): Promise<number> {
|
||||||
|
const users = (await this.users.findAll(MAX_TEST_ACTORS + 20, 0)).filter((user) => user.is_test_account === true);
|
||||||
|
if (users.length === 0) {
|
||||||
|
if (reason === 'startup') clearTestLabPresences();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (const presence of getAllTestLabPresences()) this.gateway.removeTestLabActor(presence.userId);
|
||||||
|
await this.removeAccounts(users);
|
||||||
|
this.logger.log(`测试实验室已清理 ${users.length} 个假人账号`, { reason });
|
||||||
|
return users.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeAccounts(users: Users[]): Promise<void> {
|
||||||
|
const ids = users.map((user) => user.id);
|
||||||
|
for (const user of users) this.gateway.removeTestLabActor(user.id.toString());
|
||||||
|
await this.social.purgeTestUsers(ids);
|
||||||
|
await this.purgeDomainRows(ids);
|
||||||
|
for (const user of users) await this.users.remove(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async purgeDomainRows(ids: bigint[]): Promise<void> {
|
||||||
|
if (!this.dataSource || ids.length === 0) return;
|
||||||
|
const values = ids.map((id) => id.toString());
|
||||||
|
const placeholders = values.map(() => '?').join(', ');
|
||||||
|
const runner = this.dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
for (const table of ['room_decor_placements', 'user_assets', 'wallet_transactions', 'user_wallets', 'player_task_progress', 'user_profiles']) {
|
||||||
|
try {
|
||||||
|
await runner.query(`DELETE FROM \`${table}\` WHERE \`user_id\` IN (${placeholders})`, values);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`测试实验室清理跳过不可用表 ${table}`, { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async record(admin: AdminActor, operation: 'CREATE' | 'UPDATE' | 'DELETE', targetType: string, targetId: string | undefined, description: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.audit.createLog({
|
||||||
|
adminUserId: admin.adminId,
|
||||||
|
adminUsername: admin.username,
|
||||||
|
operationType: operation,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
operationDescription: description,
|
||||||
|
httpMethodPath: '/admin/test-lab',
|
||||||
|
operationResult: 'SUCCESS',
|
||||||
|
durationMs: 0,
|
||||||
|
requestId: randomUUID(),
|
||||||
|
context: { testLab: true },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('测试实验室操作日志写入失败', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/business/admin/test_lab_presence.registry.ts
Normal file
46
src/business/admin/test_lab_presence.registry.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type TestLabPresence = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
mapId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
skinId: string;
|
||||||
|
avatarId: string;
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const presences = new Map<string, TestLabPresence>();
|
||||||
|
|
||||||
|
export function upsertTestLabPresence(presence: TestLabPresence): TestLabPresence {
|
||||||
|
const normalized = { ...presence, userId: String(presence.userId), online: Boolean(presence.online) };
|
||||||
|
presences.set(normalized.userId, normalized);
|
||||||
|
return { ...normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestLabPresence(userId: string | bigint): TestLabPresence | null {
|
||||||
|
const presence = presences.get(String(userId));
|
||||||
|
return presence ? { ...presence } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestLabPresences(mapId?: string): TestLabPresence[] {
|
||||||
|
return [...presences.values()]
|
||||||
|
.filter((presence) => presence.online && (!mapId || presence.mapId === mapId))
|
||||||
|
.map((presence) => ({ ...presence }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllTestLabPresences(): TestLabPresence[] {
|
||||||
|
return [...presences.values()].map((presence) => ({ ...presence }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTestLabPresence(userId: string | bigint): TestLabPresence | null {
|
||||||
|
const existing = presences.get(String(userId));
|
||||||
|
presences.delete(String(userId));
|
||||||
|
return existing ? { ...existing } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTestLabPresences(): TestLabPresence[] {
|
||||||
|
const existing = getTestLabPresences();
|
||||||
|
presences.clear();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ export interface AccountProfilePayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountSettings = Record<string, boolean | number>;
|
export type RoomVisitPolicy = 'friends' | 'public' | 'closed';
|
||||||
|
export type AccountSettings = Record<string, boolean | number | RoomVisitPolicy>;
|
||||||
|
|
||||||
export interface UpdateAccountProfileRequest {
|
export interface UpdateAccountProfileRequest {
|
||||||
skin_id?: string;
|
skin_id?: string;
|
||||||
@@ -92,6 +93,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
|
|||||||
ui_scale: 1.00,
|
ui_scale: 1.00,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
show_interaction_hints: true,
|
show_interaction_hints: true,
|
||||||
|
show_interaction_points: false,
|
||||||
show_name_always: false,
|
show_name_always: false,
|
||||||
show_chat_bubbles: true,
|
show_chat_bubbles: true,
|
||||||
world_notifications: true,
|
world_notifications: true,
|
||||||
@@ -99,12 +101,15 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
|
|||||||
friend_request_notifications: true,
|
friend_request_notifications: true,
|
||||||
allow_nearby_private: true,
|
allow_nearby_private: true,
|
||||||
allow_nearby_friend_requests: true,
|
allow_nearby_friend_requests: true,
|
||||||
|
allow_nearby_profile: true,
|
||||||
|
room_visit_policy: 'friends',
|
||||||
mute_ui_sfx: false,
|
mute_ui_sfx: false,
|
||||||
};
|
};
|
||||||
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']);
|
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']);
|
||||||
const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
|
const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
|
||||||
'fullscreen',
|
'fullscreen',
|
||||||
'show_interaction_hints',
|
'show_interaction_hints',
|
||||||
|
'show_interaction_points',
|
||||||
'show_name_always',
|
'show_name_always',
|
||||||
'show_chat_bubbles',
|
'show_chat_bubbles',
|
||||||
'world_notifications',
|
'world_notifications',
|
||||||
@@ -112,8 +117,10 @@ const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
|
|||||||
'friend_request_notifications',
|
'friend_request_notifications',
|
||||||
'allow_nearby_private',
|
'allow_nearby_private',
|
||||||
'allow_nearby_friend_requests',
|
'allow_nearby_friend_requests',
|
||||||
|
'allow_nearby_profile',
|
||||||
'mute_ui_sfx',
|
'mute_ui_sfx',
|
||||||
]);
|
]);
|
||||||
|
const ROOM_VISIT_POLICIES = new Set<RoomVisitPolicy>(['friends', 'public', 'closed']);
|
||||||
|
|
||||||
export interface AccountSkinAsset {
|
export interface AccountSkinAsset {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -246,7 +253,7 @@ export class AccountProfileService {
|
|||||||
tags: {
|
tags: {
|
||||||
[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: true,
|
[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: true,
|
||||||
},
|
},
|
||||||
current_map: 'plaza',
|
current_map: 'whale_port',
|
||||||
pos_x: 0,
|
pos_x: 0,
|
||||||
pos_y: 0,
|
pos_y: 0,
|
||||||
status: 0,
|
status: 0,
|
||||||
@@ -665,6 +672,8 @@ export class AccountProfileService {
|
|||||||
}
|
}
|
||||||
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
|
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
|
||||||
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
|
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
|
||||||
|
} else if (key === 'room_visit_policy' && typeof value === 'string' && ROOM_VISIT_POLICIES.has(value as RoomVisitPolicy)) {
|
||||||
|
sanitized[key] = value as RoomVisitPolicy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { LoginCoreService } from '../../core/login_core/login_core.service';
|
|||||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||||
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
||||||
import { AccountProfileService } from '../auth/account_profile.service';
|
import { AccountProfileService } from '../auth/account_profile.service';
|
||||||
|
import { TaskService } from '../tasks/task.service';
|
||||||
|
|
||||||
// ========== 接口定义 ==========
|
// ========== 接口定义 ==========
|
||||||
|
|
||||||
@@ -262,6 +263,7 @@ export class ChatService {
|
|||||||
@Inject('ZulipAccountsService')
|
@Inject('ZulipAccountsService')
|
||||||
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||||
private readonly accountProfileService: AccountProfileService,
|
private readonly accountProfileService: AccountProfileService,
|
||||||
|
private readonly taskService: TaskService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('ChatService初始化完成');
|
this.logger.log('ChatService初始化完成');
|
||||||
}
|
}
|
||||||
@@ -445,6 +447,11 @@ export class ChatService {
|
|||||||
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedScope === 'global') {
|
||||||
|
await this.taskService.recordActivity(BigInt(session.userId), 'public_message_sent')
|
||||||
|
.catch((error: unknown) => this.logger.warn('记录公共聊天任务失败', { error: error instanceof Error ? error.message : String(error) }));
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log('聊天消息发送完成', {
|
this.logger.log('聊天消息发送完成', {
|
||||||
operation: 'sendChatMessage',
|
operation: 'sendChatMessage',
|
||||||
messageId,
|
messageId,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
|
import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
|
||||||
import { InventoryService } from '../player/inventory.service';
|
import { InventoryService } from '../player/inventory.service';
|
||||||
import { EconomyService } from '../player/economy.service';
|
import { EconomyService } from '../player/economy.service';
|
||||||
import { PlayerStateService } from '../player/player_state.service';
|
import { PlayerStateService } from '../player/player_state.service';
|
||||||
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
||||||
|
import { TaskService } from '../tasks/task.service';
|
||||||
|
|
||||||
interface IUserWalletsService {
|
interface IUserWalletsService {
|
||||||
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
||||||
@@ -51,11 +52,14 @@ export interface MallCatalogPayload {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MallService {
|
export class MallService {
|
||||||
|
private readonly logger = new Logger(MallService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
private readonly economyService: EconomyService,
|
private readonly economyService: EconomyService,
|
||||||
private readonly playerStateService: PlayerStateService,
|
private readonly playerStateService: PlayerStateService,
|
||||||
|
private readonly taskService: TaskService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getWallet(userId: bigint) {
|
async getWallet(userId: bigint) {
|
||||||
@@ -123,6 +127,10 @@ export class MallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
|
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
|
||||||
|
if (!alreadyOwned) {
|
||||||
|
await this.taskService.recordActivity(userId, 'skin_purchased', item.itemId)
|
||||||
|
.catch((error: unknown) => this.logger.warn(`记录首次皮肤任务失败: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
|
}
|
||||||
const [inventory, snapshot] = await Promise.all([
|
const [inventory, snapshot] = await Promise.all([
|
||||||
this.inventoryService.listInventory(userId),
|
this.inventoryService.listInventory(userId),
|
||||||
this.playerStateService.getSnapshot(userId),
|
this.playerStateService.getSnapshot(userId),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ROOM_DECOR_DEFINITIONS } from '../room_decor/room_decor_catalog';
|
||||||
|
|
||||||
export type MallItemType = 'skin' | 'room_decor';
|
export type MallItemType = 'skin' | 'room_decor';
|
||||||
|
|
||||||
export interface MallCatalogItem {
|
export interface MallCatalogItem {
|
||||||
@@ -23,6 +25,19 @@ export const MALL_CATEGORIES = [
|
|||||||
{ id: 'limited', label: '限时', icon: 'limited' },
|
{ id: 'limited', label: '限时', icon: 'limited' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ROOM_DECOR_MALL_ITEMS: MallCatalogItem[] = ROOM_DECOR_DEFINITIONS.map((definition) => ({
|
||||||
|
itemId: definition.item_id,
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: definition.decor_id,
|
||||||
|
icon: definition.icon,
|
||||||
|
name: definition.name,
|
||||||
|
category: definition.shop.category,
|
||||||
|
description: definition.shop.description,
|
||||||
|
price: definition.shop.price,
|
||||||
|
tags: definition.shop.tags,
|
||||||
|
sortOrder: definition.shop.sort_order,
|
||||||
|
}));
|
||||||
|
|
||||||
export const MALL_ITEMS: MallCatalogItem[] = [
|
export const MALL_ITEMS: MallCatalogItem[] = [
|
||||||
{
|
{
|
||||||
itemId: 'skin_classic_whale',
|
itemId: 'skin_classic_whale',
|
||||||
@@ -81,126 +96,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
tags: ['可预览', '永久', '皮肤'],
|
tags: ['可预览', '永久', '皮肤'],
|
||||||
sortOrder: 50,
|
sortOrder: 50,
|
||||||
},
|
},
|
||||||
{
|
...ROOM_DECOR_MALL_ITEMS,
|
||||||
itemId: 'decor_whale_floor_rug',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'whale_floor_rug',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
|
||||||
name: '鲸浪地毯',
|
|
||||||
category: 'space',
|
|
||||||
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
|
||||||
price: 260,
|
|
||||||
tags: ['房间家具', '可拖拽', '地面'],
|
|
||||||
sortOrder: 110,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_whale_memory_board',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'whale_memory_board',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
|
||||||
name: '鲸语记忆板',
|
|
||||||
category: 'space',
|
|
||||||
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
|
||||||
price: 220,
|
|
||||||
tags: ['房间家具', '可拖拽', '挂件'],
|
|
||||||
sortOrder: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_whale_tail_lamp',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'whale_tail_lamp',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
|
||||||
name: '鲸尾暖灯',
|
|
||||||
category: 'space',
|
|
||||||
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
|
||||||
price: 360,
|
|
||||||
tags: ['房间家具', '可拖拽', '灯具'],
|
|
||||||
sortOrder: 130,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_boat_cabin_bed',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'boat_cabin_bed',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
|
||||||
name: '船舱小床',
|
|
||||||
category: 'space',
|
|
||||||
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
|
||||||
price: 520,
|
|
||||||
tags: ['房间家具', '可拖拽', '床'],
|
|
||||||
sortOrder: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_low_wave_bed',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'low_wave_bed',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
|
||||||
name: '海浪低床',
|
|
||||||
category: 'space',
|
|
||||||
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
|
||||||
price: 500,
|
|
||||||
tags: ['房间家具', '可拖拽', '床'],
|
|
||||||
sortOrder: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_whale_tail_headboard_bed',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'whale_tail_headboard_bed',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
|
||||||
name: '鲸尾床头床',
|
|
||||||
category: 'space',
|
|
||||||
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
|
||||||
price: 580,
|
|
||||||
tags: ['房间家具', '可拖拽', '床'],
|
|
||||||
sortOrder: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_dev_whale_bookshelf',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'dev_whale_bookshelf',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
|
||||||
name: '程序员鲸书架',
|
|
||||||
category: 'space',
|
|
||||||
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
|
||||||
price: 620,
|
|
||||||
tags: ['房间家具', '可拖拽', '书架'],
|
|
||||||
sortOrder: 190,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_datawhale_bug_feature_badge',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'datawhale_bug_feature_badge',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
|
||||||
name: 'BUG特性徽章',
|
|
||||||
category: 'space',
|
|
||||||
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
|
||||||
price: 120,
|
|
||||||
tags: ['房间家具', '可拖拽', '徽章'],
|
|
||||||
sortOrder: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_datawhale_buddhist_learning_badge',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'datawhale_buddhist_learning_badge',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
|
||||||
name: '佛系学习徽章',
|
|
||||||
category: 'space',
|
|
||||||
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
|
||||||
price: 140,
|
|
||||||
tags: ['房间家具', '可拖拽', '徽章'],
|
|
||||||
sortOrder: 210,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemId: 'decor_datawhale_ok_working_badge',
|
|
||||||
itemType: 'room_decor',
|
|
||||||
decorId: 'datawhale_ok_working_badge',
|
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
|
||||||
name: '已经在做徽章',
|
|
||||||
category: 'space',
|
|
||||||
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
|
||||||
price: 120,
|
|
||||||
tags: ['房间家具', '可拖拽', '徽章'],
|
|
||||||
sortOrder: 220,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);
|
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { EconomyService } from './economy.service';
|
|||||||
import { UpdatePlayerAppearanceDto } from './dto/update_player_appearance.dto';
|
import { UpdatePlayerAppearanceDto } from './dto/update_player_appearance.dto';
|
||||||
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
||||||
import { UpdatePlayerSettingsDto } from './dto/update_player_settings.dto';
|
import { UpdatePlayerSettingsDto } from './dto/update_player_settings.dto';
|
||||||
|
import { SocialService } from '../social/social.service';
|
||||||
|
import { UpdateSocialProfileDto } from '../social/dto/social.dto';
|
||||||
|
|
||||||
@ApiTags('player')
|
@ApiTags('player')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -18,6 +20,7 @@ export class PlayerController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly playerStateService: PlayerStateService,
|
private readonly playerStateService: PlayerStateService,
|
||||||
private readonly economyService: EconomyService,
|
private readonly economyService: EconomyService,
|
||||||
|
private readonly socialService: SocialService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: '获取当前玩家快照' })
|
@ApiOperation({ summary: '获取当前玩家快照' })
|
||||||
@@ -77,4 +80,16 @@ export class PlayerController {
|
|||||||
const data = await this.playerStateService.updateProfileAssets(BigInt(user.sub), dto);
|
const data = await this.playerStateService.updateProfileAssets(BigInt(user.sub), dto);
|
||||||
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家资源更新成功' });
|
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家资源更新成功' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: '更新当前玩家社区名片' })
|
||||||
|
@Patch('social-profile')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
async updateSocialProfile(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: UpdateSocialProfileDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.socialService.updateSocialProfile(BigInt(user.sub), dto);
|
||||||
|
res.status(HttpStatus.OK).json({ success: true, data, message: '社区名片已更新' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ export interface PlayerSnapshotPayload {
|
|||||||
owned_skin_ids: string[];
|
owned_skin_ids: string[];
|
||||||
owned_skins: unknown[];
|
owned_skins: unknown[];
|
||||||
};
|
};
|
||||||
settings: Record<string, boolean | number>;
|
settings: Record<string, boolean | number | string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Controller, Get, Header, Param, Query, Res } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
|
||||||
import { RankingsService } from './rankings.service';
|
import { RankingsService } from './rankings.service';
|
||||||
import { RankingCategoryId } from './rankings.types';
|
import { RankingCategoryId } from './rankings.types';
|
||||||
|
|
||||||
@@ -9,17 +8,6 @@ import { RankingCategoryId } from './rankings.types';
|
|||||||
export class RankingsController {
|
export class RankingsController {
|
||||||
constructor(private readonly rankingsService: RankingsService) {}
|
constructor(private readonly rankingsService: RankingsService) {}
|
||||||
|
|
||||||
@Get('datawhale-honor/avatar/:memberId')
|
|
||||||
@Header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800')
|
|
||||||
@ApiOperation({ summary: '代理 Datawhale 荣誉榜成员头像' })
|
|
||||||
async getDatawhaleMemberAvatar(
|
|
||||||
@Param('memberId') memberId: string,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const avatar = await this.rankingsService.getMemberAvatar(memberId);
|
|
||||||
res.type(avatar.contentType).send(avatar.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('datawhale-honor')
|
@Get('datawhale-honor')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '获取Datawhale荣誉榜',
|
summary: '获取Datawhale荣誉榜',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadGatewayException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
|
import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
@@ -17,12 +17,6 @@ const DATAWHALE_WEEKLY_COMMITS_URL = 'https://mv.datawhale.cc/data/commits_weekl
|
|||||||
const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/';
|
const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/';
|
||||||
const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits';
|
const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits';
|
||||||
const DEFAULT_LIMIT = 10;
|
const DEFAULT_LIMIT = 10;
|
||||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
export interface RankingAvatarPayload {
|
|
||||||
body: Buffer;
|
|
||||||
contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORIES: RankingCategory[] = [
|
const CATEGORIES: RankingCategory[] = [
|
||||||
{
|
{
|
||||||
@@ -121,40 +115,6 @@ export class RankingsService implements OnModuleInit {
|
|||||||
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
|
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMemberAvatar(memberId: string): Promise<RankingAvatarPayload> {
|
|
||||||
const normalizedId = this.cleanString(memberId);
|
|
||||||
const member = this.members.find(item => this.cleanString(item.id) === normalizedId);
|
|
||||||
if (!member) {
|
|
||||||
throw new NotFoundException('榜单成员不存在');
|
|
||||||
}
|
|
||||||
const avatarUrl = this.sourceAvatarUrl(member);
|
|
||||||
if (!avatarUrl) {
|
|
||||||
throw new NotFoundException('榜单成员没有头像');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await axios.get<ArrayBuffer>(avatarUrl, {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 10000,
|
|
||||||
maxContentLength: MAX_AVATAR_BYTES,
|
|
||||||
maxBodyLength: MAX_AVATAR_BYTES,
|
|
||||||
});
|
|
||||||
const body = Buffer.from(response.data);
|
|
||||||
if (body.length === 0 || body.length > MAX_AVATAR_BYTES) {
|
|
||||||
throw new BadGatewayException('头像源返回的文件大小异常');
|
|
||||||
}
|
|
||||||
const contentType = this.detectImageContentType(body);
|
|
||||||
if (!contentType) {
|
|
||||||
throw new BadGatewayException('头像源返回了不支持的文件类型');
|
|
||||||
}
|
|
||||||
return { body, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadGatewayException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadGatewayException(`头像源请求失败:${this.errorMessage(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCachedPayload(
|
private getCachedPayload(
|
||||||
category: RankingCategoryId,
|
category: RankingCategoryId,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -406,13 +366,6 @@ export class RankingsService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private avatarUrl(member: DatawhaleMemberRow): string {
|
private avatarUrl(member: DatawhaleMemberRow): string {
|
||||||
const id = this.cleanString(member.id);
|
|
||||||
return id && this.sourceAvatarUrl(member)
|
|
||||||
? `/api/rankings/datawhale-honor/avatar/${encodeURIComponent(id)}`
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private sourceAvatarUrl(member: DatawhaleMemberRow): string {
|
|
||||||
const avatar = this.cleanString(member.avatar);
|
const avatar = this.cleanString(member.avatar);
|
||||||
if (!avatar) {
|
if (!avatar) {
|
||||||
return '';
|
return '';
|
||||||
@@ -470,19 +423,6 @@ export class RankingsService implements OnModuleInit {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectImageContentType(body: Buffer): string {
|
|
||||||
if (body.length >= 8 && body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
||||||
return 'image/png';
|
|
||||||
}
|
|
||||||
if (body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff) {
|
|
||||||
return 'image/jpeg';
|
|
||||||
}
|
|
||||||
if (body.length >= 12 && body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') {
|
|
||||||
return 'image/webp';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanString(value: unknown): string {
|
private cleanString(value: unknown): string {
|
||||||
return String(value ?? '').trim();
|
return String(value ?? '').trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsInt, IsString, Length, Matches, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetRoomDecorPlacementsDto {
|
||||||
|
@IsInt({ message: '布局版本必须是整数' })
|
||||||
|
@Min(0, { message: '布局版本不能小于0' })
|
||||||
|
layout_revision!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'mutation版本必须是整数' })
|
||||||
|
@Min(1, { message: 'mutation版本必须大于0' })
|
||||||
|
mutation_revision!: number;
|
||||||
|
|
||||||
|
@IsString({ message: 'mutation ID必须是字符串' })
|
||||||
|
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
|
||||||
|
mutation_id!: string;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { IsBoolean, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class SaveRoomDecorPlacementDto {
|
export class SaveRoomDecorPlacementDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString({ message: '摆件ID必须是字符串' })
|
@IsString({ message: '摆件ID必须是字符串' })
|
||||||
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
|
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
|
||||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
|
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
|
||||||
decor_id!: string;
|
decor_id?: string;
|
||||||
|
|
||||||
@IsBoolean({ message: '摆放状态必须是布尔值' })
|
@IsBoolean({ message: '摆放状态必须是布尔值' })
|
||||||
placed!: boolean;
|
placed!: boolean;
|
||||||
@@ -27,9 +28,29 @@ export class SaveRoomDecorPlacementDto {
|
|||||||
@Max(4, { message: '缩放不能太大' })
|
@Max(4, { message: '缩放不能太大' })
|
||||||
scale?: number;
|
scale?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({}, { message: '旋转角度必须是数字' })
|
||||||
|
@IsInt({ message: '旋转角度必须是整数' })
|
||||||
|
@Min(0, { message: '旋转角度超出范围' })
|
||||||
|
@Max(270, { message: '旋转角度超出范围' })
|
||||||
|
rotation_degrees?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber({}, { message: '层级必须是数字' })
|
@IsNumber({}, { message: '层级必须是数字' })
|
||||||
@Min(-1000, { message: '层级超出范围' })
|
@Min(-1000, { message: '层级超出范围' })
|
||||||
@Max(1000, { message: '层级超出范围' })
|
@Max(1000, { message: '层级超出范围' })
|
||||||
z_index?: number;
|
z_index?: number;
|
||||||
|
|
||||||
|
@IsInt({ message: '布局版本必须是整数' })
|
||||||
|
@Min(0, { message: '布局版本不能小于0' })
|
||||||
|
layout_revision!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'mutation版本必须是整数' })
|
||||||
|
@Min(1, { message: 'mutation版本必须大于0' })
|
||||||
|
mutation_revision!: number;
|
||||||
|
|
||||||
|
@IsString({ message: 'mutation ID必须是字符串' })
|
||||||
|
@Length(16, 64, { message: 'mutation ID长度需在16-64字符之间' })
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'mutation ID格式不正确' })
|
||||||
|
mutation_id!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Body, Controller, Get, HttpStatus, Param, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Post, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
|
||||||
import { RoomDecorService } from './room_decor.service';
|
import { RoomDecorService } from './room_decor.service';
|
||||||
|
|
||||||
@ApiTags('room-decor')
|
@ApiTags('room-decor')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('rooms/me/decor-placements')
|
@Controller('rooms')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class RoomDecorController {
|
export class RoomDecorController {
|
||||||
constructor(private readonly roomDecorService: RoomDecorService) {}
|
constructor(private readonly roomDecorService: RoomDecorService) {}
|
||||||
@@ -22,7 +23,7 @@ export class RoomDecorController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: '房间家具背包获取成功',
|
description: '房间家具背包获取成功',
|
||||||
})
|
})
|
||||||
@Get()
|
@Get('me/decor-placements')
|
||||||
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||||
const data = await this.roomDecorService.getInventory(BigInt(user.sub));
|
const data = await this.roomDecorService.getInventory(BigInt(user.sub));
|
||||||
res.status(HttpStatus.OK).json({
|
res.status(HttpStatus.OK).json({
|
||||||
@@ -32,6 +33,45 @@ export class RoomDecorController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '参观其他玩家的房间布局',
|
||||||
|
description: '按房主访问策略返回只读布局;拉黑关系始终拒绝访问。',
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({ status: 200, description: '只读房间布局获取成功' })
|
||||||
|
@Get(':ownerUserId/decor-placements')
|
||||||
|
async getRoomView(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('ownerUserId') ownerUserId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.roomDecorService.getRoomView(BigInt(user.sub), this.parseUserId(ownerUserId));
|
||||||
|
res.status(HttpStatus.OK).json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '房间布局获取成功',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '收回当前账号所有已摆放家具',
|
||||||
|
description: '仅清空布局,不删除已拥有家具资产。',
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({ status: 200, description: '房间布局已重置' })
|
||||||
|
@Post('me/decor-placements/reset')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
async resetPlacements(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ResetRoomDecorPlacementsDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub), dto);
|
||||||
|
res.status(HttpStatus.OK).json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
message: '房间布局已重置,家具已收回背包',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '保存房间家具摆放',
|
summary: '保存房间家具摆放',
|
||||||
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
|
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
|
||||||
@@ -40,7 +80,7 @@ export class RoomDecorController {
|
|||||||
status: 200,
|
status: 200,
|
||||||
description: '家具摆放保存成功',
|
description: '家具摆放保存成功',
|
||||||
})
|
})
|
||||||
@Put(':decorId')
|
@Put('me/decor-placements/:decorId')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async savePlacement(
|
async savePlacement(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@@ -58,4 +98,11 @@ export class RoomDecorController {
|
|||||||
message: '家具摆放保存成功',
|
message: '家具摆放保存成功',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseUserId(value: string): bigint {
|
||||||
|
if (!/^\d+$/.test(value)) {
|
||||||
|
throw new BadRequestException('房主用户ID格式不正确');
|
||||||
|
}
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { InventoryService } from '../player/inventory.service';
|
import { InventoryService } from '../player/inventory.service';
|
||||||
|
import { SocialService } from '../social/social.service';
|
||||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from './dto/reset_room_decor_placements.dto';
|
||||||
import {
|
import {
|
||||||
ROOM_DECOR_BED_DEFAULT_SCALE,
|
ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||||
ROOM_DECOR_DEFINITIONS,
|
ROOM_DECOR_DEFINITIONS,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
ROOM_DECOR_LEGACY_BED_MAX_SCALE,
|
ROOM_DECOR_LEGACY_BED_MAX_SCALE,
|
||||||
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
|
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
|
||||||
ROOM_DECOR_ROOM_SCALE,
|
ROOM_DECOR_ROOM_SCALE,
|
||||||
|
RoomDecorDefinition,
|
||||||
findRoomDecorDefinition,
|
findRoomDecorDefinition,
|
||||||
} from './room_decor_catalog';
|
} from './room_decor_catalog';
|
||||||
|
|
||||||
@@ -17,18 +20,22 @@ interface UserRoomDecorRow {
|
|||||||
position_x: number | null;
|
position_x: number | null;
|
||||||
position_y: number | null;
|
position_y: number | null;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
rotation_degrees?: number;
|
||||||
z_index: number;
|
z_index: number;
|
||||||
|
mutation_revision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IRoomDecorPlacementsService {
|
interface IRoomDecorPlacementsService {
|
||||||
listPlacements(userId: bigint): Promise<UserRoomDecorRow[]>;
|
getSnapshot(userId: bigint): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
|
||||||
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<UserRoomDecorRow>;
|
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<{ revision: number; placement: UserRoomDecorRow }>;
|
||||||
|
resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto): Promise<{ revision: number; placements: UserRoomDecorRow[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomDecorPayloadPlacement {
|
interface RoomDecorPayloadPlacement {
|
||||||
position_x: number | null;
|
position_x: number | null;
|
||||||
position_y: number | null;
|
position_y: number | null;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
rotation_degrees: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -36,13 +43,15 @@ export class RoomDecorService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
|
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
|
private readonly socialService: SocialService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getInventory(userId: bigint) {
|
async getInventory(userId: bigint) {
|
||||||
const [inventory, placements] = await Promise.all([
|
const [inventory, snapshot] = await Promise.all([
|
||||||
this.inventoryService.listInventory(userId, 'room_decor'),
|
this.inventoryService.listInventory(userId, 'room_decor'),
|
||||||
this.roomDecorPlacementsService.listPlacements(userId),
|
this.roomDecorPlacementsService.getSnapshot(userId),
|
||||||
]);
|
]);
|
||||||
|
const placements = snapshot.placements;
|
||||||
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
|
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
|
||||||
const rows = inventory.room_decor_ids.map((decorId) => {
|
const rows = inventory.room_decor_ids.map((decorId) => {
|
||||||
const definition = findRoomDecorDefinition(decorId);
|
const definition = findRoomDecorDefinition(decorId);
|
||||||
@@ -53,6 +62,7 @@ export class RoomDecorService {
|
|||||||
position_x: definition?.default_position.x ?? null,
|
position_x: definition?.default_position.x ?? null,
|
||||||
position_y: definition?.default_position.y ?? null,
|
position_y: definition?.default_position.y ?? null,
|
||||||
scale: definition?.default_scale ?? 1,
|
scale: definition?.default_scale ?? 1,
|
||||||
|
rotation_degrees: definition?.default_rotation_degrees ?? 0,
|
||||||
z_index: definition?.default_z_index ?? 0,
|
z_index: definition?.default_z_index ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -61,23 +71,67 @@ export class RoomDecorService {
|
|||||||
.filter((row) => findRoomDecorDefinition(row.decor_id))
|
.filter((row) => findRoomDecorDefinition(row.decor_id))
|
||||||
.map((row) => this.toPayload(row)),
|
.map((row) => this.toPayload(row)),
|
||||||
definitions: ROOM_DECOR_DEFINITIONS,
|
definitions: ROOM_DECOR_DEFINITIONS,
|
||||||
|
revision: snapshot.revision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
|
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
|
||||||
const definition = findRoomDecorDefinition(placement.decor_id);
|
const decorId = placement.decor_id?.trim() || '';
|
||||||
|
const definition = findRoomDecorDefinition(decorId);
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new BadRequestException('摆件不存在或暂未开放');
|
throw new BadRequestException('摆件不存在或暂未开放');
|
||||||
}
|
}
|
||||||
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', placement.decor_id))) {
|
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', decorId))) {
|
||||||
throw new BadRequestException('尚未拥有该房间摆件');
|
throw new BadRequestException('尚未拥有该房间摆件');
|
||||||
}
|
}
|
||||||
const row = await this.roomDecorPlacementsService.savePlacement(userId, {
|
const rotationDegrees = placement.rotation_degrees ?? definition.default_rotation_degrees;
|
||||||
|
if (!definition.rotation_steps.includes(rotationDegrees)) {
|
||||||
|
throw new BadRequestException('该摆件不支持此旋转角度');
|
||||||
|
}
|
||||||
|
const positionX = this.snapToGrid(placement.position_x ?? definition.default_position.x, definition.grid_size);
|
||||||
|
const positionY = this.snapToGrid(placement.position_y ?? definition.default_position.y, definition.grid_size);
|
||||||
|
if (placement.placed && !this.isWithinPlacementBounds(positionX, positionY, definition)) {
|
||||||
|
throw new BadRequestException(definition.placement_surface === 'wall'
|
||||||
|
? '墙面挂饰只能摆放在墙面网格内'
|
||||||
|
: '地面家具只能摆放在地面网格内');
|
||||||
|
}
|
||||||
|
const result = await this.roomDecorPlacementsService.savePlacement(userId, {
|
||||||
...placement,
|
...placement,
|
||||||
|
decor_id: decorId,
|
||||||
|
position_x: positionX,
|
||||||
|
position_y: positionY,
|
||||||
scale: placement.scale ?? definition.default_scale,
|
scale: placement.scale ?? definition.default_scale,
|
||||||
|
rotation_degrees: rotationDegrees,
|
||||||
z_index: placement.z_index ?? definition.default_z_index,
|
z_index: placement.z_index ?? definition.default_z_index,
|
||||||
});
|
});
|
||||||
return this.toPayload(row);
|
return {
|
||||||
|
...this.toPayload(result.placement),
|
||||||
|
layout_revision: result.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoomView(viewerId: bigint, ownerId: bigint) {
|
||||||
|
const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId);
|
||||||
|
const [inventory, snapshot] = await Promise.all([
|
||||||
|
this.inventoryService.listInventory(ownerId, 'room_decor'),
|
||||||
|
this.roomDecorPlacementsService.getSnapshot(ownerId),
|
||||||
|
]);
|
||||||
|
const placements = snapshot.placements;
|
||||||
|
const ownedDecorIds = new Set(inventory.room_decor_ids);
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
read_only: true,
|
||||||
|
items: placements
|
||||||
|
.filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id)))
|
||||||
|
.map((row) => this.toPayload(row)),
|
||||||
|
definitions: ROOM_DECOR_DEFINITIONS,
|
||||||
|
revision: snapshot.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPlacements(userId: bigint, mutation: ResetRoomDecorPlacementsDto) {
|
||||||
|
await this.roomDecorPlacementsService.resetPlacements(userId, mutation);
|
||||||
|
return this.getInventory(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPayload(row: UserRoomDecorRow) {
|
private toPayload(row: UserRoomDecorRow) {
|
||||||
@@ -93,26 +147,36 @@ export class RoomDecorService {
|
|||||||
position_x: placement.position_x,
|
position_x: placement.position_x,
|
||||||
position_y: placement.position_y,
|
position_y: placement.position_y,
|
||||||
scale: placement.scale,
|
scale: placement.scale,
|
||||||
|
rotation_degrees: placement.rotation_degrees,
|
||||||
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
||||||
|
mutation_revision: row.mutation_revision ?? 0,
|
||||||
|
category: definition?.category ?? 'floor',
|
||||||
|
placement_surface: definition?.placement_surface ?? 'floor',
|
||||||
|
placement_bounds: definition?.placement_bounds,
|
||||||
|
grid_size: definition?.grid_size ?? 16,
|
||||||
|
rotation_steps: definition?.rotation_steps ?? [0],
|
||||||
|
stackable: definition?.stackable ?? false,
|
||||||
default_position: definition?.default_position ?? { x: 0, y: 0 },
|
default_position: definition?.default_position ?? { x: 0, y: 0 },
|
||||||
default_scale: definition?.default_scale ?? 1,
|
default_scale: definition?.default_scale ?? 1,
|
||||||
|
default_rotation_degrees: definition?.default_rotation_degrees ?? 0,
|
||||||
default_z_index: definition?.default_z_index ?? 0,
|
default_z_index: definition?.default_z_index ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizedPlacement(
|
private normalizedPlacement(
|
||||||
row: UserRoomDecorRow,
|
row: UserRoomDecorRow,
|
||||||
definition?: { default_scale: number; default_position: { x: number; y: number } },
|
definition?: RoomDecorDefinition,
|
||||||
): RoomDecorPayloadPlacement {
|
): RoomDecorPayloadPlacement {
|
||||||
const usesLegacyPlacement = this.usesLegacyPlacement(row);
|
const usesLegacyPlacement = this.usesLegacyPlacement(row);
|
||||||
return {
|
return {
|
||||||
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
|
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
|
||||||
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
|
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
|
||||||
scale: this.normalizedScale(row, definition),
|
scale: this.normalizedScale(row, definition),
|
||||||
|
rotation_degrees: this.normalizedRotation(row, definition),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizedScale(row: UserRoomDecorRow, definition?: { default_scale: number }) {
|
private normalizedScale(row: UserRoomDecorRow, definition?: Pick<RoomDecorDefinition, 'default_scale'>) {
|
||||||
const scale = row.scale ?? definition?.default_scale ?? 1;
|
const scale = row.scale ?? definition?.default_scale ?? 1;
|
||||||
if (!row.placed && definition) {
|
if (!row.placed && definition) {
|
||||||
return definition.default_scale;
|
return definition.default_scale;
|
||||||
@@ -146,6 +210,20 @@ export class RoomDecorService {
|
|||||||
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
|
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizedRotation(row: UserRoomDecorRow, definition?: RoomDecorDefinition): number {
|
||||||
|
const rotation = row.rotation_degrees ?? definition?.default_rotation_degrees ?? 0;
|
||||||
|
return definition?.rotation_steps.includes(rotation) ? rotation : definition?.default_rotation_degrees ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapToGrid(value: number, gridSize: number): number {
|
||||||
|
return Math.round(value / gridSize) * gridSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWithinPlacementBounds(x: number, y: number, definition: RoomDecorDefinition): boolean {
|
||||||
|
const bounds = definition.placement_bounds;
|
||||||
|
return x >= bounds.min_x && x <= bounds.max_x && y >= bounds.min_y && y <= bounds.max_y;
|
||||||
|
}
|
||||||
|
|
||||||
private usesLegacyPlacement(row: UserRoomDecorRow) {
|
private usesLegacyPlacement(row: UserRoomDecorRow) {
|
||||||
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
|
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
|
||||||
if (!legacy) {
|
if (!legacy) {
|
||||||
|
|||||||
@@ -2,14 +2,28 @@ export interface RoomDecorDefinition {
|
|||||||
decor_id: string;
|
decor_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
item_id: string;
|
item_id: string;
|
||||||
|
category: 'floor' | 'rug' | 'wall';
|
||||||
|
placement_surface: 'floor' | 'wall';
|
||||||
|
placement_bounds: RoomDecorPlacementBounds;
|
||||||
|
grid_size: number;
|
||||||
|
rotation_steps: number[];
|
||||||
|
stackable: boolean;
|
||||||
icon: string;
|
icon: string;
|
||||||
texture?: string;
|
texture?: string;
|
||||||
default_scale: number;
|
default_scale: number;
|
||||||
|
default_rotation_degrees: number;
|
||||||
default_position: {
|
default_position: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
default_z_index: number;
|
default_z_index: number;
|
||||||
|
shop: {
|
||||||
|
category: 'space';
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
tags: string[];
|
||||||
|
sort_order: number;
|
||||||
|
};
|
||||||
collision_size?: {
|
collision_size?: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -20,6 +34,13 @@ export interface RoomDecorDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomDecorPlacementBounds {
|
||||||
|
min_x: number;
|
||||||
|
max_x: number;
|
||||||
|
min_y: number;
|
||||||
|
max_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomDecorLegacyDefault {
|
export interface RoomDecorLegacyDefault {
|
||||||
scale: number;
|
scale: number;
|
||||||
default_position: {
|
default_position: {
|
||||||
@@ -35,6 +56,18 @@ export const ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE = 1.0;
|
|||||||
export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04;
|
export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04;
|
||||||
export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35;
|
export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35;
|
||||||
export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18];
|
export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18];
|
||||||
|
export const ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS: RoomDecorPlacementBounds = {
|
||||||
|
min_x: -426,
|
||||||
|
max_x: 426,
|
||||||
|
min_y: -176,
|
||||||
|
max_y: 212,
|
||||||
|
};
|
||||||
|
export const ROOM_DECOR_WALL_PLACEMENT_BOUNDS: RoomDecorPlacementBounds = {
|
||||||
|
min_x: -378,
|
||||||
|
max_x: 378,
|
||||||
|
min_y: -258,
|
||||||
|
max_y: -192,
|
||||||
|
};
|
||||||
|
|
||||||
export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = {
|
export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = {
|
||||||
whale_floor_rug: {
|
whale_floor_rug: {
|
||||||
@@ -84,30 +117,54 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'whale_floor_rug',
|
decor_id: 'whale_floor_rug',
|
||||||
item_id: 'decor_whale_floor_rug',
|
item_id: 'decor_whale_floor_rug',
|
||||||
name: '鲸浪地毯',
|
name: '鲸浪地毯',
|
||||||
|
category: 'rug',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
|
||||||
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 0, y: 161 },
|
default_position: { x: 0, y: 161 },
|
||||||
default_z_index: -8,
|
default_z_index: -8,
|
||||||
|
shop: { category: 'space', description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', price: 260, tags: ['房间家具', '可拖拽', '地面'], sort_order: 110 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'whale_memory_board',
|
decor_id: 'whale_memory_board',
|
||||||
item_id: 'decor_whale_memory_board',
|
item_id: 'decor_whale_memory_board',
|
||||||
name: '鲸语记忆板',
|
name: '鲸语记忆板',
|
||||||
|
category: 'wall',
|
||||||
|
placement_surface: 'wall',
|
||||||
|
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||||
default_scale: 0.11,
|
default_scale: 0.11,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 182, y: -207 },
|
default_position: { x: 182, y: -207 },
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
shop: { category: 'space', description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', price: 220, tags: ['房间家具', '可拖拽', '挂件'], sort_order: 120 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'whale_tail_lamp',
|
decor_id: 'whale_tail_lamp',
|
||||||
item_id: 'decor_whale_tail_lamp',
|
item_id: 'decor_whale_tail_lamp',
|
||||||
name: '鲸尾暖灯',
|
name: '鲸尾暖灯',
|
||||||
|
category: 'floor',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0, 90, 180, 270],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
|
||||||
default_scale: 1,
|
default_scale: 1,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 231, y: -175 },
|
default_position: { x: 231, y: -175 },
|
||||||
default_z_index: -10,
|
default_z_index: -10,
|
||||||
|
shop: { category: 'space', description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', price: 360, tags: ['房间家具', '可拖拽', '灯具'], sort_order: 130 },
|
||||||
collision_size: { x: 50, y: 32 },
|
collision_size: { x: 50, y: 32 },
|
||||||
collision_offset: { x: 0, y: 56 },
|
collision_offset: { x: 0, y: 56 },
|
||||||
},
|
},
|
||||||
@@ -115,11 +172,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'boat_cabin_bed',
|
decor_id: 'boat_cabin_bed',
|
||||||
item_id: 'decor_boat_cabin_bed',
|
item_id: 'decor_boat_cabin_bed',
|
||||||
name: '船舱小床',
|
name: '船舱小床',
|
||||||
|
category: 'floor',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0, 90, 180, 270],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: -161, y: 25 },
|
default_position: { x: -161, y: 25 },
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
|
shop: { category: 'space', description: '白木船舱造型的小床,适合放在个人房间地面区域。', price: 520, tags: ['房间家具', '可拖拽', '床'], sort_order: 140 },
|
||||||
collision_size: { x: 220, y: 112 },
|
collision_size: { x: 220, y: 112 },
|
||||||
collision_offset: { x: 0, y: 52 },
|
collision_offset: { x: 0, y: 52 },
|
||||||
},
|
},
|
||||||
@@ -127,11 +192,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'low_wave_bed',
|
decor_id: 'low_wave_bed',
|
||||||
item_id: 'decor_low_wave_bed',
|
item_id: 'decor_low_wave_bed',
|
||||||
name: '海浪低床',
|
name: '海浪低床',
|
||||||
|
category: 'floor',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0, 90, 180, 270],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: -98, y: 39 },
|
default_position: { x: -98, y: 39 },
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
|
shop: { category: 'space', description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', price: 500, tags: ['房间家具', '可拖拽', '床'], sort_order: 150 },
|
||||||
collision_size: { x: 220, y: 112 },
|
collision_size: { x: 220, y: 112 },
|
||||||
collision_offset: { x: 0, y: 56 },
|
collision_offset: { x: 0, y: 56 },
|
||||||
},
|
},
|
||||||
@@ -139,11 +212,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'whale_tail_headboard_bed',
|
decor_id: 'whale_tail_headboard_bed',
|
||||||
item_id: 'decor_whale_tail_headboard_bed',
|
item_id: 'decor_whale_tail_headboard_bed',
|
||||||
name: '鲸尾床头床',
|
name: '鲸尾床头床',
|
||||||
|
category: 'floor',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0, 90, 180, 270],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 0, y: 32 },
|
default_position: { x: 0, y: 32 },
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
|
shop: { category: 'space', description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', price: 580, tags: ['房间家具', '可拖拽', '床'], sort_order: 180 },
|
||||||
collision_size: { x: 214, y: 112 },
|
collision_size: { x: 214, y: 112 },
|
||||||
collision_offset: { x: 0, y: 62 },
|
collision_offset: { x: 0, y: 62 },
|
||||||
},
|
},
|
||||||
@@ -151,11 +232,19 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'dev_whale_bookshelf',
|
decor_id: 'dev_whale_bookshelf',
|
||||||
item_id: 'decor_dev_whale_bookshelf',
|
item_id: 'decor_dev_whale_bookshelf',
|
||||||
name: '程序员鲸书架',
|
name: '程序员鲸书架',
|
||||||
|
category: 'floor',
|
||||||
|
placement_surface: 'floor',
|
||||||
|
placement_bounds: ROOM_DECOR_FLOOR_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0, 90, 180, 270],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||||
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||||
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: -210, y: -39 },
|
default_position: { x: -210, y: -39 },
|
||||||
default_z_index: -10,
|
default_z_index: -10,
|
||||||
|
shop: { category: 'space', description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。', price: 620, tags: ['房间家具', '可拖拽', '书架'], sort_order: 190 },
|
||||||
collision_size: { x: 626.667, y: 226.667 },
|
collision_size: { x: 626.667, y: 226.667 },
|
||||||
collision_offset: { x: 0, y: 580 },
|
collision_offset: { x: 0, y: 580 },
|
||||||
},
|
},
|
||||||
@@ -163,31 +252,55 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
|||||||
decor_id: 'datawhale_bug_feature_badge',
|
decor_id: 'datawhale_bug_feature_badge',
|
||||||
item_id: 'decor_datawhale_bug_feature_badge',
|
item_id: 'decor_datawhale_bug_feature_badge',
|
||||||
name: 'BUG特性徽章',
|
name: 'BUG特性徽章',
|
||||||
|
category: 'wall',
|
||||||
|
placement_surface: 'wall',
|
||||||
|
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: -210, y: -203 },
|
default_position: { x: -210, y: -203 },
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
shop: { category: 'space', description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 200 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'datawhale_buddhist_learning_badge',
|
decor_id: 'datawhale_buddhist_learning_badge',
|
||||||
item_id: 'decor_datawhale_buddhist_learning_badge',
|
item_id: 'decor_datawhale_buddhist_learning_badge',
|
||||||
name: '佛系学习徽章',
|
name: '佛系学习徽章',
|
||||||
|
category: 'wall',
|
||||||
|
placement_surface: 'wall',
|
||||||
|
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 0, y: -203 },
|
default_position: { x: 0, y: -203 },
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
shop: { category: 'space', description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', price: 140, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 210 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'datawhale_ok_working_badge',
|
decor_id: 'datawhale_ok_working_badge',
|
||||||
item_id: 'decor_datawhale_ok_working_badge',
|
item_id: 'decor_datawhale_ok_working_badge',
|
||||||
name: '已经在做徽章',
|
name: '已经在做徽章',
|
||||||
|
category: 'wall',
|
||||||
|
placement_surface: 'wall',
|
||||||
|
placement_bounds: ROOM_DECOR_WALL_PLACEMENT_BOUNDS,
|
||||||
|
grid_size: 16,
|
||||||
|
rotation_steps: [0],
|
||||||
|
stackable: false,
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||||
|
default_rotation_degrees: 0,
|
||||||
default_position: { x: 210, y: -203 },
|
default_position: { x: 210, y: -203 },
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
shop: { category: 'space', description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 220 },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
65
src/business/social/dto/social.dto.ts
Normal file
65
src/business/social/dto/social.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString, Length, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateSocialProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 50)
|
||||||
|
nickname?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 160)
|
||||||
|
bio?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(3)
|
||||||
|
@IsString({ each: true })
|
||||||
|
interests?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SocialUserActionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBlockDto extends SocialUserActionDto {}
|
||||||
|
|
||||||
|
export class CreateReportDto extends SocialUserActionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['harassment', 'spam', 'inappropriate_content', 'impersonation', 'other'])
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 500)
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
messageId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
blockAlso?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaginationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number = 30;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
before?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TravelDestinationDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
destinationId: string;
|
||||||
|
}
|
||||||
92
src/business/social/migrations/create-social-tables.sql
Normal file
92
src/business/social/migrations/create-social-tables.sql
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
-- WhaleTown V2 core social infrastructure. Run after the existing users/user_profiles tables.
|
||||||
|
-- Compatible with MySQL 5.7+/MariaDB: add the column only once.
|
||||||
|
SET @nickname_column_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'nickname_updated_at'
|
||||||
|
);
|
||||||
|
SET @nickname_column_sql := IF(
|
||||||
|
@nickname_column_exists = 0,
|
||||||
|
'ALTER TABLE users ADD COLUMN nickname_updated_at DATETIME NULL COMMENT ''社区昵称最近修改时间''',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE nickname_column_statement FROM @nickname_column_sql;
|
||||||
|
EXECUTE nickname_column_statement;
|
||||||
|
DEALLOCATE PREPARE nickname_column_statement;
|
||||||
|
UPDATE user_profiles SET current_map = 'whale_port' WHERE current_map = 'plaza';
|
||||||
|
UPDATE user_profiles SET current_map = 'personal_space' WHERE current_map = 'room';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS friendships (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_low_id BIGINT NOT NULL,
|
||||||
|
user_high_id BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_friendships_pair (user_low_id, user_high_id),
|
||||||
|
KEY idx_friendships_low (user_low_id),
|
||||||
|
KEY idx_friendships_high (user_high_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
requester_id BIGINT NOT NULL,
|
||||||
|
recipient_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
responded_at DATETIME NULL,
|
||||||
|
KEY idx_friend_requests_recipient (recipient_id, status, expires_at),
|
||||||
|
KEY idx_friend_requests_pair (requester_id, recipient_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
blocked_user_id BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_blocks_pair (user_id, blocked_user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
sender_id BIGINT NOT NULL,
|
||||||
|
recipient_id BIGINT NOT NULL,
|
||||||
|
content VARCHAR(1000) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
read_at DATETIME NULL,
|
||||||
|
KEY idx_direct_messages_conversation (sender_id, recipient_id, created_at),
|
||||||
|
KEY idx_direct_messages_unread (recipient_id, read_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_reports (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
reporter_id BIGINT NOT NULL,
|
||||||
|
reported_user_id BIGINT NOT NULL,
|
||||||
|
reason VARCHAR(32) NOT NULL,
|
||||||
|
note VARCHAR(500) NULL,
|
||||||
|
message_id BIGINT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL DEFAULT 'received',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_user_reports_reporter (reporter_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_travel_unlocks (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
destination_id VARCHAR(80) NOT NULL,
|
||||||
|
discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_player_travel_unlocks (user_id, destination_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS social_notifications (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
category VARCHAR(48) NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
content VARCHAR(500) NOT NULL,
|
||||||
|
action_metadata JSON NULL,
|
||||||
|
read_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_social_notifications_user (user_id, read_at, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
100
src/business/social/social.controller.ts
Normal file
100
src/business/social/social.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||||
|
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||||
|
import { CreateBlockDto, CreateReportDto, PaginationDto, SocialUserActionDto, TravelDestinationDto, UpdateSocialProfileDto } from './dto/social.dto';
|
||||||
|
import { SocialService } from './social.service';
|
||||||
|
|
||||||
|
function id(value: string): bigint {
|
||||||
|
if (!/^\d+$/.test(String(value || ''))) throw new Error('用户标识无效');
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('social')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('social')
|
||||||
|
export class SocialController {
|
||||||
|
constructor(private readonly social: SocialService) {}
|
||||||
|
|
||||||
|
@Get('interest-tags')
|
||||||
|
interestTags() { return { success: true, data: this.social.getInterestTags() }; }
|
||||||
|
|
||||||
|
@Patch('profile')
|
||||||
|
async updateProfile(@CurrentUser() user: JwtPayload, @Body() body: UpdateSocialProfileDto) { return { success: true, data: await this.social.updateSocialProfile(id(user.sub), body) }; }
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
async ownProfile(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getOwnSocialProfile(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Get('profiles/:userId')
|
||||||
|
async profile(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.getPublicProfile(id(user.sub), id(userId)) }; }
|
||||||
|
|
||||||
|
@Get('friends')
|
||||||
|
async friends(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getFriends(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Get('friend-requests')
|
||||||
|
async friendRequests(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getFriendRequests(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Post('friend-requests')
|
||||||
|
async createFriendRequest(@CurrentUser() user: JwtPayload, @Body() body: SocialUserActionDto) { return { success: true, data: await this.social.createFriendRequest(id(user.sub), id(body.userId)) }; }
|
||||||
|
|
||||||
|
@Post('friend-requests/:requestId/accept')
|
||||||
|
async acceptFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { return { success: true, data: await this.social.acceptFriendRequest(id(user.sub), id(requestId)) }; }
|
||||||
|
|
||||||
|
@Post('friend-requests/:requestId/reject')
|
||||||
|
async rejectFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { await this.social.rejectFriendRequest(id(user.sub), id(requestId)); return { success: true }; }
|
||||||
|
|
||||||
|
@Delete('friend-requests/:requestId')
|
||||||
|
async cancelFriendRequest(@CurrentUser() user: JwtPayload, @Param('requestId') requestId: string) { await this.social.cancelFriendRequest(id(user.sub), id(requestId)); return { success: true }; }
|
||||||
|
|
||||||
|
@Delete('friends/:userId')
|
||||||
|
async removeFriend(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { await this.social.removeFriend(id(user.sub), id(userId)); return { success: true }; }
|
||||||
|
|
||||||
|
@Get('blocks')
|
||||||
|
async blocks(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.listBlocks(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Post('blocks')
|
||||||
|
async block(@CurrentUser() user: JwtPayload, @Body() body: CreateBlockDto) { return { success: true, data: await this.social.blockUser(id(user.sub), id(body.userId)) }; }
|
||||||
|
|
||||||
|
@Delete('blocks/:userId')
|
||||||
|
async unblock(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.unblockUser(id(user.sub), id(userId)) }; }
|
||||||
|
|
||||||
|
@Post('reports')
|
||||||
|
async report(@CurrentUser() user: JwtPayload, @Body() body: CreateReportDto) { return { success: true, data: await this.social.createReport(id(user.sub), { userId: id(body.userId), reason: body.reason, note: body.note, messageId: body.messageId ? id(body.messageId) : undefined, blockAlso: body.blockAlso }) }; }
|
||||||
|
|
||||||
|
@Get('conversations')
|
||||||
|
async conversations(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.listConversations(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Get('conversations/:userId/messages')
|
||||||
|
async conversation(@CurrentUser() user: JwtPayload, @Param('userId') userId: string, @Query() query: PaginationDto) { return { success: true, data: await this.social.listConversation(id(user.sub), id(userId), query.limit || 30, query.before ? new Date(query.before) : undefined) }; }
|
||||||
|
|
||||||
|
@Patch('conversations/:userId/read')
|
||||||
|
async markConversationRead(@CurrentUser() user: JwtPayload, @Param('userId') userId: string) { return { success: true, data: await this.social.markConversationRead(id(user.sub), id(userId)) }; }
|
||||||
|
|
||||||
|
@Get('notifications')
|
||||||
|
async notifications(@CurrentUser() user: JwtPayload, @Query() query: PaginationDto) { return { success: true, data: await this.social.getNotificationSummary(id(user.sub), query.limit || 30, query.before ? new Date(query.before) : undefined) }; }
|
||||||
|
|
||||||
|
@Patch('notifications/:notificationId/read')
|
||||||
|
async markNotificationRead(@CurrentUser() user: JwtPayload, @Param('notificationId') notificationId: string) { return { success: true, data: await this.social.markNotificationRead(id(user.sub), id(notificationId)) }; }
|
||||||
|
|
||||||
|
@Patch('notifications/read-all')
|
||||||
|
async markAllNotificationsRead(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.markAllNotificationsRead(id(user.sub)) }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('world')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('world/travel-destinations')
|
||||||
|
export class WorldTravelController {
|
||||||
|
constructor(private readonly social: SocialService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async destinations(@CurrentUser() user: JwtPayload) { return { success: true, data: await this.social.getTravelDestinations(id(user.sub)) }; }
|
||||||
|
|
||||||
|
@Post(':destinationId/discover')
|
||||||
|
async discover(@CurrentUser() user: JwtPayload, @Param('destinationId') destinationId: string) { return { success: true, data: await this.social.discoverDestination(id(user.sub), destinationId) }; }
|
||||||
|
|
||||||
|
@Post(':destinationId/travel')
|
||||||
|
async travel(@CurrentUser() user: JwtPayload, @Param('destinationId') destinationId: string) { return { success: true, data: await this.social.travelTo(id(user.sub), destinationId) }; }
|
||||||
|
}
|
||||||
75
src/business/social/social.database-store.ts
Normal file
75
src/business/social/social.database-store.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Brackets, LessThan, MoreThan, Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
DirectMessage,
|
||||||
|
FriendRequest,
|
||||||
|
FriendRequestStatus,
|
||||||
|
Friendship,
|
||||||
|
PlayerTravelUnlock,
|
||||||
|
SocialNotification,
|
||||||
|
UserBlock,
|
||||||
|
UserReport,
|
||||||
|
} from './social.entities';
|
||||||
|
import { SocialStore } from './social.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SocialDatabaseStore implements SocialStore {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Friendship) private readonly friendships: Repository<Friendship>,
|
||||||
|
@InjectRepository(FriendRequest) private readonly requests: Repository<FriendRequest>,
|
||||||
|
@InjectRepository(UserBlock) private readonly blocks: Repository<UserBlock>,
|
||||||
|
@InjectRepository(DirectMessage) private readonly messages: Repository<DirectMessage>,
|
||||||
|
@InjectRepository(UserReport) private readonly reports: Repository<UserReport>,
|
||||||
|
@InjectRepository(PlayerTravelUnlock) private readonly unlocks: Repository<PlayerTravelUnlock>,
|
||||||
|
@InjectRepository(SocialNotification) private readonly notifications: Repository<SocialNotification>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listFriendships(userId: bigint): Promise<Friendship[]> {
|
||||||
|
return this.friendships.find({ where: [{ user_low_id: userId }, { user_high_id: userId }], order: { created_at: 'DESC' } });
|
||||||
|
}
|
||||||
|
async findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null> { return this.friendships.findOne({ where: { user_low_id: userLowId, user_high_id: userHighId } }); }
|
||||||
|
async createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship> {
|
||||||
|
const existing = await this.findFriendship(userLowId, userHighId); return existing || this.friendships.save(this.friendships.create({ user_low_id: userLowId, user_high_id: userHighId }));
|
||||||
|
}
|
||||||
|
async deleteFriendship(userLowId: bigint, userHighId: bigint): Promise<void> { await this.friendships.delete({ user_low_id: userLowId, user_high_id: userHighId }); }
|
||||||
|
async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null> { return this.requests.findOne({ where: { requester_id: requesterId, recipient_id: recipientId, status: FriendRequestStatus.PENDING, expires_at: MoreThan(new Date()) } }); }
|
||||||
|
async createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise<FriendRequest> { return this.requests.save(this.requests.create({ requester_id: requesterId, recipient_id: recipientId, expires_at: expiresAt, status: FriendRequestStatus.PENDING })); }
|
||||||
|
async findFriendRequest(id: bigint): Promise<FriendRequest | null> { return this.requests.findOne({ where: { id } }); }
|
||||||
|
async saveFriendRequest(request: FriendRequest): Promise<FriendRequest> { return this.requests.save(request); }
|
||||||
|
async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void> {
|
||||||
|
await this.requests.createQueryBuilder().update(FriendRequest).set({ status: FriendRequestStatus.CANCELLED, responded_at: new Date() }).where('status = :status', { status: FriendRequestStatus.PENDING }).andWhere(new Brackets((qb) => qb.where('(requester_id = :a AND recipient_id = :b)', { a: userA, b: userB }).orWhere('(requester_id = :b AND recipient_id = :a)', { a: userA, b: userB }))).execute();
|
||||||
|
}
|
||||||
|
async listFriendRequests(userId: bigint): Promise<FriendRequest[]> { return this.requests.find({ where: { recipient_id: userId, status: FriendRequestStatus.PENDING, expires_at: MoreThan(new Date()) }, order: { created_at: 'DESC' } }); }
|
||||||
|
async createBlock(userId: bigint, blockedUserId: bigint): Promise<UserBlock> { const found = await this.blocks.findOne({ where: { user_id: userId, blocked_user_id: blockedUserId } }); return found || this.blocks.save(this.blocks.create({ user_id: userId, blocked_user_id: blockedUserId })); }
|
||||||
|
async deleteBlock(userId: bigint, blockedUserId: bigint): Promise<void> { await this.blocks.delete({ user_id: userId, blocked_user_id: blockedUserId }); }
|
||||||
|
async isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean> { return (await this.blocks.count({ where: { user_id: userId, blocked_user_id: blockedUserId } })) > 0; }
|
||||||
|
async listBlocks(userId: bigint): Promise<UserBlock[]> { return this.blocks.find({ where: { user_id: userId }, order: { created_at: 'DESC' } }); }
|
||||||
|
async createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage> { return this.messages.save(this.messages.create(input)); }
|
||||||
|
async listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise<DirectMessage[]> { const query = this.messages.createQueryBuilder('message').where('message.expires_at > :now', { now: new Date() }).andWhere(new Brackets((qb) => qb.where('(message.sender_id = :a AND message.recipient_id = :b)', { a: userA, b: userB }).orWhere('(message.sender_id = :b AND message.recipient_id = :a)', { a: userA, b: userB }))); if (before) query.andWhere('message.created_at < :before', { before }); return query.orderBy('message.created_at', 'DESC').take(limit).getMany(); }
|
||||||
|
async markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise<number> { const result = await this.messages.createQueryBuilder().update(DirectMessage).set({ read_at: new Date() }).where('sender_id = :otherUserId AND recipient_id = :readerId AND read_at IS NULL', { readerId, otherUserId }).execute(); return result.affected || 0; }
|
||||||
|
async listConversations(userId: bigint): Promise<DirectMessage[]> { const rows = await this.messages.createQueryBuilder('message').where('(message.sender_id = :userId OR message.recipient_id = :userId)', { userId }).andWhere('message.expires_at > :now', { now: new Date() }).orderBy('message.created_at', 'DESC').getMany(); const seen = new Set<string>(); return rows.filter((row) => { const other = (row.sender_id === userId ? row.recipient_id : row.sender_id).toString(); if (seen.has(other)) return false; seen.add(other); return true; }); }
|
||||||
|
async countUnreadDirectMessages(userId: bigint): Promise<number> { return this.messages.count({ where: { recipient_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
||||||
|
async createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport> { return this.reports.save(this.reports.create(input)); }
|
||||||
|
async createUnlock(userId: bigint, destinationId: string): Promise<PlayerTravelUnlock> { const found = await this.unlocks.findOne({ where: { user_id: userId, destination_id: destinationId } }); return found || this.unlocks.save(this.unlocks.create({ user_id: userId, destination_id: destinationId })); }
|
||||||
|
async listUnlocks(userId: bigint): Promise<PlayerTravelUnlock[]> { return this.unlocks.find({ where: { user_id: userId }, order: { discovered_at: 'ASC' } }); }
|
||||||
|
async createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification> { return this.notifications.save(this.notifications.create(input)); }
|
||||||
|
async listNotifications(userId: bigint, limit: number, before?: Date): Promise<SocialNotification[]> { const where: any = { user_id: userId, expires_at: MoreThan(new Date()) }; if (before) where.created_at = LessThan(before); return this.notifications.find({ where, order: { created_at: 'DESC' }, take: limit }); }
|
||||||
|
async markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { const notification = await this.notifications.findOne({ where: { id, user_id: userId } }); if (!notification) return null; notification.read_at = new Date(); return this.notifications.save(notification); }
|
||||||
|
async markAllNotificationsRead(userId: bigint): Promise<number> { const result = await this.notifications.createQueryBuilder().update(SocialNotification).set({ read_at: new Date() }).where('user_id = :userId AND read_at IS NULL', { userId }).execute(); return result.affected || 0; }
|
||||||
|
async countUnreadNotifications(userId: bigint): Promise<number> { return this.notifications.count({ where: { user_id: userId, read_at: null, expires_at: MoreThan(new Date()) } }); }
|
||||||
|
async purgeUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
if (userIds.length === 0) return;
|
||||||
|
const ids = userIds;
|
||||||
|
await Promise.all([
|
||||||
|
this.friendships.createQueryBuilder().delete().where('user_low_id IN (:...ids) OR user_high_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.requests.createQueryBuilder().delete().where('requester_id IN (:...ids) OR recipient_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.blocks.createQueryBuilder().delete().where('user_id IN (:...ids) OR blocked_user_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.messages.createQueryBuilder().delete().where('sender_id IN (:...ids) OR recipient_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.reports.createQueryBuilder().delete().where('reporter_id IN (:...ids) OR reported_user_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.unlocks.createQueryBuilder().delete().where('user_id IN (:...ids)', { ids }).execute(),
|
||||||
|
this.notifications.createQueryBuilder().delete().where('user_id IN (:...ids)', { ids }).orWhere("JSON_CONTAINS(action_metadata, JSON_OBJECT('testLab', true))").execute(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
async cleanupExpired(now: Date): Promise<void> { await Promise.all([this.requests.delete({ status: FriendRequestStatus.PENDING, expires_at: LessThan(now) }), this.messages.delete({ expires_at: LessThan(now) }), this.notifications.delete({ expires_at: LessThan(now) })]); }
|
||||||
|
}
|
||||||
176
src/business/social/social.entities.ts
Normal file
176
src/business/social/social.entities.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('friendships')
|
||||||
|
@Index(['user_low_id', 'user_high_id'], { unique: true })
|
||||||
|
export class Friendship {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
user_low_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
user_high_id: bigint;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FriendRequestStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
ACCEPTED = 'accepted',
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('friend_requests')
|
||||||
|
@Index(['requester_id', 'recipient_id', 'status'])
|
||||||
|
export class FriendRequest {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
requester_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
recipient_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, default: FriendRequestStatus.PENDING })
|
||||||
|
status: FriendRequestStatus;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime' })
|
||||||
|
expires_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
responded_at?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('user_blocks')
|
||||||
|
@Index(['user_id', 'blocked_user_id'], { unique: true })
|
||||||
|
export class UserBlock {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
blocked_user_id: bigint;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('direct_messages')
|
||||||
|
@Index(['sender_id', 'recipient_id', 'created_at'])
|
||||||
|
@Index(['recipient_id', 'read_at'])
|
||||||
|
export class DirectMessage {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
sender_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
recipient_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 1000 })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime' })
|
||||||
|
expires_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
read_at?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('user_reports')
|
||||||
|
@Index(['reporter_id', 'created_at'])
|
||||||
|
export class UserReport {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
reporter_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
reported_user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32 })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
message_id?: bigint | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 24, default: 'received' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('player_travel_unlocks')
|
||||||
|
@Index(['user_id', 'destination_id'], { unique: true })
|
||||||
|
export class PlayerTravelUnlock {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
destination_id: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
discovered_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('social_notifications')
|
||||||
|
@Index(['user_id', 'read_at', 'created_at'])
|
||||||
|
export class SocialNotification {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 48 })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500 })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
action_metadata?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
read_at?: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'datetime' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime' })
|
||||||
|
expires_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'datetime' })
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
89
src/business/social/social.memory-store.ts
Normal file
89
src/business/social/social.memory-store.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DirectMessage,
|
||||||
|
FriendRequest,
|
||||||
|
FriendRequestStatus,
|
||||||
|
Friendship,
|
||||||
|
PlayerTravelUnlock,
|
||||||
|
SocialNotification,
|
||||||
|
UserBlock,
|
||||||
|
UserReport,
|
||||||
|
} from './social.entities';
|
||||||
|
import { SocialStore } from './social.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SocialMemoryStore implements SocialStore {
|
||||||
|
private nextId = BigInt(1);
|
||||||
|
private friendships: Friendship[] = [];
|
||||||
|
private requests: FriendRequest[] = [];
|
||||||
|
private blocks: UserBlock[] = [];
|
||||||
|
private messages: DirectMessage[] = [];
|
||||||
|
private reports: UserReport[] = [];
|
||||||
|
private unlocks: PlayerTravelUnlock[] = [];
|
||||||
|
private notifications: SocialNotification[] = [];
|
||||||
|
|
||||||
|
private id(): bigint { return this.nextId++; }
|
||||||
|
|
||||||
|
async listFriendships(userId: bigint): Promise<Friendship[]> {
|
||||||
|
return this.friendships.filter((item) => item.user_low_id === userId || item.user_high_id === userId);
|
||||||
|
}
|
||||||
|
async findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null> {
|
||||||
|
return this.friendships.find((item) => item.user_low_id === userLowId && item.user_high_id === userHighId) || null;
|
||||||
|
}
|
||||||
|
async createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship> {
|
||||||
|
const found = await this.findFriendship(userLowId, userHighId);
|
||||||
|
if (found) return found;
|
||||||
|
const record = Object.assign(new Friendship(), { id: this.id(), user_low_id: userLowId, user_high_id: userHighId, created_at: new Date() });
|
||||||
|
this.friendships.push(record); return record;
|
||||||
|
}
|
||||||
|
async deleteFriendship(userLowId: bigint, userHighId: bigint): Promise<void> {
|
||||||
|
this.friendships = this.friendships.filter((item) => item.user_low_id !== userLowId || item.user_high_id !== userHighId);
|
||||||
|
}
|
||||||
|
async findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null> {
|
||||||
|
return this.requests.find((item) => item.requester_id === requesterId && item.recipient_id === recipientId && item.status === FriendRequestStatus.PENDING && item.expires_at > new Date()) || null;
|
||||||
|
}
|
||||||
|
async createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise<FriendRequest> {
|
||||||
|
const record = Object.assign(new FriendRequest(), { id: this.id(), requester_id: requesterId, recipient_id: recipientId, status: FriendRequestStatus.PENDING, created_at: new Date(), expires_at: expiresAt, responded_at: null });
|
||||||
|
this.requests.push(record); return record;
|
||||||
|
}
|
||||||
|
async findFriendRequest(id: bigint): Promise<FriendRequest | null> { return this.requests.find((item) => item.id === id) || null; }
|
||||||
|
async saveFriendRequest(request: FriendRequest): Promise<FriendRequest> { return request; }
|
||||||
|
async cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void> {
|
||||||
|
for (const request of this.requests) if (request.status === FriendRequestStatus.PENDING && ((request.requester_id === userA && request.recipient_id === userB) || (request.requester_id === userB && request.recipient_id === userA))) { request.status = FriendRequestStatus.CANCELLED; request.responded_at = new Date(); }
|
||||||
|
}
|
||||||
|
async listFriendRequests(userId: bigint): Promise<FriendRequest[]> { return this.requests.filter((item) => item.recipient_id === userId && item.status === FriendRequestStatus.PENDING && item.expires_at > new Date()).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); }
|
||||||
|
async createBlock(userId: bigint, blockedUserId: bigint): Promise<UserBlock> {
|
||||||
|
const found = this.blocks.find((item) => item.user_id === userId && item.blocked_user_id === blockedUserId); if (found) return found;
|
||||||
|
const record = Object.assign(new UserBlock(), { id: this.id(), user_id: userId, blocked_user_id: blockedUserId, created_at: new Date() }); this.blocks.push(record); return record;
|
||||||
|
}
|
||||||
|
async deleteBlock(userId: bigint, blockedUserId: bigint): Promise<void> { this.blocks = this.blocks.filter((item) => item.user_id !== userId || item.blocked_user_id !== blockedUserId); }
|
||||||
|
async isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean> { return this.blocks.some((item) => item.user_id === userId && item.blocked_user_id === blockedUserId); }
|
||||||
|
async listBlocks(userId: bigint): Promise<UserBlock[]> { return this.blocks.filter((item) => item.user_id === userId); }
|
||||||
|
async createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage> {
|
||||||
|
const record = Object.assign(new DirectMessage(), { id: this.id(), ...input, created_at: new Date(), read_at: null }); this.messages.push(record); return record;
|
||||||
|
}
|
||||||
|
async listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise<DirectMessage[]> { return this.messages.filter((item) => ((item.sender_id === userA && item.recipient_id === userB) || (item.sender_id === userB && item.recipient_id === userA)) && item.expires_at > new Date() && (!before || item.created_at < before)).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit); }
|
||||||
|
async markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise<number> { let affected = 0; for (const item of this.messages) if (item.sender_id === otherUserId && item.recipient_id === readerId && !item.read_at) { item.read_at = new Date(); affected++; } return affected; }
|
||||||
|
async listConversations(userId: bigint): Promise<DirectMessage[]> { const latest = new Map<string, DirectMessage>(); for (const item of this.messages) { if (item.expires_at <= new Date() || (item.sender_id !== userId && item.recipient_id !== userId)) continue; const other = item.sender_id === userId ? item.recipient_id : item.sender_id; const old = latest.get(other.toString()); if (!old || old.created_at < item.created_at) latest.set(other.toString(), item); } return [...latest.values()].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); }
|
||||||
|
async countUnreadDirectMessages(userId: bigint): Promise<number> { return this.messages.filter((item) => item.recipient_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
||||||
|
async createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport> { const record = Object.assign(new UserReport(), { id: this.id(), ...input, status: 'received', created_at: new Date() }); this.reports.push(record); return record; }
|
||||||
|
async createUnlock(userId: bigint, destinationId: string): Promise<PlayerTravelUnlock> { const found = this.unlocks.find((item) => item.user_id === userId && item.destination_id === destinationId); if (found) return found; const record = Object.assign(new PlayerTravelUnlock(), { id: this.id(), user_id: userId, destination_id: destinationId, discovered_at: new Date() }); this.unlocks.push(record); return record; }
|
||||||
|
async listUnlocks(userId: bigint): Promise<PlayerTravelUnlock[]> { return this.unlocks.filter((item) => item.user_id === userId); }
|
||||||
|
async createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification> { const now = new Date(); const record = Object.assign(new SocialNotification(), { id: this.id(), ...input, created_at: now, updated_at: now, read_at: null }); this.notifications.push(record); return record; }
|
||||||
|
async listNotifications(userId: bigint, limit: number, before?: Date): Promise<SocialNotification[]> { return this.notifications.filter((item) => item.user_id === userId && item.expires_at > new Date() && (!before || item.created_at < before)).sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit); }
|
||||||
|
async markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null> { const item = this.notifications.find((entry) => entry.user_id === userId && entry.id === id); if (item) { item.read_at = new Date(); item.updated_at = new Date(); } return item || null; }
|
||||||
|
async markAllNotificationsRead(userId: bigint): Promise<number> { let affected = 0; for (const item of this.notifications) if (item.user_id === userId && !item.read_at) { item.read_at = new Date(); item.updated_at = new Date(); affected++; } return affected; }
|
||||||
|
async countUnreadNotifications(userId: bigint): Promise<number> { return this.notifications.filter((item) => item.user_id === userId && !item.read_at && item.expires_at > new Date()).length; }
|
||||||
|
async purgeUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
const ids = new Set(userIds.map((id) => id.toString()));
|
||||||
|
const has = (id: bigint) => ids.has(id.toString());
|
||||||
|
this.friendships = this.friendships.filter((item) => !has(item.user_low_id) && !has(item.user_high_id));
|
||||||
|
this.requests = this.requests.filter((item) => !has(item.requester_id) && !has(item.recipient_id));
|
||||||
|
this.blocks = this.blocks.filter((item) => !has(item.user_id) && !has(item.blocked_user_id));
|
||||||
|
this.messages = this.messages.filter((item) => !has(item.sender_id) && !has(item.recipient_id));
|
||||||
|
this.reports = this.reports.filter((item) => !has(item.reporter_id) && !has(item.reported_user_id));
|
||||||
|
this.unlocks = this.unlocks.filter((item) => !has(item.user_id));
|
||||||
|
this.notifications = this.notifications.filter((item) => !has(item.user_id) && !(item.action_metadata as Record<string, unknown> | null)?.testLab);
|
||||||
|
}
|
||||||
|
async cleanupExpired(now: Date): Promise<void> { this.requests = this.requests.filter((item) => item.status !== FriendRequestStatus.PENDING || item.expires_at > now); this.messages = this.messages.filter((item) => item.expires_at > now); this.notifications = this.notifications.filter((item) => item.expires_at > now); }
|
||||||
|
}
|
||||||
34
src/business/social/social.module.ts
Normal file
34
src/business/social/social.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ChatModule } from '../chat/chat.module';
|
||||||
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { SocialController, WorldTravelController } from './social.controller';
|
||||||
|
import { SocialDatabaseStore } from './social.database-store';
|
||||||
|
import { SocialMemoryStore } from './social.memory-store';
|
||||||
|
import { DirectMessage, FriendRequest, Friendship, PlayerTravelUnlock, SocialNotification, UserBlock, UserReport } from './social.entities';
|
||||||
|
import { SocialService } from './social.service';
|
||||||
|
import { SOCIAL_STORE } from './social.store';
|
||||||
|
|
||||||
|
function isDatabaseConfigured(): boolean {
|
||||||
|
return ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'].every((key) => process.env[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({})
|
||||||
|
export class SocialModule {
|
||||||
|
static forRoot(): DynamicModule {
|
||||||
|
const database = isDatabaseConfigured();
|
||||||
|
return {
|
||||||
|
module: SocialModule,
|
||||||
|
imports: [AuthModule, ChatModule, LoginCoreModule, ...(database ? [TypeOrmModule.forFeature([Friendship, FriendRequest, UserBlock, DirectMessage, UserReport, PlayerTravelUnlock, SocialNotification])] : [])],
|
||||||
|
controllers: [SocialController, WorldTravelController],
|
||||||
|
providers: [
|
||||||
|
...(database ? [SocialDatabaseStore] : [SocialMemoryStore]),
|
||||||
|
{ provide: SOCIAL_STORE, useExisting: database ? SocialDatabaseStore : SocialMemoryStore },
|
||||||
|
SocialService,
|
||||||
|
],
|
||||||
|
exports: [SocialService],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
572
src/business/social/social.service.ts
Normal file
572
src/business/social/social.service.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { AccountProfileService } from '../auth/account_profile.service';
|
||||||
|
import { getTestLabPresence } from '../admin/test_lab_presence.registry';
|
||||||
|
import { ChatSessionService } from '../chat/services/chat_session.service';
|
||||||
|
import { FriendRequestStatus } from './social.entities';
|
||||||
|
import { SOCIAL_STORE, SocialStore } from './social.store';
|
||||||
|
|
||||||
|
const NEARBY_DISTANCE = 160;
|
||||||
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const FRIEND_REQUEST_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const NICKNAME_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const INTEREST_TAGS = [
|
||||||
|
{ id: 'ai', label: 'AI/大模型' },
|
||||||
|
{ id: 'programming', label: '编程开发' },
|
||||||
|
{ id: 'data_science', label: '数据科学' },
|
||||||
|
{ id: 'open_source', label: '开源协作' },
|
||||||
|
{ id: 'product', label: '产品' },
|
||||||
|
{ id: 'design', label: '设计' },
|
||||||
|
{ id: 'game_dev', label: '游戏开发' },
|
||||||
|
{ id: 'content_creation', label: '内容创作' },
|
||||||
|
{ id: 'community', label: '社区活动' },
|
||||||
|
{ id: 'learning_partner', label: '学习搭子' },
|
||||||
|
{ id: 'career', label: '职业成长' },
|
||||||
|
{ id: 'casual_chat', label: '轻松闲聊' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const INTEREST_IDS = new Set<string>(INTEREST_TAGS.map((tag) => tag.id));
|
||||||
|
const SOCIAL_SETTINGS_KEY = 'whaletown_settings';
|
||||||
|
const DEFAULT_PRIVACY = {
|
||||||
|
allow_nearby_profile: true,
|
||||||
|
allow_nearby_private: true,
|
||||||
|
allow_nearby_friend_requests: true,
|
||||||
|
};
|
||||||
|
const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']);
|
||||||
|
const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = {
|
||||||
|
whale_port: { x: 1280, y: 960 },
|
||||||
|
work_zone: { x: 1280, y: 960 },
|
||||||
|
whale_cafe: { x: 768, y: 512 },
|
||||||
|
personal_space: { x: 768, y: 512 },
|
||||||
|
};
|
||||||
|
const DISCOVERY_DISTANCE = 180;
|
||||||
|
|
||||||
|
export const TRAVEL_DESTINATIONS = [
|
||||||
|
{ id: 'square_center', mapId: 'whale_port', label: '广场中心', x: 1280, y: 990, initial: true },
|
||||||
|
{ id: 'square_dock', mapId: 'whale_port', label: '码头', x: 410, y: 758 },
|
||||||
|
{ id: 'square_headquarters', mapId: 'whale_port', label: '总部', x: 1293, y: 360 },
|
||||||
|
{ id: 'square_cottage', mapId: 'whale_port', label: '小屋', x: 2125, y: 768 },
|
||||||
|
{ id: 'square_workshop', mapId: 'whale_port', label: '工坊', x: 1925, y: 1460 },
|
||||||
|
{ id: 'square_notice', mapId: 'whale_port', label: '公告栏', x: 738, y: 1608 },
|
||||||
|
{ id: 'square_work_zone_gate', mapId: 'whale_port', label: '打工区入口', x: 1280, y: 1735 },
|
||||||
|
{ id: 'work_entrance', mapId: 'work_zone', label: '打工区入口', x: 1280, y: 1715 },
|
||||||
|
{ id: 'work_mall', mapId: 'work_zone', label: '商城', x: 1280, y: 346 },
|
||||||
|
{ id: 'work_cafe_gate', mapId: 'work_zone', label: '咖啡馆入口', x: 236, y: 1182 },
|
||||||
|
{ id: 'work_jobs', mapId: 'work_zone', label: '任务中心', x: 778, y: 1152 },
|
||||||
|
{ id: 'work_courses', mapId: 'work_zone', label: '课程看板', x: 1776, y: 960 },
|
||||||
|
{ id: 'work_ai', mapId: 'work_zone', label: 'AI 站', x: 1732, y: 1508 },
|
||||||
|
{ id: 'work_exchange', mapId: 'work_zone', label: '鲸币兑换处', x: 2355, y: 1508 },
|
||||||
|
{ id: 'cafe_entrance', mapId: 'whale_cafe', label: '咖啡馆入口', x: 768, y: 875 },
|
||||||
|
{ id: 'cafe_counter', mapId: 'whale_cafe', label: '服务台', x: 768, y: 475 },
|
||||||
|
{ id: 'cafe_companion', mapId: 'whale_cafe', label: '陪伴区', x: 370, y: 286 },
|
||||||
|
{ id: 'personal_room', mapId: 'personal_space', label: '我的房间', x: 768, y: 512, initial: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type RealtimeGateway = {
|
||||||
|
sendToPlayer(socketId: string, payload: Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserRecord {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
nickname_updated_at?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserService {
|
||||||
|
findOne(id: bigint): Promise<UserRecord>;
|
||||||
|
update(id: bigint, payload: Record<string, unknown>): Promise<UserRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileRecord {
|
||||||
|
id: bigint;
|
||||||
|
user_id: bigint;
|
||||||
|
bio?: string | null;
|
||||||
|
tags?: Record<string, unknown> | null;
|
||||||
|
skin_id?: string | null;
|
||||||
|
current_map: string;
|
||||||
|
pos_x: number;
|
||||||
|
pos_y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileService {
|
||||||
|
findByUserId(userId: bigint): Promise<ProfileRecord | null>;
|
||||||
|
update(id: bigint, payload: Record<string, unknown>): Promise<ProfileRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SocialService {
|
||||||
|
private readonly logger = new Logger(SocialService.name);
|
||||||
|
private realtimeGateway?: RealtimeGateway;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(SOCIAL_STORE) private readonly store: SocialStore,
|
||||||
|
@Inject('UsersService') private readonly usersService: UserService,
|
||||||
|
@Inject('IUserProfilesService') private readonly profiles: ProfileService,
|
||||||
|
private readonly accountProfileService: AccountProfileService,
|
||||||
|
private readonly sessions: ChatSessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setRealtimeGateway(gateway: RealtimeGateway): void {
|
||||||
|
this.realtimeGateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInterestTags() { return INTEREST_TAGS; }
|
||||||
|
|
||||||
|
async getOwnSocialProfile(userId: bigint) {
|
||||||
|
await this.ensureProfile(userId);
|
||||||
|
return this.buildProfile(userId, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSocialProfile(userId: bigint, update: { nickname?: string; bio?: string; interests?: string[] }) {
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
const profile = await this.ensureProfile(userId);
|
||||||
|
|
||||||
|
if (update.nickname !== undefined) {
|
||||||
|
const nickname = update.nickname.trim();
|
||||||
|
if (!nickname) throw new BadRequestException('昵称不能为空');
|
||||||
|
if (nickname !== user.nickname) {
|
||||||
|
const lastUpdatedAt = user.nickname_updated_at ? new Date(user.nickname_updated_at).getTime() : 0;
|
||||||
|
const remaining = NICKNAME_COOLDOWN_MS - (Date.now() - lastUpdatedAt);
|
||||||
|
if (lastUpdatedAt && remaining > 0) {
|
||||||
|
throw new ForbiddenException(`昵称每 7 天只能修改一次,还需等待 ${Math.ceil(remaining / 86400000)} 天`);
|
||||||
|
}
|
||||||
|
await this.usersService.update(userId, { nickname, nickname_updated_at: new Date() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = this.profileTags(profile);
|
||||||
|
if (update.interests !== undefined) {
|
||||||
|
const interests = [...new Set(update.interests.map((value) => value.trim()))];
|
||||||
|
if (interests.length > 3 || interests.some((value) => !INTEREST_IDS.has(value))) {
|
||||||
|
throw new BadRequestException('兴趣标签不在允许的目录内');
|
||||||
|
}
|
||||||
|
tags.interests = interests;
|
||||||
|
}
|
||||||
|
await this.profiles.update(profile.id, { bio: update.bio !== undefined ? update.bio.trim() : profile.bio || '', tags });
|
||||||
|
return this.getOwnSocialProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicProfile(viewerId: bigint, targetId: bigint) {
|
||||||
|
const self = viewerId === targetId;
|
||||||
|
if (!self && !(await this.areFriends(viewerId, targetId))) {
|
||||||
|
await this.assertNearbyAllowed(viewerId, targetId, 'profile');
|
||||||
|
}
|
||||||
|
return this.buildProfile(viewerId, targetId, self);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canVisitRoom(viewerId: bigint, ownerId: bigint): Promise<boolean> {
|
||||||
|
if (viewerId === ownerId) return true;
|
||||||
|
const profile = await this.ensureProfile(ownerId);
|
||||||
|
return await this.canVisitRoomWithProfile(viewerId, ownerId, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoomOwnerProfile(viewerId: bigint, ownerId: bigint) {
|
||||||
|
if (!(await this.canVisitRoom(viewerId, ownerId))) {
|
||||||
|
throw new ForbiddenException('该玩家的个人空间暂不可访问');
|
||||||
|
}
|
||||||
|
const profile = await this.buildProfile(viewerId, ownerId, viewerId === ownerId);
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
username: profile.username,
|
||||||
|
nickname: profile.nickname,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
skinId: profile.skinId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(userId: bigint) {
|
||||||
|
const friendships = await this.store.listFriendships(userId);
|
||||||
|
const result = [];
|
||||||
|
for (const friendship of friendships) {
|
||||||
|
const friendId = friendship.user_low_id === userId ? friendship.user_high_id : friendship.user_low_id;
|
||||||
|
result.push(await this.buildProfile(userId, friendId, false));
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => Number(b.online) - Number(a.online) || a.nickname.localeCompare(b.nickname));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriendRequests(userId: bigint) {
|
||||||
|
const requests = await this.store.listFriendRequests(userId);
|
||||||
|
return Promise.all(requests.map(async (request) => ({
|
||||||
|
id: request.id.toString(),
|
||||||
|
createdAt: request.created_at,
|
||||||
|
expiresAt: request.expires_at,
|
||||||
|
requester: await this.buildProfile(userId, request.requester_id, false),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFriendRequest(requesterId: bigint, targetId: bigint) {
|
||||||
|
this.assertDistinct(requesterId, targetId);
|
||||||
|
await this.assertNotBlockedEitherWay(requesterId, targetId);
|
||||||
|
if (await this.areFriends(requesterId, targetId)) throw new BadRequestException('已经是好友');
|
||||||
|
await this.assertNearbyAllowed(requesterId, targetId, 'friend');
|
||||||
|
if (await this.store.findPendingFriendRequest(requesterId, targetId)) throw new BadRequestException('好友请求已发送');
|
||||||
|
|
||||||
|
await this.usersService.findOne(targetId);
|
||||||
|
const request = await this.store.createFriendRequest(requesterId, targetId, new Date(Date.now() + FRIEND_REQUEST_MS));
|
||||||
|
const requester = await this.buildProfile(targetId, requesterId, false);
|
||||||
|
await this.createNotification(targetId, 'friend_request', '新的好友申请', `${requester.nickname} 想与你成为好友`, { requestId: request.id.toString(), userId: requesterId.toString() });
|
||||||
|
await this.sendToUser(targetId, { t: 'friend_request_received', request: { id: request.id.toString(), requester } });
|
||||||
|
return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅供测试实验室调用:使用真实社交记录,但合成玩家无需真实 WebSocket 会话。 */
|
||||||
|
async createTestFriendRequest(requesterId: bigint, targetId: bigint) {
|
||||||
|
this.assertDistinct(requesterId, targetId);
|
||||||
|
await this.assertNotBlockedEitherWay(requesterId, targetId);
|
||||||
|
if (await this.areFriends(requesterId, targetId)) throw new BadRequestException('已经是好友');
|
||||||
|
if (await this.store.findPendingFriendRequest(requesterId, targetId)) throw new BadRequestException('好友请求已发送');
|
||||||
|
|
||||||
|
await this.usersService.findOne(targetId);
|
||||||
|
const request = await this.store.createFriendRequest(requesterId, targetId, new Date(Date.now() + FRIEND_REQUEST_MS));
|
||||||
|
const requester = await this.buildProfile(targetId, requesterId, false);
|
||||||
|
await this.createNotification(targetId, 'friend_request', '新的好友申请', `${requester.nickname} 想与你成为好友`, { requestId: request.id.toString(), userId: requesterId.toString(), testLab: true });
|
||||||
|
await this.sendToUser(targetId, { t: 'friend_request_received', request: { id: request.id.toString(), requester } });
|
||||||
|
return { id: request.id.toString(), createdAt: request.created_at, expiresAt: request.expires_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondToTestFriendRequest(actorId: bigint, requesterId: bigint, accept: boolean) {
|
||||||
|
const request = await this.store.findPendingFriendRequest(requesterId, actorId);
|
||||||
|
if (!request) throw new NotFoundException('不存在待处理的好友申请');
|
||||||
|
return accept
|
||||||
|
? this.acceptFriendRequest(actorId, request.id)
|
||||||
|
: this.rejectFriendRequest(actorId, request.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptFriendRequest(userId: bigint, requestId: bigint) {
|
||||||
|
const request = await this.store.findFriendRequest(requestId);
|
||||||
|
if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING || request.expires_at <= new Date()) throw new NotFoundException('好友请求不存在或已过期');
|
||||||
|
await this.assertNotBlockedEitherWay(userId, request.requester_id);
|
||||||
|
const [low, high] = this.sortIds(userId, request.requester_id);
|
||||||
|
await this.store.createFriendship(low, high);
|
||||||
|
request.status = FriendRequestStatus.ACCEPTED;
|
||||||
|
request.responded_at = new Date();
|
||||||
|
await this.store.saveFriendRequest(request);
|
||||||
|
await this.store.cancelPendingRequestsBetween(userId, request.requester_id);
|
||||||
|
const accepter = await this.buildProfile(request.requester_id, userId, false);
|
||||||
|
await this.createNotification(request.requester_id, 'friend_accepted', '好友申请已接受', `${accepter.nickname} 已成为你的好友`, { userId: userId.toString() });
|
||||||
|
await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'accepted', friend: accepter });
|
||||||
|
await this.sendToUser(userId, { t: 'friendship_changed', action: 'accepted', friend: await this.buildProfile(userId, request.requester_id, false) });
|
||||||
|
return { friend: await this.buildProfile(userId, request.requester_id, false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectFriendRequest(userId: bigint, requestId: bigint) {
|
||||||
|
const request = await this.store.findFriendRequest(requestId);
|
||||||
|
if (!request || request.recipient_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在');
|
||||||
|
request.status = FriendRequestStatus.REJECTED;
|
||||||
|
request.responded_at = new Date();
|
||||||
|
await this.store.saveFriendRequest(request);
|
||||||
|
const rejecter = await this.buildProfile(request.requester_id, userId, false);
|
||||||
|
await this.createNotification(request.requester_id, 'friend_rejected', '好友申请未通过', `${rejecter.nickname} 暂未接受你的好友申请`, { userId: userId.toString() });
|
||||||
|
await this.sendToUser(request.requester_id, { t: 'friendship_changed', action: 'rejected', userId: userId.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelFriendRequest(userId: bigint, requestId: bigint) {
|
||||||
|
const request = await this.store.findFriendRequest(requestId);
|
||||||
|
if (!request || request.requester_id !== userId || request.status !== FriendRequestStatus.PENDING) throw new NotFoundException('好友请求不存在');
|
||||||
|
request.status = FriendRequestStatus.CANCELLED;
|
||||||
|
request.responded_at = new Date();
|
||||||
|
await this.store.saveFriendRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFriend(userId: bigint, friendId: bigint) {
|
||||||
|
const [low, high] = this.sortIds(userId, friendId);
|
||||||
|
await this.store.deleteFriendship(low, high);
|
||||||
|
await this.sendToUser(friendId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBlocks(userId: bigint) {
|
||||||
|
const blocks = await this.store.listBlocks(userId);
|
||||||
|
return Promise.all(blocks.map(async (block) => ({ createdAt: block.created_at, profile: await this.buildProfile(userId, block.blocked_user_id, false) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async blockUser(userId: bigint, targetId: bigint) {
|
||||||
|
this.assertDistinct(userId, targetId);
|
||||||
|
await this.usersService.findOne(targetId);
|
||||||
|
await this.store.createBlock(userId, targetId);
|
||||||
|
const [low, high] = this.sortIds(userId, targetId);
|
||||||
|
await this.store.deleteFriendship(low, high);
|
||||||
|
await this.store.cancelPendingRequestsBetween(userId, targetId);
|
||||||
|
await this.sendToUser(targetId, { t: 'friendship_changed', action: 'removed', userId: userId.toString() });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unblockUser(userId: bigint, targetId: bigint) {
|
||||||
|
await this.store.deleteBlock(userId, targetId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReport(reporterId: bigint, input: { userId: bigint; reason: string; note?: string; messageId?: bigint; blockAlso?: boolean }) {
|
||||||
|
this.assertDistinct(reporterId, input.userId);
|
||||||
|
const report = await this.store.createReport({ reporter_id: reporterId, reported_user_id: input.userId, reason: input.reason, note: input.note?.trim() || null, message_id: input.messageId || null });
|
||||||
|
if (input.blockAlso) await this.blockUser(reporterId, input.userId);
|
||||||
|
await this.createNotification(reporterId, 'report_receipt', '举报已提交', '我们已收到你的举报,会尽快处理。', { reportId: report.id.toString() });
|
||||||
|
return { id: report.id.toString(), status: report.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendDirectMessage(senderId: bigint, targetId: bigint, content: string) {
|
||||||
|
this.assertDistinct(senderId, targetId);
|
||||||
|
const normalizedContent = content.trim();
|
||||||
|
if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符');
|
||||||
|
await this.assertNotBlockedEitherWay(senderId, targetId);
|
||||||
|
const targetSocket = await this.sessions.getSocketIdByUserId(targetId.toString());
|
||||||
|
const targetTestPresence = getTestLabPresence(targetId.toString());
|
||||||
|
// 测试实验室假人没有真实 WebSocket 连接,但其合成在线状态应与普通在线玩家
|
||||||
|
// 一致地通过私聊在线校验。消息仍只写入现有的私聊存储,不触发外部副作用。
|
||||||
|
if (!targetSocket && !targetTestPresence?.online) throw new BadRequestException('对方当前不在线');
|
||||||
|
if (!(await this.areFriends(senderId, targetId))) await this.assertNearbyAllowed(senderId, targetId, 'private');
|
||||||
|
|
||||||
|
const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) });
|
||||||
|
const sender = await this.buildProfile(targetId, senderId, false);
|
||||||
|
const payload = { t: 'dm_message', message: { id: message.id.toString(), senderId: senderId.toString(), recipientId: targetId.toString(), content: message.content, createdAt: message.created_at, sender } };
|
||||||
|
await this.sendToUser(senderId, payload);
|
||||||
|
await this.sendToUser(targetId, payload);
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试假人私聊:仍写入正式私聊存储,但不要求假人拥有真实 socket。 */
|
||||||
|
async sendTestDirectMessage(senderId: bigint, targetId: bigint, content: string) {
|
||||||
|
this.assertDistinct(senderId, targetId);
|
||||||
|
const normalizedContent = content.trim();
|
||||||
|
if (!normalizedContent || normalizedContent.length > 1000) throw new BadRequestException('私聊内容需为 1-1000 个字符');
|
||||||
|
await this.assertNotBlockedEitherWay(senderId, targetId);
|
||||||
|
await this.usersService.findOne(targetId);
|
||||||
|
const message = await this.store.createDirectMessage({ sender_id: senderId, recipient_id: targetId, content: normalizedContent, expires_at: new Date(Date.now() + RETENTION_MS) });
|
||||||
|
const sender = await this.buildProfile(targetId, senderId, false);
|
||||||
|
const payload = { t: 'dm_message', message: { id: message.id.toString(), senderId: senderId.toString(), recipientId: targetId.toString(), content: message.content, createdAt: message.created_at, sender } };
|
||||||
|
await this.sendToUser(targetId, payload);
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listConversation(userId: bigint, otherUserId: bigint, limit: number, before?: Date) {
|
||||||
|
await this.assertNotBlockedEitherWay(userId, otherUserId);
|
||||||
|
const messages = await this.store.listDirectMessages(userId, otherUserId, limit, before);
|
||||||
|
const other = await this.buildProfile(userId, otherUserId, false);
|
||||||
|
return { other, messages: messages.reverse().map((message) => this.serializeDirectMessage(message)), unreadCount: await this.store.countUnreadDirectMessages(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listConversations(userId: bigint) {
|
||||||
|
const latest = await this.store.listConversations(userId);
|
||||||
|
return Promise.all(latest.map(async (message) => {
|
||||||
|
const otherId = message.sender_id === userId ? message.recipient_id : message.sender_id;
|
||||||
|
return { other: await this.buildProfile(userId, otherId, false), latestMessage: this.serializeDirectMessage(message) };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markConversationRead(userId: bigint, otherUserId: bigint) {
|
||||||
|
const affected = await this.store.markDirectMessagesRead(userId, otherUserId);
|
||||||
|
await this.sendToUser(otherUserId, { t: 'dm_read', readerId: userId.toString() });
|
||||||
|
return { affected };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNotificationSummary(userId: bigint, limit: number, before?: Date) {
|
||||||
|
const [notifications, unreadCount, unreadMessages] = await Promise.all([
|
||||||
|
this.store.listNotifications(userId, limit, before),
|
||||||
|
this.store.countUnreadNotifications(userId),
|
||||||
|
this.store.countUnreadDirectMessages(userId),
|
||||||
|
]);
|
||||||
|
return { notifications: notifications.map((notification) => this.serializeNotification(notification)), unreadCount, unreadMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNotificationRead(userId: bigint, notificationId: bigint) {
|
||||||
|
const notification = await this.store.markNotificationRead(userId, notificationId);
|
||||||
|
if (!notification) throw new NotFoundException('通知不存在');
|
||||||
|
return this.serializeNotification(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllNotificationsRead(userId: bigint) { return { affected: await this.store.markAllNotificationsRead(userId) }; }
|
||||||
|
|
||||||
|
async getTravelDestinations(userId: bigint) {
|
||||||
|
const unlocks = await this.store.listUnlocks(userId);
|
||||||
|
const unlocked = new Set(unlocks.map((unlock) => unlock.destination_id));
|
||||||
|
return TRAVEL_DESTINATIONS.map((destination) => ({ ...destination, unlocked: Boolean(('initial' in destination && destination.initial) || unlocked.has(destination.id)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverDestination(userId: bigint, destinationId: string) {
|
||||||
|
const destination = this.destination(destinationId);
|
||||||
|
if (!('initial' in destination && destination.initial)) await this.assertAtTravelDestination(userId, destination);
|
||||||
|
await this.store.createUnlock(userId, destination.id);
|
||||||
|
return { ...destination, unlocked: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async travelTo(userId: bigint, destinationId: string) {
|
||||||
|
const destination = this.destination(destinationId);
|
||||||
|
const unlocked = ('initial' in destination && destination.initial) || (await this.store.listUnlocks(userId)).some((unlock) => unlock.destination_id === destinationId);
|
||||||
|
if (!unlocked) throw new ForbiddenException('该地点尚未解锁');
|
||||||
|
return { ...destination, unlocked: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async canSeeChat(senderId: string, recipientId: string): Promise<boolean> {
|
||||||
|
if (!/^\d+$/.test(senderId) || !/^\d+$/.test(recipientId)) return false;
|
||||||
|
return !(await this.isBlockedEitherWay(BigInt(senderId), BigInt(recipientId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyPresenceChanged(userId: string, online: boolean): Promise<void> {
|
||||||
|
if (!/^\d+$/.test(userId)) return;
|
||||||
|
const friends = await this.store.listFriendships(BigInt(userId));
|
||||||
|
for (const friendship of friends) {
|
||||||
|
const otherId = friendship.user_low_id === BigInt(userId) ? friendship.user_high_id : friendship.user_low_id;
|
||||||
|
await this.sendToUser(otherId, { t: 'friend_presence_changed', userId, online });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
|
async cleanupExpiredData(): Promise<void> {
|
||||||
|
await this.store.cleanupExpired(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeTestUsers(userIds: bigint[]): Promise<void> {
|
||||||
|
await this.store.purgeUsers(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildProfile(viewerId: bigint, targetId: bigint, includePrivate: boolean) {
|
||||||
|
const [user, profile, socketId] = await Promise.all([
|
||||||
|
this.usersService.findOne(targetId),
|
||||||
|
this.ensureProfile(targetId),
|
||||||
|
this.sessions.getSocketIdByUserId(targetId.toString()),
|
||||||
|
]);
|
||||||
|
const tags = this.profileTags(profile);
|
||||||
|
const session = socketId ? await this.sessions.getSession(socketId) : null;
|
||||||
|
const testPresence = getTestLabPresence(targetId);
|
||||||
|
const isFriend = viewerId === targetId ? false : await this.areFriends(viewerId, targetId);
|
||||||
|
const blockedEitherWay = viewerId === targetId ? false : await this.isBlockedEitherWay(viewerId, targetId);
|
||||||
|
return {
|
||||||
|
id: targetId.toString(),
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatar_url || '',
|
||||||
|
skinId: profile.skin_id || '',
|
||||||
|
online: Boolean(socketId) || Boolean(testPresence?.online),
|
||||||
|
currentArea: testPresence?.mapId || session?.currentMap || profile.current_map,
|
||||||
|
bio: String(profile.bio || '').slice(0, 160),
|
||||||
|
interests: this.validInterests(tags.interests),
|
||||||
|
privacy: includePrivate ? this.privacy(profile) : undefined,
|
||||||
|
isFriend: includePrivate || viewerId === targetId ? false : isFriend,
|
||||||
|
blocked: includePrivate ? false : blockedEitherWay,
|
||||||
|
room_visitable: viewerId === targetId || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'public') || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'friends' && isFriend),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProfile(userId: bigint): Promise<ProfileRecord> {
|
||||||
|
const existing = await this.profiles.findByUserId(userId);
|
||||||
|
if (existing) return existing;
|
||||||
|
await this.accountProfileService.ensureProfile(userId);
|
||||||
|
const profile = await this.profiles.findByUserId(userId);
|
||||||
|
if (!profile) throw new NotFoundException('用户档案不存在');
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private profileTags(profile: ProfileRecord): Record<string, any> {
|
||||||
|
return profile.tags && typeof profile.tags === 'object' ? { ...profile.tags } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private privacy(profile: ProfileRecord): Record<string, boolean> {
|
||||||
|
const tags = this.profileTags(profile);
|
||||||
|
const values = tags[SOCIAL_SETTINGS_KEY];
|
||||||
|
return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomVisitPolicy(profile: ProfileRecord): 'friends' | 'public' | 'closed' {
|
||||||
|
const value = this.profileTags(profile)[SOCIAL_SETTINGS_KEY];
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const policy = String((value as Record<string, unknown>).room_visit_policy || 'friends');
|
||||||
|
if (ROOM_VISIT_POLICIES.has(policy)) return policy as 'friends' | 'public' | 'closed';
|
||||||
|
}
|
||||||
|
return 'friends';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canVisitRoomWithProfile(viewerId: bigint, ownerId: bigint, profile: ProfileRecord): Promise<boolean> {
|
||||||
|
if (viewerId === ownerId) return true;
|
||||||
|
if (await this.isBlockedEitherWay(viewerId, ownerId)) return false;
|
||||||
|
const policy = this.roomVisitPolicy(profile);
|
||||||
|
if (policy === 'public') return true;
|
||||||
|
return policy === 'friends' && await this.areFriends(viewerId, ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validInterests(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNearbyAllowed(sourceId: bigint, targetId: bigint, purpose: 'profile' | 'private' | 'friend') {
|
||||||
|
const targetSocketId = await this.sessions.getSocketIdByUserId(targetId.toString());
|
||||||
|
const sourceSocketId = await this.sessions.getSocketIdByUserId(sourceId.toString());
|
||||||
|
const targetTestPresence = getTestLabPresence(targetId);
|
||||||
|
const sourceTestPresence = getTestLabPresence(sourceId);
|
||||||
|
const [target, source, profile] = await Promise.all([
|
||||||
|
targetSocketId ? this.sessions.getSession(targetSocketId) : Promise.resolve(null),
|
||||||
|
sourceSocketId ? this.sessions.getSession(sourceSocketId) : Promise.resolve(null),
|
||||||
|
this.ensureProfile(targetId),
|
||||||
|
]);
|
||||||
|
const targetPosition = targetTestPresence?.online
|
||||||
|
? { mapId: targetTestPresence.mapId, x: targetTestPresence.x, y: targetTestPresence.y }
|
||||||
|
: target ? { mapId: target.currentMap, x: Number(target.position?.x || 0), y: Number(target.position?.y || 0) } : null;
|
||||||
|
const sourcePosition = sourceTestPresence?.online
|
||||||
|
? { mapId: sourceTestPresence.mapId, x: sourceTestPresence.x, y: sourceTestPresence.y }
|
||||||
|
: source ? { mapId: source.currentMap, x: Number(source.position?.x || 0), y: Number(source.position?.y || 0) } : null;
|
||||||
|
if (!targetPosition || !sourcePosition) throw new ForbiddenException('陌生玩家需要在线且在附近才能互动');
|
||||||
|
if (targetPosition.mapId !== sourcePosition.mapId) throw new ForbiddenException('陌生玩家仅可在同一地图互动');
|
||||||
|
const distance = Math.hypot(targetPosition.x - sourcePosition.x, targetPosition.y - sourcePosition.y);
|
||||||
|
if (distance > NEARBY_DISTANCE) throw new ForbiddenException('请靠近该玩家后再互动');
|
||||||
|
const privacy = this.privacy(profile);
|
||||||
|
const key = purpose === 'profile' ? 'allow_nearby_profile' : purpose === 'private' ? 'allow_nearby_private' : 'allow_nearby_friend_requests';
|
||||||
|
if (!privacy[key]) throw new ForbiddenException('对方已关闭此类附近互动');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async areFriends(userA: bigint, userB: bigint): Promise<boolean> {
|
||||||
|
const [low, high] = this.sortIds(userA, userB);
|
||||||
|
return Boolean(await this.store.findFriendship(low, high));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isBlockedEitherWay(userA: bigint, userB: bigint): Promise<boolean> {
|
||||||
|
const [aBlocksB, bBlocksA] = await Promise.all([this.store.isBlocked(userA, userB), this.store.isBlocked(userB, userA)]);
|
||||||
|
return aBlocksB || bBlocksA;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNotBlockedEitherWay(userA: bigint, userB: bigint) {
|
||||||
|
if (await this.isBlockedEitherWay(userA, userB)) throw new ForbiddenException('该互动当前不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createNotification(userId: bigint, category: string, title: string, content: string, actionMetadata: Record<string, unknown>) {
|
||||||
|
const notification = await this.store.createNotification({ user_id: userId, category, title, content, action_metadata: actionMetadata, expires_at: new Date(Date.now() + RETENTION_MS) });
|
||||||
|
await this.sendToUser(userId, { t: 'notification_created', notification: this.serializeNotification(notification) });
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendToUser(userId: bigint, payload: Record<string, unknown>) {
|
||||||
|
const socketId = await this.sessions.getSocketIdByUserId(userId.toString());
|
||||||
|
if (socketId && this.realtimeGateway) this.realtimeGateway.sendToPlayer(socketId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeDirectMessage(message: any) {
|
||||||
|
return { id: message.id.toString(), senderId: message.sender_id.toString(), recipientId: message.recipient_id.toString(), content: message.content, createdAt: message.created_at, readAt: message.read_at || null };
|
||||||
|
}
|
||||||
|
private serializeNotification(notification: any) {
|
||||||
|
return { id: notification.id.toString(), category: notification.category, title: notification.title, content: notification.content, actionMetadata: notification.action_metadata || {}, createdAt: notification.created_at, readAt: notification.read_at || null, expiresAt: notification.expires_at };
|
||||||
|
}
|
||||||
|
private destination(destinationId: string) {
|
||||||
|
const destination = TRAVEL_DESTINATIONS.find((item) => item.id === destinationId);
|
||||||
|
if (!destination) throw new NotFoundException('未知地点');
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
private async assertAtTravelDestination(userId: bigint, destination: (typeof TRAVEL_DESTINATIONS)[number]) {
|
||||||
|
const socketId = await this.sessions.getSocketIdByUserId(userId.toString());
|
||||||
|
const session = socketId ? await this.sessions.getSession(socketId) : null;
|
||||||
|
if (!session || session.currentMap !== destination.mapId) throw new ForbiddenException('需要先在该地点附近探索');
|
||||||
|
const origin = TRAVEL_MAP_ORIGINS[destination.mapId] || { x: 0, y: 0 };
|
||||||
|
const targetX = destination.x - origin.x;
|
||||||
|
const targetY = destination.y - origin.y;
|
||||||
|
const distance = Math.hypot(Number(session.position?.x || 0) - targetX, Number(session.position?.y || 0) - targetY);
|
||||||
|
if (distance > DISCOVERY_DISTANCE) throw new ForbiddenException('需要靠近地点后才能解锁');
|
||||||
|
}
|
||||||
|
private assertDistinct(userA: bigint, userB: bigint) { if (userA === userB) throw new BadRequestException('不能对自己执行此操作'); }
|
||||||
|
private sortIds(userA: bigint, userB: bigint): [bigint, bigint] { return userA < userB ? [userA, userB] : [userB, userA]; }
|
||||||
|
}
|
||||||
43
src/business/social/social.store.ts
Normal file
43
src/business/social/social.store.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
DirectMessage,
|
||||||
|
FriendRequest,
|
||||||
|
Friendship,
|
||||||
|
PlayerTravelUnlock,
|
||||||
|
SocialNotification,
|
||||||
|
UserBlock,
|
||||||
|
UserReport,
|
||||||
|
} from './social.entities';
|
||||||
|
|
||||||
|
export const SOCIAL_STORE = 'SOCIAL_STORE';
|
||||||
|
|
||||||
|
export interface SocialStore {
|
||||||
|
listFriendships(userId: bigint): Promise<Friendship[]>;
|
||||||
|
findFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship | null>;
|
||||||
|
createFriendship(userLowId: bigint, userHighId: bigint): Promise<Friendship>;
|
||||||
|
deleteFriendship(userLowId: bigint, userHighId: bigint): Promise<void>;
|
||||||
|
findPendingFriendRequest(requesterId: bigint, recipientId: bigint): Promise<FriendRequest | null>;
|
||||||
|
createFriendRequest(requesterId: bigint, recipientId: bigint, expiresAt: Date): Promise<FriendRequest>;
|
||||||
|
findFriendRequest(id: bigint): Promise<FriendRequest | null>;
|
||||||
|
saveFriendRequest(request: FriendRequest): Promise<FriendRequest>;
|
||||||
|
cancelPendingRequestsBetween(userA: bigint, userB: bigint): Promise<void>;
|
||||||
|
listFriendRequests(userId: bigint): Promise<FriendRequest[]>;
|
||||||
|
createBlock(userId: bigint, blockedUserId: bigint): Promise<UserBlock>;
|
||||||
|
deleteBlock(userId: bigint, blockedUserId: bigint): Promise<void>;
|
||||||
|
isBlocked(userId: bigint, blockedUserId: bigint): Promise<boolean>;
|
||||||
|
listBlocks(userId: bigint): Promise<UserBlock[]>;
|
||||||
|
createDirectMessage(input: Pick<DirectMessage, 'sender_id' | 'recipient_id' | 'content' | 'expires_at'>): Promise<DirectMessage>;
|
||||||
|
listDirectMessages(userA: bigint, userB: bigint, limit: number, before?: Date): Promise<DirectMessage[]>;
|
||||||
|
markDirectMessagesRead(readerId: bigint, otherUserId: bigint): Promise<number>;
|
||||||
|
listConversations(userId: bigint): Promise<DirectMessage[]>;
|
||||||
|
countUnreadDirectMessages(userId: bigint): Promise<number>;
|
||||||
|
createReport(input: Pick<UserReport, 'reporter_id' | 'reported_user_id' | 'reason' | 'note' | 'message_id'>): Promise<UserReport>;
|
||||||
|
createUnlock(userId: bigint, destinationId: string): Promise<PlayerTravelUnlock>;
|
||||||
|
listUnlocks(userId: bigint): Promise<PlayerTravelUnlock[]>;
|
||||||
|
createNotification(input: Pick<SocialNotification, 'user_id' | 'category' | 'title' | 'content' | 'action_metadata' | 'expires_at'>): Promise<SocialNotification>;
|
||||||
|
listNotifications(userId: bigint, limit: number, before?: Date): Promise<SocialNotification[]>;
|
||||||
|
markNotificationRead(userId: bigint, id: bigint): Promise<SocialNotification | null>;
|
||||||
|
markAllNotificationsRead(userId: bigint): Promise<number>;
|
||||||
|
countUnreadNotifications(userId: bigint): Promise<number>;
|
||||||
|
purgeUsers(userIds: bigint[]): Promise<void>;
|
||||||
|
cleanupExpired(now: Date): Promise<void>;
|
||||||
|
}
|
||||||
21
src/business/tasks/dto/report_task_activity.dto.ts
Normal file
21
src/business/tasks/dto/report_task_activity.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsIn, IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
import { TASK_ACTIVITY_TYPES } from '../task_catalog';
|
||||||
|
|
||||||
|
export class ReportTaskActivityDto {
|
||||||
|
@IsIn(TASK_ACTIVITY_TYPES)
|
||||||
|
activity: typeof TASK_ACTIVITY_TYPES[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
target_id?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]{16,64}$/)
|
||||||
|
nonce: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
occurred_at?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `player_task_progress` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`user_id` bigint NOT NULL COMMENT '关联users.id',
|
||||||
|
`task_id` varchar(80) NOT NULL COMMENT '静态任务ID',
|
||||||
|
`cycle_key` varchar(32) NOT NULL COMMENT '任务周期键',
|
||||||
|
`progress` int NOT NULL DEFAULT 0 COMMENT '当前进度',
|
||||||
|
`activity_state` json NOT NULL COMMENT '去重活动目标等状态',
|
||||||
|
`completed_at` timestamp NULL DEFAULT NULL COMMENT '完成时间',
|
||||||
|
`claimed_at` timestamp NULL DEFAULT NULL COMMENT '领奖时间',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_player_task_progress_task_cycle` (`user_id`, `task_id`, `cycle_key`),
|
||||||
|
KEY `idx_player_task_progress_user_cycle` (`user_id`, `cycle_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='玩家任务进度表';
|
||||||
21
src/business/tasks/migrations/create-task-activity-audit.sql
Normal file
21
src/business/tasks/migrations/create-task-activity-audit.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `task_activity_audit` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL,
|
||||||
|
`nonce` varchar(64) NOT NULL,
|
||||||
|
`activity` varchar(40) NOT NULL,
|
||||||
|
`requested_target_id` varchar(80) NULL,
|
||||||
|
`resolved_target_id` varchar(80) NULL,
|
||||||
|
`accepted` boolean NOT NULL DEFAULT FALSE,
|
||||||
|
`reason` varchar(120) NOT NULL,
|
||||||
|
`session_id` varchar(80) NULL,
|
||||||
|
`map_id` varchar(40) NULL,
|
||||||
|
`position_x` float NULL,
|
||||||
|
`position_y` float NULL,
|
||||||
|
`client_ip` varchar(64) NULL,
|
||||||
|
`user_agent` varchar(255) NULL,
|
||||||
|
`client_occurred_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_task_activity_audit_user_created` (`user_id`, `created_at`),
|
||||||
|
KEY `idx_task_activity_audit_nonce` (`user_id`, `nonce`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务活动校验审计';
|
||||||
36
src/business/tasks/player_task_progress.entity.ts
Normal file
36
src/business/tasks/player_task_progress.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('player_task_progress')
|
||||||
|
@Index('uq_player_task_progress_task_cycle', ['user_id', 'task_id', 'cycle_key'], { unique: true })
|
||||||
|
@Index('idx_player_task_progress_user_cycle', ['user_id', 'cycle_key'])
|
||||||
|
export class PlayerTaskProgress {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint', comment: '主键ID' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: false, comment: '关联users.id' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: false, comment: '静态任务ID' })
|
||||||
|
task_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: false, comment: '任务周期键' })
|
||||||
|
cycle_key: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: false, default: 0, comment: '当前进度' })
|
||||||
|
progress: number;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: false, comment: '去重活动目标等状态' })
|
||||||
|
activity_state: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, comment: '完成时间' })
|
||||||
|
completed_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, comment: '领奖时间' })
|
||||||
|
claimed_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', comment: '更新时间' })
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
45
src/business/tasks/task.service.ts
Normal file
45
src/business/tasks/task.service.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
|
||||||
|
import { TaskActivityAuthorityService } from './task_activity_authority.service';
|
||||||
|
import { TaskActivityType } from './task_catalog';
|
||||||
|
import { TaskBoardPayload, TaskClaimResult, TaskProgressStore } from './tasks.types';
|
||||||
|
|
||||||
|
const CLIENT_ACTIVITY_TYPES: TaskActivityType[] = [
|
||||||
|
'guide_opened',
|
||||||
|
'notice_viewed',
|
||||||
|
'map_visited',
|
||||||
|
'course_board_opened',
|
||||||
|
'facility_interacted',
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskService {
|
||||||
|
constructor(
|
||||||
|
@Inject('ITaskProgressStore') private readonly taskProgressStore: TaskProgressStore,
|
||||||
|
private readonly taskActivityAuthority: TaskActivityAuthorityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
|
return await this.taskProgressStore.getBoard(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordClientActivity(
|
||||||
|
userId: bigint,
|
||||||
|
dto: ReportTaskActivityDto,
|
||||||
|
context: { clientIp?: string; userAgent?: string } = {},
|
||||||
|
): Promise<TaskBoardPayload> {
|
||||||
|
if (!CLIENT_ACTIVITY_TYPES.includes(dto.activity)) {
|
||||||
|
throw new BadRequestException('该任务活动只能由服务器业务记录');
|
||||||
|
}
|
||||||
|
const resolved = await this.taskActivityAuthority.resolve(userId, dto, context);
|
||||||
|
return await this.taskProgressStore.recordActivity(userId, resolved.activity, resolved.targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
||||||
|
return await this.taskProgressStore.recordActivity(userId, activity, targetId?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
|
||||||
|
return await this.taskProgressStore.claim(userId, taskId.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/business/tasks/task_activity_audit.entity.ts
Normal file
54
src/business/tasks/task_activity_audit.entity.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('task_activity_audit')
|
||||||
|
@Index('idx_task_activity_audit_user_created', ['user_id', 'created_at'])
|
||||||
|
@Index('idx_task_activity_audit_nonce', ['user_id', 'nonce'])
|
||||||
|
export class TaskActivityAudit {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: false })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: false })
|
||||||
|
nonce: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40, nullable: false })
|
||||||
|
activity: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
requested_target_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
resolved_target_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false })
|
||||||
|
accepted: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: false })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
session_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40, nullable: true })
|
||||||
|
map_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
position_x: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
position_y: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
client_ip: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
user_agent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
client_occurred_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
22
src/business/tasks/task_activity_audit.store.ts
Normal file
22
src/business/tasks/task_activity_audit.store.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface TaskActivityAuditRecord {
|
||||||
|
user_id: bigint;
|
||||||
|
nonce: string;
|
||||||
|
activity: string;
|
||||||
|
requested_target_id: string | null;
|
||||||
|
resolved_target_id: string | null;
|
||||||
|
accepted: boolean;
|
||||||
|
reason: string;
|
||||||
|
session_id: string | null;
|
||||||
|
map_id: string | null;
|
||||||
|
position_x: number | null;
|
||||||
|
position_y: number | null;
|
||||||
|
client_ip: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
client_occurred_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskActivityAuditStore {
|
||||||
|
append(record: TaskActivityAuditRecord): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_ACTIVITY_AUDIT_STORE = 'TASK_ACTIVITY_AUDIT_STORE';
|
||||||
17
src/business/tasks/task_activity_audit_database.service.ts
Normal file
17
src/business/tasks/task_activity_audit_database.service.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { TaskActivityAudit } from './task_activity_audit.entity';
|
||||||
|
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuditDatabaseService implements TaskActivityAuditStore {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TaskActivityAudit)
|
||||||
|
private readonly repository: Repository<TaskActivityAudit>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async append(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
await this.repository.insert(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/business/tasks/task_activity_audit_memory.service.ts
Normal file
15
src/business/tasks/task_activity_audit_memory.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TaskActivityAuditRecord, TaskActivityAuditStore } from './task_activity_audit.store';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuditMemoryService implements TaskActivityAuditStore {
|
||||||
|
private readonly records: Array<TaskActivityAuditRecord & { created_at: Date }> = [];
|
||||||
|
private static readonly MAX_RECORDS = 5000;
|
||||||
|
|
||||||
|
async append(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
this.records.push({ ...record, created_at: new Date() });
|
||||||
|
if (this.records.length > TaskActivityAuditMemoryService.MAX_RECORDS) {
|
||||||
|
this.records.splice(0, this.records.length - TaskActivityAuditMemoryService.MAX_RECORDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/business/tasks/task_activity_authority.service.ts
Normal file
218
src/business/tasks/task_activity_authority.service.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { ConflictException, HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { IRedisService } from '../../core/redis/redis.interface';
|
||||||
|
import { Position } from '../../core/location_broadcast_core/position.interface';
|
||||||
|
import { LocationBroadcastCore } from '../../core/location_broadcast_core/location_broadcast_core.service';
|
||||||
|
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
|
||||||
|
import { TaskActivityType } from './task_catalog';
|
||||||
|
import {
|
||||||
|
TASK_ACTIVITY_AUDIT_STORE,
|
||||||
|
TaskActivityAuditRecord,
|
||||||
|
TaskActivityAuditStore,
|
||||||
|
} from './task_activity_audit.store';
|
||||||
|
|
||||||
|
interface TaskActivityRequestContext {
|
||||||
|
clientIp?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActivity {
|
||||||
|
activity: TaskActivityType;
|
||||||
|
targetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FacilityPoint {
|
||||||
|
mapId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_REQUIRED_ACTIVITIES = new Set<TaskActivityType>([
|
||||||
|
'map_visited',
|
||||||
|
'notice_viewed',
|
||||||
|
'course_board_opened',
|
||||||
|
'facility_interacted',
|
||||||
|
]);
|
||||||
|
const MAP_TASK_TARGETS: Record<string, string> = {
|
||||||
|
whale_port: 'square',
|
||||||
|
work_zone: 'work_zone',
|
||||||
|
whale_cafe: 'whale_cafe',
|
||||||
|
};
|
||||||
|
const FACILITY_POINTS: Record<string, FacilityPoint[]> = {
|
||||||
|
welcome_board: [{ mapId: 'whale_port', x: 1470, y: 408, radius: 220 }],
|
||||||
|
notice_board: [{ mapId: 'whale_port', x: 701, y: 1529, radius: 240 }],
|
||||||
|
course_board: [{ mapId: 'work_zone', x: 1778, y: 1366, radius: 240 }],
|
||||||
|
npc: [
|
||||||
|
{ mapId: 'whale_port', x: 1081, y: 445, radius: 220 },
|
||||||
|
{ mapId: 'whale_port', x: 455, y: 1397, radius: 220 },
|
||||||
|
{ mapId: 'whale_cafe', x: 296, y: 221, radius: 240 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const NONCE_TTL_SECONDS = 10 * 60;
|
||||||
|
const SESSION_FRESHNESS_MS = 30 * 1000;
|
||||||
|
const RATE_LIMIT_PER_MINUTE = 30;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskActivityAuthorityService {
|
||||||
|
private readonly logger = new Logger(TaskActivityAuthorityService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('REDIS_SERVICE') private readonly redisService: IRedisService,
|
||||||
|
private readonly locationBroadcastCore: LocationBroadcastCore,
|
||||||
|
@Inject(TASK_ACTIVITY_AUDIT_STORE) private readonly auditStore: TaskActivityAuditStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
userId: bigint,
|
||||||
|
dto: ReportTaskActivityDto,
|
||||||
|
context: TaskActivityRequestContext = {},
|
||||||
|
): Promise<ResolvedActivity> {
|
||||||
|
const requestedTargetId = dto.target_id?.trim() || undefined;
|
||||||
|
const auditBase: TaskActivityAuditRecord = {
|
||||||
|
user_id: userId,
|
||||||
|
nonce: dto.nonce,
|
||||||
|
activity: dto.activity,
|
||||||
|
requested_target_id: requestedTargetId ?? null,
|
||||||
|
resolved_target_id: null,
|
||||||
|
accepted: false,
|
||||||
|
reason: 'pending',
|
||||||
|
session_id: null,
|
||||||
|
map_id: null,
|
||||||
|
position_x: null,
|
||||||
|
position_y: null,
|
||||||
|
client_ip: context.clientIp?.slice(0, 64) || null,
|
||||||
|
user_agent: context.userAgent?.slice(0, 255) || null,
|
||||||
|
client_occurred_at: dto.occurred_at ? new Date(dto.occurred_at) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.enforceRateLimit(userId);
|
||||||
|
const nonceAccepted = await this.redisService.setIfAbsent(
|
||||||
|
`tasks:activity:nonce:${userId.toString()}:${dto.nonce}`,
|
||||||
|
'1',
|
||||||
|
NONCE_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
if (!nonceAccepted) {
|
||||||
|
throw new ConflictException('任务活动 nonce 已使用');
|
||||||
|
}
|
||||||
|
|
||||||
|
let position: Position | null = null;
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
if (SESSION_REQUIRED_ACTIVITIES.has(dto.activity)) {
|
||||||
|
[sessionId, position] = await Promise.all([
|
||||||
|
this.redisService.get(`user:${userId.toString()}:session`),
|
||||||
|
this.locationBroadcastCore.getUserPosition(userId.toString()),
|
||||||
|
]);
|
||||||
|
auditBase.session_id = sessionId;
|
||||||
|
auditBase.map_id = position?.mapId ?? null;
|
||||||
|
auditBase.position_x = position?.x ?? null;
|
||||||
|
auditBase.position_y = position?.y ?? null;
|
||||||
|
this.assertActiveSession(sessionId, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = this.resolveActivity(dto.activity, requestedTargetId, position);
|
||||||
|
await this.appendAudit({
|
||||||
|
...auditBase,
|
||||||
|
resolved_target_id: resolved.targetId ?? null,
|
||||||
|
accepted: true,
|
||||||
|
reason: 'accepted',
|
||||||
|
});
|
||||||
|
return resolved;
|
||||||
|
} catch (error) {
|
||||||
|
await this.appendAudit({
|
||||||
|
...auditBase,
|
||||||
|
reason: error instanceof Error ? error.message.slice(0, 120) : 'unknown_error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enforceRateLimit(userId: bigint): Promise<void> {
|
||||||
|
const minuteBucket = Math.floor(Date.now() / 60000);
|
||||||
|
const key = `tasks:activity:rate:${userId.toString()}:${minuteBucket}`;
|
||||||
|
const count = await this.redisService.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await this.redisService.expire(key, 120);
|
||||||
|
}
|
||||||
|
if (count > RATE_LIMIT_PER_MINUTE) {
|
||||||
|
throw new HttpException('任务活动上报过于频繁', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertActiveSession(sessionId: string | null, position: Position | null): asserts position is Position {
|
||||||
|
if (!sessionId || !position) {
|
||||||
|
throw new ConflictException('游戏会话尚未就绪');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(position.timestamp) || Date.now() - position.timestamp > SESSION_FRESHNESS_MS) {
|
||||||
|
throw new ConflictException('游戏会话位置已过期');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveActivity(
|
||||||
|
activity: TaskActivityType,
|
||||||
|
requestedTargetId: string | undefined,
|
||||||
|
position: Position | null,
|
||||||
|
): ResolvedActivity {
|
||||||
|
if (activity === 'map_visited') {
|
||||||
|
const targetId = MAP_TASK_TARGETS[position?.mapId ?? ''];
|
||||||
|
if (!targetId) {
|
||||||
|
throw new ConflictException('当前地图不计入任务进度');
|
||||||
|
}
|
||||||
|
if (requestedTargetId && requestedTargetId !== targetId) {
|
||||||
|
throw new ConflictException('地图活动与当前会话不一致');
|
||||||
|
}
|
||||||
|
return { activity, targetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'notice_viewed') {
|
||||||
|
this.assertNearFacility(position, 'notice_board');
|
||||||
|
return { activity, targetId: 'notice_board' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'course_board_opened') {
|
||||||
|
this.assertNearFacility(position, 'course_board');
|
||||||
|
return { activity, targetId: 'course_board' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity === 'facility_interacted') {
|
||||||
|
if (!requestedTargetId || !FACILITY_POINTS[requestedTargetId]) {
|
||||||
|
throw new ConflictException('设施活动目标无效');
|
||||||
|
}
|
||||||
|
this.assertNearFacility(position, requestedTargetId);
|
||||||
|
return { activity, targetId: requestedTargetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activity, targetId: requestedTargetId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNearFacility(position: Position | null, facilityId: string): void {
|
||||||
|
if (!position) {
|
||||||
|
throw new ConflictException('游戏会话尚未就绪');
|
||||||
|
}
|
||||||
|
const matched = FACILITY_POINTS[facilityId].some((point) => {
|
||||||
|
if (point.mapId !== position.mapId) return false;
|
||||||
|
const deltaX = position.x - point.x;
|
||||||
|
const deltaY = position.y - point.y;
|
||||||
|
return deltaX * deltaX + deltaY * deltaY <= point.radius * point.radius;
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
throw new ConflictException('玩家不在目标设施交互范围内');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendAudit(record: TaskActivityAuditRecord): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.auditStore.append(record);
|
||||||
|
this.logger.log({
|
||||||
|
operation: 'task_activity',
|
||||||
|
userId: record.user_id.toString(),
|
||||||
|
activity: record.activity,
|
||||||
|
accepted: record.accepted,
|
||||||
|
reason: record.reason,
|
||||||
|
mapId: record.map_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('任务活动审计写入失败', error instanceof Error ? error.stack : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/business/tasks/task_catalog.ts
Normal file
189
src/business/tasks/task_catalog.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
export const NEWBIE_CYCLE_KEY = 'newbie';
|
||||||
|
|
||||||
|
export const TASK_ACTIVITY_TYPES = [
|
||||||
|
'guide_opened',
|
||||||
|
'notice_viewed',
|
||||||
|
'map_visited',
|
||||||
|
'course_board_opened',
|
||||||
|
'facility_interacted',
|
||||||
|
'public_message_sent',
|
||||||
|
'skin_purchased',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TaskActivityType = typeof TASK_ACTIVITY_TYPES[number];
|
||||||
|
export type TaskGroup = 'newbie' | 'weekly';
|
||||||
|
export type TaskProgressMode = 'count' | 'unique_target';
|
||||||
|
|
||||||
|
export interface TaskDefinition {
|
||||||
|
id: string;
|
||||||
|
group: TaskGroup;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
reward: number;
|
||||||
|
target: number;
|
||||||
|
activity?: TaskActivityType;
|
||||||
|
progress_mode?: TaskProgressMode;
|
||||||
|
allowed_targets?: string[];
|
||||||
|
optional?: boolean;
|
||||||
|
bonus?: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyCycle {
|
||||||
|
key: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskProgressState {
|
||||||
|
targets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEWBIE_TASKS: TaskDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'newbie_guide',
|
||||||
|
group: 'newbie',
|
||||||
|
title: '翻阅新人手册',
|
||||||
|
description: '打开新人引导,了解鲸镇的基本操作。',
|
||||||
|
reward: 40,
|
||||||
|
target: 1,
|
||||||
|
activity: 'guide_opened',
|
||||||
|
sort_order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newbie_notice',
|
||||||
|
group: 'newbie',
|
||||||
|
title: '查看镇务公告',
|
||||||
|
description: '在广场查看一次公告栏。',
|
||||||
|
reward: 60,
|
||||||
|
target: 1,
|
||||||
|
activity: 'notice_viewed',
|
||||||
|
sort_order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newbie_work_zone',
|
||||||
|
group: 'newbie',
|
||||||
|
title: '探索打工区',
|
||||||
|
description: '前往打工区,看看小镇的工作与学习入口。',
|
||||||
|
reward: 80,
|
||||||
|
target: 1,
|
||||||
|
activity: 'map_visited',
|
||||||
|
allowed_targets: ['work_zone'],
|
||||||
|
sort_order: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newbie_course_board',
|
||||||
|
group: 'newbie',
|
||||||
|
title: '浏览课程板',
|
||||||
|
description: '在打工区打开 Datawhale 课程看板。',
|
||||||
|
reward: 120,
|
||||||
|
target: 1,
|
||||||
|
activity: 'course_board_opened',
|
||||||
|
sort_order: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newbie_first_skin',
|
||||||
|
group: 'newbie',
|
||||||
|
title: '选择你的形象',
|
||||||
|
description: '在鲸鱼商城购买任意一款皮肤。此任务可跳过。',
|
||||||
|
reward: 100,
|
||||||
|
target: 1,
|
||||||
|
activity: 'skin_purchased',
|
||||||
|
optional: true,
|
||||||
|
sort_order: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEEKLY_TASKS: TaskDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'weekly_explore',
|
||||||
|
group: 'weekly',
|
||||||
|
title: '海风巡游',
|
||||||
|
description: '探索两个不同的开放地图。',
|
||||||
|
reward: 100,
|
||||||
|
target: 2,
|
||||||
|
activity: 'map_visited',
|
||||||
|
progress_mode: 'unique_target',
|
||||||
|
allowed_targets: ['square', 'work_zone', 'whale_cafe'],
|
||||||
|
sort_order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weekly_course',
|
||||||
|
group: 'weekly',
|
||||||
|
title: '本周学习计划',
|
||||||
|
description: '打开一次 Datawhale 课程看板。',
|
||||||
|
reward: 100,
|
||||||
|
target: 1,
|
||||||
|
activity: 'course_board_opened',
|
||||||
|
sort_order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weekly_interact',
|
||||||
|
group: 'weekly',
|
||||||
|
title: '和小镇打招呼',
|
||||||
|
description: '与两个不同的公共设施或 NPC 互动。',
|
||||||
|
reward: 100,
|
||||||
|
target: 2,
|
||||||
|
activity: 'facility_interacted',
|
||||||
|
progress_mode: 'unique_target',
|
||||||
|
allowed_targets: ['welcome_board', 'notice_board', 'npc'],
|
||||||
|
sort_order: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weekly_public_message',
|
||||||
|
group: 'weekly',
|
||||||
|
title: '分享此刻',
|
||||||
|
description: '在公共频道成功发送一条消息。',
|
||||||
|
reward: 100,
|
||||||
|
target: 1,
|
||||||
|
activity: 'public_message_sent',
|
||||||
|
sort_order: 40,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEEKLY_COMPLETION_BONUS: TaskDefinition = {
|
||||||
|
id: 'weekly_completion_bonus',
|
||||||
|
group: 'weekly',
|
||||||
|
title: '本周任务书结算',
|
||||||
|
description: '完成本周全部四项任务后领取额外奖励。',
|
||||||
|
reward: 200,
|
||||||
|
target: 1,
|
||||||
|
bonus: true,
|
||||||
|
sort_order: 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCurrentWeeklyCycle(now: Date = new Date()): WeeklyCycle {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(formatter.formatToParts(now)
|
||||||
|
.filter((part) => part.type !== 'literal')
|
||||||
|
.map((part) => [part.type, part.value]));
|
||||||
|
const year = Number(parts.year);
|
||||||
|
const month = Number(parts.month);
|
||||||
|
const day = Number(parts.day);
|
||||||
|
const chinaDateAsUtc = Date.UTC(year, month - 1, day);
|
||||||
|
const weekday = new Date(chinaDateAsUtc).getUTCDay();
|
||||||
|
const daysSinceMonday = (weekday + 6) % 7;
|
||||||
|
const mondayAsUtc = chinaDateAsUtc - daysSinceMonday * 24 * 60 * 60 * 1000;
|
||||||
|
const monday = new Date(mondayAsUtc);
|
||||||
|
const cycleDate = monday.toISOString().slice(0, 10);
|
||||||
|
const startsAt = new Date(mondayAsUtc - 8 * 60 * 60 * 1000);
|
||||||
|
const endsAt = new Date(startsAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
return {
|
||||||
|
key: `weekly:${cycleDate}`,
|
||||||
|
starts_at: startsAt.toISOString(),
|
||||||
|
ends_at: endsAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskDefinitions(): TaskDefinition[] {
|
||||||
|
return [...NEWBIE_TASKS, ...WEEKLY_TASKS, WEEKLY_COMPLETION_BONUS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskCycleKey(definition: TaskDefinition, cycle: WeeklyCycle): string {
|
||||||
|
return definition.group === 'weekly' ? cycle.key : NEWBIE_CYCLE_KEY;
|
||||||
|
}
|
||||||
140
src/business/tasks/task_progress_database.service.ts
Normal file
140
src/business/tasks/task_progress_database.service.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
import { UserWalletsService } from '../../core/db/user_wallets/user_wallets.service';
|
||||||
|
import {
|
||||||
|
getCurrentWeeklyCycle,
|
||||||
|
getTaskCycleKey,
|
||||||
|
getTaskDefinitions,
|
||||||
|
NEWBIE_CYCLE_KEY,
|
||||||
|
TaskActivityType,
|
||||||
|
TaskDefinition,
|
||||||
|
TaskProgressState,
|
||||||
|
WEEKLY_COMPLETION_BONUS,
|
||||||
|
WEEKLY_TASKS,
|
||||||
|
WeeklyCycle,
|
||||||
|
} from './task_catalog';
|
||||||
|
import { PlayerTaskProgress } from './player_task_progress.entity';
|
||||||
|
import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskProgressDatabaseService implements TaskProgressStore {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PlayerTaskProgress) private readonly progressRepository: Repository<PlayerTaskProgress>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly walletService: UserWalletsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
await this.ensureRows(this.progressRepository, userId, cycle);
|
||||||
|
const rows = await this.findRows(this.progressRepository, userId, cycle);
|
||||||
|
await this.syncWeeklyBonus(this.progressRepository, rows);
|
||||||
|
return buildTaskBoard(rows, cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repository = manager.getRepository(PlayerTaskProgress);
|
||||||
|
await this.ensureRows(repository, userId, cycle);
|
||||||
|
const rows = await this.findRows(repository, userId, cycle, true);
|
||||||
|
for (const definition of getTaskDefinitions()) {
|
||||||
|
if (definition.bonus || definition.activity !== activity) continue;
|
||||||
|
const row = this.findRow(rows, definition, cycle);
|
||||||
|
this.applyActivity(definition, row, targetId);
|
||||||
|
}
|
||||||
|
await repository.save(rows);
|
||||||
|
await this.syncWeeklyBonus(repository, rows);
|
||||||
|
return buildTaskBoard(rows, cycle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
const definition = getTaskDefinitions().find((item) => item.id === taskId);
|
||||||
|
if (!definition) throw new BadRequestException('任务不存在');
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repository = manager.getRepository(PlayerTaskProgress);
|
||||||
|
await this.ensureRows(repository, userId, cycle);
|
||||||
|
const rows = await this.findRows(repository, userId, cycle, true);
|
||||||
|
await this.syncWeeklyBonus(repository, rows);
|
||||||
|
const row = this.findRow(rows, definition, cycle);
|
||||||
|
if (!row.completed_at) throw new BadRequestException('任务尚未完成');
|
||||||
|
if (row.claimed_at) throw new ConflictException('任务奖励已领取');
|
||||||
|
const walletResult = await this.walletService.earnInTransaction(
|
||||||
|
manager,
|
||||||
|
userId,
|
||||||
|
definition.reward,
|
||||||
|
'task_reward',
|
||||||
|
`${getTaskCycleKey(definition, cycle)}:${definition.id}`,
|
||||||
|
`任务奖励:${definition.title}`,
|
||||||
|
);
|
||||||
|
row.claimed_at = new Date();
|
||||||
|
await repository.save(row);
|
||||||
|
return {
|
||||||
|
board: buildTaskBoard(rows, cycle),
|
||||||
|
wallet: {
|
||||||
|
user_id: userId.toString(),
|
||||||
|
balance: walletResult.wallet.balance,
|
||||||
|
currency: 'whale_coin',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRows(repository: Repository<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle): Promise<void> {
|
||||||
|
const values = getTaskDefinitions().map((definition) => ({
|
||||||
|
user_id: userId,
|
||||||
|
task_id: definition.id,
|
||||||
|
cycle_key: getTaskCycleKey(definition, cycle),
|
||||||
|
progress: 0,
|
||||||
|
activity_state: {},
|
||||||
|
completed_at: null,
|
||||||
|
claimed_at: null,
|
||||||
|
}));
|
||||||
|
await repository.createQueryBuilder().insert().values(values).orIgnore().execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRows(repository: Repository<PlayerTaskProgress>, userId: bigint, cycle: WeeklyCycle, lock = false): Promise<PlayerTaskProgress[]> {
|
||||||
|
return await repository.find({
|
||||||
|
where: { user_id: userId, cycle_key: In([cycle.key, NEWBIE_CYCLE_KEY]) },
|
||||||
|
...(lock ? { lock: { mode: 'pessimistic_write' as const } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findRow(rows: PlayerTaskProgress[], definition: TaskDefinition, cycle: WeeklyCycle): PlayerTaskProgress {
|
||||||
|
const cycleKey = getTaskCycleKey(definition, cycle);
|
||||||
|
const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey);
|
||||||
|
if (!row) throw new Error(`任务进度缺失: ${definition.id}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyActivity(definition: TaskDefinition, row: PlayerTaskProgress, targetId?: string): void {
|
||||||
|
if (row.completed_at) return;
|
||||||
|
if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return;
|
||||||
|
if (definition.progress_mode === 'unique_target') {
|
||||||
|
if (!targetId) return;
|
||||||
|
const state = row.activity_state as TaskProgressState;
|
||||||
|
const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : [];
|
||||||
|
if (targets.includes(targetId)) return;
|
||||||
|
targets.push(targetId);
|
||||||
|
row.activity_state = { ...state, targets };
|
||||||
|
row.progress = Math.min(definition.target, targets.length);
|
||||||
|
} else {
|
||||||
|
row.progress = Math.min(definition.target, row.progress + 1);
|
||||||
|
}
|
||||||
|
if (row.progress >= definition.target) row.completed_at = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncWeeklyBonus(repository: Repository<PlayerTaskProgress>, rows: PlayerTaskProgress[]): Promise<void> {
|
||||||
|
const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id);
|
||||||
|
if (!bonus || bonus.completed_at) return;
|
||||||
|
const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at));
|
||||||
|
if (complete) {
|
||||||
|
bonus.progress = 1;
|
||||||
|
bonus.completed_at = new Date();
|
||||||
|
await repository.save(bonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/business/tasks/task_progress_memory.service.ts
Normal file
147
src/business/tasks/task_progress_memory.service.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PlayerWalletPayload } from '../player/player.types';
|
||||||
|
import {
|
||||||
|
getCurrentWeeklyCycle,
|
||||||
|
getTaskCycleKey,
|
||||||
|
getTaskDefinitions,
|
||||||
|
NEWBIE_CYCLE_KEY,
|
||||||
|
TaskActivityType,
|
||||||
|
TaskDefinition,
|
||||||
|
TaskProgressState,
|
||||||
|
WEEKLY_COMPLETION_BONUS,
|
||||||
|
WEEKLY_TASKS,
|
||||||
|
} from './task_catalog';
|
||||||
|
import { buildTaskBoard, TaskBoardPayload, TaskClaimResult, TaskProgressRow, TaskProgressStore } from './tasks.types';
|
||||||
|
|
||||||
|
interface IUserWalletsService {
|
||||||
|
earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryProgressRow extends TaskProgressRow {
|
||||||
|
user_id: bigint;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskProgressMemoryService implements TaskProgressStore {
|
||||||
|
private readonly rows = new Map<string, MemoryProgressRow>();
|
||||||
|
|
||||||
|
constructor(@Inject('IUserWalletsService') private readonly walletService: IUserWalletsService) {}
|
||||||
|
|
||||||
|
async getBoard(userId: bigint): Promise<TaskBoardPayload> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
const rows = this.ensureRows(userId, cycle.key);
|
||||||
|
this.syncWeeklyBonus(rows);
|
||||||
|
return buildTaskBoard(rows, cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
const rows = this.ensureRows(userId, cycle.key);
|
||||||
|
for (const definition of getTaskDefinitions()) {
|
||||||
|
if (definition.bonus || definition.activity !== activity) continue;
|
||||||
|
const row = this.findRow(rows, definition, cycle.key);
|
||||||
|
this.applyActivity(definition, row, targetId);
|
||||||
|
}
|
||||||
|
this.syncWeeklyBonus(rows);
|
||||||
|
return buildTaskBoard(rows, cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async claim(userId: bigint, taskId: string): Promise<TaskClaimResult> {
|
||||||
|
const cycle = getCurrentWeeklyCycle();
|
||||||
|
const rows = this.ensureRows(userId, cycle.key);
|
||||||
|
this.syncWeeklyBonus(rows);
|
||||||
|
const definition = getTaskDefinitions().find((item) => item.id === taskId);
|
||||||
|
if (!definition) {
|
||||||
|
throw new BadRequestException('任务不存在');
|
||||||
|
}
|
||||||
|
const row = this.findRow(rows, definition, cycle.key);
|
||||||
|
if (!row.completed_at) {
|
||||||
|
throw new BadRequestException('任务尚未完成');
|
||||||
|
}
|
||||||
|
if (row.claimed_at) {
|
||||||
|
throw new ConflictException('任务奖励已领取');
|
||||||
|
}
|
||||||
|
const result = await this.walletService.earn(
|
||||||
|
userId,
|
||||||
|
definition.reward,
|
||||||
|
'task_reward',
|
||||||
|
`${getTaskCycleKey(definition, cycle)}:${definition.id}`,
|
||||||
|
`任务奖励:${definition.title}`,
|
||||||
|
);
|
||||||
|
row.claimed_at = new Date();
|
||||||
|
row.updated_at = new Date();
|
||||||
|
const wallet: PlayerWalletPayload = {
|
||||||
|
user_id: userId.toString(),
|
||||||
|
balance: result.wallet.balance,
|
||||||
|
currency: 'whale_coin',
|
||||||
|
};
|
||||||
|
return { board: buildTaskBoard(rows, cycle), wallet };
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRows(userId: bigint, weeklyCycleKey: string): MemoryProgressRow[] {
|
||||||
|
for (const definition of getTaskDefinitions()) {
|
||||||
|
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
|
||||||
|
const key = this.rowKey(userId, definition.id, cycleKey);
|
||||||
|
if (!this.rows.has(key)) {
|
||||||
|
const now = new Date();
|
||||||
|
this.rows.set(key, {
|
||||||
|
user_id: userId,
|
||||||
|
task_id: definition.id,
|
||||||
|
cycle_key: cycleKey,
|
||||||
|
progress: 0,
|
||||||
|
activity_state: {},
|
||||||
|
completed_at: null,
|
||||||
|
claimed_at: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getTaskDefinitions().map((definition) => {
|
||||||
|
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
|
||||||
|
return this.rows.get(this.rowKey(userId, definition.id, cycleKey)) as MemoryProgressRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findRow(rows: MemoryProgressRow[], definition: TaskDefinition, weeklyCycleKey: string): MemoryProgressRow {
|
||||||
|
const cycleKey = definition.group === 'weekly' ? weeklyCycleKey : NEWBIE_CYCLE_KEY;
|
||||||
|
const row = rows.find((item) => item.task_id === definition.id && item.cycle_key === cycleKey);
|
||||||
|
if (!row) throw new Error(`任务进度缺失: ${definition.id}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyActivity(definition: TaskDefinition, row: MemoryProgressRow, targetId?: string): void {
|
||||||
|
if (row.completed_at) return;
|
||||||
|
if (definition.allowed_targets && (!targetId || !definition.allowed_targets.includes(targetId))) return;
|
||||||
|
if (definition.progress_mode === 'unique_target') {
|
||||||
|
if (!targetId) return;
|
||||||
|
const state = row.activity_state as TaskProgressState;
|
||||||
|
const targets = Array.isArray(state.targets) ? state.targets.filter((item): item is string => typeof item === 'string') : [];
|
||||||
|
if (targets.includes(targetId)) return;
|
||||||
|
targets.push(targetId);
|
||||||
|
row.activity_state = { ...state, targets };
|
||||||
|
row.progress = Math.min(definition.target, targets.length);
|
||||||
|
} else {
|
||||||
|
row.progress = Math.min(definition.target, row.progress + 1);
|
||||||
|
}
|
||||||
|
if (row.progress >= definition.target) row.completed_at = new Date();
|
||||||
|
row.updated_at = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncWeeklyBonus(rows: MemoryProgressRow[]): void {
|
||||||
|
const bonus = rows.find((row) => row.task_id === WEEKLY_COMPLETION_BONUS.id);
|
||||||
|
if (!bonus || bonus.completed_at) return;
|
||||||
|
const complete = WEEKLY_TASKS.every((definition) => rows.some((row) => row.task_id === definition.id && row.completed_at));
|
||||||
|
if (complete) {
|
||||||
|
bonus.progress = 1;
|
||||||
|
bonus.completed_at = new Date();
|
||||||
|
bonus.updated_at = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowKey(userId: bigint, taskId: string, cycleKey: string): string {
|
||||||
|
return `${userId.toString()}:${taskId}:${cycleKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/business/tasks/tasks.controller.ts
Normal file
52
src/business/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Body, Controller, Get, HttpStatus, Param, Post, Req, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||||
|
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||||
|
import { ReportTaskActivityDto } from './dto/report_task_activity.dto';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
|
||||||
|
@ApiTags('tasks')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('tasks')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TasksController {
|
||||||
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
|
@Get('board')
|
||||||
|
@ApiOperation({ summary: '获取玩家任务书' })
|
||||||
|
@SwaggerApiResponse({ status: 200, description: '任务书获取成功' })
|
||||||
|
async getBoard(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||||
|
const data = await this.taskService.getBoard(BigInt(user.sub));
|
||||||
|
res.status(HttpStatus.OK).json({ success: true, data, message: '任务书获取成功' });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('activities')
|
||||||
|
@ApiOperation({ summary: '上报客户端白名单任务活动' })
|
||||||
|
@ApiBody({ type: ReportTaskActivityDto })
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
async reportActivity(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ReportTaskActivityDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.taskService.recordClientActivity(BigInt(user.sub), dto, {
|
||||||
|
clientIp: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
});
|
||||||
|
res.status(HttpStatus.OK).json({ success: true, data, message: '任务进度已更新' });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':taskId/claim')
|
||||||
|
@ApiOperation({ summary: '领取任务奖励' })
|
||||||
|
async claim(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('taskId') taskId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await this.taskService.claim(BigInt(user.sub), taskId);
|
||||||
|
res.status(HttpStatus.OK).json({ success: true, data, message: '任务奖励已领取' });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/business/tasks/tasks.module.ts
Normal file
64
src/business/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LocationBroadcastCoreModule } from '../../core/location_broadcast_core/location_broadcast_core.module';
|
||||||
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { RedisModule } from '../../core/redis/redis.module';
|
||||||
|
import { PlayerTaskProgress } from './player_task_progress.entity';
|
||||||
|
import { TaskActivityAudit } from './task_activity_audit.entity';
|
||||||
|
import { TASK_ACTIVITY_AUDIT_STORE } from './task_activity_audit.store';
|
||||||
|
import { TaskActivityAuditDatabaseService } from './task_activity_audit_database.service';
|
||||||
|
import { TaskActivityAuditMemoryService } from './task_activity_audit_memory.service';
|
||||||
|
import { TaskActivityAuthorityService } from './task_activity_authority.service';
|
||||||
|
import { TaskProgressDatabaseService } from './task_progress_database.service';
|
||||||
|
import { TaskProgressMemoryService } from './task_progress_memory.service';
|
||||||
|
import { TaskService } from './task.service';
|
||||||
|
import { TasksController } from './tasks.controller';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({})
|
||||||
|
export class TasksModule {
|
||||||
|
static forDatabase(): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: TasksModule,
|
||||||
|
global: true,
|
||||||
|
imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule, TypeOrmModule.forFeature([PlayerTaskProgress, TaskActivityAudit])],
|
||||||
|
controllers: [TasksController],
|
||||||
|
providers: [
|
||||||
|
TaskProgressDatabaseService,
|
||||||
|
{ provide: 'ITaskProgressStore', useExisting: TaskProgressDatabaseService },
|
||||||
|
TaskActivityAuditDatabaseService,
|
||||||
|
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditDatabaseService },
|
||||||
|
TaskActivityAuthorityService,
|
||||||
|
TaskService,
|
||||||
|
],
|
||||||
|
exports: [TaskService, 'ITaskProgressStore'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static forMemory(): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: TasksModule,
|
||||||
|
global: true,
|
||||||
|
imports: [LoginCoreModule, RedisModule, LocationBroadcastCoreModule],
|
||||||
|
controllers: [TasksController],
|
||||||
|
providers: [
|
||||||
|
TaskProgressMemoryService,
|
||||||
|
{ provide: 'ITaskProgressStore', useExisting: TaskProgressMemoryService },
|
||||||
|
TaskActivityAuditMemoryService,
|
||||||
|
{ provide: TASK_ACTIVITY_AUDIT_STORE, useExisting: TaskActivityAuditMemoryService },
|
||||||
|
TaskActivityAuthorityService,
|
||||||
|
TaskService,
|
||||||
|
],
|
||||||
|
exports: [TaskService, 'ITaskProgressStore'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static forRoot(useMemory?: boolean): DynamicModule {
|
||||||
|
const shouldUseMemory = useMemory ?? (
|
||||||
|
process.env.NODE_ENV === 'test' ||
|
||||||
|
process.env.USE_MEMORY_STORAGE === 'true' ||
|
||||||
|
!process.env.DB_HOST
|
||||||
|
);
|
||||||
|
return shouldUseMemory ? this.forMemory() : this.forDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/business/tasks/tasks.types.ts
Normal file
78
src/business/tasks/tasks.types.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { PlayerWalletPayload } from '../player/player.types';
|
||||||
|
import { getTaskCycleKey, NEWBIE_TASKS, TaskActivityType, TaskDefinition, WEEKLY_COMPLETION_BONUS, WEEKLY_TASKS, WeeklyCycle } from './task_catalog';
|
||||||
|
|
||||||
|
export interface TaskProgressRow {
|
||||||
|
task_id: string;
|
||||||
|
cycle_key: string;
|
||||||
|
progress: number;
|
||||||
|
activity_state: Record<string, unknown>;
|
||||||
|
completed_at: Date | null;
|
||||||
|
claimed_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskPayload {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
reward: number;
|
||||||
|
target: number;
|
||||||
|
progress: number;
|
||||||
|
optional: boolean;
|
||||||
|
bonus: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
claimed: boolean;
|
||||||
|
claimable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskBoardPayload {
|
||||||
|
weekly_cycle: WeeklyCycle;
|
||||||
|
newbie_tasks: TaskPayload[];
|
||||||
|
weekly_tasks: TaskPayload[];
|
||||||
|
weekly_bonus: TaskPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskClaimResult {
|
||||||
|
board: TaskBoardPayload;
|
||||||
|
wallet: PlayerWalletPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskProgressStore {
|
||||||
|
getBoard(userId: bigint): Promise<TaskBoardPayload>;
|
||||||
|
recordActivity(userId: bigint, activity: TaskActivityType, targetId?: string): Promise<TaskBoardPayload>;
|
||||||
|
claim(userId: bigint, taskId: string): Promise<TaskClaimResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTaskPayload(definition: TaskDefinition, row: TaskProgressRow): TaskPayload {
|
||||||
|
const completed = row.completed_at != null;
|
||||||
|
const claimed = row.claimed_at != null;
|
||||||
|
return {
|
||||||
|
id: definition.id,
|
||||||
|
title: definition.title,
|
||||||
|
description: definition.description,
|
||||||
|
reward: definition.reward,
|
||||||
|
target: definition.target,
|
||||||
|
progress: Math.min(definition.target, Math.max(0, row.progress)),
|
||||||
|
optional: Boolean(definition.optional),
|
||||||
|
bonus: Boolean(definition.bonus),
|
||||||
|
completed,
|
||||||
|
claimed,
|
||||||
|
claimable: completed && !claimed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTaskBoard(rows: TaskProgressRow[], cycle: WeeklyCycle): TaskBoardPayload {
|
||||||
|
const rowsByKey = new Map(rows.map((row) => [`${row.task_id}:${row.cycle_key}`, row]));
|
||||||
|
const rowFor = (definition: TaskDefinition): TaskProgressRow => {
|
||||||
|
const row = rowsByKey.get(`${definition.id}:${getTaskCycleKey(definition, cycle)}`);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`任务进度缺失: ${definition.id}`);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
weekly_cycle: cycle,
|
||||||
|
newbie_tasks: NEWBIE_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))),
|
||||||
|
weekly_tasks: WEEKLY_TASKS.map((definition) => toTaskPayload(definition, rowFor(definition))),
|
||||||
|
weekly_bonus: toTaskPayload(WEEKLY_COMPLETION_BONUS, rowFor(WEEKLY_COMPLETION_BONUS)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ export class AdminCoreService implements OnModuleInit {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.ensureDevelopmentTestAdmin();
|
||||||
await this.bootstrapAdminIfEnabled();
|
await this.bootstrapAdminIfEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +318,50 @@ export class AdminCoreService implements OnModuleInit {
|
|||||||
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发/测试专用管理员。密码必须由本地或 CI 环境注入,不能作为生产回退值。
|
||||||
|
*/
|
||||||
|
private async ensureDevelopmentTestAdmin(): Promise<void> {
|
||||||
|
const environment = this.configService.get<string>('NODE_ENV', 'development');
|
||||||
|
if (environment !== 'development' && environment !== 'test') return;
|
||||||
|
if (this.configService.get<string>('TEST_ADMIN_AUTO_PROVISION', 'true') !== 'true') return;
|
||||||
|
|
||||||
|
const username = this.configService.get<string>('TEST_ADMIN_USERNAME', 'admin')?.trim();
|
||||||
|
const password = this.configService.get<string>('TEST_ADMIN_PASSWORD')
|
||||||
|
|| this.configService.get<string>('ADMIN_PASSWORD');
|
||||||
|
const nickname = this.configService.get<string>('TEST_ADMIN_NICKNAME', '测试管理员');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
this.logger.warn('测试管理员未创建:请在开发/测试环境配置 TEST_ADMIN_PASSWORD');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.usersService.findByUsername(username);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== 9) {
|
||||||
|
this.logger.warn(`测试管理员用户名已被普通账号占用,拒绝提权:${username}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.validatePasswordStrength(password);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : '密码不满足强度要求';
|
||||||
|
this.logger.warn(`测试管理员未创建:TEST_ADMIN_PASSWORD 配置无效(${reason})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.usersService.create({
|
||||||
|
username,
|
||||||
|
password_hash: await this.hashPassword(password),
|
||||||
|
nickname,
|
||||||
|
role: 9,
|
||||||
|
email_verified: true,
|
||||||
|
is_test_account: false,
|
||||||
|
});
|
||||||
|
this.logger.log(`开发/测试管理员账号已创建:${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
private getAdminTokenSecret(): string {
|
private getAdminTokenSecret(): string {
|
||||||
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
||||||
if (!secret || secret.length < 16) {
|
if (!secret || secret.length < 16) {
|
||||||
|
|||||||
@@ -19,10 +19,21 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` (
|
|||||||
`position_x` FLOAT NULL COMMENT '房间内X坐标',
|
`position_x` FLOAT NULL COMMENT '房间内X坐标',
|
||||||
`position_y` FLOAT NULL COMMENT '房间内Y坐标',
|
`position_y` FLOAT NULL COMMENT '房间内Y坐标',
|
||||||
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
|
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
|
||||||
|
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
|
||||||
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
|
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
|
||||||
|
`mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本',
|
||||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`),
|
UNIQUE KEY `idx_room_decor_placements_user_decor_unique` (`user_id`, `decor_id`),
|
||||||
KEY `idx_room_decor_placements_user_id` (`user_id`)
|
KEY `idx_room_decor_placements_user_id` (`user_id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
|
||||||
|
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
|
||||||
|
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
|
||||||
|
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE `room_decor_placements`
|
||||||
|
ADD COLUMN IF NOT EXISTS `mutation_revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改该摆件的布局版本' AFTER `z_index`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `room_decor_layout_state` (
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '关联users.id',
|
||||||
|
`revision` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前布局版本',
|
||||||
|
`last_mutation_id` VARCHAR(64) NULL COMMENT '最后一次幂等mutation ID',
|
||||||
|
`last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域',
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `room_decor_layout_state`
|
||||||
|
ADD COLUMN IF NOT EXISTS `last_mutation_scope` VARCHAR(120) NULL COMMENT '最后一次mutation作用域' AFTER `last_mutation_id`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `room_decor_placements`
|
||||||
|
ADD COLUMN IF NOT EXISTS `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度' AFTER `scale`;
|
||||||
@@ -4,6 +4,7 @@ import { PlayerAssets } from './player_assets.entity';
|
|||||||
import { PlayerAssetsMemoryService } from './player_assets_memory.service';
|
import { PlayerAssetsMemoryService } from './player_assets_memory.service';
|
||||||
import { PlayerAssetsService } from './player_assets.service';
|
import { PlayerAssetsService } from './player_assets.service';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
|
||||||
import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service';
|
import { RoomDecorPlacementsMemoryService } from './room_decor_placements_memory.service';
|
||||||
import { RoomDecorPlacementsService } from './room_decor_placements.service';
|
import { RoomDecorPlacementsService } from './room_decor_placements.service';
|
||||||
|
|
||||||
@@ -13,12 +14,12 @@ export class PlayerAssetsModule {
|
|||||||
static forDatabase(): DynamicModule {
|
static forDatabase(): DynamicModule {
|
||||||
return {
|
return {
|
||||||
module: PlayerAssetsModule,
|
module: PlayerAssetsModule,
|
||||||
imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements])],
|
imports: [TypeOrmModule.forFeature([PlayerAssets, RoomDecorPlacements, RoomDecorLayoutState])],
|
||||||
providers: [
|
providers: [
|
||||||
PlayerAssetsService,
|
PlayerAssetsService,
|
||||||
RoomDecorPlacementsService,
|
RoomDecorPlacementsService,
|
||||||
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsService },
|
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsService },
|
||||||
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsService },
|
{ provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsService },
|
||||||
],
|
],
|
||||||
exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
exports: [PlayerAssetsService, RoomDecorPlacementsService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
||||||
};
|
};
|
||||||
@@ -31,7 +32,7 @@ export class PlayerAssetsModule {
|
|||||||
PlayerAssetsMemoryService,
|
PlayerAssetsMemoryService,
|
||||||
RoomDecorPlacementsMemoryService,
|
RoomDecorPlacementsMemoryService,
|
||||||
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService },
|
{ provide: 'IPlayerAssetsService', useClass: PlayerAssetsMemoryService },
|
||||||
{ provide: 'IRoomDecorPlacementsService', useClass: RoomDecorPlacementsMemoryService },
|
{ provide: 'IRoomDecorPlacementsService', useExisting: RoomDecorPlacementsMemoryService },
|
||||||
],
|
],
|
||||||
exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
exports: [PlayerAssetsMemoryService, RoomDecorPlacementsMemoryService, 'IPlayerAssetsService', 'IRoomDecorPlacementsService'],
|
||||||
};
|
};
|
||||||
|
|||||||
19
src/core/db/player_assets/room_decor_layout_state.entity.ts
Normal file
19
src/core/db/player_assets/room_decor_layout_state.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('room_decor_layout_state')
|
||||||
|
export class RoomDecorLayoutState {
|
||||||
|
@PrimaryColumn({ type: 'bigint', comment: '关联users.id' })
|
||||||
|
user_id: bigint;
|
||||||
|
|
||||||
|
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '当前布局版本' })
|
||||||
|
revision: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true, comment: '最后一次幂等mutation ID' })
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, nullable: true, comment: '最后一次mutation作用域' })
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@@ -25,9 +25,15 @@ export class RoomDecorPlacements {
|
|||||||
@Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' })
|
@Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' })
|
||||||
scale: number;
|
scale: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: false, default: 0, comment: '摆件旋转角度' })
|
||||||
|
rotation_degrees: number;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
|
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
|
||||||
z_index: number;
|
z_index: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', unsigned: true, nullable: false, default: 0, comment: '最后修改该摆件的布局版本' })
|
||||||
|
mutation_revision: number;
|
||||||
|
|
||||||
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
|
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', comment: '创建时间' })
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,149 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
|
||||||
|
import { RoomDecorLayoutState } from './room_decor_layout_state.entity';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomDecorPlacementsService {
|
export class RoomDecorPlacementsService {
|
||||||
constructor(
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
@InjectRepository(RoomDecorPlacements)
|
|
||||||
private readonly placementsRepository: Repository<RoomDecorPlacements>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
||||||
return await this.placementsRepository.find({
|
return await this.dataSource.getRepository(RoomDecorPlacements).find({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId },
|
||||||
order: { created_at: 'ASC', id: 'ASC' },
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> {
|
async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
const decorId = this.normalizeDecorId(placement.decor_id);
|
return await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||||
let row = await this.placementsRepository.findOne({
|
await this.ensureLayoutState(manager, userId);
|
||||||
where: { user_id: userId, decor_id: decorId },
|
const state = await manager.getRepository(RoomDecorLayoutState).findOneByOrFail({ user_id: userId });
|
||||||
|
const placements = await manager.getRepository(RoomDecorPlacements).find({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
return { revision: state.revision, placements };
|
||||||
});
|
});
|
||||||
if (!row) {
|
}
|
||||||
row = new RoomDecorPlacements();
|
|
||||||
row.user_id = userId;
|
async savePlacement(
|
||||||
row.decor_id = decorId;
|
userId: bigint,
|
||||||
row.created_at = new Date();
|
placement: SaveRoomDecorPlacementDto,
|
||||||
|
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockLayoutState(manager, userId);
|
||||||
|
const placementsRepository = manager.getRepository(RoomDecorPlacements);
|
||||||
|
const decorId = this.normalizeDecorId(placement.decor_id);
|
||||||
|
const mutationScope = `decor:${decorId}`;
|
||||||
|
let row = await placementsRepository.findOne({
|
||||||
|
where: { user_id: userId, decor_id: decorId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (this.isIdempotentRetry(state, placement, mutationScope)) {
|
||||||
|
if (!row) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
this.assertNextRevision(state, placement);
|
||||||
|
if (!row) {
|
||||||
|
row = new RoomDecorPlacements();
|
||||||
|
row.user_id = userId;
|
||||||
|
row.decor_id = decorId;
|
||||||
|
row.created_at = new Date();
|
||||||
|
}
|
||||||
|
row.placed = placement.placed;
|
||||||
|
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
||||||
|
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
||||||
|
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||||
|
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||||
|
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||||
|
row.mutation_revision = placement.mutation_revision;
|
||||||
|
row.updated_at = new Date();
|
||||||
|
const saved = await placementsRepository.save(row);
|
||||||
|
await this.advanceLayoutState(manager, state, placement.mutation_revision, placement.mutation_id, mutationScope);
|
||||||
|
return { revision: placement.mutation_revision, placement: saved };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPlacements(
|
||||||
|
userId: bigint,
|
||||||
|
mutation: ResetRoomDecorPlacementsDto,
|
||||||
|
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.lockLayoutState(manager, userId);
|
||||||
|
if (!this.isIdempotentRetry(state, mutation, 'reset')) {
|
||||||
|
this.assertNextRevision(state, mutation);
|
||||||
|
await manager.getRepository(RoomDecorPlacements).createQueryBuilder()
|
||||||
|
.update(RoomDecorPlacements)
|
||||||
|
.set({
|
||||||
|
placed: false,
|
||||||
|
position_x: null,
|
||||||
|
position_y: null,
|
||||||
|
mutation_revision: mutation.mutation_revision,
|
||||||
|
})
|
||||||
|
.where('user_id = :userId', { userId: userId.toString() })
|
||||||
|
.execute();
|
||||||
|
await this.advanceLayoutState(manager, state, mutation.mutation_revision, mutation.mutation_id, 'reset');
|
||||||
|
}
|
||||||
|
const placements = await manager.getRepository(RoomDecorPlacements).find({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: { created_at: 'ASC', id: 'ASC' },
|
||||||
|
});
|
||||||
|
return { revision: state.revision, placements };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLayoutState(manager: EntityManager, userId: bigint): Promise<void> {
|
||||||
|
await manager.getRepository(RoomDecorLayoutState).createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.values({ user_id: userId, revision: 0, last_mutation_id: null, last_mutation_scope: null })
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockLayoutState(manager: EntityManager, userId: bigint): Promise<RoomDecorLayoutState> {
|
||||||
|
await this.ensureLayoutState(manager, userId);
|
||||||
|
return await manager.getRepository(RoomDecorLayoutState).findOneOrFail({
|
||||||
|
where: { user_id: userId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIdempotentRetry(
|
||||||
|
state: RoomDecorLayoutState,
|
||||||
|
mutation: Pick<SaveRoomDecorPlacementDto, 'mutation_revision' | 'mutation_id'>,
|
||||||
|
mutationScope: string,
|
||||||
|
): boolean {
|
||||||
|
return mutation.mutation_revision === state.revision
|
||||||
|
&& mutation.mutation_id === state.last_mutation_id
|
||||||
|
&& mutationScope === state.last_mutation_scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNextRevision(
|
||||||
|
state: RoomDecorLayoutState,
|
||||||
|
mutation: Pick<SaveRoomDecorPlacementDto, 'layout_revision' | 'mutation_revision'>,
|
||||||
|
): void {
|
||||||
|
if (mutation.layout_revision !== state.revision || mutation.mutation_revision !== state.revision + 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: '装修布局版本已过期,请刷新后重试',
|
||||||
|
current_revision: state.revision,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
row.placed = placement.placed;
|
}
|
||||||
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
|
||||||
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
private async advanceLayoutState(
|
||||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
manager: EntityManager,
|
||||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
state: RoomDecorLayoutState,
|
||||||
row.updated_at = new Date();
|
revision: number,
|
||||||
return await this.placementsRepository.save(row);
|
mutationId: string,
|
||||||
|
mutationScope: string,
|
||||||
|
): Promise<void> {
|
||||||
|
state.revision = revision;
|
||||||
|
state.last_mutation_id = mutationId;
|
||||||
|
state.last_mutation_scope = mutationScope;
|
||||||
|
state.updated_at = new Date();
|
||||||
|
await manager.getRepository(RoomDecorLayoutState).save(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeDecorId(decorId: string): string {
|
private normalizeDecorId(decorId: string): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
import { SaveRoomDecorPlacementDto } from '../../../business/room_decor/dto/save_room_decor_placement.dto';
|
||||||
|
import { ResetRoomDecorPlacementsDto } from '../../../business/room_decor/dto/reset_room_decor_placements.dto';
|
||||||
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
import { RoomDecorPlacements } from './room_decor_placements.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -7,6 +8,11 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
private placements: Map<bigint, RoomDecorPlacements> = new Map();
|
private placements: Map<bigint, RoomDecorPlacements> = new Map();
|
||||||
private userDecorIndex: Map<string, bigint> = new Map();
|
private userDecorIndex: Map<string, bigint> = new Map();
|
||||||
private currentId: bigint = BigInt(1);
|
private currentId: bigint = BigInt(1);
|
||||||
|
private layoutStates: Map<bigint, {
|
||||||
|
revision: number;
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
async listPlacements(userId: bigint): Promise<RoomDecorPlacements[]> {
|
||||||
return Array.from(this.placements.values())
|
return Array.from(this.placements.values())
|
||||||
@@ -17,11 +23,28 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<RoomDecorPlacements> {
|
async getSnapshot(userId: bigint): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
|
return { revision: state.revision, placements: await this.listPlacements(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlacement(
|
||||||
|
userId: bigint,
|
||||||
|
placement: SaveRoomDecorPlacementDto,
|
||||||
|
): Promise<{ revision: number; placement: RoomDecorPlacements }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
const decorId = this.normalizeDecorId(placement.decor_id);
|
const decorId = this.normalizeDecorId(placement.decor_id);
|
||||||
|
const mutationScope = `decor:${decorId}`;
|
||||||
const key = this.indexKey(userId, decorId);
|
const key = this.indexKey(userId, decorId);
|
||||||
const existingId = this.userDecorIndex.get(key);
|
const existingId = this.userDecorIndex.get(key);
|
||||||
const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements();
|
const row = existingId ? this.placements.get(existingId) as RoomDecorPlacements : new RoomDecorPlacements();
|
||||||
|
if (placement.mutation_revision === state.revision
|
||||||
|
&& placement.mutation_id === state.last_mutation_id
|
||||||
|
&& mutationScope === state.last_mutation_scope) {
|
||||||
|
if (!existingId) throw new ConflictException('装修 mutation 状态不完整,请刷新布局');
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
this.assertNextRevision(state, placement.layout_revision, placement.mutation_revision);
|
||||||
if (!existingId) {
|
if (!existingId) {
|
||||||
row.id = this.currentId++;
|
row.id = this.currentId++;
|
||||||
row.user_id = userId;
|
row.user_id = userId;
|
||||||
@@ -34,9 +57,64 @@ export class RoomDecorPlacementsMemoryService {
|
|||||||
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
||||||
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
||||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||||
|
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||||
|
row.mutation_revision = placement.mutation_revision;
|
||||||
row.updated_at = new Date();
|
row.updated_at = new Date();
|
||||||
return row;
|
state.revision = placement.mutation_revision;
|
||||||
|
state.last_mutation_id = placement.mutation_id;
|
||||||
|
state.last_mutation_scope = mutationScope;
|
||||||
|
return { revision: state.revision, placement: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPlacements(
|
||||||
|
userId: bigint,
|
||||||
|
mutation: ResetRoomDecorPlacementsDto,
|
||||||
|
): Promise<{ revision: number; placements: RoomDecorPlacements[] }> {
|
||||||
|
const state = this.getLayoutState(userId);
|
||||||
|
if (!(mutation.mutation_revision === state.revision
|
||||||
|
&& mutation.mutation_id === state.last_mutation_id
|
||||||
|
&& state.last_mutation_scope === 'reset')) {
|
||||||
|
this.assertNextRevision(state, mutation.layout_revision, mutation.mutation_revision);
|
||||||
|
for (const row of this.placements.values()) {
|
||||||
|
if (row.user_id !== userId) continue;
|
||||||
|
row.placed = false;
|
||||||
|
row.position_x = null;
|
||||||
|
row.position_y = null;
|
||||||
|
row.mutation_revision = mutation.mutation_revision;
|
||||||
|
row.updated_at = new Date();
|
||||||
|
}
|
||||||
|
state.revision = mutation.mutation_revision;
|
||||||
|
state.last_mutation_id = mutation.mutation_id;
|
||||||
|
state.last_mutation_scope = 'reset';
|
||||||
|
}
|
||||||
|
return { revision: state.revision, placements: await this.listPlacements(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLayoutState(userId: bigint): {
|
||||||
|
revision: number;
|
||||||
|
last_mutation_id: string | null;
|
||||||
|
last_mutation_scope: string | null;
|
||||||
|
} {
|
||||||
|
let state = this.layoutStates.get(userId);
|
||||||
|
if (!state) {
|
||||||
|
state = { revision: 0, last_mutation_id: null, last_mutation_scope: null };
|
||||||
|
this.layoutStates.set(userId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNextRevision(
|
||||||
|
state: { revision: number },
|
||||||
|
layoutRevision: number,
|
||||||
|
mutationRevision: number,
|
||||||
|
): void {
|
||||||
|
if (layoutRevision !== state.revision || mutationRevision !== state.revision + 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: '装修布局版本已过期,请刷新后重试',
|
||||||
|
current_revision: state.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private indexKey(userId: bigint, decorId: string): string {
|
private indexKey(userId: bigint, decorId: string): string {
|
||||||
|
|||||||
@@ -241,14 +241,14 @@ export class UserProfiles {
|
|||||||
*
|
*
|
||||||
* 数据库设计:
|
* 数据库设计:
|
||||||
* - 类型:VARCHAR(50),支持地图名称
|
* - 类型:VARCHAR(50),支持地图名称
|
||||||
* - 约束:非空、默认值'plaza'
|
* - 约束:非空、默认值'whale_port'
|
||||||
* - 索引:用于地图用户查询
|
* - 索引:用于地图用户查询
|
||||||
*
|
*
|
||||||
* 业务规则:
|
* 业务规则:
|
||||||
* - 用户当前所在的游戏地图
|
* - 用户当前所在的游戏地图
|
||||||
* - 用于位置广播系统的地图过滤
|
* - 用于位置广播系统的地图过滤
|
||||||
* - 影响用户可见性和交互范围
|
* - 影响用户可见性和交互范围
|
||||||
* - 默认为广场(plaza),新用户的起始位置
|
* - 默认为广场(whale_port),新用户的起始位置
|
||||||
*
|
*
|
||||||
* 位置广播系统:
|
* 位置广播系统:
|
||||||
* - 核心字段,用于确定用户所在区域
|
* - 核心字段,用于确定用户所在区域
|
||||||
@@ -259,7 +259,7 @@ export class UserProfiles {
|
|||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 50,
|
length: 50,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
default: 'plaza',
|
default: 'whale_port',
|
||||||
comment: '当前所在地图'
|
comment: '当前所在地图'
|
||||||
})
|
})
|
||||||
current_map: string;
|
current_map: string;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export class UserProfilesService extends BaseUserProfilesService {
|
|||||||
userProfile.tags = createUserProfileDto.tags || null;
|
userProfile.tags = createUserProfileDto.tags || null;
|
||||||
userProfile.social_links = createUserProfileDto.social_links || null;
|
userProfile.social_links = createUserProfileDto.social_links || null;
|
||||||
userProfile.skin_id = createUserProfileDto.skin_id || null;
|
userProfile.skin_id = createUserProfileDto.skin_id || null;
|
||||||
userProfile.current_map = createUserProfileDto.current_map || 'plaza';
|
userProfile.current_map = createUserProfileDto.current_map || 'whale_port';
|
||||||
userProfile.pos_x = createUserProfileDto.pos_x || 0;
|
userProfile.pos_x = createUserProfileDto.pos_x || 0;
|
||||||
userProfile.pos_y = createUserProfileDto.pos_y || 0;
|
userProfile.pos_y = createUserProfileDto.pos_y || 0;
|
||||||
userProfile.status = createUserProfileDto.status || 0;
|
userProfile.status = createUserProfileDto.status || 0;
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class UserProfilesMemoryService extends BaseUserProfilesService {
|
|||||||
userProfile.tags = createUserProfileDto.tags || null;
|
userProfile.tags = createUserProfileDto.tags || null;
|
||||||
userProfile.social_links = createUserProfileDto.social_links || null;
|
userProfile.social_links = createUserProfileDto.social_links || null;
|
||||||
userProfile.skin_id = createUserProfileDto.skin_id || null;
|
userProfile.skin_id = createUserProfileDto.skin_id || null;
|
||||||
userProfile.current_map = createUserProfileDto.current_map || 'plaza';
|
userProfile.current_map = createUserProfileDto.current_map || 'whale_port';
|
||||||
userProfile.pos_x = createUserProfileDto.pos_x || 0;
|
userProfile.pos_x = createUserProfileDto.pos_x || 0;
|
||||||
userProfile.pos_y = createUserProfileDto.pos_y || 0;
|
userProfile.pos_y = createUserProfileDto.pos_y || 0;
|
||||||
userProfile.status = createUserProfileDto.status || 0;
|
userProfile.status = createUserProfileDto.status || 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
import { UserWallets } from './user_wallets.entity';
|
import { UserWallets } from './user_wallets.entity';
|
||||||
import { WalletTransactions } from './wallet_transactions.entity';
|
import { WalletTransactions } from './wallet_transactions.entity';
|
||||||
|
|
||||||
@@ -96,6 +96,60 @@ export class UserWalletsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async earnInTransaction(
|
||||||
|
manager: EntityManager,
|
||||||
|
userId: bigint,
|
||||||
|
amount: number,
|
||||||
|
referenceType: string,
|
||||||
|
referenceId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<EarnWalletResult> {
|
||||||
|
if (!Number.isInteger(amount) || amount < 0) {
|
||||||
|
throw new BadRequestException('鲸币收入数量不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletRepository = manager.getRepository(UserWallets);
|
||||||
|
const transactionRepository = manager.getRepository(WalletTransactions);
|
||||||
|
let wallet = await walletRepository.findOne({
|
||||||
|
where: { user_id: userId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
wallet = walletRepository.create({
|
||||||
|
user_id: userId,
|
||||||
|
balance: DEFAULT_INITIAL_WHALE_COINS,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
wallet = await walletRepository.save(wallet);
|
||||||
|
await transactionRepository.save(transactionRepository.create({
|
||||||
|
user_id: userId,
|
||||||
|
type: 'grant',
|
||||||
|
amount: DEFAULT_INITIAL_WHALE_COINS,
|
||||||
|
balance_after: wallet.balance,
|
||||||
|
reference_type: 'registration',
|
||||||
|
reference_id: 'initial_wallet',
|
||||||
|
note: '新用户初始鲸币',
|
||||||
|
created_at: new Date(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet.balance += amount;
|
||||||
|
wallet.updated_at = new Date();
|
||||||
|
const savedWallet = await walletRepository.save(wallet);
|
||||||
|
const transaction = await transactionRepository.save(transactionRepository.create({
|
||||||
|
user_id: userId,
|
||||||
|
type: 'earn',
|
||||||
|
amount,
|
||||||
|
balance_after: savedWallet.balance,
|
||||||
|
reference_type: referenceType,
|
||||||
|
reference_id: referenceId,
|
||||||
|
note: note || null,
|
||||||
|
created_at: new Date(),
|
||||||
|
}));
|
||||||
|
return { wallet: savedWallet, transaction };
|
||||||
|
}
|
||||||
|
|
||||||
private async createTransaction(
|
private async createTransaction(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
18
src/core/db/users/migrations/add-is-test-account.sql
Normal file
18
src/core/db/users/migrations/add-is-test-account.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE `users`
|
||||||
|
ADD COLUMN IF NOT EXISTS `is_test_account` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为开发测试实验室账号' AFTER `role`;
|
||||||
|
|
||||||
|
SET @test_account_index_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'users'
|
||||||
|
AND index_name = 'idx_users_is_test_account'
|
||||||
|
);
|
||||||
|
SET @test_account_index_sql := IF(
|
||||||
|
@test_account_index_exists = 0,
|
||||||
|
'CREATE INDEX `idx_users_is_test_account` ON `users` (`is_test_account`)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE test_account_index_stmt FROM @test_account_index_sql;
|
||||||
|
EXECUTE test_account_index_stmt;
|
||||||
|
DEALLOCATE PREPARE test_account_index_stmt;
|
||||||
@@ -36,7 +36,8 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
Length,
|
Length,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsEnum
|
IsEnum,
|
||||||
|
IsBoolean
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { UserStatus } from './user_status.enum';
|
import { UserStatus } from './user_status.enum';
|
||||||
import { USER_ROLES, FIELD_LIMITS } from './users.constants';
|
import { USER_ROLES, FIELD_LIMITS } from './users.constants';
|
||||||
@@ -91,6 +92,10 @@ export class CreateUserDto {
|
|||||||
@Length(1, FIELD_LIMITS.USERNAME_MAX_LENGTH, { message: `用户名长度需在1-${FIELD_LIMITS.USERNAME_MAX_LENGTH}字符之间` })
|
@Length(1, FIELD_LIMITS.USERNAME_MAX_LENGTH, { message: `用户名长度需在1-${FIELD_LIMITS.USERNAME_MAX_LENGTH}字符之间` })
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
is_test_account?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 邮箱地址
|
* 邮箱地址
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -318,6 +318,13 @@ export class Users {
|
|||||||
})
|
})
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'datetime',
|
||||||
|
nullable: true,
|
||||||
|
comment: '社区昵称最近修改时间'
|
||||||
|
})
|
||||||
|
nickname_updated_at?: Date | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户角色
|
* 用户角色
|
||||||
*
|
*
|
||||||
@@ -351,6 +358,17 @@ export class Users {
|
|||||||
})
|
})
|
||||||
role: number;
|
role: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅供开发/测试实验室创建的合成玩家使用。生产业务不得据此授予权限。
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false,
|
||||||
|
default: false,
|
||||||
|
comment: '是否为测试实验室账号'
|
||||||
|
})
|
||||||
|
is_test_account: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户状态
|
* 用户状态
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export class UsersService extends BaseUsersService {
|
|||||||
user.github_id = createUserDto.github_id || null;
|
user.github_id = createUserDto.github_id || null;
|
||||||
user.avatar_url = createUserDto.avatar_url || null;
|
user.avatar_url = createUserDto.avatar_url || null;
|
||||||
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
||||||
|
user.is_test_account = createUserDto.is_test_account === true;
|
||||||
user.email_verified = createUserDto.email_verified || false;
|
user.email_verified = createUserDto.email_verified || false;
|
||||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export class UsersMemoryService extends BaseUsersService {
|
|||||||
user.github_id = createUserDto.github_id || null;
|
user.github_id = createUserDto.github_id || null;
|
||||||
user.avatar_url = createUserDto.avatar_url || null;
|
user.avatar_url = createUserDto.avatar_url || null;
|
||||||
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
user.role = createUserDto.role || USER_ROLES.NORMAL_USER;
|
||||||
|
user.is_test_account = createUserDto.is_test_account === true;
|
||||||
user.email_verified = createUserDto.email_verified || false;
|
user.email_verified = createUserDto.email_verified || false;
|
||||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||||
user.created_at = new Date();
|
user.created_at = new Date();
|
||||||
|
|||||||
@@ -265,6 +265,22 @@ export class FileRedisService implements IRedisService, OnModuleDestroy {
|
|||||||
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||||
|
const existing = this.data.get(key);
|
||||||
|
if (existing && (!existing.expireAt || existing.expireAt > Date.now())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
this.data.delete(key);
|
||||||
|
}
|
||||||
|
this.data.set(key, {
|
||||||
|
value,
|
||||||
|
expireAt: ttl && ttl > 0 ? Date.now() + ttl * 1000 : undefined,
|
||||||
|
});
|
||||||
|
await this.saveData();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取键对应的值
|
* 获取键对应的值
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ export class RealRedisService implements IRedisService, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = ttl && ttl > 0
|
||||||
|
? await this.redis.set(key, value, 'EX', ttl, 'NX')
|
||||||
|
: await this.redis.set(key, value, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`原子设置Redis键失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取键对应的值
|
* 获取键对应的值
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export interface IRedisService {
|
|||||||
*/
|
*/
|
||||||
set(key: string, value: string, ttl?: number): Promise<void>;
|
set(key: string, value: string, ttl?: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅当键不存在时设置值,用于 nonce、幂等键等原子占位。
|
||||||
|
*
|
||||||
|
* @returns 成功占位返回 true,键已存在返回 false
|
||||||
|
*/
|
||||||
|
setIfAbsent(key: string, value: string, ttl?: number): Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置键值对并指定过期时间
|
* 设置键值对并指定过期时间
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export class ConfigManagerService implements OnModuleDestroy {
|
|||||||
zulipBotEmail: process.env.ZULIP_BOT_EMAIL || 'bot@example.com',
|
zulipBotEmail: process.env.ZULIP_BOT_EMAIL || 'bot@example.com',
|
||||||
zulipBotApiKey: process.env.ZULIP_BOT_API_KEY || '',
|
zulipBotApiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||||
|
|
||||||
websocketPort: parseInt(process.env.WEBSOCKET_PORT || '3001', 10),
|
websocketPort: parseInt(process.env.WEBSOCKET_PORT || '3000', 10),
|
||||||
websocketNamespace: process.env.WEBSOCKET_NAMESPACE || '/game',
|
websocketNamespace: process.env.WEBSOCKET_NAMESPACE || '/game',
|
||||||
|
|
||||||
messageRateLimit: parseInt(process.env.MESSAGE_RATE_LIMIT || '10', 10),
|
messageRateLimit: parseInt(process.env.MESSAGE_RATE_LIMIT || '10', 10),
|
||||||
@@ -1189,7 +1189,7 @@ export class ConfigManagerService implements OnModuleDestroy {
|
|||||||
zulipServerUrl: 'https://your-zulip-server.com',
|
zulipServerUrl: 'https://your-zulip-server.com',
|
||||||
zulipBotEmail: 'bot@example.com',
|
zulipBotEmail: 'bot@example.com',
|
||||||
zulipBotApiKey: '',
|
zulipBotApiKey: '',
|
||||||
websocketPort: 3001,
|
websocketPort: 3000,
|
||||||
websocketNamespace: '/game',
|
websocketNamespace: '/game',
|
||||||
messageRateLimit: 10,
|
messageRateLimit: 10,
|
||||||
messageMaxLength: 1000,
|
messageMaxLength: 1000,
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export const DEFAULT_ZULIP_CONFIG: ZulipConfiguration = {
|
|||||||
botApiKey: '',
|
botApiKey: '',
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
port: 3001,
|
port: 3000,
|
||||||
namespace: '/game',
|
namespace: '/game',
|
||||||
pingInterval: 25000,
|
pingInterval: 25000,
|
||||||
pingTimeout: 5000,
|
pingTimeout: 5000,
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
import { ChatService } from '../../business/chat/chat.service';
|
import { ChatService } from '../../business/chat/chat.service';
|
||||||
|
import { SocialService } from '../../business/social/social.service';
|
||||||
|
import {
|
||||||
|
getTestLabPresence,
|
||||||
|
getTestLabPresences,
|
||||||
|
removeTestLabPresence,
|
||||||
|
TestLabPresence,
|
||||||
|
upsertTestLabPresence,
|
||||||
|
} from '../../business/admin/test_lab_presence.registry';
|
||||||
|
|
||||||
/** WebSocket 服务器默认端口 */
|
/** WebSocket 服务器默认端口 */
|
||||||
const DEFAULT_WEBSOCKET_PORT = 3001;
|
const DEFAULT_WEBSOCKET_PORT = 3001;
|
||||||
@@ -102,7 +110,10 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
private mapRooms = new Map<string, Set<string>>();
|
private mapRooms = new Map<string, Set<string>>();
|
||||||
private lastWelcomeAtByUserId = new Map<string, number>();
|
private lastWelcomeAtByUserId = new Map<string, number>();
|
||||||
|
|
||||||
constructor(private readonly chatService: ChatService) {}
|
constructor(
|
||||||
|
private readonly chatService: ChatService,
|
||||||
|
private readonly socialService: SocialService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT;
|
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT;
|
||||||
@@ -140,6 +151,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
|
|
||||||
// 设置网关引用到业务层
|
// 设置网关引用到业务层
|
||||||
this.chatService.setWebSocketGateway(this);
|
this.chatService.setWebSocketGateway(this);
|
||||||
|
this.socialService.setRealtimeGateway(this);
|
||||||
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +198,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
case 'chat':
|
case 'chat':
|
||||||
await this.handleChat(ws, message);
|
await this.handleChat(ws, message);
|
||||||
break;
|
break;
|
||||||
|
case 'dm_send':
|
||||||
|
await this.handleDirectMessage(ws, message);
|
||||||
|
break;
|
||||||
|
case 'dm_read':
|
||||||
|
await this.handleDirectMessageRead(ws, message);
|
||||||
|
break;
|
||||||
case 'position':
|
case 'position':
|
||||||
await this.handlePosition(ws, message);
|
await this.handlePosition(ws, message);
|
||||||
break;
|
break;
|
||||||
@@ -260,6 +278,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`用户登录成功: ${result.username} (${ws.id})`);
|
this.logger.log(`用户登录成功: ${result.username} (${ws.id})`);
|
||||||
|
await this.socialService.notifyPresenceChanged(String(result.userId), true);
|
||||||
} else {
|
} else {
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
t: 'login_error',
|
t: 'login_error',
|
||||||
@@ -316,6 +335,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = String(message.scope || 'local').trim().toLowerCase();
|
||||||
|
if (scope === 'private' || scope === 'whisper' || scope === 'dm') {
|
||||||
|
await this.handleDirectMessage(ws, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.chatService.sendChatMessage({
|
const result = await this.chatService.sendChatMessage({
|
||||||
socketId: ws.id,
|
socketId: ws.id,
|
||||||
@@ -347,6 +372,43 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleDirectMessage(ws: ExtendedWebSocket, message: any) {
|
||||||
|
if (!ws.authenticated || !ws.userId) {
|
||||||
|
this.sendError(ws, '请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetUserId = String(message.targetUserId || message.target_user_id || message.userId || message.user_id || '').trim();
|
||||||
|
const content = String(message.content || message.txt || '').trim();
|
||||||
|
if (!/^\d+$/.test(targetUserId) || !content) {
|
||||||
|
this.sendMessage(ws, { t: 'chat_error', code: 'CHAT_ERROR', message: '私聊目标或内容无效' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.socialService.sendDirectMessage(BigInt(ws.userId), BigInt(targetUserId), content);
|
||||||
|
this.sendMessage(ws, { t: 'chat_sent', messageId: result.id, message: '消息发送成功' });
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode((error as Error).message), message: (error as Error).message || '私聊发送失败' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDirectMessageRead(ws: ExtendedWebSocket, message: any) {
|
||||||
|
if (!ws.authenticated || !ws.userId) {
|
||||||
|
this.sendError(ws, '请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetUserId = String(message.userId || message.user_id || message.targetUserId || '').trim();
|
||||||
|
if (!/^\d+$/.test(targetUserId)) {
|
||||||
|
this.sendError(ws, '私聊对象无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.socialService.markConversationRead(BigInt(ws.userId), BigInt(targetUserId));
|
||||||
|
this.sendMessage(ws, { t: 'dm_read_success', ...result, userId: targetUserId });
|
||||||
|
} catch (error) {
|
||||||
|
this.sendError(ws, (error as Error).message || '标记私聊已读失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleFriendAdd(ws: ExtendedWebSocket, message: any) {
|
private async handleFriendAdd(ws: ExtendedWebSocket, message: any) {
|
||||||
if (!ws.authenticated) {
|
if (!ws.authenticated) {
|
||||||
this.sendError(ws, '请先登录');
|
this.sendError(ws, '请先登录');
|
||||||
@@ -359,26 +421,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.chatService.addFriend({
|
try {
|
||||||
socketId: ws.id,
|
const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
|
||||||
friendUserId,
|
this.sendMessage(ws, { t: 'friend_request_sent', request });
|
||||||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
} catch (error) {
|
||||||
});
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' });
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_added',
|
|
||||||
friend: result.friend,
|
|
||||||
});
|
|
||||||
await this.sendFriendList(ws);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '添加好友失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFriendRequest(ws: ExtendedWebSocket, message: any) {
|
private async handleFriendRequest(ws: ExtendedWebSocket, message: any) {
|
||||||
@@ -393,25 +441,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.chatService.requestFriend({
|
try {
|
||||||
socketId: ws.id,
|
const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
|
||||||
friendUserId,
|
this.sendMessage(ws, { t: 'friend_request_sent', request });
|
||||||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
} catch (error) {
|
||||||
});
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' });
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_request_sent',
|
|
||||||
request: result.friendRequest,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '好友请求发送失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFriendAccept(ws: ExtendedWebSocket, message: any) {
|
private async handleFriendAccept(ws: ExtendedWebSocket, message: any) {
|
||||||
@@ -426,26 +461,16 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.chatService.acceptFriendRequest({
|
try {
|
||||||
socketId: ws.id,
|
const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId)));
|
||||||
friendUserId,
|
const request = requests.find((item) => item.requester.id === String(friendUserId));
|
||||||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
if (!request) throw new Error('好友请求不存在或已过期');
|
||||||
});
|
const result = await this.socialService.acceptFriendRequest(BigInt(String(ws.userId)), BigInt(request.id));
|
||||||
|
this.sendMessage(ws, { t: 'friend_added', friend: this.legacyFriend(result.friend) });
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_added',
|
|
||||||
friend: result.friend,
|
|
||||||
});
|
|
||||||
await this.sendFriendList(ws);
|
await this.sendFriendList(ws);
|
||||||
return;
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '接受好友请求失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '接受好友请求失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFriendReject(ws: ExtendedWebSocket, message: any) {
|
private async handleFriendReject(ws: ExtendedWebSocket, message: any) {
|
||||||
@@ -460,26 +485,16 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.chatService.rejectFriendRequest({
|
try {
|
||||||
socketId: ws.id,
|
const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId)));
|
||||||
friendUserId,
|
const request = requests.find((item) => item.requester.id === String(friendUserId));
|
||||||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
if (!request) throw new Error('好友请求不存在或已过期');
|
||||||
});
|
await this.socialService.rejectFriendRequest(BigInt(String(ws.userId)), BigInt(request.id));
|
||||||
|
this.sendMessage(ws, { t: 'friend_request_rejected', userId: String(friendUserId) });
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_request_rejected',
|
|
||||||
userId: friendUserId,
|
|
||||||
});
|
|
||||||
await this.sendFriendList(ws);
|
await this.sendFriendList(ws);
|
||||||
return;
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '拒绝好友请求失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '拒绝好友请求失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFriendRemove(ws: ExtendedWebSocket, message: any) {
|
private async handleFriendRemove(ws: ExtendedWebSocket, message: any) {
|
||||||
@@ -494,25 +509,13 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.chatService.removeFriend({
|
try {
|
||||||
socketId: ws.id,
|
await this.socialService.removeFriend(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
|
||||||
friendUserId,
|
this.sendMessage(ws, { t: 'friend_removed', friendUserId: String(friendUserId) });
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_removed',
|
|
||||||
friendUserId,
|
|
||||||
});
|
|
||||||
await this.sendFriendList(ws);
|
await this.sendFriendList(ws);
|
||||||
return;
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '移除好友失败' });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '移除好友失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFriendList(ws: ExtendedWebSocket) {
|
private async handleFriendList(ws: ExtendedWebSocket) {
|
||||||
@@ -525,21 +528,13 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendFriendList(ws: ExtendedWebSocket) {
|
private async sendFriendList(ws: ExtendedWebSocket) {
|
||||||
const result = await this.chatService.getFriends(ws.id);
|
try {
|
||||||
if (result.success) {
|
const userId = BigInt(String(ws.userId));
|
||||||
this.sendMessage(ws, {
|
const [friends, requests] = await Promise.all([this.socialService.getFriends(userId), this.socialService.getFriendRequests(userId)]);
|
||||||
t: 'friend_list',
|
this.sendMessage(ws, { t: 'friend_list', friends: friends.map((friend) => this.legacyFriend(friend)), requests: requests.map((request) => ({ userId: request.requester.id, username: request.requester.nickname, createdAt: request.createdAt, requestId: request.id })) });
|
||||||
friends: result.friends || [],
|
} catch (error) {
|
||||||
requests: result.requests || [],
|
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '获取好友列表失败' });
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'friend_error',
|
|
||||||
code: this.toClientErrorCode(result.error),
|
|
||||||
message: result.error || '获取好友列表失败',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -868,27 +863,29 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastToMap(mapId: string, data: any, excludeId?: string): void {
|
public async broadcastToMap(mapId: string, data: any, excludeId?: string): Promise<void> {
|
||||||
const room = this.mapRooms.get(mapId);
|
const room = this.mapRooms.get(mapId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
room.forEach(clientId => {
|
for (const clientId of room) {
|
||||||
if (clientId !== excludeId) {
|
if (clientId !== excludeId) {
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
|
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
|
||||||
|
if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue;
|
||||||
this.sendMessage(client, data);
|
this.sendMessage(client, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastToAll(data: any, excludeId?: string): void {
|
public async broadcastToAll(data: any, excludeId?: string): Promise<void> {
|
||||||
this.clients.forEach((client, clientId) => {
|
for (const [clientId, client] of this.clients) {
|
||||||
if (clientId === excludeId) return;
|
if (clientId === excludeId) continue;
|
||||||
if (client.authenticated && client.readyState === WebSocket.OPEN) {
|
if (client.authenticated && client.readyState === WebSocket.OPEN) {
|
||||||
|
if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue;
|
||||||
this.sendMessage(client, data);
|
this.sendMessage(client, data);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionCount(): number {
|
public getConnectionCount(): number {
|
||||||
@@ -921,6 +918,58 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
return players;
|
return players;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 管理端测试实验室使用:返回真实在线玩家,绝不包含合成假人。 */
|
||||||
|
public getOnlineWorldPlayers(): Array<{ userId: string; username: string; mapId: string }> {
|
||||||
|
return [...this.clients.values()]
|
||||||
|
.filter((client) => client.authenticated && client.worldReady && client.userId && client.currentMap)
|
||||||
|
.map((client) => ({ userId: String(client.userId), username: String(client.username || ''), mapId: String(client.currentMap) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTestLabPresence(presence: TestLabPresence): void {
|
||||||
|
const previous = getTestLabPresence(presence.userId);
|
||||||
|
const current = upsertTestLabPresence(presence);
|
||||||
|
if (previous?.online && (!current.online || previous.mapId !== current.mapId)) {
|
||||||
|
this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId });
|
||||||
|
}
|
||||||
|
if (!current.online) return;
|
||||||
|
const payload = {
|
||||||
|
t: previous?.online && previous.mapId === current.mapId ? 'position_update' : 'player_joined',
|
||||||
|
userId: current.userId,
|
||||||
|
username: current.nickname,
|
||||||
|
mapId: current.mapId,
|
||||||
|
x: current.x,
|
||||||
|
y: current.y,
|
||||||
|
skinId: current.skinId,
|
||||||
|
avatarId: current.avatarId,
|
||||||
|
appearance: { skinId: current.skinId, avatarId: current.avatarId },
|
||||||
|
};
|
||||||
|
this.broadcastToMap(current.mapId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeTestLabActor(userId: string): void {
|
||||||
|
const previous = removeTestLabPresence(userId);
|
||||||
|
if (previous?.online) {
|
||||||
|
this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public broadcastTestLabChat(actor: TestLabPresence, content: string, scope: 'local' | 'global' = 'local'): void {
|
||||||
|
const payload = {
|
||||||
|
t: 'chat_render',
|
||||||
|
from: actor.nickname,
|
||||||
|
fromUserId: actor.userId,
|
||||||
|
txt: content,
|
||||||
|
bubble: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
messageId: `test_${Date.now()}_${actor.userId}`,
|
||||||
|
mapId: actor.mapId,
|
||||||
|
scope,
|
||||||
|
testLab: true,
|
||||||
|
};
|
||||||
|
if (scope === 'global') this.broadcastToAll(payload);
|
||||||
|
else this.broadcastToMap(actor.mapId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 私有辅助方法 ==========
|
// ========== 私有辅助方法 ==========
|
||||||
|
|
||||||
private sendMessage(ws: ExtendedWebSocket, data: any) {
|
private sendMessage(ws: ExtendedWebSocket, data: any) {
|
||||||
@@ -986,10 +1035,22 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
});
|
});
|
||||||
const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id))
|
const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id))
|
||||||
.filter((player) => activeUserIds.has(String(player.userId)));
|
.filter((player) => activeUserIds.has(String(player.userId)));
|
||||||
|
const testPlayers = getTestLabPresences(normalizedMapId).map((player) => ({
|
||||||
|
userId: player.userId,
|
||||||
|
username: player.nickname,
|
||||||
|
mapId: player.mapId,
|
||||||
|
x: player.x,
|
||||||
|
y: player.y,
|
||||||
|
skinId: player.skinId,
|
||||||
|
avatarId: player.avatarId,
|
||||||
|
appearance: { skinId: player.skinId, avatarId: player.avatarId },
|
||||||
|
cafeCompanion: null,
|
||||||
|
movementLocked: false,
|
||||||
|
}));
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
t: 'map_players_snapshot',
|
t: 'map_players_snapshot',
|
||||||
mapId: normalizedMapId,
|
mapId: normalizedMapId,
|
||||||
players,
|
players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,6 +1066,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
}
|
}
|
||||||
if (ws.authenticated && ws.id) {
|
if (ws.authenticated && ws.id) {
|
||||||
await this.chatService.handlePlayerLogout(ws.id, reason);
|
await this.chatService.handlePlayerLogout(ws.id, reason);
|
||||||
|
if (ws.userId) await this.socialService.notifyPresenceChanged(String(ws.userId), false);
|
||||||
}
|
}
|
||||||
if (ws.currentMap) {
|
if (ws.currentMap) {
|
||||||
this.leaveMapRoom(ws.id, ws.currentMap);
|
this.leaveMapRoom(ws.id, ws.currentMap);
|
||||||
@@ -1044,4 +1106,13 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
private generateClientId(): string {
|
private generateClientId(): string {
|
||||||
return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private legacyFriend(profile: any) {
|
||||||
|
return {
|
||||||
|
userId: String(profile.id),
|
||||||
|
username: String(profile.nickname || profile.username || '玩家'),
|
||||||
|
online: Boolean(profile.online),
|
||||||
|
room_visitable: Boolean(profile.room_visitable),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user