更新下载次数记录和更新次数记录等
This commit is contained in:
689
API.md
689
API.md
@@ -1,75 +1,23 @@
|
||||
# Nanami Web API 文档
|
||||
# Nanami 启动器 API 文档
|
||||
|
||||
Base URL: `https://nui.rucky.cn`
|
||||
> Base URL: `https://nanami.rucky.cn`(或对应部署地址)
|
||||
>
|
||||
> 标注说明:🆕 新增 | ✏️ 有变动 | 无标注为原有不变
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
## 1. 检查更新 ✏️
|
||||
|
||||
- [公开接口](#公开接口)
|
||||
- [服务器时间](#服务器时间)
|
||||
- [检查更新](#检查更新)
|
||||
- [启动器最新版本](#启动器最新版本)
|
||||
- [软件版本下载](#软件版本下载)
|
||||
- [插件列表](#插件列表)
|
||||
- [插件详情](#插件详情)
|
||||
- [插件版本下载](#插件版本下载)
|
||||
- [Banner 列表](#banner-列表)
|
||||
- [画廊图片列表](#画廊图片列表)
|
||||
- [管理接口(需认证)](#管理接口需认证)
|
||||
- [软件管理](#软件管理)
|
||||
- [软件版本管理](#软件版本管理)
|
||||
- [插件管理](#插件管理)
|
||||
- [插件版本发布](#插件版本发布)
|
||||
- [Banner 管理](#banner-管理)
|
||||
- [画廊管理](#画廊管理)
|
||||
- [文件上传](#文件上传)
|
||||
- [修改密码](#修改密码)
|
||||
|
||||
---
|
||||
|
||||
## 公开接口
|
||||
|
||||
### 服务器时间
|
||||
|
||||
获取服务器当前时间。
|
||||
|
||||
```
|
||||
GET /api/server-time
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1710748800000,
|
||||
"iso": "2026-03-18T08:00:00.000Z",
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `timestamp` | `number` | Unix 毫秒时间戳 |
|
||||
| `iso` | `string` | ISO 8601 格式时间 |
|
||||
| `timezone` | `string` | 服务器时区 |
|
||||
|
||||
---
|
||||
|
||||
### 检查更新
|
||||
|
||||
客户端检查软件是否有新版本可用。
|
||||
检查指定软件是否有新版本。
|
||||
|
||||
```
|
||||
GET /api/software/check-update?slug={slug}&versionCode={versionCode}
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `slug` | 是 | 软件标识符,如 `nanami-launcher` |
|
||||
| `versionCode` | 否 | 当前客户端版本号(整数),默认 `0` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` |
|
||||
| versionCode | number | 否 | 当前客户端版本号(整数),不传默认为 0 |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
@@ -78,564 +26,177 @@ GET /api/software/check-update?slug={slug}&versionCode={versionCode}
|
||||
"hasUpdate": true,
|
||||
"forceUpdate": false,
|
||||
"latest": {
|
||||
"version": "0.8.15",
|
||||
"versionCode": 815,
|
||||
"changelog": "修复了若干问题...",
|
||||
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
|
||||
"version": "1.3.0",
|
||||
"versionCode": 130,
|
||||
"changelog": "- 修复xxx\n- 新增xxx",
|
||||
"downloadUrl": "https://nanami.rucky.cn/api/software/download/clxxx?source=launcher",
|
||||
"fileSize": 52428800,
|
||||
"minVersion": "0.7.0",
|
||||
"createdAt": "2026-03-15T10:00:00.000Z"
|
||||
"minVersion": "1.0.0",
|
||||
"createdAt": "2026-03-25T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `hasUpdate` | `boolean` | 是否有新版本 |
|
||||
| `forceUpdate` | `boolean` | 是否需要强制更新 |
|
||||
| `latest.version` | `string` | 最新版本号 |
|
||||
| `latest.versionCode` | `number` | 最新版本编码 |
|
||||
| `latest.changelog` | `string` | 更新日志 |
|
||||
| `latest.downloadUrl` | `string` | 下载地址 |
|
||||
| `latest.fileSize` | `number` | 文件大小(字节) |
|
||||
| `latest.minVersion` | `string\|null` | 最低兼容版本 |
|
||||
| `latest.createdAt` | `string` | 发布时间 |
|
||||
**✏️ 变动说明**:返回的 `downloadUrl` 现在自动附带 `?source=launcher` 参数,启动器直接使用该 URL 下载即可,下载量会被单独统计为「客户端更新下载」,与网页端下载区分。启动器无需做任何额外处理。
|
||||
|
||||
---
|
||||
|
||||
### 启动器最新版本
|
||||
## 2. 下载文件 ✏️
|
||||
|
||||
获取启动器最新版本信息或直接下载。
|
||||
|
||||
#### 获取信息
|
||||
根据版本 ID 下载文件。通常不需要手动拼接,直接使用 check-update 返回的 `downloadUrl` 即可。
|
||||
|
||||
```
|
||||
GET /api/software/latest?info=1
|
||||
GET /api/software/download/{versionId}?source={source}
|
||||
```
|
||||
|
||||
添加 `&track=1` 可同时记录一次下载计数。
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| versionId | string | 是 | 路径参数,版本记录 ID |
|
||||
| source | string | 否 | 下载来源标识,传 `launcher` 表示客户端更新下载 |
|
||||
|
||||
**响应示例:**
|
||||
**响应**:
|
||||
|
||||
- 本地文件:直接返回二进制流(`Content-Type: application/octet-stream`)
|
||||
- 外链文件:302 重定向到外部 URL
|
||||
|
||||
**✏️ 变动说明**:新增 `source` 查询参数。当 `source=launcher` 时,下载量会同时计入总下载量和启动器更新下载量两个计数器。check-update 返回的 URL 已自动包含此参数,启动器无需手动处理。
|
||||
|
||||
---
|
||||
|
||||
## 3. 获取最新版本信息(网页用)
|
||||
|
||||
获取 nanami-launcher 的最新版本信息或直接下载,主要给网页端使用。
|
||||
|
||||
```
|
||||
GET /api/software/latest?info=1&track=1
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| info | string | 否 | 传 `1` 返回 JSON 元数据;不传则直接下载文件 |
|
||||
| track | string | 否 | 仅在 `info=1` 时生效,传 `1` 同时计数下载量 |
|
||||
|
||||
**响应示例(info=1):**
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"version": "0.8.15",
|
||||
"versionCode": 815,
|
||||
"changelog": "更新内容...",
|
||||
"version": "1.3.0",
|
||||
"versionCode": 130,
|
||||
"changelog": "...",
|
||||
"fileSize": 52428800,
|
||||
"createdAt": "2026-03-15T10:00:00.000Z",
|
||||
"downloadUrl": "https://cdn.example.com/launcher-setup.exe",
|
||||
"downloadType": "url"
|
||||
"createdAt": "2026-03-25T10:00:00.000Z",
|
||||
"downloadUrl": "/api/software/download/clxxx",
|
||||
"downloadType": "local"
|
||||
}
|
||||
```
|
||||
|
||||
当无可用版本时返回:
|
||||
|
||||
```json
|
||||
{ "available": false }
|
||||
```
|
||||
|
||||
#### 直接下载
|
||||
|
||||
```
|
||||
GET /api/software/latest
|
||||
```
|
||||
|
||||
- 外部链接类型:302 重定向到外部 URL
|
||||
- 本地文件类型:返回文件流
|
||||
- 自动记录下载次数
|
||||
> 此接口固定查询 slug 为 `nanami-launcher` 的软件,启动器通常使用 check-update 接口而非此接口。
|
||||
|
||||
---
|
||||
|
||||
### 软件版本下载
|
||||
## 4. 上报心跳(在线状态) 🆕
|
||||
|
||||
按版本 ID 下载特定版本。
|
||||
启动器定期调用此接口上报在线状态,建议每 **60 秒** 调用一次。超过 3 分钟无心跳视为离线。
|
||||
|
||||
```
|
||||
GET /api/software/download/{id}
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 软件版本 ID |
|
||||
|
||||
- 外部链接类型:302 重定向
|
||||
- 本地文件类型:返回文件流(`application/octet-stream`)
|
||||
- 自动记录下载次数
|
||||
|
||||
**错误响应:**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| `404` | 版本不存在或文件不存在 |
|
||||
|
||||
---
|
||||
|
||||
### 插件列表
|
||||
|
||||
获取已发布的插件列表。
|
||||
|
||||
```
|
||||
GET /api/addons
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `category` | 否 | 按分类筛选 |
|
||||
| `search` | 否 | 按名称/简介搜索(模糊匹配) |
|
||||
| `published` | 否 | 默认 `true`,设为 `false` 查询所有 |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "clxx...",
|
||||
"name": "插件名",
|
||||
"slug": "addon-slug",
|
||||
"summary": "插件简介",
|
||||
"description": "详细描述...",
|
||||
"iconUrl": "/uploads/icon.png",
|
||||
"category": "ui",
|
||||
"published": true,
|
||||
"totalDownloads": 1024,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-01T00:00:00.000Z",
|
||||
"releases": [{ "...最新版本..." }],
|
||||
"_count": { "releases": 5 }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 插件详情
|
||||
|
||||
获取单个插件详细信息,包含所有版本和截图。
|
||||
|
||||
```
|
||||
GET /api/addons/{id}
|
||||
```
|
||||
|
||||
**路径参数:** `id` 可以是插件 ID 或 slug。
|
||||
|
||||
**响应:** 插件完整信息,含 `releases`(按时间倒序)和 `screenshots`(按排序序号)。
|
||||
|
||||
---
|
||||
|
||||
### 插件版本下载
|
||||
|
||||
```
|
||||
GET /api/download/{id}
|
||||
```
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `id` | Release 版本 ID |
|
||||
|
||||
- 自动增加 Release 和 Addon 的下载计数
|
||||
- 外部链接类型:302 重定向
|
||||
- 本地文件类型:返回文件流
|
||||
|
||||
---
|
||||
|
||||
### Banner 列表
|
||||
|
||||
```
|
||||
GET /api/banners
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `enabled` | 设为 `1` 仅返回已启用的 Banner |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "clxx...",
|
||||
"imageUrl": "/banners/banner_1.png",
|
||||
"sortOrder": 0,
|
||||
"enabled": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 画廊图片列表
|
||||
|
||||
```
|
||||
GET /api/gallery
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `enabled` | 设为 `1` 仅返回已启用的图片 |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "clxx...",
|
||||
"imageUrl": "/views/view_1.png",
|
||||
"title": "主界面",
|
||||
"sortOrder": 0,
|
||||
"enabled": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 管理接口(需认证)
|
||||
|
||||
以下接口需要管理员登录后的 Session 认证。未认证请求返回 `401 Unauthorized`。
|
||||
|
||||
### 软件管理
|
||||
|
||||
#### 获取软件列表
|
||||
|
||||
```
|
||||
GET /api/software
|
||||
```
|
||||
|
||||
返回所有软件及其最新版本和版本数量。
|
||||
|
||||
#### 创建软件
|
||||
|
||||
```
|
||||
POST /api/software
|
||||
POST /api/launcher/heartbeat
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Nanami Launcher",
|
||||
"slug": "nanami-launcher",
|
||||
"description": "启动器描述"
|
||||
"deviceId": "unique-machine-id",
|
||||
"os": "Windows",
|
||||
"osVersion": "10.0.19045",
|
||||
"appVersion": "1.3.0"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | 是 | 软件名称 |
|
||||
| `slug` | 是 | 唯一标识符 |
|
||||
| `description` | 否 | 描述 |
|
||||
|
||||
#### 获取软件详情
|
||||
|
||||
```
|
||||
GET /api/software/{id}
|
||||
```
|
||||
|
||||
`id` 可以是软件 ID 或 slug,返回软件信息及所有版本。
|
||||
|
||||
#### 更新软件
|
||||
|
||||
```
|
||||
PUT /api/software/{id}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "新名称",
|
||||
"slug": "new-slug",
|
||||
"description": "新描述"
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除软件
|
||||
|
||||
```
|
||||
DELETE /api/software/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 软件版本管理
|
||||
|
||||
#### 创建新版本
|
||||
|
||||
```
|
||||
POST /api/software/{id}/versions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"versionCode": 100,
|
||||
"changelog": "首次发布",
|
||||
"downloadType": "url",
|
||||
"externalUrl": "https://cdn.example.com/file.exe",
|
||||
"fileSize": 52428800,
|
||||
"forceUpdate": false,
|
||||
"minVersion": null
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `version` | 是 | `string` | 版本号 |
|
||||
| `versionCode` | 是 | `number` | 版本编码(用于比较) |
|
||||
| `changelog` | 否 | `string` | 更新日志 |
|
||||
| `downloadType` | 否 | `string` | `"local"` 或 `"url"`,默认 `"local"` |
|
||||
| `filePath` | 否 | `string` | 本地文件路径 |
|
||||
| `externalUrl` | 否 | `string` | 外部下载链接 |
|
||||
| `fileSize` | 否 | `number` | 文件大小(字节) |
|
||||
| `forceUpdate` | 否 | `boolean` | 是否强制更新 |
|
||||
| `minVersion` | 否 | `string` | 最低兼容版本 |
|
||||
| deviceId | string | **是** | 设备唯一标识,建议使用机器码或 UUID 持久化存储 |
|
||||
| os | string | 否 | 操作系统名称,如 `Windows`、`macOS`、`Linux` |
|
||||
| osVersion | string | 否 | 系统版本号,如 `10.0.19045`、`14.3` |
|
||||
| appVersion | string | 否 | 启动器版本号,如 `1.3.0` |
|
||||
|
||||
新版本自动设为 `isLatest: true`,之前的最新版本会被取消。
|
||||
**响应:**
|
||||
|
||||
#### 更新版本
|
||||
|
||||
```
|
||||
PUT /api/software/versions/{versionId}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"changelog": "修复问题",
|
||||
"isLatest": true
|
||||
}
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
所有字段均为可选,仅更新传入的字段。设置 `isLatest: true` 时自动取消其他版本的最新标记。
|
||||
|
||||
#### 删除版本
|
||||
|
||||
```
|
||||
DELETE /api/software/versions/{versionId}
|
||||
```
|
||||
**实现建议**:
|
||||
- 启动器启动时立即发送一次心跳,之后每 60 秒发送一次
|
||||
- `deviceId` 需要在客户端持久化保存,确保同一台机器始终使用相同 ID
|
||||
- IP 地址由服务端自动获取,无需客户端上报
|
||||
- 网络失败时静默忽略即可,不影响启动器正常功能
|
||||
|
||||
---
|
||||
|
||||
### 插件管理
|
||||
## 5. 历史更新日志 🆕
|
||||
|
||||
#### 创建插件
|
||||
查询指定软件的所有版本历史和更新日志,按版本号倒序排列。
|
||||
|
||||
```
|
||||
POST /api/addons
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "插件名",
|
||||
"slug": "addon-slug",
|
||||
"summary": "简介",
|
||||
"description": "详细描述",
|
||||
"iconUrl": "/uploads/icon.png",
|
||||
"category": "ui"
|
||||
}
|
||||
GET /api/software/changelog?slug={slug}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | 是 | 插件名称 |
|
||||
| `slug` | 是 | 唯一标识符 |
|
||||
| `summary` | 是 | 简短描述 |
|
||||
| `description` | 否 | 详细描述 |
|
||||
| `iconUrl` | 否 | 图标 URL |
|
||||
| `category` | 否 | 分类,默认 `"general"` |
|
||||
|
||||
#### 更新插件
|
||||
|
||||
```
|
||||
PUT /api/addons/{id}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "新名称",
|
||||
"published": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除插件
|
||||
|
||||
```
|
||||
DELETE /api/addons/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 插件版本发布
|
||||
|
||||
```
|
||||
POST /api/releases
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"addonId": "插件ID",
|
||||
"version": "1.0.0",
|
||||
"changelog": "首次发布",
|
||||
"downloadType": "url",
|
||||
"externalUrl": "https://cdn.example.com/addon.zip",
|
||||
"gameVersion": "11.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `addonId` | 是 | 所属插件 ID |
|
||||
| `version` | 是 | 版本号 |
|
||||
| `changelog` | 否 | 更新日志 |
|
||||
| `downloadType` | 否 | `"local"` 或 `"url"` |
|
||||
| `filePath` | 否 | 本地文件路径 |
|
||||
| `externalUrl` | 否 | 外部链接(`downloadType` 为 `"url"` 时必填) |
|
||||
| `gameVersion` | 否 | 适配的游戏版本 |
|
||||
|
||||
#### 获取版本列表
|
||||
|
||||
```
|
||||
GET /api/releases?addonId={addonId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Banner 管理
|
||||
|
||||
#### 创建 Banner
|
||||
|
||||
```
|
||||
POST /api/banners
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imageUrl": "/uploads/banner.png",
|
||||
"sortOrder": 0,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新 Banner
|
||||
|
||||
```
|
||||
PUT /api/banners/{id}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"sortOrder": 1,
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除 Banner
|
||||
|
||||
```
|
||||
DELETE /api/banners/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 画廊管理
|
||||
|
||||
#### 创建画廊图片
|
||||
|
||||
```
|
||||
POST /api/gallery
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imageUrl": "/uploads/screenshot.png",
|
||||
"title": "主界面截图",
|
||||
"sortOrder": 0,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新画廊图片
|
||||
|
||||
```
|
||||
PUT /api/gallery/{id}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "新标题",
|
||||
"sortOrder": 2,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除画廊图片
|
||||
|
||||
```
|
||||
DELETE /api/gallery/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 文件上传
|
||||
|
||||
上传文件到服务器。
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: (二进制文件)
|
||||
```
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| slug | string | 是 | 软件标识,如 `nanami-launcher` 或 `nanami-launcher-patch` |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"filePath": "/uploads/1710748800000-image.png",
|
||||
"originalName": "image.png",
|
||||
"size": 204800
|
||||
"name": "Nanami 启动器(全量包)",
|
||||
"slug": "nanami-launcher",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"versionCode": 130,
|
||||
"changelog": "- 新增xxx\n- 修复xxx",
|
||||
"fileSize": 52428800,
|
||||
"isLatest": true,
|
||||
"forceUpdate": false,
|
||||
"createdAt": "2026-03-25T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"versionCode": 120,
|
||||
"changelog": "- 优化xxx",
|
||||
"fileSize": 50331648,
|
||||
"isLatest": false,
|
||||
"forceUpdate": false,
|
||||
"createdAt": "2026-03-10T08:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
返回的 `filePath` 可用于其他接口的 `imageUrl`、`filePath` 等字段。
|
||||
**响应字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| versions[].version | string | 版本号 |
|
||||
| versions[].versionCode | number | 版本号(整数),用于比较大小 |
|
||||
| versions[].changelog | string | 更新日志内容 |
|
||||
| versions[].fileSize | number | 文件大小(字节) |
|
||||
| versions[].isLatest | boolean | 是否为当前最新版本 |
|
||||
| versions[].forceUpdate | boolean | 是否为强制更新版本 |
|
||||
| versions[].createdAt | string | 发布时间(ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
### 修改密码
|
||||
## 接口一览
|
||||
|
||||
```
|
||||
POST /api/admin/change-password
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码(≥6位)"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| `400` | 参数缺失或新密码过短 |
|
||||
| `403` | 当前密码错误 |
|
||||
| `404` | 用户不存在 |
|
||||
|
||||
---
|
||||
|
||||
## 通用错误格式
|
||||
|
||||
所有 API 错误均以 JSON 格式返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| `400` | 请求参数错误 |
|
||||
| `401` | 未认证 |
|
||||
| `404` | 资源不存在 |
|
||||
| `409` | 资源冲突(如 slug 重复) |
|
||||
| `500` | 服务器内部错误 |
|
||||
| 方法 | 路径 | 状态 | 用途 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/software/check-update` | ✏️ 变动 | 检查更新,downloadUrl 已含 source 标记 |
|
||||
| GET | `/api/software/download/{id}` | ✏️ 变动 | 下载文件,支持 source 参数区分来源 |
|
||||
| GET | `/api/software/latest` | 无变动 | 获取最新版信息/下载(网页用) |
|
||||
| GET | `/api/software/changelog` | 🆕 新增 | 查询历史更新日志 |
|
||||
| POST | `/api/launcher/heartbeat` | 🆕 新增 | 上报在线状态 |
|
||||
|
||||
@@ -71,21 +71,22 @@ model Software {
|
||||
}
|
||||
|
||||
model SoftwareVersion {
|
||||
id String @id @default(cuid())
|
||||
softwareId String
|
||||
version String
|
||||
versionCode Int
|
||||
changelog String @db.Text
|
||||
downloadType String @default("local")
|
||||
filePath String?
|
||||
externalUrl String?
|
||||
fileSize Int @default(0)
|
||||
downloadCount Int @default(0)
|
||||
isLatest Boolean @default(false)
|
||||
forceUpdate Boolean @default(false)
|
||||
minVersion String?
|
||||
createdAt DateTime @default(now())
|
||||
software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
softwareId String
|
||||
version String
|
||||
versionCode Int
|
||||
changelog String @db.Text
|
||||
downloadType String @default("local")
|
||||
filePath String?
|
||||
externalUrl String?
|
||||
fileSize Int @default(0)
|
||||
downloadCount Int @default(0)
|
||||
launcherDownloadCount Int @default(0)
|
||||
isLatest Boolean @default(false)
|
||||
forceUpdate Boolean @default(false)
|
||||
minVersion String?
|
||||
createdAt DateTime @default(now())
|
||||
software Software @relation(fields: [softwareId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([softwareId, version])
|
||||
@@index([softwareId])
|
||||
@@ -134,3 +135,16 @@ model PageView {
|
||||
@@index([date])
|
||||
@@index([path])
|
||||
}
|
||||
|
||||
model LauncherOnline {
|
||||
id String @id @default(cuid())
|
||||
deviceId String @unique
|
||||
ip String @default("")
|
||||
os String @default("")
|
||||
osVersion String @default("")
|
||||
appVersion String @default("")
|
||||
lastSeen DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([lastSeen])
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
|
||||
import { DownloadButton } from "@/components/public/DownloadButton";
|
||||
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AddonCard } from "@/components/public/AddonCard";
|
||||
import Link from "next/link";
|
||||
import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
general: "通用",
|
||||
gameplay: "游戏玩法",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { prisma } from "@/lib/db";
|
||||
import { Calendar, ArrowLeft } from "lucide-react";
|
||||
import { MarkdownContent } from "@/components/public/MarkdownContent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const metadata = {
|
||||
description: "Nanami 最新动态、更新公告与使用教程",
|
||||
};
|
||||
|
||||
export const revalidate = 30;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Download, Calendar, Tag } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "版本历史",
|
||||
description: "Nanami 启动器版本更新日志",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HeroBanner } from "@/components/public/HeroBanner";
|
||||
import { GameGallery } from "@/components/public/GameGallery";
|
||||
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
|
||||
|
||||
246
src/app/admin/(dashboard)/launcher-online/page.tsx
Normal file
246
src/app/admin/(dashboard)/launcher-online/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Users, Monitor, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface OnlineUser {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
os: string;
|
||||
osVersion: string;
|
||||
appVersion: string;
|
||||
lastSeen: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface OsCount {
|
||||
os: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface OnlineData {
|
||||
users: OnlineUser[];
|
||||
total: number;
|
||||
osList: OsCount[];
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 15_000;
|
||||
|
||||
export default function LauncherOnlinePage() {
|
||||
const [data, setData] = useState<OnlineData | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [osFilter, setOsFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (osFilter) params.set("os", osFilter);
|
||||
const res = await fetch(`/api/launcher/online?${params}`);
|
||||
if (res.ok) {
|
||||
setData(await res.json());
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, osFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const timer = setInterval(fetchData, REFRESH_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
function formatLastSeen(iso: string) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec} 秒前`;
|
||||
return `${Math.floor(sec / 60)} 分钟前`;
|
||||
}
|
||||
|
||||
function getOsBadgeVariant(os: string) {
|
||||
const lower = os.toLowerCase();
|
||||
if (lower.includes("windows")) return "default" as const;
|
||||
if (lower.includes("mac") || lower.includes("darwin"))
|
||||
return "secondary" as const;
|
||||
if (lower.includes("linux")) return "outline" as const;
|
||||
return "secondary" as const;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">在线用户</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
实时监控启动器在线状态,每 15 秒自动刷新
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
当前在线
|
||||
</CardTitle>
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{data?.total ?? "—"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data?.osList
|
||||
.filter((o) => o.os)
|
||||
.slice(0, 2)
|
||||
.map((o) => (
|
||||
<Card key={o.os}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{o.os}
|
||||
</CardTitle>
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{o.count}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>在线用户列表</CardTitle>
|
||||
<CardDescription>
|
||||
显示最近 3 分钟内有心跳的客户端
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
fetchData();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Input
|
||||
placeholder="搜索 IP / 设备ID / 版本号…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={osFilter} onValueChange={(v) => setOsFilter(v ?? "")}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="全部系统" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部系统</SelectItem>
|
||||
{data?.osList
|
||||
.filter((o) => o.os)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.os} value={o.os}>
|
||||
{o.os} ({o.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP 地址</TableHead>
|
||||
<TableHead>地理位置</TableHead>
|
||||
<TableHead>设备 ID</TableHead>
|
||||
<TableHead>操作系统</TableHead>
|
||||
<TableHead>系统版本</TableHead>
|
||||
<TableHead>启动器版本</TableHead>
|
||||
<TableHead>最后心跳</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!data || data.users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{loading ? "加载中…" : "暂无在线用户"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{user.ip || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{user.location || "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[140px] truncate font-mono text-xs text-muted-foreground"
|
||||
title={user.deviceId}
|
||||
>
|
||||
{user.deviceId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getOsBadgeVariant(user.os)}>
|
||||
{user.os || "Unknown"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{user.osVersion || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">v{user.appVersion}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatLastSeen(user.lastSeen)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
|
||||
import { Package, Download, FileUp, Eye, Users, TrendingUp, Monitor, Wifi } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -24,6 +24,8 @@ export default async function DashboardPage() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const days = getLast7Days();
|
||||
|
||||
const onlineThreshold = new Date(Date.now() - 3 * 60 * 1000);
|
||||
|
||||
const [
|
||||
addonCount,
|
||||
totalDownloads,
|
||||
@@ -33,6 +35,9 @@ export default async function DashboardPage() {
|
||||
totalPV,
|
||||
todayUV,
|
||||
pvByDay,
|
||||
launcherDownloads,
|
||||
launcherUpdateDownloads,
|
||||
onlineCount,
|
||||
] = await Promise.all([
|
||||
prisma.addon.count(),
|
||||
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
|
||||
@@ -54,6 +59,9 @@ export default async function DashboardPage() {
|
||||
_count: true,
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.softwareVersion.aggregate({ _sum: { downloadCount: true } }),
|
||||
prisma.softwareVersion.aggregate({ _sum: { launcherDownloadCount: true } }),
|
||||
prisma.launcherOnline.count({ where: { lastSeen: { gte: onlineThreshold } } }),
|
||||
]);
|
||||
|
||||
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
|
||||
@@ -63,10 +71,17 @@ export default async function DashboardPage() {
|
||||
}));
|
||||
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
|
||||
|
||||
const totalSwDownloads = launcherDownloads._sum.downloadCount || 0;
|
||||
const totalLauncherUpdates = launcherUpdateDownloads._sum.launcherDownloadCount || 0;
|
||||
const webDownloads = totalSwDownloads - totalLauncherUpdates;
|
||||
|
||||
const stats = [
|
||||
{ title: "插件总数", value: addonCount, icon: Package },
|
||||
{ title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
|
||||
{ title: "插件总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
|
||||
{ title: "版本发布数", value: releaseCount, icon: FileUp },
|
||||
{ title: "启动器下载量 (网页)", value: webDownloads, icon: Monitor },
|
||||
{ title: "启动器更新量 (客户端)", value: totalLauncherUpdates, icon: Download },
|
||||
{ title: "启动器在线人数", value: onlineCount, icon: Wifi },
|
||||
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
|
||||
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
|
||||
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
|
||||
@@ -76,7 +91,7 @@ export default async function DashboardPage() {
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-3xl font-bold">仪表盘</h1>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, summary, description, iconUrl, category } = body;
|
||||
const { name, slug, summary, description, iconUrl, category, published } = body;
|
||||
|
||||
if (!name || !slug || !summary) {
|
||||
return NextResponse.json(
|
||||
@@ -65,6 +65,7 @@ export async function POST(request: NextRequest) {
|
||||
description: description || "",
|
||||
iconUrl: iconUrl || null,
|
||||
category: category || "general",
|
||||
published: published === true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
48
src/app/api/launcher/heartbeat/route.ts
Normal file
48
src/app/api/launcher/heartbeat/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { deviceId, os, osVersion, appVersion } = body as {
|
||||
deviceId?: string;
|
||||
os?: string;
|
||||
osVersion?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
if (!deviceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "deviceId is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
|
||||
await prisma.launcherOnline.upsert({
|
||||
where: { deviceId },
|
||||
update: {
|
||||
ip,
|
||||
os: os || "",
|
||||
osVersion: osVersion || "",
|
||||
appVersion: appVersion || "",
|
||||
lastSeen: new Date(),
|
||||
},
|
||||
create: {
|
||||
deviceId,
|
||||
ip,
|
||||
os: os || "",
|
||||
osVersion: osVersion || "",
|
||||
appVersion: appVersion || "",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
src/app/api/launcher/online/route.ts
Normal file
122
src/app/api/launcher/online/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const ONLINE_THRESHOLD_MS = 3 * 60 * 1000;
|
||||
|
||||
// IP geolocation cache: ip -> { location, expiry }
|
||||
const ipCache = new Map<string, { location: string; expiry: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
interface IpApiResult {
|
||||
query: string;
|
||||
status: string;
|
||||
country?: string;
|
||||
regionName?: string;
|
||||
city?: string;
|
||||
isp?: string;
|
||||
}
|
||||
|
||||
async function resolveIpLocations(ips: string[]): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
const now = Date.now();
|
||||
const toQuery: string[] = [];
|
||||
|
||||
for (const ip of ips) {
|
||||
const cached = ipCache.get(ip);
|
||||
if (cached && cached.expiry > now) {
|
||||
result.set(ip, cached.location);
|
||||
} else {
|
||||
toQuery.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
if (toQuery.length === 0) return result;
|
||||
|
||||
try {
|
||||
const res = await fetch("http://ip-api.com/batch?lang=zh-CN", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
toQuery.map((ip) => ({ query: ip, fields: "query,status,country,regionName,city,isp" }))
|
||||
),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data: IpApiResult[] = await res.json();
|
||||
for (const item of data) {
|
||||
let location = "";
|
||||
if (item.status === "success") {
|
||||
const parts = [item.country, item.regionName, item.city].filter(Boolean);
|
||||
const unique = [...new Set(parts)];
|
||||
location = unique.join(" ") + (item.isp ? ` (${item.isp})` : "");
|
||||
}
|
||||
result.set(item.query, location);
|
||||
ipCache.set(item.query, { location, expiry: now + CACHE_TTL_MS });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// IP lookup failure is non-critical
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get("search") || "";
|
||||
const osFilter = searchParams.get("os") || "";
|
||||
|
||||
const threshold = new Date(Date.now() - ONLINE_THRESHOLD_MS);
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
lastSeen: { gte: threshold },
|
||||
};
|
||||
|
||||
if (osFilter) {
|
||||
where.os = osFilter;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ ip: { contains: search } },
|
||||
{ deviceId: { contains: search } },
|
||||
{ appVersion: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.launcherOnline.findMany({
|
||||
where,
|
||||
orderBy: { lastSeen: "desc" },
|
||||
}),
|
||||
prisma.launcherOnline.count({
|
||||
where: { lastSeen: { gte: threshold } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const osList = await prisma.launcherOnline.groupBy({
|
||||
by: ["os"],
|
||||
where: { lastSeen: { gte: threshold } },
|
||||
_count: true,
|
||||
orderBy: { _count: { os: "desc" } },
|
||||
});
|
||||
|
||||
const uniqueIps = [...new Set(users.map((u) => u.ip).filter(Boolean))];
|
||||
const ipLocations = await resolveIpLocations(uniqueIps);
|
||||
|
||||
return NextResponse.json({
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
deviceId: u.deviceId,
|
||||
ip: u.ip,
|
||||
location: ipLocations.get(u.ip) || "",
|
||||
os: u.os,
|
||||
osVersion: u.osVersion,
|
||||
appVersion: u.appVersion,
|
||||
lastSeen: u.lastSeen.toISOString(),
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
osList: osList.map((o) => ({ os: o.os, count: o._count })),
|
||||
});
|
||||
}
|
||||
53
src/app/api/software/changelog/route.ts
Normal file
53
src/app/api/software/changelog/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get("slug");
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{ error: "slug parameter is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const software = await prisma.software.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionCode: "desc" },
|
||||
select: {
|
||||
version: true,
|
||||
versionCode: true,
|
||||
changelog: true,
|
||||
fileSize: true,
|
||||
isLatest: true,
|
||||
forceUpdate: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!software) {
|
||||
return NextResponse.json(
|
||||
{ error: "Software not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
name: software.name,
|
||||
slug: software.slug,
|
||||
versions: software.versions.map((v) => ({
|
||||
version: v.version,
|
||||
versionCode: v.versionCode,
|
||||
changelog: v.changelog,
|
||||
fileSize: v.fileSize,
|
||||
isLatest: v.isLatest,
|
||||
forceUpdate: v.forceUpdate,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -39,10 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const origin = process.env.API_BASE_URL || process.env.NEXTAUTH_URL || request.nextUrl.origin;
|
||||
|
||||
const downloadUrl =
|
||||
latest.downloadType === "url" && latest.externalUrl
|
||||
? latest.externalUrl
|
||||
: `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}`;
|
||||
const downloadUrl = `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}?source=launcher`;
|
||||
|
||||
return NextResponse.json({
|
||||
hasUpdate,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { readFile, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const source = new URL(request.url).searchParams.get("source");
|
||||
|
||||
const sv = await prisma.softwareVersion.findUnique({
|
||||
where: { id },
|
||||
@@ -18,9 +19,13 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const isLauncher = source === "launcher";
|
||||
await prisma.softwareVersion.update({
|
||||
where: { id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
data: {
|
||||
downloadCount: { increment: 1 },
|
||||
...(isLauncher && { launcherDownloadCount: { increment: 1 } }),
|
||||
},
|
||||
});
|
||||
|
||||
if (sv.downloadType === "url" && sv.externalUrl) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "https://nanami.rucky.cn";
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
@@ -23,6 +24,7 @@ const navItems = [
|
||||
{ href: "/admin/addons", label: "插件管理", icon: Package },
|
||||
{ href: "/admin/releases", label: "插件版本", icon: Upload },
|
||||
{ href: "/admin/software", label: "软件管理", icon: Monitor },
|
||||
{ href: "/admin/launcher-online", label: "在线用户", icon: Users },
|
||||
{ href: "/admin/media", label: "媒体管理", icon: ImageIcon },
|
||||
{ href: "/admin/articles", label: "文章管理", icon: FileText },
|
||||
{ href: "/admin/settings", label: "系统设置", icon: Settings },
|
||||
|
||||
Reference in New Issue
Block a user