Compare commits

...

4 Commits

Author SHA1 Message Date
QWQLwToo 2513eb2903 继续更新 update 门户站点界面和功能
build-winui / winui (push) Has been cancelled
2026-06-26 20:17:48 +08:00
QWQLwToo f525e5f3ba 提交说明 2026-06-26 20:17:48 +08:00
QWQLwToo 2171b933eb 继续更新 update 门户站点界面和功能 2026-06-26 20:17:48 +08:00
QWQLwToo cd2fd435a2 更新了update门户站点界面和部分功能 2026-06-26 20:17:48 +08:00
87 changed files with 6884 additions and 3269 deletions
+525
View File
@@ -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("YMhut unified management %s preflight", config.Version)
log.Printf("entrypoint ok: go run main.go") log.Printf("entrypoint ok: go run main.go")
log.Printf("listen: %s", cfg.Listen) 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) log.Print(line)
} }
if !cfg.Initialized && os.Getenv("YMHUT_SKIP_SETUP") != "1" { 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,6 +24,9 @@ const (
SessionCookie = "ymhut_unified_session" SessionCookie = "ymhut_unified_session"
captchaTTL = 5 * time.Minute captchaTTL = 5 * time.Minute
sessionTTL = 12 * time.Hour sessionTTL = 12 * time.Hour
loginWindow = 5 * time.Minute
loginLockTTL = 5 * time.Minute
loginMaxFails = 5
) )
type Service struct { type Service struct {
@@ -31,6 +34,7 @@ type Service struct {
mu sync.Mutex mu sync.Mutex
captchas map[string]captchaEntry captchas map[string]captchaEntry
sessions map[string]sessionEntry sessions map[string]sessionEntry
loginAttempts map[string]loginAttempt
} }
type captchaEntry struct { type captchaEntry struct {
@@ -44,6 +48,12 @@ type sessionEntry struct {
expiresAt time.Time expiresAt time.Time
} }
type loginAttempt struct {
failures int
lastFailure time.Time
lockedUntil time.Time
}
type Captcha struct { type Captcha struct {
ID string `json:"captchaId"` ID string `json:"captchaId"`
Image string `json:"image"` Image string `json:"image"`
@@ -54,6 +64,7 @@ func NewService(store *db.Store) *Service {
store: store, store: store,
captchas: map[string]captchaEntry{}, captchas: map[string]captchaEntry{},
sessions: map[string]sessionEntry{}, sessions: map[string]sessionEntry{},
loginAttempts: map[string]loginAttempt{},
} }
} }
@@ -91,12 +102,18 @@ func (s *Service) NewCaptcha() (Captcha, error) {
}, nil }, 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) { if !s.consumeCaptcha(captchaID, captcha) {
s.recordLoginFailure(attemptKey)
return "", "", false, nil return "", "", false, nil
} }
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password) user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
if err != nil || !ok { if err != nil || !ok {
s.recordLoginFailure(attemptKey)
return "", "", false, err return "", "", false, err
} }
sessionID := randomToken(32) sessionID := randomToken(32)
@@ -104,6 +121,7 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
s.mu.Lock() s.mu.Lock()
s.cleanupLocked() s.cleanupLocked()
s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)} s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)}
delete(s.loginAttempts, attemptKey)
s.mu.Unlock() s.mu.Unlock()
return sessionID, csrf, true, nil 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, csrf, ok := s.UserForRequest(r) _, csrf, ok := s.UserForRequest(r)
if !ok { 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 return
} }
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
actual := r.Header.Get("X-CSRF-Token") actual := r.Header.Get("X-CSRF-Token")
if actual == "" || subtle.ConstantTimeCompare([]byte(csrf), []byte(actual)) != 1 { 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 return
} }
} }
@@ -151,6 +169,14 @@ func (s *Service) Require(next http.Handler) http.Handler {
} }
func SetSessionCookie(w http.ResponseWriter, sessionID string) { 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{ http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Name: SessionCookie,
Value: sessionID, Value: sessionID,
@@ -158,6 +184,7 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string) {
MaxAge: int(sessionTTL.Seconds()), MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, 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}) 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 { func (s *Service) consumeCaptcha(id, answer string) bool {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
answer = strings.TrimSpace(answer) answer = strings.TrimSpace(answer)
@@ -193,6 +230,50 @@ func (s *Service) cleanupLocked() {
delete(s.sessions, id) 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 { func randomDigits(count int) string {
@@ -2,6 +2,9 @@ package auth
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing" "testing"
"ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/config"
@@ -35,7 +38,7 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" { if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" {
t.Fatalf("unexpected bootstrap payload: %#v", payload) 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) t.Fatal(err)
} }
payload, err = service.Bootstrap(context.Background()) payload, err = service.Bootstrap(context.Background())
@@ -46,3 +49,91 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
t.Fatalf("default password leaked after change: %#v", payload) 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])
}
}
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
) )
@@ -67,16 +68,28 @@ func Load() (*Config, error) {
} }
cfg := defaults(root) cfg := defaults(root)
path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json")) 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 data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, cfg); err != nil { if err := json.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }
cfg.Initialized = true cfg.Initialized = true
rawConfig = data
loaded = true
} }
cfg.BaseDir = root cfg.BaseDir = root
cfg.ConfigPath = path cfg.ConfigPath = path
if loaded {
sanitizeNonPortablePaths(cfg)
}
applyEnv(cfg) applyEnv(cfg)
normalize(root, cfg) normalize(root, cfg)
if loaded && shouldRewriteRelativeConfig(rawConfig) {
if err := Save(cfg); err != nil {
return nil, err
}
}
return cfg, nil return cfg, nil
} }
@@ -333,13 +346,110 @@ func Save(cfg *Config) error {
if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil {
return err return err
} }
data, err := json.MarshalIndent(cfg, "", " ") persisted := cfg.relativeCopy()
data, err := json.MarshalIndent(persisted, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(cfg.ConfigPath, data, 0o600) 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 { func absPath(base, value string) string {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
return 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))
}
}
@@ -8,6 +8,25 @@ import (
webassets "ymhut-box/server/unified-management/web" 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 { type Check struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
@@ -19,12 +38,12 @@ func Preflight(cfg *Config) []Check {
checks := []Check{ checks := []Check{
checkDir("storage", cfg.StorageDir, true), checkDir("storage", cfg.StorageDir, true),
checkParent("sqlite", cfg.Database.SQLitePath), checkParent("sqlite", cfg.Database.SQLitePath),
checkDir("update public", cfg.UpdatePublicDir, false), checkDir("update public", cfg.UpdatePublicDir, true),
checkDir("update notice", cfg.UpdateNoticeDir, false), checkDir("update notice", cfg.UpdateNoticeDir, true),
checkDir("downloads", cfg.DownloadsDir, false), checkDir("downloads", cfg.DownloadsDir, true),
checkFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), false), checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
checkFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), false), checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
checkFile("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json"), false), checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"), checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"), checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/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} 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 { func checkParent(name, path string) Check {
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil { if err := os.MkdirAll(dir, 0o750); err != nil {
@@ -116,12 +166,12 @@ func embeddedWebBuildOK(embedRoot string) bool {
return false return false
} }
func FormatPreflight(checks []Check) []string { func FormatPreflight(cfg *Config, checks []Check) []string {
lines := make([]string, 0, len(checks)) lines := make([]string, 0, len(checks))
for _, check := range checks { for _, check := range checks {
line := fmt.Sprintf("[%s] %s", check.Status, check.Name) line := fmt.Sprintf("[%s] %s", check.Status, check.Name)
if check.Path != "" { if check.Path != "" {
line += " -> " + check.Path line += " -> " + relativePath(cfg.BaseDir, check.Path)
} }
if check.Message != "" { if check.Message != "" {
line += " (" + 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,173 @@
package db
import (
"fmt"
"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, e.source_id, 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 int64
var sourceID, name, status, message, checkedAt string
var latency int
if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
return nil, err
}
items = append(items, map[string]any{"id": id, "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) 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,203 @@
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) {
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
local := s.localDB
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
err := errors.New("remote database is not configured")
s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err)
return SyncResult{}, err
}
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
s.setSyncStatus(result, err)
return result, err
}
func (s *Store) SyncNow() (SyncResult, error) {
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", Tables: map[string]int{}, FinishedAt: Now()}
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 = ""
}
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"}},
{"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, Tables: map[string]int{}, FinishedAt: Now()}
for _, table := range syncTables {
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
if err != nil {
return result, err
}
result.Tables[table.Name] = count
}
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 " + strings.Join(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
}
@@ -0,0 +1,345 @@
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) 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,282 @@
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"`
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"`
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"`
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 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,296 @@
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
}
}
return s.recordSchemaVersion(conn, d)
}
func schemaStatements(d dialect) []string {
return []string{
`CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(64) NOT NULL PRIMARY KEY,
applied_at TEXT NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT ''
)`,
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
id %s,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_changed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
id %s,
session_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
csrf TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
id %s,
product TEXT NOT NULL,
version TEXT NOT NULL,
platform TEXT NOT NULL,
arch TEXT NOT NULL,
file_name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
sha256 TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
id %s,
version TEXT NOT NULL UNIQUE,
build TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL DEFAULT 'stable',
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
release_notes TEXT NOT NULL DEFAULT '',
message_md TEXT NOT NULL DEFAULT '',
release_notes_md TEXT NOT NULL DEFAULT '',
download_url TEXT NOT NULL DEFAULT '',
notice_file TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL,
published_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
id %s,
version TEXT NOT NULL,
raw_json TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
code TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
severity TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
status_detail TEXT NOT NULL DEFAULT '',
public_reply TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee TEXT NOT NULL DEFAULT '',
handled_by TEXT NOT NULL DEFAULT '',
due_at TEXT NOT NULL DEFAULT '',
resolved_at TEXT NOT NULL DEFAULT '',
archived_at TEXT NOT NULL DEFAULT '',
sla_level TEXT NOT NULL DEFAULT '',
source_channel TEXT NOT NULL DEFAULT '',
risk_score INTEGER NOT NULL DEFAULT 0,
resolution TEXT NOT NULL DEFAULT '',
attachment TEXT NOT NULL DEFAULT '',
package_path TEXT NOT NULL DEFAULT '',
encrypted_package_path TEXT NOT NULL DEFAULT '',
package_sha256 TEXT NOT NULL DEFAULT '',
plain_package_sha256 TEXT NOT NULL DEFAULT '',
summary_text TEXT NOT NULL DEFAULT '',
included_files TEXT NOT NULL DEFAULT '',
mail_sent INTEGER NOT NULL DEFAULT 0,
remote_addr TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_activity_at TEXT NOT NULL
)`),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
id %s,
feedback_code TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
internal INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
id %s,
feedback_code TEXT NOT NULL,
kind TEXT NOT NULL,
path TEXT NOT NULL,
file_name TEXT NOT NULL,
sha256 TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
id %s,
feedback_code TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
from_value TEXT NOT NULL DEFAULT '',
to_value TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
`CREATE TABLE IF NOT EXISTS feedback_tags (
feedback_code TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (feedback_code, tag)
)`,
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
id %s,
feedback_code TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
to_address TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
plain_body TEXT NOT NULL DEFAULT '',
html_body TEXT NOT NULL DEFAULT '',
attachment_path TEXT NOT NULL DEFAULT '',
attachment_name TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
id %s,
category_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
ui_config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
id %s,
category_id TEXT NOT NULL,
category_name TEXT NOT NULL,
source_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT 'GET',
api_url TEXT NOT NULL DEFAULT '',
url_template TEXT NOT NULL DEFAULT '',
thumbnail_url TEXT NOT NULL DEFAULT '',
proxy_mode TEXT 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 TEXT NOT NULL DEFAULT '[]',
last_status TEXT NOT NULL DEFAULT 'unknown',
last_latency_ms INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
consecutive_failure INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
id %s,
source_db_id BIGINT NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
checked_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
id %s,
source_id TEXT NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
client TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
id %s,
direction TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
tables_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
id %s,
status TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
stats_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
id %s,
actor TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
id %s,
name TEXT NOT NULL,
raw TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`, d.idType()),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
id %s,
webhook_name TEXT NOT NULL DEFAULT '',
event TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
response_code INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
payload_sha256 TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)`, d.idType()),
`CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`,
`CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`,
`CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`,
`CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`,
}
}
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,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, 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
}
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
@@ -63,3 +64,212 @@ func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) {
t.Fatalf("expected prototype backup, got %v", matches) 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 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)
}
}
}
@@ -3,6 +3,7 @@ package feedback
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hmac" "crypto/hmac"
@@ -26,8 +27,25 @@ import (
const PackageMagic = "YMHUTFB1" 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}$`) var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
type requestContextKey string
const duplicateContextKey requestContextKey = "ymhut.feedback.duplicate"
type Service struct { type Service struct {
cfg *config.Config cfg *config.Config
store *db.Store store *db.Store
@@ -64,7 +82,7 @@ func (s *Service) Submit(r *http.Request) (db.Feedback, error) {
if strings.Contains(contentType, "multipart/form-data") { if strings.Contains(contentType, "multipart/form-data") {
if item, err := s.submitMultipart(r); err == nil { if item, err := s.submitMultipart(r); err == nil {
return item, nil 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 db.Feedback{}, err
} }
} }
@@ -151,6 +169,7 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
code = db.NewFeedbackCode() code = db.NewFeedbackCode()
} }
if existing, err := s.store.GetFeedback(code); err == nil { if existing, err := s.store.GetFeedback(code); err == nil {
setDuplicateSubmission(r, true)
return existing, nil return existing, nil
} }
file, _, err := r.FormFile("package") file, _, err := r.FormFile("package")
@@ -197,9 +216,48 @@ func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) {
return db.Feedback{}, err return db.Feedback{}, err
} }
item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr) item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr)
setDuplicateSubmission(r, false)
return item, s.store.InsertFeedback(item) 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 { func hasSignedFields(r *http.Request) bool {
if r.MultipartForm == nil { if r.MultipartForm == nil {
return false return false
@@ -368,10 +368,11 @@ func sha256File(path string) string {
} }
func safePackageName(name string) (string, error) { func safePackageName(name string) (string, error) {
name = strings.TrimSpace(filepath.Base(name)) original := strings.TrimSpace(name)
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) {
return "", errors.New("invalid filename") return "", errors.New("invalid filename")
} }
name = filepath.Base(original)
lower := strings.ToLower(name) lower := strings.ToLower(name)
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
if strings.HasSuffix(lower, suffix) { if strings.HasSuffix(lower, suffix) {
@@ -51,6 +51,7 @@ func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
Database: config.DatabaseConfig{ Database: config.DatabaseConfig{
Provider: "sqlite", Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
}, },
} }
store, err := db.Open(cfg) store, err := db.Open(cfg)
@@ -90,6 +91,7 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
Database: config.DatabaseConfig{ Database: config.DatabaseConfig{
Provider: "sqlite", Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
}, },
} }
store, err := db.Open(cfg) store, err := db.Open(cfg)
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -22,6 +23,25 @@ type Service struct {
client *http.Client client *http.Client
stop chan struct{} stop chan struct{}
once sync.Once 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 legacyMedia struct { type legacyMedia struct {
@@ -52,6 +72,8 @@ func NewService(cfg *config.Config, store *db.Store) *Service {
store: store, store: store,
client: &http.Client{Timeout: 10 * time.Second}, client: &http.Client{Timeout: 10 * time.Second},
stop: make(chan struct{}), stop: make(chan struct{}),
jobs: map[string]CheckJob{},
subscribers: map[chan Event]struct{}{},
} }
} }
@@ -237,21 +259,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) { func (s *Service) CheckSourceID(ctx context.Context, sourceID string) (db.Source, error) {
item, err := s.store.GetSourceBySourceID(sourceID) item, err := s.store.GetSourceBySourceID(sourceID)
if err != nil { if err != nil {
return db.Source{}, err 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 { 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) == "" { 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 timeout := time.Duration(item.TimeoutMS) * time.Millisecond
if timeout <= 0 { if timeout <= 0 || timeout < 15*time.Second {
timeout = 8 * time.Second timeout = 15 * time.Second
} }
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@@ -262,7 +390,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
req, err := http.NewRequestWithContext(ctx, method, item.APIURL, nil) req, err := http.NewRequestWithContext(ctx, method, item.APIURL, nil)
if err != nil { if err != nil {
_ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error()) _ = s.store.RecordSourceCheck(item.ID, "error", 0, err.Error())
return err return "error", err
} }
redirects := []string{} redirects := []string{}
client := *s.client client := *s.client
@@ -282,7 +410,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
latency := int(time.Since(start).Milliseconds()) latency := int(time.Since(start).Milliseconds())
if err != nil { if err != nil {
_ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error()) _ = s.store.RecordSourceCheck(item.ID, "error", latency, err.Error())
return err return "error", err
} }
defer resp.Body.Close() defer resp.Body.Close()
status := "ok" status := "ok"
@@ -306,7 +434,51 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
"error": resp.Status, "error": resp.Status,
}) })
} }
return s.store.RecordSourceCheck(item.ID, status, latency, message) 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 { func isHTTPURL(value *url.URL) bool {
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/db"
@@ -57,6 +58,67 @@ 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 testStore(t *testing.T) (*config.Config, *db.Store) { func testStore(t *testing.T) (*config.Config, *db.Store) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
@@ -70,6 +132,7 @@ func testStore(t *testing.T) (*config.Config, *db.Store) {
Database: config.DatabaseConfig{ Database: config.DatabaseConfig{
Provider: "sqlite", Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
}, },
} }
store, err := db.Open(cfg) store, err := db.Open(cfg)
@@ -52,11 +52,11 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
Ok: true, Ok: true,
DryRun: dryRun, DryRun: dryRun,
Paths: map[string]any{ Paths: map[string]any{
"legacyUpdateDir": s.cfg.LegacyUpdateDir, "legacyUpdateDir": s.displayPath(s.cfg.LegacyUpdateDir),
"legacyFeedbackDir": s.cfg.LegacyFeedbackDir, "legacyFeedbackDir": s.displayPath(s.cfg.LegacyFeedbackDir),
"legacyUpdateNoticeDir": s.cfg.LegacyUpdateNoticeDir, "legacyUpdateNoticeDir": s.displayPath(s.cfg.LegacyUpdateNoticeDir),
"updatePublicDir": s.cfg.UpdatePublicDir, "updatePublicDir": s.displayPath(s.cfg.UpdatePublicDir),
"updateNoticeDir": s.cfg.UpdateNoticeDir, "updateNoticeDir": s.displayPath(s.cfg.UpdateNoticeDir),
}, },
Stats: map[string]int{}, Stats: map[string]int{},
Started: db.Now(), Started: db.Now(),
@@ -84,6 +84,17 @@ func (s *Service) run(ctx context.Context, dryRun bool) Result {
return 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) { func (s *Service) previewPath(result *Result, key, path string) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@@ -0,0 +1,139 @@
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.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)
}
@@ -0,0 +1,90 @@
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())
}
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)
}
@@ -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,70 @@
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
}
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)
}
}
@@ -0,0 +1,164 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/health"
)
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) 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":
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
}
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)
}
}
@@ -0,0 +1,93 @@
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"},
"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,166 @@
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 连接信息",
"sqlite path is required": "请填写 SQLite 路径",
"mysql_dsn is required": "请填写 MySQL DSN",
"release notices are not configured": "版本日志功能尚未配置",
"legacy sync service is not configured": "旧项目同步服务尚未配置",
}
if translated, ok := exact[lower]; ok {
return translated
}
byCode := map[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": "请求方法不正确",
"FILE_REQUIRED": "请选择要上传的文件",
"CHECK_JOB_NOT_FOUND": "未找到心跳检测任务",
"SSE_UNSUPPORTED": "当前运行环境不支持实时事件流",
"SOURCES_FAILED": "接口源数据加载失败",
"ENDPOINTS_FAILED": "客户端接口数据加载失败",
"DASHBOARD_FAILED": "仪表盘数据加载失败",
"AUDIT_FAILED": "审计日志加载失败",
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
"FEEDBACK_UPDATE_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 ""
}
+12 -818
View File
@@ -1,29 +1,20 @@
package web package web
import ( import (
"bytes"
"encoding/csv"
"encoding/json" "encoding/json"
"errors" "errors"
"mime"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"strings" "strings"
"time"
"ymhut-box/server/unified-management/internal/auth" "ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/feedback" "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/legacy"
"ymhut-box/server/unified-management/internal/notices" "ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/releases" "ymhut-box/server/unified-management/internal/releases"
"ymhut-box/server/unified-management/internal/sources" "ymhut-box/server/unified-management/internal/sources"
"ymhut-box/server/unified-management/internal/synclegacy" "ymhut-box/server/unified-management/internal/synclegacy"
webassets "ymhut-box/server/unified-management/web"
) )
type router struct { 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) r.auth.Require(http.HandlerFunc(r.handleAdminSources)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/endpoints"): case strings.HasPrefix(path, "/api/admin/endpoints"):
r.auth.Require(http.HandlerFunc(r.handleAdminEndpoints)).ServeHTTP(w, req) 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"): case strings.HasPrefix(path, "/api/admin/legacy"):
r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req) r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/database"): case strings.HasPrefix(path, "/api/admin/database"):
@@ -167,16 +160,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
if body.Username == "" { if body.Username == "" {
body.Username = "admin" 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err) writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
return return
} }
if !ok { 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 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()}) _ = 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}}) 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) writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return 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) writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
return return
} }
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) _ = 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}) payload := map[string]any{"ok": true, "isDefaultPassword": false}
} if warning != "" {
payload["warning"] = warning
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) { }
release := r.releases.Manifest(req) writeJSON(w, http.StatusOK, payload)
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 ""
} }
@@ -1,15 +1,28 @@
package web package web
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"image/color"
"image/png"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"ymhut-box/server/unified-management/internal/auth" "ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/config"
@@ -25,7 +38,7 @@ func TestCompatibilityRoutes(t *testing.T) {
handler, cleanup := testRouter(t) handler, cleanup := testRouter(t)
defer cleanup() 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) req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder() res := httptest.NewRecorder()
handler.ServeHTTP(res, req) 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) { func TestClientBootstrapAndEndpointsShape(t *testing.T) {
handler, cleanup := testRouter(t) handler, cleanup := testRouter(t)
defer cleanup() defer cleanup()
@@ -66,6 +136,193 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
} }
} }
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) { func TestBuiltFrontendAssetsAreServed(t *testing.T) {
handler, cleanup := testRouter(t) handler, cleanup := testRouter(t)
defer cleanup() defer cleanup()
@@ -91,6 +348,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 { func containsAny(value string, needles []string) bool {
for _, needle := range needles { for _, needle := range needles {
if strings.Contains(value, needle) { if strings.Contains(value, needle) {
@@ -100,6 +374,15 @@ func containsAny(value string, needles []string) bool {
return false 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) { func TestReleaseNoticesRoutes(t *testing.T) {
handler, cleanup := testRouter(t) handler, cleanup := testRouter(t)
defer cleanup() defer cleanup()
@@ -136,6 +419,129 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
} }
} }
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()) { func testRouter(t *testing.T) (http.Handler, func()) {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
@@ -181,6 +587,9 @@ func testRouter(t *testing.T) (http.Handler, func()) {
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}}, "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{ mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
"schema_version": 1, "schema_version": 1,
"latest_version": "2.0.0", "latest_version": "2.0.0",
@@ -199,6 +608,11 @@ func testRouter(t *testing.T) (http.Handler, func()) {
AdminWebDir: adminDist, AdminWebDir: adminDist,
PortalWebDir: portalDist, PortalWebDir: portalDist,
SourceCheckSeconds: 3600, 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{ Database: config.DatabaseConfig{
Provider: "sqlite", Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
@@ -206,6 +620,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
HotSyncEnabled: true, HotSyncEnabled: true,
HealthIntervalSec: 3600, 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) store, err := db.Open(cfg)
if err != nil { if err != nil {
@@ -235,6 +650,50 @@ func testRouter(t *testing.T) (http.Handler, func()) {
return handler, func() { _ = store.Close() } 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) { func mustWriteJSON(t *testing.T, path string, payload any) {
t.Helper() t.Helper()
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
@@ -68,8 +68,8 @@ func (r *setupRouter) status() map[string]any {
return map[string]any{ return map[string]any{
"ok": true, "ok": true,
"initialized": r.cfg.Initialized, "initialized": r.cfg.Initialized,
"baseDir": r.cfg.BaseDir, "baseDir": ".",
"configPath": r.cfg.ConfigPath, "configPath": relativeToBase(r.cfg.BaseDir, r.cfg.ConfigPath),
"defaults": map[string]any{ "defaults": map[string]any{
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"), "provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath), "sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.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 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 = @( $Targets = @(
@{ GOOS = "windows"; GOARCH = "amd64"; Name = "ymhut-unified-management-windows-amd64.exe" }, @{ GOOS = "windows"; GOARCH = "amd64"; Name = "ymhut-unified-management-windows-amd64.exe" },
@{ GOOS = "linux"; GOARCH = "amd64"; Name = "ymhut-unified-management-linux-amd64" }, @{ 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" OUT="$ROOT/$OUT_DIR"
if [[ "$SKIP_FRONTEND" != "1" ]]; then if [[ "$SKIP_FRONTEND" != "1" ]]; then
for app in admin portal; do for app in admin portal setup; do
web_dir="$ROOT/web/$app" web_dir="$ROOT/web/$app"
if [[ ! -d "$web_dir/node_modules" ]]; then if [[ ! -d "$web_dir/node_modules" ]]; then
(cd "$web_dir" && npm install) (cd "$web_dir" && npm install)
@@ -19,6 +19,28 @@ fi
mkdir -p "$OUT" 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() { build_target() {
local goos="$1" local goos="$1"
local goarch="$2" local goarch="$2"
@@ -2,7 +2,13 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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="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> <title>YMhut Unified Admin</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+402 -118
View File
@@ -1,35 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { import {
ArrowDownToLine, ArrowDownToLine,
CheckCircle2,
ClipboardList, ClipboardList,
Code2, Code2,
Database, Database,
FileJson, FileJson,
HeartPulse,
LayoutDashboard, LayoutDashboard,
ListChecks,
LogOut, LogOut,
MessageSquareText, MessageSquareText,
Network, Network,
RefreshCw, RefreshCw,
Settings,
ShieldCheck, ShieldCheck,
} from "lucide-vue-next"; } from "lucide-vue-next";
import AuditView from "./views/AuditView.vue";
import DashboardView from "./views/DashboardView.vue";
import DatabaseView from "./views/DatabaseView.vue";
import EndpointsView from "./views/EndpointsView.vue"; import EndpointsView from "./views/EndpointsView.vue";
import FeedbacksView from "./views/FeedbacksView.vue"; import FeedbacksView from "./views/FeedbacksView.vue";
import HealthView from "./views/HealthView.vue";
import LegacyJsonView from "./views/LegacyJsonView.vue"; import LegacyJsonView from "./views/LegacyJsonView.vue";
import ReleasesView from "./views/ReleasesView.vue"; import ReleasesView from "./views/ReleasesView.vue";
import SettingsView from "./views/SettingsView.vue";
import SourcesView from "./views/SourcesView.vue"; import SourcesView from "./views/SourcesView.vue";
import SystemView from "./views/SystemView.vue";
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
import { createAuthStore } from "./stores/auth";
import { createDashboardStore } from "./stores/dashboard";
import { createFeedbackStore } from "./stores/feedback";
import { createLegacyStore, type LegacyName } from "./stores/legacy";
import { createReleaseStore } from "./stores/releases";
import { createSourceStore } from "./stores/sources";
import { createSystemStore } from "./stores/system";
type LegacyName = "update-info" | "media-types"; const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
type ToastState = { message: string; type: "success" | "warn" | "error" };
type Captcha = { type Captcha = {
captchaId: string; captchaId: string;
@@ -49,61 +52,31 @@ type RouteItem = {
icon: any; icon: any;
}; };
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path)); const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false); const loading = ref(false);
const toast = ref(""); const toast = ref<ToastState | null>(null);
const autoRefreshPaused = ref(false); const autoRefreshPaused = ref(false);
let refreshTimer: number | undefined; let refreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
const captcha = ref<Captcha | null>(null); const authStore = createAuthStore();
const authBootstrap = ref<AuthBootstrap | null>(null); const dashboardStore = createDashboardStore();
const dashboard = ref<any>({}); const feedbackStore = createFeedbackStore();
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 }); const releaseStore = createReleaseStore();
const selectedFeedback = ref<any | null>(null); const legacyStore = createLegacyStore();
const releases = ref<any>(null); const sourceStore = createSourceStore();
const releaseNotices = ref<any[]>([]); const systemStore = createSystemStore();
const selectedNotice = ref<any | null>(null);
const sources = ref<any>({ categories: [] });
const endpoints = ref<any[]>([]);
const database = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const legacySync = ref<any>(null);
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const loginForm = reactive({ username: "admin", password: "", captcha: "" }); const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
const passwordForm = reactive({ currentPassword: "", newPassword: "" }); const { dashboard, sourceCheckJobs } = dashboardStore;
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 }); const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" }); const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
const commentDraft = reactive({ body: "", internal: true }); const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore;
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" }); const { sources, endpoints, draft: sourceDraft } = sourceStore;
const sourceDraft = reactive({ const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
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\"]",
});
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
"update-info": { raw: "", note: "", preview: null },
"media-types": { raw: "", note: "", preview: null },
});
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
const routes: RouteItem[] = [ const routes: RouteItem[] = [
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard }, { path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
@@ -113,10 +86,7 @@ const routes: RouteItem[] = [
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList }, { path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network }, { path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 }, { path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
{ path: "/admin/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database }, { path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
]; ];
const navGroups = [ const navGroups = [
@@ -124,17 +94,7 @@ const navGroups = [
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) }, { label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) }, { label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) }, { label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
{ label: "系统运维", items: routes.filter((item) => ["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) }, { label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
];
const quickActions = [
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", icon: ArrowDownToLine },
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "编辑旧版 update-info.json", icon: FileJson },
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "同步旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "接口源目录", description: "新增接口并执行健康检测", icon: Network },
{ path: "/admin/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
]; ];
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]); const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
@@ -151,9 +111,10 @@ const clientCalls = computed(() => dashboard.value?.clientCalls || []);
const releasePackages = computed(() => releases.value?.packages || []); const releasePackages = computed(() => releases.value?.packages || []);
const sourceCategories = computed(() => sources.value?.categories || []); const sourceCategories = computed(() => sources.value?.categories || []);
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length); const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => endpointStatus(item) === "ok").length); const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
const latestNotice = computed(() => releaseNotices.value[0] || null); const latestNotice = computed(() => releaseNotices.value[0] || null);
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json"); const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
const heartbeatOption = computed(() => ({ const heartbeatOption = computed(() => ({
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
@@ -176,7 +137,13 @@ const heartbeatOption = computed(() => ({
], ],
})); }));
const healthOption = computed(() => ({ const healthOption = computed(() => {
const data = healthStatusOrder.map((item) => ({
name: item.label,
value: Number(sourceHealth.value?.[item.key] || 0),
itemStyle: { color: item.color },
})).filter((item) => item.value > 0);
return {
tooltip: { trigger: "item" }, tooltip: { trigger: "item" },
legend: { bottom: 0 }, legend: { bottom: 0 },
series: [ series: [
@@ -184,11 +151,11 @@ const healthOption = computed(() => ({
name: "接口健康", name: "接口健康",
type: "pie", type: "pie",
radius: ["48%", "72%"], radius: ["48%", "72%"],
data: objectEntries(sourceHealth.value), data: data.length ? data : [{ name: "暂无数据", value: 1, itemStyle: { color: "#cbd5e1" } }],
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
}, },
], ],
})); };
});
const feedbackOption = computed(() => ({ const feedbackOption = computed(() => ({
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
@@ -200,7 +167,7 @@ const feedbackOption = computed(() => ({
const availabilityOption = computed(() => { const availabilityOption = computed(() => {
const total = Number(kpis.value.sourceTotal || 0); const total = Number(kpis.value.sourceTotal || 0);
const ok = Number(sourceHealth.value.ok || 0); const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0);
const value = total ? Math.round((ok / total) * 100) : 0; const value = total ? Math.round((ok / total) * 100) : 0;
return { return {
series: [ series: [
@@ -217,10 +184,21 @@ const availabilityOption = computed(() => {
}; };
}); });
const healthStatusOrder = [
{ key: "ok", label: "正常", color: "#16a34a" },
{ key: "redirected", label: "重定向健康", color: "#f59e0b" },
{ key: "degraded", label: "降级", color: "#d97706" },
{ key: "error", label: "错误", color: "#dc2626" },
{ key: "unknown", label: "未知", color: "#94a3b8" },
];
const viewContext = computed(() => ({ const viewContext = computed(() => ({
activeLegacyLabel: activeLegacyLabel.value, activeLegacyLabel: activeLegacyLabel.value,
activeLegacyName: activeLegacyName.value, activeLegacyName: activeLegacyName.value,
addFeedbackComment, addFeedbackComment,
addMediaCategory,
addMediaSubcategory,
addUpdateMirror,
auditLogs: auditLogs.value, auditLogs: auditLogs.value,
autoRefreshPaused: autoRefreshPaused.value, autoRefreshPaused: autoRefreshPaused.value,
availabilityOption: availabilityOption.value, availabilityOption: availabilityOption.value,
@@ -231,6 +209,9 @@ const viewContext = computed(() => ({
copyEndpointToSource, copyEndpointToSource,
database: database.value, database: database.value,
databaseForm, databaseForm,
databaseLastSync: databaseLastSync.value,
databaseSyncDirectionLabel,
databaseSyncTableCount,
endpointStatus, endpointStatus,
endpoints: endpoints.value, endpoints: endpoints.value,
feedbackFilters, feedbackFilters,
@@ -250,16 +231,18 @@ const viewContext = computed(() => ({
legacyDocuments, legacyDocuments,
legacyDrafts, legacyDrafts,
legacySync: legacySync.value, legacySync: legacySync.value,
legacySyncMode: legacySyncMode.value,
loadAudit, loadAudit,
loadFeedbacks, loadFeedbacks,
navigate, navigate,
noticeDraft, noticeDraft,
onPackageSelected,
openFeedback, openFeedback,
openNotice, openNotice,
passwordForm, passwordForm,
pretty, pretty,
previewLegacySync, previewLegacySync,
quickActions, removeItem,
releaseNotices: releaseNotices.value, releaseNotices: releaseNotices.value,
releasePackages: releasePackages.value, releasePackages: releasePackages.value,
releases: releases.value, releases: releases.value,
@@ -273,32 +256,45 @@ const viewContext = computed(() => ({
selectedFeedback: selectedFeedback.value, selectedFeedback: selectedFeedback.value,
selectedNotice: selectedNotice.value, selectedNotice: selectedNotice.value,
sourceCategories: sourceCategories.value, sourceCategories: sourceCategories.value,
sourceCheckJobs: sourceCheckJobs.value,
sourceDraft, sourceDraft,
statusTone, statusTone,
syncDatabase, syncDatabase,
systemTab: systemTab.value,
setSystemTab,
testDatabase, testDatabase,
toggleAutoRefresh, toggleAutoRefresh,
updateLegacyRawFromForm,
uploadDraft,
uploadPackage,
auditMessage,
auditTypeLabel,
validateLegacy, validateLegacy,
validateNotice, validateNotice,
visibleEndpointCount: visibleEndpointCount.value, visibleEndpointCount: visibleEndpointCount.value,
})); }));
async function api<T>(target: string, init: RequestInit = {}): Promise<T> { async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers); return adminFetch<T>(target, init, { csrf: csrf.value });
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json"); }
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
const res = await fetch(target, { ...init, headers, credentials: "include" }); function uploadWithProgress<T>(target: string, form: FormData, onProgress: (loaded: number, total: number) => void): Promise<T> {
const data = await res.json().catch(() => ({})); return uploadAdminFile<T>(target, form, { csrf: csrf.value }, (progress) => onProgress(progress.loaded, progress.total));
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
return data as T;
} }
function normalizeAdminPath(value: string) { function normalizeAdminPath(value: string) {
if (value === "/admin" || value === "/admin/") return "/admin/dashboard"; if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
if (value === "/") return "/admin/dashboard"; if (value === "/") return "/admin/dashboard";
if (["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(value)) return "/admin/system";
return value; return value;
} }
function normalizeSystemTab(value: unknown): SystemTab {
const tab = Array.isArray(value) ? value[0] : value;
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
return "database";
}
function navigate(next: string) { function navigate(next: string) {
if (currentPath.value === next) { if (currentPath.value === next) {
void load(); void load();
@@ -307,15 +303,20 @@ function navigate(next: string) {
void router.push(next); void router.push(next);
} }
function setSystemTab(tab: SystemTab) {
void router.replace({ path: "/admin/system", query: tab === "database" ? {} : { tab } });
}
function toggleAutoRefresh() { function toggleAutoRefresh() {
autoRefreshPaused.value = !autoRefreshPaused.value; autoRefreshPaused.value = !autoRefreshPaused.value;
} }
function setToast(message: string) { function setToast(message: string, type: ToastState["type"] = "success") {
toast.value = message; toast.value = { message, type };
window.setTimeout(() => { if (toastTimer) window.clearTimeout(toastTimer);
if (toast.value === message) toast.value = ""; toastTimer = window.setTimeout(() => {
}, 4200); if (toast.value?.message === message) toast.value = null;
}, 2500);
} }
async function guarded(task: () => Promise<void>) { async function guarded(task: () => Promise<void>) {
@@ -323,9 +324,15 @@ async function guarded(task: () => Promise<void>) {
try { try {
await task(); await task();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const rawMessage = error instanceof Error ? error.message : String(error);
toast.value = message; const message = toChineseError(rawMessage);
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) { setToast(message, "error");
if (isAuthError(rawMessage, message)) {
csrf.value = "";
sessionStorage.removeItem("ymhut.csrf");
localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
navigate("/admin/login"); navigate("/admin/login");
} }
} finally { } finally {
@@ -333,6 +340,11 @@ async function guarded(task: () => Promise<void>) {
} }
} }
function isAuthError(raw: string, message: string) {
const text = `${raw} ${message}`.toLowerCase();
return text.includes("unauthorized") || text.includes("login required") || text.includes("401") || message.includes("需要登录");
}
async function loadCaptcha() { async function loadCaptcha() {
captcha.value = await api<Captcha>("/api/admin/auth/captcha"); captcha.value = await api<Captcha>("/api/admin/auth/captcha");
} }
@@ -348,7 +360,9 @@ async function login() {
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }), body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
}); });
csrf.value = data.csrfToken; csrf.value = data.csrfToken;
localStorage.setItem("ymhut.csrf", csrf.value); sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard"); navigate("/admin/dashboard");
}); });
} }
@@ -356,7 +370,10 @@ async function login() {
async function logout() { async function logout() {
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined); await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
csrf.value = ""; csrf.value = "";
sessionStorage.removeItem("ymhut.csrf");
localStorage.removeItem("ymhut.csrf"); localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
navigate("/admin/login"); navigate("/admin/login");
} }
@@ -366,17 +383,19 @@ async function load() {
await Promise.all([loadAuthBootstrap(), loadCaptcha()]); await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
return; return;
} }
if (!csrf.value) {
navigate("/admin/login");
return;
}
if (currentPath.value === "/admin/dashboard") await loadDashboard(); if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks(); if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
if (currentPath.value === "/admin/releases") await loadReleases(); if (currentPath.value === "/admin/releases") await loadReleases();
if (currentPath.value === "/admin/sources") await loadSources(); if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/endpoints") await loadEndpoints(); if (currentPath.value === "/admin/endpoints") await loadEndpoints();
if (currentPath.value === "/admin/database") await loadDatabase(); if (currentPath.value === "/admin/system") await loadSystem();
if (currentPath.value === "/admin/health") await loadHealth();
if (currentPath.value === "/admin/audit") await loadAudit();
if (currentPath.value === "/admin/settings") await previewLegacySync();
const legacyName = activeLegacyName.value; const legacyName = activeLegacyName.value;
if (legacyName) await loadLegacy(legacyName); if (legacyName) await loadLegacy(legacyName);
connectAdminEvents();
}); });
} }
@@ -384,6 +403,10 @@ async function loadDashboard() {
dashboard.value = await api("/api/admin/dashboard/overview?window=24h"); dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
} }
async function loadSystem() {
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
}
async function loadFeedbacks() { async function loadFeedbacks() {
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) }); const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
if (feedbackFilters.q) params.set("q", feedbackFilters.q); if (feedbackFilters.q) params.set("q", feedbackFilters.q);
@@ -492,6 +515,64 @@ async function loadLegacy(name: LegacyName) {
legacyDocuments[name] = data.document; legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw || ""; legacyDrafts[name].raw = data.document.raw || "";
legacyDrafts[name].preview = data.document.parsed || null; legacyDrafts[name].preview = data.document.parsed || null;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
}
function onPackageSelected(event: Event) {
const input = event.target as HTMLInputElement;
uploadDraft.file = input.files?.[0] || null;
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
uploadDraft.status = uploadDraft.file ? "等待上传" : "";
if (uploadDraft.file && !uploadDraft.version) {
const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0];
if (version) uploadDraft.version = version;
}
}
async function uploadPackage() {
if (!uploadDraft.file) {
setToast("请选择要上传的发布包", "warn");
return;
}
await guarded(async () => {
const form = new FormData();
form.append("file", uploadDraft.file as File);
form.append("version", uploadDraft.version);
form.append("platform", uploadDraft.platform);
form.append("arch", uploadDraft.arch);
form.append("channel", uploadDraft.channel);
form.append("notes", uploadDraft.notes);
form.append("updateManifest", String(uploadDraft.updateManifest));
uploadDraft.uploading = true;
uploadDraft.status = "正在上传";
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
await uploadWithProgress("/api/admin/releases/packages", form, (loaded, total) => {
uploadDraft.loadedBytes = loaded;
uploadDraft.totalBytes = total;
uploadDraft.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
uploadDraft.status = uploadDraft.progress >= 100 ? "服务端处理中" : "正在上传";
});
uploadDraft.progress = 100;
uploadDraft.status = "上传完成";
uploadDraft.file = null;
uploadDraft.notes = "";
setToast("发布包已上传并放入下载目录");
await loadReleases();
window.setTimeout(() => {
if (!uploadDraft.uploading) {
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = 0;
uploadDraft.status = "";
}
}, 1200);
}).finally(() => {
uploadDraft.uploading = false;
});
} }
async function validateLegacy(name: LegacyName) { async function validateLegacy(name: LegacyName) {
@@ -506,6 +587,7 @@ async function validateLegacy(name: LegacyName) {
async function saveLegacy(name: LegacyName) { async function saveLegacy(name: LegacyName) {
await guarded(async () => { await guarded(async () => {
if (legacyDrafts[name].tab === "form") updateLegacyRawFromForm(name);
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, { const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }), body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }),
@@ -513,6 +595,7 @@ async function saveLegacy(name: LegacyName) {
legacyDocuments[name] = data.document; legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw; legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed; legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
legacyDrafts[name].note = ""; legacyDrafts[name].note = "";
setToast("兼容 JSON 已保存并发布到旧路径"); setToast("兼容 JSON 已保存并发布到旧路径");
}); });
@@ -527,10 +610,110 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
legacyDocuments[name] = data.document; legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw; legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed; legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
setToast("兼容 JSON 已恢复"); setToast("兼容 JSON 已恢复");
}); });
} }
function makeLegacyForm(name: LegacyName, parsed: any) {
if (name === "media-types") {
return {
layout_version: parsed.layout_version || "1.0.0",
last_updated: parsed.last_updated || "",
ui_config: JSON.stringify(parsed.ui_config || {}, null, 2),
categories: clone(parsed.categories || []).map((cat: any) => ({
id: cat.id || "",
name: cat.name || "",
enabled: cat.enabled !== false,
subcategories: clone(cat.subcategories || []).map((sub: any) => ({
id: sub.id || "",
name: sub.name || "",
description: sub.description || "",
api_url: sub.api_url || "",
thumbnail_url: sub.thumbnail_url || "",
refresh_interval: Number(sub.refresh_interval || 300),
supported_formats: Array.isArray(sub.supported_formats) ? sub.supported_formats.join(", ") : "",
downloadable: sub.downloadable !== false,
})),
})),
};
}
return {
app_version: parsed.app_version || parsed.version || "",
title: parsed.title || "",
message: parsed.message || "",
message_md: parsed.message_md || "",
download_url: parsed.download_url || "",
release_notes: parsed.release_notes || "",
release_notes_md: parsed.release_notes_md || "",
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2),
package_sha256: parsed.package_sha256 || "",
package_size: parsed.package_size || "",
updated_at: parsed.updated_at || parsed.last_updated || "",
};
}
function updateLegacyRawFromForm(name: LegacyName) {
const current = parseJSONSafe(legacyDrafts[name].raw, legacyDrafts[name].preview || {});
const form = legacyDrafts[name].form || {};
if (name === "media-types") {
current.layout_version = form.layout_version || "1.0.0";
current.last_updated = form.last_updated || new Date().toISOString();
current.ui_config = parseJSONSafe(form.ui_config, current.ui_config || {});
current.categories = (form.categories || []).map((cat: any) => ({
...(findByID(current.categories, cat.id) || {}),
id: cat.id,
name: cat.name,
enabled: cat.enabled !== false,
subcategories: (cat.subcategories || []).map((sub: any) => ({
...(findByID((findByID(current.categories, cat.id) || {}).subcategories, sub.id) || {}),
id: sub.id,
name: sub.name,
description: sub.description,
api_url: sub.api_url,
thumbnail_url: sub.thumbnail_url,
refresh_interval: Number(sub.refresh_interval || 300),
supported_formats: splitList(sub.supported_formats),
downloadable: sub.downloadable !== false,
})),
}));
} else {
for (const key of ["app_version", "title", "message", "message_md", "download_url", "release_notes", "release_notes_md", "package_sha256", "updated_at"]) {
if (form[key] !== undefined) current[key] = form[key];
}
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {});
}
legacyDrafts[name].raw = JSON.stringify(current, null, 2) + "\n";
legacyDrafts[name].preview = current;
}
function addUpdateMirror() {
const doc = parseJSONSafe(legacyDrafts["update-info"].raw, legacyDrafts["update-info"].preview || {});
const mirrors = Array.isArray(doc.download_mirrors) ? doc.download_mirrors : [];
mirrors.push({ id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true });
doc.download_mirrors = mirrors;
legacyDrafts["update-info"].raw = JSON.stringify(doc, null, 2) + "\n";
legacyDrafts["update-info"].preview = doc;
}
function addMediaCategory(name: LegacyName) {
const form = legacyDrafts[name].form;
if (!Array.isArray(form.categories)) form.categories = [];
form.categories.push({ id: `category-${form.categories.length + 1}`, name: "新分类", enabled: true, subcategories: [] });
}
function addMediaSubcategory(category: any) {
if (!Array.isArray(category.subcategories)) category.subcategories = [];
category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true });
}
function removeItem(list: any[], index: number) {
list.splice(index, 1);
}
async function loadSources() { async function loadSources() {
const data = await api<{ catalog: any }>("/api/admin/sources"); const data = await api<{ catalog: any }>("/api/admin/sources");
sources.value = data.catalog || { categories: [] }; sources.value = data.catalog || { categories: [] };
@@ -546,13 +729,20 @@ async function saveSource() {
async function checkSources() { async function checkSources() {
await guarded(async () => { await guarded(async () => {
await api("/api/admin/sources/check", { method: "POST", body: "{}" }); const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
setToast("接口心跳检测已进入队列"); if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5);
setToast(`接口心跳检测已进入队列:${data.jobId}`);
if (currentPath.value === "/admin/dashboard") await loadDashboard(); if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources(); if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/system") await loadSystem();
}); });
} }
async function loadSourceCheckJobs() {
const data = await api<{ items: any[] }>("/api/admin/sources/check/status");
sourceCheckJobs.value = data.items || [];
}
async function loadEndpoints() { async function loadEndpoints() {
const data = await api<{ items: any[] }>("/api/admin/endpoints"); const data = await api<{ items: any[] }>("/api/admin/endpoints");
endpoints.value = data.items || []; endpoints.value = data.items || [];
@@ -577,11 +767,11 @@ function copyEndpointToSource(item: any) {
navigate("/admin/sources"); navigate("/admin/sources");
} }
async function loadDatabase() { async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
const data = await api<{ database: any }>("/api/admin/database/status"); const data = await api<{ database: any }>("/api/admin/database/status");
database.value = data.database; database.value = data.database;
databaseForm.provider = data.database?.configProvider || "sqlite"; databaseForm.provider = data.database?.configProvider || "sqlite";
await previewLegacySync(); if (options.previewLegacy !== false) await previewLegacySync();
} }
async function testDatabase() { async function testDatabase() {
@@ -596,21 +786,24 @@ async function testDatabase() {
async function syncDatabase(direction: "import" | "sync") { async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => { await guarded(async () => {
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" }); const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地"); setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
await loadDatabase(); await loadDatabase({ previewLegacy: false });
}); });
} }
async function previewLegacySync() { async function previewLegacySync() {
legacySyncMode.value = "preview";
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] })); legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
} }
async function runLegacySync() { async function runLegacySync() {
await guarded(async () => { await guarded(async () => {
legacySyncMode.value = "run";
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" }); legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
setToast("旧项目同步已完成"); setToast("旧项目同步已完成");
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]); await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
}); });
} }
@@ -625,10 +818,11 @@ async function loadAudit() {
async function changePassword() { async function changePassword() {
await guarded(async () => { await guarded(async () => {
await api("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) }); const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
passwordForm.currentPassword = ""; passwordForm.currentPassword = "";
passwordForm.newPassword = ""; passwordForm.newPassword = "";
setToast("后台密码已修改,登录页将不再提示默认密码"); if (authBootstrap.value) authBootstrap.value.isDefaultPassword = data.isDefaultPassword;
setToast(data.warning || "后台密码已修改,登录页将不再提示默认密码", data.warning ? "warn" : "success");
}); });
} }
@@ -639,7 +833,7 @@ function endpointStatus(item: any) {
function statusTone(status: string) { function statusTone(status: string) {
const value = String(status || "").toLowerCase(); const value = String(status || "").toLowerCase();
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good"; if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn"; if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
if (["error", "failed", "closed", "offline"].includes(value)) return "bad"; if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
return "neutral"; return "neutral";
} }
@@ -651,6 +845,7 @@ function objectEntries(value: Record<string, number>) {
function labelStatus(value: string) { function labelStatus(value: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
ok: "正常", ok: "正常",
redirected: "重定向健康",
error: "错误", error: "错误",
degraded: "降级", degraded: "降级",
unknown: "未知", unknown: "未知",
@@ -662,6 +857,46 @@ function labelStatus(value: string) {
return labels[value] || value || "未知"; return labels[value] || value || "未知";
} }
function auditTypeLabel(value: string) {
const labels: Record<string, string> = {
"auth.login": "管理员登录",
"auth.password_changed": "修改后台密码",
"feedback.created": "客户端提交反馈",
"feedback.updated": "更新反馈工单",
"legacy_json.saved": "保存兼容 JSON",
"legacy_json.restored": "恢复兼容 JSON",
"legacy_json.seeded": "导入 JSON 基板",
"release_notice.saved": "保存版本日志",
"release.package_uploaded": "上传发布包",
"legacy.sync": "旧项目同步",
};
return labels[value] || value || "未知操作";
}
function auditMessage(item: any) {
const message = String(item?.message || "");
const legacy: Record<string, string> = {
"Admin login": "管理员登录",
"Admin password changed": "后台密码已修改",
"Legacy JSON saved": "兼容 JSON 已保存",
"Legacy JSON restored": "兼容 JSON 已恢复",
"Release notice saved": "版本日志已保存",
"Feedback updated": "反馈工单已更新",
};
return legacy[message] || message || auditTypeLabel(item?.type);
}
function databaseSyncDirectionLabel(value: string) {
if (value === "sqlite_to_remote") return "SQLite -> MySQL";
if (value === "remote_to_sqlite") return "MySQL -> SQLite";
return value || "-";
}
function databaseSyncTableCount(result: any) {
const tables = result?.tables || {};
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
}
function formatBytes(value: number) { function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0 B"; if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"]; const units = ["B", "KB", "MB", "GB"];
@@ -683,19 +918,74 @@ function pretty(value: any) {
return JSON.stringify(value || {}, null, 2); return JSON.stringify(value || {}, null, 2);
} }
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function parseJSONSafe(value: string, fallback: any) {
try {
return JSON.parse(value || "{}");
} catch {
return clone(fallback || {});
}
}
function findByID(list: any, id: string) {
if (!Array.isArray(list)) return null;
return list.find((item) => item?.id === id) || null;
}
function splitList(value: string) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
onMounted(() => { onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load(); void load();
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard(); if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000); }, 15000);
}); });
watch(currentPath, () => {
void load();
});
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer); if (refreshTimer) window.clearInterval(refreshTimer);
events?.close();
events = null;
}); });
function connectAdminEvents() {
if (!csrf.value || events) return;
events = new EventSource("/api/admin/events", { withCredentials: true });
const refreshCurrent = () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
if (currentPath.value === "/admin/system") void loadSystem();
};
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
events.addEventListener(name, refreshCurrent);
}
events.onerror = () => {
events?.close();
events = null;
window.setTimeout(connectAdminEvents, 5000);
};
}
</script> </script>
<template> <template>
<Teleport to="body">
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
</Teleport>
<main v-if="currentPath === '/admin/login'" class="login-shell"> <main v-if="currentPath === '/admin/login'" class="login-shell">
<section class="login-panel"> <section class="login-panel">
<div> <div>
@@ -721,7 +1011,6 @@ onUnmounted(() => {
</label> </label>
<button class="btn primary full" type="submit">登录</button> <button class="btn primary full" type="submit">登录</button>
</form> </form>
<p v-if="toast" class="notice">{{ toast }}</p>
</section> </section>
</main> </main>
@@ -737,7 +1026,7 @@ onUnmounted(() => {
<button <button
v-for="item in group.items" v-for="item in group.items"
:key="item.path" :key="item.path"
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }" :class="{ active: currentPath === item.path }"
@click="navigate(item.path)" @click="navigate(item.path)"
> >
<component :is="item.icon" :size="17" /> <component :is="item.icon" :size="17" />
@@ -760,18 +1049,13 @@ onUnmounted(() => {
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button> <button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
</div> </div>
</header> </header>
<p v-if="toast" class="notice">{{ toast }}</p>
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" /> <DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" /> <FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" /> <ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" /> <LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" /> <SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" /> <EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" /> <SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
</section> </section>
</main> </main>
</template> </template>
@@ -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/legacy/media-types",
"/admin/sources", "/admin/sources",
"/admin/endpoints", "/admin/endpoints",
"/admin/database", "/admin/system",
"/admin/health",
"/admin/settings",
"/admin/audit",
].map((path) => ({ path, component: RoutePlaceholder })); ].map((path) => ({ path, component: RoutePlaceholder }));
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
...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", redirect: "/admin/dashboard" },
{ path: "/admin/:pathMatch(.*)*", 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: "", page: 1, perPage: 20 });
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
return { page, selected, filters, update, commentDraft };
}
@@ -0,0 +1,14 @@
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 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 };
}
@@ -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,12 @@
import { reactive, ref } from "vue";
export function createSystemStore() {
const database = ref<any>(null);
const databaseLastSync = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const legacySyncMode = ref<"preview" | "run">("preview");
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
}
@@ -20,11 +20,12 @@
--bad: #b42318; --bad: #b42318;
--bad-bg: #fff0ed; --bad-bg: #fff0ed;
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08); --shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
--ease: cubic-bezier(.2,.8,.2,1);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { min-width: 320px; } html { min-width: 320px; max-width: 100%; overflow-x: clip; }
body { margin: 0; background: var(--bg); } body { margin: 0; background: var(--bg); max-width: 100%; overflow-x: clip; }
button, input, textarea, select { font: inherit; } button, input, textarea, select { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.65; } button:disabled { cursor: not-allowed; opacity: 0.65; }
@@ -57,7 +58,7 @@ h3 { margin-bottom: 8px; font-size: 15px; }
padding: 28px; padding: 28px;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 16px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -67,7 +68,7 @@ input, textarea, select {
width: 100%; width: 100%;
min-height: 40px; min-height: 40px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 10px;
background: #fff; background: #fff;
color: var(--ink); color: var(--ink);
padding: 8px 10px; padding: 8px 10px;
@@ -83,7 +84,7 @@ input:focus, textarea:focus, select:focus {
.captcha-button { .captcha-button {
min-height: 40px; min-height: 40px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 10px;
background: #fff; background: #fff;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
@@ -96,7 +97,7 @@ input:focus, textarea:focus, select:focus {
.btn { .btn {
min-height: 38px; min-height: 38px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 10px;
background: #fff; background: #fff;
color: var(--ink); color: var(--ink);
padding: 8px 12px; padding: 8px 12px;
@@ -106,9 +107,9 @@ input:focus, textarea:focus, select:focus {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
font-weight: 800; 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 { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); } .btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn.ghost { background: transparent; } .btn.ghost { background: transparent; }
@@ -125,7 +126,35 @@ input:focus, textarea:focus, select:focus {
line-height: 1.55; 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 { .sidebar {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
@@ -137,13 +166,18 @@ input:focus, textarea:focus, select:focus {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100dvh; 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 { 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: 8px; display: grid; place-items: center; background: #111827; color: #fff; } .brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand strong { display: block; } .brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; } .brand small { display: block; color: var(--muted); margin-top: 2px; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; } .brand > div { min-width: 0; overflow: hidden; }
.nav-group { display: flex; flex-direction: column; gap: 5px; } .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 { .nav-group p {
margin: 0 0 2px; margin: 0 0 2px;
color: var(--muted); color: var(--muted);
@@ -154,7 +188,7 @@ input:focus, textarea:focus, select:focus {
} }
.nav-group button, .logout { .nav-group button, .logout {
border: 0; border: 0;
border-radius: 6px; border-radius: 10px;
background: transparent; background: transparent;
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@@ -164,12 +198,24 @@ input:focus, textarea:focus, select:focus {
color: #526070; color: #526070;
font-weight: 800; font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease; 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); } .nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
.logout { color: #7f1d1d; } .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, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
.topbar { min-height: 72px; } .topbar { min-height: 72px; }
.section-head h2 { margin: 0; } .section-head h2 { margin: 0; }
@@ -179,10 +225,17 @@ input:focus, textarea:focus, select:focus {
.metric, .panel { .metric, .panel {
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 14px;
padding: 16px; padding: 16px;
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04); box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
} }
.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 { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
.metric span, .metric small { color: var(--muted); } .metric span, .metric small { color: var(--muted); }
.metric strong { font-size: 26px; overflow-wrap: anywhere; } .metric strong { font-size: 26px; overflow-wrap: anywhere; }
@@ -190,23 +243,6 @@ input:focus, textarea:focus, select:focus {
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .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 { min-height: 330px; display: flex; flex-direction: column; }
.chart { min-height: 260px; width: 100%; flex: 1; } .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;
display: grid;
align-content: start;
gap: 7px;
text-align: left;
}
.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; }
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; } .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); } .split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
@@ -229,7 +265,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, 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; } th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
tr.clickable { cursor: pointer; } 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; } tr.selected td { background: #eef4ff; }
.badge { .badge {
@@ -267,7 +304,7 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
.compact-editor { min-height: 260px; } .compact-editor { min-height: 260px; }
details { details {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 12px;
padding: 10px; padding: 10px;
background: #fff; background: #fff;
} }
@@ -277,7 +314,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
max-height: 360px; max-height: 360px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 12px;
background: #0f172a; background: #0f172a;
color: #dbeafe; color: #dbeafe;
padding: 12px; padding: 12px;
@@ -289,7 +326,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.revision-list { display: flex; flex-direction: column; gap: 8px; } .revision-list { display: flex; flex-direction: column; gap: 8px; }
.revision-list button { .revision-list button {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 12px;
background: #fff; background: #fff;
color: var(--ink); color: var(--ink);
text-align: left; text-align: left;
@@ -300,10 +337,127 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; } .kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
.kv-grid span { color: var(--muted); } .kv-grid span { color: var(--muted); }
.kv-grid strong { overflow-wrap: anywhere; } .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; }
.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) { @media (max-width: 1180px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .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; } .detail-panel { position: static; max-height: none; }
} }
@@ -314,12 +468,12 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.workspace { padding: 16px; } .workspace { padding: 16px; }
.topbar, .section-head { align-items: stretch; flex-direction: column; } .topbar, .section-head { align-items: stretch; flex-direction: column; }
.metric-grid, .two-col { grid-template-columns: 1fr; } .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; } .captcha-row { grid-template-columns: 1fr; }
table { min-width: 720px; } table { min-width: 720px; }
.panel { overflow-x: auto; } .panel { overflow-x: auto; max-width: 100%; }
} }
@media (prefers-reduced-motion: reduce) { @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>
@@ -29,15 +29,22 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
<span class="muted"> 15 秒自动刷新仪表盘数据</span> <span class="muted"> 15 秒自动刷新仪表盘数据</span>
</div> </div>
<section class="panel quick-panel"> <section v-if="ctx.sourceCheckJobs.length" class="panel">
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div> <div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<div class="quick-grid"> <table>
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)"> <thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
<component :is="item.icon" :size="18" /> <tbody>
<strong>{{ item.label }}</strong> <tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
<span>{{ item.description }}</span> <td class="mono">{{ job.id }}</td>
</button> <td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
</div> <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> </section>
<div class="chart-grid"> <div class="chart-grid">
@@ -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>
@@ -12,7 +12,10 @@ defineProps<{ ctx: any }>();
<td class="mono">{{ item.id || item.sourceId }}</td> <td class="mono">{{ item.id || item.sourceId }}</td>
<td>{{ item.category || item.categoryId }}</td> <td>{{ item.category || item.categoryId }}</td>
<td>{{ item.proxyMode }}</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>{{ item.cacheSeconds || 0 }}s</td>
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td> <td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td> <td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
@@ -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,106 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircle2, Save } from "lucide-vue-next"; import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
defineProps<{ ctx: any }>(); defineProps<{ ctx: any }>();
</script> </script>
<template> <template>
<section class="split wide-split"> <section class="panel page-stack">
<section class="panel editor-panel">
<div class="section-head"> <div class="section-head">
<div>
<h2>{{ ctx.activeLegacyLabel }}</h2> <h2>{{ ctx.activeLegacyLabel }}</h2>
<p class="muted">以当前兼容 JSON 为基板表单保存会合并进原 JSON未知字段保留</p>
</div>
<div class="button-row"> <div class="button-row">
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button> <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> <button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
</div> </div>
</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>
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
生产环境不再自动依赖旧项目路径需要以 server/update/public/media-types.json 为基板时请切换到 Raw JSON 粘贴完整内容校验通过后保存发布
</p>
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" 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="5"></textarea></label>
<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 class="wide button-row">
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
</div>
</section>
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="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="button-row">
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
</div>
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
<div class="section-head">
<h3>分类 {{ cIndex + 1 }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
</div>
<div class="form-grid">
<label>ID<input v-model="cat.id" /></label>
<label>名称<input v-model="cat.name" /></label>
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
</div>
<div class="button-row">
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
</div>
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
<div class="section-head">
<h3>{{ sub.name || "子接口" }}</h3>
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
</div>
<div class="form-grid">
<label>ID<input v-model="sub.id" /></label>
<label>名称<input v-model="sub.name" /></label>
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
</div>
</section>
</section>
</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> <textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label> <label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
</section> </section>
<aside class="panel page-stack">
<h2>预览与历史</h2> <section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview'">
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre> <pre class="json-preview tall">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
<div class="revision-list"> </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)"> <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> #{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
</button> </button>
</div> <div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本</div>
</aside> </section>
</section> </section>
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircle2, Save } from "lucide-vue-next"; import { CheckCircle2, Save, UploadCloud } from "lucide-vue-next";
defineProps<{ ctx: any }>(); defineProps<{ ctx: any }>();
</script> </script>
@@ -9,8 +9,39 @@ defineProps<{ ctx: any }>();
<section class="panel page-stack"> <section class="panel page-stack">
<div class="section-head"> <div class="section-head">
<h2>发布包</h2> <h2>发布包</h2>
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a> <span class="badge">{{ ctx.releasePackages.length }} 个文件</span>
</div> </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> <table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead> <thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
<tbody> <tbody>
@@ -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,159 @@
<script setup lang="ts">
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
defineProps<{ ctx: any }>();
const tabs = [
{ id: "database", label: "数据库", icon: Database },
{ 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>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?.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.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
</div>
<div>
<span><Clock3 :size="15" />完成时间</span>
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
</div>
</div>
<div class="ops-note">
<AlertTriangle :size="16" />
<span>数据库同步是覆盖式全表 upsert执行前确认方向SQLite 导入远端会以本地库为源远端同步回本地会以 MySQL 为源</span>
</div>
</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>
<div class="button-row">
<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>
</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 page-stack">
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
<div class="kv-grid">
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
<span>Cookie</span><strong>HTTPS X-Forwarded-Proto=https 时自动 Secure</strong>
<span>会话范围</span><strong>后台 API SSE 事件流均要求登录</strong>
<span>密码规则</span><strong>至少 8 不能为 admin不能与当前密码相同</strong>
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容后续可平滑迁移到更强算法</strong>
</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>
<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><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.auditLogs.length === 0"><td colspan="5">暂无审计日志</td></tr>
</tbody>
</table>
</section>
</section>
</template>
@@ -4,9 +4,21 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({ export default defineConfig({
base: "/admin/", base: "/admin/",
plugins: [vue()], plugins: [vue()],
build: {
chunkSizeWarningLimit: 650,
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router"],
charts: ["echarts", "vue-echarts"],
icons: ["lucide-vue-next"],
},
},
},
},
server: { server: {
proxy: { proxy: {
"/api": "http://127.0.0.1:33550" "/api": "http://127.0.0.1:33550",
} },
} },
}); });
@@ -2,7 +2,13 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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="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> <title>YMhut Box Service Portal</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { onMounted } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router"; 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"; import { usePortalState } from "./state";
const route = useRoute(); const route = useRoute();
@@ -22,7 +22,7 @@ onMounted(() => state.load());
<main class="portal-shell"> <main class="portal-shell">
<nav class="topnav"> <nav class="topnav">
<RouterLink class="brand" to="/"> <RouterLink class="brand" to="/">
<span><ShieldCheck :size="22" /></span> <span><img src="/logo-44.png" alt="YMhut Box" /></span>
<strong>YMhut Box</strong> <strong>YMhut Box</strong>
</RouterLink> </RouterLink>
<div class="nav-links"> <div class="nav-links">
@@ -30,11 +30,11 @@ onMounted(() => state.load());
<component :is="item.icon" :size="15" />{{ item.label }} <component :is="item.icon" :size="15" />{{ item.label }}
</RouterLink> </RouterLink>
</div> </div>
<a class="admin-link" href="/admin/login">控制台</a>
</nav> </nav>
<p v-if="state.error.value" class="state-banner error">部分状态读取失败{{ state.error.value }}</p> <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-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 /> <RouterView />
</main> </main>
@@ -1,12 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
const routes = [ const items = [
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" }, { title: "旧版更新能力", body: "客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
{ path: "/api/client/releases", label: "新版发布信息" }, { title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
{ path: "/api/client/sources", label: "新版接口源目录" }, { title: "新版动态配置", body: "新版客户端优先读取发布、接口源、健康状态和缓存策略,失败时可回退旧路径。" },
{ path: "/update-info.json", label: "旧版更新 JSON" }, { title: "反馈兼容", body: "反馈提交和状态查询入口继续保留,查询结果只展示公开进度。" },
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
{ path: "/tool-status.json", label: "旧版工具状态" },
{ path: "/modules.json", label: "旧版模块清单" },
]; ];
</script> </script>
@@ -14,16 +11,13 @@ const routes = [
<section class="page-heading"> <section class="page-heading">
<p class="eyebrow">Compatibility</p> <p class="eyebrow">Compatibility</p>
<h1>兼容说明</h1> <h1>兼容说明</h1>
<p>新旧客户端共用 update.ymhut.cn旧路径和旧 JSON 字段继续保留</p> <p>新旧客户端共用 update.ymhut.cn门户只展示能力说明具体接口由客户端自动选择</p>
</section> </section>
<section class="panel wide"> <section class="content-grid">
<h2>公开路径</h2> <article v-for="item in items" :key="item.title" class="panel compat-card">
<div class="route-list"> <h2>{{ item.title }}</h2>
<a v-for="item in routes" :key="item.path" :href="item.path"> <p>{{ item.body }}</p>
<strong>{{ item.path }}</strong> </article>
<span>{{ item.label }}</span>
</a>
</div>
</section> </section>
</template> </template>
@@ -10,7 +10,7 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
<section class="page-heading"> <section class="page-heading">
<p class="eyebrow">Feedback</p> <p class="eyebrow">Feedback</p>
<h1>反馈查询</h1> <h1>反馈查询</h1>
<p>旧客户端继续向根路径提交反馈已有反馈可通过反馈码查询处理状态</p> <p>已有反馈可通过反馈码查询公开处理状态</p>
</section> </section>
<section class="panel feedback-panel"> <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" /> <input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a> <a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
</div> </div>
<p class="muted">反馈提交接口保持旧版兼容客户端仍可 POST 到服务根路径</p> <p class="muted">状态查询只返回公开进度公开回复和接收时间不展示后台内部处理记录</p>
</section> </section>
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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"; import { usePortalState } from "../state";
const state = usePortalState(); const state = usePortalState();
@@ -10,20 +10,19 @@ const state = usePortalState();
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">update.ymhut.cn</p> <p class="eyebrow">update.ymhut.cn</p>
<h1>统一发布反馈与接口源状态门户</h1> <h1>统一发布反馈与接口源状态门户</h1>
<p> <p>统一展示 YMhut Box 的发布状态反馈入口接口源可用性与版本日志新版客户端动态读取服务配置旧客户端兼容能力继续保留</p>
新版客户端通过 bootstrap 动态获取发布信息版本日志媒体/数据源目录和接口健康状态旧客户端仍可继续访问
update-info.jsonmedia-types.json下载路径和反馈根路径
</p>
<div class="actions"> <div class="actions">
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a> <a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a> <RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink> <RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
</div> </div>
<div class="hero-tags"> <div class="hero-tags">
<span>Legacy JSON 兼容</span> <span>Legacy JSON 兼容</span>
<span>接口健康检测</span> <span>接口健康检测</span>
<span>反馈状态追踪</span> <span>反馈状态追踪</span>
</div> </div>
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口请稍后刷新</p>
</div> </div>
<aside class="release-card"> <aside class="release-card">
@@ -39,7 +38,7 @@ const state = usePortalState();
</section> </section>
<section class="metric-grid"> <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"><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"><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> <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"> <section class="content-grid">
<article class="panel"> <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"> <div class="route-list">
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink> <RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink>
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源数据源和动态客户端接口状态</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> <strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p> <p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
</div> </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> </article>
</section> </section>
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { BookOpenText, ExternalLink } from "lucide-vue-next"; import { BookOpenText } from "lucide-vue-next";
import { usePortalState } from "../state"; import { usePortalState } from "../state";
const state = usePortalState(); const state = usePortalState();
@@ -9,12 +9,12 @@ const state = usePortalState();
<section class="page-heading"> <section class="page-heading">
<p class="eyebrow">Releases</p> <p class="eyebrow">Releases</p>
<h1>发布版本</h1> <h1>发布版本</h1>
<p>展示发布包下载入口和 update-notice 版本日志</p> <p>展示客户端可见的发布包下载入口和版本日志</p>
</section> </section>
<section class="content-grid"> <section class="content-grid">
<article class="panel wide"> <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> <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></tr></thead>
<tbody> <tbody>
@@ -25,13 +25,15 @@ const state = usePortalState();
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td> <td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td> <td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
</tr> </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> </tbody>
</table> </table>
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败{{ state.error.value }}</p>
</article> </article>
<article class="panel wide"> <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"> <div class="notice-list">
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card"> <section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
<BookOpenText :size="22" /> <BookOpenText :size="22" />
@@ -41,7 +43,8 @@ const state = usePortalState();
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span> <span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
</div> </div>
</section> </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> </div>
</article> </article>
</section> </section>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircle2, ExternalLink } from "lucide-vue-next"; import { CheckCircle2 } from "lucide-vue-next";
import { usePortalState } from "../state"; import { usePortalState } from "../state";
const state = usePortalState(); const state = usePortalState();
@@ -9,11 +9,11 @@ const state = usePortalState();
<section class="page-heading"> <section class="page-heading">
<p class="eyebrow">Sources</p> <p class="eyebrow">Sources</p>
<h1>接口源健康</h1> <h1>接口源健康</h1>
<p>媒体源数据源和客户端动态接口目录的可用性汇总</p> <p>客户端可见接口目录和最近健康状态汇总</p>
</section> </section>
<section class="panel wide"> <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"> <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"> <section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
<div> <div>
@@ -22,11 +22,13 @@ const state = usePortalState();
</div> </div>
<div class="source-list"> <div class="source-list">
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]"> <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> </span>
</div> </div>
</section> </section>
</div> </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> </section>
</template> </template>
@@ -6,12 +6,39 @@ const sources = ref<any>(null);
const notices = ref<any[]>([]); const notices = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(""); 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; 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) { async function fetchJSON(path: string) {
const res = await fetch(path); let res: Response;
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`); try {
return res.json(); 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() { export function usePortalState() {
@@ -23,15 +50,19 @@ export function usePortalState() {
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length; return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
}, 0)); }, 0));
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 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 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 serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
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) { async function load(force = false) {
if (loaded && !force) return; if (loaded && !force) return;
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
try { try {
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([ const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
fetchJSON("/api/client/bootstrap"), fetchJSON("/api/client/bootstrap"),
@@ -39,15 +70,36 @@ export function usePortalState() {
fetchJSON("/api/client/sources"), fetchJSON("/api/client/sources"),
fetchJSON("/api/client/notices"), fetchJSON("/api/client/notices"),
]); ]);
if (bootstrapData.status === "fulfilled") bootstrap.value = bootstrapData.value; if (bootstrapData.status === "fulfilled") {
if (releaseData.status === "fulfilled") releases.value = releaseData.value; bootstrap.value = bootstrapData.value;
if (sourceData.status === "fulfilled") sources.value = sourceData.value; requestState.value.bootstrap = "ready";
if (noticeData.status === "fulfilled") notices.value = noticeData.value.items || []; } 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; 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; loaded = true;
loadedAt.value = new Date().toISOString();
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err); error.value = failureMessage(err);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -60,6 +112,8 @@ export function usePortalState() {
notices, notices,
loading, loading,
error, error,
loadedAt,
requestState,
packages, packages,
categories, categories,
latestNotice, latestNotice,
@@ -68,8 +122,11 @@ export function usePortalState() {
availability, availability,
downloadUrl, downloadUrl,
appVersion, appVersion,
databaseStatus,
serviceVersion, serviceVersion,
isReady,
hasPartialData,
releasesEmpty,
sourcesEmpty,
load, load,
sourceStatus, sourceStatus,
statusTone, statusTone,
@@ -83,7 +140,7 @@ export function sourceStatus(item: any) {
export function statusTone(status: string) { export function statusTone(status: string) {
const value = String(status || "").toLowerCase(); 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 (["degraded", "pending", "missing"].includes(value)) return "warn";
if (["error", "offline", "failed"].includes(value)) return "bad"; if (["error", "offline", "failed"].includes(value)) return "bad";
return "neutral"; return "neutral";
@@ -2,23 +2,22 @@
color-scheme: light; color-scheme: light;
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif; font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
color: #172033; color: #172033;
background: #f7f9ff; background: #f5f7f4;
--ink: #172033; --ink: #172033;
--muted: #63718a; --muted: #63718a;
--soft: #f7f9ff; --soft: #f5f7f4;
--panel: rgba(255, 255, 255, 0.82); --panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff; --panel-strong: #ffffff;
--line: rgba(112, 132, 170, 0.18); --line: rgba(112, 132, 170, 0.18);
--line-strong: rgba(94, 114, 158, 0.28); --line-strong: rgba(94, 114, 158, 0.28);
--primary: #3b82f6; --primary: #1f6f5b;
--primary-dark: #2563eb; --primary-dark: #155241;
--cyan: #06b6d4; --accent: #d99227;
--violet: #8b5cf6;
--pink: #f472b6;
--good: #059669; --good: #059669;
--warn: #b7791f; --warn: #b7791f;
--bad: #dc2626; --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; } * { box-sizing: border-box; }
@@ -26,21 +25,14 @@ html { min-width: 320px; }
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
background: background: #f6f8f4;
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%);
} }
body::before { body::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
background-image: background: rgba(31, 111, 91, 0.025);
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%);
} }
a { color: inherit; } a { color: inherit; }
button, input { font: inherit; } button, input { font: inherit; }
@@ -87,8 +79,14 @@ button { cursor: pointer; }
place-items: center; place-items: center;
border-radius: 50%; border-radius: 50%;
color: #fff; color: #fff;
background: linear-gradient(135deg, var(--primary), var(--cyan)); background: #10231d;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26); 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; } .brand strong { letter-spacing: 0; }
.nav-links { .nav-links {
@@ -97,7 +95,7 @@ button { cursor: pointer; }
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
} }
.nav-links a, .admin-link { .nav-links a {
min-height: 38px; min-height: 38px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -108,18 +106,13 @@ button { cursor: pointer; }
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 14px;
font-weight: 800; 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 { .nav-links a:hover, .nav-links a.active {
color: var(--primary-dark); 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 { .hero {
position: relative; position: relative;
@@ -132,24 +125,12 @@ button { cursor: pointer; }
align-items: stretch; align-items: stretch;
border: 1px solid rgba(255, 255, 255, 0.70); border: 1px solid rgba(255, 255, 255, 0.70);
border-radius: 32px; border-radius: 32px;
background: background: rgba(255, 255, 255, 0.88);
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%);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: clamp(28px, 5vw, 58px); padding: clamp(28px, 5vw, 58px);
overflow: hidden; overflow: hidden;
} }
.hero::after { .hero::after { content: none; }
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-copy { .hero-copy {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -193,7 +174,7 @@ p {
margin-top: 22px; margin-top: 22px;
} }
.hero-tags span { .hero-tags span {
border: 1px solid rgba(59, 130, 246, 0.18); border: 1px solid rgba(31, 111, 91, 0.16);
border-radius: 999px; border-radius: 999px;
padding: 7px 11px; padding: 7px 11px;
color: #355075; color: #355075;
@@ -215,7 +196,7 @@ p {
color: #263856; color: #263856;
font-weight: 900; font-weight: 900;
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10); 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 { .button:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -225,8 +206,8 @@ p {
.button.primary { .button.primary {
color: #fff; color: #fff;
border-color: transparent; border-color: transparent;
background: linear-gradient(135deg, #2563eb, #06b6d4); background: #10231d;
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26); box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
} }
.release-card, .panel, .metric { .release-card, .panel, .metric {
@@ -236,6 +217,13 @@ p {
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11); box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
backdrop-filter: blur(16px); 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 { .release-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -322,7 +310,7 @@ p {
margin: 0 auto 18px; margin: 0 auto 18px;
border: 1px solid rgba(255, 255, 255, 0.74); border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 28px; 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); box-shadow: var(--shadow);
padding: clamp(24px, 4vw, 42px); padding: clamp(24px, 4vw, 42px);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
@@ -363,6 +351,7 @@ th {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.muted, .empty { color: var(--muted); } .muted, .empty { color: var(--muted); }
.empty.strong { font-weight: 900; color: var(--bad); }
.notice-list { display: grid; gap: 12px; } .notice-list { display: grid; gap: 12px; }
.notice-card { .notice-card {
display: grid; display: grid;
@@ -396,8 +385,8 @@ input {
outline: none; outline: none;
} }
input:focus { input:focus {
border-color: rgba(59, 130, 246, 0.65); border-color: rgba(31, 111, 91, 0.58);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14); box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
} }
.source-board { .source-board {
display: grid; display: grid;
@@ -434,7 +423,7 @@ input:focus {
} }
.route-list a:hover { .route-list a:hover {
transform: translateY(-1px); 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); background: rgba(255, 255, 255, 0.92);
} }
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; } .route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
@@ -452,6 +441,7 @@ input:focus {
} }
.error { color: var(--bad); } .error { color: var(--bad); }
.loading { color: var(--muted); } .loading { color: var(--muted); }
.ready { color: var(--good); }
@media (max-width: 980px) { @media (max-width: 980px) {
.topnav { .topnav {
@@ -476,5 +466,5 @@ input:focus {
} }
@media (prefers-reduced-motion: reduce) { @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"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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="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> <title>YMhut Unified Setup</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

-4
View File
@@ -1,4 +0,0 @@
{
"manifest_version": 1,
"modules": []
}
+51 -86
View File
@@ -1,95 +1,60 @@
{ {
"api_keys": { "manifestVersion": 5,
"uapipro": "" "latestVersion": "2.0.7.5",
}, "appVersion": "2.0.7.5",
"app_version": "2.0.6.2", "version": "2.0.7",
"build": "2", "build": "05",
"channel": "stable", "channel": "stable",
"title": "YMhut Box 2.0.6.2", "latest": {
"message": "本版本重点修复覆盖安装后白屏退出、用户目录 runtime 占用、语言包膨胀和设置页初始化问题,并继续完善 WinUI 3 工具型工作台体验。", "version": "2.0.7.5",
"message_md": "# YMhut Box 2.0.6.2\n\n本版本继续收尾 WinUI 3 工具型工作台:修复覆盖安装/直启稳定性、用户目录 runtime 残留、自检结果页卡顿、排行榜/资讯显示、中文日志和设置页初始化问题,并新增安装器输出框、Markdown 公告、媒体播放器与随机放映室增强、价格/指标图表化展示。", "fullInstaller": {
"release_notes": "修复 EXE/latest 直启和覆盖安装后因语言资源布局导致的白屏退出;发布布局改为纯 lang\\zh-CN 与 lang\\en-US,移除多余语言包和旧 resources\\lang 压缩依赖;启动与自检链路禁止在用户数据目录保存 Runtime/runtime/Runtimes/runtimes 等运行时副本,旧残留会在启动和安装时清理;完善启动自检、安装完整性检查、服务状态结果页、工具箱与工具详情布局、结果/原始输出渲染、排行榜/资讯结构化显示、设置页控制中心和系统概况实时图表;修复设置页初始化失败、中文模式英文漏出、部分日志英文展示、天气胶囊图标缺失以及关闭确认记住选择等问题。", "fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"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 媒体体验,加载、失败、保存、全屏等状态更清晰。", "url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
"category_list": [ "sha256": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
{ "size": 113480968,
"icon": "monitor", "version": "2.0.7.5"
"id": "system",
"name": "系统工具"
}, },
{ "msix": {
"icon": "code", "fileName": "YMhutBox_2.0.7.5_x64.msix",
"id": "developer", "url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
"name": "开发工具" "sha256": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"version": "2.0.7.5"
}, },
{ "appInstaller": {
"icon": "image", "fileName": "winui.appinstaller",
"id": "image", "url": "https://update.ymhut.cn/downloads/winui.appinstaller",
"name": "图像工具" "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": "3852e0f6ef98bda862b06de61788deaa836c50fcfcf9703a903bbcfbdd09ce4b",
"size": 113480968,
"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": "c1130c0cc5381854681d0879f168cf31bb346b589bc916a458552763b1fa47db",
"size": 259959751,
"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"
} }
],
"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": [ "messages": {
{ "updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
"enabled": true, "distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
"id": "primary",
"name": "官方直连",
"sha256": "",
"type": "direct",
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.6.2.exe"
}, },
{ "createdAt": "2026-06-26T10:00:33.3827184Z"
"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 风格,移除渐变主视觉,蓝色作为主强调色,橙红仅用于更新、警告和风险行为。",
"天气胶囊": "补齐阴天、未知、离线等状态的基础图标和轻量动效,遵守关闭动画与高对比度设置。",
"本地化与日志": "修复工具箱与安全、风险确认、默认工具范围、设置弹窗和高频日志的中文模式英文漏出;反馈码和原始错误信息仍保留必要英文。"
}
} }