Compare commits
6 Commits
57f4d94d0a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f00124c1c0 | |||
| 962a2f2143 | |||
| 2513eb2903 | |||
| f525e5f3ba | |||
| 2171b933eb | |||
| cd2fd435a2 |
@@ -0,0 +1,525 @@
|
||||
# YMhut Box 中文项目说明
|
||||
|
||||
YMhut Box 是一个以 C#、.NET 和 WinUI 3 为核心的 Windows 原生工具箱项目。当前仓库同时保留了新版 WinUI 桌面端、核心工具逻辑、测试项目、辅助进程、统一管理服务、旧版 Electron 项目以及更新/反馈相关服务代码。
|
||||
|
||||
当前版本信息来自 `version.json`:
|
||||
|
||||
- 版本号:`2.0.7`
|
||||
- 构建号:`05`
|
||||
- 发布通道:`stable`
|
||||
|
||||
## 项目定位
|
||||
|
||||
本项目用于构建 YMhut Box 桌面工具箱,主要目标是把原有 Web/Electron 工具箱迁移到原生 Windows 桌面体验,并配套提供更新、反馈、资源管理和服务端管理能力。
|
||||
|
||||
核心能力包括:
|
||||
|
||||
- WinUI 3 桌面壳、页面、窗口和托盘体验。
|
||||
- C#/.NET 核心工具目录、工具执行、设置、日志、反馈、下载和更新逻辑。
|
||||
- WebView2 内嵌页面,用于工具页、结果页、启动动画和 3D/可视化内容。
|
||||
- 独立 Worker、插件宿主和下载宿主进程。
|
||||
- MSTest 单元测试。
|
||||
- MSIX 和传统 EXE 安装包构建流程。
|
||||
- Go 版本统一管理服务,兼容旧版更新 JSON、下载目录和反馈接口。
|
||||
- 旧版 Electron 项目保留,便于历史对照和迁移。
|
||||
|
||||
## 技术栈
|
||||
|
||||
桌面端:
|
||||
|
||||
- C# / .NET 10
|
||||
- WinUI 3
|
||||
- Windows App SDK
|
||||
- WebView2
|
||||
- Microsoft.Extensions.DependencyInjection
|
||||
- SQLite
|
||||
- MSTest
|
||||
|
||||
服务端:
|
||||
|
||||
- Go
|
||||
- SQLite,部分路径支持 MySQL 配置
|
||||
- Vue 前端管理台和门户页
|
||||
- Rust WASM 辅助模块
|
||||
|
||||
旧版客户端:
|
||||
|
||||
- Electron
|
||||
- Node.js
|
||||
- HTML/CSS/JavaScript
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```text
|
||||
.
|
||||
|-- src/
|
||||
| |-- box-winUI/ # WinUI 3 桌面应用
|
||||
| |-- YMhut.Box.Core/ # 核心业务逻辑、工具系统、设置、日志、反馈、更新
|
||||
| |-- YMhut.Box.Tests/ # MSTest 测试项目
|
||||
| |-- YMhut.Box.Worker/ # 工具执行 Worker 进程
|
||||
| |-- YMhut.Box.PluginHost/ # 插件宿主进程
|
||||
| `-- YMhut.Box.DownloadHost/ # 下载宿主进程
|
||||
|-- assets/ # 图标、字体、图片、本地数据、太阳系纹理源素材
|
||||
|-- server/
|
||||
| |-- unified-management/ # 统一管理服务,整合更新和反馈能力
|
||||
| |-- update/ # 旧更新服务 Go 实现
|
||||
| `-- feedback-mailer/ # 旧反馈工单服务
|
||||
|-- box-old/ # 旧版 Electron 工具箱
|
||||
|-- installer/ # Inno Setup 安装脚本
|
||||
|-- scripts/ # 构建脚本
|
||||
|-- docs/ # 架构、贡献、迁移、插件文档
|
||||
|-- update-notice/ # 更新公告 JSON
|
||||
|-- 新增工具文档/ # 新增工具接口/需求说明
|
||||
|-- build.bat # Windows 构建入口
|
||||
|-- YMhut.Box.Native.sln # Visual Studio 解决方案
|
||||
|-- Directory.Build.props # 统一版本注入
|
||||
|-- NuGet.Config # NuGet 配置
|
||||
|-- version.json # 项目版本源
|
||||
`-- README.md # 英文简要说明
|
||||
```
|
||||
|
||||
## 解决方案项目
|
||||
|
||||
`YMhut.Box.Native.sln` 包含以下项目:
|
||||
|
||||
- `YMhut.Box.Core`:核心库,目标框架 `net10.0`。
|
||||
- `YMhut.Box.WinUI`:桌面主程序,目标框架 `net10.0-windows10.0.19041.0`。
|
||||
- `YMhut.Box.Tests`:测试项目,目标框架 `net10.0`。
|
||||
- `YMhut.Box.Worker`:独立工具执行进程。
|
||||
- `YMhut.Box.PluginHost`:插件宿主进程。
|
||||
- `YMhut.Box.DownloadHost`:下载宿主进程。
|
||||
|
||||
## 桌面端说明
|
||||
|
||||
主程序目录是 `src/box-winUI/`。
|
||||
|
||||
主要内容:
|
||||
|
||||
- `App.xaml`、`App.xaml.cs`:应用入口和生命周期。
|
||||
- `MainWindow.xaml`、`MainWindow.xaml.cs`:主窗口。
|
||||
- `Views/`:主页面、设置页、工具箱页、媒体页、浏览器页、日志页、插件页等。
|
||||
- `Views/Tools/`:具体工具页面、工具注册表、工具页面基类和生成工具页。
|
||||
- `Services/`:下载、更新、托盘、启动、插件、WebView2、窗口状态等服务。
|
||||
- `Controls/`:天气、指标图表、信息条等自定义控件。
|
||||
- `Assets/`:桌面端图标、启动页、工具页 Web 资源、太阳系/地球 3D 资源。
|
||||
- `Strings/zh-CN`、`Strings/en-US`:中英文资源文件。
|
||||
|
||||
Release 构建时:
|
||||
|
||||
- 运行时标识为 `win-x64`。
|
||||
- 使用 MSIX 包类型。
|
||||
- 关闭 Appx 签名,由构建脚本处理本地证书和输出。
|
||||
- 会复制 Worker、PluginHost、DownloadHost 输出到主程序输出目录。
|
||||
- 会复制内置 WebView2 静态资源和工具数据。
|
||||
|
||||
## 核心库说明
|
||||
|
||||
核心库目录是 `src/YMhut.Box.Core/`。
|
||||
|
||||
主要模块:
|
||||
|
||||
- `Api/`:API 端点和远程接口管理。
|
||||
- `App/`:应用路径、安装布局、运行时布局策略、开源参考资料。
|
||||
- `Data/`:本地参考数据加载。
|
||||
- `DevEnvironments/`:开发环境配置模型。
|
||||
- `Downloads/`:下载队列、下载模型、直接下载校验。
|
||||
- `Feedback/`:反馈码、反馈提交、反馈包加密、反馈记录。
|
||||
- `Logging/`:JSON、SQLite、弹性日志服务和日志展示本地化。
|
||||
- `Media/`:远程媒体目录、媒体解析、音量模型。
|
||||
- `Net/`:HTTP 服务和重定向解析。
|
||||
- `Plugins/`:内置插件、插件注册、插件状态和插件宿主协议。
|
||||
- `Settings/`:应用设置、语言、置顶工具、协议文档。
|
||||
- `SolarSystem/`:太阳系星历模型和服务。
|
||||
- `Startup/`:启动检查、安装完整性检查和启动初始化管线。
|
||||
- `System/`:硬件信息和系统指标。
|
||||
- `Tools/`:工具目录、工具执行、结果渲染、隐私清理、Worker 协议和布局计算。
|
||||
- `Updates/`:更新模型、版本比较、更新接口策略。
|
||||
|
||||
## 测试
|
||||
|
||||
测试项目在 `src/YMhut.Box.Tests/`。
|
||||
|
||||
测试覆盖方向包括:
|
||||
|
||||
- 设置读写和协议接受。
|
||||
- 下载、开发环境、反馈服务。
|
||||
- 安装布局、日志、媒体解析。
|
||||
- 插件、工具目录、工具执行和 Worker。
|
||||
- 远程工具排序、结果体验、Web payload 序列化。
|
||||
- 敏感文本、启动检查、更新版本比较。
|
||||
|
||||
常用测试命令:
|
||||
|
||||
```powershell
|
||||
dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources
|
||||
dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
推荐使用根目录的 `build.bat`:
|
||||
|
||||
```powershell
|
||||
build.bat --target=publish
|
||||
build.bat --target=msix
|
||||
build.bat --target=exe
|
||||
build.bat --target=both
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
```powershell
|
||||
build.bat --target=both --configuration=Release --platform=x64
|
||||
build.bat --target=both --skip-tests
|
||||
build.bat --target=both --skip-exe
|
||||
build.bat --target=exe --skip-msix
|
||||
```
|
||||
|
||||
构建脚本实际入口是:
|
||||
|
||||
```powershell
|
||||
scripts\build-winui.ps1
|
||||
```
|
||||
|
||||
主要输出目录:
|
||||
|
||||
- `build/winui/publish/`:发布输出。
|
||||
- `build/winui/msix-stage/`:MSIX 临时目录。
|
||||
- `build/winui/logs/`:构建日志。
|
||||
- `latest/`:最新构建结果。
|
||||
- `installer_output/`:安装包、证书、更新信息。
|
||||
- `server/update/public/downloads/`:可被更新服务使用的下载包目录。
|
||||
|
||||
这些输出目录通常不提交到 Git。
|
||||
|
||||
## 发布和更新
|
||||
|
||||
构建脚本会使用以下默认地址生成下载和更新信息:
|
||||
|
||||
- 下载基础地址:`https://update.ymhut.cn/downloads/`
|
||||
- 更新信息基础地址:`https://update.ymhut.cn/update-info/`
|
||||
|
||||
可通过环境变量覆盖:
|
||||
|
||||
```powershell
|
||||
$env:YMHUT_DOWNLOAD_BASE_URI="https://example.com/downloads/"
|
||||
$env:YMHUT_UPDATE_BASE_URI="https://example.com/update-info/"
|
||||
```
|
||||
|
||||
更新公告文件位于 `update-notice/`,用于维护不同版本的更新说明。
|
||||
|
||||
## 服务端
|
||||
|
||||
### 统一管理服务
|
||||
|
||||
目录:
|
||||
|
||||
```text
|
||||
server/unified-management/
|
||||
```
|
||||
|
||||
它用于整合旧 `server/update` 和 `server/feedback-mailer` 的能力。
|
||||
|
||||
主要能力:
|
||||
|
||||
- Go 后端,默认 SQLite。
|
||||
- 可选 MySQL 配置。
|
||||
- 兼容旧路由:`update-info.json`、`tool-status.json`、`media-types.json`、`modules.json`、下载文件和反馈接口。
|
||||
- 提供 `/api/client/bootstrap` 客户端发现接口。
|
||||
- 管理员登录、验证码、HttpOnly session cookie、CSRF 检查。
|
||||
- 媒体源和数据源健康检查。
|
||||
- Vue 管理台、门户页和 Rust WASM 辅助模块。
|
||||
|
||||
运行:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```powershell
|
||||
go run .\cmd\unified-management
|
||||
```
|
||||
|
||||
测试:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
默认监听地址:
|
||||
|
||||
```text
|
||||
:33550
|
||||
```
|
||||
|
||||
常用环境变量:
|
||||
|
||||
- `YMHUT_LISTEN`:监听地址。
|
||||
- `PORT`:端口,作为监听地址的简写来源。
|
||||
- `YMHUT_BASE_URL`:公开服务地址。
|
||||
- `YMHUT_DB_PROVIDER`:`sqlite` 或 `mysql`。
|
||||
- `YMHUT_SQLITE_PATH`:SQLite 数据库路径。
|
||||
- `YMHUT_MYSQL_DSN`:MySQL DSN。
|
||||
- `YMHUT_UPDATE_PUBLIC_DIR`:旧更新服务 public 目录。
|
||||
- `YMHUT_DOWNLOADS_DIR`:下载包目录。
|
||||
- `YMHUT_SOURCE_CHECK_SECONDS`:源健康检查间隔。
|
||||
|
||||
前端目录:
|
||||
|
||||
- `web/admin`:Vue 管理台。
|
||||
- `web/portal`:Vue 公共门户。
|
||||
- `web/setup`:初始化/设置页面。
|
||||
- `web/wasm`:Rust WASM 辅助模块。
|
||||
|
||||
前端构建示例:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management\web\admin
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
cd ..\portal
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
发布二进制:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management
|
||||
.\scripts\build-release.ps1 -Version 0.1.0
|
||||
```
|
||||
|
||||
输出目录为 `server/unified-management/dist-release/`,该目录不提交。
|
||||
|
||||
### 更新服务
|
||||
|
||||
目录:
|
||||
|
||||
```text
|
||||
server/update/
|
||||
```
|
||||
|
||||
该目录保留旧更新服务的 Go 实现,包括:
|
||||
|
||||
- 下载中心页面。
|
||||
- JSON API。
|
||||
- 下载文件目录。
|
||||
- 管理后台。
|
||||
- 路由配置。
|
||||
- 用户和认证模型。
|
||||
- 静态资源和视图模板。
|
||||
|
||||
注意:`server/update/public/downloads/` 存放安装包和 MSIX 等大文件,已在 `.gitignore` 中排除。
|
||||
|
||||
### 反馈服务
|
||||
|
||||
目录:
|
||||
|
||||
```text
|
||||
server/feedback-mailer/
|
||||
```
|
||||
|
||||
该目录保留反馈工单服务,包括:
|
||||
|
||||
- Go 后端。
|
||||
- SQLite 数据。
|
||||
- 管理前端。
|
||||
- 反馈包处理。
|
||||
- 旧客户端兼容接口。
|
||||
|
||||
注意:`config.json`、生成的可执行文件和 `storage/` 运行态目录已在 `.gitignore` 中排除。
|
||||
|
||||
## 旧版项目
|
||||
|
||||
旧版 Electron 项目保留在:
|
||||
|
||||
```text
|
||||
box-old/
|
||||
```
|
||||
|
||||
它包含:
|
||||
|
||||
- Electron 主进程和 preload。
|
||||
- 旧版 Web UI。
|
||||
- 旧版工具实现。
|
||||
- 旧版工具状态和更新信息。
|
||||
- 旧构建脚本和安装配置。
|
||||
|
||||
该目录主要用于历史保留、迁移参考和功能对照。
|
||||
|
||||
## 资源
|
||||
|
||||
资源目录:
|
||||
|
||||
```text
|
||||
assets/
|
||||
```
|
||||
|
||||
主要内容:
|
||||
|
||||
- `icons/`:应用图标。
|
||||
- `images/`:图片资源。
|
||||
- `fonts/`:字体资源。
|
||||
- `data/`:本地数据,如行政区划、三国杀皮肤配置、参考数据。
|
||||
- `source-textures/solar/`:太阳系纹理源文件。
|
||||
|
||||
桌面端运行时还会从 `src/box-winUI/Assets/` 加载应用内静态资源,包括:
|
||||
|
||||
- 启动画面。
|
||||
- 工具页 Web 资源。
|
||||
- 工具结果页 Web 资源。
|
||||
- home globe 和太阳系可视化资源。
|
||||
|
||||
## Git 和 Gitea 推送
|
||||
|
||||
当前远端仓库地址:
|
||||
|
||||
```text
|
||||
http://admin_gitea@git.ymhut.cn/admin_gitea/YMhut-box-C-.git
|
||||
```
|
||||
|
||||
常用提交流程:
|
||||
|
||||
```powershell
|
||||
git status
|
||||
git add .
|
||||
git commit -m "提交说明"
|
||||
git push
|
||||
```
|
||||
|
||||
查看远端:
|
||||
|
||||
```powershell
|
||||
git remote -v
|
||||
```
|
||||
|
||||
如果提示需要登录,请使用 Gitea 账号登录。不要把密码写进远端 URL,也不要把密码写进文档或提交记录。
|
||||
|
||||
如果 Git 使用了错误的缓存凭据,可以清理该站点凭据后重新推送:
|
||||
|
||||
```powershell
|
||||
@"
|
||||
protocol=http
|
||||
host=git.ymhut.cn
|
||||
|
||||
"@ | git credential reject
|
||||
|
||||
git push
|
||||
```
|
||||
|
||||
## 不应提交的内容
|
||||
|
||||
`.gitignore` 已排除常见缓存、构建产物、运行态数据和大安装包。
|
||||
|
||||
主要包括:
|
||||
|
||||
- `.cache/`
|
||||
- `.codex/`
|
||||
- `.codex_state/`
|
||||
- `.agents/`
|
||||
- `.tmp/`
|
||||
- `.idea/`
|
||||
- `.vs/`
|
||||
- `build/`
|
||||
- `latest/`
|
||||
- `installer_output/`
|
||||
- `Release/`
|
||||
- `node_modules/`
|
||||
- `dist/`
|
||||
- `bin/`
|
||||
- `obj/`
|
||||
- `src/box-winUI/AppPackages/`
|
||||
- `server/update/build/`
|
||||
- `server/update/public/downloads/`
|
||||
- `server/unified-management/dist-release/`
|
||||
- `server/unified-management/storage/`
|
||||
- `server/feedback-mailer/storage/`
|
||||
- `server/feedback-mailer/config.json`
|
||||
|
||||
如果执行 `git status --ignored` 看到这些路径前面是 `!!`,表示它们已被忽略,这是正常现象。
|
||||
|
||||
## 常见状态说明
|
||||
|
||||
`git status` 显示:
|
||||
|
||||
```text
|
||||
Your branch is up to date with 'origin/main'.
|
||||
```
|
||||
|
||||
表示本地分支和远端分支同步。
|
||||
|
||||
显示:
|
||||
|
||||
```text
|
||||
Changes not staged for commit
|
||||
```
|
||||
|
||||
表示有文件已修改,但还没有执行 `git add`。
|
||||
|
||||
显示:
|
||||
|
||||
```text
|
||||
LF will be replaced by CRLF
|
||||
```
|
||||
|
||||
这是 Windows 换行提示,不是错误。它表示 Git 可能在工作区把 LF 换行转换成 CRLF。
|
||||
|
||||
显示:
|
||||
|
||||
```text
|
||||
Everything up-to-date
|
||||
```
|
||||
|
||||
表示没有新的本地提交需要推送。
|
||||
|
||||
## 本地开发建议
|
||||
|
||||
推荐流程:
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
dotnet restore YMhut.Box.Native.sln --configfile NuGet.Config --ignore-failed-sources
|
||||
dotnet build src\box-winUI\YMhut.Box.WinUI.csproj -c Debug -p:Platform=x64 --no-restore
|
||||
dotnet test src\YMhut.Box.Tests\YMhut.Box.Tests.csproj -c Debug --no-restore
|
||||
```
|
||||
|
||||
服务端开发:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management
|
||||
go test ./...
|
||||
go run main.go
|
||||
```
|
||||
|
||||
前端管理台开发:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management\web\admin
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
门户页开发:
|
||||
|
||||
```powershell
|
||||
cd server\unified-management\web\portal
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 维护注意事项
|
||||
|
||||
- 修改版本号时优先更新 `version.json`,构建时会通过 `Directory.Build.props` 注入程序集版本。
|
||||
- 提交前先看 `git status`,确认没有缓存、构建产物和本地数据库混入。
|
||||
- 发布安装包、MSIX、下载包建议走构建脚本输出目录,不直接手动放入源码提交。
|
||||
- 服务端默认账号、数据库路径、生产密钥和密码不要写入仓库。
|
||||
- 大文件如果确实需要进入仓库,建议分批提交和推送,避免 HTTP 413。
|
||||
|
||||
## 许可证
|
||||
|
||||
仓库包含 `LICENSE`,当前为 GNU General Public License v3.0 文本。
|
||||
@@ -30,7 +30,7 @@ func Run() {
|
||||
log.Printf("YMhut unified management %s preflight", config.Version)
|
||||
log.Printf("entrypoint ok: go run main.go")
|
||||
log.Printf("listen: %s", cfg.Listen)
|
||||
for _, line := range config.FormatPreflight(config.Preflight(cfg)) {
|
||||
for _, line := range config.FormatPreflight(cfg, config.Preflight(cfg)) {
|
||||
log.Print(line)
|
||||
}
|
||||
if !cfg.Initialized && os.Getenv("YMHUT_SKIP_SETUP") != "1" {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# YMhut Unified Management Backend Architecture Review
|
||||
|
||||
## 结论
|
||||
|
||||
`server/unified-management` 目前已经具备统一服务的基本形态:一个 Go 后端同时承接旧版更新 JSON、下载、媒体源、反馈提交、新客户端 bootstrap、管理后台、SQLite/MySQL 存储和旧项目同步。现有 `go test ./...` 全部通过,说明核心兼容路由和主要模块有基本回归保障。
|
||||
|
||||
但当前实现仍属于“快速整合型单体”:`internal/web/router.go` 和 `internal/db/store.go` 承担了过多职责,后台能力已经铺开,但业务边界、数据访问边界、兼容适配边界还不够清晰。继续扩展前,应先把后台架构拆成稳定的分层单体,保留旧访问方式和旧反馈协议作为独立兼容层。
|
||||
|
||||
## 当前主要问题
|
||||
|
||||
1. 路由层过重
|
||||
|
||||
`internal/web/router.go` 同时处理公共门户、旧版路由、客户端 API、后台 API、静态资源、错误本地化、SSE、CSV 导出和文件下载。后续新增后台功能时,容易出现路径匹配顺序冲突、权限遗漏和响应结构不一致。
|
||||
|
||||
2. 数据层过重
|
||||
|
||||
`internal/db/store.go` 同时包含表结构迁移、实体定义、Repository、密码哈希、业务默认值、统计聚合、SQLite/MySQL failover、全表同步和旧原型 JSON 导入。它已经变成“全系统共享内核”,任何改动都容易影响多个业务域。
|
||||
|
||||
3. 兼容逻辑与新业务逻辑混杂
|
||||
|
||||
旧版 `update-info.json`、`media-types.json`、`tool-status.json`、`modules.json`、`/downloads/*` 和旧反馈 `POST /` 都是必须保留的外部契约,但现在它们和新 API 共享服务/存储细节,长期看会让新后台被旧 JSON 格式牵着走。
|
||||
|
||||
4. 安全策略需要后台化
|
||||
|
||||
当前有验证码、HttpOnly session cookie、CSRF、上传包校验和路径逃逸防护,这是好基础。但后台还需要更明确的密码策略、登录失败限流、会话持久化/吊销、审计事件规范、管理员角色模型。当前密码哈希是静态 SHA-256,建议迁移到 Argon2id 或 bcrypt。
|
||||
|
||||
5. 数据库双写/同步语义不清
|
||||
|
||||
配置里有 failover 和 hot sync 字段,但当前更接近手动全表复制和运行时 MySQL 失败回退 SQLite。需要明确“单主数据库、备份数据库、灾备同步”的关系,否则后台管理员很难判断哪些操作会覆盖数据。
|
||||
|
||||
6. 前端后台状态集中
|
||||
|
||||
`web/admin/src/App.vue` 集中了 API client、页面路由、全局状态、业务动作、表单转换、错误翻译和 SSE 连接。后台页面已经很多,建议拆成 `api/`、`stores/`、`features/`,否则维护体验会快速变差。
|
||||
|
||||
## 推荐后台架构
|
||||
|
||||
保持 Go 单体部署,但按边界拆成分层模块:
|
||||
|
||||
```text
|
||||
cmd/unified-management
|
||||
app/ # 进程启动、依赖组装、优雅退出
|
||||
|
||||
internal/http
|
||||
middleware/ # auth、csrf、security headers、request id、rate limit
|
||||
legacy/ # 旧访问方式适配器
|
||||
clientapi/ # 新客户端 API
|
||||
adminapi/ # 后台管理 API
|
||||
setupapi/ # 首次初始化 API
|
||||
static/ # admin/portal/setup 前端资源
|
||||
|
||||
internal/domain
|
||||
feedback/ # 反馈工单聚合、状态流转、附件规则
|
||||
releases/ # 发布包、版本公告、manifest
|
||||
sources/ # 数据源目录、健康检查、调用日志
|
||||
compatibility/ # 旧 JSON 文档模型和转换
|
||||
audit/ # 审计事件
|
||||
admin/ # 管理员、角色、会话
|
||||
system/ # 健康检查、配置、数据库状态
|
||||
|
||||
internal/storage
|
||||
migrations/ # schema 和版本迁移
|
||||
repos/ # 按领域拆分 Repository
|
||||
sqlstore/ # SQLite/MySQL 方言、连接、事务
|
||||
sync/ # SQLite/MySQL 同步策略
|
||||
|
||||
internal/jobs
|
||||
sourcecheck/ # 数据源健康检测
|
||||
legacysync/ # 旧项目导入/同步
|
||||
cleanup/ # 过期会话、旧日志、临时文件清理
|
||||
|
||||
internal/contracts
|
||||
legacy/ # 旧客户端响应 DTO
|
||||
client/ # 新客户端响应 DTO
|
||||
admin/ # 后台响应 DTO
|
||||
```
|
||||
|
||||
核心原则:
|
||||
|
||||
- `legacy` 只负责旧协议输入输出,不直接写业务表细节。
|
||||
- `domain` 负责业务规则,不依赖 HTTP request/response。
|
||||
- `storage` 负责持久化和事务,不包含业务默认文案、状态流转和 UI 语义。
|
||||
- `adminapi` 是后台编排层,只调用 domain service,不直接拼 SQL。
|
||||
- 所有对外 JSON 响应都定义 DTO,避免直接暴露数据库结构。
|
||||
|
||||
## 必须保留的旧版访问契约
|
||||
|
||||
这些路径应作为长期兼容 API,不随后台重构而改变:
|
||||
|
||||
| 旧版入口 | 当前用途 | 建议归属 |
|
||||
| --- | --- | --- |
|
||||
| `GET /update-info.json`、`GET /update-info` | 旧客户端更新信息 | `internal/http/legacy/update.go` |
|
||||
| `GET /tool-status.json`、`GET /tool-status` | 旧工具状态 | `internal/http/legacy/static_json.go` |
|
||||
| `GET /modules.json`、`GET /modules`、`GET /api/modules` | 旧模块配置 | `internal/http/legacy/static_json.go` |
|
||||
| `GET /media-types.json`、`GET /media-types` | 旧媒体源目录 | `internal/http/legacy/media_types.go` |
|
||||
| `GET /downloads/:filename` | 旧下载包 | `internal/http/legacy/downloads.go` |
|
||||
| `POST /` | 旧反馈提交 | `internal/http/legacy/feedback.go` |
|
||||
| `GET /?api=status&code=:code` | 旧反馈状态查询 | `internal/http/legacy/feedback.go` |
|
||||
|
||||
兼容层的响应字段应保持“只增不删、不改名、不改含义”。新后台可以增加内部字段,但旧接口输出必须通过 legacy DTO 过滤,避免把后台工单详情、内部备注、附件路径等泄漏给旧客户端。
|
||||
|
||||
## 旧版反馈兼容设计
|
||||
|
||||
旧反馈应当分为三个入口模型:
|
||||
|
||||
1. 简单 JSON 表单
|
||||
|
||||
兼容旧客户端或网页直接提交 `title/type/severity/contact/body/message`。服务端生成反馈码,返回旧状态结构。
|
||||
|
||||
2. 普通 multipart 表单
|
||||
|
||||
兼容旧表单字段 `title/subject/category/priority/message/description/email`。如果没有签名字段,按简单反馈处理。
|
||||
|
||||
3. 签名加密包 multipart
|
||||
|
||||
继续保留 `payload/timestamp/nonce/packageSha256/signature/package`。校验顺序固定为:请求大小 -> 时间窗 -> SHA256 格式 -> HMAC 签名 -> 加密包 magic -> 包哈希 -> 解密 -> zip 安全检查 -> 写入附件 -> 创建工单。
|
||||
|
||||
后台内部统一落到 `feedback_tickets` 聚合:
|
||||
|
||||
```text
|
||||
legacy feedback request
|
||||
-> LegacyFeedbackAdapter
|
||||
-> FeedbackCommand(CreateTicket)
|
||||
-> FeedbackService
|
||||
-> FeedbackRepository + AttachmentStorage
|
||||
-> LegacyFeedbackStatusDTO
|
||||
```
|
||||
|
||||
旧状态查询只返回:
|
||||
|
||||
- `ok`
|
||||
- `code`
|
||||
- `status`
|
||||
- `statusLabel`
|
||||
- `statusDetail`
|
||||
- `category`
|
||||
- `priority`
|
||||
- `hasReply`
|
||||
- `reply`
|
||||
- `receivedAt`
|
||||
- `updatedAt`
|
||||
- `mailSent`
|
||||
- `duplicate`
|
||||
|
||||
不返回内部 `note`、`assignee`、`handledBy`、本地文件路径、审计日志、mail record。
|
||||
|
||||
## 后台管理能力设计
|
||||
|
||||
后台建议分为以下域:
|
||||
|
||||
1. 仪表盘
|
||||
|
||||
提供反馈数量、今日反馈、数据源总数、可见源数量、发布版本、数据库状态、最近心跳、最近客户端调用。只读,适合高频刷新。
|
||||
|
||||
2. 反馈工单
|
||||
|
||||
支持分页、筛选、搜索、状态流转、公开回复、内部备注、评论、标签、分派、优先级、SLA、附件查看、CSV 导出。状态流转应集中在 `FeedbackService`,不要由 Store 直接决定。
|
||||
|
||||
3. 发布管理
|
||||
|
||||
管理发布包上传、版本号、平台/架构、SHA256、manifest 生成、`update-info.json` 同步、版本公告保存、公告历史恢复。
|
||||
|
||||
4. 兼容 JSON
|
||||
|
||||
管理 `update-info.json` 和 `media-types.json`,提供验证、预览、保存、修订历史、恢复。该模块属于兼容层后台,不应成为新客户端的唯一数据来源。
|
||||
|
||||
5. 数据源目录
|
||||
|
||||
管理媒体/数据源、健康检测、客户端可见性、缓存时间、代理策略、调用日志。健康检测任务应进入 jobs 模块,并持久化任务结果。
|
||||
|
||||
6. 数据库与同步
|
||||
|
||||
明确 SQLite/MySQL 角色:推荐 SQLite 单机默认,MySQL 生产主库,SQLite 作为本地备份。后台按钮应显示方向、影响范围、覆盖风险和最后同步结果。
|
||||
|
||||
7. 系统设置
|
||||
|
||||
管理管理员密码、会话、旧项目路径、BaseURL、上传限制、签名密钥轮换、服务健康。
|
||||
|
||||
8. 审计日志
|
||||
|
||||
所有后台写操作、旧项目同步、发布包上传、JSON 恢复、密码修改都写入审计。审计事件统一字段:`actor/type/target/message/ip/userAgent/createdAt`。
|
||||
|
||||
## 数据模型建议
|
||||
|
||||
将数据库实体按业务域拆分:
|
||||
|
||||
- `admin_users`
|
||||
- `admin_sessions`
|
||||
- `feedback_tickets`
|
||||
- `feedback_comments`
|
||||
- `feedback_attachments`
|
||||
- `feedback_events`
|
||||
- `release_packages`
|
||||
- `release_notices`
|
||||
- `release_notice_revisions`
|
||||
- `legacy_json_revisions`
|
||||
- `source_categories`
|
||||
- `source_endpoints`
|
||||
- `endpoint_health_checks`
|
||||
- `endpoint_call_logs`
|
||||
- `audit_logs`
|
||||
- `database_sync_jobs`
|
||||
- `legacy_sync_jobs`
|
||||
|
||||
建议新增:
|
||||
|
||||
- `schema_migrations`:记录数据库迁移版本。
|
||||
- `admin_roles`、`admin_user_roles`:为后续多管理员预留。
|
||||
- `settings`:保存可后台修改的配置项,和 `config.json` 做边界区分。
|
||||
- `background_jobs`:统一记录旧同步、源检查、清理任务。
|
||||
- `api_tokens`:为未来自动发布、CI 上传包、客户端管理接口预留。
|
||||
|
||||
## 改造优先级
|
||||
|
||||
第一阶段:稳住兼容契约
|
||||
|
||||
- 为旧版路径建立专门测试:`/update-info.json`、`/tool-status.json`、`/modules.json`、`/media-types.json`、`/downloads/*`、`POST /`、`/?api=status`。
|
||||
- 把旧响应结构固定为 DTO 测试快照。
|
||||
- 给旧反馈三种提交方式分别补测试。
|
||||
|
||||
第二阶段:拆 HTTP 层
|
||||
|
||||
- 把 `router.go` 拆成 `legacy_routes.go`、`client_routes.go`、`admin_feedback_routes.go`、`admin_release_routes.go`、`admin_source_routes.go`、`admin_system_routes.go`、`static_routes.go`。
|
||||
- 引入统一 `RouteGroup` 或 `http.ServeMux` 包装,避免一个巨大 switch 继续增长。
|
||||
|
||||
第三阶段:拆 Store
|
||||
|
||||
- 将实体移动到 `internal/domain/*` 或 `internal/contracts/*`。
|
||||
- 将 SQL 拆成 `FeedbackRepository`、`ReleaseRepository`、`SourceRepository`、`AuditRepository`、`AdminRepository`。
|
||||
- 将迁移、连接、failover、sync 从业务 repo 中拆出。
|
||||
|
||||
第四阶段:完善后台安全
|
||||
|
||||
- 密码哈希迁移到 Argon2id/bcrypt,并保留旧 SHA-256 登录后自动升级。
|
||||
- 登录失败限流和验证码刷新频率限制。
|
||||
- 会话持久化、会话吊销、Secure cookie 配置。
|
||||
- 敏感配置和密钥不在 bootstrap 或日志中明文输出。
|
||||
|
||||
第五阶段:前端后台模块化
|
||||
|
||||
- `web/admin/src/api/*`:封装后台 API。
|
||||
- `web/admin/src/stores/*`:按域拆状态。
|
||||
- `web/admin/src/features/*`:按页面拆业务动作。
|
||||
- `App.vue` 只保留壳层、导航、路由出口和全局 toast。
|
||||
|
||||
## 验证清单
|
||||
|
||||
当前已通过:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
后续每次重构必须至少验证:
|
||||
|
||||
- 旧版更新 JSON 路由仍返回 200 且字段不减少。
|
||||
- 旧版反馈 `POST /` 能创建工单,重复反馈码返回 duplicate。
|
||||
- 旧版反馈状态查询不泄漏内部字段。
|
||||
- 管理后台写操作未登录返回 401,未带 CSRF 返回 403。
|
||||
- 发布包上传拒绝路径逃逸和不支持扩展名。
|
||||
- 数据源健康检测拒绝非 HTTP/HTTPS 重定向。
|
||||
- SQLite 默认启动正常,MySQL 配置失败时 failover 行为明确。
|
||||
- 旧项目同步 dry-run 不写数据,run 前有备份。
|
||||
|
||||
@@ -24,13 +24,17 @@ const (
|
||||
SessionCookie = "ymhut_unified_session"
|
||||
captchaTTL = 5 * time.Minute
|
||||
sessionTTL = 12 * time.Hour
|
||||
loginWindow = 5 * time.Minute
|
||||
loginLockTTL = 5 * time.Minute
|
||||
loginMaxFails = 5
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *db.Store
|
||||
mu sync.Mutex
|
||||
captchas map[string]captchaEntry
|
||||
sessions map[string]sessionEntry
|
||||
store *db.Store
|
||||
mu sync.Mutex
|
||||
captchas map[string]captchaEntry
|
||||
sessions map[string]sessionEntry
|
||||
loginAttempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
type captchaEntry struct {
|
||||
@@ -44,6 +48,12 @@ type sessionEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lastFailure time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
ID string `json:"captchaId"`
|
||||
Image string `json:"image"`
|
||||
@@ -51,9 +61,10 @@ type Captcha struct {
|
||||
|
||||
func NewService(store *db.Store) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
captchas: map[string]captchaEntry{},
|
||||
sessions: map[string]sessionEntry{},
|
||||
store: store,
|
||||
captchas: map[string]captchaEntry{},
|
||||
sessions: map[string]sessionEntry{},
|
||||
loginAttempts: map[string]loginAttempt{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +102,18 @@ func (s *Service) NewCaptcha() (Captcha, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string) (string, string, bool, error) {
|
||||
func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, bool, error) {
|
||||
attemptKey := loginAttemptKey(username, clientKeys...)
|
||||
if s.loginLocked(attemptKey) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if !s.consumeCaptcha(captchaID, captcha) {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
return "", "", false, nil
|
||||
}
|
||||
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
|
||||
if err != nil || !ok {
|
||||
s.recordLoginFailure(attemptKey)
|
||||
return "", "", false, err
|
||||
}
|
||||
sessionID := randomToken(32)
|
||||
@@ -104,6 +121,7 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked()
|
||||
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
|
||||
delete(s.loginAttempts, attemptKey)
|
||||
s.mu.Unlock()
|
||||
return sessionID, csrf, true, nil
|
||||
}
|
||||
@@ -136,13 +154,13 @@ func (s *Service) Require(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, csrf, ok := s.UserForRequest(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "UNAUTHORIZED", "message": "需要登录后继续操作"})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
||||
actual := r.Header.Get("X-CSRF-Token")
|
||||
if actual == "" || subtle.ConstantTimeCompare([]byte(csrf), []byte(actual)) != 1 {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "CSRF_INVALID", "message": "Invalid CSRF token"})
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "CSRF_INVALID", "message": "页面安全令牌无效,请刷新后重试"})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -151,6 +169,14 @@ func (s *Service) Require(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
setSessionCookie(w, sessionID, false)
|
||||
}
|
||||
|
||||
func SetSessionCookieForRequest(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
setSessionCookie(w, sessionID, requestIsHTTPS(r))
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, sessionID string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: sessionID,
|
||||
@@ -158,6 +184,7 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: secure,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +192,16 @@ func clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
|
||||
func requestIsHTTPS(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
func (s *Service) consumeCaptcha(id, answer string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
answer = strings.TrimSpace(answer)
|
||||
@@ -193,6 +230,50 @@ func (s *Service) cleanupLocked() {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
for key, attempt := range s.loginAttempts {
|
||||
if attempt.lockedUntil.IsZero() && now.Sub(attempt.lastFailure) > loginWindow {
|
||||
delete(s.loginAttempts, key)
|
||||
continue
|
||||
}
|
||||
if !attempt.lockedUntil.IsZero() && now.After(attempt.lockedUntil) && now.Sub(attempt.lastFailure) > loginWindow {
|
||||
delete(s.loginAttempts, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loginLocked(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleanupLocked()
|
||||
attempt := s.loginAttempts[key]
|
||||
return !attempt.lockedUntil.IsZero() && time.Now().Before(attempt.lockedUntil)
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
attempt := s.loginAttempts[key]
|
||||
if now.Sub(attempt.lastFailure) > loginWindow {
|
||||
attempt.failures = 0
|
||||
}
|
||||
attempt.failures++
|
||||
attempt.lastFailure = now
|
||||
if attempt.failures >= loginMaxFails {
|
||||
attempt.lockedUntil = now.Add(loginLockTTL)
|
||||
}
|
||||
s.loginAttempts[key] = attempt
|
||||
}
|
||||
|
||||
func loginAttemptKey(username string, clientKeys ...string) string {
|
||||
parts := []string{strings.ToLower(strings.TrimSpace(username))}
|
||||
for _, value := range clientKeys {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func randomDigits(count int) string {
|
||||
|
||||
@@ -2,6 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -35,7 +38,7 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
|
||||
if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" {
|
||||
t.Fatalf("unexpected bootstrap payload: %#v", payload)
|
||||
}
|
||||
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed"); err != nil {
|
||||
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err = service.Bootstrap(context.Background())
|
||||
@@ -46,3 +49,91 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
|
||||
t.Fatalf("default password leaked after change: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordPersistsAfterReopen(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dbPath := filepath.Join(root, "test.sqlite")
|
||||
cfg := &config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: dbPath,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "persisted-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = store.Close()
|
||||
|
||||
reopened, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if _, ok, err := reopened.VerifyAdminPassword(context.Background(), "admin", "persisted-password"); err != nil || !ok {
|
||||
t.Fatalf("new password did not persist, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := reopened.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok {
|
||||
t.Fatalf("old password still works, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLocksAfterRepeatedFailures(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "test.sqlite"),
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(store)
|
||||
for i := 0; i < loginMaxFails; i++ {
|
||||
if _, _, ok, err := service.Login(context.Background(), "admin", "wrong", "bad-captcha", "00000", "127.0.0.1"); err != nil || ok {
|
||||
t.Fatalf("failed login %d returned ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
}
|
||||
captcha, err := service.NewCaptcha()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.mu.Lock()
|
||||
answer := service.captchas[captcha.ID].answer
|
||||
service.mu.Unlock()
|
||||
if _, _, ok, err := service.Login(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || ok {
|
||||
t.Fatalf("locked login should fail without error, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieUsesSecureForForwardedHTTPS(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
res := httptest.NewRecorder()
|
||||
SetSessionCookieForRequest(res, req, "session-id")
|
||||
cookies := res.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected one cookie, got %d", len(cookies))
|
||||
}
|
||||
if !cookies[0].Secure {
|
||||
t.Fatalf("expected secure cookie for forwarded https: %#v", cookies[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
type SafeBrandingConfig struct {
|
||||
SiteIconURL string `json:"siteIconUrl"`
|
||||
DeveloperAvatarURL string `json:"developerAvatarUrl"`
|
||||
DeveloperName string `json:"developerName"`
|
||||
FeedbackEmail string `json:"feedbackEmail"`
|
||||
}
|
||||
|
||||
func SafeBranding(cfg BrandingConfig) SafeBrandingConfig {
|
||||
return SafeBrandingConfig{
|
||||
SiteIconURL: strings.TrimSpace(cfg.SiteIconURL),
|
||||
DeveloperAvatarURL: strings.TrimSpace(cfg.DeveloperAvatarURL),
|
||||
DeveloperName: strings.TrimSpace(firstNonEmpty(cfg.DeveloperName, "YMhut")),
|
||||
FeedbackEmail: strings.TrimSpace(firstNonEmpty(cfg.FeedbackEmail, "support@ymhut.cn")),
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) BrandingConfig {
|
||||
next := current
|
||||
if value := strings.TrimSpace(incoming.SiteIconURL); value != "" {
|
||||
next.SiteIconURL = value
|
||||
}
|
||||
if value := strings.TrimSpace(incoming.DeveloperAvatarURL); value != "" {
|
||||
next.DeveloperAvatarURL = value
|
||||
}
|
||||
if value := strings.TrimSpace(incoming.DeveloperName); value != "" {
|
||||
next.DeveloperName = value
|
||||
}
|
||||
if value := strings.TrimSpace(incoming.FeedbackEmail); value != "" {
|
||||
next.FeedbackEmail = value
|
||||
}
|
||||
if next.SiteIconURL == "" {
|
||||
next.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
|
||||
}
|
||||
if next.DeveloperAvatarURL == "" {
|
||||
next.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
|
||||
}
|
||||
if next.DeveloperName == "" {
|
||||
next.DeveloperName = "YMhut"
|
||||
}
|
||||
if next.FeedbackEmail == "" {
|
||||
next.FeedbackEmail = "support@ymhut.cn"
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -35,6 +36,8 @@ type Config struct {
|
||||
MaxRequestBytes int64 `json:"max_request_bytes"`
|
||||
MaxPackageBytes int64 `json:"max_package_bytes"`
|
||||
Database DatabaseConfig `json:"database"`
|
||||
Mail MailConfig `json:"mail"`
|
||||
Branding BrandingConfig `json:"branding"`
|
||||
UploadGuard UploadGuardConfig `json:"upload_guard"`
|
||||
SourceCheckSeconds int `json:"source_check_seconds"`
|
||||
}
|
||||
@@ -43,6 +46,11 @@ type DatabaseConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
SQLitePath string `json:"sqlite_path"`
|
||||
MySQLDSN string `json:"mysql_dsn"`
|
||||
MySQLHost string `json:"mysql_host"`
|
||||
MySQLPort int `json:"mysql_port"`
|
||||
MySQLDatabase string `json:"mysql_database"`
|
||||
MySQLUser string `json:"mysql_user"`
|
||||
MySQLPassword string `json:"mysql_password"`
|
||||
FailoverEnabled bool `json:"failover_enabled"`
|
||||
HotSyncEnabled bool `json:"hot_sync_enabled"`
|
||||
HealthIntervalSec int `json:"health_interval_sec"`
|
||||
@@ -51,6 +59,25 @@ type DatabaseConfig struct {
|
||||
ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"`
|
||||
}
|
||||
|
||||
type MailConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Secure string `json:"secure"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromAddress string `json:"from_address"`
|
||||
FromName string `json:"from_name"`
|
||||
DeveloperAddress string `json:"developer_address"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
type BrandingConfig struct {
|
||||
SiteIconURL string `json:"site_icon_url"`
|
||||
DeveloperAvatarURL string `json:"developer_avatar_url"`
|
||||
DeveloperName string `json:"developer_name"`
|
||||
FeedbackEmail string `json:"feedback_email"`
|
||||
}
|
||||
|
||||
type UploadGuardConfig struct {
|
||||
MaxZipFiles int `json:"max_zip_files"`
|
||||
MaxDecompressedBytes int64 `json:"max_decompressed_bytes"`
|
||||
@@ -67,16 +94,28 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg := defaults(root)
|
||||
path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json"))
|
||||
var rawConfig []byte
|
||||
loaded := false
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Initialized = true
|
||||
rawConfig = data
|
||||
loaded = true
|
||||
}
|
||||
cfg.BaseDir = root
|
||||
cfg.ConfigPath = path
|
||||
if loaded {
|
||||
sanitizeNonPortablePaths(cfg)
|
||||
}
|
||||
applyEnv(cfg)
|
||||
normalize(root, cfg)
|
||||
if loaded && shouldRewriteRelativeConfig(rawConfig) {
|
||||
if err := Save(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -107,6 +146,8 @@ func defaults(root string) *Config {
|
||||
Database: DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
MySQLHost: "127.0.0.1",
|
||||
MySQLPort: 3306,
|
||||
FailoverEnabled: true,
|
||||
HotSyncEnabled: true,
|
||||
HealthIntervalSec: 30,
|
||||
@@ -114,6 +155,19 @@ func defaults(root string) *Config {
|
||||
MaxIdleConns: 4,
|
||||
ConnMaxLifetimeSeconds: 300,
|
||||
},
|
||||
Mail: MailConfig{
|
||||
Port: 465,
|
||||
Secure: "ssl",
|
||||
FromName: "YMhut Box Feedback",
|
||||
DeveloperAddress: "support@ymhut.cn",
|
||||
TimeoutSeconds: 20,
|
||||
},
|
||||
Branding: BrandingConfig{
|
||||
SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
||||
DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
||||
DeveloperName: "YMhut",
|
||||
FeedbackEmail: "support@ymhut.cn",
|
||||
},
|
||||
UploadGuard: UploadGuardConfig{
|
||||
MaxZipFiles: 80,
|
||||
MaxDecompressedBytes: 30 * 1024 * 1024,
|
||||
@@ -171,6 +225,66 @@ func applyEnv(cfg *Config) {
|
||||
if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" {
|
||||
cfg.Database.MySQLDSN = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MYSQL_HOST"); value != "" {
|
||||
cfg.Database.MySQLHost = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MYSQL_PORT"); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Database.MySQLPort = parsed
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MYSQL_DATABASE"); value != "" {
|
||||
cfg.Database.MySQLDatabase = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MYSQL_USER"); value != "" {
|
||||
cfg.Database.MySQLUser = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MYSQL_PASSWORD"); value != "" {
|
||||
cfg.Database.MySQLPassword = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_HOST"); value != "" {
|
||||
cfg.Mail.Host = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_PORT"); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Mail.Port = parsed
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_SECURE"); value != "" {
|
||||
cfg.Mail.Secure = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_USERNAME"); value != "" {
|
||||
cfg.Mail.Username = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_PASSWORD"); value != "" {
|
||||
cfg.Mail.Password = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_FROM_ADDRESS"); value != "" {
|
||||
cfg.Mail.FromAddress = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_FROM_NAME"); value != "" {
|
||||
cfg.Mail.FromName = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_DEVELOPER_ADDRESS"); value != "" {
|
||||
cfg.Mail.DeveloperAddress = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_MAIL_TIMEOUT_SECONDS"); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Mail.TimeoutSeconds = parsed
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" {
|
||||
cfg.Branding.SiteIconURL = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" {
|
||||
cfg.Branding.DeveloperAvatarURL = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_BRAND_DEVELOPER_NAME"); value != "" {
|
||||
cfg.Branding.DeveloperName = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_BRAND_FEEDBACK_EMAIL"); value != "" {
|
||||
cfg.Branding.FeedbackEmail = value
|
||||
}
|
||||
if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" {
|
||||
cfg.ClientSignatureKey = value
|
||||
}
|
||||
@@ -254,10 +368,30 @@ func normalize(root string, cfg *Config) {
|
||||
if cfg.Database.Provider == "" {
|
||||
cfg.Database.Provider = "sqlite"
|
||||
}
|
||||
cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider))
|
||||
if cfg.Database.SQLitePath == "" {
|
||||
cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite")
|
||||
}
|
||||
cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath)
|
||||
if cfg.Database.MySQLHost == "" {
|
||||
cfg.Database.MySQLHost = "127.0.0.1"
|
||||
}
|
||||
if cfg.Database.MySQLPort <= 0 {
|
||||
cfg.Database.MySQLPort = 3306
|
||||
}
|
||||
if cfg.Database.Provider == "mysql" && cfg.Database.MySQLDSN == "" && cfg.Database.MySQLDatabase != "" && cfg.Database.MySQLUser != "" {
|
||||
if dsn, err := BuildMySQLDSN(MySQLInput{
|
||||
Host: cfg.Database.MySQLHost,
|
||||
Port: cfg.Database.MySQLPort,
|
||||
Database: cfg.Database.MySQLDatabase,
|
||||
Username: cfg.Database.MySQLUser,
|
||||
Password: cfg.Database.MySQLPassword,
|
||||
Charset: "utf8mb4",
|
||||
ParseTime: true,
|
||||
}); err == nil {
|
||||
cfg.Database.MySQLDSN = dsn
|
||||
}
|
||||
}
|
||||
if cfg.Database.HealthIntervalSec <= 0 {
|
||||
cfg.Database.HealthIntervalSec = 30
|
||||
}
|
||||
@@ -303,6 +437,37 @@ func normalize(root string, cfg *Config) {
|
||||
if cfg.SourceCheckSeconds <= 0 {
|
||||
cfg.SourceCheckSeconds = 300
|
||||
}
|
||||
if cfg.Mail.Port <= 0 {
|
||||
cfg.Mail.Port = 465
|
||||
}
|
||||
cfg.Mail.Secure = strings.ToLower(strings.TrimSpace(cfg.Mail.Secure))
|
||||
if cfg.Mail.Secure == "" {
|
||||
cfg.Mail.Secure = "ssl"
|
||||
}
|
||||
if cfg.Mail.FromName == "" {
|
||||
cfg.Mail.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if cfg.Mail.FromAddress == "" {
|
||||
cfg.Mail.FromAddress = cfg.Mail.Username
|
||||
}
|
||||
if cfg.Mail.TimeoutSeconds <= 0 {
|
||||
cfg.Mail.TimeoutSeconds = 20
|
||||
}
|
||||
if cfg.Branding.SiteIconURL == "" {
|
||||
cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
|
||||
}
|
||||
if cfg.Branding.DeveloperAvatarURL == "" {
|
||||
cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
|
||||
}
|
||||
if cfg.Branding.DeveloperName == "" {
|
||||
cfg.Branding.DeveloperName = "YMhut"
|
||||
}
|
||||
if cfg.Branding.FeedbackEmail == "" {
|
||||
cfg.Branding.FeedbackEmail = "support@ymhut.cn"
|
||||
}
|
||||
if cfg.Mail.DeveloperAddress == "" {
|
||||
cfg.Mail.DeveloperAddress = cfg.Branding.FeedbackEmail
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveBaseDir() (string, error) {
|
||||
@@ -333,13 +498,110 @@ func Save(cfg *Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
persisted := cfg.relativeCopy()
|
||||
data, err := json.MarshalIndent(persisted, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.ConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *Config) relativeCopy() Config {
|
||||
next := *cfg
|
||||
base := cfg.BaseDir
|
||||
next.BaseDir = "."
|
||||
next.StorageDir = relativePath(base, cfg.StorageDir)
|
||||
next.DataDir = relativePath(base, cfg.DataDir)
|
||||
next.UpdatePublicDir = relativePath(base, cfg.UpdatePublicDir)
|
||||
next.UpdateNoticeDir = relativePath(base, cfg.UpdateNoticeDir)
|
||||
next.DownloadsDir = relativePath(base, cfg.DownloadsDir)
|
||||
next.AdminWebDir = relativePath(base, cfg.AdminWebDir)
|
||||
next.PortalWebDir = relativePath(base, cfg.PortalWebDir)
|
||||
next.SetupWebDir = relativePath(base, cfg.SetupWebDir)
|
||||
next.LegacyUpdateDir = relativePath(base, cfg.LegacyUpdateDir)
|
||||
next.LegacyFeedbackDir = relativePath(base, cfg.LegacyFeedbackDir)
|
||||
next.LegacyUpdateNoticeDir = relativePath(base, cfg.LegacyUpdateNoticeDir)
|
||||
next.Database.SQLitePath = relativePath(base, cfg.Database.SQLitePath)
|
||||
return next
|
||||
}
|
||||
|
||||
func relativePath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return value
|
||||
}
|
||||
rel, err := filepath.Rel(base, value)
|
||||
if err != nil || rel == "" {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func sanitizeNonPortablePaths(cfg *Config) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
for _, target := range []*string{
|
||||
&cfg.StorageDir,
|
||||
&cfg.DataDir,
|
||||
&cfg.UpdatePublicDir,
|
||||
&cfg.UpdateNoticeDir,
|
||||
&cfg.DownloadsDir,
|
||||
&cfg.AdminWebDir,
|
||||
&cfg.PortalWebDir,
|
||||
&cfg.SetupWebDir,
|
||||
&cfg.LegacyUpdateDir,
|
||||
&cfg.LegacyFeedbackDir,
|
||||
&cfg.LegacyUpdateNoticeDir,
|
||||
&cfg.Database.SQLitePath,
|
||||
} {
|
||||
if isWindowsAbsolutePath(*target) {
|
||||
*target = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRewriteRelativeConfig(data []byte) bool {
|
||||
var payload any
|
||||
if len(data) == 0 || json.Unmarshal(data, &payload) != nil {
|
||||
return false
|
||||
}
|
||||
return containsAbsolutePath(payload)
|
||||
}
|
||||
|
||||
func containsAbsolutePath(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return filepath.IsAbs(typed) || isWindowsAbsolutePath(typed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWindowsAbsolutePath(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 3 {
|
||||
drive := value[0]
|
||||
if ((drive >= 'A' && drive <= 'Z') || (drive >= 'a' && drive <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, `\\`)
|
||||
}
|
||||
|
||||
func absPath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavePersistsRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
cfg.Initialized = true
|
||||
cfg.ConfigPath = filepath.Join(root, "config.json")
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved Config
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.BaseDir != "." {
|
||||
t.Fatalf("BaseDir = %q, want relative dot", saved.BaseDir)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"storage_dir": saved.StorageDir,
|
||||
"data_dir": saved.DataDir,
|
||||
"update_public_dir": saved.UpdatePublicDir,
|
||||
"update_notice_dir": saved.UpdateNoticeDir,
|
||||
"downloads_dir": saved.DownloadsDir,
|
||||
"sqlite_path": saved.Database.SQLitePath,
|
||||
} {
|
||||
if filepath.IsAbs(value) || strings.Contains(value, root) {
|
||||
t.Fatalf("%s saved as absolute path %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightCreatesRuntimeDirectoriesAndNoticeIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
checks := Preflight(cfg)
|
||||
for _, path := range []string{
|
||||
cfg.UpdatePublicDir,
|
||||
cfg.UpdateNoticeDir,
|
||||
cfg.DownloadsDir,
|
||||
filepath.Join(cfg.UpdatePublicDir, "update-info.json"),
|
||||
filepath.Join(cfg.UpdatePublicDir, "media-types.json"),
|
||||
filepath.Join(cfg.UpdateNoticeDir, "total.json"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected preflight to create %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, line := range FormatPreflight(cfg, checks) {
|
||||
if strings.Contains(line, root) {
|
||||
t.Fatalf("preflight line leaked absolute base path: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRewritesAbsoluteConfigPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("YMHUT_BASE_DIR", root)
|
||||
configPath := filepath.Join(root, "config.json")
|
||||
payload := map[string]any{
|
||||
"initialized": true,
|
||||
"listen": ":33550",
|
||||
"storage_dir": filepath.Join(root, "storage"),
|
||||
"data_dir": filepath.Join(root, "data"),
|
||||
"update_public_dir": filepath.Join(root, "data", "update", "public"),
|
||||
"update_notice_dir": filepath.Join(root, "data", "update-notice"),
|
||||
"downloads_dir": filepath.Join(root, "data", "update", "public", "downloads"),
|
||||
"database": map[string]any{
|
||||
"provider": "sqlite",
|
||||
"sqlite_path": filepath.Join(root, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewritten, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(rewritten), root) {
|
||||
t.Fatalf("config still contains absolute base path: %s", string(rewritten))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MySQLInput struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Charset string `json:"charset"`
|
||||
ParseTime bool `json:"parseTime"`
|
||||
TLS string `json:"tls"`
|
||||
}
|
||||
|
||||
type SafeDatabaseConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
SQLitePath string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysqlDsn"`
|
||||
MySQLHost string `json:"mysqlHost"`
|
||||
MySQLPort int `json:"mysqlPort"`
|
||||
MySQLDatabase string `json:"mysqlDatabase"`
|
||||
MySQLUser string `json:"mysqlUser"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
}
|
||||
|
||||
func BuildMySQLDSN(input MySQLInput) (string, error) {
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := input.Port
|
||||
if port <= 0 {
|
||||
port = 3306
|
||||
}
|
||||
database := strings.TrimSpace(input.Database)
|
||||
username := strings.TrimSpace(input.Username)
|
||||
if database == "" {
|
||||
return "", errors.New("mysql database is required")
|
||||
}
|
||||
if username == "" {
|
||||
return "", errors.New("mysql username is required")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
|
||||
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
|
||||
if tls := strings.TrimSpace(input.TLS); tls != "" {
|
||||
params.Set("tls", tls)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
|
||||
}
|
||||
|
||||
func NormalizeDatabase(baseDir string, current DatabaseConfig, incoming DatabaseConfig, keepPassword bool) (DatabaseConfig, error) {
|
||||
next := current
|
||||
structuredChanged := false
|
||||
if incoming.Provider != "" {
|
||||
next.Provider = strings.ToLower(strings.TrimSpace(incoming.Provider))
|
||||
}
|
||||
if next.Provider == "" {
|
||||
next.Provider = "sqlite"
|
||||
}
|
||||
if incoming.SQLitePath != "" {
|
||||
next.SQLitePath = incoming.SQLitePath
|
||||
}
|
||||
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
|
||||
next.SQLitePath = filepath.Join(baseDir, next.SQLitePath)
|
||||
}
|
||||
if incoming.MySQLHost != "" {
|
||||
next.MySQLHost = strings.TrimSpace(incoming.MySQLHost)
|
||||
structuredChanged = true
|
||||
}
|
||||
if incoming.MySQLPort > 0 {
|
||||
next.MySQLPort = incoming.MySQLPort
|
||||
structuredChanged = true
|
||||
}
|
||||
if incoming.MySQLDatabase != "" {
|
||||
next.MySQLDatabase = strings.TrimSpace(incoming.MySQLDatabase)
|
||||
structuredChanged = true
|
||||
}
|
||||
if incoming.MySQLUser != "" {
|
||||
next.MySQLUser = strings.TrimSpace(incoming.MySQLUser)
|
||||
structuredChanged = true
|
||||
}
|
||||
if incoming.MySQLPassword != "" || !keepPassword {
|
||||
next.MySQLPassword = incoming.MySQLPassword
|
||||
structuredChanged = true
|
||||
}
|
||||
if incoming.MySQLDSN != "" {
|
||||
next.MySQLDSN = strings.TrimSpace(incoming.MySQLDSN)
|
||||
}
|
||||
if next.MySQLHost == "" {
|
||||
next.MySQLHost = "127.0.0.1"
|
||||
}
|
||||
if next.MySQLPort <= 0 {
|
||||
next.MySQLPort = 3306
|
||||
}
|
||||
if next.Provider == "sqlite" {
|
||||
next.MySQLDSN = ""
|
||||
} else if next.Provider == "mysql" {
|
||||
if structuredChanged || next.MySQLDSN == "" {
|
||||
dsn, err := BuildMySQLDSN(MySQLInput{
|
||||
Host: next.MySQLHost,
|
||||
Port: next.MySQLPort,
|
||||
Database: next.MySQLDatabase,
|
||||
Username: next.MySQLUser,
|
||||
Password: next.MySQLPassword,
|
||||
Charset: "utf8mb4",
|
||||
ParseTime: true,
|
||||
})
|
||||
if err != nil {
|
||||
return DatabaseConfig{}, err
|
||||
}
|
||||
next.MySQLDSN = dsn
|
||||
}
|
||||
if strings.TrimSpace(next.MySQLDSN) == "" {
|
||||
return DatabaseConfig{}, errors.New("mysql connection is required")
|
||||
}
|
||||
} else {
|
||||
return DatabaseConfig{}, errors.New("provider must be sqlite or mysql")
|
||||
}
|
||||
if strings.TrimSpace(next.SQLitePath) == "" {
|
||||
return DatabaseConfig{}, errors.New("sqlite path is required")
|
||||
}
|
||||
if next.MaxOpenConns <= 0 {
|
||||
next.MaxOpenConns = 10
|
||||
}
|
||||
if next.MaxIdleConns <= 0 {
|
||||
next.MaxIdleConns = 4
|
||||
}
|
||||
if next.ConnMaxLifetimeSeconds <= 0 {
|
||||
next.ConnMaxLifetimeSeconds = 300
|
||||
}
|
||||
if next.HealthIntervalSec <= 0 {
|
||||
next.HealthIntervalSec = 30
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func SafeDatabase(baseDir string, cfg DatabaseConfig) SafeDatabaseConfig {
|
||||
return SafeDatabaseConfig{
|
||||
Provider: firstNonEmpty(cfg.Provider, "sqlite"),
|
||||
SQLitePath: relativeToBase(baseDir, cfg.SQLitePath),
|
||||
MySQLDSN: MaskDSN(cfg.MySQLDSN),
|
||||
MySQLHost: cfg.MySQLHost,
|
||||
MySQLPort: cfg.MySQLPort,
|
||||
MySQLDatabase: cfg.MySQLDatabase,
|
||||
MySQLUser: cfg.MySQLUser,
|
||||
HasPassword: strings.TrimSpace(cfg.MySQLPassword) != "" || dsnHasPassword(cfg.MySQLDSN),
|
||||
}
|
||||
}
|
||||
|
||||
func MaskDSN(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.Index(value, "@")
|
||||
colon := strings.Index(value, ":")
|
||||
if at > -1 && colon > -1 && colon < at {
|
||||
return value[:colon+1] + "******" + value[at:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func relativeToBase(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
if base != "" {
|
||||
if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
}
|
||||
return filepath.ToSlash(value)
|
||||
}
|
||||
|
||||
func dsnHasPassword(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
at := strings.Index(value, "@")
|
||||
colon := strings.Index(value, ":")
|
||||
return at > -1 && colon > -1 && colon < at && colon+1 < at
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import (
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
const defaultUpdateInfoJSON = `{
|
||||
"app_version": "0.0.0",
|
||||
"download_url": "",
|
||||
"update_notes": {},
|
||||
"last_update_notes": {},
|
||||
"release_notes": "",
|
||||
"release_notes_md": "",
|
||||
"last_updated": ""
|
||||
}
|
||||
`
|
||||
|
||||
const defaultMediaTypesJSON = `{
|
||||
"layout_version": "1.0.0",
|
||||
"last_updated": "",
|
||||
"ui_config": {},
|
||||
"categories": []
|
||||
}
|
||||
`
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
@@ -19,12 +38,12 @@ func Preflight(cfg *Config) []Check {
|
||||
checks := []Check{
|
||||
checkDir("storage", cfg.StorageDir, true),
|
||||
checkParent("sqlite", cfg.Database.SQLitePath),
|
||||
checkDir("update public", cfg.UpdatePublicDir, false),
|
||||
checkDir("update notice", cfg.UpdateNoticeDir, false),
|
||||
checkDir("downloads", cfg.DownloadsDir, false),
|
||||
checkFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), false),
|
||||
checkFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), false),
|
||||
checkFile("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json"), false),
|
||||
checkDir("update public", cfg.UpdatePublicDir, true),
|
||||
checkDir("update notice", cfg.UpdateNoticeDir, true),
|
||||
checkDir("downloads", cfg.DownloadsDir, true),
|
||||
checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
|
||||
checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
|
||||
checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
|
||||
checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
|
||||
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
|
||||
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
|
||||
@@ -48,6 +67,37 @@ func checkDir(name, path string, create bool) Check {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
}
|
||||
|
||||
func checkNoticeIndex(name, path string) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
data := []byte("{\n \"schema_version\": 1,\n \"product\": \"YMhut Box\",\n \"versions\": []\n}\n")
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created empty notice index"}
|
||||
}
|
||||
|
||||
func checkSeedFile(name, path string, data []byte) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created default compatibility JSON"}
|
||||
}
|
||||
|
||||
func checkParent(name, path string) Check {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
@@ -116,12 +166,12 @@ func embeddedWebBuildOK(embedRoot string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func FormatPreflight(checks []Check) []string {
|
||||
func FormatPreflight(cfg *Config, checks []Check) []string {
|
||||
lines := make([]string, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
line := fmt.Sprintf("[%s] %s", check.Status, check.Name)
|
||||
if check.Path != "" {
|
||||
line += " -> " + check.Path
|
||||
line += " -> " + relativePath(cfg.BaseDir, check.Path)
|
||||
}
|
||||
if check.Message != "" {
|
||||
line += " (" + check.Message + ")"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Store) EnsureDefaultAdmin(ctx context.Context) error {
|
||||
if err := s.ensureDefaultAdminOn(s.localDB, s.localDialect); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
if err := s.ensureDefaultAdminOn(remote, remoteDialect); err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureDefaultAdminOn(conn *sql.DB, d dialect) error {
|
||||
if conn == nil {
|
||||
return errors.New("database is not available")
|
||||
}
|
||||
var count int
|
||||
if err := conn.QueryRow(d.rebind(`SELECT COUNT(*) FROM admin_users WHERE username = ?`), "admin").Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
now := Now()
|
||||
_, err := conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 0, ?, ?)`),
|
||||
"admin", passwordHash("admin"), now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) VerifyAdminPassword(ctx context.Context, username, password string) (AdminUser, bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
user, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, password)
|
||||
if err == nil && (ok || user.Username != "") {
|
||||
return user, ok, nil
|
||||
}
|
||||
if err != nil {
|
||||
return user, ok, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
user, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return user, ok, err
|
||||
}
|
||||
return user, ok, nil
|
||||
}
|
||||
|
||||
func (s *Store) verifyAdminPasswordOn(conn *sql.DB, d dialect, username, password string) (AdminUser, bool, error) {
|
||||
if conn == nil {
|
||||
return AdminUser{}, false, errors.New("database is not available")
|
||||
}
|
||||
var row adminRow
|
||||
var changed int
|
||||
err := conn.QueryRow(d.rebind(`SELECT id, username, password_hash, password_changed, created_at, updated_at FROM admin_users WHERE username = ?`), username).
|
||||
Scan(&row.ID, &row.Username, &row.PasswordHash, &changed, &row.CreatedAt, &row.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AdminUser{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AdminUser{}, false, err
|
||||
}
|
||||
row.PasswordChanged = changed == 1
|
||||
user := AdminUser{ID: row.ID, Username: row.Username, PasswordChanged: row.PasswordChanged, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
return user, subtleConstantCompare(row.PasswordHash, password), nil
|
||||
}
|
||||
|
||||
func (s *Store) IsDefaultAdminPassword(ctx context.Context) (bool, error) {
|
||||
user, ok, err := s.VerifyAdminPassword(ctx, "admin", "admin")
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
return !user.PasswordChanged, nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangeAdminPassword(ctx context.Context, username, current, next string) error {
|
||||
_, err := s.ChangeAdminPasswordWithWarning(ctx, username, current, next)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, current, next string) (string, error) {
|
||||
next = strings.TrimSpace(next)
|
||||
if err := validateAdminPasswordChange(current, next); err != nil {
|
||||
return "", err
|
||||
}
|
||||
username = firstNonEmpty(strings.TrimSpace(username), "admin")
|
||||
_, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
remoteOK, remoteErr := s.verifyRemoteAdminPassword(username, current)
|
||||
if remoteErr != nil {
|
||||
s.markFailover(remoteErr)
|
||||
}
|
||||
if !remoteOK {
|
||||
return "", errors.New("当前密码不正确")
|
||||
}
|
||||
}
|
||||
hash := passwordHash(next)
|
||||
now := Now()
|
||||
if err := s.changeAdminPasswordOn(s.localDB, s.localDialect, username, hash, now, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote != nil && remote != s.localDB {
|
||||
if err := s.changeAdminPasswordOn(remote, remoteDialect, username, hash, now, false); err != nil {
|
||||
s.markFailover(err)
|
||||
return "远端 MySQL 同步失败,密码已持久化到本地 SQLite", nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func validateAdminPasswordChange(current, next string) error {
|
||||
if next == "" {
|
||||
return errors.New("new password is required")
|
||||
}
|
||||
if len([]rune(next)) < 8 {
|
||||
return errors.New("new password must be at least 8 characters")
|
||||
}
|
||||
if strings.EqualFold(next, "admin") {
|
||||
return errors.New("new password cannot be admin")
|
||||
}
|
||||
if strings.TrimSpace(current) != "" && subtleConstantCompare(passwordHash(current), next) {
|
||||
return errors.New("new password must be different from current password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) verifyRemoteAdminPassword(username, password string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
remote, remoteDialect := s.remoteDB, s.remoteDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil || remote == s.localDB {
|
||||
return false, nil
|
||||
}
|
||||
_, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (s *Store) changeAdminPasswordOn(conn *sql.DB, d dialect, username, hash, updatedAt string, insertIfMissing bool) error {
|
||||
if conn == nil {
|
||||
return errors.New("database is not available")
|
||||
}
|
||||
result, err := conn.Exec(d.rebind(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`), hash, updatedAt, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := result.RowsAffected(); rows > 0 {
|
||||
return nil
|
||||
}
|
||||
if !insertIfMissing {
|
||||
return errors.New("admin user not found")
|
||||
}
|
||||
_, err = conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`), username, hash, updatedAt, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func passwordHash(password string) string {
|
||||
sum := sha256.Sum256([]byte("ymhut-unified|" + strings.TrimSpace(password)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func subtleConstantCompare(hash, password string) bool {
|
||||
expected := passwordHash(password)
|
||||
return subtleConstantTimeCompare([]byte(hash), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func subtleConstantTimeCompare(a, b []byte) int {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var v byte
|
||||
for i := range a {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 80
|
||||
}
|
||||
feedbackTotal, _ := s.countTable("feedback_tickets")
|
||||
feedbackToday, _ := s.countWhere("feedback_tickets", "created_at LIKE ?", time.Now().UTC().Format("2006-01-02")+"%")
|
||||
sourceTotal, _ := s.countTable("source_endpoints")
|
||||
sourceVisible, _ := s.countWhere("source_endpoints", "enabled = 1 AND client_visible = 1")
|
||||
releaseTotal, _ := s.countTable("release_notices")
|
||||
mailFailed, _ := s.countWhere("mail_records", "status = ?", "failed")
|
||||
statusCounts, _ := s.groupCounts("feedback_tickets", "status")
|
||||
healthCounts, _ := s.groupCounts("source_endpoints", "last_status")
|
||||
recentChecks, _ := s.RecentSourceChecks(limit)
|
||||
recentCalls, _ := s.RecentSourceCalls(limit)
|
||||
audit, _ := s.ListAuditLogs(10)
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"kpis": map[string]any{
|
||||
"feedbackTotal": feedbackTotal,
|
||||
"feedbackToday": feedbackToday,
|
||||
"sourceTotal": sourceTotal,
|
||||
"sourceVisible": sourceVisible,
|
||||
"releaseNotices": releaseTotal,
|
||||
"mailFailed": mailFailed,
|
||||
},
|
||||
"feedbackStatus": statusCounts,
|
||||
"sourceHealth": healthCounts,
|
||||
"heartbeats": recentChecks,
|
||||
"clientCalls": recentCalls,
|
||||
"database": s.Status(),
|
||||
"audit": audit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
|
||||
rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at
|
||||
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
|
||||
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, sourceDBID int64
|
||||
var sourceID, name, status, message, checkedAt string
|
||||
var latency int
|
||||
if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceID == "" {
|
||||
sourceID = fmt.Sprintf("deleted-%d", sourceDBID)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("已删除接口 #%d", sourceDBID)
|
||||
}
|
||||
items = append(items, map[string]any{"id": id, "sourceDbId": sourceDBID, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
|
||||
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var sourceID, status, message, client, createdAt string
|
||||
var latency int
|
||||
if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "status": status, "latencyMs": latency, "error": message, "client": client, "createdAt": createdAt})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertAudit(log AuditLog) error {
|
||||
if log.CreatedAt == "" {
|
||||
log.CreatedAt = Now()
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO audit_logs (actor, type, target, message, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sanitize(log.Actor), sanitize(log.Type), sanitize(log.Target), sanitize(log.Message), sanitize(log.IP), sanitize(log.UserAgent), log.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAuditRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogsPage(filters AuditFilters) (AuditPage, error) {
|
||||
page := filters.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
perPage := filters.PerPage
|
||||
if perPage <= 0 {
|
||||
perPage = 35
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
where, args := auditWhere(filters)
|
||||
var total int
|
||||
if err := s.queryRow(`SELECT COUNT(*) FROM audit_logs`+where, args...).Scan(&total); err != nil {
|
||||
return AuditPage{}, err
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
queryArgs := append(append([]any{}, args...), perPage, offset)
|
||||
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return AuditPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := scanAuditRows(rows)
|
||||
if err != nil {
|
||||
return AuditPage{}, err
|
||||
}
|
||||
return AuditPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil
|
||||
}
|
||||
|
||||
func auditWhere(filters AuditFilters) (string, []any) {
|
||||
clauses := []string{}
|
||||
args := []any{}
|
||||
if value := strings.TrimSpace(filters.Type); value != "" {
|
||||
clauses = append(clauses, "type = ?")
|
||||
args = append(args, sanitize(value))
|
||||
}
|
||||
if value := strings.TrimSpace(filters.Target); value != "" {
|
||||
clauses = append(clauses, "target = ?")
|
||||
args = append(args, sanitize(value))
|
||||
}
|
||||
if value := strings.TrimSpace(filters.Query); value != "" {
|
||||
clauses = append(clauses, "(actor LIKE ? OR type LIKE ? OR target LIKE ? OR message LIKE ? OR ip LIKE ?)")
|
||||
like := "%" + sanitize(value) + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs WHERE target = ? ORDER BY id DESC LIMIT ?`, target, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAuditRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) countTable(table string) (int, error) {
|
||||
if !validStatsTable(table) {
|
||||
return 0, fmt.Errorf("invalid table %q", table)
|
||||
}
|
||||
var total int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM ` + table).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Store) countWhere(table, where string, args ...any) (int, error) {
|
||||
if !validStatsTable(table) {
|
||||
return 0, fmt.Errorf("invalid table %q", table)
|
||||
}
|
||||
var total int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM `+table+` WHERE `+where, args...).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Store) groupCounts(table, column string) (map[string]int, error) {
|
||||
if !validStatsColumn(table, column) {
|
||||
return nil, fmt.Errorf("invalid group %s.%s", table, column)
|
||||
}
|
||||
rows, err := s.query(`SELECT ` + column + `, COUNT(*) FROM ` + table + ` GROUP BY ` + column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var count int
|
||||
if err := rows.Scan(&key, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
out[key] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func validStatsTable(table string) bool {
|
||||
switch table {
|
||||
case "feedback_tickets", "source_endpoints", "release_notices", "mail_records":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validStatsColumn(table, column string) bool {
|
||||
switch table + "." + column {
|
||||
case "feedback_tickets.status", "source_endpoints.last_status":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) CopySQLiteToRemote() (string, error) {
|
||||
result, err := s.ImportSQLiteToRemote()
|
||||
return result.FinishedAt, err
|
||||
}
|
||||
|
||||
func (s *Store) CopyRemoteToSQLite() (string, error) {
|
||||
result, err := s.SyncNow()
|
||||
return result.FinishedAt, err
|
||||
}
|
||||
|
||||
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
||||
if !s.trySyncLock() {
|
||||
return SyncResult{Direction: "sqlite_to_remote", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
|
||||
}
|
||||
defer s.syncMu.Unlock()
|
||||
s.mu.RLock()
|
||||
remote := s.remoteDB
|
||||
remoteDialect := s.remoteDialect
|
||||
local := s.localDB
|
||||
localDialect := s.localDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil {
|
||||
result := SyncResult{Direction: "sqlite_to_remote", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
|
||||
s.setSyncStatus(result, nil)
|
||||
return result, nil
|
||||
}
|
||||
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
||||
s.setSyncStatus(result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Store) SyncNow() (SyncResult, error) {
|
||||
if !s.trySyncLock() {
|
||||
return SyncResult{Direction: "remote_to_sqlite", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
|
||||
}
|
||||
defer s.syncMu.Unlock()
|
||||
s.mu.RLock()
|
||||
remote := s.remoteDB
|
||||
remoteDialect := s.remoteDialect
|
||||
local := s.localDB
|
||||
localDialect := s.localDialect
|
||||
s.mu.RUnlock()
|
||||
if remote == nil {
|
||||
result := SyncResult{Direction: "remote_to_sqlite", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
|
||||
s.setSyncStatus(result, nil)
|
||||
return result, nil
|
||||
}
|
||||
result, err := copyAllTables(remote, remoteDialect, local, localDialect, "remote_to_sqlite")
|
||||
s.setSyncStatus(result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Store) setSyncStatus(result SyncResult, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
s.status.LastSyncAt = result.FinishedAt
|
||||
s.status.LastSyncError = err.Error()
|
||||
return
|
||||
}
|
||||
s.status.LastSyncAt = result.FinishedAt
|
||||
s.status.LastSyncError = ""
|
||||
}
|
||||
|
||||
func (s *Store) trySyncLock() bool {
|
||||
return s.syncMu.TryLock()
|
||||
}
|
||||
|
||||
type tableSpec struct {
|
||||
Name string
|
||||
Columns []string
|
||||
Conflict []string
|
||||
}
|
||||
|
||||
var syncTables = []tableSpec{
|
||||
{"schema_migrations", []string{"version", "applied_at", "description"}, []string{"version"}},
|
||||
{"admin_users", []string{"id", "username", "password_hash", "password_changed", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_packages", []string{"id", "product", "version", "platform", "arch", "file_name", "url", "sha256", "size_bytes", "enabled", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_notices", []string{"id", "version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"release_notice_revisions", []string{"id", "version", "raw_json", "note", "created_by", "created_at"}, []string{"id"}},
|
||||
{"feedback_tickets", []string{"code", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "public_reply", "note", "assignee", "handled_by", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "attachment", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "summary_text", "included_files", "mail_sent", "remote_addr", "tags", "created_at", "updated_at", "last_activity_at"}, []string{"code"}},
|
||||
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
|
||||
{"feedback_attachments", []string{"id", "feedback_code", "kind", "path", "file_name", "sha256", "size_bytes", "created_at"}, []string{"id"}},
|
||||
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
|
||||
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
|
||||
{"mail_records", []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}, []string{"id"}},
|
||||
{"source_categories", []string{"id", "category_id", "name", "enabled", "ui_config", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
|
||||
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
|
||||
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
|
||||
{"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}},
|
||||
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
||||
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}},
|
||||
{"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}},
|
||||
{"legacy_sync_jobs", []string{"id", "status", "summary", "stats_json", "started_at", "finished_at"}, []string{"id"}},
|
||||
}
|
||||
|
||||
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
|
||||
result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()}
|
||||
for _, table := range syncTables {
|
||||
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.FinishedAt = Now()
|
||||
return result, err
|
||||
}
|
||||
result.Tables[table.Name] = count
|
||||
}
|
||||
result.FinishedAt = Now()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
||||
rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
insertSQL := dstDialect.rebind(dstDialect.upsert(spec.Name, spec.Columns, spec.Conflict))
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
values := make([]any, len(spec.Columns))
|
||||
ptrs := make([]any, len(spec.Columns))
|
||||
for index := range values {
|
||||
ptrs[index] = &values[index]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return count, err
|
||||
}
|
||||
for index, value := range values {
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
values[index] = string(bytes)
|
||||
}
|
||||
}
|
||||
if _, err := dst.Exec(insertSQL, values...); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, rows.Err()
|
||||
}
|
||||
|
||||
func readPrototypeState(path string) (*state, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return nil, nil
|
||||
}
|
||||
var prototype state
|
||||
if err := json.Unmarshal(data, &prototype); err != nil {
|
||||
return nil, fmt.Errorf("existing sqlite path is not a valid sqlite database or JSON prototype: %w", err)
|
||||
}
|
||||
return &prototype, nil
|
||||
}
|
||||
|
||||
func backupPrototypeFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(data)), "{") {
|
||||
return nil
|
||||
}
|
||||
backup := path + ".json-prototype-" + time.Now().UTC().Format("20060102-150405") + ".bak"
|
||||
if err := os.WriteFile(backup, data, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) importPrototype(prototype state) error {
|
||||
for _, admin := range prototype.Admins {
|
||||
if admin.CreatedAt == "" {
|
||||
admin.CreatedAt = Now()
|
||||
}
|
||||
if admin.UpdatedAt == "" {
|
||||
admin.UpdatedAt = admin.CreatedAt
|
||||
}
|
||||
_, _ = s.exec(`INSERT INTO admin_users (id, username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
admin.ID, admin.Username, admin.PasswordHash, boolInt(admin.PasswordChanged), admin.CreatedAt, admin.UpdatedAt)
|
||||
}
|
||||
for _, item := range prototype.Feedbacks {
|
||||
_ = s.InsertFeedback(item)
|
||||
}
|
||||
for _, item := range prototype.Sources {
|
||||
_, _ = s.UpsertSource(item)
|
||||
}
|
||||
for _, item := range prototype.SourceChecks {
|
||||
_ = s.RecordSourceCheck(item.SourceID, item.Status, item.LatencyMS, item.Error)
|
||||
}
|
||||
for _, item := range prototype.SourceCalls {
|
||||
_ = s.RecordSourceCall(item)
|
||||
}
|
||||
for _, item := range prototype.AuditLogs {
|
||||
_ = s.InsertAudit(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +42,46 @@ func (d dialect) idType() string {
|
||||
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||
}
|
||||
|
||||
func (d dialect) keyTextType() string {
|
||||
if d.name == "mysql" {
|
||||
return "VARCHAR(191)"
|
||||
}
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (d dialect) shortTextType() string {
|
||||
if d.name == "mysql" {
|
||||
return "VARCHAR(255)"
|
||||
}
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (d dialect) mediumTextType() string {
|
||||
if d.name == "mysql" {
|
||||
return "VARCHAR(1024)"
|
||||
}
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (d dialect) longTextType() string {
|
||||
if d.name == "mysql" {
|
||||
return "LONGTEXT"
|
||||
}
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
func (d dialect) quoteIdent(identifier string) string {
|
||||
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
||||
}
|
||||
|
||||
func (d dialect) columnList(columns []string) string {
|
||||
quoted := make([]string, len(columns))
|
||||
for index, column := range columns {
|
||||
quoted[index] = d.quoteIdent(column)
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func (d dialect) boolExpr(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
@@ -54,7 +94,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
||||
for i := range placeholders {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
|
||||
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, d.columnList(columns), strings.Join(placeholders, ", "))
|
||||
conflictSet := map[string]bool{}
|
||||
for _, column := range conflict {
|
||||
conflictSet[column] = true
|
||||
@@ -64,10 +104,11 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
||||
if conflictSet[column] {
|
||||
continue
|
||||
}
|
||||
quoted := d.quoteIdent(column)
|
||||
if d.name == "mysql" {
|
||||
updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", column, column))
|
||||
updates = append(updates, fmt.Sprintf("%s = VALUES(%s)", quoted, quoted))
|
||||
} else {
|
||||
updates = append(updates, fmt.Sprintf("%s = excluded.%s", column, column))
|
||||
updates = append(updates, fmt.Sprintf("%s = excluded.%s", quoted, quoted))
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
@@ -79,7 +120,7 @@ func (d dialect) upsert(table string, columns, conflict []string) string {
|
||||
if d.name == "mysql" {
|
||||
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updates, ", ")
|
||||
}
|
||||
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updates, ", ")
|
||||
return base + " ON CONFLICT (" + d.columnList(conflict) + ") DO UPDATE SET " + strings.Join(updates, ", ")
|
||||
}
|
||||
|
||||
func (d dialect) limitOffset(limit, offset int) string {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (s *Store) InsertFeedback(item Feedback) error {
|
||||
now := Now()
|
||||
if item.Code == "" {
|
||||
item.Code = NewFeedbackCode()
|
||||
}
|
||||
if item.Status == "" {
|
||||
item.Status = "new"
|
||||
}
|
||||
if item.Category == "" {
|
||||
item.Category = normalizeCategory(item.Type)
|
||||
}
|
||||
if item.Priority == "" {
|
||||
item.Priority = normalizePriority(item.Severity)
|
||||
}
|
||||
if item.SLALevel == "" {
|
||||
item.SLALevel = defaultSLA(item.Priority)
|
||||
}
|
||||
if item.SourceChannel == "" {
|
||||
item.SourceChannel = "winui"
|
||||
}
|
||||
if item.RiskScore == 0 {
|
||||
item.RiskScore = defaultRisk(item.Priority)
|
||||
}
|
||||
if item.StatusDetail == "" {
|
||||
item.StatusDetail = "反馈已接收,等待后台处理。"
|
||||
}
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
item.LastActivityAt = now
|
||||
tagsJSON, _ := json.Marshal(normalizeTags(item.Tags))
|
||||
_, err := s.exec(`INSERT INTO feedback_tickets (
|
||||
code, title, type, severity, category, priority, contact, body, status, status_detail,
|
||||
public_reply, note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level,
|
||||
source_channel, risk_score, resolution, attachment, package_path, encrypted_package_path,
|
||||
package_sha256, plain_package_sha256, summary_text, included_files, mail_sent, remote_addr,
|
||||
tags, created_at, updated_at, last_activity_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.Code, sanitize(item.Title), sanitize(item.Type), sanitize(item.Severity), item.Category, item.Priority,
|
||||
sanitize(item.Contact), sanitizeLong(item.Body, 5000), item.Status, sanitize(item.StatusDetail),
|
||||
sanitizeLong(item.PublicReply, 3000), sanitizeLong(item.Note, 3000), sanitize(item.Assignee), sanitize(item.HandledBy),
|
||||
item.DueAt, item.ResolvedAt, item.ArchivedAt, item.SLALevel, item.SourceChannel, item.RiskScore,
|
||||
sanitizeLong(item.Resolution, 3000), item.Attachment, item.PackagePath, item.EncryptedPackagePath,
|
||||
item.PackageSha256, item.PlainPackageSha256, sanitizeLong(item.SummaryText, 6000), item.IncludedFiles,
|
||||
boolInt(item.MailSent), sanitize(item.RemoteAddr), string(tagsJSON), item.CreatedAt, item.UpdatedAt, item.LastActivityAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.PackagePath != "" {
|
||||
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "package", Path: item.PackagePath, FileName: filepath.Base(item.PackagePath), SHA256: item.PlainPackageSha256})
|
||||
}
|
||||
if item.EncryptedPackagePath != "" {
|
||||
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "encrypted_package", Path: item.EncryptedPackagePath, FileName: filepath.Base(item.EncryptedPackagePath), SHA256: item.PackageSha256})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFeedback(code string) (Feedback, error) {
|
||||
item, err := s.scanFeedbackRow(s.queryRow(feedbackSelectSQL()+` WHERE code = ?`, code))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Feedback{}, errors.New("feedback not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) GetFeedbackDetail(code string) (*FeedbackDetail, error) {
|
||||
item, err := s.GetFeedback(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments, err := s.ListFeedbackComments(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, err := s.ListFeedbackAttachments(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, _ := s.ListAuditLogsForTarget(code, 100)
|
||||
legacyEvents, _ := s.ListFeedbackEvents(code, 100)
|
||||
mailRecords, _ := s.ListMailRecords(code, 100)
|
||||
return &FeedbackDetail{Feedback: item, Comments: comments, Attachments: attachments, Events: events, LegacyEvents: legacyEvents, MailRecords: mailRecords}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbacks(limit int) ([]Feedback, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.query(feedbackSelectSQL()+` ORDER BY last_activity_at DESC, created_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFeedbackRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) {
|
||||
page, perPage = normalizePage(page, perPage)
|
||||
where, args := feedbackWhere(filters)
|
||||
var total int
|
||||
if err := s.queryRow(`SELECT COUNT(*) FROM feedback_tickets`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := ` ORDER BY last_activity_at DESC, created_at DESC`
|
||||
if filters.Sort == "oldest" {
|
||||
order = ` ORDER BY created_at ASC`
|
||||
}
|
||||
args = append(args, perPage, (page-1)*perPage)
|
||||
rows, err := s.query(feedbackSelectSQL()+where+order+` LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := scanFeedbackRows(rows)
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFeedback(code, status, detail, reply string) error {
|
||||
update := FeedbackUpdate{Status: status, StatusDetail: detail, PublicReply: reply, Actor: "admin"}
|
||||
return s.UpdateFeedbackTicket(code, update)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFeedbackTicket(code string, update FeedbackUpdate) error {
|
||||
current, err := s.GetFeedback(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if update.Status == "" {
|
||||
update.Status = current.Status
|
||||
}
|
||||
if update.Category == "" {
|
||||
update.Category = current.Category
|
||||
}
|
||||
if update.Priority == "" {
|
||||
update.Priority = current.Priority
|
||||
}
|
||||
if update.SLALevel == "" {
|
||||
update.SLALevel = current.SLALevel
|
||||
}
|
||||
if update.StatusDetail == "" {
|
||||
update.StatusDetail = current.StatusDetail
|
||||
}
|
||||
if update.PublicReply == "" {
|
||||
update.PublicReply = current.PublicReply
|
||||
}
|
||||
if update.Note == "" {
|
||||
update.Note = current.Note
|
||||
}
|
||||
if update.Assignee == "" {
|
||||
update.Assignee = current.Assignee
|
||||
}
|
||||
if update.HandledBy == "" {
|
||||
update.HandledBy = current.HandledBy
|
||||
}
|
||||
if update.DueAt == "" {
|
||||
update.DueAt = current.DueAt
|
||||
}
|
||||
if update.Resolution == "" {
|
||||
update.Resolution = current.Resolution
|
||||
}
|
||||
tags := current.Tags
|
||||
if len(update.Tags) > 0 {
|
||||
tags = update.Tags
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(normalizeTags(tags))
|
||||
now := Now()
|
||||
_, err = s.exec(`UPDATE feedback_tickets SET status = ?, category = ?, priority = ?, status_detail = ?, public_reply = ?,
|
||||
note = ?, assignee = ?, handled_by = ?, due_at = ?, sla_level = ?, resolution = ?, tags = ?, updated_at = ?, last_activity_at = ?
|
||||
WHERE code = ?`,
|
||||
update.Status, update.Category, update.Priority, sanitize(update.StatusDetail), sanitizeLong(update.PublicReply, 3000),
|
||||
sanitizeLong(update.Note, 3000), sanitize(update.Assignee), sanitize(update.HandledBy), update.DueAt, update.SLALevel,
|
||||
sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code)
|
||||
if err == nil {
|
||||
_ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "反馈工单已更新"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) BulkUpdateFeedback(codes []string, update FeedbackUpdate) error {
|
||||
for _, code := range codes {
|
||||
if err := s.UpdateFeedbackTicket(code, update); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertFeedbackComment(comment FeedbackComment) (FeedbackComment, error) {
|
||||
if comment.CreatedAt == "" {
|
||||
comment.CreatedAt = Now()
|
||||
}
|
||||
id, err := s.insertID(`INSERT INTO feedback_comments (feedback_code, author, body, internal, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
comment.Code, sanitize(comment.Author), sanitizeLong(comment.Body, 3000), boolInt(comment.Internal), comment.CreatedAt)
|
||||
if err != nil {
|
||||
return FeedbackComment{}, err
|
||||
}
|
||||
comment.ID = id
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackComments(code string) ([]FeedbackComment, error) {
|
||||
rows, err := s.query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FeedbackComment{}
|
||||
for rows.Next() {
|
||||
var item FeedbackComment
|
||||
var internal int
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Author, &item.Body, &internal, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Internal = internal == 1
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertFeedbackAttachment(item FeedbackAttachment) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
if item.FileName == "" {
|
||||
item.FileName = filepath.Base(item.Path)
|
||||
}
|
||||
if item.SizeBytes == 0 {
|
||||
if info, err := os.Stat(item.Path); err == nil {
|
||||
item.SizeBytes = info.Size()
|
||||
}
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO feedback_attachments (feedback_code, kind, path, file_name, sha256, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.FeedbackCode, item.Kind, item.Path, item.FileName, item.SHA256, item.SizeBytes, item.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackAttachments(code string) ([]FeedbackAttachment, error) {
|
||||
rows, err := s.query(`SELECT id, feedback_code, kind, path, file_name, sha256, size_bytes, created_at FROM feedback_attachments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FeedbackAttachment{}
|
||||
for rows.Next() {
|
||||
var item FeedbackAttachment
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Path, &item.FileName, &item.SHA256, &item.SizeBytes, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertFeedbackEvent(item LegacyFeedbackEvent) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("feedback_events", columns, []string{"id"})),
|
||||
item.ID, sanitize(item.FeedbackCode), sanitize(item.EventType), sanitize(item.Actor), sanitize(item.FromValue), sanitize(item.ToValue), sanitizeLong(item.Message, 1000), item.CreatedAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertFeedbackTag(code, tag, createdAt string) error {
|
||||
if createdAt == "" {
|
||||
createdAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"feedback_code", "tag", "created_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("feedback_tags", columns, []string{"feedback_code", "tag"})), sanitize(code), sanitize(tag), createdAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertMailRecord(item LegacyMailRecord) error {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
conn, d := s.active()
|
||||
columns := []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("mail_records", columns, []string{"id"})),
|
||||
item.ID, sanitize(item.FeedbackCode), sanitize(item.Kind), sanitize(item.Status), sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000),
|
||||
"", "", item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) InsertMailRecord(item LegacyMailRecord) (int64, error) {
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = Now()
|
||||
}
|
||||
id, err := s.insertID(`INSERT INTO mail_records (
|
||||
feedback_code, kind, status, to_address, subject, plain_body, html_body,
|
||||
attachment_path, attachment_name, error_message, created_at, sent_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sanitize(item.FeedbackCode), sanitize(firstNonEmpty(item.Kind, "feedback")), sanitize(firstNonEmpty(item.Status, "pending")),
|
||||
sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000), sanitizeLong(item.PlainBody, 12000), sanitizeLong(item.HTMLBody, 12000),
|
||||
item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMailState(id int64, status, errorMessage string) error {
|
||||
sentAt := ""
|
||||
if status == "sent" {
|
||||
sentAt = Now()
|
||||
}
|
||||
_, err := s.exec(`UPDATE mail_records SET status = ?, error_message = ?, sent_at = ? WHERE id = ?`,
|
||||
sanitize(status), sanitizeLong(errorMessage, 1000), sentAt, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFeedbackMailState(code string, sent bool) error {
|
||||
_, err := s.exec(`UPDATE feedback_tickets SET mail_sent = ?, updated_at = ?, last_activity_at = ? WHERE code = ?`,
|
||||
boolInt(sent), Now(), Now(), code)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyFeedbackEvent{}
|
||||
for rows.Next() {
|
||||
var item LegacyFeedbackEvent
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.EventType, &item.Actor, &item.FromValue, &item.ToValue, &item.Message, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListMailRecords(code string, limit int) ([]LegacyMailRecord, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyMailRecord{}
|
||||
for rows.Next() {
|
||||
var item LegacyMailRecord
|
||||
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Status, &item.ToAddress, &item.Subject, &item.AttachmentPath, &item.AttachmentName, &item.ErrorMessage, &item.CreatedAt, &item.SentAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) SaveLegacyRevision(name, raw, note, actor string) (LegacyJsonRevision, error) {
|
||||
item := LegacyJsonRevision{Name: name, Raw: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
||||
id, err := s.insertID(`INSERT INTO legacy_json_revisions (name, raw, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
item.Name, item.Raw, item.Note, item.CreatedBy, item.CreatedAt)
|
||||
if err != nil {
|
||||
return LegacyJsonRevision{}, err
|
||||
}
|
||||
item.ID = id
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLegacyRevisions(name string, limit int) ([]LegacyJsonRevision, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.query(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? ORDER BY id DESC LIMIT ?`, name, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []LegacyJsonRevision{}
|
||||
for rows.Next() {
|
||||
var item LegacyJsonRevision
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLegacyRevision(name string, id int64) (LegacyJsonRevision, error) {
|
||||
var item LegacyJsonRevision
|
||||
err := s.queryRow(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? AND id = ?`, name, id).
|
||||
Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LegacyJsonRevision{}, errors.New("revision not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package db
|
||||
|
||||
type state struct {
|
||||
Admins []adminRow `json:"admins"`
|
||||
Feedbacks []Feedback `json:"feedbacks"`
|
||||
Sources []Source `json:"sources"`
|
||||
SourceChecks []SourceCheck `json:"sourceChecks"`
|
||||
SourceCalls []SourceCall `json:"sourceCalls"`
|
||||
AuditLogs []AuditLog `json:"auditLogs"`
|
||||
NextID map[string]int64 `json:"nextId"`
|
||||
}
|
||||
|
||||
type adminRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
PasswordChanged bool `json:"passwordChanged"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DatabaseStatus struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
ConfigProvider string `json:"configProvider"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
SQLiteReady bool `json:"sqliteReady"`
|
||||
RemoteReady bool `json:"remoteReady"`
|
||||
FailoverActive bool `json:"failoverActive"`
|
||||
LastError string `json:"lastError"`
|
||||
LastFailoverAt string `json:"lastFailoverAt"`
|
||||
LastRecoveredAt string `json:"lastRecoveredAt"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
LastSyncError string `json:"lastSyncError"`
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Direction string `json:"direction"`
|
||||
Status string `json:"status"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Tables map[string]int `json:"tables"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordChanged bool `json:"passwordChanged"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
Contact string `json:"contact"`
|
||||
Body string `json:"body"`
|
||||
Status string `json:"status"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Note string `json:"note"`
|
||||
Assignee string `json:"assignee"`
|
||||
HandledBy string `json:"handledBy"`
|
||||
DueAt string `json:"dueAt"`
|
||||
ResolvedAt string `json:"resolvedAt"`
|
||||
ArchivedAt string `json:"archivedAt"`
|
||||
SLALevel string `json:"slaLevel"`
|
||||
SourceChannel string `json:"sourceChannel"`
|
||||
RiskScore int `json:"riskScore"`
|
||||
Resolution string `json:"resolution"`
|
||||
Attachment string `json:"attachment"`
|
||||
PackagePath string `json:"packagePath"`
|
||||
EncryptedPackagePath string `json:"encryptedPackagePath"`
|
||||
PackageSha256 string `json:"packageSha256"`
|
||||
PlainPackageSha256 string `json:"plainPackageSha256"`
|
||||
SummaryText string `json:"summaryText"`
|
||||
IncludedFiles string `json:"includedFiles"`
|
||||
MailSent bool `json:"mailSent"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastActivityAt string `json:"lastActivityAt"`
|
||||
}
|
||||
|
||||
type FeedbackComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Internal bool `json:"internal"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type FeedbackAttachment struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
FileName string `json:"fileName"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LegacyFeedbackEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
EventType string `json:"eventType"`
|
||||
Actor string `json:"actor"`
|
||||
FromValue string `json:"fromValue"`
|
||||
ToValue string `json:"toValue"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LegacyMailRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedbackCode string `json:"feedbackCode"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
ToAddress string `json:"toAddress"`
|
||||
Subject string `json:"subject"`
|
||||
PlainBody string `json:"plainBody,omitempty"`
|
||||
HTMLBody string `json:"htmlBody,omitempty"`
|
||||
AttachmentPath string `json:"attachmentPath"`
|
||||
AttachmentName string `json:"attachmentName"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
SentAt string `json:"sentAt"`
|
||||
}
|
||||
|
||||
type LegacySyncJob struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
StatsJSON string `json:"statsJson"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
type FeedbackDetail struct {
|
||||
Feedback
|
||||
Comments []FeedbackComment `json:"comments"`
|
||||
Attachments []FeedbackAttachment `json:"attachments"`
|
||||
Events []AuditLog `json:"events"`
|
||||
LegacyEvents []LegacyFeedbackEvent `json:"legacyEvents"`
|
||||
MailRecords []LegacyMailRecord `json:"mailRecords"`
|
||||
}
|
||||
|
||||
type FeedbackFilters struct {
|
||||
Status string
|
||||
Category string
|
||||
Priority string
|
||||
Query string
|
||||
Assignee string
|
||||
Tag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type FeedbackUpdate struct {
|
||||
Status string `json:"status"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
HandledBy string `json:"handledBy"`
|
||||
Assignee string `json:"assignee"`
|
||||
DueAt string `json:"dueAt"`
|
||||
SLALevel string `json:"slaLevel"`
|
||||
Resolution string `json:"resolution"`
|
||||
Note string `json:"note"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Actor string `json:"actor"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type ReleasePackage struct {
|
||||
ID int64 `json:"id"`
|
||||
Product string `json:"product"`
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
FileName string `json:"fileName"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ReleaseNotice struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
Channel string `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
MessageMD string `json:"messageMd"`
|
||||
ReleaseNotesMD string `json:"releaseNotesMd"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
NoticeFile string `json:"noticeFile"`
|
||||
RawJSON string `json:"rawJson"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ReleaseNoticeRevision struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
RawJSON string `json:"rawJson"`
|
||||
Note string `json:"note"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID string `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
SourceID string `json:"sourceId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Method string `json:"method"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
URLTemplate string `json:"urlTemplate"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
ProxyMode string `json:"proxyMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
CacheSeconds int `json:"cacheSeconds"`
|
||||
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientVisible bool `json:"clientVisible"`
|
||||
SupportedFormats string `json:"supportedFormats"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastLatencyMS int `json:"lastLatencyMs"`
|
||||
LastCheckedAt string `json:"lastCheckedAt"`
|
||||
LastError string `json:"lastError"`
|
||||
ConsecutiveFailure int `json:"consecutiveFailure"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SourceCheck struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID int64 `json:"sourceDbId"`
|
||||
Status string `json:"status"`
|
||||
LatencyMS int `json:"latencyMs"`
|
||||
Error string `json:"error"`
|
||||
CheckedAt string `json:"checkedAt"`
|
||||
}
|
||||
|
||||
type SourceCall struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID string `json:"sourceId"`
|
||||
Status string `json:"status"`
|
||||
LatencyMS int `json:"latencyMs"`
|
||||
Error string `json:"error"`
|
||||
Client string `json:"client"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64 `json:"id"`
|
||||
Actor string `json:"actor"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AuditFilters struct {
|
||||
Page int
|
||||
PerPage int
|
||||
Type string
|
||||
Target string
|
||||
Query string
|
||||
}
|
||||
|
||||
type AuditPage struct {
|
||||
Items []AuditLog `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
||||
type LegacyJsonRevision struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Raw string `json:"raw"`
|
||||
Note string `json:"note"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertReleaseNotice(item ReleaseNotice) (ReleaseNotice, error) {
|
||||
now := Now()
|
||||
item.Version = strings.TrimSpace(item.Version)
|
||||
if item.Version == "" {
|
||||
return ReleaseNotice{}, errors.New("version is required")
|
||||
}
|
||||
if item.CreatedAt == "" {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if item.Channel == "" {
|
||||
item.Channel = "stable"
|
||||
}
|
||||
if item.NoticeFile == "" {
|
||||
item.NoticeFile = item.Version + ".json"
|
||||
}
|
||||
columns := []string{"version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}
|
||||
conn, d := s.active()
|
||||
_, err := conn.Exec(d.rebind(d.upsert("release_notices", columns, []string{"version"})),
|
||||
sanitize(item.Version), sanitize(item.Build), sanitize(item.Channel), sanitizeLong(item.Title, 500), sanitizeLong(item.Message, 4000),
|
||||
sanitizeLong(item.ReleaseNotes, 12000), sanitizeLong(item.MessageMD, 12000), sanitizeLong(item.ReleaseNotesMD, 20000),
|
||||
sanitizeLong(item.DownloadURL, 1200), sanitize(item.NoticeFile), item.RawJSON, sanitize(item.PublishedAt), item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
return ReleaseNotice{}, err
|
||||
}
|
||||
return s.GetReleaseNotice(item.Version)
|
||||
}
|
||||
|
||||
func (s *Store) GetReleaseNotice(version string) (ReleaseNotice, error) {
|
||||
item, err := scanReleaseNotice(s.queryRow(releaseNoticeSelectSQL()+` WHERE version = ?`, strings.TrimSpace(version)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ReleaseNotice{}, errors.New("release notice not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ListReleaseNotices(limit int) ([]ReleaseNotice, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.query(releaseNoticeSelectSQL()+` ORDER BY published_at DESC, version DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ReleaseNotice{}
|
||||
for rows.Next() {
|
||||
item, err := scanReleaseNotice(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveReleaseNoticeRevision(version, raw, note, actor string) (ReleaseNoticeRevision, error) {
|
||||
item := ReleaseNoticeRevision{Version: sanitize(version), RawJSON: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
||||
id, err := s.insertID(`INSERT INTO release_notice_revisions (version, raw_json, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
item.Version, item.RawJSON, item.Note, item.CreatedBy, item.CreatedAt)
|
||||
if err != nil {
|
||||
return ReleaseNoticeRevision{}, err
|
||||
}
|
||||
item.ID = id
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListReleaseNoticeRevisions(version string, limit int) ([]ReleaseNoticeRevision, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.query(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? ORDER BY id DESC LIMIT ?`, strings.TrimSpace(version), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ReleaseNoticeRevision{}
|
||||
for rows.Next() {
|
||||
var item ReleaseNoticeRevision
|
||||
if err := rows.Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetReleaseNoticeRevision(version string, id int64) (ReleaseNoticeRevision, error) {
|
||||
var item ReleaseNoticeRevision
|
||||
err := s.queryRow(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? AND id = ?`, strings.TrimSpace(version), id).
|
||||
Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ReleaseNoticeRevision{}, errors.New("release notice revision not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Store) scanFeedbackRow(scanner feedbackScanner) (Feedback, error) {
|
||||
return scanFeedback(scanner)
|
||||
}
|
||||
|
||||
func feedbackSelectSQL() string {
|
||||
return `SELECT code, title, type, severity, category, priority, contact, body, status, status_detail, public_reply,
|
||||
note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level, source_channel, risk_score, resolution,
|
||||
attachment, package_path, encrypted_package_path, package_sha256, plain_package_sha256, summary_text, included_files,
|
||||
mail_sent, remote_addr, tags, created_at, updated_at, last_activity_at FROM feedback_tickets`
|
||||
}
|
||||
|
||||
func releaseNoticeSelectSQL() string {
|
||||
return `SELECT id, version, build, channel, title, message, release_notes, message_md, release_notes_md,
|
||||
download_url, notice_file, raw_json, published_at, created_at, updated_at FROM release_notices`
|
||||
}
|
||||
|
||||
type feedbackScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanReleaseNotice(scanner interface{ Scan(dest ...any) error }) (ReleaseNotice, error) {
|
||||
var item ReleaseNotice
|
||||
err := scanner.Scan(&item.ID, &item.Version, &item.Build, &item.Channel, &item.Title, &item.Message,
|
||||
&item.ReleaseNotes, &item.MessageMD, &item.ReleaseNotesMD, &item.DownloadURL, &item.NoticeFile,
|
||||
&item.RawJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanFeedback(scanner feedbackScanner) (Feedback, error) {
|
||||
var item Feedback
|
||||
var mailSent int
|
||||
var tags string
|
||||
err := scanner.Scan(&item.Code, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact,
|
||||
&item.Body, &item.Status, &item.StatusDetail, &item.PublicReply, &item.Note, &item.Assignee, &item.HandledBy,
|
||||
&item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore,
|
||||
&item.Resolution, &item.Attachment, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256,
|
||||
&item.PlainPackageSha256, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.RemoteAddr, &tags,
|
||||
&item.CreatedAt, &item.UpdatedAt, &item.LastActivityAt)
|
||||
item.MailSent = mailSent == 1
|
||||
_ = json.Unmarshal([]byte(tags), &item.Tags)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanFeedbackRows(rows *sql.Rows) ([]Feedback, error) {
|
||||
items := []Feedback{}
|
||||
for rows.Next() {
|
||||
item, err := scanFeedback(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func sourceSelectSQL() string {
|
||||
return `SELECT id, category_id, category_name, source_id, name, description, method, api_url, url_template, thumbnail_url,
|
||||
proxy_mode, timeout_ms, retry_count, cache_seconds, check_interval_sec, enabled, client_visible, supported_formats,
|
||||
last_status, last_latency_ms, last_checked_at, last_error, consecutive_failure, created_at, updated_at FROM source_endpoints`
|
||||
}
|
||||
|
||||
func scanSourceRow(scanner sourceScanner) (Source, error) {
|
||||
return scanSource(scanner)
|
||||
}
|
||||
|
||||
type sourceScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSourceRowsCurrent(scanner sourceScanner) (Source, error) {
|
||||
return scanSource(scanner)
|
||||
}
|
||||
|
||||
func scanSource(scanner sourceScanner) (Source, error) {
|
||||
var item Source
|
||||
var enabled, visible int
|
||||
err := scanner.Scan(&item.ID, &item.CategoryID, &item.CategoryName, &item.SourceID, &item.Name, &item.Description,
|
||||
&item.Method, &item.APIURL, &item.URLTemplate, &item.ThumbnailURL, &item.ProxyMode, &item.TimeoutMS, &item.RetryCount,
|
||||
&item.CacheSeconds, &item.CheckIntervalSec, &enabled, &visible, &item.SupportedFormats, &item.LastStatus,
|
||||
&item.LastLatencyMS, &item.LastCheckedAt, &item.LastError, &item.ConsecutiveFailure, &item.CreatedAt, &item.UpdatedAt)
|
||||
item.Enabled = enabled == 1
|
||||
item.ClientVisible = visible == 1
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanAuditRows(rows *sql.Rows) ([]AuditLog, error) {
|
||||
items := []AuditLog{}
|
||||
for rows.Next() {
|
||||
var item AuditLog
|
||||
if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func feedbackWhere(filters FeedbackFilters) (string, []any) {
|
||||
clauses := []string{}
|
||||
args := []any{}
|
||||
if filters.Status != "" {
|
||||
clauses = append(clauses, "status = ?")
|
||||
args = append(args, filters.Status)
|
||||
}
|
||||
if filters.Category != "" {
|
||||
clauses = append(clauses, "category = ?")
|
||||
args = append(args, filters.Category)
|
||||
}
|
||||
if filters.Priority != "" {
|
||||
clauses = append(clauses, "priority = ?")
|
||||
args = append(args, filters.Priority)
|
||||
}
|
||||
if filters.Assignee != "" {
|
||||
clauses = append(clauses, "assignee = ?")
|
||||
args = append(args, filters.Assignee)
|
||||
}
|
||||
if filters.Query != "" {
|
||||
like := "%" + filters.Query + "%"
|
||||
clauses = append(clauses, "(code LIKE ? OR title LIKE ? OR contact LIKE ? OR body LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func normalizePage(page, perPage int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
|
||||
func NewFeedbackCode() string {
|
||||
var data [3]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return "FB-" + time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
return "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(data[:]))
|
||||
}
|
||||
|
||||
func Now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func sanitize(value string) string {
|
||||
return sanitizeLong(value, 1000)
|
||||
}
|
||||
|
||||
func sanitizeLong(value string, max int) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return r
|
||||
}
|
||||
if r < 32 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
runes := []rune(value)
|
||||
if max > 0 && len(runes) > max {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeCategory(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "suggestion", "ui", "other":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "issue"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePriority(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "major", "blocking":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "normal"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSLA(priority string) string {
|
||||
switch normalizePriority(priority) {
|
||||
case "blocking":
|
||||
return "urgent"
|
||||
case "major":
|
||||
return "elevated"
|
||||
default:
|
||||
return "standard"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRisk(priority string) int {
|
||||
switch normalizePriority(priority) {
|
||||
case "blocking":
|
||||
return 90
|
||||
case "major":
|
||||
return 65
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTags(tags []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, tag := range tags {
|
||||
tag = strings.ToLower(strings.Trim(strings.TrimSpace(tag), ",;#"))
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
runes := []rune(tag)
|
||||
if len(runes) > 32 {
|
||||
tag = string(runes[:32])
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProxyMode(value, category, name, url string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "server_proxy", "proxy":
|
||||
return "server_proxy"
|
||||
case "disabled":
|
||||
return "disabled"
|
||||
case "client_direct", "direct":
|
||||
return "client_direct"
|
||||
}
|
||||
haystack := strings.ToLower(category + " " + name + " " + url)
|
||||
for _, token := range []string{"ip", "weather", "location", "定位", "天气"} {
|
||||
if strings.Contains(haystack, token) {
|
||||
return "client_direct"
|
||||
}
|
||||
}
|
||||
return "client_direct"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion = "2026-06-compat-baseline"
|
||||
|
||||
func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
||||
statements := []string{}
|
||||
if d.name == "sqlite" {
|
||||
statements = append(statements,
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
)
|
||||
}
|
||||
statements = append(statements, schemaStatements(d)...)
|
||||
for _, statement := range statements {
|
||||
if _, err := conn.Exec(d.rebind(statement)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := createSchemaIndexes(conn, d); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.recordSchemaVersion(conn, d)
|
||||
}
|
||||
|
||||
func schemaStatements(d dialect) []string {
|
||||
keyText := d.keyTextType()
|
||||
shortText := d.shortTextType()
|
||||
mediumText := d.mediumTextType()
|
||||
longText := d.longTextType()
|
||||
return []string{
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
applied_at %s NOT NULL,
|
||||
description VARCHAR(255) NOT NULL DEFAULT ''
|
||||
)`, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id %s,
|
||||
username %s NOT NULL UNIQUE,
|
||||
password_hash %s NOT NULL,
|
||||
password_changed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.idType(), keyText, shortText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id %s,
|
||||
session_id %s NOT NULL UNIQUE,
|
||||
username %s NOT NULL,
|
||||
csrf %s NOT NULL,
|
||||
expires_at %s NOT NULL,
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, shortText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
|
||||
id %s,
|
||||
product %s NOT NULL,
|
||||
version %s NOT NULL,
|
||||
platform %s NOT NULL,
|
||||
arch %s NOT NULL,
|
||||
file_name %s NOT NULL UNIQUE,
|
||||
url %s NOT NULL,
|
||||
sha256 %s NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, keyText, keyText, keyText, mediumText, shortText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
|
||||
id %s,
|
||||
version %s NOT NULL UNIQUE,
|
||||
build %s NOT NULL DEFAULT '',
|
||||
channel %s NOT NULL DEFAULT 'stable',
|
||||
title %s NOT NULL DEFAULT '',
|
||||
message %s NOT NULL,
|
||||
release_notes %s NOT NULL,
|
||||
message_md %s NOT NULL,
|
||||
release_notes_md %s NOT NULL,
|
||||
download_url %s NOT NULL DEFAULT '',
|
||||
notice_file %s NOT NULL DEFAULT '',
|
||||
raw_json %s NOT NULL,
|
||||
published_at %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.idType(), keyText, shortText, shortText, mediumText, longText, longText, longText, longText, mediumText, keyText, longText, shortText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
|
||||
id %s,
|
||||
version %s NOT NULL,
|
||||
raw_json %s NOT NULL,
|
||||
note %s NOT NULL DEFAULT '',
|
||||
created_by %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
|
||||
code %s PRIMARY KEY,
|
||||
title %s NOT NULL,
|
||||
type %s NOT NULL,
|
||||
severity %s NOT NULL,
|
||||
category %s NOT NULL DEFAULT '',
|
||||
priority %s NOT NULL DEFAULT '',
|
||||
contact %s NOT NULL DEFAULT '',
|
||||
body %s NOT NULL,
|
||||
status %s NOT NULL,
|
||||
status_detail %s NOT NULL DEFAULT '',
|
||||
public_reply %s NOT NULL,
|
||||
note %s NOT NULL,
|
||||
assignee %s NOT NULL DEFAULT '',
|
||||
handled_by %s NOT NULL DEFAULT '',
|
||||
due_at %s NOT NULL DEFAULT '',
|
||||
resolved_at %s NOT NULL DEFAULT '',
|
||||
archived_at %s NOT NULL DEFAULT '',
|
||||
sla_level %s NOT NULL DEFAULT '',
|
||||
source_channel %s NOT NULL DEFAULT '',
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
resolution %s NOT NULL,
|
||||
attachment %s NOT NULL DEFAULT '',
|
||||
package_path %s NOT NULL DEFAULT '',
|
||||
encrypted_package_path %s NOT NULL DEFAULT '',
|
||||
package_sha256 %s NOT NULL DEFAULT '',
|
||||
plain_package_sha256 %s NOT NULL DEFAULT '',
|
||||
summary_text %s NOT NULL,
|
||||
included_files %s NOT NULL,
|
||||
mail_sent INTEGER NOT NULL DEFAULT 0,
|
||||
remote_addr %s NOT NULL DEFAULT '',
|
||||
tags %s NOT NULL,
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL,
|
||||
last_activity_at %s NOT NULL
|
||||
)`, keyText, mediumText, keyText, keyText, keyText, keyText, mediumText, longText, keyText, mediumText, longText, longText, keyText, keyText, shortText, shortText, shortText, keyText, keyText, longText, mediumText, mediumText, mediumText, shortText, shortText, longText, longText, shortText, longText, shortText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
|
||||
id %s,
|
||||
feedback_code %s NOT NULL,
|
||||
author %s NOT NULL DEFAULT '',
|
||||
body %s NOT NULL,
|
||||
internal INTEGER NOT NULL DEFAULT 1,
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, longText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
|
||||
id %s,
|
||||
feedback_code %s NOT NULL,
|
||||
kind %s NOT NULL,
|
||||
path %s NOT NULL,
|
||||
file_name %s NOT NULL,
|
||||
sha256 %s NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, mediumText, mediumText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
|
||||
id %s,
|
||||
feedback_code %s NOT NULL,
|
||||
event_type %s NOT NULL,
|
||||
actor %s NOT NULL DEFAULT '',
|
||||
from_value %s NOT NULL DEFAULT '',
|
||||
to_value %s NOT NULL DEFAULT '',
|
||||
message %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, mediumText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tags (
|
||||
feedback_code %s NOT NULL,
|
||||
tag %s NOT NULL,
|
||||
created_at %s NOT NULL,
|
||||
PRIMARY KEY (feedback_code, tag)
|
||||
)`, keyText, keyText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
|
||||
id %s,
|
||||
feedback_code %s NOT NULL DEFAULT '',
|
||||
kind %s NOT NULL DEFAULT '',
|
||||
status %s NOT NULL DEFAULT '',
|
||||
to_address %s NOT NULL DEFAULT '',
|
||||
subject %s NOT NULL DEFAULT '',
|
||||
plain_body %s NOT NULL,
|
||||
html_body %s NOT NULL,
|
||||
attachment_path %s NOT NULL DEFAULT '',
|
||||
attachment_name %s NOT NULL DEFAULT '',
|
||||
error_message %s NOT NULL,
|
||||
created_at %s NOT NULL,
|
||||
sent_at %s NOT NULL DEFAULT ''
|
||||
)`, d.idType(), keyText, keyText, keyText, mediumText, mediumText, longText, longText, mediumText, mediumText, longText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
|
||||
id %s,
|
||||
category_id %s NOT NULL UNIQUE,
|
||||
name %s NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
ui_config %s NOT NULL,
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.idType(), keyText, shortText, longText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
|
||||
id %s,
|
||||
category_id %s NOT NULL,
|
||||
category_name %s NOT NULL,
|
||||
source_id %s NOT NULL UNIQUE,
|
||||
name %s NOT NULL,
|
||||
description %s NOT NULL DEFAULT '',
|
||||
method %s NOT NULL DEFAULT 'GET',
|
||||
api_url %s NOT NULL DEFAULT '',
|
||||
url_template %s NOT NULL DEFAULT '',
|
||||
thumbnail_url %s NOT NULL DEFAULT '',
|
||||
proxy_mode %s NOT NULL DEFAULT 'client_direct',
|
||||
timeout_ms INTEGER NOT NULL DEFAULT 8000,
|
||||
retry_count INTEGER NOT NULL DEFAULT 1,
|
||||
cache_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
check_interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
client_visible INTEGER NOT NULL DEFAULT 1,
|
||||
supported_formats %s NOT NULL,
|
||||
last_status %s NOT NULL DEFAULT 'unknown',
|
||||
last_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at %s NOT NULL DEFAULT '',
|
||||
last_error %s NOT NULL,
|
||||
consecutive_failure INTEGER NOT NULL DEFAULT 0,
|
||||
created_at %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.idType(), keyText, shortText, keyText, shortText, mediumText, keyText, mediumText, mediumText, mediumText, keyText, longText, keyText, shortText, longText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
|
||||
id %s,
|
||||
source_db_id BIGINT NOT NULL,
|
||||
status %s NOT NULL,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error %s NOT NULL,
|
||||
checked_at %s NOT NULL
|
||||
)`, d.idType(), keyText, longText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
|
||||
id %s,
|
||||
source_id %s NOT NULL,
|
||||
status %s NOT NULL,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error %s NOT NULL,
|
||||
client %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, longText, mediumText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
|
||||
id %s,
|
||||
direction %s NOT NULL,
|
||||
status %s NOT NULL,
|
||||
message %s NOT NULL,
|
||||
tables_json %s NOT NULL,
|
||||
started_at %s NOT NULL,
|
||||
finished_at %s NOT NULL DEFAULT ''
|
||||
)`, d.idType(), keyText, keyText, longText, longText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS system_settings (
|
||||
%s %s NOT NULL PRIMARY KEY,
|
||||
value %s NOT NULL,
|
||||
updated_at %s NOT NULL
|
||||
)`, d.quoteIdent("key"), keyText, longText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
|
||||
id %s,
|
||||
status %s NOT NULL,
|
||||
summary %s NOT NULL,
|
||||
stats_json %s NOT NULL,
|
||||
started_at %s NOT NULL,
|
||||
finished_at %s NOT NULL DEFAULT ''
|
||||
)`, d.idType(), keyText, longText, longText, shortText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id %s,
|
||||
actor %s NOT NULL DEFAULT '',
|
||||
type %s NOT NULL,
|
||||
target %s NOT NULL DEFAULT '',
|
||||
message %s NOT NULL,
|
||||
ip %s NOT NULL DEFAULT '',
|
||||
user_agent %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, keyText, keyText, longText, keyText, mediumText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
|
||||
id %s,
|
||||
name %s NOT NULL,
|
||||
raw %s NOT NULL,
|
||||
note %s NOT NULL DEFAULT '',
|
||||
created_by %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL
|
||||
)`, d.idType(), keyText, longText, mediumText, keyText, shortText),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id %s,
|
||||
webhook_name %s NOT NULL DEFAULT '',
|
||||
event %s NOT NULL DEFAULT '',
|
||||
status %s NOT NULL DEFAULT '',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
response_code INTEGER NOT NULL DEFAULT 0,
|
||||
error_message %s NOT NULL,
|
||||
payload_sha256 %s NOT NULL DEFAULT '',
|
||||
created_at %s NOT NULL,
|
||||
finished_at %s NOT NULL DEFAULT ''
|
||||
)`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText),
|
||||
}
|
||||
}
|
||||
|
||||
type schemaIndex struct {
|
||||
name string
|
||||
table string
|
||||
columns string
|
||||
}
|
||||
|
||||
func schemaIndexes() []schemaIndex {
|
||||
return []schemaIndex{
|
||||
{name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"},
|
||||
{name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"},
|
||||
{name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"},
|
||||
{name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"},
|
||||
{name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"},
|
||||
{name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"},
|
||||
{name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"},
|
||||
{name: "idx_audit_logs_type", table: "audit_logs", columns: "type"},
|
||||
{name: "idx_audit_logs_target", table: "audit_logs", columns: "target"},
|
||||
{name: "idx_legacy_json_revisions_name", table: "legacy_json_revisions", columns: "name, id"},
|
||||
{name: "idx_release_notices_version", table: "release_notices", columns: "version"},
|
||||
{name: "idx_release_notice_revisions_version", table: "release_notice_revisions", columns: "version, id"},
|
||||
}
|
||||
}
|
||||
|
||||
func createSchemaIndexes(conn *sql.DB, d dialect) error {
|
||||
for _, index := range schemaIndexes() {
|
||||
if d.name == "mysql" {
|
||||
exists, err := mysqlIndexExists(conn, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := conn.Exec(d.rebind(createIndexStatement(d, index))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mysqlIndexExists(conn *sql.DB, index schemaIndex) (bool, error) {
|
||||
var count int
|
||||
err := conn.QueryRow(`SELECT COUNT(1)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND index_name = ?`, index.table, index.name).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func createIndexStatement(d dialect, index schemaIndex) string {
|
||||
if d.name == "mysql" {
|
||||
return fmt.Sprintf("CREATE INDEX %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||
}
|
||||
return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||
}
|
||||
|
||||
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
||||
columns := []string{"version", "applied_at", "description"}
|
||||
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
||||
CurrentSchemaVersion,
|
||||
Now(),
|
||||
"unified-management layered monolith baseline",
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package db
|
||||
|
||||
func (s *Store) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.queryRow("SELECT value FROM system_settings WHERE `key` = ?", sanitize(key)).Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertSetting(key, value string) error {
|
||||
columns := []string{"key", "value", "updated_at"}
|
||||
conn, d := s.active()
|
||||
_, err := conn.Exec(d.rebind(d.upsert("system_settings", columns, []string{"key"})), sanitize(key), value, Now())
|
||||
if err != nil {
|
||||
s.markFailover(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertSource(item Source) (Source, error) {
|
||||
now := Now()
|
||||
if item.SourceID == "" {
|
||||
item.SourceID = item.CategoryID + "-" + item.Name
|
||||
}
|
||||
if item.Method == "" {
|
||||
item.Method = "GET"
|
||||
}
|
||||
item.ProxyMode = normalizeProxyMode(firstNonEmpty(item.ProxyMode, "client_direct"), item.CategoryID, item.Name, item.APIURL)
|
||||
if item.URLTemplate == "" {
|
||||
item.URLTemplate = item.APIURL
|
||||
}
|
||||
if item.TimeoutMS <= 0 {
|
||||
item.TimeoutMS = 8000
|
||||
}
|
||||
if item.RetryCount <= 0 {
|
||||
item.RetryCount = 1
|
||||
}
|
||||
if item.CacheSeconds <= 0 {
|
||||
item.CacheSeconds = item.CheckIntervalSec
|
||||
}
|
||||
if item.CacheSeconds <= 0 {
|
||||
item.CacheSeconds = 300
|
||||
}
|
||||
if item.CheckIntervalSec <= 0 {
|
||||
item.CheckIntervalSec = item.CacheSeconds
|
||||
}
|
||||
if item.SupportedFormats == "" {
|
||||
item.SupportedFormats = "[]"
|
||||
}
|
||||
if item.LastStatus == "" {
|
||||
item.LastStatus = "unknown"
|
||||
}
|
||||
if item.CategoryID == "" {
|
||||
item.CategoryID = "custom"
|
||||
}
|
||||
if item.CategoryName == "" {
|
||||
item.CategoryName = item.CategoryID
|
||||
}
|
||||
_, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at)
|
||||
VALUES (?, ?, 1, '{}', ?, ?)
|
||||
ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`,
|
||||
item.CategoryID, item.CategoryName, now, now)
|
||||
conn, d := s.active()
|
||||
query := d.upsert("source_endpoints",
|
||||
[]string{"category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"},
|
||||
[]string{"source_id"})
|
||||
if _, err := conn.Exec(d.rebind(query), item.CategoryID, item.CategoryName, item.SourceID, item.Name, item.Description, item.Method, item.APIURL, item.URLTemplate, item.ThumbnailURL,
|
||||
item.ProxyMode, item.TimeoutMS, item.RetryCount, item.CacheSeconds, item.CheckIntervalSec, boolInt(item.Enabled), boolInt(item.ClientVisible), item.SupportedFormats,
|
||||
item.LastStatus, item.LastLatencyMS, item.LastCheckedAt, item.LastError, item.ConsecutiveFailure, firstNonEmpty(item.CreatedAt, now), now); err != nil {
|
||||
s.markFailover(err)
|
||||
return Source{}, err
|
||||
}
|
||||
return s.GetSourceBySourceID(item.SourceID)
|
||||
}
|
||||
|
||||
func (s *Store) GetSourceBySourceID(sourceID string) (Source, error) {
|
||||
item, err := scanSourceRow(s.queryRow(sourceSelectSQL()+` WHERE source_id = ?`, sourceID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Source{}, errors.New("source not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ListSources(includeHidden bool) ([]Source, error) {
|
||||
where := ""
|
||||
args := []any{}
|
||||
if !includeHidden {
|
||||
where = " WHERE enabled = 1 AND client_visible = 1"
|
||||
}
|
||||
rows, err := s.query(sourceSelectSQL()+where+` ORDER BY category_id ASC, name ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Source{}
|
||||
for rows.Next() {
|
||||
item, err := scanSourceRowsCurrent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CountSources() (int, error) {
|
||||
var count int
|
||||
err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSource(sourceID string) error {
|
||||
_, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, message string) error {
|
||||
now := Now()
|
||||
_, err := s.exec(`INSERT INTO endpoint_health_checks (source_db_id, status, latency_ms, error, checked_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
sourceDBID, status, latency, sanitize(message), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "ok" {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, sanitize(message), now, sourceDBID)
|
||||
} else if status == "redirected" {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = 0, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, sanitize(message), now, sourceDBID)
|
||||
} else {
|
||||
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = consecutive_failure + 1, updated_at = ? WHERE id = ?`,
|
||||
status, latency, now, sanitize(message), now, sourceDBID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecordSourceCall(call SourceCall) error {
|
||||
if call.CreatedAt == "" {
|
||||
call.CreatedAt = Now()
|
||||
}
|
||||
_, err := s.exec(`INSERT INTO endpoint_call_logs (source_id, status, latency_ms, error, client, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
sanitize(call.SourceID), sanitize(call.Status), call.LatencyMS, sanitize(call.Error), sanitize(call.Client), call.CreatedAt)
|
||||
return err
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -63,3 +65,329 @@ func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) {
|
||||
t.Fatalf("expected prototype backup, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = remote.Close()
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = dialectFor("sqlite")
|
||||
store.db = remote
|
||||
store.dialect = store.remoteDialect
|
||||
store.status.ActiveProvider = "mysql"
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("VerifyAdminPassword local priority failed, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
var description string
|
||||
if err := store.localDB.QueryRow(`SELECT description FROM schema_migrations WHERE version = ?`, CurrentSchemaVersion).Scan(&description); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if description == "" {
|
||||
t.Fatal("schema version description is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaIndexStatementsAreDialectAware(t *testing.T) {
|
||||
mysql := dialectFor("mysql")
|
||||
sqlite := dialectFor("sqlite")
|
||||
for _, statement := range schemaStatements(mysql) {
|
||||
if strings.Contains(strings.ToUpper(statement), "CREATE INDEX IF NOT EXISTS") {
|
||||
t.Fatalf("mysql schema statement contains unsupported index syntax: %s", statement)
|
||||
}
|
||||
}
|
||||
|
||||
index := schemaIndexes()[0]
|
||||
mysqlStatement := createIndexStatement(mysql, index)
|
||||
if strings.Contains(strings.ToUpper(mysqlStatement), "IF NOT EXISTS") {
|
||||
t.Fatalf("mysql index statement contains unsupported syntax: %s", mysqlStatement)
|
||||
}
|
||||
if !strings.Contains(createIndexStatement(sqlite, index), "IF NOT EXISTS") {
|
||||
t.Fatalf("sqlite index statement should remain idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCreatesSQLiteIndexesIdempotently(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
conn, err := sql.Open("sqlite", filepath.Join(root, "indexes.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := &Store{}
|
||||
d := dialectFor("sqlite")
|
||||
if err := store.migrate(conn, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.migrate(conn, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, "idx_feedback_tickets_activity").Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "idx_feedback_tickets_activity" {
|
||||
t.Fatalf("unexpected index name %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote-password.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = remote.Close()
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = dialectFor("sqlite")
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
warning, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", "new-local-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if warning == "" {
|
||||
t.Fatal("expected remote sync warning")
|
||||
}
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "new-local-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not persisted locally, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok {
|
||||
t.Fatalf("old password still works, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordAcceptsRemoteCurrentPasswordAndPersistsLocal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote, err := sql.Open("sqlite", filepath.Join(root, "remote-password.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteDialect := dialectFor("sqlite")
|
||||
if err := store.migrate(remote, remoteDialect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ensureDefaultAdminOn(remote, remoteDialect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.changeAdminPasswordOn(remote, remoteDialect, "admin", passwordHash("remote-current-password"), Now(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer remote.Close()
|
||||
|
||||
store.cfg.Database.Provider = "mysql"
|
||||
store.mu.Lock()
|
||||
store.remoteDB = remote
|
||||
store.remoteDialect = remoteDialect
|
||||
store.status.ConfigProvider = "mysql"
|
||||
store.mu.Unlock()
|
||||
|
||||
if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "remote-current-password", "merged-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := store.verifyAdminPasswordOn(store.localDB, store.localDialect, "admin", "merged-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not persisted to local sqlite, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.verifyAdminPasswordOn(remote, remoteDialect, "admin", "merged-password"); err != nil || !ok {
|
||||
t.Fatalf("new password was not synced to remote, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, next := range []string{"", "short", "admin"} {
|
||||
if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", next); err == nil {
|
||||
t.Fatalf("expected password %q to be rejected", next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLSchemaAvoidsTextKeys(t *testing.T) {
|
||||
statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n")
|
||||
for _, forbidden := range []string{
|
||||
"TEXT NOT NULL UNIQUE",
|
||||
"TEXT PRIMARY KEY",
|
||||
"TEXT NOT NULL PRIMARY KEY",
|
||||
"key VARCHAR(191) NOT NULL PRIMARY KEY",
|
||||
} {
|
||||
if strings.Contains(statements, forbidden) {
|
||||
t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") {
|
||||
t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "unified.sqlite")
|
||||
store, err := Open(&config.Config{
|
||||
StorageDir: root,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: path,
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
source, err := store.UpsertSource(Source{
|
||||
CategoryID: "video",
|
||||
CategoryName: "视频",
|
||||
SourceID: "video-demo",
|
||||
Name: "演示接口",
|
||||
APIURL: "https://example.com/video.json",
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.DeleteSource(source.SourceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
overview, err := store.DashboardOverview(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks, ok := overview["heartbeats"].([]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"])
|
||||
}
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("expected deleted source check to remain visible, got %d", len(checks))
|
||||
}
|
||||
if checks[0]["sourceId"] == "" || checks[0]["name"] == "" {
|
||||
t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package feedback
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
@@ -22,12 +23,30 @@ import (
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
feedbackmail "ymhut-box/server/unified-management/internal/mail"
|
||||
)
|
||||
|
||||
const PackageMagic = "YMHUTFB1"
|
||||
|
||||
const (
|
||||
ErrorTooLarge = "TOO_LARGE"
|
||||
ErrorMissingField = "MISSING_FIELD"
|
||||
ErrorInvalidPayload = "INVALID_PAYLOAD"
|
||||
ErrorInvalidTimestamp = "INVALID_TIMESTAMP"
|
||||
ErrorInvalidSignature = "INVALID_SIGNATURE"
|
||||
ErrorInvalidPackage = "INVALID_PACKAGE"
|
||||
ErrorInvalidEncryptedPackage = "INVALID_ENCRYPTED_PACKAGE"
|
||||
ErrorDecryptFailed = "DECRYPT_FAILED"
|
||||
ErrorHashMismatch = "HASH_MISMATCH"
|
||||
ErrorServerConfig = "SERVER_CONFIG"
|
||||
)
|
||||
|
||||
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
|
||||
|
||||
type requestContextKey string
|
||||
|
||||
const duplicateContextKey requestContextKey = "ymhut.feedback.duplicate"
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
@@ -61,14 +80,70 @@ func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||
|
||||
func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
var item db.Feedback
|
||||
var err error
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
if item, err := s.submitMultipart(r); err == nil {
|
||||
if item, err = s.submitMultipart(r); err == nil {
|
||||
if !DuplicateSubmission(r) && s.NotifyFeedback(item) == nil {
|
||||
item.MailSent = true
|
||||
}
|
||||
return item, nil
|
||||
} else if hasSignedFields(r) {
|
||||
} else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
}
|
||||
return s.submitSimple(r)
|
||||
item, err = s.submitSimple(r)
|
||||
if err == nil && s.NotifyFeedback(item) == nil {
|
||||
item.MailSent = true
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) RetryMail(code string) error {
|
||||
item, err := s.store.GetFeedback(NormalizeCode(code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.NotifyFeedback(item)
|
||||
}
|
||||
|
||||
func (s *Service) NotifyFeedback(item db.Feedback) error {
|
||||
message, err := feedbackmail.BuildFeedbackMessage(s.cfg, item)
|
||||
if err != nil {
|
||||
_, _ = s.store.InsertMailRecord(db.LegacyMailRecord{
|
||||
FeedbackCode: item.Code,
|
||||
Kind: "feedback",
|
||||
Status: "failed",
|
||||
Subject: "反馈邮件未发送",
|
||||
ErrorMessage: err.Error(),
|
||||
CreatedAt: db.Now(),
|
||||
})
|
||||
_ = s.store.UpdateFeedbackMailState(item.Code, false)
|
||||
return err
|
||||
}
|
||||
mailID, err := s.store.InsertMailRecord(db.LegacyMailRecord{
|
||||
FeedbackCode: item.Code,
|
||||
Kind: "feedback",
|
||||
Status: "pending",
|
||||
ToAddress: message.To,
|
||||
Subject: message.Subject,
|
||||
PlainBody: message.PlainBody,
|
||||
HTMLBody: message.HTMLBody,
|
||||
AttachmentPath: message.AttachmentPath,
|
||||
AttachmentName: message.AttachmentName,
|
||||
CreatedAt: db.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := feedbackmail.Send(s.cfg, message); err != nil {
|
||||
_ = s.store.UpdateMailState(mailID, "failed", err.Error())
|
||||
_ = s.store.UpdateFeedbackMailState(item.Code, false)
|
||||
return err
|
||||
}
|
||||
_ = s.store.UpdateMailState(mailID, "sent", "")
|
||||
_ = s.store.UpdateFeedbackMailState(item.Code, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) {
|
||||
@@ -151,6 +226,7 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||
code = db.NewFeedbackCode()
|
||||
}
|
||||
if existing, err := s.store.GetFeedback(code); err == nil {
|
||||
setDuplicateSubmission(r, true)
|
||||
return existing, nil
|
||||
}
|
||||
file, _, err := r.FormFile("package")
|
||||
@@ -197,9 +273,48 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
|
||||
return db.Feedback{}, err
|
||||
}
|
||||
item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr)
|
||||
setDuplicateSubmission(r, false)
|
||||
return item, s.store.InsertFeedback(item)
|
||||
}
|
||||
|
||||
func DuplicateSubmission(r *http.Request) bool {
|
||||
duplicate, _ := r.Context().Value(duplicateContextKey).(bool)
|
||||
return duplicate
|
||||
}
|
||||
|
||||
func setDuplicateSubmission(r *http.Request, duplicate bool) {
|
||||
*r = *r.WithContext(context.WithValue(r.Context(), duplicateContextKey, duplicate))
|
||||
}
|
||||
|
||||
func LegacyError(err error) (string, int) {
|
||||
if err == nil {
|
||||
return "", http.StatusOK
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(lower, "too large"):
|
||||
return ErrorTooLarge, http.StatusRequestEntityTooLarge
|
||||
case strings.Contains(lower, "signed multipart fields") || strings.Contains(lower, "missing package"):
|
||||
return ErrorMissingField, http.StatusBadRequest
|
||||
case strings.Contains(lower, "timestamp outside"):
|
||||
return ErrorInvalidTimestamp, http.StatusBadRequest
|
||||
case strings.Contains(lower, "invalid request signature"):
|
||||
return ErrorInvalidSignature, http.StatusUnauthorized
|
||||
case strings.Contains(lower, "hash mismatch") || strings.Contains(lower, "invalid package hash"):
|
||||
return ErrorHashMismatch, http.StatusBadRequest
|
||||
case strings.Contains(lower, "encrypted package format") || strings.Contains(lower, "encrypted package is required"):
|
||||
return ErrorInvalidEncryptedPackage, http.StatusBadRequest
|
||||
case strings.Contains(lower, "message authentication failed") || strings.Contains(lower, "decrypt"):
|
||||
return ErrorDecryptFailed, http.StatusBadRequest
|
||||
case strings.Contains(lower, "payload") || strings.Contains(lower, "json"):
|
||||
return ErrorInvalidPayload, http.StatusBadRequest
|
||||
case strings.Contains(lower, "zip") || strings.Contains(lower, "package"):
|
||||
return ErrorInvalidPackage, http.StatusBadRequest
|
||||
default:
|
||||
return ErrorServerConfig, http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func hasSignedFields(r *http.Request) bool {
|
||||
if r.MultipartForm == nil {
|
||||
return false
|
||||
|
||||
@@ -230,12 +230,12 @@ func validate(name string, parsed map[string]any) error {
|
||||
case "update-info":
|
||||
if _, ok := parsed["app_version"]; !ok {
|
||||
if _, ok := parsed["title"]; !ok {
|
||||
return errors.New("update-info requires app_version or title")
|
||||
return errors.New("更新 JSON 需要填写 app_version 或 title")
|
||||
}
|
||||
}
|
||||
case "media-types":
|
||||
if _, ok := parsed["categories"].([]any); !ok {
|
||||
return errors.New("media-types requires categories array")
|
||||
return errors.New("媒体源 JSON 需要包含 categories 数组")
|
||||
}
|
||||
if _, ok := parsed["layout_version"]; !ok {
|
||||
parsed["layout_version"] = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
From string
|
||||
FromName string
|
||||
To string
|
||||
Subject string
|
||||
PlainBody string
|
||||
HTMLBody string
|
||||
AttachmentPath string
|
||||
AttachmentName string
|
||||
}
|
||||
|
||||
func SafeConfig(cfg config.MailConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"host": cfg.Host,
|
||||
"port": cfg.Port,
|
||||
"secure": cfg.Secure,
|
||||
"username": cfg.Username,
|
||||
"fromAddress": cfg.FromAddress,
|
||||
"fromName": cfg.FromName,
|
||||
"developerAddress": cfg.DeveloperAddress,
|
||||
"timeoutSeconds": cfg.TimeoutSeconds,
|
||||
"hasPassword": strings.TrimSpace(cfg.Password) != "",
|
||||
"configured": IsConfigured(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func IsConfigured(cfg config.MailConfig) bool {
|
||||
channel := normalize(cfg)
|
||||
return channel.Host != "" && channel.FromAddress != "" && channel.DeveloperAddress != ""
|
||||
}
|
||||
|
||||
func BuildFeedbackMessage(cfg *config.Config, record db.Feedback) (Message, error) {
|
||||
channel, err := channel(cfg.Mail)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
attachment := record.PackagePath
|
||||
name := ""
|
||||
if attachment != "" {
|
||||
name = record.Code + ".zip"
|
||||
}
|
||||
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: subject,
|
||||
PlainBody: feedbackPlain(record),
|
||||
HTMLBody: feedbackHTML(record),
|
||||
AttachmentPath: attachment,
|
||||
AttachmentName: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildTestMessage(cfg *config.Config) (Message, error) {
|
||||
channel, err := channel(cfg.Mail)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return Message{
|
||||
From: channel.FromAddress,
|
||||
FromName: channel.FromName,
|
||||
To: channel.DeveloperAddress,
|
||||
Subject: "YMhut Box 反馈通知测试",
|
||||
PlainBody: "这是一封来自 unified-management 的测试通知。\n时间:" + now,
|
||||
HTMLBody: "<p>这是一封来自 unified-management 的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Send(cfg *config.Config, message Message) error {
|
||||
channel, err := channel(cfg.Mail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := BuildMIME(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return smtpSend(channel, message.From, message.To, raw)
|
||||
}
|
||||
|
||||
func BuildMIME(message Message) (string, error) {
|
||||
boundary := "ymhut_" + randomish()
|
||||
altBoundary := "ymhut_alt_" + randomish()
|
||||
headers := []string{
|
||||
"Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000",
|
||||
"From: " + mimeAddress(message.From, message.FromName),
|
||||
"To: " + message.To,
|
||||
"Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject),
|
||||
"MIME-Version: 1.0",
|
||||
`Content-Type: multipart/mixed; boundary="` + boundary + `"`,
|
||||
}
|
||||
body := []string{
|
||||
"--" + boundary,
|
||||
`Content-Type: multipart/alternative; boundary="` + altBoundary + `"`,
|
||||
"",
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.PlainBody)),
|
||||
"--" + altBoundary,
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
wrapBase64([]byte(message.HTMLBody)),
|
||||
"--" + altBoundary + "--",
|
||||
}
|
||||
if message.AttachmentPath != "" {
|
||||
data, err := os.ReadFile(message.AttachmentPath)
|
||||
if err == nil {
|
||||
name := firstNonEmpty(message.AttachmentName, filepath.Base(message.AttachmentPath))
|
||||
escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`)
|
||||
body = append(body,
|
||||
"--"+boundary,
|
||||
`Content-Type: application/zip; name="`+escaped+`"`,
|
||||
"Content-Transfer-Encoding: base64",
|
||||
`Content-Disposition: attachment; filename="`+escaped+`"`,
|
||||
"",
|
||||
wrapBase64(data),
|
||||
)
|
||||
}
|
||||
}
|
||||
body = append(body, "--"+boundary+"--")
|
||||
return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil
|
||||
}
|
||||
|
||||
func channel(cfg config.MailConfig) (config.MailConfig, error) {
|
||||
cfg = normalize(cfg)
|
||||
if cfg.Host == "" || cfg.FromAddress == "" || cfg.DeveloperAddress == "" {
|
||||
return cfg, errors.New("mail is not configured")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalize(cfg config.MailConfig) config.MailConfig {
|
||||
cfg.Secure = strings.ToLower(strings.TrimSpace(cfg.Secure))
|
||||
if cfg.Secure == "" {
|
||||
cfg.Secure = "ssl"
|
||||
}
|
||||
if cfg.Port <= 0 {
|
||||
cfg.Port = 465
|
||||
}
|
||||
if cfg.FromAddress == "" {
|
||||
cfg.FromAddress = cfg.Username
|
||||
}
|
||||
if cfg.FromName == "" {
|
||||
cfg.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
cfg.TimeoutSeconds = 20
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func smtpSend(channel config.MailConfig, from, to, rawMessage string) error {
|
||||
address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port))
|
||||
timeout := time.Duration(channel.TimeoutSeconds) * time.Second
|
||||
var client *smtp.Client
|
||||
if channel.Secure == "ssl" || channel.Secure == "tls" {
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host})
|
||||
if err != nil {
|
||||
return fmt.Errorf("邮件服务器连接失败:%w", err)
|
||||
}
|
||||
var clientErr error
|
||||
client, clientErr = smtp.NewClient(conn, channel.Host)
|
||||
if clientErr != nil {
|
||||
_ = conn.Close()
|
||||
return clientErr
|
||||
}
|
||||
} else {
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("邮件服务器连接失败:%w", err)
|
||||
}
|
||||
var clientErr error
|
||||
client, clientErr = smtp.NewClient(conn, channel.Host)
|
||||
if clientErr != nil {
|
||||
_ = conn.Close()
|
||||
return clientErr
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
if channel.Secure == "starttls" {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil {
|
||||
return fmt.Errorf("邮件加密握手失败:%w", err)
|
||||
}
|
||||
}
|
||||
if channel.Username != "" || channel.Password != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil {
|
||||
return fmt.Errorf("邮件认证失败:%w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(extractEmail(from)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(extractEmail(to)); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write([]byte(rawMessage)); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func feedbackPlain(record db.Feedback) string {
|
||||
return strings.Join([]string{
|
||||
"YMhut Box 反馈工单",
|
||||
"反馈编号:" + record.Code,
|
||||
"标题:" + record.Title,
|
||||
"类型:" + typeLabel(record.Type),
|
||||
"优先级:" + priorityLabel(record.Priority, record.Severity),
|
||||
"联系方式:" + record.Contact,
|
||||
"接收时间:" + record.CreatedAt,
|
||||
"包含文件:" + record.IncludedFiles,
|
||||
"反馈包 SHA256:" + record.PlainPackageSha256,
|
||||
"",
|
||||
"正文:",
|
||||
record.Body,
|
||||
"",
|
||||
"反馈包摘要:",
|
||||
record.SummaryText,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func feedbackHTML(record db.Feedback) string {
|
||||
rows := [][2]string{
|
||||
{"反馈编号", record.Code},
|
||||
{"标题", record.Title},
|
||||
{"类型", typeLabel(record.Type)},
|
||||
{"优先级", priorityLabel(record.Priority, record.Severity)},
|
||||
{"联系方式", record.Contact},
|
||||
{"接收时间", record.CreatedAt},
|
||||
{"包含文件", record.IncludedFiles},
|
||||
{"反馈包 SHA256", record.PlainPackageSha256},
|
||||
}
|
||||
html := `<h2>YMhut Box 反馈工单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
|
||||
for _, row := range rows {
|
||||
html += `<tr><th align="left">` + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
|
||||
}
|
||||
html += "</table>"
|
||||
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
|
||||
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
|
||||
return html
|
||||
}
|
||||
|
||||
func typeLabel(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "suggestion":
|
||||
return "建议"
|
||||
case "ui":
|
||||
return "界面反馈"
|
||||
case "other":
|
||||
return "其他"
|
||||
default:
|
||||
return "问题"
|
||||
}
|
||||
}
|
||||
|
||||
func priorityLabel(priority, severity string) string {
|
||||
value := firstNonEmpty(priority, severity)
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "urgent", "blocking":
|
||||
return "紧急"
|
||||
case "high", "major":
|
||||
return "高"
|
||||
case "low", "minor":
|
||||
return "低"
|
||||
default:
|
||||
return "普通"
|
||||
}
|
||||
}
|
||||
|
||||
func htmlEscape(value string) string {
|
||||
value = strings.ReplaceAll(value, "&", "&")
|
||||
value = strings.ReplaceAll(value, "<", "<")
|
||||
value = strings.ReplaceAll(value, ">", ">")
|
||||
value = strings.ReplaceAll(value, `"`, """)
|
||||
return strings.ReplaceAll(value, "'", "'")
|
||||
}
|
||||
|
||||
func mimeAddress(address, name string) string {
|
||||
if name == "" {
|
||||
return address
|
||||
}
|
||||
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
|
||||
}
|
||||
|
||||
func extractEmail(value string) string {
|
||||
re := regexp.MustCompile(`<([^>]+)>`)
|
||||
if match := re.FindStringSubmatch(value); len(match) == 2 {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func wrapBase64(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var builder strings.Builder
|
||||
for len(encoded) > 76 {
|
||||
builder.WriteString(encoded[:76])
|
||||
builder.WriteString("\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
builder.WriteString(encoded)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func randomish() string {
|
||||
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
|
||||
}
|
||||
|
||||
func truncate(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= max {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -139,6 +139,34 @@ func (s *Service) Save(ctx context.Context, version string, req SaveRequest, act
|
||||
return s.Get(saved.Version)
|
||||
}
|
||||
|
||||
func (s *Service) SyncFromLegacyUpdateInfo(ctx context.Context, raw string, actor string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
item, parsed, formatted, err := parseNotice([]byte(raw), "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.RawJSON = formatted
|
||||
current, err := s.store.GetReleaseNotice(item.Version)
|
||||
if err == nil && current.RawJSON != "" && current.RawJSON != formatted {
|
||||
_, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before legacy update-info sync", actor)
|
||||
}
|
||||
saved, err := s.store.UpsertReleaseNotice(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, "synced from update-info.json", actor)
|
||||
if err := s.writeNoticeFile(saved, formatted); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeTotalIndex(saved, parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.synced", Target: saved.Version, Message: "版本日志已从兼容 update-info.json 同步"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
|
||||
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
|
||||
if err != nil {
|
||||
@@ -227,10 +255,7 @@ func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any)
|
||||
|
||||
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &payload)
|
||||
}
|
||||
payload := s.legacyUpdateBase(path)
|
||||
payload["app_version"] = item.Version
|
||||
setNonEmpty(payload, "build", item.Build)
|
||||
setNonEmpty(payload, "channel", item.Channel)
|
||||
@@ -256,6 +281,36 @@ func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]
|
||||
return atomicWrite(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *Service) legacyUpdateBase(currentPath string) map[string]any {
|
||||
payload := map[string]any{}
|
||||
for _, path := range []string{
|
||||
filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"),
|
||||
currentPath,
|
||||
} {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
var doc map[string]any
|
||||
if json.Unmarshal(data, &doc) == nil {
|
||||
for key, value := range doc {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if payload["app_version"] == nil {
|
||||
if value, ok := payload["appVersion"]; ok {
|
||||
payload["app_version"] = value
|
||||
} else if value, ok := payload["latestVersion"]; ok {
|
||||
payload["app_version"] = value
|
||||
}
|
||||
}
|
||||
if payload["manifest_version"] == nil {
|
||||
if value, ok := payload["manifestVersion"]; ok {
|
||||
payload["manifest_version"] = value
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
|
||||
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
|
||||
return parsed, formatted, err
|
||||
@@ -270,7 +325,7 @@ func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNot
|
||||
}
|
||||
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
|
||||
if version == "" {
|
||||
return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required")
|
||||
return db.ReleaseNotice{}, nil, "", errors.New("版本日志需要填写 version 或 app_version")
|
||||
}
|
||||
if noticeFile == "" {
|
||||
noticeFile = version + ".json"
|
||||
|
||||
@@ -69,6 +69,56 @@ func TestSaveNoticeSyncsFilesAndLegacyUpdateInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFromLegacyUpdateInfoUpdatesNoticeIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
public := filepath.Join(root, "public")
|
||||
noticeDir := filepath.Join(root, "update-notice")
|
||||
if err := os.MkdirAll(public, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{"schema_version": 1, "versions": []any{}})
|
||||
|
||||
cfg := &config.Config{
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
UpdatePublicDir: public,
|
||||
UpdateNoticeDir: noticeDir,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
FailoverEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
ConnMaxLifetimeSeconds: 60,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
service := NewService(cfg, store)
|
||||
raw := `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览","download_url":"https://update.ymhut.cn/downloads/app.exe"}`
|
||||
if err := service.SyncFromLegacyUpdateInfo(context.Background(), raw, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.List(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Version != "2.0.7.5" || items[0].Title != "YMhut Box 2.0.7.5" {
|
||||
t.Fatalf("notice list not synced: %#v", items)
|
||||
}
|
||||
total := readJSONFile(t, filepath.Join(noticeDir, "total.json"))
|
||||
if total["latest_version"] != "2.0.7.5" {
|
||||
t.Fatalf("total index not updated: %#v", total)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, path string, payload any) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
@@ -61,7 +61,7 @@ func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.S
|
||||
}
|
||||
|
||||
func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
|
||||
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||||
payload := s.legacyUpdateBase()
|
||||
manifest := s.Manifest(r)
|
||||
for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} {
|
||||
if value, ok := manifest[key]; ok {
|
||||
@@ -72,7 +72,7 @@ func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
|
||||
}
|
||||
|
||||
func (s *Service) Manifest(r *http.Request) map[string]any {
|
||||
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||||
payload := s.legacyUpdateBase()
|
||||
packages := s.ScanPackages(r)
|
||||
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
|
||||
if modules == nil {
|
||||
@@ -116,6 +116,20 @@ func (s *Service) Manifest(r *http.Request) map[string]any {
|
||||
return payload
|
||||
}
|
||||
|
||||
func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error {
|
||||
payload := s.LegacyUpdateInfo(r)
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
if err := atomicWrite(path, append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setIfMissing(payload map[string]any, key, value string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
@@ -244,7 +258,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
|
||||
|
||||
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := readJSON(path)
|
||||
payload := s.legacyUpdateBase()
|
||||
payload["app_version"] = pkg.Version
|
||||
payload["download_url"] = pkg.URL
|
||||
payload["package_sha256"] = pkg.SHA256
|
||||
@@ -265,10 +279,32 @@ func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
return atomicWrite(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *Service) legacyUpdateBase() map[string]any {
|
||||
payload := map[string]any{}
|
||||
for _, path := range []string{
|
||||
filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"),
|
||||
filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"),
|
||||
} {
|
||||
for key, value := range readJSON(path) {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o640)
|
||||
if payload["app_version"] == nil {
|
||||
if value, ok := payload["appVersion"]; ok {
|
||||
payload["app_version"] = value
|
||||
} else if value, ok := payload["latestVersion"]; ok {
|
||||
payload["app_version"] = value
|
||||
}
|
||||
}
|
||||
if payload["manifest_version"] == nil {
|
||||
if value, ok := payload["manifestVersion"]; ok {
|
||||
payload["manifest_version"] = value
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func readJSON(path string) map[string]any {
|
||||
@@ -283,6 +319,30 @@ func readJSON(path string) map[string]any {
|
||||
return payload
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request, fallback string) string {
|
||||
if r != nil {
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
@@ -368,10 +428,11 @@ func sha256File(path string) string {
|
||||
}
|
||||
|
||||
func safePackageName(name string) (string, error) {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
||||
original := strings.TrimSpace(name)
|
||||
if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
name = filepath.Base(original)
|
||||
lower := strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
|
||||
@@ -49,8 +49,9 @@ func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
HealthIntervalSec: 30,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
@@ -88,8 +89,9 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
|
||||
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
HealthIntervalSec: 30,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,21 +20,58 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
cfg *config.Config
|
||||
store *db.Store
|
||||
client *http.Client
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
jobs map[string]CheckJob
|
||||
subscribers map[chan Event]struct{}
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type CheckJob struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
Total int `json:"total"`
|
||||
Checked int `json:"checked"`
|
||||
Stats map[string]int `json:"stats"`
|
||||
LastError string `json:"lastError"`
|
||||
}
|
||||
|
||||
type mediaResolution struct {
|
||||
URL string
|
||||
Key string
|
||||
MediaType string
|
||||
Direct bool
|
||||
}
|
||||
|
||||
type mediaCandidate struct {
|
||||
Resolution mediaResolution
|
||||
Score int
|
||||
Depth int
|
||||
Order int
|
||||
}
|
||||
|
||||
type legacyMedia struct {
|
||||
Categories []legacyCategory `json:"categories"`
|
||||
}
|
||||
|
||||
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
|
||||
|
||||
var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`)
|
||||
|
||||
type legacyCategory struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Subcategories []legacySubcategory `json:"subcategories"`
|
||||
}
|
||||
|
||||
@@ -43,15 +83,17 @@ type legacySubcategory struct {
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
RefreshInterval int `json:"refresh_interval"`
|
||||
SupportedFormats []string `json:"supported_formats"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
stop: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
stop: make(chan struct{}),
|
||||
jobs: map[string]CheckJob{},
|
||||
subscribers: map[chan Event]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +172,42 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSourceAndPublishCompatibility(ctx context.Context, sourceID, actor string) error {
|
||||
sourceID = strings.TrimSpace(sourceID)
|
||||
if sourceID == "" || strings.ContainsAny(sourceID, `/\`) || strings.Contains(sourceID, "..") {
|
||||
return errors.New("invalid source id")
|
||||
}
|
||||
if _, err := s.store.GetSourceBySourceID(sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.DeleteSource(sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.PublishLegacyMediaTypes(ctx, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "source.deleted", Target: sourceID, Message: "客户端接口已删除并同步兼容 media-types.json"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) PublishLegacyMediaTypes(ctx context.Context, actor string) error {
|
||||
catalog, err := s.Catalog(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(catalog, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formatted := append(data, '\n')
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "media-types.json")
|
||||
if err := atomicWrite(path, formatted); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.store.SaveLegacyRevision("media-types", string(formatted), "generated from source database", firstNonEmpty(actor, "system"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||
items, err := s.store.ListSources(includeHidden)
|
||||
if err != nil {
|
||||
@@ -150,19 +228,19 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||
var formats []string
|
||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||
sub := map[string]any{
|
||||
"id": item.SourceID,
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"api_url": item.APIURL,
|
||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"thumbnail_url": item.ThumbnailURL,
|
||||
"method": item.Method,
|
||||
"proxy_mode": item.ProxyMode,
|
||||
"proxyMode": item.ProxyMode,
|
||||
"refresh_interval": item.CheckIntervalSec,
|
||||
"cacheSeconds": item.CacheSeconds,
|
||||
"supported_formats": formats,
|
||||
"downloadable": true,
|
||||
"id": item.SourceID,
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"api_url": item.APIURL,
|
||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"thumbnail_url": item.ThumbnailURL,
|
||||
"method": item.Method,
|
||||
"proxy_mode": item.ProxyMode,
|
||||
"proxyMode": item.ProxyMode,
|
||||
"refresh_interval": item.CheckIntervalSec,
|
||||
"cacheSeconds": item.CacheSeconds,
|
||||
"supported_formats": formats,
|
||||
"downloadable": true,
|
||||
"health": map[string]any{
|
||||
"status": item.LastStatus,
|
||||
"latency_ms": item.LastLatencyMS,
|
||||
@@ -172,6 +250,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
}
|
||||
applyResolvedFields(sub, item.LastError)
|
||||
cat["subcategories"] = append(cat["subcategories"].([]map[string]any), sub)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
@@ -194,7 +273,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
||||
for _, item := range items {
|
||||
var formats []string
|
||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||
out = append(out, map[string]any{
|
||||
endpoint := map[string]any{
|
||||
"id": item.SourceID,
|
||||
"category": item.CategoryID,
|
||||
"name": item.Name,
|
||||
@@ -213,7 +292,9 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
||||
"consecutiveFailure": item.ConsecutiveFailure,
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
})
|
||||
}
|
||||
applyResolvedFields(endpoint, item.LastError)
|
||||
out = append(out, endpoint)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -237,21 +318,127 @@ func (s *Service) CheckDue(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) QueueCheckAll() CheckJob {
|
||||
items, err := s.store.ListSources(true)
|
||||
if err != nil {
|
||||
job := CheckJob{ID: newJobID(), Status: "failed", StartedAt: time.Now().UTC().Format(time.RFC3339), FinishedAt: time.Now().UTC().Format(time.RFC3339), Stats: map[string]int{}, LastError: err.Error()}
|
||||
s.saveJob(job)
|
||||
return job
|
||||
}
|
||||
filtered := make([]db.Source, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Enabled {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
job := CheckJob{ID: newJobID(), Status: "running", StartedAt: time.Now().UTC().Format(time.RFC3339), Total: len(filtered), Stats: map[string]int{"ok": 0, "redirected": 0, "degraded": 0, "error": 0}}
|
||||
s.saveJob(job)
|
||||
go s.runCheckJob(context.Background(), job.ID, filtered)
|
||||
return job
|
||||
}
|
||||
|
||||
func (s *Service) CheckJobs() []CheckJob {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]CheckJob, 0, len(s.jobs))
|
||||
for _, item := range s.jobs {
|
||||
items = append(items, item)
|
||||
}
|
||||
sortJobs(items)
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Service) CheckJob(id string) (CheckJob, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
item, ok := s.jobs[id]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
func (s *Service) SubscribeEvents() (<-chan Event, func()) {
|
||||
ch := make(chan Event, 16)
|
||||
s.mu.Lock()
|
||||
s.subscribers[ch] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
unsubscribe := func() {
|
||||
s.mu.Lock()
|
||||
if _, ok := s.subscribers[ch]; ok {
|
||||
delete(s.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
func (s *Service) runCheckJob(ctx context.Context, id string, items []db.Source) {
|
||||
if len(items) == 0 {
|
||||
s.updateJob(id, func(job *CheckJob) {
|
||||
job.Status = "completed"
|
||||
job.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
})
|
||||
s.emit("source_check.completed", map[string]any{"jobId": id})
|
||||
return
|
||||
}
|
||||
const concurrency = 4
|
||||
work := make(chan db.Source)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range work {
|
||||
status, err := s.CheckOneStatus(ctx, item)
|
||||
if err != nil && status == "" {
|
||||
status = "error"
|
||||
}
|
||||
s.updateJob(id, func(job *CheckJob) {
|
||||
job.Checked++
|
||||
if job.Stats == nil {
|
||||
job.Stats = map[string]int{}
|
||||
}
|
||||
job.Stats[status]++
|
||||
if err != nil {
|
||||
job.LastError = err.Error()
|
||||
}
|
||||
})
|
||||
s.emit("source_check.progress", map[string]any{"jobId": id, "sourceId": item.SourceID, "status": status})
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, item := range items {
|
||||
work <- item
|
||||
}
|
||||
close(work)
|
||||
wg.Wait()
|
||||
s.updateJob(id, func(job *CheckJob) {
|
||||
job.Status = "completed"
|
||||
job.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
})
|
||||
s.emit("source_check.completed", map[string]any{"jobId": id})
|
||||
}
|
||||
|
||||
func (s *Service) CheckSourceID(ctx context.Context, sourceID string) (db.Source, error) {
|
||||
item, err := s.store.GetSourceBySourceID(sourceID)
|
||||
if err != nil {
|
||||
return db.Source{}, err
|
||||
}
|
||||
return item, s.CheckOne(ctx, item)
|
||||
_, err = s.CheckOneStatus(ctx, item)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
_, err := s.CheckOneStatus(ctx, item)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, error) {
|
||||
if strings.TrimSpace(item.APIURL) == "" {
|
||||
return errors.New("source api_url is empty")
|
||||
return "error", errors.New("source api_url is empty")
|
||||
}
|
||||
timeout := time.Duration(item.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
if timeout <= 0 || timeout < 15*time.Second {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
@@ -262,7 +449,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, item.APIURL, nil)
|
||||
if err != nil {
|
||||
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
|
||||
return err
|
||||
return "error", err
|
||||
}
|
||||
redirects := []string{}
|
||||
client := *s.client
|
||||
@@ -282,7 +469,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
_ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error())
|
||||
return err
|
||||
return "error", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
status := "ok"
|
||||
@@ -306,7 +493,63 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
|
||||
"error": resp.Status,
|
||||
})
|
||||
}
|
||||
return s.store.RecordSourceCheck(item.ID, status, latency, message)
|
||||
if resp.StatusCode < 400 {
|
||||
meta := parseHealthMeta(message)
|
||||
meta["finalUrl"] = resp.Request.URL.String()
|
||||
meta["finalStatus"] = resp.StatusCode
|
||||
if resolution := resolveMediaFromResponse(resp); resolution.URL != "" {
|
||||
meta["resolvedUrl"] = resolution.URL
|
||||
meta["resolvedKey"] = resolution.Key
|
||||
meta["mediaType"] = resolution.MediaType
|
||||
meta["directMedia"] = resolution.Direct
|
||||
}
|
||||
message = healthMetaMessage(meta)
|
||||
}
|
||||
if err := s.store.RecordSourceCheck(item.ID, status, latency, message); err != nil {
|
||||
return status, err
|
||||
}
|
||||
s.emit("source_check.item", map[string]any{"sourceId": item.SourceID, "status": status, "latencyMs": latency})
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveJob(job CheckJob) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.jobs[job.ID] = job
|
||||
}
|
||||
|
||||
func (s *Service) updateJob(id string, mutate func(*CheckJob)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
job := s.jobs[id]
|
||||
mutate(&job)
|
||||
s.jobs[id] = job
|
||||
}
|
||||
|
||||
func (s *Service) emit(kind string, data map[string]any) {
|
||||
event := Event{Type: kind, Data: data}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for ch := range s.subscribers {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newJobID() string {
|
||||
return fmt.Sprintf("check-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func sortJobs(items []CheckJob) {
|
||||
for i := 0; i < len(items); i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
if items[j].StartedAt > items[i].StartedAt {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPURL(value *url.URL) bool {
|
||||
@@ -314,6 +557,259 @@ func isHTTPURL(value *url.URL) bool {
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
func resolveMediaFromResponse(resp *http.Response) mediaResolution {
|
||||
if resp == nil || resp.Request == nil || resp.Request.URL == nil {
|
||||
return mediaResolution{}
|
||||
}
|
||||
finalURL := resp.Request.URL
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]))
|
||||
if mediaType := mediaTypeFromContentType(contentType); mediaType != "" || looksLikeMediaURL(finalURL) {
|
||||
return mediaResolution{URL: finalURL.String(), Key: "response", MediaType: firstNonEmpty(mediaType, mediaTypeFromURL(finalURL)), Direct: true}
|
||||
}
|
||||
if !canProbeText(contentType, resp.ContentLength) {
|
||||
return mediaResolution{}
|
||||
}
|
||||
reader := io.LimitReader(resp.Body, maxSourceProbeBytes+1)
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil || int64(len(data)) > maxSourceProbeBytes {
|
||||
return mediaResolution{}
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return mediaResolution{}
|
||||
}
|
||||
var decoded any
|
||||
if json.Unmarshal(data, &decoded) == nil {
|
||||
if candidate, ok := bestJSONMediaCandidate(decoded, finalURL); ok {
|
||||
return candidate.Resolution
|
||||
}
|
||||
}
|
||||
if candidate, ok := bestTextMediaCandidate(text, finalURL); ok {
|
||||
return candidate.Resolution
|
||||
}
|
||||
return mediaResolution{}
|
||||
}
|
||||
|
||||
func canProbeText(contentType string, length int64) bool {
|
||||
if length > maxSourceProbeBytes {
|
||||
return false
|
||||
}
|
||||
if contentType == "" || strings.Contains(contentType, "json") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(contentType, "text/") ||
|
||||
strings.Contains(contentType, "javascript") ||
|
||||
strings.Contains(contentType, "xml") ||
|
||||
strings.Contains(contentType, "form")
|
||||
}
|
||||
|
||||
func bestJSONMediaCandidate(value any, base *url.URL) (mediaCandidate, bool) {
|
||||
candidates := []mediaCandidate{}
|
||||
order := 0
|
||||
collectJSONMediaCandidates(value, "", base, 0, &order, &candidates)
|
||||
return bestCandidate(candidates)
|
||||
}
|
||||
|
||||
func collectJSONMediaCandidates(value any, key string, base *url.URL, depth int, order *int, candidates *[]mediaCandidate) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for childKey, childValue := range typed {
|
||||
nextKey := childKey
|
||||
if key != "" {
|
||||
nextKey = key + "." + childKey
|
||||
}
|
||||
collectJSONMediaCandidates(childValue, nextKey, base, depth+1, order, candidates)
|
||||
}
|
||||
case []any:
|
||||
for _, childValue := range typed {
|
||||
collectJSONMediaCandidates(childValue, key, base, depth+1, order, candidates)
|
||||
}
|
||||
case string:
|
||||
*order = *order + 1
|
||||
if candidate, ok := candidateFromString(key, typed, base, depth, *order); ok {
|
||||
*candidates = append(*candidates, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bestTextMediaCandidate(text string, base *url.URL) (mediaCandidate, bool) {
|
||||
candidates := []mediaCandidate{}
|
||||
matches := absoluteURLPattern.FindAllString(text, 30)
|
||||
for index, match := range matches {
|
||||
if candidate, ok := candidateFromString("text", strings.TrimRight(match, ".,);]}'\""), base, 0, index+1); ok {
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
return bestCandidate(candidates)
|
||||
}
|
||||
|
||||
func candidateFromString(key, value string, base *url.URL, depth, order int) (mediaCandidate, bool) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return mediaCandidate{}, false
|
||||
}
|
||||
urls := []string{raw}
|
||||
if !strings.Contains(raw, "://") {
|
||||
urls = append(urls, absoluteURLPattern.FindAllString(raw, 10)...)
|
||||
}
|
||||
for _, candidate := range urls {
|
||||
resolved, ok := resolveCandidateURL(candidate, base)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mediaType := mediaTypeFromURL(resolved)
|
||||
if mediaType == "" {
|
||||
continue
|
||||
}
|
||||
keyScore := mediaKeyScore(key)
|
||||
score := 100 + keyScore - depth
|
||||
return mediaCandidate{
|
||||
Resolution: mediaResolution{
|
||||
URL: resolved.String(),
|
||||
Key: key,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
Score: score,
|
||||
Depth: depth,
|
||||
Order: order,
|
||||
}, true
|
||||
}
|
||||
return mediaCandidate{}, false
|
||||
}
|
||||
|
||||
func resolveCandidateURL(value string, base *url.URL) (*url.URL, bool) {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'`))
|
||||
if value == "" {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if !parsed.IsAbs() {
|
||||
if base == nil {
|
||||
return nil, false
|
||||
}
|
||||
parsed = base.ResolveReference(parsed)
|
||||
}
|
||||
if !isHTTPURL(parsed) {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func bestCandidate(candidates []mediaCandidate) (mediaCandidate, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return mediaCandidate{}, false
|
||||
}
|
||||
best := candidates[0]
|
||||
for _, candidate := range candidates[1:] {
|
||||
if candidate.Score > best.Score ||
|
||||
(candidate.Score == best.Score && candidate.Depth < best.Depth) ||
|
||||
(candidate.Score == best.Score && candidate.Depth == best.Depth && candidate.Order < best.Order) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
func mediaKeyScore(key string) int {
|
||||
last := key
|
||||
if index := strings.LastIndex(last, "."); index >= 0 {
|
||||
last = last[index+1:]
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(last))
|
||||
switch normalized {
|
||||
case "url", "src", "image", "img", "pic", "cover", "thumbnail", "video", "file", "media":
|
||||
return 80
|
||||
case "href", "poster", "preview", "download", "play", "audio":
|
||||
return 60
|
||||
}
|
||||
for _, token := range []string{"url", "src", "image", "img", "pic", "cover", "thumb", "video", "file", "media"} {
|
||||
if strings.Contains(normalized, token) {
|
||||
return 40
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func looksLikeMediaURL(value *url.URL) bool {
|
||||
return mediaTypeFromURL(value) != ""
|
||||
}
|
||||
|
||||
func mediaTypeFromURL(value *url.URL) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
extension := strings.ToLower(strings.TrimPrefix(filepath.Ext(value.Path), "."))
|
||||
switch extension {
|
||||
case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff":
|
||||
return "image"
|
||||
case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv":
|
||||
return "video"
|
||||
case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma":
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mediaTypeFromContentType(value string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(value, "video/") || value == "application/vnd.apple.mpegurl" || value == "application/x-mpegurl":
|
||||
return "video"
|
||||
case strings.HasPrefix(value, "audio/"):
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func applyResolvedFields(target map[string]any, message string) {
|
||||
meta := parseHealthMeta(message)
|
||||
resolvedURL, _ := meta["resolvedUrl"].(string)
|
||||
resolvedKey, _ := meta["resolvedKey"].(string)
|
||||
mediaType, _ := meta["mediaType"].(string)
|
||||
if strings.TrimSpace(resolvedURL) != "" {
|
||||
target["resolvedUrl"] = resolvedURL
|
||||
target["resolved_url"] = resolvedURL
|
||||
}
|
||||
if strings.TrimSpace(resolvedKey) != "" {
|
||||
target["resolvedKey"] = resolvedKey
|
||||
target["resolved_key"] = resolvedKey
|
||||
}
|
||||
if strings.TrimSpace(mediaType) != "" {
|
||||
target["mediaType"] = mediaType
|
||||
target["media_type"] = mediaType
|
||||
}
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func healthMetaMessage(meta map[string]any) string {
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,11 +2,13 @@ package sources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
@@ -57,6 +59,176 @@ func TestCheckOneTreatsRedirectToOKAsRedirected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueCheckAllUsesBackgroundContext(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
if _, err := store.UpsertSource(db.Source{
|
||||
CategoryID: "test",
|
||||
CategoryName: "Test",
|
||||
SourceID: "slow-ok",
|
||||
Name: "Slow OK",
|
||||
Method: "GET",
|
||||
APIURL: server.URL,
|
||||
TimeoutMS: 1000,
|
||||
CheckIntervalSec: 300,
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := service.QueueCheckAll()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
current, ok := service.CheckJob(job.ID)
|
||||
if ok && current.Status == "completed" {
|
||||
if current.Stats["ok"] != 1 {
|
||||
t.Fatalf("stats = %#v, want one ok", current.Stats)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job did not complete: %#v", job)
|
||||
}
|
||||
|
||||
func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) {
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
eventsA, unsubscribeA := service.SubscribeEvents()
|
||||
defer unsubscribeA()
|
||||
eventsB, unsubscribeB := service.SubscribeEvents()
|
||||
defer unsubscribeB()
|
||||
|
||||
service.emit("source_check.completed", map[string]any{"jobId": "demo"})
|
||||
|
||||
assertEvent := func(name string, events <-chan Event) {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "source_check.completed" || event.Data["jobId"] != "demo" {
|
||||
t.Fatalf("%s received unexpected event: %#v", name, event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("%s did not receive broadcast event", name)
|
||||
}
|
||||
}
|
||||
assertEvent("subscriber A", eventsA)
|
||||
assertEvent("subscriber B", eventsB)
|
||||
}
|
||||
|
||||
func TestCheckOneResolvesNestedJSONMediaURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"data": map[string]any{
|
||||
"ignored": "https://example.test/readme.txt",
|
||||
"items": []map[string]any{
|
||||
{"name": "first"},
|
||||
{"cover": "/media/poster.webp"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
item, err := store.UpsertSource(db.Source{
|
||||
CategoryID: "image",
|
||||
CategoryName: "Image",
|
||||
SourceID: "json-cover",
|
||||
Name: "JSON Cover",
|
||||
Method: "GET",
|
||||
APIURL: server.URL + "/api/random",
|
||||
TimeoutMS: 3000,
|
||||
CheckIntervalSec: 300,
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CheckOne(context.Background(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked, err := store.GetSourceBySourceID("json-cover")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := parseHealthMeta(checked.LastError)
|
||||
if meta["resolvedUrl"] != server.URL+"/media/poster.webp" {
|
||||
t.Fatalf("resolvedUrl = %#v, want relative media URL", meta["resolvedUrl"])
|
||||
}
|
||||
if meta["resolvedKey"] != "data.items.cover" {
|
||||
t.Fatalf("resolvedKey = %#v", meta["resolvedKey"])
|
||||
}
|
||||
if meta["mediaType"] != "image" {
|
||||
t.Fatalf("mediaType = %#v, want image", meta["mediaType"])
|
||||
}
|
||||
catalog, err := service.Catalog(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
categories := catalog["categories"].([]map[string]any)
|
||||
sub := categories[0]["subcategories"].([]map[string]any)[0]
|
||||
if sub["resolvedUrl"] != server.URL+"/media/poster.webp" {
|
||||
t.Fatalf("catalog resolvedUrl = %#v", sub["resolvedUrl"])
|
||||
}
|
||||
endpoints, err := service.Endpoints(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if endpoints[0]["resolvedUrl"] != server.URL+"/media/poster.webp" {
|
||||
t.Fatalf("endpoint resolvedUrl = %#v", endpoints[0]["resolvedUrl"])
|
||||
}
|
||||
if endpoints[0]["urlTemplate"] != server.URL+"/api/random" {
|
||||
t.Fatalf("urlTemplate changed: %#v", endpoints[0]["urlTemplate"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneResolvesTextMediaURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`play: https://cdn.example.test/video/sample.mp4`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg, store := testStore(t)
|
||||
service := NewService(cfg, store)
|
||||
item, err := store.UpsertSource(db.Source{
|
||||
CategoryID: "video",
|
||||
CategoryName: "Video",
|
||||
SourceID: "text-video",
|
||||
Name: "Text Video",
|
||||
Method: "GET",
|
||||
APIURL: server.URL,
|
||||
TimeoutMS: 3000,
|
||||
CheckIntervalSec: 300,
|
||||
Enabled: true,
|
||||
ClientVisible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.CheckOne(context.Background(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked, err := store.GetSourceBySourceID("text-video")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := parseHealthMeta(checked.LastError)
|
||||
if meta["resolvedUrl"] != "https://cdn.example.test/video/sample.mp4" {
|
||||
t.Fatalf("resolvedUrl = %#v", meta["resolvedUrl"])
|
||||
}
|
||||
if meta["mediaType"] != "video" {
|
||||
t.Fatalf("mediaType = %#v, want video", meta["mediaType"])
|
||||
}
|
||||
}
|
||||
|
||||
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -68,8 +240,9 @@ func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
HealthIntervalSec: 30,
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
|
||||
@@ -52,11 +52,11 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||
Ok: true,
|
||||
DryRun: dryRun,
|
||||
Paths: map[string]any{
|
||||
"legacyUpdateDir": s.cfg.LegacyUpdateDir,
|
||||
"legacyFeedbackDir": s.cfg.LegacyFeedbackDir,
|
||||
"legacyUpdateNoticeDir": s.cfg.LegacyUpdateNoticeDir,
|
||||
"updatePublicDir": s.cfg.UpdatePublicDir,
|
||||
"updateNoticeDir": s.cfg.UpdateNoticeDir,
|
||||
"legacyUpdateDir": s.displayPath(s.cfg.LegacyUpdateDir),
|
||||
"legacyFeedbackDir": s.displayPath(s.cfg.LegacyFeedbackDir),
|
||||
"legacyUpdateNoticeDir": s.displayPath(s.cfg.LegacyUpdateNoticeDir),
|
||||
"updatePublicDir": s.displayPath(s.cfg.UpdatePublicDir),
|
||||
"updateNoticeDir": s.displayPath(s.cfg.UpdateNoticeDir),
|
||||
},
|
||||
Stats: map[string]int{},
|
||||
Started: db.Now(),
|
||||
@@ -84,6 +84,17 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) displayPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
rel, err := filepath.Rel(s.cfg.BaseDir, path)
|
||||
if err != nil || rel == "" {
|
||||
return path
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func (s *Service) previewPath(result *Result, key, path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@@ -294,7 +305,7 @@ func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
|
||||
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
||||
result.Stats["importedRows"]++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method == http.MethodGet && path == "/api/admin/feedbacks" {
|
||||
if req.URL.Query().Get("page") != "" {
|
||||
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage"))
|
||||
items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{
|
||||
Status: req.URL.Query().Get("status"),
|
||||
Category: req.URL.Query().Get("category"),
|
||||
Priority: req.URL.Query().Get("priority"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
Assignee: req.URL.Query().Get("assignee"),
|
||||
Sort: req.URL.Query().Get("sort"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "page": map[string]any{"items": items, "total": total, "page": page, "perPage": perPage}})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(req.URL.Query().Get("limit"))
|
||||
items, err := r.store.ListFeedbacks(limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && path == "/api/admin/feedbacks/export" {
|
||||
items, _, err := r.store.ListFeedbacksFiltered(1, 100, db.FeedbackFilters{
|
||||
Status: req.URL.Query().Get("status"),
|
||||
Category: req.URL.Query().Get("category"),
|
||||
Priority: req.URL.Query().Get("priority"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "EXPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="feedbacks.csv"`)
|
||||
writer := csv.NewWriter(w)
|
||||
_ = writer.Write([]string{"code", "created_at", "title", "status", "category", "priority", "contact", "status_detail", "public_reply"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{item.Code, item.CreatedAt, item.Title, item.Status, item.Category, item.Priority, item.Contact, item.StatusDetail, item.PublicReply})
|
||||
}
|
||||
writer.Flush()
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||
detail, err := r.store.GetFeedbackDetail(code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": detail})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPatch && path == "/api/admin/feedbacks/bulk" {
|
||||
var body struct {
|
||||
Codes []string `json:"codes"`
|
||||
Status string `json:"status"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Assignee string `json:"assignee"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || len(body.Codes) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("codes are required"))
|
||||
return
|
||||
}
|
||||
if err := r.store.BulkUpdateFeedback(body.Codes, db.FeedbackUpdate{Status: body.Status, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Assignee: body.Assignee, Actor: "admin", Tags: body.Tags}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "BULK_UPDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": len(body.Codes)})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/comments") {
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/comments")
|
||||
var body struct {
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Internal bool `json:"internal"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
comment, err := r.store.InsertFeedbackComment(db.FeedbackComment{Code: code, Author: firstNonEmpty(body.Author, "admin"), Body: body.Body, Internal: body.Internal})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "COMMENT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/mail/retry") {
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/mail/retry")
|
||||
if err := r.feedback.RetryMail(code); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "MAIL_RETRY_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "feedback.mail.retry", Target: code, Message: "反馈邮件已重试发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), Priority: body.Priority, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/legacy"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
name := ""
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/admin/legacy/update-info"):
|
||||
name = "update-info"
|
||||
case strings.HasPrefix(path, "/api/admin/legacy/media-types"):
|
||||
name = "media-types"
|
||||
default:
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/admin/legacy/"), "/")
|
||||
if len(parts) > 0 {
|
||||
name = parts[0]
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||
doc, err := r.legacy.Get(req.Context(), name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_GET_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPut && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||
var body legacy.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Save(req.Context(), name, body, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
if name == "media-types" {
|
||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||
}
|
||||
if name == "update-info" {
|
||||
r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasSuffix(path, "/validate") {
|
||||
var body legacy.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Validate(req.Context(), name, body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_VALIDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasSuffix(path, "/restore") {
|
||||
var body struct {
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Restore(req.Context(), name, body.RevisionID, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_RESTORE_FAILED", err)
|
||||
return
|
||||
}
|
||||
if name == "media-types" {
|
||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||
}
|
||||
if name == "update-info" {
|
||||
r.syncNoticeFromLegacyUpdateInfo(req, doc.Raw)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func (r *router) syncNoticeFromLegacyUpdateInfo(req *http.Request, raw string) {
|
||||
if r.notices == nil {
|
||||
return
|
||||
}
|
||||
_ = r.notices.SyncFromLegacyUpdateInfo(req.Context(), raw, "admin")
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
"ymhut-box/server/unified-management/internal/releases"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if strings.HasPrefix(path, "/api/admin/releases/notices") {
|
||||
r.handleAdminReleaseNotices(w, req)
|
||||
return
|
||||
}
|
||||
switch path {
|
||||
case "/api/admin/releases/packages":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(256 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
|
||||
FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
|
||||
Version: req.FormValue("version"),
|
||||
Platform: req.FormValue("platform"),
|
||||
Arch: req.FormValue("arch"),
|
||||
Channel: req.FormValue("channel"),
|
||||
Notes: req.FormValue("notes"),
|
||||
UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
case "/api/admin/releases":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
||||
case "/api/admin/releases/legacy-preview":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updateInfo": r.releases.LegacyUpdateInfo(req), "toolStatus": r.releases.StaticJSON("tool-status.json")})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
|
||||
if r.notices == nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method == http.MethodPost && path == "/api/admin/releases/notices/import" {
|
||||
if err := r.notices.Import(req.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICE_IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
items, _ := r.notices.List(100)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && path == "/api/admin/releases/notices" {
|
||||
items, err := r.notices.List(100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICE_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(path, "/api/admin/releases/notices/")
|
||||
if rest == "" || rest == path {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
version := parts[0]
|
||||
if req.Method == http.MethodGet && len(parts) == 1 {
|
||||
doc, err := r.notices.Get(version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPut && len(parts) == 1 {
|
||||
var body notices.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Save(req.Context(), version, body, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "validate" {
|
||||
var body notices.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Validate(req.Context(), version, body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_VALIDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restore" {
|
||||
var body struct {
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Restore(req.Context(), version, body.RevisionID, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_RESTORE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sources":
|
||||
catalog, err := r.sources.Catalog(true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "catalog": catalog})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sources/import-media-types":
|
||||
if err := r.sources.ImportLegacyMediaTypes(req.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
||||
job := r.sources.QueueCheckAll()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
|
||||
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
|
||||
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
|
||||
if job, ok := r.sources.CheckJob(jobID); ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
|
||||
case req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/sources/") && strings.HasSuffix(path, "/check"):
|
||||
sourceID := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/sources/"), "/check")
|
||||
item, err := r.sources.CheckSourceID(req.Context(), sourceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "CHECK_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
||||
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
||||
var item db.Source
|
||||
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
saved, err := r.store.UpsertSource(item)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin")
|
||||
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "source.saved", Target: saved.SourceID, Message: "客户端接口已保存并同步兼容 media-types.json", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
|
||||
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
||||
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
|
||||
if err := r.sources.DeleteSourceAndPublishCompatibility(req.Context(), sourceID, "admin"); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.releases.PublishLegacyUpdateInfo(req, "admin")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
feedbackmail "ymhut-box/server/unified-management/internal/mail"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/config":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/test":
|
||||
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := db.TestDatabase(body); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, body)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/save":
|
||||
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := db.TestDatabase(body); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
next := *r.cfg
|
||||
next.Database = body
|
||||
if err := config.Save(&next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
r.cfg.Database = next.Database
|
||||
if err := r.store.ReconfigureDatabase(r.cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/sync":
|
||||
result, err := r.store.SyncNow()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_SYNC_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
type adminDatabaseRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
SQLitePath string `json:"sqlite_path"`
|
||||
SQLitePathAlt string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysql_dsn"`
|
||||
MySQLDSNAlt string `json:"mysqlDsn"`
|
||||
MySQLHost string `json:"mysql_host"`
|
||||
MySQLHostAlt string `json:"mysqlHost"`
|
||||
MySQLPort int `json:"mysql_port"`
|
||||
MySQLPortAlt int `json:"mysqlPort"`
|
||||
MySQLDatabase string `json:"mysql_database"`
|
||||
MySQLDBAlt string `json:"mysqlDatabase"`
|
||||
MySQLUser string `json:"mysql_user"`
|
||||
MySQLUserAlt string `json:"mysqlUser"`
|
||||
MySQLPassword string `json:"mysql_password"`
|
||||
MySQLPassAlt string `json:"mysqlPassword"`
|
||||
MySQL config.MySQLInput `json:"mysql"`
|
||||
}
|
||||
|
||||
func decodeAdminDatabaseConfig(req *http.Request, baseDir string, current config.DatabaseConfig, keepPassword bool) (config.DatabaseConfig, error) {
|
||||
var body adminDatabaseRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return config.DatabaseConfig{}, err
|
||||
}
|
||||
incoming := config.DatabaseConfig{
|
||||
Provider: body.Provider,
|
||||
SQLitePath: firstNonEmpty(body.SQLitePath, body.SQLitePathAlt),
|
||||
MySQLDSN: firstNonEmpty(body.MySQLDSN, body.MySQLDSNAlt),
|
||||
MySQLHost: firstNonEmpty(body.MySQLHost, body.MySQLHostAlt, body.MySQL.Host),
|
||||
MySQLPort: firstPositive(body.MySQLPort, body.MySQLPortAlt, body.MySQL.Port),
|
||||
MySQLDatabase: firstNonEmpty(body.MySQLDatabase, body.MySQLDBAlt, body.MySQL.Database),
|
||||
MySQLUser: firstNonEmpty(body.MySQLUser, body.MySQLUserAlt, body.MySQL.Username),
|
||||
MySQLPassword: firstNonEmpty(body.MySQLPassword, body.MySQLPassAlt, body.MySQL.Password),
|
||||
}
|
||||
return config.NormalizeDatabase(baseDir, current, incoming, keepPassword)
|
||||
}
|
||||
|
||||
func firstPositive(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
overview, err := r.store.DashboardOverview(80)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err)
|
||||
return
|
||||
}
|
||||
overview["health"] = health.Snapshot(r.cfg, r.store)
|
||||
writeJSON(w, http.StatusOK, overview)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) {
|
||||
if r.syncer == nil {
|
||||
writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured"))
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sync/legacy/preview":
|
||||
writeJSON(w, http.StatusOK, r.syncer.Preview(req.Context()))
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sync/legacy/run":
|
||||
writeJSON(w, http.StatusOK, r.syncer.Run(req.Context()))
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
items, err := r.sources.Endpoints(true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
}
|
||||
|
||||
func (r *router) handleAdminEvents(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
events, unsubscribe := r.sources.SubscribeEvents()
|
||||
defer unsubscribe()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeSSE(w, event.Type, event.Data)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)})
|
||||
flusher.Flush()
|
||||
case <-req.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch path {
|
||||
case "/api/admin/system/health":
|
||||
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
||||
case "/api/admin/system/audit":
|
||||
page, err := r.store.ListAuditLogsPage(db.AuditFilters{
|
||||
Page: queryInt(req, "page", 1),
|
||||
PerPage: queryInt(req, "perPage", 35),
|
||||
Type: req.URL.Query().Get("type"),
|
||||
Target: req.URL.Query().Get("target"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||
case "/api/admin/system/mail/config":
|
||||
r.handleMailConfig(w, req)
|
||||
case "/api/admin/system/mail/test":
|
||||
r.handleMailTest(w, req)
|
||||
case "/api/admin/system/branding":
|
||||
r.handleBranding(w, req)
|
||||
case "/api/admin/system/migration":
|
||||
r.handleMigrationStatus(w, req)
|
||||
case "/api/admin/system/database/sync":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result, "finishedAt": result.FinishedAt})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func queryInt(req *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(req.URL.Query().Get(key))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
status := r.store.Status()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"migration": map[string]any{
|
||||
"strategy": "database_first_with_file_assets",
|
||||
"databaseCovers": []string{
|
||||
"系统设置与品牌",
|
||||
"管理员与会话元数据",
|
||||
"反馈工单、附件元数据与邮件记录",
|
||||
"来源目录、客户端接口与健康记录",
|
||||
"发布元数据、版本公告与兼容 JSON 修订",
|
||||
"审计日志、旧项目同步记录与数据库同步状态",
|
||||
},
|
||||
"fileAssets": []map[string]string{
|
||||
{"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"},
|
||||
{"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"},
|
||||
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback"), "description": "反馈加密包和解密后的本地包"},
|
||||
},
|
||||
"sqlitePath": r.store.Path(),
|
||||
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
||||
"schemaVersion": status.SchemaVersion,
|
||||
"lastSyncAt": status.LastSyncAt,
|
||||
"lastSyncError": status.LastSyncError,
|
||||
"lastError": status.LastError,
|
||||
"failoverActive": status.FailoverActive,
|
||||
"remoteReady": status.RemoteReady,
|
||||
"activeProvider": status.ActiveProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
SiteIconURL string `json:"siteIconUrl"`
|
||||
SiteIconURLSnake string `json:"site_icon_url"`
|
||||
DeveloperAvatarURL string `json:"developerAvatarUrl"`
|
||||
DeveloperAvatarAlt string `json:"developer_avatar_url"`
|
||||
DeveloperName string `json:"developerName"`
|
||||
DeveloperNameSnake string `json:"developer_name"`
|
||||
FeedbackEmail string `json:"feedbackEmail"`
|
||||
FeedbackEmailSnake string `json:"feedback_email"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
next := config.BrandingConfig{
|
||||
SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake),
|
||||
DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt),
|
||||
DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake),
|
||||
FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake),
|
||||
}
|
||||
if err := r.saveBranding(next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "BRANDING_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.branding.saved", Target: r.cfg.Branding.DeveloperName, Message: "站点品牌信息已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleMailConfig(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
|
||||
case http.MethodPost:
|
||||
nextMail, err := decodeMailConfig(req, r.cfg.Mail)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
next := *r.cfg
|
||||
next.Mail = nextMail
|
||||
if err := config.Save(&next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "MAIL_CONFIG_FAILED", err)
|
||||
return
|
||||
}
|
||||
r.cfg.Mail = next.Mail
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.saved", Target: nextMail.Host, Message: "邮件通知配置已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleMailTest(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
message, err := feedbackmail.BuildTestMessage(r.cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "MAIL_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
if err := feedbackmail.Send(r.cfg, message); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "MAIL_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.test", Target: message.To, Message: "测试邮件已发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
type mailConfigRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Secure string `json:"secure"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromAddress string `json:"from_address"`
|
||||
FromAddressAlt string `json:"fromAddress"`
|
||||
FromName string `json:"from_name"`
|
||||
FromNameAlt string `json:"fromName"`
|
||||
DeveloperAddress string `json:"developer_address"`
|
||||
DeveloperAlt string `json:"developerAddress"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
TimeoutAlt int `json:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
func decodeMailConfig(req *http.Request, current config.MailConfig) (config.MailConfig, error) {
|
||||
var body mailConfigRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return config.MailConfig{}, err
|
||||
}
|
||||
next := current
|
||||
if body.Host != "" {
|
||||
next.Host = body.Host
|
||||
}
|
||||
if body.Port > 0 {
|
||||
next.Port = body.Port
|
||||
}
|
||||
if body.Secure != "" {
|
||||
next.Secure = body.Secure
|
||||
}
|
||||
if body.Username != "" {
|
||||
next.Username = body.Username
|
||||
}
|
||||
if body.Password != "" {
|
||||
next.Password = body.Password
|
||||
}
|
||||
if value := firstNonEmpty(body.FromAddress, body.FromAddressAlt); value != "" {
|
||||
next.FromAddress = value
|
||||
}
|
||||
if value := firstNonEmpty(body.FromName, body.FromNameAlt); value != "" {
|
||||
next.FromName = value
|
||||
}
|
||||
if value := firstNonEmpty(body.DeveloperAddress, body.DeveloperAlt); value != "" {
|
||||
next.DeveloperAddress = value
|
||||
}
|
||||
if timeout := firstPositive(body.TimeoutSeconds, body.TimeoutAlt); timeout > 0 {
|
||||
next.TimeoutSeconds = timeout
|
||||
}
|
||||
if next.Port <= 0 {
|
||||
next.Port = 465
|
||||
}
|
||||
if next.Secure == "" {
|
||||
next.Secure = "ssl"
|
||||
}
|
||||
if next.FromName == "" {
|
||||
next.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if next.FromAddress == "" {
|
||||
next.FromAddress = next.Username
|
||||
}
|
||||
if next.TimeoutSeconds <= 0 {
|
||||
next.TimeoutSeconds = 20
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
)
|
||||
|
||||
const brandingSettingKey = "branding"
|
||||
|
||||
func (r *router) effectiveBranding() config.BrandingConfig {
|
||||
branding := config.NormalizeBranding(config.BrandingConfig{}, r.cfg.Branding)
|
||||
if r.store == nil {
|
||||
return branding
|
||||
}
|
||||
raw, err := r.store.GetSetting(brandingSettingKey)
|
||||
if err != nil || raw == "" {
|
||||
return branding
|
||||
}
|
||||
var stored config.BrandingConfig
|
||||
if json.Unmarshal([]byte(raw), &stored) != nil {
|
||||
return branding
|
||||
}
|
||||
return config.NormalizeBranding(branding, stored)
|
||||
}
|
||||
|
||||
func (r *router) saveBranding(branding config.BrandingConfig) error {
|
||||
branding = config.NormalizeBranding(r.cfg.Branding, branding)
|
||||
next := *r.cfg
|
||||
next.Branding = branding
|
||||
next.Mail.DeveloperAddress = firstNonEmpty(next.Mail.DeveloperAddress, branding.FeedbackEmail)
|
||||
if err := config.Save(&next); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cfg.Branding = next.Branding
|
||||
r.cfg.Mail = next.Mail
|
||||
data, err := json.Marshal(r.cfg.Branding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.store.UpsertSetting(brandingSettingKey, string(data))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
)
|
||||
|
||||
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
|
||||
release := r.releases.Manifest(req)
|
||||
sourceCatalog, _ := r.sources.Catalog(false)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"serviceVersion": config.Version,
|
||||
"baseUrl": requestBaseURL(req, r.cfg.BaseURL),
|
||||
"capabilities": map[string]bool{
|
||||
"dynamicSources": true,
|
||||
"sourceHealth": true,
|
||||
"feedbackStatus": true,
|
||||
"releaseManifest": true,
|
||||
"endpointCalls": true,
|
||||
"legacyJson": true,
|
||||
},
|
||||
"endpoints": map[string]string{
|
||||
"releases": "/api/client/releases",
|
||||
"sources": "/api/client/sources",
|
||||
"clientEndpoints": "/api/client/endpoints",
|
||||
"endpointCalls": "/api/client/endpoint-calls",
|
||||
"notices": "/api/client/notices",
|
||||
"feedback": "/",
|
||||
},
|
||||
"cache": map[string]int{
|
||||
"bootstrapSeconds": 300,
|
||||
"releasesSeconds": 300,
|
||||
"sourcesSeconds": 600,
|
||||
"healthSeconds": 300,
|
||||
},
|
||||
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
|
||||
"release": release,
|
||||
"sources": sourceCatalog,
|
||||
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
|
||||
"branding": config.SafeBranding(r.effectiveBranding()),
|
||||
"health": health.Snapshot(r.cfg, r.store),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) {
|
||||
catalog, err := r.sources.Catalog(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalog)
|
||||
}
|
||||
|
||||
func (r *router) handleClientEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||
items, err := r.sources.Endpoints(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
}
|
||||
|
||||
func (r *router) handleClientNotices(w http.ResponseWriter, req *http.Request) {
|
||||
if r.notices == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": []any{}})
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
if path == "/api/client/notices" {
|
||||
items, err := r.notices.List(100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": notices.PublicList(items)})
|
||||
return
|
||||
}
|
||||
version := strings.TrimPrefix(path, "/api/client/notices/")
|
||||
if version == "" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Get(version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "notice": notices.PublicNotice(doc.Notice), "raw": doc.Parsed})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
type legacyFeedbackStatusDTO struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
HasReply bool `json:"hasReply"`
|
||||
Reply string `json:"reply"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
MailSent bool `json:"mailSent"`
|
||||
Duplicate bool `json:"duplicate,omitempty"`
|
||||
}
|
||||
|
||||
func legacyFeedbackStatus(item db.Feedback, duplicate bool) legacyFeedbackStatusDTO {
|
||||
reply := strings.TrimSpace(item.PublicReply)
|
||||
return legacyFeedbackStatusDTO{
|
||||
OK: true,
|
||||
Code: item.Code,
|
||||
Status: firstNonEmpty(item.Status, "new"),
|
||||
StatusLabel: feedbackStatusLabel(item.Status),
|
||||
StatusDetail: item.StatusDetail,
|
||||
Category: item.Category,
|
||||
Priority: item.Priority,
|
||||
HasReply: reply != "",
|
||||
Reply: reply,
|
||||
ReceivedAt: item.CreatedAt,
|
||||
UpdatedAt: firstNonEmpty(item.LastActivityAt, item.UpdatedAt, item.CreatedAt),
|
||||
MailSent: item.MailSent,
|
||||
Duplicate: duplicate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/feedback"
|
||||
)
|
||||
|
||||
func (r *router) handleLegacyMediaTypes(w http.ResponseWriter, req *http.Request) {
|
||||
catalog, err := r.sources.Catalog(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "MEDIA_TYPES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalog)
|
||||
}
|
||||
|
||||
func (r *router) handleSourceCall(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
var body db.SourceCall
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
body.Client = firstNonEmpty(body.Client, req.UserAgent())
|
||||
if err := r.store.RecordSourceCall(body); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_CALL_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request) {
|
||||
item, err := r.feedback.Submit(req)
|
||||
if err != nil {
|
||||
code, status := feedback.LegacyError(err)
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.rejected", Target: "feedback", Message: "旧反馈提交失败:" + localizedErrorMessage(code, err.Error()), IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeError(w, status, code, err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: "客户端提交反馈:" + item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, legacyFeedbackStatus(item, feedback.DuplicateSubmission(req)))
|
||||
}
|
||||
|
||||
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
|
||||
code := feedback.NormalizeCode(req.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_CODE", errors.New("code is required"))
|
||||
return
|
||||
}
|
||||
item, err := r.store.GetFeedback(code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, legacyFeedbackStatus(item, false))
|
||||
}
|
||||
|
||||
func feedbackStatusLabel(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "processing", "in_progress":
|
||||
return "处理中"
|
||||
case "closed", "resolved", "done":
|
||||
return "已关闭"
|
||||
case "rejected":
|
||||
return "已驳回"
|
||||
default:
|
||||
return "已接收"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func withSecurity(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event string, payload any) {
|
||||
data, _ := json.Marshal(payload)
|
||||
_, _ = w.Write([]byte("event: " + event + "\n"))
|
||||
_, _ = w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string, err error) {
|
||||
message := ""
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": localizedErrorMessage(code, message)})
|
||||
}
|
||||
|
||||
func localizedErrorMessage(code, message string) string {
|
||||
raw := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(raw)
|
||||
exact := map[string]string{
|
||||
"current password is invalid": "当前密码不正确",
|
||||
"new password is required": "新密码不能为空",
|
||||
"new password must be at least 8 characters": "新密码至少需要 8 位",
|
||||
"new password cannot be admin": "新密码不能为 admin",
|
||||
"new password must be different from current password": "新密码不能与当前密码相同",
|
||||
"invalid password or captcha": "密码或验证码不正确",
|
||||
"login required": "需要登录后继续操作",
|
||||
"csrf token required": "页面安全令牌已失效,请刷新后重试",
|
||||
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
|
||||
"code is required": "缺少反馈编号",
|
||||
"revisionid is required": "请选择要恢复的历史版本",
|
||||
"post required": "该操作需要使用 POST 请求",
|
||||
"get required": "该操作需要使用 GET 请求",
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到心跳检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
"source api_url is empty": "接口地址不能为空",
|
||||
"database is not available": "数据库当前不可用",
|
||||
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
|
||||
"mysql connection is required": "请填写 MySQL 连接信息",
|
||||
"mysql database is required": "请填写 MySQL 数据库名",
|
||||
"mysql username is required": "请填写 MySQL 数据库用户",
|
||||
"sqlite path is required": "请填写 SQLite 路径",
|
||||
"mysql_dsn is required": "请填写 MySQL DSN",
|
||||
"remote database is not configured": "远端 MySQL 未配置",
|
||||
"database sync is already running": "数据库同步正在执行,请稍后再试",
|
||||
"mail is not configured": "邮件通知尚未配置完整",
|
||||
"release notices are not configured": "版本日志功能尚未配置",
|
||||
"legacy sync service is not configured": "旧项目同步服务尚未配置",
|
||||
"update-info requires app_version or title": "更新 JSON 需要填写 app_version 或 title",
|
||||
"media-types requires categories array": "媒体源 JSON 需要包含 categories 数组",
|
||||
"version or app_version is required": "版本日志需要填写 version 或 app_version",
|
||||
}
|
||||
if translated, ok := exact[lower]; ok {
|
||||
return translated
|
||||
}
|
||||
byCode := map[string]string{
|
||||
"UNAUTHORIZED": "需要登录后继续操作",
|
||||
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
|
||||
"PASSWORD_CHANGE_FAILED": "密码修改失败",
|
||||
"INVALID_PAYLOAD": "提交内容格式不正确",
|
||||
"DATABASE_TEST_FAILED": "数据库连接测试失败",
|
||||
"DATABASE_SAVE_FAILED": "数据库配置保存失败",
|
||||
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
|
||||
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
|
||||
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
|
||||
"LEGACY_VALIDATE_FAILED": "兼容 JSON 校验失败",
|
||||
"LEGACY_RESTORE_FAILED": "兼容 JSON 恢复失败",
|
||||
"NOTICE_SAVE_FAILED": "版本日志保存失败",
|
||||
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
|
||||
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
|
||||
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
|
||||
"SOURCE_SAVE_FAILED": "接口源保存失败",
|
||||
"CHECK_FAILED": "接口健康检测失败",
|
||||
"SYNC_FAILED": "同步操作失败",
|
||||
"FORBIDDEN": "没有权限执行该操作",
|
||||
"METHOD_NOT_ALLOWED": "请求方法不正确",
|
||||
"FILE_REQUIRED": "请选择要上传的文件",
|
||||
"CHECK_JOB_NOT_FOUND": "未找到心跳检测任务",
|
||||
"SSE_UNSUPPORTED": "当前运行环境不支持实时事件流",
|
||||
"SOURCES_FAILED": "接口源数据加载失败",
|
||||
"ENDPOINTS_FAILED": "客户端接口数据加载失败",
|
||||
"DASHBOARD_FAILED": "仪表盘数据加载失败",
|
||||
"AUDIT_FAILED": "审计日志加载失败",
|
||||
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
|
||||
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
|
||||
"MAIL_CONFIG_FAILED": "邮件配置保存失败",
|
||||
"MAIL_TEST_FAILED": "测试邮件发送失败",
|
||||
"MAIL_RETRY_FAILED": "反馈邮件重试失败",
|
||||
"NOTICE_NOT_FOUND": "未找到版本日志",
|
||||
"NOTICES_FAILED": "版本日志加载失败",
|
||||
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
|
||||
"SOURCE_CALL_FAILED": "接口调用状态上报失败",
|
||||
"IMPORT_FAILED": "导入失败",
|
||||
"PATH_FAILED": "路径解析失败",
|
||||
"INVALID_UPLOAD": "上传内容不正确",
|
||||
"BOOTSTRAP_FAILED": "后台初始化信息加载失败",
|
||||
"CAPTCHA_FAILED": "验证码加载失败",
|
||||
"TOO_LARGE": "反馈包过大",
|
||||
"MISSING_FIELD": "缺少旧反馈提交字段",
|
||||
"INVALID_TIMESTAMP": "反馈提交时间已过期",
|
||||
"INVALID_SIGNATURE": "反馈签名校验失败",
|
||||
"INVALID_PACKAGE": "反馈包格式不正确",
|
||||
"INVALID_ENCRYPTED_PACKAGE": "反馈加密包格式不正确",
|
||||
"DECRYPT_FAILED": "反馈包解密失败",
|
||||
"HASH_MISMATCH": "反馈包哈希校验失败",
|
||||
"SERVER_CONFIG": "反馈服务配置异常",
|
||||
}
|
||||
if translated, ok := byCode[code]; ok {
|
||||
if raw == "" || strings.EqualFold(raw, code) {
|
||||
return translated
|
||||
}
|
||||
return translated + ":" + raw
|
||||
}
|
||||
if raw == "" {
|
||||
return "操作失败"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if path != "/" {
|
||||
path = strings.TrimRight(path, "/")
|
||||
}
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request, fallback string) string {
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
if r.Host != "" {
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
return strings.TrimRight(fallback, "/")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,29 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/auth"
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/feedback"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
"ymhut-box/server/unified-management/internal/legacy"
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
"ymhut-box/server/unified-management/internal/releases"
|
||||
"ymhut-box/server/unified-management/internal/sources"
|
||||
"ymhut-box/server/unified-management/internal/synclegacy"
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
type router struct {
|
||||
@@ -116,6 +107,8 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.auth.Require(http.HandlerFunc(r.handleAdminSources)).ServeHTTP(w, req)
|
||||
case strings.HasPrefix(path, "/api/admin/endpoints"):
|
||||
r.auth.Require(http.HandlerFunc(r.handleAdminEndpoints)).ServeHTTP(w, req)
|
||||
case path == "/api/admin/events":
|
||||
r.auth.Require(http.HandlerFunc(r.handleAdminEvents)).ServeHTTP(w, req)
|
||||
case strings.HasPrefix(path, "/api/admin/legacy"):
|
||||
r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req)
|
||||
case strings.HasPrefix(path, "/api/admin/database"):
|
||||
@@ -167,16 +160,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
if body.Username == "" {
|
||||
body.Username = "admin"
|
||||
}
|
||||
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha)
|
||||
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
||||
writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha"))
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(w, sessionID)
|
||||
auth.SetSessionCookieForRequest(w, req, sessionID)
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
|
||||
}
|
||||
@@ -195,814 +188,15 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := r.store.ChangeAdminPassword(req.Context(), "admin", body.CurrentPassword, body.NewPassword); err != nil {
|
||||
warning, err := r.store.ChangeAdminPasswordWithWarning(req.Context(), "admin", body.CurrentPassword, body.NewPassword)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
|
||||
release := r.releases.Manifest(req)
|
||||
sourceCatalog, _ := r.sources.Catalog(false)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"serviceVersion": config.Version,
|
||||
"baseUrl": requestBaseURL(req, r.cfg.BaseURL),
|
||||
"capabilities": map[string]bool{
|
||||
"dynamicSources": true,
|
||||
"sourceHealth": true,
|
||||
"feedbackStatus": true,
|
||||
"releaseManifest": true,
|
||||
"endpointCalls": true,
|
||||
"legacyJson": true,
|
||||
},
|
||||
"endpoints": map[string]string{
|
||||
"releases": "/api/client/releases",
|
||||
"sources": "/api/client/sources",
|
||||
"clientEndpoints": "/api/client/endpoints",
|
||||
"endpointCalls": "/api/client/endpoint-calls",
|
||||
"notices": "/api/client/notices",
|
||||
"feedback": "/",
|
||||
},
|
||||
"cache": map[string]int{
|
||||
"bootstrapSeconds": 300,
|
||||
"releasesSeconds": 300,
|
||||
"sourcesSeconds": 600,
|
||||
"healthSeconds": 300,
|
||||
},
|
||||
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
|
||||
"release": release,
|
||||
"sources": sourceCatalog,
|
||||
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
|
||||
"health": health.Snapshot(r.cfg, r.store),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) {
|
||||
catalog, err := r.sources.Catalog(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalog)
|
||||
}
|
||||
|
||||
func (r *router) handleClientEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||
items, err := r.sources.Endpoints(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
}
|
||||
|
||||
func (r *router) handleClientNotices(w http.ResponseWriter, req *http.Request) {
|
||||
if r.notices == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": []any{}})
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
if path == "/api/client/notices" {
|
||||
items, err := r.notices.List(100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": notices.PublicList(items)})
|
||||
return
|
||||
}
|
||||
version := strings.TrimPrefix(path, "/api/client/notices/")
|
||||
if version == "" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Get(version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "notice": notices.PublicNotice(doc.Notice), "raw": doc.Parsed})
|
||||
}
|
||||
|
||||
func (r *router) handleLegacyMediaTypes(w http.ResponseWriter, req *http.Request) {
|
||||
catalog, err := r.sources.Catalog(false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "MEDIA_TYPES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalog)
|
||||
}
|
||||
|
||||
func (r *router) handleSourceCall(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
var body db.SourceCall
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
body.Client = firstNonEmpty(body.Client, req.UserAgent())
|
||||
if err := r.store.RecordSourceCall(body); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_CALL_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request) {
|
||||
item, err := r.feedback.Submit(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FEEDBACK_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: "客户端提交反馈:" + item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "code": item.Code})
|
||||
}
|
||||
|
||||
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
|
||||
code := strings.TrimSpace(req.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_CODE", errors.New("code is required"))
|
||||
return
|
||||
}
|
||||
item, err := r.store.GetFeedback(code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": item})
|
||||
}
|
||||
|
||||
func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method == http.MethodGet && path == "/api/admin/feedbacks" {
|
||||
if req.URL.Query().Get("page") != "" {
|
||||
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage"))
|
||||
items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{
|
||||
Status: req.URL.Query().Get("status"),
|
||||
Category: req.URL.Query().Get("category"),
|
||||
Priority: req.URL.Query().Get("priority"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
Assignee: req.URL.Query().Get("assignee"),
|
||||
Sort: req.URL.Query().Get("sort"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "page": map[string]any{"items": items, "total": total, "page": page, "perPage": perPage}})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(req.URL.Query().Get("limit"))
|
||||
items, err := r.store.ListFeedbacks(limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && path == "/api/admin/feedbacks/export" {
|
||||
items, _, err := r.store.ListFeedbacksFiltered(1, 100, db.FeedbackFilters{
|
||||
Status: req.URL.Query().Get("status"),
|
||||
Category: req.URL.Query().Get("category"),
|
||||
Priority: req.URL.Query().Get("priority"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "EXPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="feedbacks.csv"`)
|
||||
writer := csv.NewWriter(w)
|
||||
_ = writer.Write([]string{"code", "created_at", "title", "status", "category", "priority", "contact", "status_detail", "public_reply"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{item.Code, item.CreatedAt, item.Title, item.Status, item.Category, item.Priority, item.Contact, item.StatusDetail, item.PublicReply})
|
||||
}
|
||||
writer.Flush()
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||
detail, err := r.store.GetFeedbackDetail(code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": detail})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPatch && path == "/api/admin/feedbacks/bulk" {
|
||||
var body struct {
|
||||
Codes []string `json:"codes"`
|
||||
Status string `json:"status"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
Assignee string `json:"assignee"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || len(body.Codes) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("codes are required"))
|
||||
return
|
||||
}
|
||||
if err := r.store.BulkUpdateFeedback(body.Codes, db.FeedbackUpdate{Status: body.Status, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Assignee: body.Assignee, Actor: "admin", Tags: body.Tags}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "BULK_UPDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": len(body.Codes)})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/comments") {
|
||||
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/comments")
|
||||
var body struct {
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
Internal bool `json:"internal"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
comment, err := r.store.InsertFeedbackComment(db.FeedbackComment{Code: code, Author: firstNonEmpty(body.Author, "admin"), Body: body.Body, Internal: body.Internal})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "COMMENT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
|
||||
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
PublicReply string `json:"publicReply"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
name := ""
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/admin/legacy/update-info"):
|
||||
name = "update-info"
|
||||
case strings.HasPrefix(path, "/api/admin/legacy/media-types"):
|
||||
name = "media-types"
|
||||
default:
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/admin/legacy/"), "/")
|
||||
if len(parts) > 0 {
|
||||
name = parts[0]
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||
doc, err := r.legacy.Get(req.Context(), name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_GET_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPut && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
|
||||
var body legacy.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Save(req.Context(), name, body, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
if name == "media-types" {
|
||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasSuffix(path, "/validate") {
|
||||
var body legacy.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Validate(req.Context(), name, body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_VALIDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && strings.HasSuffix(path, "/restore") {
|
||||
var body struct {
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||
return
|
||||
}
|
||||
doc, err := r.legacy.Restore(req.Context(), name, body.RevisionID, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "LEGACY_RESTORE_FAILED", err)
|
||||
return
|
||||
}
|
||||
if name == "media-types" {
|
||||
_ = r.sources.ImportLegacyMediaTypes(req.Context())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/test":
|
||||
var body config.DatabaseConfig
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if body.Provider == "" {
|
||||
body.Provider = r.cfg.Database.Provider
|
||||
}
|
||||
if body.SQLitePath == "" {
|
||||
body.SQLitePath = r.cfg.Database.SQLitePath
|
||||
}
|
||||
if body.MySQLDSN == "" {
|
||||
body.MySQLDSN = r.cfg.Database.MySQLDSN
|
||||
}
|
||||
if err := db.TestDatabase(body); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/sync":
|
||||
result, err := r.store.SyncNow()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_SYNC_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
overview, err := r.store.DashboardOverview(80)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err)
|
||||
return
|
||||
}
|
||||
overview["health"] = health.Snapshot(r.cfg, r.store)
|
||||
writeJSON(w, http.StatusOK, overview)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) {
|
||||
if r.syncer == nil {
|
||||
writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured"))
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sync/legacy/preview":
|
||||
writeJSON(w, http.StatusOK, r.syncer.Preview(req.Context()))
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sync/legacy/run":
|
||||
writeJSON(w, http.StatusOK, r.syncer.Run(req.Context()))
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminEndpoints(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
items, err := r.sources.Endpoints(true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
}
|
||||
|
||||
func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if strings.HasPrefix(path, "/api/admin/releases/notices") {
|
||||
r.handleAdminReleaseNotices(w, req)
|
||||
return
|
||||
}
|
||||
switch path {
|
||||
case "/api/admin/releases/packages":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(256 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
|
||||
FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
|
||||
Version: req.FormValue("version"),
|
||||
Platform: req.FormValue("platform"),
|
||||
Arch: req.FormValue("arch"),
|
||||
Channel: req.FormValue("channel"),
|
||||
Notes: req.FormValue("notes"),
|
||||
UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
case "/api/admin/releases":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
||||
case "/api/admin/releases/legacy-preview":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updateInfo": r.releases.LegacyUpdateInfo(req), "toolStatus": r.releases.StaticJSON("tool-status.json")})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
|
||||
if r.notices == nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
|
||||
return
|
||||
}
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method == http.MethodPost && path == "/api/admin/releases/notices/import" {
|
||||
if err := r.notices.Import(req.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICE_IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
items, _ := r.notices.List(100)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet && path == "/api/admin/releases/notices" {
|
||||
items, err := r.notices.List(100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "NOTICE_LIST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(path, "/api/admin/releases/notices/")
|
||||
if rest == "" || rest == path {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
version := parts[0]
|
||||
if req.Method == http.MethodGet && len(parts) == 1 {
|
||||
doc, err := r.notices.Get(version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPut && len(parts) == 1 {
|
||||
var body notices.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Save(req.Context(), version, body, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "validate" {
|
||||
var body notices.SaveRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Validate(req.Context(), version, body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_VALIDATE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restore" {
|
||||
var body struct {
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
||||
return
|
||||
}
|
||||
doc, err := r.notices.Restore(req.Context(), version, body.RevisionID, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "NOTICE_RESTORE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/sources":
|
||||
catalog, err := r.sources.Catalog(true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "catalog": catalog})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sources/import-media-types":
|
||||
if err := r.sources.ImportLegacyMediaTypes(req.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
||||
go r.sources.CheckDue(req.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true})
|
||||
case req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/sources/") && strings.HasSuffix(path, "/check"):
|
||||
sourceID := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/sources/"), "/check")
|
||||
item, err := r.sources.CheckSourceID(req.Context(), sourceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "CHECK_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
||||
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
||||
var item db.Source
|
||||
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
saved, err := r.store.UpsertSource(item)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
|
||||
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
|
||||
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
|
||||
if err := r.store.DeleteSource(sourceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch path {
|
||||
case "/api/admin/system/health":
|
||||
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
||||
case "/api/admin/system/audit":
|
||||
items, err := r.store.ListAuditLogs(100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
case "/api/admin/system/database/sync":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
finishedAt, err := r.store.CopySQLiteToRemote()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "finishedAt": finishedAt})
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleDownload(w http.ResponseWriter, req *http.Request) {
|
||||
name := strings.TrimPrefix(cleanPath(req.URL.Path), "/downloads/")
|
||||
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid filename"))
|
||||
return
|
||||
}
|
||||
path := filepath.Join(r.cfg.DownloadsDir, name)
|
||||
resolved, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||
return
|
||||
}
|
||||
base, _ := filepath.Abs(r.cfg.DownloadsDir)
|
||||
if !strings.HasPrefix(resolved, base) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, resolved)
|
||||
}
|
||||
|
||||
func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot, assetPath string) {
|
||||
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
||||
return
|
||||
}
|
||||
if tryServeDiskFile(w, req, root, assetPath) {
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
||||
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||
resolved, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||
return true
|
||||
}
|
||||
base, _ := filepath.Abs(root)
|
||||
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(w, req, resolved)
|
||||
return true
|
||||
}
|
||||
|
||||
func serveEmbeddedFile(w http.ResponseWriter, req *http.Request, name string) bool {
|
||||
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid embedded asset path"))
|
||||
return true
|
||||
}
|
||||
data, err := webassets.ReadFile(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
|
||||
index := filepath.Join(r.cfg.PortalWebDir, "index.html")
|
||||
if _, err := os.Stat(index); err == nil {
|
||||
http.ServeFile(w, req, index)
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "portal/dist/index.html") {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
|
||||
}
|
||||
|
||||
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
||||
if _, err := os.Stat(index); err == nil {
|
||||
http.ServeFile(w, req, index)
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
|
||||
}
|
||||
|
||||
func isPortalRoute(path string) bool {
|
||||
switch path {
|
||||
case "/", "/releases", "/sources", "/feedback", "/compatibility":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func withSecurity(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string, err error) {
|
||||
message := ""
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if path != "/" {
|
||||
path = strings.TrimRight(path, "/")
|
||||
}
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request, fallback string) string {
|
||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
if r.Host != "" {
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
return strings.TrimRight(fallback, "/")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
payload := map[string]any{"ok": true, "isDefaultPassword": false}
|
||||
if warning != "" {
|
||||
payload["warning"] = warning
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/auth"
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
@@ -25,7 +38,7 @@ func TestCompatibilityRoutes(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/media-types.json", "/modules.json"} {
|
||||
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/update-info", "/tool-status.json", "/tool-status", "/media-types.json", "/media-types", "/modules.json", "/modules", "/api/modules"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
@@ -39,6 +52,63 @@ func TestCompatibilityRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPublicContractFields(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
cases := []struct {
|
||||
Path string
|
||||
RequiredKeys []string
|
||||
}{
|
||||
{Path: "/update-info.json", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
|
||||
{Path: "/update-info", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
|
||||
{Path: "/tool-status.json", RequiredKeys: []string{"ok"}},
|
||||
{Path: "/tool-status", RequiredKeys: []string{"ok"}},
|
||||
{Path: "/modules.json", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/modules", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/api/modules", RequiredKeys: []string{"modules"}},
|
||||
{Path: "/media-types.json", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
|
||||
{Path: "/media-types", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.Path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", tc.Path, res.Code, res.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("%s did not return JSON: %v", tc.Path, err)
|
||||
}
|
||||
assertJSONKeys(t, tc.Path, payload, tc.RequiredKeys)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/downloads/fixture.txt", nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("download returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(res.Body.String()) != "download fixture" {
|
||||
t.Fatalf("unexpected download body: %q", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRejectsPathEscape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/downloads/../update-info.json", "/downloads/%2e%2e/update-info.json", "/downloads/foo\\bar.exe"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden && res.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s returned %d, want forbidden or not found: %s", path, res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -60,12 +130,298 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if path == "/api/client/bootstrap" {
|
||||
branding, ok := payload["branding"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("bootstrap missing branding: %#v", payload)
|
||||
}
|
||||
if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" {
|
||||
t.Fatalf("unexpected branding defaults: %#v", branding)
|
||||
}
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Fatalf("%s missing ok=true: %#v", path, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/admin/sources/demo", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("delete source returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
|
||||
mediaReq := httptest.NewRequest(http.MethodGet, "/media-types.json", nil)
|
||||
mediaRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(mediaRes, mediaReq)
|
||||
if mediaRes.Code != http.StatusOK {
|
||||
t.Fatalf("media-types returned %d: %s", mediaRes.Code, mediaRes.Body.String())
|
||||
}
|
||||
if strings.Contains(mediaRes.Body.String(), `"demo"`) {
|
||||
t.Fatalf("deleted source leaked into media-types.json: %s", mediaRes.Body.String())
|
||||
}
|
||||
|
||||
updateReq := httptest.NewRequest(http.MethodGet, "/update-info.json", nil)
|
||||
updateRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(updateRes, updateReq)
|
||||
if updateRes.Code != http.StatusOK {
|
||||
t.Fatalf("update-info returned %d: %s", updateRes.Code, updateRes.Body.String())
|
||||
}
|
||||
var updatePayload map[string]any
|
||||
if err := json.Unmarshal(updateRes.Body.Bytes(), &updatePayload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONKeys(t, "update-info after source delete", updatePayload, []string{"app_version", "manifest_version", "packages", "modules"})
|
||||
}
|
||||
|
||||
func TestAdminAuditPaginationAndBranding(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 40; i++ {
|
||||
body := strings.NewReader(`{"developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("audit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Items []any `json:"items"`
|
||||
Page struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
} `json:"page"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Page.Page != 1 || payload.Page.PerPage != 35 {
|
||||
t.Fatalf("unexpected audit page metadata: %#v", payload.Page)
|
||||
}
|
||||
if payload.Page.Total < 40 {
|
||||
t.Fatalf("expected at least 40 branding audit records, got %d", payload.Page.Total)
|
||||
}
|
||||
if len(payload.Items) > 35 {
|
||||
t.Fatalf("expected at most 35 audit items, got %d", len(payload.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackStatusDTOContract(t *testing.T) {
|
||||
payload := legacyFeedbackStatus(db.Feedback{
|
||||
Code: "FB-20260626-ABCDEF",
|
||||
Status: "processing",
|
||||
StatusDetail: "公开进度",
|
||||
Category: "issue",
|
||||
Priority: "normal",
|
||||
PublicReply: "公开回复",
|
||||
Note: "内部备注",
|
||||
Assignee: "owner",
|
||||
HandledBy: "admin",
|
||||
Attachment: "private.zip",
|
||||
PackagePath: "storage/feedback/private.zip",
|
||||
EncryptedPackagePath: "storage/feedback/private.ymfb",
|
||||
MailSent: true,
|
||||
CreatedAt: "2026-06-26T00:00:00Z",
|
||||
UpdatedAt: "2026-06-26T00:10:00Z",
|
||||
LastActivityAt: "2026-06-26T00:20:00Z",
|
||||
}, true)
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONKeys(t, "legacy feedback status", out, []string{"ok", "code", "status", "statusLabel", "statusDetail", "category", "priority", "hasReply", "reply", "receivedAt", "updatedAt", "mailSent", "duplicate"})
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "legacyEvents", "mailRecords", "path", "attachment", "packagePath", "encryptedPackagePath", "packageSha256", "plainPackageSha256"} {
|
||||
if _, ok := out[privateKey]; ok {
|
||||
t.Fatalf("legacy DTO leaked private key %q: %#v", privateKey, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackPublicStatusShape(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"title":"旧版反馈","type":"issue","severity":"normal","body":"客户端反馈内容"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var submitted map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ := submitted["code"].(string)
|
||||
if code == "" || submitted["statusLabel"] == nil || submitted["feedback"] != nil {
|
||||
t.Fatalf("unexpected submit payload: %#v", submitted)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/?api=status&code="+code, nil)
|
||||
res = httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["code"] != code || status["statusLabel"] == nil || status["feedback"] != nil {
|
||||
t.Fatalf("unexpected status payload: %#v", status)
|
||||
}
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachment", "attachments", "path", "packagePath", "encryptedPackagePath", "events", "legacyEvents", "mailRecords", "packageSha256", "plainPackageSha256"} {
|
||||
if _, ok := status[privateKey]; ok {
|
||||
t.Fatalf("status leaked private key %q: %#v", privateKey, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackMultipartFallback(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("subject", "Multipart legacy feedback")
|
||||
_ = writer.WriteField("category", "issue")
|
||||
_ = writer.WriteField("priority", "normal")
|
||||
_ = writer.WriteField("email", "user@example.com")
|
||||
_ = writer.WriteField("message", "Submitted by an old multipart client.")
|
||||
if part, err := writer.CreateFormFile("ignored", "note.txt"); err == nil {
|
||||
_, _ = io.WriteString(part, "not signed, should fall back")
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("multipart submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["code"] == "" || payload["feedback"] != nil {
|
||||
t.Fatalf("unexpected multipart payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyFeedbackSignedEncryptedMultipartRoute(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
plain := routeZipBytes(t, map[string]string{
|
||||
"feedback.json": `{"request":{"title":"Signed route feedback","type":"issue","severity":"major","contact":"user@example.com","body":"Signed package body."}}`,
|
||||
"summary.txt": "signed route summary",
|
||||
})
|
||||
encrypted := routeEncryptPackage(t, plain, "ymhut-box-feedback-package-v1")
|
||||
encryptedHash := routeSHA256Hex(encrypted)
|
||||
plainHash := routeSHA256Hex(plain)
|
||||
payloadData, err := json.Marshal(map[string]any{
|
||||
"feedbackCode": "FB-20260626-ABC123",
|
||||
"title": "Signed route feedback",
|
||||
"type": "issue",
|
||||
"severity": "major",
|
||||
"contact": "user@example.com",
|
||||
"bodyLength": 20,
|
||||
"packageEncrypted": true,
|
||||
"encryption": feedback.PackageMagic,
|
||||
"packageBytes": len(encrypted),
|
||||
"packageSha256": encryptedHash,
|
||||
"plainPackageBytes": len(plain),
|
||||
"plainPackageSha256": plainHash,
|
||||
"createdAt": "2026-06-26T00:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(payloadData)
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("payload", payload)
|
||||
_ = writer.WriteField("timestamp", timestamp)
|
||||
_ = writer.WriteField("nonce", "route-test")
|
||||
_ = writer.WriteField("packageSha256", encryptedHash)
|
||||
_ = writer.WriteField("signature", feedback.SignWithKey("ymhut-box-feedback-client-v1", timestamp, "route-test", encryptedHash, payload))
|
||||
part, err := writer.CreateFormFile("package", "feedback.ymfb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(part, bytes.NewReader(encrypted))
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("signed multipart submit returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var submitted map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if submitted["code"] != "FB-20260626-ABC123" || submitted["duplicate"] != nil {
|
||||
t.Fatalf("unexpected signed submit payload: %#v", submitted)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/?api=status&code=FB-20260626-ABC123", nil)
|
||||
res = httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("signed status returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["code"] != "FB-20260626-ABC123" || status["statusLabel"] == nil || status["reply"] == nil {
|
||||
t.Fatalf("unexpected signed status payload: %#v", status)
|
||||
}
|
||||
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "mailRecords", "packagePath", "encryptedPackagePath", "path"} {
|
||||
if _, ok := status[privateKey]; ok {
|
||||
t.Fatalf("signed status leaked private key %q: %#v", privateKey, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -91,6 +447,23 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
for _, path := range []string{"/admin/system", "/admin/database", "/admin/health", "/admin/settings", "/admin/audit"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
|
||||
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles []string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
@@ -100,6 +473,15 @@ func containsAny(value string, needles []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func assertJSONKeys(t *testing.T, label string, payload map[string]any, keys []string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
if _, ok := payload[key]; !ok {
|
||||
t.Fatalf("%s missing key %q: %#v", label, key, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNoticesRoutes(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
@@ -136,6 +518,203 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLegacyUpdateInfoSyncsReleaseNotice(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"raw": `{"app_version":"2.0.7.5","title":"YMhut Box 2.0.7.5","message":"随机放映室优化","release_notes":"修复图片源和全屏预览"}`,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/update-info", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("save update-info returned %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/admin/releases/notices", nil)
|
||||
listReq.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
listRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(listRes, listReq)
|
||||
if listRes.Code != http.StatusOK {
|
||||
t.Fatalf("notice list returned %d: %s", listRes.Code, listRes.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Items []struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(listRes.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, item := range payload.Items {
|
||||
if item.Version == "2.0.7.5" && item.Title == "YMhut Box 2.0.7.5" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("synced release notice not found: %#v", payload.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLegacyValidationErrorIsChinese(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
session, csrf, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"raw": `{"message":"missing version and title"}`})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/legacy/update-info/validate", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected validation failure, got %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if strings.Contains(res.Body.String(), "update-info requires app_version or title") {
|
||||
t.Fatalf("english validation leaked: %s", res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "更新 JSON 需要填写 app_version 或 title") {
|
||||
t.Fatalf("missing chinese validation message: %s", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWriteRequiresCSRF(t *testing.T) {
|
||||
handler, cleanup := testRouter(t)
|
||||
defer cleanup()
|
||||
|
||||
session, _, err := loginForTest(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/sources/check", bytes.NewBufferString(`{}`))
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
||||
res := httptest.NewRecorder()
|
||||
handler.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden without csrf, got %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func loginForTest(handler http.Handler) (string, string, error) {
|
||||
captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil)
|
||||
captchaRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(captchaRes, captchaReq)
|
||||
if captchaRes.Code != http.StatusOK {
|
||||
return "", "", errors.New(captchaRes.Body.String())
|
||||
}
|
||||
var captchaPayload struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.Unmarshal(captchaRes.Body.Bytes(), &captchaPayload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
answer, err := readTestCaptcha(captchaPayload.Image)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"captchaId": captchaPayload.CaptchaID,
|
||||
"captcha": answer,
|
||||
})
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRes := httptest.NewRecorder()
|
||||
handler.ServeHTTP(loginRes, loginReq)
|
||||
if loginRes.Code != http.StatusOK {
|
||||
return "", "", errors.New(loginRes.Body.String())
|
||||
}
|
||||
var loginPayload struct {
|
||||
OK bool `json:"ok"`
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRes.Body.Bytes(), &loginPayload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !loginPayload.OK {
|
||||
return "", "", errors.New(loginPayload.Message)
|
||||
}
|
||||
for _, cookie := range loginRes.Result().Cookies() {
|
||||
if cookie.Name == auth.SessionCookie {
|
||||
return cookie.Value, loginPayload.CSRFToken, nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.New("session cookie not set")
|
||||
}
|
||||
|
||||
func readTestCaptcha(dataURL string) (string, error) {
|
||||
const prefix = "data:image/png;base64,"
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var builder strings.Builder
|
||||
for index := 0; index < 5; index++ {
|
||||
x := 18 + index*32
|
||||
y := 13
|
||||
mask := [7]bool{
|
||||
isCaptchaInk(img.At(x+11, y+2)),
|
||||
isCaptchaInk(img.At(x+20, y+12)),
|
||||
isCaptchaInk(img.At(x+20, y+28)),
|
||||
isCaptchaInk(img.At(x+11, y+34)),
|
||||
isCaptchaInk(img.At(x+2, y+28)),
|
||||
isCaptchaInk(img.At(x+2, y+12)),
|
||||
isCaptchaInk(img.At(x+11, y+18)),
|
||||
}
|
||||
digit := -1
|
||||
for candidate, segments := range testCaptchaSegments {
|
||||
if segments == mask {
|
||||
digit = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if digit < 0 {
|
||||
return "", errors.New("captcha digit could not be read")
|
||||
}
|
||||
builder.WriteByte(byte('0' + digit))
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func isCaptchaInk(colorValue color.Color) bool {
|
||||
r, g, b, _ := colorValue.RGBA()
|
||||
return r>>8 < 80 && g>>8 < 100 && b>>8 < 130
|
||||
}
|
||||
|
||||
var testCaptchaSegments = [10][7]bool{
|
||||
{true, true, true, true, true, true, false},
|
||||
{false, true, true, false, false, false, false},
|
||||
{true, true, false, true, true, false, true},
|
||||
{true, true, true, true, false, false, true},
|
||||
{false, true, true, false, false, true, true},
|
||||
{true, false, true, true, false, true, true},
|
||||
{true, false, true, true, true, true, true},
|
||||
{true, true, true, false, false, false, false},
|
||||
{true, true, true, true, true, true, true},
|
||||
{true, true, true, true, false, true, true},
|
||||
}
|
||||
|
||||
func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
@@ -181,6 +760,9 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}},
|
||||
}},
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(public, "downloads", "fixture.txt"), []byte("download fixture\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
|
||||
"schema_version": 1,
|
||||
"latest_version": "2.0.0",
|
||||
@@ -190,15 +772,23 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
})
|
||||
mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"})
|
||||
cfg := &config.Config{
|
||||
Listen: ":0",
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
UpdatePublicDir: public,
|
||||
UpdateNoticeDir: noticeDir,
|
||||
DownloadsDir: filepath.Join(public, "downloads"),
|
||||
AdminWebDir: adminDist,
|
||||
PortalWebDir: portalDist,
|
||||
SourceCheckSeconds: 3600,
|
||||
BaseDir: root,
|
||||
ConfigPath: filepath.Join(root, "config.json"),
|
||||
Listen: ":0",
|
||||
BaseURL: "https://update.ymhut.cn",
|
||||
StorageDir: filepath.Join(root, "storage"),
|
||||
DataDir: filepath.Join(root, "data"),
|
||||
UpdatePublicDir: public,
|
||||
UpdateNoticeDir: noticeDir,
|
||||
DownloadsDir: filepath.Join(public, "downloads"),
|
||||
AdminWebDir: adminDist,
|
||||
PortalWebDir: portalDist,
|
||||
SourceCheckSeconds: 3600,
|
||||
ClientSignatureKey: "ymhut-box-feedback-client-v1",
|
||||
PackageEncryptionKey: "ymhut-box-feedback-package-v1",
|
||||
TimestampWindowSeconds: 600,
|
||||
MaxRequestBytes: 12 << 20,
|
||||
MaxPackageBytes: 10 << 20,
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||
@@ -206,6 +796,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
HotSyncEnabled: true,
|
||||
HealthIntervalSec: 3600,
|
||||
},
|
||||
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
@@ -235,6 +826,50 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
||||
return handler, func() { _ = store.Close() }
|
||||
}
|
||||
|
||||
func routeZipBytes(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
writer := zip.NewWriter(&buf)
|
||||
for name, body := range files {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = entry.Write([]byte(body))
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func routeEncryptPackage(t *testing.T, plain []byte, keyMaterial string) []byte {
|
||||
t.Helper()
|
||||
key := sha256.Sum256([]byte(keyMaterial))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := []byte("123456789012")
|
||||
sealed := gcm.Seal(nil, nonce, plain, []byte(feedback.PackageMagic))
|
||||
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
|
||||
tag := sealed[len(sealed)-gcm.Overhead():]
|
||||
out := []byte(feedback.PackageMagic)
|
||||
out = append(out, nonce...)
|
||||
out = append(out, tag...)
|
||||
out = append(out, ciphertext...)
|
||||
return out
|
||||
}
|
||||
|
||||
func routeSHA256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func mustWriteJSON(t *testing.T, path string, payload any) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
@@ -3,11 +3,8 @@ package web
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,18 +21,7 @@ type setupRequest struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
SQLitePath string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysqlDsn"`
|
||||
MySQL setupMySQLConfig `json:"mysql"`
|
||||
}
|
||||
|
||||
type setupMySQLConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Charset string `json:"charset"`
|
||||
ParseTime bool `json:"parseTime"`
|
||||
TLS string `json:"tls"`
|
||||
MySQL config.MySQLInput `json:"mysql"`
|
||||
}
|
||||
|
||||
func NewSetupRouter(cfg *config.Config) http.Handler {
|
||||
@@ -68,12 +54,12 @@ func (r *setupRouter) status() map[string]any {
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"initialized": r.cfg.Initialized,
|
||||
"baseDir": r.cfg.BaseDir,
|
||||
"configPath": r.cfg.ConfigPath,
|
||||
"baseDir": ".",
|
||||
"configPath": relativeToBase(r.cfg.BaseDir, r.cfg.ConfigPath),
|
||||
"defaults": map[string]any{
|
||||
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
||||
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
|
||||
"mysqlDsn": maskDSN(r.cfg.Database.MySQLDSN),
|
||||
"mysqlDsn": config.MaskDSN(r.cfg.Database.MySQLDSN),
|
||||
"baseUrl": r.cfg.BaseURL,
|
||||
},
|
||||
}
|
||||
@@ -103,7 +89,7 @@ func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Reques
|
||||
"provider": next.Provider,
|
||||
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
|
||||
"sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath),
|
||||
"mysqlDsn": maskDSN(next.MySQLDSN),
|
||||
"mysqlDsn": config.MaskDSN(next.MySQLDSN),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -153,44 +139,18 @@ func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseCon
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return config.DatabaseConfig{}, body, err
|
||||
}
|
||||
next := r.cfg.Database
|
||||
next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite")))
|
||||
if body.SQLitePath != "" {
|
||||
next.SQLitePath = body.SQLitePath
|
||||
incoming := config.DatabaseConfig{
|
||||
Provider: body.Provider,
|
||||
SQLitePath: body.SQLitePath,
|
||||
MySQLDSN: body.MySQLDSN,
|
||||
MySQLHost: body.MySQL.Host,
|
||||
MySQLPort: body.MySQL.Port,
|
||||
MySQLDatabase: body.MySQL.Database,
|
||||
MySQLUser: body.MySQL.Username,
|
||||
MySQLPassword: body.MySQL.Password,
|
||||
}
|
||||
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
|
||||
next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath)
|
||||
}
|
||||
if next.Provider == "sqlite" {
|
||||
next.MySQLDSN = ""
|
||||
} else if body.MySQLDSN != "" {
|
||||
next.MySQLDSN = body.MySQLDSN
|
||||
} else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" {
|
||||
dsn, err := buildMySQLDSN(body.MySQL)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, body, err
|
||||
}
|
||||
next.MySQLDSN = dsn
|
||||
}
|
||||
if next.Provider != "sqlite" && next.Provider != "mysql" {
|
||||
return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql")
|
||||
}
|
||||
if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" {
|
||||
return config.DatabaseConfig{}, body, errors.New("mysql connection is required")
|
||||
}
|
||||
if next.MaxOpenConns <= 0 {
|
||||
next.MaxOpenConns = 10
|
||||
}
|
||||
if next.MaxIdleConns <= 0 {
|
||||
next.MaxIdleConns = 4
|
||||
}
|
||||
if next.ConnMaxLifetimeSeconds <= 0 {
|
||||
next.ConnMaxLifetimeSeconds = 300
|
||||
}
|
||||
if next.HealthIntervalSec <= 0 {
|
||||
next.HealthIntervalSec = 30
|
||||
}
|
||||
return next, body, nil
|
||||
next, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false)
|
||||
return next, body, err
|
||||
}
|
||||
|
||||
func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -205,48 +165,9 @@ func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
|
||||
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
|
||||
}
|
||||
|
||||
func buildMySQLDSN(input setupMySQLConfig) (string, error) {
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := input.Port
|
||||
if port <= 0 {
|
||||
port = 3306
|
||||
}
|
||||
database := strings.TrimSpace(input.Database)
|
||||
username := strings.TrimSpace(input.Username)
|
||||
if database == "" {
|
||||
return "", errors.New("mysql database is required")
|
||||
}
|
||||
if username == "" {
|
||||
return "", errors.New("mysql username is required")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
|
||||
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
|
||||
if tls := strings.TrimSpace(input.TLS); tls != "" {
|
||||
params.Set("tls", tls)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
|
||||
}
|
||||
|
||||
func maskDSN(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.Index(value, "@")
|
||||
colon := strings.Index(value, ":")
|
||||
if at > -1 && colon > -1 && colon < at {
|
||||
return value[:colon+1] + "******" + value[at:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
|
||||
if strings.EqualFold(cfg.Provider, "mysql") {
|
||||
return maskDSN(cfg.MySQLDSN)
|
||||
return config.MaskDSN(cfg.MySQLDSN)
|
||||
}
|
||||
return relativeToBase(base, cfg.SQLitePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
func (r *router) handleDownload(w http.ResponseWriter, req *http.Request) {
|
||||
name := strings.TrimPrefix(cleanPath(req.URL.Path), "/downloads/")
|
||||
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid filename"))
|
||||
return
|
||||
}
|
||||
path := filepath.Join(r.cfg.DownloadsDir, name)
|
||||
resolved, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||
return
|
||||
}
|
||||
base, _ := filepath.Abs(r.cfg.DownloadsDir)
|
||||
if !strings.HasPrefix(resolved, base) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, resolved)
|
||||
}
|
||||
|
||||
func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot, assetPath string) {
|
||||
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
||||
return
|
||||
}
|
||||
if tryServeDiskFile(w, req, root, assetPath) {
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
||||
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||
resolved, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
||||
return true
|
||||
}
|
||||
base, _ := filepath.Abs(root)
|
||||
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(w, req, resolved)
|
||||
return true
|
||||
}
|
||||
|
||||
func serveEmbeddedFile(w http.ResponseWriter, req *http.Request, name string) bool {
|
||||
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid embedded asset path"))
|
||||
return true
|
||||
}
|
||||
data, err := webassets.ReadFile(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
|
||||
index := filepath.Join(r.cfg.PortalWebDir, "index.html")
|
||||
if _, err := os.Stat(index); err == nil {
|
||||
http.ServeFile(w, req, index)
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "portal/dist/index.html") {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
|
||||
}
|
||||
|
||||
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
||||
if _, err := os.Stat(index); err == nil {
|
||||
http.ServeFile(w, req, index)
|
||||
return
|
||||
}
|
||||
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
|
||||
}
|
||||
|
||||
func isPortalRoute(path string) bool {
|
||||
switch path {
|
||||
case "/", "/releases", "/sources", "/feedback", "/compatibility":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,33 @@ if (-not $SkipFrontend) {
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||
|
||||
function Copy-IfExists {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
if (Test-Path $Source) {
|
||||
if (Test-Path $Destination) {
|
||||
Remove-Item -Recurse -Force $Destination
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $Destination -Parent) | Out-Null
|
||||
Copy-Item -Recurse -Force $Source $Destination
|
||||
}
|
||||
}
|
||||
|
||||
$RepoRoot = Resolve-Path (Join-Path $Root "..\..")
|
||||
$DataUpdatePublic = Join-Path $Out "data\update\public"
|
||||
$DataUpdateNotice = Join-Path $Out "data\update-notice"
|
||||
New-Item -ItemType Directory -Force -Path $DataUpdatePublic | Out-Null
|
||||
Copy-IfExists (Join-Path $RepoRoot "update-notice") $DataUpdateNotice
|
||||
foreach ($Name in @("update-info.json", "media-types.json", "tool-status.json", "modules.json")) {
|
||||
$Source = Join-Path $RepoRoot "server\update\public\$Name"
|
||||
if (Test-Path $Source) {
|
||||
Copy-Item -Force $Source (Join-Path $DataUpdatePublic $Name)
|
||||
}
|
||||
}
|
||||
Copy-IfExists (Join-Path $RepoRoot "server\update\public\downloads") (Join-Path $DataUpdatePublic "downloads")
|
||||
|
||||
$Targets = @(
|
||||
@{ GOOS = "windows"; GOARCH = "amd64"; Name = "ymhut-unified-management-windows-amd64.exe" },
|
||||
@{ GOOS = "linux"; GOARCH = "amd64"; Name = "ymhut-unified-management-linux-amd64" },
|
||||
|
||||
@@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="$ROOT/$OUT_DIR"
|
||||
|
||||
if [[ "$SKIP_FRONTEND" != "1" ]]; then
|
||||
for app in admin portal; do
|
||||
for app in admin portal setup; do
|
||||
web_dir="$ROOT/web/$app"
|
||||
if [[ ! -d "$web_dir/node_modules" ]]; then
|
||||
(cd "$web_dir" && npm install)
|
||||
@@ -19,6 +19,28 @@ fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
copy_if_exists() {
|
||||
local source="$1"
|
||||
local target="$2"
|
||||
if [[ -e "$source" ]]; then
|
||||
rm -rf "$target"
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -R "$source" "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
REPO_ROOT="$(cd "$ROOT/../.." && pwd)"
|
||||
DATA_UPDATE_PUBLIC="$OUT/data/update/public"
|
||||
DATA_UPDATE_NOTICE="$OUT/data/update-notice"
|
||||
mkdir -p "$DATA_UPDATE_PUBLIC"
|
||||
copy_if_exists "$REPO_ROOT/update-notice" "$DATA_UPDATE_NOTICE"
|
||||
for name in update-info.json media-types.json tool-status.json modules.json; do
|
||||
if [[ -f "$REPO_ROOT/server/update/public/$name" ]]; then
|
||||
cp -f "$REPO_ROOT/server/update/public/$name" "$DATA_UPDATE_PUBLIC/$name"
|
||||
fi
|
||||
done
|
||||
copy_if_exists "$REPO_ROOT/server/update/public/downloads" "$DATA_UPDATE_PUBLIC/downloads"
|
||||
|
||||
build_target() {
|
||||
local goos="$1"
|
||||
local goarch="$2"
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/admin/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/admin/logo-150.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="author" content="YMhut" />
|
||||
<meta name="application-name" content="YMhut Box Admin" />
|
||||
<meta name="description" content="YMhut Box unified management console for update.ymhut.cn." />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>YMhut Unified Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,105 @@
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AdminApiOptions = {
|
||||
csrf?: string;
|
||||
};
|
||||
|
||||
const exactMessages: Record<string, string> = {
|
||||
"current password is invalid": "当前密码不正确",
|
||||
"new password is required": "新密码不能为空",
|
||||
"new password must be at least 8 characters": "新密码至少需要 8 位",
|
||||
"new password cannot be admin": "新密码不能为 admin",
|
||||
"new password must be different from current password": "新密码不能与当前密码相同",
|
||||
"invalid password or captcha": "密码或验证码不正确",
|
||||
"login required": "需要登录后继续操作",
|
||||
"csrf token required": "页面安全令牌已失效,请刷新后重试",
|
||||
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
|
||||
"code is required": "缺少反馈编号",
|
||||
"revisionid is required": "请选择要恢复的历史版本",
|
||||
"post required": "该操作需要使用 POST 请求",
|
||||
"get required": "该操作需要使用 GET 请求",
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到服务端检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
};
|
||||
|
||||
const codeMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: "需要登录后继续操作",
|
||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
||||
INVALID_PAYLOAD: "提交内容格式不正确",
|
||||
DATABASE_TEST_FAILED: "数据库连接测试失败",
|
||||
DATABASE_IMPORT_FAILED: "SQLite 导入远端库失败",
|
||||
DATABASE_SYNC_FAILED: "远端库同步回本地失败",
|
||||
LEGACY_SAVE_FAILED: "兼容 JSON 保存失败",
|
||||
LEGACY_VALIDATE_FAILED: "兼容 JSON 校验失败",
|
||||
LEGACY_RESTORE_FAILED: "兼容 JSON 恢复失败",
|
||||
NOTICE_SAVE_FAILED: "版本日志保存失败",
|
||||
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
|
||||
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
|
||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||
CHECK_FAILED: "接口健康检测失败",
|
||||
SYNC_FAILED: "同步操作失败",
|
||||
FORBIDDEN: "没有权限执行该操作",
|
||||
METHOD_NOT_ALLOWED: "请求方法不正确",
|
||||
};
|
||||
|
||||
export async function adminFetch<T>(target: string, init: RequestInit = {}, options: AdminApiOptions = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", target);
|
||||
xhr.withCredentials = true;
|
||||
if (options.csrf) xhr.setRequestHeader("X-CSRF-Token", options.csrf);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const data = parseJSONSafe(xhr.responseText, {});
|
||||
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
|
||||
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve(data as T);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络异常,发布包上传失败"));
|
||||
xhr.onabort = () => reject(new Error("发布包上传已取消"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
export function toChineseError(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
const lower = raw.toLowerCase();
|
||||
if (exactMessages[lower]) return exactMessages[lower];
|
||||
if (codeMessages[raw]) return codeMessages[raw];
|
||||
if (/^HTTP\s+\d+/.test(raw)) return `请求失败:${raw}`;
|
||||
return raw || "操作失败";
|
||||
}
|
||||
|
||||
function parseJSONSafe(value: string, fallback: any) {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,17 @@ const routes = [
|
||||
"/admin/legacy/media-types",
|
||||
"/admin/sources",
|
||||
"/admin/endpoints",
|
||||
"/admin/database",
|
||||
"/admin/health",
|
||||
"/admin/settings",
|
||||
"/admin/audit",
|
||||
"/admin/system",
|
||||
].map((path) => ({ path, component: RoutePlaceholder }));
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...routes,
|
||||
{ path: "/admin/database", redirect: { path: "/admin/system", query: { tab: "database" } } },
|
||||
{ path: "/admin/health", redirect: { path: "/admin/system", query: { tab: "health" } } },
|
||||
{ path: "/admin/settings", redirect: { path: "/admin/system", query: { tab: "security" } } },
|
||||
{ path: "/admin/audit", redirect: { path: "/admin/system", query: { tab: "audit" } } },
|
||||
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createAuthStore() {
|
||||
const csrf = ref(sessionStorage.getItem("ymhut.csrf") || "");
|
||||
const captcha = ref<any | null>(null);
|
||||
const bootstrap = ref<any | null>(null);
|
||||
const loginForm = reactive({ username: "", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
|
||||
return { csrf, captcha, bootstrap, loginForm, passwordForm };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createDashboardStore() {
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = ref<any[]>([]);
|
||||
|
||||
return { dashboard, sourceCheckJobs };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createFeedbackStore() {
|
||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<any | null>(null);
|
||||
const filters = reactive({ q: "", status: "", priority: "", page: 1, perPage: 20 });
|
||||
const update = reactive({ status: "", priority: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
|
||||
return { page, selected, filters, update, commentDraft };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export type LegacyName = "update-info" | "media-types";
|
||||
|
||||
export function createLegacyStore() {
|
||||
const sync = ref<any>(null);
|
||||
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
const modal = reactive({
|
||||
open: false,
|
||||
type: "",
|
||||
categoryIndex: -1,
|
||||
itemIndex: -1,
|
||||
draft: {} as any,
|
||||
});
|
||||
const activeMediaCategoryIndex = ref(0);
|
||||
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
|
||||
return { sync, documents, drafts, modal, activeMediaCategoryIndex };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createReleaseStore() {
|
||||
const releases = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const selectedNotice = ref<any | null>(null);
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: "",
|
||||
});
|
||||
|
||||
return { releases, notices, selectedNotice, noticeDraft, uploadDraft };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSourceStore() {
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const draft = reactive({
|
||||
sourceId: "",
|
||||
categoryId: "custom",
|
||||
categoryName: "自定义接口",
|
||||
name: "",
|
||||
description: "",
|
||||
method: "GET",
|
||||
apiUrl: "",
|
||||
urlTemplate: "",
|
||||
thumbnailUrl: "",
|
||||
proxyMode: "client_direct",
|
||||
timeoutMs: 8000,
|
||||
retryCount: 1,
|
||||
cacheSeconds: 300,
|
||||
checkIntervalSec: 300,
|
||||
enabled: true,
|
||||
clientVisible: true,
|
||||
supportedFormats: "[\"json\"]",
|
||||
});
|
||||
|
||||
return { sources, endpoints, draft };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
const databaseConfig = ref<any>(null);
|
||||
const databaseLastSync = ref<any>(null);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const auditPage = reactive({
|
||||
items: [] as any[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 35,
|
||||
q: "",
|
||||
type: "",
|
||||
target: "",
|
||||
selected: null as any | null,
|
||||
});
|
||||
const migrationStatus = ref<any>(null);
|
||||
const branding = reactive({
|
||||
siteIconUrl: "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
||||
developerAvatarUrl: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
||||
developerName: "YMhut",
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
});
|
||||
const databaseForm = reactive({
|
||||
provider: "sqlite",
|
||||
sqlitePath: "",
|
||||
mysqlHost: "127.0.0.1",
|
||||
mysqlPort: 3306,
|
||||
mysqlDatabase: "",
|
||||
mysqlUser: "",
|
||||
mysqlPassword: "",
|
||||
mysqlDsn: "",
|
||||
});
|
||||
const databaseConfigCollapsed = ref(true);
|
||||
const mailConfig = reactive({
|
||||
host: "",
|
||||
port: 465,
|
||||
secure: "ssl",
|
||||
username: "",
|
||||
password: "",
|
||||
fromAddress: "",
|
||||
fromName: "YMhut Box Feedback",
|
||||
developerAddress: "",
|
||||
timeoutSeconds: 20,
|
||||
hasPassword: false,
|
||||
configured: false,
|
||||
});
|
||||
const legacySyncMode = ref<"preview" | "run">("preview");
|
||||
|
||||
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
|
||||
}
|
||||
@@ -20,11 +20,12 @@
|
||||
--bad: #b42318;
|
||||
--bad-bg: #fff0ed;
|
||||
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||
--ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; }
|
||||
body { margin: 0; background: var(--bg); }
|
||||
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
|
||||
body { margin: 0; background: var(--bg); max-width: 100%; overflow-x: clip; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.65; }
|
||||
@@ -57,7 +58,7 @@ h3 { margin-bottom: 8px; font-size: 15px; }
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ input, textarea, select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 10px;
|
||||
@@ -83,7 +84,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.captcha-button {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -96,7 +97,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.btn {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 12px;
|
||||
@@ -106,14 +107,16 @@ input:focus, textarea:focus, select:focus {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
transition: transform 0.18s var(--ease), background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--line-strong); background: #f9fafb; }
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--line-strong); background: #f9fafb; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); }
|
||||
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.compact { min-height: 30px; padding: 5px 8px; font-size: 12px; }
|
||||
.btn.full { width: 100%; }
|
||||
.btn.danger { color: var(--bad); border-color: #f0b8b1; }
|
||||
.btn.danger:hover { background: var(--bad-bg); color: var(--bad); }
|
||||
.button-row, .top-actions, .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.alert-line, .notice {
|
||||
@@ -125,7 +128,35 @@ input:focus, textarea:focus, select:focus {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
|
||||
.toast {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
min-width: min(420px, calc(100vw - 32px));
|
||||
max-width: calc(100vw - 32px);
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
color: #102033;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
backdrop-filter: blur(14px);
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
animation: toastIn 0.18s var(--ease);
|
||||
}
|
||||
.toast.success { border-color: #b7e4ca; background: rgba(232, 247, 239, 0.96); color: var(--good); }
|
||||
.toast.warn { border-color: #f4d38c; background: rgba(255, 247, 230, 0.96); color: var(--warn); }
|
||||
.toast.error { border-color: #f0b8b1; background: rgba(255, 240, 237, 0.96); color: var(--bad); }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100dvh; max-width: 100vw; overflow-x: hidden; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
@@ -137,13 +168,19 @@ input:focus, textarea:focus, select:focus {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
.brand strong, .brand small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; min-width: 0; overflow-x: hidden; }
|
||||
.nav-group p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--muted);
|
||||
@@ -154,7 +191,7 @@ input:focus, textarea:focus, select:focus {
|
||||
}
|
||||
.nav-group button, .logout {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
@@ -164,12 +201,24 @@ input:focus, textarea:focus, select:focus {
|
||||
color: #526070;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button svg, .logout svg { flex: 0 0 auto; }
|
||||
.nav-group button span, .logout span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); box-shadow: inset 3px 0 0 var(--primary); overflow: hidden; }
|
||||
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||
.logout { color: #7f1d1d; }
|
||||
|
||||
.workspace { min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.workspace { min-width: 0; max-width: 100%; overflow-x: hidden; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
|
||||
.topbar { min-height: 72px; }
|
||||
.section-head h2 { margin: 0; }
|
||||
@@ -179,36 +228,50 @@ input:focus, textarea:focus, select:focus {
|
||||
.metric, .panel {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.panel-soft {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
}
|
||||
.metric, .panel, .revision-list button, .nested-card {
|
||||
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.metric:hover, .panel:hover, .revision-list button:hover, .nested-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.metric span, .metric small { color: var(--muted); }
|
||||
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
|
||||
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
|
||||
.chart-panel-relative { position: relative; }
|
||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||
.quick-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.quick-grid button {
|
||||
min-height: 112px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 12px;
|
||||
.chart-empty {
|
||||
position: absolute;
|
||||
inset: 56px 16px 16px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
text-align: left;
|
||||
place-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.84);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.quick-grid button:hover { border-color: var(--primary); background: #f8fbff; }
|
||||
.quick-grid svg { color: var(--primary); }
|
||||
.quick-grid span { color: var(--muted); line-height: 1.45; font-size: 13px; }
|
||||
.chart-empty strong { color: var(--ink); }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
|
||||
.legacy-media-editor > * { min-width: 0; }
|
||||
|
||||
.search-box {
|
||||
min-width: min(420px, 100%);
|
||||
@@ -229,7 +292,8 @@ table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover td { background: #f8fbff; }
|
||||
tbody tr { transition: background-color 0.18s ease; }
|
||||
tr.clickable:hover td, tbody tr:hover td { background: #f8fbff; }
|
||||
tr.selected td { background: #eef4ff; }
|
||||
|
||||
.badge {
|
||||
@@ -263,11 +327,53 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
|
||||
.empty-state.compact { min-height: 96px; border: 1px dashed var(--line); border-radius: 6px; }
|
||||
.source-group { margin-top: 12px; }
|
||||
.source-group h3 { display: flex; align-items: center; gap: 8px; }
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.media-subcategory-panel { min-width: 0; overflow: hidden; }
|
||||
.media-subcategory-table table { table-layout: fixed; min-width: 680px; }
|
||||
.media-subcategory-table th:nth-child(1), .media-subcategory-table td:nth-child(1) { width: 170px; }
|
||||
.media-subcategory-table th:nth-child(2), .media-subcategory-table td:nth-child(2) { width: 120px; }
|
||||
.media-subcategory-table th:nth-child(3), .media-subcategory-table td:nth-child(3) { width: 72px; }
|
||||
.media-subcategory-table th:nth-child(5), .media-subcategory-table td:nth-child(5) { width: 150px; }
|
||||
.media-subcategory-table .button-row { flex-wrap: nowrap; }
|
||||
.url-cell {
|
||||
max-width: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.category-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.category-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.category-list button:hover, .category-list button.active {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: var(--primary-soft);
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
.category-list span:first-child { min-width: 0; display: grid; gap: 2px; }
|
||||
.category-list strong, .category-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.code-editor { min-height: 56dvh; white-space: pre; overflow: auto; font-size: 13px; }
|
||||
.compact-editor { min-height: 260px; }
|
||||
details {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -277,7 +383,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
padding: 12px;
|
||||
@@ -289,7 +395,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.revision-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.revision-list button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
@@ -297,13 +403,188 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
}
|
||||
.revision-list button:hover, .revision-list button.active { border-color: var(--primary); background: #f8fbff; }
|
||||
.revision-list small { display: block; color: var(--muted); margin-top: 3px; }
|
||||
.compact-side { gap: 10px; }
|
||||
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
|
||||
.kv-grid span { color: var(--muted); }
|
||||
.kv-grid strong { overflow-wrap: anywhere; }
|
||||
.sync-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.sync-summary div {
|
||||
min-height: 74px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
.sync-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.sync-summary strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
}
|
||||
.ops-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #f4d38c;
|
||||
border-radius: 10px;
|
||||
background: var(--warn-bg);
|
||||
color: #7a3b00;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
|
||||
.plain-list { margin: 0; padding-left: 18px; color: var(--muted); line-height: 1.8; }
|
||||
.asset-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
.asset-row span { color: var(--muted); }
|
||||
.asset-row code { overflow-wrap: anywhere; font-size: 12px; color: var(--primary-dark); }
|
||||
.brand-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
.brand-preview img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tabs button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
padding: 7px 12px;
|
||||
font-weight: 900;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.tabs button:hover, .tabs button.active {
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.form-grid .wide, label.wide { grid-column: 1 / -1; }
|
||||
.mini-editor { min-height: 160px; }
|
||||
.nested-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.78);
|
||||
padding: 14px;
|
||||
}
|
||||
.nested-card.inner {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.upload-card {
|
||||
background: #fff;
|
||||
}
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.upload-progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.upload-progress-head strong { color: var(--ink); }
|
||||
.upload-progress-head span { color: var(--primary-dark); font-weight: 900; }
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 9px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--primary);
|
||||
transition: width 0.18s var(--ease);
|
||||
}
|
||||
.upload-progress small {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
@@ -314,12 +595,12 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.workspace { padding: 16px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
.quick-grid { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.captcha-row { grid-template-columns: 1fr; }
|
||||
table { min-width: 720px; }
|
||||
.panel { overflow-x: auto; }
|
||||
.panel { overflow-x: auto; max-width: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<td>{{ item.type }}</td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.message }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即心跳检测</button>
|
||||
<button class="btn primary" @click="ctx.checkSources"><Activity :size="16" />立即服务端检测</button>
|
||||
<button class="btn ghost" @click="ctx.toggleAutoRefresh">
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
@@ -29,26 +29,40 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<span class="muted">每 15 秒自动刷新仪表盘数据。</span>
|
||||
</div>
|
||||
|
||||
<section class="panel quick-panel">
|
||||
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
|
||||
<div class="quick-grid">
|
||||
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)">
|
||||
<component :is="item.icon" :size="18" />
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
||||
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
|
||||
<td class="mono">{{ job.id }}</td>
|
||||
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
||||
<td>{{ job.stats?.ok || 0 }}</td>
|
||||
<td>{{ job.stats?.redirected || 0 }}</td>
|
||||
<td>{{ job.stats?.degraded || 0 }}</td>
|
||||
<td>{{ job.stats?.error || 0 }}</td>
|
||||
<td>{{ job.startedAt || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>服务端接口延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击“立即服务端检测”后会生成延迟曲线。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>最近接口心跳</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -59,7 +73,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<td class="hash">{{ item.error || "-" }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无心跳记录,点击“立即心跳检测”后会刷新。</td></tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击“立即服务端检测”后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库运行状态</h2><span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="section-head"><h2>旧项目同步</h2><button class="btn ghost" @click="ctx.previewLegacySync">预览</button></div>
|
||||
<pre class="json-preview small">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<h2>连接与同步</h2>
|
||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,21 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>客户端动态接口</h2><span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span></div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>客户端动态接口</h2>
|
||||
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON。</p>
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th></th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.endpointStatus(item) }}</span></td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
||||
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
import { Mail, Save, Search, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -8,44 +8,107 @@ defineProps<{ ctx: any }>();
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="toolbar">
|
||||
<label class="search-box"><Search :size="16" /><input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" /></label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks"><option value="">全部状态</option><option value="new">new</option><option value="processing">processing</option><option value="closed">closed</option></select>
|
||||
<label class="search-box">
|
||||
<Search :size="16" />
|
||||
<input v-model="ctx.feedbackFilters.q" placeholder="搜索编号、标题、联系方式" @keyup.enter="ctx.loadFeedbacks" />
|
||||
</label>
|
||||
<select v-model="ctx.feedbackFilters.status" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部状态</option>
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
<select v-model="ctx.feedbackFilters.priority" @change="ctx.loadFeedbacks">
|
||||
<option value="">全部优先级</option>
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
<button class="btn ghost" @click="ctx.loadFeedbacks">查询</button>
|
||||
<a class="btn ghost" href="/api/admin/feedbacks/export" target="_blank"><UploadCloud :size="16" />CSV</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>最近活动</th></tr></thead>
|
||||
<thead><tr><th>编号</th><th>标题</th><th>状态</th><th>优先级</th><th>邮件</th><th>最近活动</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.feedbackPage.items" :key="item.code" class="clickable" :class="{ selected: ctx.selectedFeedback?.code === item.code }" @click="ctx.openFeedback(item)">
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.title || item.summaryText || "未命名反馈" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ item.status }}</span></td>
|
||||
<td>{{ item.priority || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.priority)]">{{ ctx.labelPriority(item.priority) }}</span></td>
|
||||
<td><span :class="['badge', item.mailSent ? 'good' : 'warn']">{{ item.mailSent ? "已发送" : "未发送" }}</span></td>
|
||||
<td>{{ item.lastActivityAt || item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="5">暂无反馈工单。</td></tr>
|
||||
<tr v-if="!ctx.feedbackPage.items?.length"><td colspan="6">暂无反馈工单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<aside class="panel detail-panel">
|
||||
<template v-if="ctx.selectedFeedback">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.selectedFeedback.code }}</h2>
|
||||
<span :class="['badge', ctx.selectedFeedback.mailSent ? 'good' : 'warn']">{{ ctx.selectedFeedback.mailSent ? "邮件已发送" : "邮件未发送" }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ ctx.selectedFeedback.title || ctx.selectedFeedback.body }}</p>
|
||||
<label>状态<select v-model="ctx.feedbackUpdate.status"><option>new</option><option>processing</option><option>closed</option></select></label>
|
||||
<div class="kv-grid">
|
||||
<span>联系方式</span><strong>{{ ctx.selectedFeedback.contact || "-" }}</strong>
|
||||
<span>来源</span><strong>{{ ctx.selectedFeedback.sourceChannel || "-" }}</strong>
|
||||
<span>接收时间</span><strong>{{ ctx.selectedFeedback.createdAt || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<label>状态
|
||||
<select v-model="ctx.feedbackUpdate.status">
|
||||
<option value="new">新建</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="closed">已关闭</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>优先级
|
||||
<select v-model="ctx.feedbackUpdate.priority">
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>状态说明<input v-model="ctx.feedbackUpdate.statusDetail" /></label>
|
||||
<label>公开回复<textarea v-model="ctx.feedbackUpdate.publicReply" rows="3"></textarea></label>
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存状态</button>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveFeedbackUpdate"><Save :size="16" />保存工单</button>
|
||||
<button class="btn ghost" @click="ctx.retryFeedbackMail"><Mail :size="16" />重试邮件</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h3>评论</h3>
|
||||
<div class="comment-list">
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment"><strong>{{ item.author }}</strong><p>{{ item.body }}</p></div>
|
||||
<div v-for="item in ctx.selectedFeedback.comments || []" :key="item.id" class="comment">
|
||||
<strong>{{ item.author }}</strong>
|
||||
<p>{{ item.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>新增评论<textarea v-model="ctx.commentDraft.body" rows="3"></textarea></label>
|
||||
<label class="checkbox"><input v-model="ctx.commentDraft.internal" type="checkbox" />内部备注</label>
|
||||
<button class="btn ghost" @click="ctx.addFeedbackComment">添加评论</button>
|
||||
|
||||
<details>
|
||||
<summary>旧反馈事件 / 邮件记录</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents, mail: ctx.selectedFeedback.mailRecords }) }}</pre>
|
||||
<summary>邮件记录</summary>
|
||||
<table>
|
||||
<thead><tr><th>状态</th><th>收件人</th><th>主题</th><th>错误</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.selectedFeedback.mailRecords || []" :key="item.id">
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.toAddress || "-" }}</td>
|
||||
<td>{{ item.subject || "-" }}</td>
|
||||
<td>{{ item.errorMessage || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.selectedFeedback.mailRecords || []).length"><td colspan="4">暂无邮件记录。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<details>
|
||||
<summary>旧反馈事件</summary>
|
||||
<pre class="json-preview small">{{ ctx.pretty({ events: ctx.selectedFeedback.legacyEvents }) }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
<div v-else class="empty-state">选择一条工单查看详情。</div>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<h2>健康快照</h2>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,30 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split wide-split">
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||
</div>
|
||||
<p class="muted">可视化表单只维护常用字段,保存时会合并回当前 JSON,未识别字段继续保留。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="page-stack">
|
||||
<section class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="4"></textarea></label>
|
||||
</section>
|
||||
|
||||
<section class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>下载镜像</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal()"><Plus :size="14" />新增镜像</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>状态</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(mirror, index) in ctx.legacyDrafts['update-info'].form.download_mirrors || []" :key="mirror.id || index">
|
||||
<td class="mono">{{ mirror.id }}</td>
|
||||
<td>{{ mirror.name }}</td>
|
||||
<td>{{ mirror.type || "direct" }}</td>
|
||||
<td><span :class="['badge', mirror.enabled === false ? 'neutral' : 'good']">{{ mirror.enabled === false ? "停用" : "启用" }}</span></td>
|
||||
<td class="hash">{{ mirror.url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openUpdateMirrorModal(index)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['update-info'].form.download_mirrors, index)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.legacyDrafts['update-info'].form.download_mirrors || []).length"><td colspan="6">暂无镜像。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>高级 JSON 字段</summary>
|
||||
<div class="form-grid">
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="button-row"><button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="split legacy-media-editor">
|
||||
<section class="panel-soft page-stack">
|
||||
<div class="form-grid">
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="section-head">
|
||||
<h3>分类</h3>
|
||||
<button class="btn ghost compact" @click="ctx.openMediaCategoryModal()"><Plus :size="14" />新增分类</button>
|
||||
</div>
|
||||
<div class="category-list" v-if="ctx.legacyDrafts['media-types'].form.categories.length">
|
||||
<button
|
||||
v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories"
|
||||
:key="cat.id || cIndex"
|
||||
type="button"
|
||||
:class="{ active: ctx.activeMediaCategoryIndex === cIndex }"
|
||||
@click="ctx.selectMediaCategory(cIndex)"
|
||||
>
|
||||
<span><strong>{{ cat.name || cat.id || `分类 ${cIndex + 1}` }}</strong><small class="mono">{{ cat.id || "-" }}</small></span>
|
||||
<span class="badge">{{ cat.subcategories?.length || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">暂无分类。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel-soft page-stack media-subcategory-panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
|
||||
<p class="muted">右侧仅显示当前选中分类下的子接口。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex)"><Plus :size="14" />新增子接口</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="ctx.activeMediaCategory" class="source-group">
|
||||
<div class="button-row">
|
||||
<span :class="['badge', ctx.activeMediaCategory.enabled === false ? 'neutral' : 'good']">{{ ctx.activeMediaCategory.enabled === false ? "停用" : "启用" }}</span>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, ctx.activeMediaCategoryIndex)"><Trash2 :size="14" />删除分类</button>
|
||||
</div>
|
||||
<div class="table-scroll media-subcategory-table">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>名称</th><th>刷新</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(sub, sIndex) in ctx.activeMediaCategory.subcategories || []" :key="sub.id || sIndex">
|
||||
<td class="mono">{{ sub.id }}</td>
|
||||
<td>{{ sub.name }}</td>
|
||||
<td>{{ sub.refresh_interval || 300 }}s</td>
|
||||
<td class="hash url-cell" :title="sub.api_url">{{ sub.api_url }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.openMediaSubcategoryModal(ctx.activeMediaCategoryIndex, sIndex)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.removeItem(ctx.activeMediaCategory.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.activeMediaCategory.subcategories || []).length"><td colspan="5">当前分类暂无子接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<div v-else class="empty-state compact">请选择或新增一个分类。</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
||||
<textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
|
||||
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
|
||||
</section>
|
||||
<aside class="panel page-stack">
|
||||
<h2>预览与历史</h2>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||
<div class="revision-list">
|
||||
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||
</button>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview'">
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||
</section>
|
||||
|
||||
<section v-else class="revision-list">
|
||||
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||
</button>
|
||||
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.legacyModal.open" class="modal-backdrop" @click.self="ctx.closeLegacyModal">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>{{ ctx.legacyModal.type === 'media-category' ? '分类' : ctx.legacyModal.type === 'media-subcategory' ? '子接口' : '下载镜像' }}</h2>
|
||||
<button class="btn ghost compact" @click="ctx.closeLegacyModal">关闭</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.legacyModal.type === 'media-category'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用分类</label>
|
||||
</div>
|
||||
|
||||
<div v-else-if="ctx.legacyModal.type === 'media-subcategory'" class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="ctx.legacyModal.draft.api_url" /></label>
|
||||
<label>缩略图<input v-model="ctx.legacyModal.draft.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="ctx.legacyModal.draft.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="ctx.legacyModal.draft.supported_formats" placeholder="json, mp4, webp" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="ctx.legacyModal.draft.description" rows="2"></textarea></label>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-grid">
|
||||
<label>ID<input v-model="ctx.legacyModal.draft.id" /></label>
|
||||
<label>名称<input v-model="ctx.legacyModal.draft.name" /></label>
|
||||
<label>类型<input v-model="ctx.legacyModal.draft.type" /></label>
|
||||
<label class="wide">URL<input v-model="ctx.legacyModal.draft.url" /></label>
|
||||
<label>SHA256<input v-model="ctx.legacyModal.draft.sha256" /></label>
|
||||
<label class="checkbox"><input v-model="ctx.legacyModal.draft.enabled" type="checkbox" />启用</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.applyLegacyModal">保存</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
import { CheckCircle2, Save, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -9,8 +9,39 @@ defineProps<{ ctx: any }>();
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>发布包</h2>
|
||||
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a>
|
||||
<span class="badge">{{ ctx.releasePackages.length }} 个文件</span>
|
||||
</div>
|
||||
<section class="nested-card upload-card">
|
||||
<div class="section-head">
|
||||
<h3>上传最新版本包</h3>
|
||||
<span class="badge neutral">保存到下载目录</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="wide">安装包<input type="file" accept=".exe,.msix,.appinstaller,.msi,.zip,.7z" @change="ctx.onPackageSelected" /></label>
|
||||
<label>版本号<input v-model="ctx.uploadDraft.version" placeholder="2.0.6.31" /></label>
|
||||
<label>平台<select v-model="ctx.uploadDraft.platform"><option value="windows">Windows</option><option value="linux">Linux</option></select></label>
|
||||
<label>架构<select v-model="ctx.uploadDraft.arch"><option value="x64">x64</option><option value="x86">x86</option><option value="arm64">arm64</option></select></label>
|
||||
<label>通道<select v-model="ctx.uploadDraft.channel"><option value="stable">stable</option><option value="beta">beta</option></select></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
|
||||
</div>
|
||||
<div v-if="ctx.uploadDraft.file || ctx.uploadDraft.uploading || ctx.uploadDraft.status" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<strong>{{ ctx.uploadDraft.status || "等待上传" }}</strong>
|
||||
<span>{{ ctx.uploadDraft.progress || 0 }}%</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${ctx.uploadDraft.progress || 0}%` }"></span>
|
||||
</div>
|
||||
<small>
|
||||
{{ ctx.uploadDraft.file?.name || "发布包" }}
|
||||
<template v-if="ctx.uploadDraft.totalBytes">
|
||||
· {{ ctx.formatBytes(ctx.uploadDraft.loadedBytes || 0) }} / {{ ctx.formatBytes(ctx.uploadDraft.totalBytes || 0) }}
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
|
||||
</section>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -25,13 +56,18 @@ defineProps<{ ctx: any }>();
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<div class="section-head"><h2>版本日志</h2><button class="btn ghost" @click="ctx.importNotices">导入目录</button></div>
|
||||
<aside class="panel editor-panel compact-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>版本日志</h2>
|
||||
<p class="muted">以 update-info.json 模板为基础动态生成更新信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="revision-list">
|
||||
<button v-for="item in ctx.releaseNotices" :key="item.version" :class="{ active: ctx.noticeDraft.version === item.version }" @click="ctx.openNotice(item.version)">
|
||||
<strong>{{ item.version }}</strong><small>{{ item.title || item.updatedAt }}</small>
|
||||
</button>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可先执行导入。</div>
|
||||
<div v-if="ctx.releaseNotices.length === 0" class="empty-state compact">暂无版本日志,可直接填写版本和 Raw JSON 后保存。</div>
|
||||
</div>
|
||||
<label>版本<input v-model="ctx.noticeDraft.version" placeholder="1.0.0" /></label>
|
||||
<label>Raw JSON<textarea v-model="ctx.noticeDraft.raw" class="code-editor compact-editor"></textarea></label>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" /></label>
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>旧项目同步预览</h2><button class="btn ghost" @click="ctx.previewLegacySync">刷新预览</button></div>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行同步</button>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
const tabs = [
|
||||
{ id: "database", label: "数据库", icon: Database },
|
||||
{ id: "migration", label: "迁移状态", icon: HardDrive },
|
||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||
{ id: "security", label: "安全与邮件", icon: ShieldCheck },
|
||||
{ id: "health", label: "健康快照", icon: Activity },
|
||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<nav class="tabs" aria-label="系统运维标签">
|
||||
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: ctx.systemTab === tab.id }" @click="ctx.setSystemTab(tab.id)">
|
||||
<component :is="tab.icon" :size="15" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="ctx.systemTab === 'database'" class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>数据库运行状态</h2>
|
||||
<span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>Schema</span><strong>{{ ctx.database?.schemaVersion || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>恢复时间</span><strong>{{ ctx.database?.lastRecoveredAt || "-" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
||||
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
||||
<div><span><Clock3 :size="15" />影响记录</span><strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong></div>
|
||||
</div>
|
||||
<div v-if="ctx.databaseLastSync?.warnings?.length" class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>{{ ctx.databaseLastSync.warnings.join(";") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>连接与同步</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.databaseFormEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.databaseFormEditing" class="btn ghost compact" type="button" @click="ctx.reloadDatabaseConfig">重新读取配置</button>
|
||||
<button v-if="ctx.databaseConfigCollapsed" class="btn ghost compact" type="button" @click="ctx.editDatabaseConfig">修改配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ctx.databaseConfigCollapsed" class="kv-grid">
|
||||
<span>当前配置</span><strong>{{ ctx.databaseConfigSummary() }}</strong>
|
||||
<span>密码状态</span><strong>{{ ctx.databaseConfig?.hasPassword ? "已保存,前端不回显" : "未保存" }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-stack" @input="ctx.markDatabaseFormEditing" @change="ctx.markDatabaseFormEditing">
|
||||
<label>Provider
|
||||
<select v-model="ctx.databaseForm.provider">
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="ctx.databaseForm.provider === 'sqlite'">SQLite 路径
|
||||
<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" />
|
||||
</label>
|
||||
<div v-else class="form-grid">
|
||||
<label>主机<input v-model="ctx.databaseForm.mysqlHost" placeholder="127.0.0.1" /></label>
|
||||
<label>端口<input v-model.number="ctx.databaseForm.mysqlPort" type="number" min="1" placeholder="3306" /></label>
|
||||
<label>数据库名<input v-model="ctx.databaseForm.mysqlDatabase" placeholder="ymhut_unified" /></label>
|
||||
<label>数据库用户<input v-model="ctx.databaseForm.mysqlUser" autocomplete="username" /></label>
|
||||
<label class="wide">数据库密码
|
||||
<input v-model="ctx.databaseForm.mysqlPassword" type="password" autocomplete="new-password" :placeholder="ctx.databaseConfig?.hasPassword ? '留空沿用已保存密码' : '请输入数据库密码'" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'migration'" class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库优先迁移</h2><button class="btn ghost" @click="ctx.loadMigrationStatus"><RefreshCw :size="16" />刷新</button></div>
|
||||
<div class="kv-grid">
|
||||
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
||||
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
||||
<span>Schema</span><strong>{{ ctx.migrationStatus?.schemaVersion || "-" }}</strong>
|
||||
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.migrationStatus?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>Failover</span><strong>{{ ctx.migrationStatus?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库保存站点结构化状态;发布包、下载文件和反馈附件仍属于文件资产,迁移时需要连同数据库一起备份。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel page-stack">
|
||||
<h2>数据库覆盖范围</h2>
|
||||
<ul class="plain-list">
|
||||
<li v-for="item in ctx.migrationStatus?.databaseCovers || []" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<h2>文件资产目录</h2>
|
||||
<div v-for="asset in ctx.migrationStatus?.fileAssets || []" :key="asset.name" class="asset-row">
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span>{{ asset.description }}</span>
|
||||
<code>{{ asset.path }}</code>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'sync'" class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>旧项目同步</h2>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行前会备份当前兼容输出,再复制旧项目数据并导入记录。</p>
|
||||
</div>
|
||||
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
||||
</div>
|
||||
<div class="sync-summary">
|
||||
<div><span>当前模式</span><strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong></div>
|
||||
<div><span>完成时间</span><strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong></div>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
||||
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
|
||||
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
|
||||
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
||||
</div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<div class="button-row"><button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" autocomplete="current-password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" autocomplete="new-password" /></label>
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>站点品牌</h2>
|
||||
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||
</div>
|
||||
<div class="brand-preview">
|
||||
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||
<strong>{{ ctx.branding.developerName }}</strong>
|
||||
</div>
|
||||
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
|
||||
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
|
||||
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>反馈邮件通知</h2>
|
||||
<div class="button-row compact-row">
|
||||
<span v-if="ctx.mailConfigEditing" class="badge warn">编辑中,自动刷新不会覆盖表单</span>
|
||||
<button v-if="ctx.mailConfigEditing" class="btn ghost compact" type="button" @click="ctx.reloadMailConfig">重新读取配置</button>
|
||||
<span :class="['badge', ctx.mailConfig.configured ? 'good' : 'warn']">{{ ctx.mailConfig.configured ? "已配置" : "未完成" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid" @input="ctx.markMailConfigEditing" @change="ctx.markMailConfigEditing">
|
||||
<label>SMTP 主机<input v-model="ctx.mailConfig.host" placeholder="smtp.example.com" /></label>
|
||||
<label>端口<input v-model.number="ctx.mailConfig.port" type="number" min="1" /></label>
|
||||
<label>加密方式
|
||||
<select v-model="ctx.mailConfig.secure">
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="none">不加密</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>账号<input v-model="ctx.mailConfig.username" autocomplete="username" /></label>
|
||||
<label>密码<input v-model="ctx.mailConfig.password" type="password" autocomplete="new-password" :placeholder="ctx.mailConfig.hasPassword ? '留空沿用已保存密码' : '请输入 SMTP 密码'" /></label>
|
||||
<label>超时秒数<input v-model.number="ctx.mailConfig.timeoutSeconds" type="number" min="3" /></label>
|
||||
<label>发件地址<input v-model="ctx.mailConfig.fromAddress" /></label>
|
||||
<label>发件名称<input v-model="ctx.mailConfig.fromName" /></label>
|
||||
<label class="wide">开发者收件地址<input v-model="ctx.mailConfig.developerAddress" :placeholder="ctx.branding.feedbackEmail" /></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.saveMailConfig"><Mail :size="16" />保存邮件配置</button>
|
||||
<button class="btn ghost" @click="ctx.testMail">发送测试邮件</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'health'" class="panel page-stack">
|
||||
<div class="section-head"><h2>健康快照</h2><span class="badge neutral">只读</span></div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
|
||||
<section v-else class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>审计日志</h2>
|
||||
<button class="btn ghost" @click="ctx.loadAudit">刷新</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input v-model="ctx.auditPage.q" placeholder="搜索操作、目标、信息或 IP" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.type" placeholder="类型,例如 source.deleted" @keyup.enter="ctx.loadAudit" />
|
||||
<input v-model="ctx.auditPage.target" placeholder="目标" @keyup.enter="ctx.loadAudit" />
|
||||
<button class="btn ghost" @click="ctx.auditPage.page = 1; ctx.loadAudit()">筛选</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditPage.items" :key="item.id" class="clickable" @click="ctx.selectAuditLog(item)">
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditPage.items.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager">
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page <= 1" @click="ctx.setAuditPage(ctx.auditPage.page - 1)">上一页</button>
|
||||
<span>第 {{ ctx.auditPage.page }} 页 / 共 {{ Math.max(1, Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)) }} 页,{{ ctx.auditPage.total }} 条</span>
|
||||
<button class="btn ghost compact" :disabled="ctx.auditPage.page >= Math.ceil(ctx.auditPage.total / ctx.auditPage.perPage)" @click="ctx.setAuditPage(ctx.auditPage.page + 1)">下一页</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ctx.auditPage.selected" class="modal-backdrop" @click.self="ctx.auditPage.selected = null">
|
||||
<section class="modal-panel">
|
||||
<div class="section-head">
|
||||
<h2>审计详情</h2>
|
||||
<button class="btn ghost compact" @click="ctx.auditPage.selected = null">关闭</button>
|
||||
</div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.auditPage.selected) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -4,9 +4,21 @@ import vue from "@vitejs/plugin-vue";
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ["vue", "vue-router"],
|
||||
charts: ["echarts", "vue-echarts"],
|
||||
icons: ["lucide-vue-next"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
"/api": "http://127.0.0.1:33550",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo-150.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="author" content="YMhut" />
|
||||
<meta name="application-name" content="YMhut Box" />
|
||||
<meta name="description" content="YMhut Box unified update, feedback, and interface source status portal for update.ymhut.cn." />
|
||||
<meta name="theme-color" content="#10231d" />
|
||||
<title>YMhut Box Service Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network } from "lucide-vue-next";
|
||||
import { usePortalState } from "./state";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -22,19 +22,19 @@ onMounted(() => state.load());
|
||||
<main class="portal-shell">
|
||||
<nav class="topnav">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span><ShieldCheck :size="22" /></span>
|
||||
<strong>YMhut Box</strong>
|
||||
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
|
||||
<strong>{{ state.branding.value.developerName }}</strong>
|
||||
</RouterLink>
|
||||
<div class="nav-links">
|
||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
||||
<component :is="item.icon" :size="15" />{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<a class="admin-link" href="/admin/login">控制台</a>
|
||||
</nav>
|
||||
|
||||
<p v-if="state.error.value" class="state-banner error">部分状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取服务状态...</p>
|
||||
<p v-if="state.error.value" class="state-banner error">服务状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取客户端公开状态...</p>
|
||||
<p v-else-if="state.loadedAt.value" class="state-banner ready">公开状态已更新:{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</p>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const routes = [
|
||||
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" },
|
||||
{ path: "/api/client/releases", label: "新版发布信息" },
|
||||
{ path: "/api/client/sources", label: "新版接口源目录" },
|
||||
{ path: "/update-info.json", label: "旧版更新 JSON" },
|
||||
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
|
||||
{ path: "/tool-status.json", label: "旧版工具状态" },
|
||||
{ path: "/modules.json", label: "旧版模块清单" },
|
||||
const items = [
|
||||
{ title: "旧版更新能力", body: "旧客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
|
||||
{ title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
|
||||
{ title: "新版动态配置", body: "新版客户端优先读取发布、接口源、健康状态和缓存策略,失败时可回退旧路径。" },
|
||||
{ title: "反馈兼容", body: "反馈提交和状态查询入口继续保留,查询结果只展示公开进度。" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -14,16 +11,13 @@ const routes = [
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Compatibility</p>
|
||||
<h1>兼容说明</h1>
|
||||
<p>新旧客户端共用 update.ymhut.cn,旧路径和旧 JSON 字段继续保留。</p>
|
||||
<p>新旧客户端共用 update.ymhut.cn。门户只展示能力说明,具体接口由客户端自动选择。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
<h2>公开路径</h2>
|
||||
<div class="route-list">
|
||||
<a v-for="item in routes" :key="item.path" :href="item.path">
|
||||
<strong>{{ item.path }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<section class="content-grid">
|
||||
<article v-for="item in items" :key="item.title" class="panel compat-card">
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.body }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h1>反馈查询</h1>
|
||||
<p>旧客户端继续向根路径提交反馈。已有反馈可通过反馈码查询处理状态。</p>
|
||||
<p>已有反馈可通过反馈码查询公开处理状态。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel feedback-panel">
|
||||
@@ -19,6 +19,6 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
|
||||
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
|
||||
</div>
|
||||
<p class="muted">反馈提交接口保持旧版兼容:客户端仍可 POST 到服务根路径。</p>
|
||||
<p class="muted">状态查询只返回公开进度、公开回复和接收时间,不展示后台内部处理记录。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, ArrowDownToLine, Database, ExternalLink, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import { Activity, ArrowDownToLine, Box, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
@@ -10,20 +10,19 @@ const state = usePortalState();
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>统一发布、反馈与接口源状态门户</h1>
|
||||
<p>
|
||||
新版客户端通过 bootstrap 动态获取发布信息、版本日志、媒体/数据源目录和接口健康状态。旧客户端仍可继续访问
|
||||
update-info.json、media-types.json、下载路径和反馈根路径。
|
||||
</p>
|
||||
<p>统一展示 YMhut Box 的发布状态、反馈入口、接口源可用性与版本日志。新版客户端动态读取服务配置,旧客户端兼容能力继续保留。</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
|
||||
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a>
|
||||
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink>
|
||||
<a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
|
||||
<RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
|
||||
<RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
|
||||
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<span>Legacy JSON 兼容</span>
|
||||
<span>接口健康检测</span>
|
||||
<span>反馈状态追踪</span>
|
||||
</div>
|
||||
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口,请稍后刷新。</p>
|
||||
</div>
|
||||
|
||||
<aside class="release-card">
|
||||
@@ -39,7 +38,7 @@ const state = usePortalState();
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric"><Database :size="20" /><span>数据库</span><strong>{{ state.databaseStatus.value }}</strong></article>
|
||||
<article class="metric"><Box :size="20" /><span>服务版本</span><strong>{{ state.serviceVersion.value }}</strong></article>
|
||||
<article class="metric"><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></article>
|
||||
<article class="metric"><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></article>
|
||||
<article class="metric"><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></article>
|
||||
@@ -47,7 +46,7 @@ const state = usePortalState();
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="section-head"><h2>服务入口</h2><a href="/api/client/bootstrap">Bootstrap <ExternalLink :size="14" /></a></div>
|
||||
<div class="section-head"><h2>服务入口</h2><span class="badge good">运行中</span></div>
|
||||
<div class="route-list">
|
||||
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包、版本公告和 update-notice 日志</span></RouterLink>
|
||||
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源、数据源和动态客户端接口状态</span></RouterLink>
|
||||
@@ -60,7 +59,8 @@ const state = usePortalState();
|
||||
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
|
||||
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
|
||||
</div>
|
||||
<p v-else class="empty">暂无远程版本日志。可在后台“发布与日志”中导入 update-notice。</p>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else class="empty">暂无可展示的版本日志。</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { BookOpenText, ExternalLink } from "lucide-vue-next";
|
||||
import { BookOpenText } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
@@ -9,12 +9,12 @@ const state = usePortalState();
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Releases</p>
|
||||
<h1>发布版本</h1>
|
||||
<p>展示发布包、下载入口和 update-notice 版本日志。</p>
|
||||
<p>展示客户端可见的发布包、下载入口和版本日志。</p>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>发布包</h2><a href="/update-info.json">旧版 update-info.json <ExternalLink :size="14" /></a></div>
|
||||
<div class="section-head"><h2>发布包</h2><span class="badge">{{ state.packages.value.length }} 个可用包</span></div>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@@ -25,13 +25,15 @@ const state = usePortalState();
|
||||
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
|
||||
</tr>
|
||||
<tr v-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包,旧客户端接口仍保持可用。</td></tr>
|
||||
<tr v-if="state.loading.value"><td colspan="5">正在读取发布包...</td></tr>
|
||||
<tr v-else-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败:{{ state.error.value }}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>版本日志</h2><a href="/api/client/notices">Notices API <ExternalLink :size="14" /></a></div>
|
||||
<div class="section-head"><h2>版本日志</h2><span class="badge good">自动同步</span></div>
|
||||
<div class="notice-list">
|
||||
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
|
||||
<BookOpenText :size="22" />
|
||||
@@ -41,7 +43,8 @@ const state = usePortalState();
|
||||
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||
<p v-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, ExternalLink } from "lucide-vue-next";
|
||||
import { CheckCircle2 } from "lucide-vue-next";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
@@ -9,11 +9,11 @@ const state = usePortalState();
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Sources</p>
|
||||
<h1>接口源健康</h1>
|
||||
<p>媒体源、数据源和客户端动态接口目录的可用性汇总。</p>
|
||||
<p>客户端可见接口目录和最近健康状态汇总。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
<div class="section-head"><h2>接口源可用性</h2><a href="/api/client/sources">Sources API <ExternalLink :size="14" /></a></div>
|
||||
<div class="section-head"><h2>接口源可用性</h2><span class="badge">{{ state.sourceCount.value }} 个接口源</span></div>
|
||||
<div v-if="state.categories.value.length" class="source-board">
|
||||
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
|
||||
<div>
|
||||
@@ -22,11 +22,13 @@ const state = usePortalState();
|
||||
</div>
|
||||
<div class="source-list">
|
||||
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
|
||||
<CheckCircle2 :size="13" />{{ src.name }}
|
||||
<CheckCircle2 :size="13" />{{ src.name }}<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="empty">暂无接口源数据。后台同步旧 media-types.json 或手动添加后会显示在这里。</p>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
|
||||
<p v-else-if="state.error.value" class="empty">接口源状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else class="empty">暂无客户端可见接口源。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -6,12 +6,39 @@ const sources = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const loadedAt = ref("");
|
||||
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
|
||||
bootstrap: "idle",
|
||||
releases: "idle",
|
||||
sources: "idle",
|
||||
notices: "idle",
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
const endpointLabels: Record<string, string> = {
|
||||
"/api/client/bootstrap": "客户端启动配置",
|
||||
"/api/client/releases": "发布信息",
|
||||
"/api/client/sources": "接口源目录",
|
||||
"/api/client/notices": "版本日志",
|
||||
};
|
||||
|
||||
async function fetchJSON(path: string) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
|
||||
return res.json();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { headers: { Accept: "application/json" } });
|
||||
} catch {
|
||||
throw new Error(`${endpointLabels[path] || path} 暂时无法连接`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`${endpointLabels[path] || path} 返回 HTTP ${res.status}`);
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new Error(`${endpointLabels[path] || path} 返回内容不是有效 JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : String(reason || "读取失败");
|
||||
}
|
||||
|
||||
export function usePortalState() {
|
||||
@@ -23,15 +50,25 @@ export function usePortalState() {
|
||||
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
|
||||
}, 0));
|
||||
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
|
||||
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || "/update-info.json");
|
||||
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
|
||||
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||
const databaseStatus = computed(() => bootstrap.value?.health?.database?.activeProvider || bootstrap.value?.health?.database?.configProvider || "-");
|
||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||
const branding = computed(() => ({
|
||||
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
||||
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
||||
developerName: bootstrap.value?.branding?.developerName || "YMhut",
|
||||
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
|
||||
}));
|
||||
const isReady = computed(() => loaded && !loading.value && !error.value);
|
||||
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
|
||||
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
|
||||
const sourcesEmpty = computed(() => !loading.value && categories.value.length === 0);
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded && !force) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
|
||||
try {
|
||||
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
|
||||
fetchJSON("/api/client/bootstrap"),
|
||||
@@ -39,15 +76,36 @@ export function usePortalState() {
|
||||
fetchJSON("/api/client/sources"),
|
||||
fetchJSON("/api/client/notices"),
|
||||
]);
|
||||
if (bootstrapData.status === "fulfilled") bootstrap.value = bootstrapData.value;
|
||||
if (releaseData.status === "fulfilled") releases.value = releaseData.value;
|
||||
if (sourceData.status === "fulfilled") sources.value = sourceData.value;
|
||||
if (noticeData.status === "fulfilled") notices.value = noticeData.value.items || [];
|
||||
if (bootstrapData.status === "fulfilled") {
|
||||
bootstrap.value = bootstrapData.value;
|
||||
requestState.value.bootstrap = "ready";
|
||||
} else {
|
||||
requestState.value.bootstrap = "error";
|
||||
}
|
||||
if (releaseData.status === "fulfilled") {
|
||||
releases.value = releaseData.value;
|
||||
requestState.value.releases = "ready";
|
||||
} else {
|
||||
requestState.value.releases = "error";
|
||||
}
|
||||
if (sourceData.status === "fulfilled") {
|
||||
sources.value = sourceData.value;
|
||||
requestState.value.sources = "ready";
|
||||
} else {
|
||||
requestState.value.sources = "error";
|
||||
}
|
||||
if (noticeData.status === "fulfilled") {
|
||||
notices.value = noticeData.value.items || [];
|
||||
requestState.value.notices = "ready";
|
||||
} else {
|
||||
requestState.value.notices = "error";
|
||||
}
|
||||
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
|
||||
if (firstFailure && !bootstrap.value) error.value = firstFailure.reason?.message || String(firstFailure.reason);
|
||||
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
|
||||
loaded = true;
|
||||
loadedAt.value = new Date().toISOString();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
error.value = failureMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -60,6 +118,8 @@ export function usePortalState() {
|
||||
notices,
|
||||
loading,
|
||||
error,
|
||||
loadedAt,
|
||||
requestState,
|
||||
packages,
|
||||
categories,
|
||||
latestNotice,
|
||||
@@ -68,8 +128,12 @@ export function usePortalState() {
|
||||
availability,
|
||||
downloadUrl,
|
||||
appVersion,
|
||||
databaseStatus,
|
||||
serviceVersion,
|
||||
branding,
|
||||
isReady,
|
||||
hasPartialData,
|
||||
releasesEmpty,
|
||||
sourcesEmpty,
|
||||
load,
|
||||
sourceStatus,
|
||||
statusTone,
|
||||
@@ -83,7 +147,7 @@ export function sourceStatus(item: any) {
|
||||
|
||||
export function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
|
||||
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
|
||||
if (["degraded", "pending", "missing"].includes(value)) return "warn";
|
||||
if (["error", "offline", "failed"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
|
||||
@@ -2,23 +2,22 @@
|
||||
color-scheme: light;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||
color: #172033;
|
||||
background: #f7f9ff;
|
||||
background: #f5f7f4;
|
||||
--ink: #172033;
|
||||
--muted: #63718a;
|
||||
--soft: #f7f9ff;
|
||||
--soft: #f5f7f4;
|
||||
--panel: rgba(255, 255, 255, 0.82);
|
||||
--panel-strong: #ffffff;
|
||||
--line: rgba(112, 132, 170, 0.18);
|
||||
--line-strong: rgba(94, 114, 158, 0.28);
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--cyan: #06b6d4;
|
||||
--violet: #8b5cf6;
|
||||
--pink: #f472b6;
|
||||
--primary: #1f6f5b;
|
||||
--primary-dark: #155241;
|
||||
--accent: #d99227;
|
||||
--good: #059669;
|
||||
--warn: #b7791f;
|
||||
--bad: #dc2626;
|
||||
--shadow: 0 22px 65px rgba(65, 88, 140, 0.16);
|
||||
--shadow: 0 22px 65px rgba(31, 48, 40, 0.12);
|
||||
--ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -26,21 +25,14 @@ html { min-width: 320px; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 8% 6%, rgba(96, 165, 250, 0.30), transparent 28%),
|
||||
radial-gradient(circle at 88% 8%, rgba(244, 114, 182, 0.20), transparent 30%),
|
||||
linear-gradient(180deg, #eef6ff 0%, #f8fbff 42%, #ffffff 100%);
|
||||
background: #f6f8f4;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 70%);
|
||||
background: rgba(31, 111, 91, 0.025);
|
||||
}
|
||||
a { color: inherit; }
|
||||
button, input { font: inherit; }
|
||||
@@ -87,8 +79,14 @@ button { cursor: pointer; }
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--primary), var(--cyan));
|
||||
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26);
|
||||
background: #10231d;
|
||||
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
|
||||
}
|
||||
.brand img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.brand strong { letter-spacing: 0; }
|
||||
.nav-links {
|
||||
@@ -97,7 +95,7 @@ button { cursor: pointer; }
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-links a, .admin-link {
|
||||
.nav-links a {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -108,18 +106,13 @@ button { cursor: pointer; }
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
transition: transform 0.18s var(--ease), background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--primary-dark);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
background: rgba(31, 111, 91, 0.10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.admin-link {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.24);
|
||||
}
|
||||
.admin-link:hover { box-shadow: 0 16px 36px rgba(59, 130, 246, 0.32); }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
@@ -132,24 +125,12 @@ button { cursor: pointer; }
|
||||
align-items: stretch;
|
||||
border: 1px solid rgba(255, 255, 255, 0.70);
|
||||
border-radius: 32px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)),
|
||||
radial-gradient(circle at 88% 18%, rgba(14, 165, 233, 0.26), transparent 34%),
|
||||
radial-gradient(circle at 18% 82%, rgba(139, 92, 246, 0.18), transparent 30%);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(28px, 5vw, 58px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
bottom: -120px;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.20), transparent 68%);
|
||||
}
|
||||
.hero::after { content: none; }
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -193,7 +174,7 @@ p {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.hero-tags span {
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
border: 1px solid rgba(31, 111, 91, 0.16);
|
||||
border-radius: 999px;
|
||||
padding: 7px 11px;
|
||||
color: #355075;
|
||||
@@ -215,7 +196,7 @@ p {
|
||||
color: #263856;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -225,8 +206,8 @@ p {
|
||||
.button.primary {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, #2563eb, #06b6d4);
|
||||
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26);
|
||||
background: #10231d;
|
||||
box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
|
||||
}
|
||||
|
||||
.release-card, .panel, .metric {
|
||||
@@ -236,6 +217,13 @@ p {
|
||||
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.release-card, .panel, .metric, .source-group, .notice-card, .route-list a {
|
||||
transition: transform 0.22s var(--ease), border-color 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease;
|
||||
}
|
||||
.release-card:hover, .panel:hover, .metric:hover, .source-group:hover, .notice-card:hover, .route-list a:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 46px rgba(31, 48, 40, 0.13);
|
||||
}
|
||||
.release-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -322,7 +310,7 @@ p {
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.68));
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(24px, 4vw, 42px);
|
||||
backdrop-filter: blur(16px);
|
||||
@@ -363,6 +351,7 @@ th {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.muted, .empty { color: var(--muted); }
|
||||
.empty.strong { font-weight: 900; color: var(--bad); }
|
||||
.notice-list { display: grid; gap: 12px; }
|
||||
.notice-card {
|
||||
display: grid;
|
||||
@@ -396,8 +385,8 @@ input {
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.65);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||
border-color: rgba(31, 111, 91, 0.58);
|
||||
box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
|
||||
}
|
||||
.source-board {
|
||||
display: grid;
|
||||
@@ -434,7 +423,7 @@ input:focus {
|
||||
}
|
||||
.route-list a:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(59, 130, 246, 0.36);
|
||||
border-color: rgba(31, 111, 91, 0.30);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
|
||||
@@ -452,6 +441,7 @@ input:focus {
|
||||
}
|
||||
.error { color: var(--bad); }
|
||||
.loading { color: var(--muted); }
|
||||
.ready { color: var(--good); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.topnav {
|
||||
@@ -476,5 +466,5 @@ input:focus {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/setup/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/setup/logo-150.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="author" content="YMhut" />
|
||||
<meta name="application-name" content="YMhut Box Setup" />
|
||||
<meta name="description" content="YMhut Box unified management setup wizard for update.ymhut.cn." />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>YMhut Unified Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -25,7 +25,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg"
|
||||
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://v2.xxapi.cn/api/baisi?return=302",
|
||||
@@ -40,7 +41,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg"
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
|
||||
@@ -55,7 +57,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg"
|
||||
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.pearapi.ai/api/beautifulgirl?type=image",
|
||||
@@ -70,7 +73,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg"
|
||||
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://apii.ctose.cn/api/cy/api/",
|
||||
@@ -85,7 +89,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/"
|
||||
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.suyanw.cn/api/mao.php",
|
||||
@@ -100,7 +105,8 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/mao.php"
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/mao.php",
|
||||
"mediaType": "image"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.suyanw.cn/api/scenery.php",
|
||||
@@ -115,9 +121,11 @@
|
||||
"png",
|
||||
"webp"
|
||||
],
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php"
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php",
|
||||
"mediaType": "image"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kind": "image"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
@@ -143,7 +151,8 @@
|
||||
"mp4",
|
||||
"webm"
|
||||
],
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg"
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg",
|
||||
"mediaType": "video"
|
||||
},
|
||||
{
|
||||
"api_url": "https://api.mmp.cc/api/miss?type=mp4",
|
||||
@@ -156,9 +165,11 @@
|
||||
"mp4",
|
||||
"webm"
|
||||
],
|
||||
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg"
|
||||
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg",
|
||||
"mediaType": "video"
|
||||
}
|
||||
]
|
||||
],
|
||||
"kind": "video"
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-09-9T17:45:00Z",
|
||||
@@ -172,4 +183,4 @@
|
||||
"default_view": "grid",
|
||||
"show_thumbnails": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"modules": []
|
||||
}
|
||||
@@ -1,95 +1,60 @@
|
||||
{
|
||||
"api_keys": {
|
||||
"uapipro": ""
|
||||
},
|
||||
"app_version": "2.0.6.2",
|
||||
"build": "2",
|
||||
"channel": "stable",
|
||||
"title": "YMhut Box 2.0.6.2",
|
||||
"message": "本版本重点修复覆盖安装后白屏退出、用户目录 runtime 占用、语言包膨胀和设置页初始化问题,并继续完善 WinUI 3 工具型工作台体验。",
|
||||
"message_md": "# YMhut Box 2.0.6.2\n\n本版本继续收尾 WinUI 3 工具型工作台:修复覆盖安装/直启稳定性、用户目录 runtime 残留、自检结果页卡顿、排行榜/资讯显示、中文日志和设置页初始化问题,并新增安装器输出框、Markdown 公告、媒体播放器与随机放映室增强、价格/指标图表化展示。",
|
||||
"release_notes": "修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出;发布布局改为纯 lang\\zh-CN 与 lang\\en-US,移除多余语言包和旧 resources\\lang 压缩依赖;启动与自检链路禁止在用户数据目录保存 Runtime/runtime/Runtimes/runtimes 等运行时副本,旧残留会在启动和安装时清理;完善启动自检、安装完整性检查、服务状态结果页、工具箱与工具详情布局、结果/原始输出渲染、排行榜/资讯结构化显示、设置页控制中心和系统概况实时图表;修复设置页初始化失败、中文模式英文漏出、部分日志英文展示、天气胶囊图标缺失以及关闭确认记住选择等问题。",
|
||||
"release_notes_md": "## Bug 修复\n\n- 修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出,失败时写入清晰日志并显示可读错误。\n- 杜绝用户数据目录生成 Runtime/runtime/Runtimes/runtimes 或完整程序 payload 副本,旧残留会在启动和安装时清理。\n- 修复自检结果页加载大量历史明细时卡顿或短暂无响应的问题,改为确认后摘要优先、明细按需加载。\n- 修复中文模式下部分日志仍显示英文的问题,错误码、HTTP 状态和反馈状态仍保留必要原文。\n- 修复排行榜、热榜和资讯类工具被“已隐藏远程地址,仅展示脱敏来源名称”提示干扰后无法生成卡片的问题,同时继续隐藏远程 URL。\n- 修复设置页初始化失败、关闭确认记住选择、天气胶囊部分状态缺少动画图标等问题。\n\n## 支持增强\n\n- 发布布局统一为纯 `lang\\zh-CN` / `lang\\en-US`,移除多余语言包、根目录 culture 目录和旧 `resources\\lang` 压缩依赖。\n- 客户端公告、首页公告、关于页更新弹窗和更新日志弹窗支持 Markdown 标题、列表、表格、代码与链接,解析失败时回退纯文本。\n- 随机放映室支持远程媒体重定向解析,并为图片、视频、音频加载提供进度提示。\n- 数据类工具增强价格/指标结果展示,黄金价格等结果可同时显示摘要、表格和趋势折线图。\n\n## 新增能力\n\n- 安装引导程序在提取文件阶段新增只读输出框,展示旧版本清理、payload 提取、依赖检查和安装收尾进度。\n- 新增启动/安装完整性自检结果入口,服务状态页可确认后查看结果、复制摘要和导出 JSON。\n- 媒体播放器补齐播放列表、常用播放控制、倍速、音量、全屏、图片/视频/音频混播和常见系统解码格式入口。\n- 随机放映室迁移旧版展示思路,改为图片、视频、音频三段式远程媒体浏览。\n\n## 体验重构\n\n- 工具箱与工具详情页继续向高密度 WinUI 工具工作台收束,结果区固定提供“结果”和“原始输出”。\n- 设置控制中心增加分页滚动提示,系统概况图表补齐网格、坐标和实时信息。\n- 主题继续参考 Microsoft Store 与 Windows 媒体播放器的中性 Fluent 风格,不使用渐变,蓝色为主强调,橙/红仅用于警示。\n- 随机放映室和播放器 UI 更接近 Win11 媒体体验,加载、失败、保存、全屏等状态更清晰。",
|
||||
"category_list": [
|
||||
{
|
||||
"icon": "monitor",
|
||||
"id": "system",
|
||||
"name": "系统工具"
|
||||
},
|
||||
{
|
||||
"icon": "code",
|
||||
"id": "developer",
|
||||
"name": "开发工具"
|
||||
},
|
||||
{
|
||||
"icon": "image",
|
||||
"id": "image",
|
||||
"name": "图像工具"
|
||||
}
|
||||
],
|
||||
"detected_product": "YMhut Box",
|
||||
"detected_packages": {
|
||||
"YMhut Box": [
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
},
|
||||
{
|
||||
"version": "2.0.6.2",
|
||||
"extension": "exe",
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"downloadPath": "/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe",
|
||||
"size": "待发布",
|
||||
"sizeBytes": 0,
|
||||
"updateDate": "2026-06-14",
|
||||
"updateTime": "2026-06-14 00:00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"download_mirrors": [
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "light",
|
||||
"name": "轻量安装包",
|
||||
"sha256": "",
|
||||
"type": "direct",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2_Light.exe"
|
||||
}
|
||||
],
|
||||
"download_url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe",
|
||||
"home_notes": "v2.0.6.2 聚焦安装布局、启动稳定性和 WinUI 工作台体验:覆盖安装会清理旧程序布局和 runtime 残留,用户目录只保留设置、日志、缓存和轻量状态;语言资源只保留中英双语并归入 lang;工具箱、工具详情、结果渲染、排行榜/资讯卡片、设置控制中心、系统概况和服务状态页继续向原生 Fluent 工具软件风格收束。",
|
||||
"last_update_notes": {
|
||||
"v2.0.5.3 稳定性": "延续启动首页、插件扫描、反馈中心和日志展示修复;本版本进一步处理覆盖安装、语言资源和用户目录 runtime 残留。",
|
||||
"v2.0.5.3 安装包体积": "上一版新增轻量安装包通道;本版本继续收束语言包和旧资源布局,避免无关文件混入发布目录。",
|
||||
"v2.0.5.3 设置外观": "上一版加入窗口材质设置;本版本继续完善设置页控制中心、系统概况实时图表和中文本地化。"
|
||||
},
|
||||
"last_updated": "2026-06-14T00:00:00Z",
|
||||
"tool_metadata": {},
|
||||
"update_notes": {
|
||||
"启动与覆盖安装": "修复覆盖安装后启动白屏或应用自行退出的问题;旧压缩语言布局不再回退复制到用户目录,失败时写入清晰日志并显示可读错误。",
|
||||
"用户目录瘦身": "用户数据目录不再生成 Runtime、runtime、Runtimes 或 runtimes 文件夹;启动、自检和安装器都会清理旧 runtime 残留,避免大型运行时副本占用本机存储。",
|
||||
"语言资源布局": "发布布局统一为纯 lang,保留 lang\\zh-CN 与 lang\\en-US;移除其他语言包、根目录 culture 目录和 resources\\lang 压缩残留。",
|
||||
"启动自检": "迁移并改造旧版启动初始化思路:快速预检负责用户目录、设置、数据库、下载队列、安装根和关键资源;月度完整性检查在窗口可用后后台运行。",
|
||||
"自检结果页": "服务状态页新增启动自检与安装完整性入口;查看结果前增加确认提示和加载进度,历史先加载摘要,用户选择后再读取完整明细,降低大量结果渲染造成的卡顿。",
|
||||
"排行榜与资讯": "修复部分排行榜、热榜和资讯类工具因“已隐藏远程地址,仅展示脱敏来源名称”提示混入结构化解析而无法显示卡片内容的问题,同时继续隐藏远程地址。",
|
||||
"工具箱与工具详情": "工具箱改为更高密度的原生 WinUI 工作台布局;工具内容区域按功能类型优化输入、结果、原始输出、复制、搜索和导出体验。",
|
||||
"设置页": "修复设置页初始化失败;控制中心分页限制为 5 项可视并增加动态上下箭头提示;系统概况加入网格坐标、暗色绘图区和更多实时系统信息。",
|
||||
"主题与视觉": "主题改为微软商店/媒体播放器式中性 Fluent 风格,移除渐变主视觉,蓝色作为主强调色,橙红仅用于更新、警告和风险行为。",
|
||||
"天气胶囊": "补齐阴天、未知、离线等状态的基础图标和轻量动效,遵守关闭动画与高对比度设置。",
|
||||
"本地化与日志": "修复工具箱与安全、风险确认、默认工具范围、设置弹窗和高频日志的中文模式英文漏出;反馈码和原始错误信息仍保留必要英文。"
|
||||
}
|
||||
}
|
||||
"manifestVersion": 5,
|
||||
"latestVersion": "2.0.7.5",
|
||||
"appVersion": "2.0.7.5",
|
||||
"version": "2.0.7",
|
||||
"build": "05",
|
||||
"channel": "stable",
|
||||
"latest": {
|
||||
"version": "2.0.7.5",
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
||||
"size": 113484192,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
||||
"size": 259968386,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"appInstaller": {
|
||||
"fileName": "winui.appinstaller",
|
||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
||||
"size": 558,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"files": {
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
||||
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
||||
"size": 113484192,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
||||
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
||||
"size": 259968386,
|
||||
"version": "2.0.7.5"
|
||||
},
|
||||
"appInstaller": {
|
||||
"fileName": "winui.appinstaller",
|
||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
||||
"size": 558,
|
||||
"version": "2.0.7.5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
||||
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
||||
},
|
||||
"createdAt": "2026-06-27T08:54:56.8073504Z"
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace YMhut.Box.Core.Feedback;
|
||||
|
||||
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
||||
{
|
||||
public const string Endpoint = "https://mail-smtp.ymhut.cn/";
|
||||
public const string Endpoint = "https://update.ymhut.cn/";
|
||||
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
||||
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
|
||||
@@ -61,13 +61,20 @@ public sealed record RemoteMediaSource(
|
||||
string Name,
|
||||
string Description,
|
||||
string ApiUrl,
|
||||
string ResolvedUrl,
|
||||
string ResolvedKey,
|
||||
string MediaType,
|
||||
string ThumbnailUrl,
|
||||
bool Downloadable,
|
||||
int RefreshIntervalSeconds,
|
||||
IReadOnlyList<string> SupportedFormats,
|
||||
RemoteMediaKind Kind)
|
||||
{
|
||||
public bool IsAvailable => Uri.TryCreate(ApiUrl, UriKind.Absolute, out _);
|
||||
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
|
||||
|
||||
public string RefreshApiUrl => string.IsNullOrWhiteSpace(ApiUrl) ? EffectiveApiUrl : ApiUrl;
|
||||
|
||||
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
|
||||
|
||||
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
|
||||
|
||||
@@ -132,7 +139,11 @@ public static class RemoteMediaCatalogParser
|
||||
}
|
||||
|
||||
var id = JsonString(categoryElement, "id");
|
||||
var categoryKind = InferKind(id, []);
|
||||
var categoryKind = ParseKind(JsonString(categoryElement, "kind", "type", "mediaType", "media_type"));
|
||||
if (categoryKind == RemoteMediaKind.Unknown)
|
||||
{
|
||||
categoryKind = InferKind(id, []);
|
||||
}
|
||||
var sources = ParseSources(categoryElement, categoryKind);
|
||||
if (categoryKind == RemoteMediaKind.Unknown)
|
||||
{
|
||||
@@ -316,7 +327,12 @@ public static class RemoteMediaCatalogParser
|
||||
|
||||
var id = JsonString(sourceElement, "id");
|
||||
var formats = JsonStringArray(sourceElement, "supported_formats", "supportedFormats");
|
||||
var kind = InferKind(id, formats);
|
||||
var mediaType = JsonString(sourceElement, "mediaType", "media_type", "kind", "type");
|
||||
var kind = ParseKind(mediaType);
|
||||
if (kind == RemoteMediaKind.Unknown)
|
||||
{
|
||||
kind = InferKind(id, formats);
|
||||
}
|
||||
if (kind == RemoteMediaKind.Unknown)
|
||||
{
|
||||
kind = categoryKind;
|
||||
@@ -328,13 +344,18 @@ public static class RemoteMediaCatalogParser
|
||||
}
|
||||
|
||||
var apiUrl = JsonString(sourceElement, "api_url", "apiUrl", "url");
|
||||
var resolvedUrl = JsonString(sourceElement, "resolvedUrl", "resolved_url");
|
||||
var resolvedKey = JsonString(sourceElement, "resolvedKey", "resolved_key");
|
||||
var thumbnailUrl = JsonString(sourceElement, "thumbnail_url", "thumbnailUrl", "thumbnail", "cover");
|
||||
sources.Add(new RemoteMediaSource(
|
||||
Id: string.IsNullOrWhiteSpace(id) ? $"source-{sources.Count + 1}" : id,
|
||||
Name: JsonString(sourceElement, "name"),
|
||||
Description: JsonString(sourceElement, "description"),
|
||||
ApiUrl: apiUrl,
|
||||
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? apiUrl : thumbnailUrl,
|
||||
ResolvedUrl: resolvedUrl,
|
||||
ResolvedKey: resolvedKey,
|
||||
MediaType: string.IsNullOrWhiteSpace(mediaType) ? KindName(kind) : mediaType,
|
||||
ThumbnailUrl: string.IsNullOrWhiteSpace(thumbnailUrl) ? (string.IsNullOrWhiteSpace(resolvedUrl) ? apiUrl : resolvedUrl) : thumbnailUrl,
|
||||
Downloadable: JsonBool(sourceElement, true, "downloadable"),
|
||||
RefreshIntervalSeconds: NormalizedRefreshInterval(sourceElement, kind),
|
||||
SupportedFormats: formats,
|
||||
@@ -437,6 +458,29 @@ public static class RemoteMediaCatalogParser
|
||||
return kind;
|
||||
}
|
||||
|
||||
private static RemoteMediaKind ParseKind(string value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"image" or "img" or "picture" or "photo" => RemoteMediaKind.Image,
|
||||
"video" or "movie" or "mp4" => RemoteMediaKind.Video,
|
||||
"audio" or "music" or "mp3" => RemoteMediaKind.Audio,
|
||||
_ => RemoteMediaKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string KindName(RemoteMediaKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
RemoteMediaKind.Image => "image",
|
||||
RemoteMediaKind.Video => "video",
|
||||
RemoteMediaKind.Audio => "audio",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string JsonString(JsonElement root, params string[] names)
|
||||
{
|
||||
if (!TryGet(root, out var value, names))
|
||||
|
||||