commit 834ecacdb9275b2f8c273b53f021bf33744f947f Author: chiyi Date: Thu Oct 1 01:58:47 2026 +0800 cyi-box: Memphis-style toolbox admin platform Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway (Epay-compatible + CDK stock), license/card system (per-app appid, domain binding), AI memory bank with built-in MCP server (/mcp, FTS5 search, signed read links), server SSH monitor + terminal + SFTP, proxy pool with protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL autocomplete + import/export), disk share, vault (AES-GCM + TOTP), bookmarks, dev tools. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776db54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +data/ +*.log +*.db +*.db-shm +*.db-wal +.env +.env.* +backend/cyibox diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41ebd4f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cyi-cc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d3873f --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +NODE_BIN := $(HOME)/.local/opt/node-v22.23.2-linux-x64/bin +export PATH := $(NODE_BIN):$(PATH) + +.PHONY: dev backend frontend gen build + +# 前后端一起起:后端 :8890,前端 :5173 +dev: + (cd backend && go run ./cmd/server) & (cd frontend && npm run dev) + +backend: + cd backend && go run ./cmd/server + +frontend: + cd frontend && npm run dev + +# 改了 backend 服务/DTO 后跑这个:sqlc 重新生成 internal/db,再生成 frontend/src/api +gen: + cd backend && go tool sqlc generate && go run ./cmd/gen + # 用 cp 覆盖而非 mv:内容重写才能触发 vite watcher,mv(rename) 会让 dev server 继续喂过期模块 + cp -f frontend/src/api/ts/* frontend/src/api/ && rm -rf frontend/src/api/ts + +build: + cd backend && go build ./... + cd frontend && npm run build diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d3d745 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# 池易工作箱 · cyi-box + +孟菲斯/新粗野主义风格的个人工作箱 + 管理后台。单二进制 + SQLite 就能跑:支付网关、授权卡密系统、内置 MCP Server 的 AI 共享记忆库、服务器监控、代理池、数据库管理器、网盘、密码箱、书签导航和一套开发者小工具。 + +后端 Go + [fun](https://github.com/cyi-cc/fun) 单端点 RPC + sqlc/SQLite,前端 Vue 3 + Vite + Naive UI + ECharts + CodeMirror。 + +![License](https://img.shields.io/badge/license-MIT-blue) + +## 截图 + +| 仪表盘 | 代理池 | +|---|---| +| ![仪表盘](docs/screenshots/dashboard.png) | ![代理池](docs/screenshots/proxies.png) | +| **订单管理** | **授权卡密** | +| ![订单](docs/screenshots/pay.png) | ![卡密](docs/screenshots/licenses.png) | +| **记忆库** | **数据库管理** | +| ![记忆库](docs/screenshots/memories.png) | ![数据库](docs/screenshots/dbm.png) | + +## 功能一览 + +- **仪表盘**:营收/订单/会话 KPI、模块宫格快捷入口、近 14 天营收趋势(金额+单数双轴)、最近成交与记忆动态 +- **支付网关**:Epay 兼容协议(`submit.php` / `mapi.php` / `api.php`)、商户 PID/Key、商品+CDK 库存(锁定分配、裸 hex 卡密)、二维码收银台、订单状态实时推送(`fun.Stream`)、异步通知重试 +- **授权系统**:项目管理(每个项目独立 `appid`)+ 卡密批量生成;`GET /v1/license` 公开校验——首次激活绑域名、异域名拒绝、0 小时=永久、项目停用全卡失效 +- **记忆库 / MCP Server**:`POST /mcp` Streamable HTTP MCP 端点(Bearer 鉴权),`memory_save/get/search/list/delete/projects` 六个工具 + `memory://` resources + `save_context` prompt;FTS5 trigram 全文搜索(中文子串可命中);`GET /m/?sig=` HMAC 签名阅读链接;Devin/Codex/Claude 共用一座库 +- **服务器管理**:SSH 密码/密钥登录(AES-GCM 加密存储)、CPU/内存/磁盘/网速实时监控、WebSocket 终端(xterm.js)、SFTP 文件管理 +- **代理池**:双数据源每日同步、协议级握手测活(SOCKS4/5、HTTP,裸 TCP 不算数)、大洲→地区分组、提取密钥 `pk_xxx` + `GET /v1/proxy` 随机活代理 API +- **数据库管理器**:MySQL / PostgreSQL / SQLite 连接(密码加密存储)、库表列结构 → CodeMirror SQL 补全、Ctrl+Enter 执行、JSON/CSV/XLSX 导入导出 +- **网盘**:拖拽上传、限时/永久分享链接(`GET /d/` 免登录)、下载计数 +- **密码箱**:AES-GCM 加密存储、TOTP/otpauth 解析与验证码 +- **开发者工具**:JSON 格式化、时间戳转换、Markdown 编辑器、接口调试(服务端代发绕 CORS)、取色板、图片拾色、渐变图鉴 + +## 快速开始 + +环境:Go 1.24+(含 `go tool sqlc`)、Node 20+。 + +```bash +make dev # 后端 :8890 + 前端 :5173(vite 把 /api 代理到后端) +``` + +浏览器开 `http://127.0.0.1:5173`,默认账号 `admin / admin123`(首次启动自动建库建号,用 `CYIBOX_ADMIN_PASSWORD` 覆盖)。 + +生产部署: + +```bash +make build # go build + vite build → backend/cyibox + frontend/dist +``` + +`cyibox` 单二进制即可跑(数据库首启自动创建于 `./data/cyibox.db`);前端 dist 用任意静态服务/反代托管,把 `/api`、`/mcp`、`/m`、`/v1`、`/d` 等前缀转发到后端即可。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `CYIBOX_PORT` | `8890` | 后端监听端口 | +| `CYIBOX_DB` | `./data/cyibox.db` | SQLite 路径(自动创建) | +| `CYIBOX_ADMIN_NAME` | `admin` | 初始管理员名 | +| `CYIBOX_ADMIN_PASSWORD` | `admin123` | 初始管理员密码(**上线前必改**) | +| `CYIBOX_CORS` | 空 | 跨域白名单(逗号分隔) | +| `CYIBOX_VAULT_KEY` | `/vault.key` | AES-GCM 密钥文件(自动生成) | +| `CYIBOX_DISK_DIR` | `/files` | 网盘文件目录 | + +## MCP 接入(AI 记忆库) + +「记忆库 → 接入 MCP」页面拿端点和令牌,配置到客户端: + +```json +{ + "mcpServers": { + "memory": { + "url": "http://<你的域名或IP>:8890/mcp", + "transport": "http", + "headers": { "Authorization": "Bearer <令牌>" } + } + } +} +``` + +工具:`memory_save`(key 必填,`project/topic` 命名)· `memory_get` · `memory_search`(FTS5)· `memory_list` · `memory_projects` · `memory_delete`;资源 `memory://`;prompt `save_context`。 + +## 开发 + +```bash +make gen # sqlc 重新生成 internal/db + TS 客户端(改了 service/DTO/SQL 后跑) +make build # go build + vite build +``` + +## 结构 + +``` +backend/ Go 后端(module github.com/cyi-cc/cyi-box/backend) + sqlc.yaml sqlc 配置(engine sqlite) + cmd/server/ 服务入口 + cmd/gen/ TS 客户端生成(BindServiceForGen,不碰数据库) + internal/config/ 环境变量配置 + internal/db/ schema.sql + queries.sql + sqlc 生成物(勿手改 *.sql.go / models.go) + internal/store/ 数据门面层:委托 db.Queries + 会话续期/种子等定制逻辑 + internal/sshx/ SSH 拨号 + 指标采集(/proc 两采样) + internal/vault/ AES-GCM 字段加密 + TOTP/otpauth 解析 + internal/dto/ RPC DTO(定宽整型/指针可空,见 fun 文档) + internal/service/ AuthGuard 策略表 + 各 Svc + SFTP/终端自定义路由 +frontend/ Vue 3 + Vite + Naive UI + Pinia + vue-router + src/api/ fun GenTs 生成产物(勿手改,跑 make gen 重新生成) + src/lib/api.ts client 封装:token 注入 state、4011 统一跳登录 + src/layouts/ 后台布局(左侧导航:通用工具静态组 + 书签组) + src/views/ 登录/仪表盘/用户/密码箱/书签/服务器,tools/ 下为内置工具页 +``` + +## 启动 + +```bash +make dev # 后端 :8890 + 前端 :5173(vite 代理 /api → /cell) +``` + +首次启动自动建库并种子 `admin / admin123`(用 `CYIBOX_ADMIN_PASSWORD` 改掉)。 +可选环境变量:`CYIBOX_PORT`、`CYIBOX_DB`、`CYIBOX_ADMIN_NAME`、`CYIBOX_CORS`。 + +## 常用 + +```bash +make gen # sqlc 重新生成 internal/db + 重新生成 TS 客户端 +make build # go build + vite build +``` + +## 数据层约定 + +- 表结构与查询写在 `internal/db/schema.sql` / `queries.sql`,`go tool sqlc generate` 生成类型安全代码; +- `internal/store` 是门面层:服务层统一调它,sqlc 表达不了的(会话滑动续期、映射)在这里实现; +- 注意:sqlc 的 sqlite 解析器对 `.sql` 注释里的 `/`、`:` 有兼容问题,注释只写 `-- name:` 行; +- 建表语句以 `schema.sql` 为准,运行时经 `db.SchemaDDL` 自动迁移。 + +## 服务器管理 + +- 密钥/私钥 AES-GCM 加密存 `servers.secret_enc`(复用 vault.key,可用 `CYIBOX_VAULT_KEY` 指定); +- 指标:SSH 跑一段 shell 两采样 `/proc/stat` + `/proc/net/dev` + `df`,得 CPU%/内存/磁盘/网速率; +- SFTP:RPC 列目录/建目录/删/改名,上传 `POST /api/files/upload`、下载 `GET /api/files/download`; +- 终端:`GET /api/ws/terminal` 升级 WebSocket → SSH PTY(xterm.js),query `token` 鉴权(仅 admin)。 + +## 通用工具 + +侧边栏「通用工具」组内置 6 个小工具: + +- `/tools/json` JSON 格式化/压缩/校验,左右分栏 + 语法高亮 +- `/tools/timestamp` 时间戳转换:实时钟、单位自动识别(s/ms/µs/ns)、多时区、相对时间、批量转换 +- `/tools/markdown` Markdown 编辑 + 实时预览(markdown-it,html 关闭防 XSS) +- `/tools/postman` 接口调试:`WebSvc.Fetch` 服务端代发绕开 CORS,30s 超时/5 次重定向/响应 2MB 截断,历史存 localStorage +- `/tools/color` 取色板:HSL 色球(角度=色相/半径=饱和/明度滑杆)、图片上传拾色 + 主色提取、明度阶梯、配色方案 +- `/tools/gradient` 渐变图鉴:预设 + 随机 + 自定义,一键复制 CSS + +## 网盘 + +- 文件落盘 `/files/`(`CYIBOX_DISK_DIR` 可改),`files` 表记录原始名/大小/mime; +- 上传 `POST /disk/upload`(multipart,登录用户)、属主下载 `GET /disk/download?id=&token=`; +- 分享:`shares` 表存 code + 到期时间(0=永久),公开下载 `GET /d/` 免登录, + 过期返回 410 页面,命中计 downloads;删文件连带删分享与磁盘文件; +- 前端 `/disk`:拖拽/点选上传、文件行内分享弹窗(永久/10分~7天/自定义分钟)、链接列表显示剩余时间。 + +## 数据库管理器 + +- 支持 mysql / postgres / sqlite 连接,密码 AES-GCM 存 `dbconns.password_enc`; +- 库/表/列 introspection 喂给 CodeMirror sql 补全;Ctrl+Enter 执行(选中片段优先); +- 查询 20s 超时、行数封顶(默认 500,上限 10000);按首关键字判定是否结果集语句; +- 导出 `POST /api/dbm/export`:json/csv/xlsx(结果集最大 5 万行); +- 导入 `POST /api/dbm/import`:json 对象数组 / csv / xlsx,首行表头与表列按名交集,逐行插入、坏行跳过计数,上限 1 万行。 + +## 代理池 + +- 数据源两个,任一失败不致命(失败源名下的代理本轮不删): + - `GET https://proxy5.net/api/free-proxies.php?v=<30min窗口>`(页面 JS 的接口,直连会被 + Cloudflare 拦:`proxyx.Fetch` 带浏览器 UA/Referer/gzip,403 时先拉列表页热 cookie 再重试); + - `GET https://rola-ip.co/proxy-api/api/v1/proxies?page=N&pageSize=500`(`proxyx.FetchRola`, + 页面前端走的公开前缀,免登录,500/页翻到底,大洲由内置国家码映射补); +- 多源按 ip:port 合并(协议并集),`proxies.source` 记录归属源;删除规则=没出现在任何成功源里才删; +- `proxy_regions`(code 如 SG/NL + 中文名 + 大洲)与 `proxies`(region_id 外键,ip:port 唯一); +- 同步:启动时若无数据或上次超过 20h 补一次,之后每日 00:00(本地时区)全量 diff—— + 源上消失即删、已存在刷新指标复活、新增入库;`proxy_syncs` 记录 added/removed/updated 与明细 + (各最多 200 个地址); +- 测活:每小时一轮,对距上次检测 >55min 的存活代理做**协议级握手**(SOCKS5 greeting+CONNECT / + SOCKS4 / HTTP GET 转发——裸 TCP 连通不算数,防透明网关误判),并发 128、4s 超时; + 失败标 alive=0,死记录 7 天后物理清除; +- 页面 `/proxies`:左栏大洲→地区分类(存活数),右侧筛选/搜索/分页列表,底部同步记录可展开变更明细; +- `ProxySvc`:List/Regions/Stats 登录即可,Syncs/SyncNow/CheckNow 仅 admin。 + +## 代理提取 API + +- `/proxies/extract` 页生成密钥(`pk_xxx`,存 `proxy_keys` 表,绑定地区或「全部」),删除即吊销; +- `GET /v1/proxy?key=<密钥>` 免登录:随机抽 15 个存活候选**并行**协议级探活(3s 超时), + 先活先回一行 `ip:port` 纯文本;死的顺手标 `alive=0`,最多 4 批 / 20s 总超时,全死 503; +- 每次成功提取 `used_count+1`、`last_used_at` 更新。 + +## 系统设置 + +- `/settings`(admin):修改密码。`SettingSvc`(Get 登录可读 / Set 仅 admin,`settings` KV 表) + 已就位,暂无前端消费方,后续站点级开关可挂在这。 + +## 加一个新工具页 + +1. `frontend/src/views/tools/` 下写页面,路由加在 `router/index.ts` 的 children 里; +2. 侧边栏 `AdminLayout.vue` 的「通用工具」组里加一项; +3. 需要后端时:`service/` 里建 Svc(嵌 `fun.Ctx`,方法 `DTO → (View, error)`), + 在 `auth_guard.go` 的策略表登记端点,`cmd/server` 与 `cmd/gen` 各加一行注册, + 然后 `make gen`。注意:svc 结构体字段必须全部导出(fun autowired 反射赋值,未导出字段会 panic), + 包级单例放普通变量即可。 + +## 鉴权约定 + +- 前端 token 存 localStorage,请求拦截器写 `state.token`; +- `AuthGuard` 按「服务.方法」策略表鉴权(缺省拒绝),会话存 SQLite,24h 滑动过期(闲置 2h 后的下次请求自动续回 24h); +- 业务错误 `fun.Error(code, msg)`;`4011` = 登录失效,前端统一跳登录页; +- SFTP/终端等自定义路由不走 AuthGuard,`routeAdmin` 手动校验 query token。 diff --git a/backend/cmd/gen/main.go b/backend/cmd/gen/main.go new file mode 100644 index 0000000..583850e --- /dev/null +++ b/backend/cmd/gen/main.go @@ -0,0 +1,34 @@ +// Command gen 生成前端 TS 客户端:BindServiceForGen 只反射方法签名, +// 不装配数据库等基础设施,离线可用。用法:cd backend && go run ./cmd/gen +package main + +import ( + "log" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/service" +) + +func main() { + f := fun.GetFun() + f.BindServiceForGen(&service.AuthSvc{}) + f.BindServiceForGen(&service.UserSvc{}) + f.BindServiceForGen(&service.DashboardSvc{}) + f.BindServiceForGen(&service.ToolSvc{}) + f.BindServiceForGen(&service.VaultSvc{}) + f.BindServiceForGen(&service.BookmarkSvc{}) + f.BindServiceForGen(&service.ServerSvc{}) + f.BindServiceForGen(&service.DbMgrSvc{}) + f.BindServiceForGen(&service.WebSvc{}) + f.BindServiceForGen(&service.DiskSvc{}) + f.BindServiceForGen(&service.ProxySvc{}) + f.BindServiceForGen(&service.SettingSvc{}) + f.BindServiceForGen(&service.PaySvc{}) + f.BindServiceForGen(&service.LicenseSvc{}) + f.BindServiceForGen(&service.MemorySvc{}) + + fun.SetOutput("../frontend/src/api") + fun.GenCode(fun.GenTs{}) + log.Println("gen: TS client written to frontend/src/api/ts") +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..8eddcd2 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cyi-cc/fun" + "golang.org/x/crypto/bcrypt" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/payx" + "github.com/cyi-cc/cyi-box/backend/internal/service" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// mustWired 装配失败即退出:基础设施缺一不可,粘性错误由 fun 容器返回 +func mustWired[T any](v *T, err error) *T { + if err != nil { + log.Fatalf("boot: wired %T failed: %v", v, err) + } + return v +} + +func main() { + cfg := mustWired(fun.Wired[config.Config]()) + st := mustWired(fun.Wired[store.Store]()) + + // 首启种子:空库写 admin + 示例工具 + if hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcrypt.DefaultCost); err == nil { + if err := st.SeedAdmin(string(hash)); err != nil { + log.Fatalf("boot: seed admin failed: %v", err) + } + } + if err := st.SeedTools(); err != nil { + log.Fatalf("boot: seed tools failed: %v", err) + } + + f := fun.GetFun() + if len(cfg.CorsOrigins) > 0 { + f.CORS(cfg.CorsOrigins...) + } + if err := f.BindGuard(&service.AuthGuard{}); err != nil { + log.Fatalf("boot: bind guard failed: %v", err) + } + for _, err := range []error{ + f.BindService(&service.AuthSvc{}), + f.BindService(&service.UserSvc{}), + f.BindService(&service.DashboardSvc{}), + f.BindService(&service.ToolSvc{}), + f.BindService(&service.VaultSvc{}), + f.BindService(&service.BookmarkSvc{}), + f.BindService(&service.ServerSvc{}), + f.BindService(&service.DbMgrSvc{}), + f.BindService(&service.WebSvc{}), + f.BindService(&service.DiskSvc{}), + f.BindService(&service.ProxySvc{}), + f.BindService(&service.SettingSvc{}), + f.BindService(&service.PaySvc{}), + f.BindService(&service.LicenseSvc{}), + f.BindService(&service.MemorySvc{}), + } { + if err != nil { + log.Fatalf("boot: bind service failed: %v", err) + } + } + + // 支付:上游客户端 + 订单轮询 + 商户通知(单例) + up := mustWired(fun.Wired[payx.Client]()) + mustWired(fun.Wired[service.PayNotifier]()) + mustWired(fun.Wired[service.PayPoller]()) + service.BootPay(st, up, cfg) + + // SFTP 上传放宽请求体上限(默认 4MB → 512MB) + f.SetBodyLimit(512 << 20) + for _, err := range []error{ + f.BindRoute("GET", "/files/download", service.SftpDownloadHandler(st, cfg)), + f.BindRoute("POST", "/files/upload", service.SftpUploadHandler(st, cfg)), + f.BindRoute("GET", "/ws/terminal", service.TerminalHandler(st, cfg)), + f.BindRoute("POST", "/dbm/export", service.DbExportHandler(st, cfg)), + f.BindRoute("POST", "/dbm/import", service.DbImportHandler(st, cfg)), + f.BindRoute("POST", "/disk/upload", service.DiskUploadHandler(st, cfg)), + f.BindRoute("GET", "/disk/download", service.DiskDownloadHandler(st, cfg)), + f.BindRoute("GET", "/d/*", service.ShareDownloadHandler(st, cfg)), + f.BindRoute("GET", "/v1/proxy", service.ProxyExtractHandler(st)), + f.BindRoute("GET", "/v1/license", service.LicenseVerifyHandler(st)), + f.BindRoute("POST", "/v1/license", service.LicenseVerifyHandler(st)), + // 共享记忆库:MCP 协议端点 + 公开阅读链接 + f.BindRoute("POST", "/mcp", service.McpHandler(st)), + f.BindRoute("GET", "/m", service.MemoryReadHandler(st)), + f.BindRoute("GET", "/m/*", service.MemoryReadHandler(st)), + // 易支付协议端点(GET/POST 同路由) + f.BindRoute("GET", "/submit.php", service.PaySubmitHandler(st, up)), + f.BindRoute("POST", "/submit.php", service.PaySubmitHandler(st, up)), + f.BindRoute("GET", "/mapi.php", service.PayMapiHandler(st, up)), + f.BindRoute("POST", "/mapi.php", service.PayMapiHandler(st, up)), + f.BindRoute("GET", "/api.php", service.PayApiHandler(st)), + f.BindRoute("POST", "/api.php", service.PayApiHandler(st)), + f.BindRoute("GET", "/healthz", func(c *fun.RouteCtx) error { + c.RequestCtx.WriteString("ok") + return nil + }), + } { + if err != nil { + log.Fatalf("boot: bind route failed: %v", err) + } + } + + // 代理池后台调度:每小时测活、每日 00:00 全量同步 + schedCtx, stopSched := context.WithCancel(context.Background()) + service.StartProxyScheduler(schedCtx, st) + + // 优雅停机:SIGINT/SIGTERM → 等在途请求收尾 + go func() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch + stopSched() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = f.Shutdown(ctx) + }() + + log.Printf("cyi-box backend listening on :%d, db=%s", cfg.Port, cfg.DBPath) + f.Start(cfg.Port) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a445734 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,85 @@ +module github.com/cyi-cc/cyi-box/backend + +go 1.26.1 + +require ( + github.com/cyi-cc/fun v1.4.2 + github.com/fasthttp/websocket v1.5.12 + github.com/go-sql-driver/mysql v1.10.1 + github.com/jackc/pgx/v5 v5.11.0 + github.com/mark3labs/mcp-go v1.1.1 + github.com/pkg/sftp v1.13.11 + github.com/valyala/fasthttp v1.73.0 + github.com/xuri/excelize/v2 v2.11.0 + golang.org/x/crypto v0.54.0 + modernc.org/sqlite v1.59.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/google/cel-go v0.28.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-sqlite3 v0.32.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect + github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/sqlc-dev/doubleclick v1.0.0 // indirect + github.com/sqlc-dev/sqlc v1.31.1 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) + +tool github.com/sqlc-dev/sqlc/cmd/sqlc diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..828f840 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,263 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/cyi-cc/fun v1.4.2 h1:sMhBlUtXAWp57mwx4MnCodL1oPc9ejz+E5ukRIAZvD4= +github.com/cyi-cc/fun v1.4.2/go.mod h1:+093hp81tF4m0LZX6FNIfL+N9vsUardWCJwjkMVB5N8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= +github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v1.1.1 h1:PMZjyayCF01Y4R2kQXgDtsmxVLOdq1Mol4CnzzTYSEo= +github.com/mark3labs/mcp-go v1.1.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20= +github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/pganalyze/pg_query_go/v6 v6.2.2 h1:O0L6zMC226R82RF3X5n0Ki6HjytDsoAzuzp4ATVAHNo= +github.com/pganalyze/pg_query_go/v6 v6.2.2/go.mod h1:Cn6+j4870kJz3iYNsb0VsNG04vpSWgEvBwc590J4qD0= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee h1:/IDPbpzkzA97t1/Z1+C3KlxbevjMeaI6BQYxvivu4u8= +github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 h1:zYmP5fBH+i2yhhU6f5uOol6zxHtR2/sD47BsJLfy0oU= +github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124/go.mod h1:zDLDsfNBU5+L6T4J9/OgWAHc/WZvMUjbpgHqQ/t3yKo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= +github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= +github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sqlc-dev/doubleclick v1.0.0 h1:2/OApfQ2eLgcfa/Fqs8WSMA6atH0G8j9hHbQIgMfAXI= +github.com/sqlc-dev/doubleclick v1.0.0/go.mod h1:ODHRroSrk/rr5neRHlWMSRijqOak8YmNaO3VAZCNl5Y= +github.com/sqlc-dev/sqlc v1.31.1 h1:+V+BjBJfFNPX/RFfL8eiZD9jk9lVJUEGGllWvnYNqbc= +github.com/sqlc-dev/sqlc v1.31.1/go.mod h1:6ZPww/Jd3G6MzJeW6NrqizjL+52vYNaaXP9yMeJ/Nao= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..fd0378b --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,90 @@ +// Package config 进程配置:全部走环境变量,零配置文件。 +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/cyi-cc/cyi-box/backend/internal/vault" +) + +type Config struct { + Port uint16 // CYIBOX_PORT,默认 8890 + DBPath string // CYIBOX_DB,默认 ./data/cyibox.db + AdminName string // CYIBOX_ADMIN_NAME,默认 admin(首启种子账号) + AdminPassword string // CYIBOX_ADMIN_PASSWORD,默认 admin123(首启种子密码) + CorsOrigins []string // CYIBOX_CORS 逗号分隔白名单;开发走 vite 代理可留空 + VaultKey []byte // 密码箱字段加密密钥(env CYIBOX_VAULT_KEY 或自动生成落盘) + DiskDir string // 网盘文件落盘目录,默认 /files +} + +func (c *Config) New() error { + c.Port = uint16(envInt("CYIBOX_PORT", 8890)) + c.DBPath = envStr("CYIBOX_DB", "./data/cyibox.db") + c.AdminName = envStr("CYIBOX_ADMIN_NAME", "admin") + c.AdminPassword = envStr("CYIBOX_ADMIN_PASSWORD", "admin123") + if v := strings.TrimSpace(os.Getenv("CYIBOX_CORS")); v != "" { + for _, o := range strings.Split(v, ",") { + if o = strings.TrimSpace(o); o != "" { + c.CorsOrigins = append(c.CorsOrigins, o) + } + } + } + if c.Port == 0 { + return fmt.Errorf("config: invalid CYIBOX_PORT") + } + if dir := filepath.Dir(c.DBPath); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("config: create db dir failed: %w", err) + } + } + key, err := loadVaultKey(filepath.Dir(c.DBPath)) + if err != nil { + return err + } + c.VaultKey = key + c.DiskDir = envStr("CYIBOX_DISK_DIR", filepath.Join(filepath.Dir(c.DBPath), "files")) + if err := os.MkdirAll(c.DiskDir, 0o755); err != nil { + return fmt.Errorf("config: create disk dir failed: %w", err) + } + return nil +} + +// loadVaultKey CYIBOX_VAULT_KEY 优先;否则 /vault.key 存在即读, +// 不存在则随机生成并以 0600 落盘——密钥独立于 db 文件,db 被拷走也解不开密文 +func loadVaultKey(dir string) ([]byte, error) { + if v := strings.TrimSpace(os.Getenv("CYIBOX_VAULT_KEY")); v != "" { + return vault.DeriveKey(v), nil + } + path := filepath.Join(dir, "vault.key") + if raw, err := os.ReadFile(path); err == nil { + return vault.DeriveKey(string(raw)), nil + } + material, err := vault.RandomKey() + if err != nil { + return nil, fmt.Errorf("config: generate vault key failed: %w", err) + } + if err := os.WriteFile(path, []byte(material), 0o600); err != nil { + return nil, fmt.Errorf("config: write vault key failed: %w", err) + } + return vault.DeriveKey(material), nil +} + +func envStr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/backend/internal/db/embed.go b/backend/internal/db/embed.go new file mode 100644 index 0000000..ffbe779 --- /dev/null +++ b/backend/internal/db/embed.go @@ -0,0 +1,8 @@ +// Package db sqlc 生成层。models.go / queries.sql.go 由 `go tool sqlc generate` 产出, +// 手写部分只有本文件——把 schema.sql 嵌进来供 store 运行时迁移。 +package db + +import _ "embed" + +//go:embed schema.sql +var SchemaDDL string diff --git a/backend/internal/db/models.go b/backend/internal/db/models.go new file mode 100644 index 0000000..e0a35df --- /dev/null +++ b/backend/internal/db/models.go @@ -0,0 +1,220 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +type Bookmark struct { + ID int64 + UserID int64 + Title string + Url string + Icon string + Sort int64 + CreatedAt int64 +} + +type Dbconn struct { + ID int64 + Name string + Engine string + Host string + Port int64 + Username string + PasswordEnc string + Database string + Params string + CreatedAt int64 + UpdatedAt int64 +} + +type File struct { + ID int64 + UserID int64 + Name string + Stored string + Size int64 + Mime string + CreatedAt int64 +} + +type LicenseApp struct { + ID int64 + Appid string + Secret string + Name string + Enabled int64 + CreatedAt int64 +} + +type LicenseCard struct { + ID int64 + AppID int64 + Card string + Hours int64 + Domain string + ActivatedAt int64 + ExpiresAt int64 + CreatedAt int64 +} + +type Memory struct { + K string + Content string + Project string + Tags string + WrittenBy string + Revision int64 + CreatedAt int64 + UpdatedAt int64 +} + +type PayOrder struct { + ID int64 + TradeNo string + OutTradeNo string + Channel string + Subject string + Money int64 + Fee int64 + Status int64 + NotifyUrl string + ReturnUrl string + ClientIp string + Notified int64 + NotifyAttempts int64 + PaidAt int64 + ExpiredAt int64 + UpstreamTradeNo string + GoodsKey string + Quantity int64 + UnitPrice int64 + Payurl string + Qrcode string + BuyerContact string + QueryPwd string + Cards string + CreatedAt int64 +} + +type Proxy struct { + ID int64 + Ip string + Port int64 + Protocols string + Anonymity string + Source string + RegionID int64 + Latency int64 + Speed int64 + Uptime int64 + Alive int64 + FailCount int64 + LastCheckedAt int64 + LastAliveAt int64 + LastSeenAt int64 + CreatedAt int64 +} + +type ProxyKey struct { + ID int64 + Kkey string + Name string + RegionID int64 + Enabled int64 + UsedCount int64 + LastUsedAt int64 + CreatedAt int64 +} + +type ProxyRegion struct { + ID int64 + Code string + Name string + ZhName string + Region string + Slug string +} + +type ProxySync struct { + ID int64 + StartedAt int64 + FinishedAt int64 + Status string + Source string + Total int64 + Added int64 + Removed int64 + Updated int64 + Detail string + Error string +} + +type Server struct { + ID int64 + Name string + Host string + Port int64 + Username string + AuthType string + SecretEnc string + Note string + CreatedAt int64 + UpdatedAt int64 +} + +type Session struct { + Token string + UserID int64 + CreatedAt int64 + ExpiresAt int64 +} + +type Setting struct { + K string + V string +} + +type Share struct { + ID int64 + Code string + FileID int64 + UserID int64 + FileName string + ExpiresAt int64 + Downloads int64 + CreatedAt int64 +} + +type Tool struct { + ID int64 + Name string + Icon string + Url string + Description string + Sort int64 + Enabled bool +} + +type User struct { + ID int64 + Name string + PasswordHash string + Role string + Status string + CreatedAt int64 + UpdatedAt int64 +} + +type Vault struct { + ID int64 + UserID int64 + Title string + Username string + PasswordEnc string + Url string + Note string + TotpEnc string + CreatedAt int64 + UpdatedAt int64 +} diff --git a/backend/internal/db/querier.go b/backend/internal/db/querier.go new file mode 100644 index 0000000..7dc6061 --- /dev/null +++ b/backend/internal/db/querier.go @@ -0,0 +1,136 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type Querier interface { + ActivateLicenseCard(ctx context.Context, arg ActivateLicenseCardParams) error + CountActiveSessions(ctx context.Context, expiresAt int64) (int64, error) + CountActiveUsers(ctx context.Context) (int64, error) + CountAdmins(ctx context.Context) (int64, error) + CountAllUsers(ctx context.Context) (int64, error) + CountBookmarksByUser(ctx context.Context, userID int64) (int64, error) + CountDbconns(ctx context.Context) (int64, error) + CountFilesAll(ctx context.Context) (int64, error) + CountLicenseApps(ctx context.Context) (int64, error) + CountLicenseCards(ctx context.Context, arg CountLicenseCardsParams) (int64, error) + CountMemories(ctx context.Context) (int64, error) + CountPayOrders(ctx context.Context, arg CountPayOrdersParams) (int64, error) + CountProxies(ctx context.Context) (int64, error) + CountProxiesAll(ctx context.Context) (int64, error) + CountProxiesFiltered(ctx context.Context, arg CountProxiesFilteredParams) (int64, error) + CountServers(ctx context.Context) (int64, error) + CountTodayLogins(ctx context.Context) (int64, error) + CountTools(ctx context.Context) (int64, error) + CountUsers(ctx context.Context, keyword sql.NullString) (int64, error) + CountVaultByUser(ctx context.Context, userID int64) (int64, error) + CreateLicenseApp(ctx context.Context, arg CreateLicenseAppParams) (int64, error) + CreateLicenseCard(ctx context.Context, arg CreateLicenseCardParams) error + CreatePayOrder(ctx context.Context, arg CreatePayOrderParams) (int64, error) + DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) (int64, error) + DeleteDbconn(ctx context.Context, id int64) (int64, error) + DeleteDeadProxies(ctx context.Context, lastCheckedAt int64) (int64, error) + DeleteFile(ctx context.Context, arg DeleteFileParams) (int64, error) + DeleteLicenseApp(ctx context.Context, id int64) error + DeleteLicenseCard(ctx context.Context, id int64) error + DeleteMemory(ctx context.Context, k string) (int64, error) + DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) error + DeleteProxy(ctx context.Context, id int64) error + DeleteProxyApiKey(ctx context.Context, id int64) (int64, error) + DeleteServer(ctx context.Context, id int64) (int64, error) + DeleteSession(ctx context.Context, token string) error + DeleteShare(ctx context.Context, arg DeleteShareParams) (int64, error) + DeleteSharesByFile(ctx context.Context, fileID int64) error + DeleteTool(ctx context.Context, id int64) error + DeleteUser(ctx context.Context, id int64) error + DeleteVault(ctx context.Context, arg DeleteVaultParams) (int64, error) + FinishProxySync(ctx context.Context, arg FinishProxySyncParams) error + GetDbconn(ctx context.Context, id int64) (Dbconn, error) + GetFile(ctx context.Context, id int64) (File, error) + GetLicenseAppByAppid(ctx context.Context, appid string) (LicenseApp, error) + GetLicenseCard(ctx context.Context, arg GetLicenseCardParams) (LicenseCard, error) + GetMemory(ctx context.Context, k string) (Memory, error) + GetPayOrder(ctx context.Context, id int64) (PayOrder, error) + GetPayOrderByOutNo(ctx context.Context, outTradeNo string) (PayOrder, error) + GetPayOrderByTradeNo(ctx context.Context, tradeNo string) (PayOrder, error) + GetProxyApiKey(ctx context.Context, kkey string) (ProxyKey, error) + GetProxyRegionByCode(ctx context.Context, code string) (ProxyRegion, error) + GetServer(ctx context.Context, id int64) (Server, error) + GetSessionUser(ctx context.Context, token string) (GetSessionUserRow, error) + GetSetting(ctx context.Context, k string) (string, error) + GetShareByCode(ctx context.Context, code string) (GetShareByCodeRow, error) + GetUserByID(ctx context.Context, id int64) (User, error) + GetUserByName(ctx context.Context, name string) (User, error) + GetVault(ctx context.Context, arg GetVaultParams) (Vault, error) + IncPayNotifyAttempts(ctx context.Context, id int64) error + IncShareDownloads(ctx context.Context, code string) error + InsertBookmark(ctx context.Context, arg InsertBookmarkParams) (int64, error) + InsertDbconn(ctx context.Context, arg InsertDbconnParams) (int64, error) + InsertFile(ctx context.Context, arg InsertFileParams) (int64, error) + InsertProxy(ctx context.Context, arg InsertProxyParams) (int64, error) + InsertProxyApiKey(ctx context.Context, arg InsertProxyApiKeyParams) (int64, error) + InsertProxySync(ctx context.Context, arg InsertProxySyncParams) (int64, error) + InsertServer(ctx context.Context, arg InsertServerParams) (int64, error) + InsertSession(ctx context.Context, arg InsertSessionParams) error + InsertShare(ctx context.Context, arg InsertShareParams) (int64, error) + InsertTool(ctx context.Context, arg InsertToolParams) (int64, error) + InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) + InsertVault(ctx context.Context, arg InsertVaultParams) (int64, error) + LastProxyCheckAt(ctx context.Context) (interface{}, error) + LicenseCardStats(ctx context.Context, arg LicenseCardStatsParams) (LicenseCardStatsRow, error) + LicenseCardStatsAll(ctx context.Context, expiresAt int64) (LicenseCardStatsAllRow, error) + ListBookmarks(ctx context.Context, userID int64) ([]Bookmark, error) + ListDbconns(ctx context.Context) ([]Dbconn, error) + ListEnabledTools(ctx context.Context) ([]Tool, error) + ListFiles(ctx context.Context, userID int64) ([]File, error) + ListLicenseApps(ctx context.Context) ([]ListLicenseAppsRow, error) + ListLicenseCards(ctx context.Context, arg ListLicenseCardsParams) ([]LicenseCard, error) + ListMemories(ctx context.Context, arg ListMemoriesParams) ([]Memory, error) + ListPayOrders(ctx context.Context, arg ListPayOrdersParams) ([]PayOrder, error) + ListPendingPayOrders(ctx context.Context) ([]PayOrder, error) + ListProxiesDue(ctx context.Context, lastCheckedAt int64) ([]ListProxiesDueRow, error) + ListProxiesFiltered(ctx context.Context, arg ListProxiesFilteredParams) ([]ListProxiesFilteredRow, error) + ListProxyApiKeys(ctx context.Context) ([]ListProxyApiKeysRow, error) + ListProxyKeys(ctx context.Context) ([]ListProxyKeysRow, error) + ListProxyRegions(ctx context.Context) ([]ListProxyRegionsRow, error) + ListProxySyncs(ctx context.Context, limit int64) ([]ProxySync, error) + ListServers(ctx context.Context) ([]Server, error) + ListShares(ctx context.Context, userID int64) ([]Share, error) + ListTools(ctx context.Context) ([]Tool, error) + ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) + ListVault(ctx context.Context, arg ListVaultParams) ([]Vault, error) + LoginTrend(ctx context.Context, createdAt int64) ([]LoginTrendRow, error) + MarkProxyAlive(ctx context.Context, arg MarkProxyAliveParams) error + MarkProxyFailed(ctx context.Context, arg MarkProxyFailedParams) error + MemoryProjects(ctx context.Context) ([]MemoryProjectsRow, error) + PayDailyPaid(ctx context.Context, paidAt int64) ([]PayDailyPaidRow, error) + PayOrderStats(ctx context.Context, createdAt int64) (PayOrderStatsRow, error) + RandomProxies(ctx context.Context, arg RandomProxiesParams) ([]RandomProxiesRow, error) + RecentPaidOrders(ctx context.Context, limit int64) ([]RecentPaidOrdersRow, error) + SetPayNotified(ctx context.Context, id int64) error + SetPayOrderQrcode(ctx context.Context, arg SetPayOrderQrcodeParams) error + SetPayOrderStatus(ctx context.Context, arg SetPayOrderStatusParams) error + SetPayOrderUpstream(ctx context.Context, arg SetPayOrderUpstreamParams) error + TouchProxyApiKey(ctx context.Context, arg TouchProxyApiKeyParams) error + TouchSession(ctx context.Context, arg TouchSessionParams) error + UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (int64, error) + UpdateDbconn(ctx context.Context, arg UpdateDbconnParams) (int64, error) + UpdateLicenseApp(ctx context.Context, arg UpdateLicenseAppParams) error + UpdateProxySeen(ctx context.Context, arg UpdateProxySeenParams) error + UpdateServer(ctx context.Context, arg UpdateServerParams) (int64, error) + UpdateTool(ctx context.Context, arg UpdateToolParams) error + UpdateUser(ctx context.Context, arg UpdateUserParams) error + UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error + UpdateVault(ctx context.Context, arg UpdateVaultParams) (int64, error) + UpsertMemory(ctx context.Context, arg UpsertMemoryParams) error + UpsertProxyRegion(ctx context.Context, arg UpsertProxyRegionParams) error + UpsertSetting(ctx context.Context, arg UpsertSettingParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/backend/internal/db/queries.sql b/backend/internal/db/queries.sql new file mode 100644 index 0000000..0fb4942 --- /dev/null +++ b/backend/internal/db/queries.sql @@ -0,0 +1,427 @@ +-- name: GetUserByName :one +SELECT * FROM users WHERE name = ? LIMIT 1; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = ?; + +-- name: ListUsers :many +SELECT * FROM users +WHERE name LIKE '%' || sqlc.arg(keyword) || '%' +ORDER BY id LIMIT ? OFFSET ?; + +-- name: CountUsers :one +SELECT COUNT(*) FROM users WHERE name LIKE '%' || sqlc.arg(keyword) || '%'; + +-- name: CountAllUsers :one +SELECT COUNT(*) FROM users; + +-- name: CountActiveUsers :one +SELECT COUNT(*) FROM users WHERE status = 'active'; + +-- name: CountAdmins :one +SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'; + +-- name: InsertUser :execlastid +INSERT INTO users(name, password_hash, role, status, created_at, updated_at) +VALUES(?, ?, ?, 'active', ?, ?); + +-- name: UpdateUser :exec +UPDATE users SET name = ?, role = ?, status = ?, updated_at = ? WHERE id = ?; + +-- name: UpdateUserPassword :exec +UPDATE users SET name = ?, role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ?; + +-- name: DeleteUser :exec +DELETE FROM users WHERE id = ?; + +-- name: InsertSession :exec +INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES(?, ?, ?, ?); + +-- name: GetSessionUser :one +SELECT u.id, u.name, u.password_hash, u.role, u.status, u.created_at, u.updated_at, s.expires_at +FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE token = ?; + +-- name: DeleteOtherSessions :exec +DELETE FROM sessions WHERE user_id = ? AND token != ?; + +-- name: TouchSession :exec +UPDATE sessions SET expires_at = ? WHERE token = ?; + +-- name: CountActiveSessions :one +SELECT COUNT(*) FROM sessions WHERE expires_at > ?; + +-- name: CountTodayLogins :one +SELECT COUNT(*) FROM sessions +WHERE date(created_at, 'unixepoch', 'localtime') = date('now', 'localtime'); + +-- name: LoginTrend :many +SELECT date(created_at, 'unixepoch', 'localtime') AS d, COUNT(*) AS c +FROM sessions WHERE created_at >= ? GROUP BY d; + +-- name: ListTools :many +SELECT * FROM tools ORDER BY sort, id; + +-- name: ListEnabledTools :many +SELECT * FROM tools WHERE enabled = 1 ORDER BY sort, id; + +-- name: InsertTool :execlastid +INSERT INTO tools(name, icon, url, description, sort, enabled) VALUES(?, ?, ?, ?, ?, ?); + +-- name: UpdateTool :exec +UPDATE tools SET name = ?, icon = ?, url = ?, description = ?, sort = ?, enabled = ? WHERE id = ?; + +-- name: DeleteTool :exec +DELETE FROM tools WHERE id = ?; + +-- name: CountTools :one +SELECT COUNT(*) FROM tools WHERE enabled = 1; + +-- name: ListVault :many +SELECT * FROM vault WHERE user_id = ? + AND (title LIKE '%' || sqlc.arg(keyword) || '%' + OR username LIKE '%' || sqlc.arg(keyword) || '%' + OR url LIKE '%' || sqlc.arg(keyword) || '%') +ORDER BY updated_at DESC; + +-- name: GetVault :one +SELECT * FROM vault WHERE user_id = ? AND id = ?; + +-- name: InsertVault :execlastid +INSERT INTO vault(user_id, title, username, password_enc, url, note, totp_enc, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateVault :execrows +UPDATE vault SET title = ?, username = ?, password_enc = ?, url = ?, note = ?, totp_enc = ?, updated_at = ? +WHERE id = ? AND user_id = ?; + +-- name: DeleteVault :execrows +DELETE FROM vault WHERE user_id = ? AND id = ?; + +-- name: ListBookmarks :many +SELECT * FROM bookmarks WHERE user_id = ? ORDER BY sort, id; + +-- name: InsertBookmark :execlastid +INSERT INTO bookmarks(user_id, title, url, icon, sort, created_at) VALUES(?, ?, ?, ?, ?, ?); + +-- name: UpdateBookmark :execrows +UPDATE bookmarks SET title = ?, url = ?, icon = ?, sort = ? WHERE id = ? AND user_id = ?; + +-- name: DeleteBookmark :execrows +DELETE FROM bookmarks WHERE user_id = ? AND id = ?; + +-- name: ListServers :many +SELECT * FROM servers ORDER BY id; + +-- name: GetServer :one +SELECT * FROM servers WHERE id = ?; + +-- name: InsertServer :execlastid +INSERT INTO servers(name, host, port, username, auth_type, secret_enc, note, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateServer :execrows +UPDATE servers SET name = ?, host = ?, port = ?, username = ?, auth_type = ?, secret_enc = ?, note = ?, updated_at = ? +WHERE id = ?; + +-- name: DeleteServer :execrows +DELETE FROM servers WHERE id = ?; + +-- name: CountServers :one +SELECT COUNT(*) FROM servers; + +-- name: ListDbconns :many +SELECT * FROM dbconns ORDER BY id; + +-- name: GetDbconn :one +SELECT * FROM dbconns WHERE id = ?; + +-- name: InsertDbconn :execlastid +INSERT INTO dbconns(name, engine, host, port, username, password_enc, database, params, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateDbconn :execrows +UPDATE dbconns SET name = ?, engine = ?, host = ?, port = ?, username = ?, password_enc = ?, database = ?, params = ?, updated_at = ? +WHERE id = ?; + +-- name: DeleteDbconn :execrows +DELETE FROM dbconns WHERE id = ?; + +-- name: InsertFile :execlastid +INSERT INTO files(user_id, name, stored, size, mime, created_at) VALUES(?, ?, ?, ?, ?, ?); + +-- name: ListFiles :many +SELECT * FROM files WHERE user_id = ? ORDER BY id DESC; + +-- name: GetFile :one +SELECT * FROM files WHERE id = ?; + +-- name: DeleteFile :execrows +DELETE FROM files WHERE id = ? AND user_id = ?; + +-- name: InsertShare :execlastid +INSERT INTO shares(code, file_id, user_id, file_name, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?); + +-- name: ListShares :many +SELECT * FROM shares WHERE user_id = ? ORDER BY id DESC; + +-- name: GetShareByCode :one +SELECT sh.id, sh.code, sh.file_id, sh.user_id, sh.file_name, sh.expires_at, sh.downloads, sh.created_at, + f.name, f.stored, f.size, f.mime +FROM shares sh JOIN files f ON f.id = sh.file_id WHERE sh.code = ?; + +-- name: DeleteShare :execrows +DELETE FROM shares WHERE id = ? AND user_id = ?; + +-- name: DeleteSharesByFile :exec +DELETE FROM shares WHERE file_id = ?; + +-- name: IncShareDownloads :exec +UPDATE shares SET downloads = downloads + 1 WHERE code = ?; + +-- name: UpsertProxyRegion :exec +INSERT INTO proxy_regions(code, name, zh_name, region, slug) VALUES(?, ?, ?, ?, ?) +ON CONFLICT(code) DO UPDATE SET name = excluded.name, zh_name = excluded.zh_name, + region = excluded.region, slug = excluded.slug; + +-- name: GetProxyRegionByCode :one +SELECT * FROM proxy_regions WHERE code = ?; + +-- name: ListProxyRegions :many +SELECT r.*, (SELECT COUNT(*) FROM proxies p WHERE p.region_id = r.id AND p.alive = 1) AS alive_count +FROM proxy_regions r ORDER BY alive_count DESC, r.code; + +-- name: ListProxyKeys :many +SELECT id, ip, port, source FROM proxies; + +-- name: InsertProxy :execlastid +INSERT INTO proxies(ip, port, protocols, anonymity, source, region_id, latency, speed, uptime, alive, last_seen_at, created_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?); + +-- name: UpdateProxySeen :exec +UPDATE proxies SET protocols = ?, anonymity = ?, region_id = ?, latency = ?, speed = ?, uptime = ?, last_seen_at = ?, alive = 1, fail_count = 0 +WHERE id = ?; + +-- name: DeleteProxy :exec +DELETE FROM proxies WHERE id = ?; + +-- name: ListProxiesDue :many +SELECT id, ip, port, protocols, fail_count FROM proxies WHERE alive = 1 AND last_checked_at < ? ORDER BY last_checked_at LIMIT 20000; + +-- name: MarkProxyAlive :exec +UPDATE proxies SET last_checked_at = ?, alive = 1, fail_count = 0, last_alive_at = ?, latency = ? WHERE id = ?; + +-- name: MarkProxyFailed :exec +UPDATE proxies SET last_checked_at = ?, alive = 0, fail_count = ? WHERE id = ?; + +-- name: DeleteDeadProxies :execrows +DELETE FROM proxies WHERE alive = 0 AND last_checked_at < ?; + +-- name: CountProxies :one +SELECT COUNT(*) FROM proxies WHERE alive = 1; + +-- name: CountProxiesFiltered :one +SELECT COUNT(*) FROM proxies p WHERE (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR p.ip LIKE '%' || ? || '%') AND (? < 0 OR p.alive = ?); + +-- name: ListProxiesFiltered :many +SELECT p.*, r.code AS region_code, r.name AS region_name, r.zh_name AS region_zh_name +FROM proxies p JOIN proxy_regions r ON r.id = p.region_id +WHERE (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR p.ip LIKE '%' || ? || '%') AND (? < 0 OR p.alive = ?) +ORDER BY p.alive DESC, p.latency LIMIT ? OFFSET ?; + +-- name: InsertProxySync :execlastid +INSERT INTO proxy_syncs(started_at, source) VALUES(?, ?); + +-- name: FinishProxySync :exec +UPDATE proxy_syncs SET finished_at = ?, status = ?, total = ?, added = ?, removed = ?, updated = ?, detail = ?, error = ? WHERE id = ?; + +-- name: ListProxySyncs :many +SELECT * FROM proxy_syncs ORDER BY id DESC LIMIT ?; + +-- name: LastProxyCheckAt :one +SELECT COALESCE(MAX(last_checked_at), 0) FROM proxies; + +-- name: InsertProxyApiKey :execlastid +INSERT INTO proxy_keys(kkey, name, region_id, created_at) VALUES(?, ?, ?, ?); + +-- name: ListProxyApiKeys :many +SELECT k.*, r.code AS region_code, r.zh_name AS region_zh_name, r.name AS region_name +FROM proxy_keys k LEFT JOIN proxy_regions r ON r.id = k.region_id +ORDER BY k.id DESC; + +-- name: GetProxyApiKey :one +SELECT * FROM proxy_keys WHERE kkey = ? AND enabled = 1; + +-- name: TouchProxyApiKey :exec +UPDATE proxy_keys SET used_count = used_count + 1, last_used_at = ? WHERE id = ?; + +-- name: DeleteProxyApiKey :execrows +DELETE FROM proxy_keys WHERE id = ?; + +-- name: RandomProxies :many +SELECT p.id, p.ip, p.port, p.protocols, p.fail_count, r.code AS region_code, r.zh_name AS region_zh_name +FROM proxies p JOIN proxy_regions r ON r.id = p.region_id +WHERE p.alive = 1 AND (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR r.code = ?) +ORDER BY RANDOM() LIMIT ?; + +-- name: GetSetting :one +SELECT v FROM settings WHERE k = ?; + +-- name: UpsertSetting :exec +INSERT INTO settings (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v; + +-- name: CreatePayOrder :execlastid +INSERT INTO pay_orders (trade_no, out_trade_no, channel, subject, money, fee, notify_url, return_url, client_ip, expired_at, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: GetPayOrderByTradeNo :one +SELECT * FROM pay_orders WHERE trade_no = ?; + +-- name: GetPayOrderByOutNo :one +SELECT * FROM pay_orders WHERE out_trade_no = ?; + +-- name: SetPayOrderUpstream :exec +UPDATE pay_orders SET upstream_trade_no = ?, goods_key = ?, quantity = ?, unit_price = ?, payurl = ?, buyer_contact = ?, query_pwd = ? WHERE id = ?; + +-- name: SetPayOrderQrcode :exec +UPDATE pay_orders SET qrcode = ? WHERE id = ?; + +-- name: SetPayOrderStatus :exec +UPDATE pay_orders SET status = ?, paid_at = ?, cards = ? WHERE id = ?; + +-- name: ListPendingPayOrders :many +SELECT * FROM pay_orders WHERE status = 0 AND upstream_trade_no != '' ORDER BY id LIMIT 200; + +-- name: ListPayOrders :many +SELECT * FROM pay_orders +WHERE (? = -1 OR status = ?) AND (? = '' OR trade_no LIKE '%' || ? || '%' OR out_trade_no LIKE '%' || ? || '%') +ORDER BY id DESC LIMIT ? OFFSET ?; + +-- name: CountPayOrders :one +SELECT COUNT(*) FROM pay_orders +WHERE (? = -1 OR status = ?) AND (? = '' OR trade_no LIKE '%' || ? || '%' OR out_trade_no LIKE '%' || ? || '%'); + +-- name: PayOrderStats :one +SELECT COUNT(*) AS cnt, COALESCE(SUM(CASE WHEN status = 1 THEN money ELSE 0 END), 0) AS money, + COALESCE(SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END), 0) AS pending, + COALESCE(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END), 0) AS paid +FROM pay_orders WHERE created_at >= ?; + +-- name: IncPayNotifyAttempts :exec +UPDATE pay_orders SET notify_attempts = notify_attempts + 1 WHERE id = ?; + +-- name: SetPayNotified :exec +UPDATE pay_orders SET notified = 1 WHERE id = ?; + +-- name: GetPayOrder :one +SELECT * FROM pay_orders WHERE id = ?; + +-- name: CreateLicenseApp :execlastid +INSERT INTO license_apps (appid, secret, name, created_at) VALUES (?, ?, ?, ?); + +-- name: GetLicenseAppByAppid :one +SELECT * FROM license_apps WHERE appid = ?; + +-- name: ListLicenseApps :many +SELECT a.*, (SELECT COUNT(*) FROM license_cards c WHERE c.app_id = a.id) AS card_count +FROM license_apps a ORDER BY a.id DESC; + +-- name: UpdateLicenseApp :exec +UPDATE license_apps SET name = ?, enabled = ? WHERE id = ?; + +-- name: DeleteLicenseApp :exec +DELETE FROM license_apps WHERE id = ?; + +-- name: CreateLicenseCard :exec +INSERT INTO license_cards (app_id, card, hours, created_at) VALUES (?, ?, ?, ?); + +-- name: GetLicenseCard :one +SELECT * FROM license_cards WHERE app_id = ? AND card = ?; + +-- name: ActivateLicenseCard :exec +UPDATE license_cards SET domain = ?, activated_at = ?, expires_at = ? WHERE id = ?; + +-- name: ListLicenseCards :many +SELECT * FROM license_cards +WHERE app_id = ? AND (? = '' OR card LIKE '%' || ? || '%' OR domain LIKE '%' || ? || '%') +ORDER BY id DESC LIMIT ? OFFSET ?; + +-- name: CountLicenseCards :one +SELECT COUNT(*) FROM license_cards +WHERE app_id = ? AND (? = '' OR card LIKE '%' || ? || '%' OR domain LIKE '%' || ? || '%'); + +-- name: LicenseCardStats :one +SELECT COUNT(*) AS total, + COALESCE(SUM(CASE WHEN activated_at = 0 THEN 1 ELSE 0 END), 0) AS unused, + COALESCE(SUM(CASE WHEN activated_at > 0 AND (expires_at = 0 OR expires_at > ?) THEN 1 ELSE 0 END), 0) AS active, + COALESCE(SUM(CASE WHEN activated_at > 0 AND expires_at > 0 AND expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired +FROM license_cards WHERE app_id = ?; + +-- name: DeleteLicenseCard :exec +DELETE FROM license_cards WHERE id = ?; + +-- name: UpsertMemory :exec +INSERT INTO memories (k, content, project, tags, written_by, revision, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, 1, ?, ?) +ON CONFLICT(k) DO UPDATE SET + content = excluded.content, + project = excluded.project, + tags = excluded.tags, + written_by = excluded.written_by, + revision = memories.revision + 1, + updated_at = excluded.updated_at; + +-- name: GetMemory :one +SELECT * FROM memories WHERE k = ?; + +-- name: DeleteMemory :execrows +DELETE FROM memories WHERE k = ?; + +-- name: ListMemories :many +SELECT * FROM memories +WHERE (? = '' OR project = ?) AND (? = '' OR tags LIKE '%"' || ? || '"%') +ORDER BY updated_at DESC LIMIT ?; + +-- name: MemoryProjects :many +SELECT project AS name, COUNT(*) AS cnt FROM memories +WHERE project != '' GROUP BY project ORDER BY MAX(updated_at) DESC; + +-- name: CountMemories :one +SELECT COUNT(*) FROM memories; + +-- name: PayDailyPaid :many +SELECT CAST(strftime('%Y-%m-%d', paid_at, 'unixepoch', 'localtime') AS TEXT) AS day, + COALESCE(SUM(money), 0) AS money, COUNT(*) AS cnt +FROM pay_orders WHERE status = 1 AND paid_at >= ? GROUP BY day; + +-- name: RecentPaidOrders :many +SELECT trade_no, subject, money, paid_at FROM pay_orders WHERE status = 1 ORDER BY paid_at DESC LIMIT ?; + +-- name: LicenseCardStatsAll :one +SELECT COUNT(*) AS total, + COALESCE(SUM(CASE WHEN activated_at = 0 THEN 1 ELSE 0 END), 0) AS unused, + COALESCE(SUM(CASE WHEN activated_at > 0 AND (expires_at = 0 OR expires_at > ?) THEN 1 ELSE 0 END), 0) AS active +FROM license_cards; + +-- name: CountLicenseApps :one +SELECT COUNT(*) FROM license_apps; + +-- name: CountProxiesAll :one +SELECT COUNT(*) FROM proxies; + +-- name: CountBookmarksByUser :one +SELECT COUNT(*) FROM bookmarks WHERE user_id = ?; + +-- name: CountVaultByUser :one +SELECT COUNT(*) FROM vault WHERE user_id = ?; + +-- name: CountDbconns :one +SELECT COUNT(*) FROM dbconns; + +-- name: CountFilesAll :one +SELECT COUNT(*) FROM files; diff --git a/backend/internal/db/queries.sql.go b/backend/internal/db/queries.sql.go new file mode 100644 index 0000000..2f8655b --- /dev/null +++ b/backend/internal/db/queries.sql.go @@ -0,0 +1,3102 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package db + +import ( + "context" + "database/sql" +) + +const activateLicenseCard = `-- name: ActivateLicenseCard :exec +UPDATE license_cards SET domain = ?, activated_at = ?, expires_at = ? WHERE id = ? +` + +type ActivateLicenseCardParams struct { + Domain string + ActivatedAt int64 + ExpiresAt int64 + ID int64 +} + +func (q *Queries) ActivateLicenseCard(ctx context.Context, arg ActivateLicenseCardParams) error { + _, err := q.db.ExecContext(ctx, activateLicenseCard, + arg.Domain, + arg.ActivatedAt, + arg.ExpiresAt, + arg.ID, + ) + return err +} + +const countActiveSessions = `-- name: CountActiveSessions :one +SELECT COUNT(*) FROM sessions WHERE expires_at > ? +` + +func (q *Queries) CountActiveSessions(ctx context.Context, expiresAt int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countActiveSessions, expiresAt) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countActiveUsers = `-- name: CountActiveUsers :one +SELECT COUNT(*) FROM users WHERE status = 'active' +` + +func (q *Queries) CountActiveUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countActiveUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countAdmins = `-- name: CountAdmins :one +SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active' +` + +func (q *Queries) CountAdmins(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countAdmins) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countAllUsers = `-- name: CountAllUsers :one +SELECT COUNT(*) FROM users +` + +func (q *Queries) CountAllUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countAllUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countBookmarksByUser = `-- name: CountBookmarksByUser :one +SELECT COUNT(*) FROM bookmarks WHERE user_id = ? +` + +func (q *Queries) CountBookmarksByUser(ctx context.Context, userID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countBookmarksByUser, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countDbconns = `-- name: CountDbconns :one +SELECT COUNT(*) FROM dbconns +` + +func (q *Queries) CountDbconns(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countDbconns) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countFilesAll = `-- name: CountFilesAll :one +SELECT COUNT(*) FROM files +` + +func (q *Queries) CountFilesAll(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countFilesAll) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLicenseApps = `-- name: CountLicenseApps :one +SELECT COUNT(*) FROM license_apps +` + +func (q *Queries) CountLicenseApps(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countLicenseApps) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLicenseCards = `-- name: CountLicenseCards :one +SELECT COUNT(*) FROM license_cards +WHERE app_id = ? AND (? = '' OR card LIKE '%' || ? || '%' OR domain LIKE '%' || ? || '%') +` + +type CountLicenseCardsParams struct { + AppID int64 + Column2 interface{} + Column3 sql.NullString + Column4 sql.NullString +} + +func (q *Queries) CountLicenseCards(ctx context.Context, arg CountLicenseCardsParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countLicenseCards, + arg.AppID, + arg.Column2, + arg.Column3, + arg.Column4, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countMemories = `-- name: CountMemories :one +SELECT COUNT(*) FROM memories +` + +func (q *Queries) CountMemories(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countMemories) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countPayOrders = `-- name: CountPayOrders :one +SELECT COUNT(*) FROM pay_orders +WHERE (? = -1 OR status = ?) AND (? = '' OR trade_no LIKE '%' || ? || '%' OR out_trade_no LIKE '%' || ? || '%') +` + +type CountPayOrdersParams struct { + Column1 interface{} + Status int64 + Column3 interface{} + Column4 sql.NullString + Column5 sql.NullString +} + +func (q *Queries) CountPayOrders(ctx context.Context, arg CountPayOrdersParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countPayOrders, + arg.Column1, + arg.Status, + arg.Column3, + arg.Column4, + arg.Column5, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countProxies = `-- name: CountProxies :one +SELECT COUNT(*) FROM proxies WHERE alive = 1 +` + +func (q *Queries) CountProxies(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countProxies) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countProxiesAll = `-- name: CountProxiesAll :one +SELECT COUNT(*) FROM proxies +` + +func (q *Queries) CountProxiesAll(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countProxiesAll) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countProxiesFiltered = `-- name: CountProxiesFiltered :one +SELECT COUNT(*) FROM proxies p WHERE (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR p.ip LIKE '%' || ? || '%') AND (? < 0 OR p.alive = ?) +` + +type CountProxiesFilteredParams struct { + Column1 interface{} + RegionID int64 + Column3 interface{} + Column4 sql.NullString + Column5 interface{} + Column6 sql.NullString + Column7 interface{} + Alive int64 +} + +func (q *Queries) CountProxiesFiltered(ctx context.Context, arg CountProxiesFilteredParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countProxiesFiltered, + arg.Column1, + arg.RegionID, + arg.Column3, + arg.Column4, + arg.Column5, + arg.Column6, + arg.Column7, + arg.Alive, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countServers = `-- name: CountServers :one +SELECT COUNT(*) FROM servers +` + +func (q *Queries) CountServers(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countServers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countTodayLogins = `-- name: CountTodayLogins :one +SELECT COUNT(*) FROM sessions +WHERE date(created_at, 'unixepoch', 'localtime') = date('now', 'localtime') +` + +func (q *Queries) CountTodayLogins(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countTodayLogins) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countTools = `-- name: CountTools :one +SELECT COUNT(*) FROM tools WHERE enabled = 1 +` + +func (q *Queries) CountTools(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countTools) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countUsers = `-- name: CountUsers :one +SELECT COUNT(*) FROM users WHERE name LIKE '%' || ?1 || '%' +` + +func (q *Queries) CountUsers(ctx context.Context, keyword sql.NullString) (int64, error) { + row := q.db.QueryRowContext(ctx, countUsers, keyword) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countVaultByUser = `-- name: CountVaultByUser :one +SELECT COUNT(*) FROM vault WHERE user_id = ? +` + +func (q *Queries) CountVaultByUser(ctx context.Context, userID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countVaultByUser, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createLicenseApp = `-- name: CreateLicenseApp :execlastid +INSERT INTO license_apps (appid, secret, name, created_at) VALUES (?, ?, ?, ?) +` + +type CreateLicenseAppParams struct { + Appid string + Secret string + Name string + CreatedAt int64 +} + +func (q *Queries) CreateLicenseApp(ctx context.Context, arg CreateLicenseAppParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createLicenseApp, + arg.Appid, + arg.Secret, + arg.Name, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const createLicenseCard = `-- name: CreateLicenseCard :exec +INSERT INTO license_cards (app_id, card, hours, created_at) VALUES (?, ?, ?, ?) +` + +type CreateLicenseCardParams struct { + AppID int64 + Card string + Hours int64 + CreatedAt int64 +} + +func (q *Queries) CreateLicenseCard(ctx context.Context, arg CreateLicenseCardParams) error { + _, err := q.db.ExecContext(ctx, createLicenseCard, + arg.AppID, + arg.Card, + arg.Hours, + arg.CreatedAt, + ) + return err +} + +const createPayOrder = `-- name: CreatePayOrder :execlastid +INSERT INTO pay_orders (trade_no, out_trade_no, channel, subject, money, fee, notify_url, return_url, client_ip, expired_at, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreatePayOrderParams struct { + TradeNo string + OutTradeNo string + Channel string + Subject string + Money int64 + Fee int64 + NotifyUrl string + ReturnUrl string + ClientIp string + ExpiredAt int64 + CreatedAt int64 +} + +func (q *Queries) CreatePayOrder(ctx context.Context, arg CreatePayOrderParams) (int64, error) { + result, err := q.db.ExecContext(ctx, createPayOrder, + arg.TradeNo, + arg.OutTradeNo, + arg.Channel, + arg.Subject, + arg.Money, + arg.Fee, + arg.NotifyUrl, + arg.ReturnUrl, + arg.ClientIp, + arg.ExpiredAt, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const deleteBookmark = `-- name: DeleteBookmark :execrows +DELETE FROM bookmarks WHERE user_id = ? AND id = ? +` + +type DeleteBookmarkParams struct { + UserID int64 + ID int64 +} + +func (q *Queries) DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteBookmark, arg.UserID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteDbconn = `-- name: DeleteDbconn :execrows +DELETE FROM dbconns WHERE id = ? +` + +func (q *Queries) DeleteDbconn(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteDbconn, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteDeadProxies = `-- name: DeleteDeadProxies :execrows +DELETE FROM proxies WHERE alive = 0 AND last_checked_at < ? +` + +func (q *Queries) DeleteDeadProxies(ctx context.Context, lastCheckedAt int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteDeadProxies, lastCheckedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteFile = `-- name: DeleteFile :execrows +DELETE FROM files WHERE id = ? AND user_id = ? +` + +type DeleteFileParams struct { + ID int64 + UserID int64 +} + +func (q *Queries) DeleteFile(ctx context.Context, arg DeleteFileParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteFile, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteLicenseApp = `-- name: DeleteLicenseApp :exec +DELETE FROM license_apps WHERE id = ? +` + +func (q *Queries) DeleteLicenseApp(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteLicenseApp, id) + return err +} + +const deleteLicenseCard = `-- name: DeleteLicenseCard :exec +DELETE FROM license_cards WHERE id = ? +` + +func (q *Queries) DeleteLicenseCard(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteLicenseCard, id) + return err +} + +const deleteMemory = `-- name: DeleteMemory :execrows +DELETE FROM memories WHERE k = ? +` + +func (q *Queries) DeleteMemory(ctx context.Context, k string) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteMemory, k) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteOtherSessions = `-- name: DeleteOtherSessions :exec +DELETE FROM sessions WHERE user_id = ? AND token != ? +` + +type DeleteOtherSessionsParams struct { + UserID int64 + Token string +} + +func (q *Queries) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) error { + _, err := q.db.ExecContext(ctx, deleteOtherSessions, arg.UserID, arg.Token) + return err +} + +const deleteProxy = `-- name: DeleteProxy :exec +DELETE FROM proxies WHERE id = ? +` + +func (q *Queries) DeleteProxy(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteProxy, id) + return err +} + +const deleteProxyApiKey = `-- name: DeleteProxyApiKey :execrows +DELETE FROM proxy_keys WHERE id = ? +` + +func (q *Queries) DeleteProxyApiKey(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteProxyApiKey, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteServer = `-- name: DeleteServer :execrows +DELETE FROM servers WHERE id = ? +` + +func (q *Queries) DeleteServer(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteServer, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE token = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.ExecContext(ctx, deleteSession, token) + return err +} + +const deleteShare = `-- name: DeleteShare :execrows +DELETE FROM shares WHERE id = ? AND user_id = ? +` + +type DeleteShareParams struct { + ID int64 + UserID int64 +} + +func (q *Queries) DeleteShare(ctx context.Context, arg DeleteShareParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteShare, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteSharesByFile = `-- name: DeleteSharesByFile :exec +DELETE FROM shares WHERE file_id = ? +` + +func (q *Queries) DeleteSharesByFile(ctx context.Context, fileID int64) error { + _, err := q.db.ExecContext(ctx, deleteSharesByFile, fileID) + return err +} + +const deleteTool = `-- name: DeleteTool :exec +DELETE FROM tools WHERE id = ? +` + +func (q *Queries) DeleteTool(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteTool, id) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users WHERE id = ? +` + +func (q *Queries) DeleteUser(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteUser, id) + return err +} + +const deleteVault = `-- name: DeleteVault :execrows +DELETE FROM vault WHERE user_id = ? AND id = ? +` + +type DeleteVaultParams struct { + UserID int64 + ID int64 +} + +func (q *Queries) DeleteVault(ctx context.Context, arg DeleteVaultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteVault, arg.UserID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const finishProxySync = `-- name: FinishProxySync :exec +UPDATE proxy_syncs SET finished_at = ?, status = ?, total = ?, added = ?, removed = ?, updated = ?, detail = ?, error = ? WHERE id = ? +` + +type FinishProxySyncParams struct { + FinishedAt int64 + Status string + Total int64 + Added int64 + Removed int64 + Updated int64 + Detail string + Error string + ID int64 +} + +func (q *Queries) FinishProxySync(ctx context.Context, arg FinishProxySyncParams) error { + _, err := q.db.ExecContext(ctx, finishProxySync, + arg.FinishedAt, + arg.Status, + arg.Total, + arg.Added, + arg.Removed, + arg.Updated, + arg.Detail, + arg.Error, + arg.ID, + ) + return err +} + +const getDbconn = `-- name: GetDbconn :one +SELECT id, name, engine, host, port, username, password_enc, "database", params, created_at, updated_at FROM dbconns WHERE id = ? +` + +func (q *Queries) GetDbconn(ctx context.Context, id int64) (Dbconn, error) { + row := q.db.QueryRowContext(ctx, getDbconn, id) + var i Dbconn + err := row.Scan( + &i.ID, + &i.Name, + &i.Engine, + &i.Host, + &i.Port, + &i.Username, + &i.PasswordEnc, + &i.Database, + &i.Params, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getFile = `-- name: GetFile :one +SELECT id, user_id, name, stored, size, mime, created_at FROM files WHERE id = ? +` + +func (q *Queries) GetFile(ctx context.Context, id int64) (File, error) { + row := q.db.QueryRowContext(ctx, getFile, id) + var i File + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Stored, + &i.Size, + &i.Mime, + &i.CreatedAt, + ) + return i, err +} + +const getLicenseAppByAppid = `-- name: GetLicenseAppByAppid :one +SELECT id, appid, secret, name, enabled, created_at FROM license_apps WHERE appid = ? +` + +func (q *Queries) GetLicenseAppByAppid(ctx context.Context, appid string) (LicenseApp, error) { + row := q.db.QueryRowContext(ctx, getLicenseAppByAppid, appid) + var i LicenseApp + err := row.Scan( + &i.ID, + &i.Appid, + &i.Secret, + &i.Name, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const getLicenseCard = `-- name: GetLicenseCard :one +SELECT id, app_id, card, hours, domain, activated_at, expires_at, created_at FROM license_cards WHERE app_id = ? AND card = ? +` + +type GetLicenseCardParams struct { + AppID int64 + Card string +} + +func (q *Queries) GetLicenseCard(ctx context.Context, arg GetLicenseCardParams) (LicenseCard, error) { + row := q.db.QueryRowContext(ctx, getLicenseCard, arg.AppID, arg.Card) + var i LicenseCard + err := row.Scan( + &i.ID, + &i.AppID, + &i.Card, + &i.Hours, + &i.Domain, + &i.ActivatedAt, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getMemory = `-- name: GetMemory :one +SELECT k, content, project, tags, written_by, revision, created_at, updated_at FROM memories WHERE k = ? +` + +func (q *Queries) GetMemory(ctx context.Context, k string) (Memory, error) { + row := q.db.QueryRowContext(ctx, getMemory, k) + var i Memory + err := row.Scan( + &i.K, + &i.Content, + &i.Project, + &i.Tags, + &i.WrittenBy, + &i.Revision, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPayOrder = `-- name: GetPayOrder :one +SELECT id, trade_no, out_trade_no, channel, subject, money, fee, status, notify_url, return_url, client_ip, notified, notify_attempts, paid_at, expired_at, upstream_trade_no, goods_key, quantity, unit_price, payurl, qrcode, buyer_contact, query_pwd, cards, created_at FROM pay_orders WHERE id = ? +` + +func (q *Queries) GetPayOrder(ctx context.Context, id int64) (PayOrder, error) { + row := q.db.QueryRowContext(ctx, getPayOrder, id) + var i PayOrder + err := row.Scan( + &i.ID, + &i.TradeNo, + &i.OutTradeNo, + &i.Channel, + &i.Subject, + &i.Money, + &i.Fee, + &i.Status, + &i.NotifyUrl, + &i.ReturnUrl, + &i.ClientIp, + &i.Notified, + &i.NotifyAttempts, + &i.PaidAt, + &i.ExpiredAt, + &i.UpstreamTradeNo, + &i.GoodsKey, + &i.Quantity, + &i.UnitPrice, + &i.Payurl, + &i.Qrcode, + &i.BuyerContact, + &i.QueryPwd, + &i.Cards, + &i.CreatedAt, + ) + return i, err +} + +const getPayOrderByOutNo = `-- name: GetPayOrderByOutNo :one +SELECT id, trade_no, out_trade_no, channel, subject, money, fee, status, notify_url, return_url, client_ip, notified, notify_attempts, paid_at, expired_at, upstream_trade_no, goods_key, quantity, unit_price, payurl, qrcode, buyer_contact, query_pwd, cards, created_at FROM pay_orders WHERE out_trade_no = ? +` + +func (q *Queries) GetPayOrderByOutNo(ctx context.Context, outTradeNo string) (PayOrder, error) { + row := q.db.QueryRowContext(ctx, getPayOrderByOutNo, outTradeNo) + var i PayOrder + err := row.Scan( + &i.ID, + &i.TradeNo, + &i.OutTradeNo, + &i.Channel, + &i.Subject, + &i.Money, + &i.Fee, + &i.Status, + &i.NotifyUrl, + &i.ReturnUrl, + &i.ClientIp, + &i.Notified, + &i.NotifyAttempts, + &i.PaidAt, + &i.ExpiredAt, + &i.UpstreamTradeNo, + &i.GoodsKey, + &i.Quantity, + &i.UnitPrice, + &i.Payurl, + &i.Qrcode, + &i.BuyerContact, + &i.QueryPwd, + &i.Cards, + &i.CreatedAt, + ) + return i, err +} + +const getPayOrderByTradeNo = `-- name: GetPayOrderByTradeNo :one +SELECT id, trade_no, out_trade_no, channel, subject, money, fee, status, notify_url, return_url, client_ip, notified, notify_attempts, paid_at, expired_at, upstream_trade_no, goods_key, quantity, unit_price, payurl, qrcode, buyer_contact, query_pwd, cards, created_at FROM pay_orders WHERE trade_no = ? +` + +func (q *Queries) GetPayOrderByTradeNo(ctx context.Context, tradeNo string) (PayOrder, error) { + row := q.db.QueryRowContext(ctx, getPayOrderByTradeNo, tradeNo) + var i PayOrder + err := row.Scan( + &i.ID, + &i.TradeNo, + &i.OutTradeNo, + &i.Channel, + &i.Subject, + &i.Money, + &i.Fee, + &i.Status, + &i.NotifyUrl, + &i.ReturnUrl, + &i.ClientIp, + &i.Notified, + &i.NotifyAttempts, + &i.PaidAt, + &i.ExpiredAt, + &i.UpstreamTradeNo, + &i.GoodsKey, + &i.Quantity, + &i.UnitPrice, + &i.Payurl, + &i.Qrcode, + &i.BuyerContact, + &i.QueryPwd, + &i.Cards, + &i.CreatedAt, + ) + return i, err +} + +const getProxyApiKey = `-- name: GetProxyApiKey :one +SELECT id, kkey, name, region_id, enabled, used_count, last_used_at, created_at FROM proxy_keys WHERE kkey = ? AND enabled = 1 +` + +func (q *Queries) GetProxyApiKey(ctx context.Context, kkey string) (ProxyKey, error) { + row := q.db.QueryRowContext(ctx, getProxyApiKey, kkey) + var i ProxyKey + err := row.Scan( + &i.ID, + &i.Kkey, + &i.Name, + &i.RegionID, + &i.Enabled, + &i.UsedCount, + &i.LastUsedAt, + &i.CreatedAt, + ) + return i, err +} + +const getProxyRegionByCode = `-- name: GetProxyRegionByCode :one +SELECT id, code, name, zh_name, region, slug FROM proxy_regions WHERE code = ? +` + +func (q *Queries) GetProxyRegionByCode(ctx context.Context, code string) (ProxyRegion, error) { + row := q.db.QueryRowContext(ctx, getProxyRegionByCode, code) + var i ProxyRegion + err := row.Scan( + &i.ID, + &i.Code, + &i.Name, + &i.ZhName, + &i.Region, + &i.Slug, + ) + return i, err +} + +const getServer = `-- name: GetServer :one +SELECT id, name, host, port, username, auth_type, secret_enc, note, created_at, updated_at FROM servers WHERE id = ? +` + +func (q *Queries) GetServer(ctx context.Context, id int64) (Server, error) { + row := q.db.QueryRowContext(ctx, getServer, id) + var i Server + err := row.Scan( + &i.ID, + &i.Name, + &i.Host, + &i.Port, + &i.Username, + &i.AuthType, + &i.SecretEnc, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSessionUser = `-- name: GetSessionUser :one +SELECT u.id, u.name, u.password_hash, u.role, u.status, u.created_at, u.updated_at, s.expires_at +FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? +` + +type GetSessionUserRow struct { + ID int64 + Name string + PasswordHash string + Role string + Status string + CreatedAt int64 + UpdatedAt int64 + ExpiresAt int64 +} + +func (q *Queries) GetSessionUser(ctx context.Context, token string) (GetSessionUserRow, error) { + row := q.db.QueryRowContext(ctx, getSessionUser, token) + var i GetSessionUserRow + err := row.Scan( + &i.ID, + &i.Name, + &i.PasswordHash, + &i.Role, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + ) + return i, err +} + +const getSetting = `-- name: GetSetting :one +SELECT v FROM settings WHERE k = ? +` + +func (q *Queries) GetSetting(ctx context.Context, k string) (string, error) { + row := q.db.QueryRowContext(ctx, getSetting, k) + var v string + err := row.Scan(&v) + return v, err +} + +const getShareByCode = `-- name: GetShareByCode :one +SELECT sh.id, sh.code, sh.file_id, sh.user_id, sh.file_name, sh.expires_at, sh.downloads, sh.created_at, + f.name, f.stored, f.size, f.mime +FROM shares sh JOIN files f ON f.id = sh.file_id WHERE sh.code = ? +` + +type GetShareByCodeRow struct { + ID int64 + Code string + FileID int64 + UserID int64 + FileName string + ExpiresAt int64 + Downloads int64 + CreatedAt int64 + Name string + Stored string + Size int64 + Mime string +} + +func (q *Queries) GetShareByCode(ctx context.Context, code string) (GetShareByCodeRow, error) { + row := q.db.QueryRowContext(ctx, getShareByCode, code) + var i GetShareByCodeRow + err := row.Scan( + &i.ID, + &i.Code, + &i.FileID, + &i.UserID, + &i.FileName, + &i.ExpiresAt, + &i.Downloads, + &i.CreatedAt, + &i.Name, + &i.Stored, + &i.Size, + &i.Mime, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, name, password_hash, role, status, created_at, updated_at FROM users WHERE id = ? +` + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Name, + &i.PasswordHash, + &i.Role, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByName = `-- name: GetUserByName :one +SELECT id, name, password_hash, role, status, created_at, updated_at FROM users WHERE name = ? LIMIT 1 +` + +func (q *Queries) GetUserByName(ctx context.Context, name string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByName, name) + var i User + err := row.Scan( + &i.ID, + &i.Name, + &i.PasswordHash, + &i.Role, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getVault = `-- name: GetVault :one +SELECT id, user_id, title, username, password_enc, url, note, totp_enc, created_at, updated_at FROM vault WHERE user_id = ? AND id = ? +` + +type GetVaultParams struct { + UserID int64 + ID int64 +} + +func (q *Queries) GetVault(ctx context.Context, arg GetVaultParams) (Vault, error) { + row := q.db.QueryRowContext(ctx, getVault, arg.UserID, arg.ID) + var i Vault + err := row.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Username, + &i.PasswordEnc, + &i.Url, + &i.Note, + &i.TotpEnc, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const incPayNotifyAttempts = `-- name: IncPayNotifyAttempts :exec +UPDATE pay_orders SET notify_attempts = notify_attempts + 1 WHERE id = ? +` + +func (q *Queries) IncPayNotifyAttempts(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, incPayNotifyAttempts, id) + return err +} + +const incShareDownloads = `-- name: IncShareDownloads :exec +UPDATE shares SET downloads = downloads + 1 WHERE code = ? +` + +func (q *Queries) IncShareDownloads(ctx context.Context, code string) error { + _, err := q.db.ExecContext(ctx, incShareDownloads, code) + return err +} + +const insertBookmark = `-- name: InsertBookmark :execlastid +INSERT INTO bookmarks(user_id, title, url, icon, sort, created_at) VALUES(?, ?, ?, ?, ?, ?) +` + +type InsertBookmarkParams struct { + UserID int64 + Title string + Url string + Icon string + Sort int64 + CreatedAt int64 +} + +func (q *Queries) InsertBookmark(ctx context.Context, arg InsertBookmarkParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertBookmark, + arg.UserID, + arg.Title, + arg.Url, + arg.Icon, + arg.Sort, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertDbconn = `-- name: InsertDbconn :execlastid +INSERT INTO dbconns(name, engine, host, port, username, password_enc, database, params, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertDbconnParams struct { + Name string + Engine string + Host string + Port int64 + Username string + PasswordEnc string + Database string + Params string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertDbconn(ctx context.Context, arg InsertDbconnParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertDbconn, + arg.Name, + arg.Engine, + arg.Host, + arg.Port, + arg.Username, + arg.PasswordEnc, + arg.Database, + arg.Params, + arg.CreatedAt, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertFile = `-- name: InsertFile :execlastid +INSERT INTO files(user_id, name, stored, size, mime, created_at) VALUES(?, ?, ?, ?, ?, ?) +` + +type InsertFileParams struct { + UserID int64 + Name string + Stored string + Size int64 + Mime string + CreatedAt int64 +} + +func (q *Queries) InsertFile(ctx context.Context, arg InsertFileParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertFile, + arg.UserID, + arg.Name, + arg.Stored, + arg.Size, + arg.Mime, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertProxy = `-- name: InsertProxy :execlastid +INSERT INTO proxies(ip, port, protocols, anonymity, source, region_id, latency, speed, uptime, alive, last_seen_at, created_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) +` + +type InsertProxyParams struct { + Ip string + Port int64 + Protocols string + Anonymity string + Source string + RegionID int64 + Latency int64 + Speed int64 + Uptime int64 + LastSeenAt int64 + CreatedAt int64 +} + +func (q *Queries) InsertProxy(ctx context.Context, arg InsertProxyParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertProxy, + arg.Ip, + arg.Port, + arg.Protocols, + arg.Anonymity, + arg.Source, + arg.RegionID, + arg.Latency, + arg.Speed, + arg.Uptime, + arg.LastSeenAt, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertProxyApiKey = `-- name: InsertProxyApiKey :execlastid +INSERT INTO proxy_keys(kkey, name, region_id, created_at) VALUES(?, ?, ?, ?) +` + +type InsertProxyApiKeyParams struct { + Kkey string + Name string + RegionID int64 + CreatedAt int64 +} + +func (q *Queries) InsertProxyApiKey(ctx context.Context, arg InsertProxyApiKeyParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertProxyApiKey, + arg.Kkey, + arg.Name, + arg.RegionID, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertProxySync = `-- name: InsertProxySync :execlastid +INSERT INTO proxy_syncs(started_at, source) VALUES(?, ?) +` + +type InsertProxySyncParams struct { + StartedAt int64 + Source string +} + +func (q *Queries) InsertProxySync(ctx context.Context, arg InsertProxySyncParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertProxySync, arg.StartedAt, arg.Source) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertServer = `-- name: InsertServer :execlastid +INSERT INTO servers(name, host, port, username, auth_type, secret_enc, note, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertServerParams struct { + Name string + Host string + Port int64 + Username string + AuthType string + SecretEnc string + Note string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertServer(ctx context.Context, arg InsertServerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertServer, + arg.Name, + arg.Host, + arg.Port, + arg.Username, + arg.AuthType, + arg.SecretEnc, + arg.Note, + arg.CreatedAt, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertSession = `-- name: InsertSession :exec +INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES(?, ?, ?, ?) +` + +type InsertSessionParams struct { + Token string + UserID int64 + CreatedAt int64 + ExpiresAt int64 +} + +func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error { + _, err := q.db.ExecContext(ctx, insertSession, + arg.Token, + arg.UserID, + arg.CreatedAt, + arg.ExpiresAt, + ) + return err +} + +const insertShare = `-- name: InsertShare :execlastid +INSERT INTO shares(code, file_id, user_id, file_name, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?) +` + +type InsertShareParams struct { + Code string + FileID int64 + UserID int64 + FileName string + ExpiresAt int64 + CreatedAt int64 +} + +func (q *Queries) InsertShare(ctx context.Context, arg InsertShareParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertShare, + arg.Code, + arg.FileID, + arg.UserID, + arg.FileName, + arg.ExpiresAt, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertTool = `-- name: InsertTool :execlastid +INSERT INTO tools(name, icon, url, description, sort, enabled) VALUES(?, ?, ?, ?, ?, ?) +` + +type InsertToolParams struct { + Name string + Icon string + Url string + Description string + Sort int64 + Enabled bool +} + +func (q *Queries) InsertTool(ctx context.Context, arg InsertToolParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertTool, + arg.Name, + arg.Icon, + arg.Url, + arg.Description, + arg.Sort, + arg.Enabled, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertUser = `-- name: InsertUser :execlastid +INSERT INTO users(name, password_hash, role, status, created_at, updated_at) +VALUES(?, ?, ?, 'active', ?, ?) +` + +type InsertUserParams struct { + Name string + PasswordHash string + Role string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertUser, + arg.Name, + arg.PasswordHash, + arg.Role, + arg.CreatedAt, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const insertVault = `-- name: InsertVault :execlastid +INSERT INTO vault(user_id, title, username, password_enc, url, note, totp_enc, created_at, updated_at) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertVaultParams struct { + UserID int64 + Title string + Username string + PasswordEnc string + Url string + Note string + TotpEnc string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertVault(ctx context.Context, arg InsertVaultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertVault, + arg.UserID, + arg.Title, + arg.Username, + arg.PasswordEnc, + arg.Url, + arg.Note, + arg.TotpEnc, + arg.CreatedAt, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +const lastProxyCheckAt = `-- name: LastProxyCheckAt :one +SELECT COALESCE(MAX(last_checked_at), 0) FROM proxies +` + +func (q *Queries) LastProxyCheckAt(ctx context.Context) (interface{}, error) { + row := q.db.QueryRowContext(ctx, lastProxyCheckAt) + var coalesce interface{} + err := row.Scan(&coalesce) + return coalesce, err +} + +const licenseCardStats = `-- name: LicenseCardStats :one +SELECT COUNT(*) AS total, + COALESCE(SUM(CASE WHEN activated_at = 0 THEN 1 ELSE 0 END), 0) AS unused, + COALESCE(SUM(CASE WHEN activated_at > 0 AND (expires_at = 0 OR expires_at > ?) THEN 1 ELSE 0 END), 0) AS active, + COALESCE(SUM(CASE WHEN activated_at > 0 AND expires_at > 0 AND expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired +FROM license_cards WHERE app_id = ? +` + +type LicenseCardStatsParams struct { + ExpiresAt int64 + ExpiresAt_2 int64 + AppID int64 +} + +type LicenseCardStatsRow struct { + Total int64 + Unused interface{} + Active interface{} + Expired interface{} +} + +func (q *Queries) LicenseCardStats(ctx context.Context, arg LicenseCardStatsParams) (LicenseCardStatsRow, error) { + row := q.db.QueryRowContext(ctx, licenseCardStats, arg.ExpiresAt, arg.ExpiresAt_2, arg.AppID) + var i LicenseCardStatsRow + err := row.Scan( + &i.Total, + &i.Unused, + &i.Active, + &i.Expired, + ) + return i, err +} + +const licenseCardStatsAll = `-- name: LicenseCardStatsAll :one +SELECT COUNT(*) AS total, + COALESCE(SUM(CASE WHEN activated_at = 0 THEN 1 ELSE 0 END), 0) AS unused, + COALESCE(SUM(CASE WHEN activated_at > 0 AND (expires_at = 0 OR expires_at > ?) THEN 1 ELSE 0 END), 0) AS active +FROM license_cards +` + +type LicenseCardStatsAllRow struct { + Total int64 + Unused interface{} + Active interface{} +} + +func (q *Queries) LicenseCardStatsAll(ctx context.Context, expiresAt int64) (LicenseCardStatsAllRow, error) { + row := q.db.QueryRowContext(ctx, licenseCardStatsAll, expiresAt) + var i LicenseCardStatsAllRow + err := row.Scan(&i.Total, &i.Unused, &i.Active) + return i, err +} + +const listBookmarks = `-- name: ListBookmarks :many +SELECT id, user_id, title, url, icon, sort, created_at FROM bookmarks WHERE user_id = ? ORDER BY sort, id +` + +func (q *Queries) ListBookmarks(ctx context.Context, userID int64) ([]Bookmark, error) { + rows, err := q.db.QueryContext(ctx, listBookmarks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Bookmark{} + for rows.Next() { + var i Bookmark + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Url, + &i.Icon, + &i.Sort, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDbconns = `-- name: ListDbconns :many +SELECT id, name, engine, host, port, username, password_enc, "database", params, created_at, updated_at FROM dbconns ORDER BY id +` + +func (q *Queries) ListDbconns(ctx context.Context) ([]Dbconn, error) { + rows, err := q.db.QueryContext(ctx, listDbconns) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Dbconn{} + for rows.Next() { + var i Dbconn + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Engine, + &i.Host, + &i.Port, + &i.Username, + &i.PasswordEnc, + &i.Database, + &i.Params, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledTools = `-- name: ListEnabledTools :many +SELECT id, name, icon, url, description, sort, enabled FROM tools WHERE enabled = 1 ORDER BY sort, id +` + +func (q *Queries) ListEnabledTools(ctx context.Context) ([]Tool, error) { + rows, err := q.db.QueryContext(ctx, listEnabledTools) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tool{} + for rows.Next() { + var i Tool + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Icon, + &i.Url, + &i.Description, + &i.Sort, + &i.Enabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFiles = `-- name: ListFiles :many +SELECT id, user_id, name, stored, size, mime, created_at FROM files WHERE user_id = ? ORDER BY id DESC +` + +func (q *Queries) ListFiles(ctx context.Context, userID int64) ([]File, error) { + rows, err := q.db.QueryContext(ctx, listFiles, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []File{} + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Stored, + &i.Size, + &i.Mime, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLicenseApps = `-- name: ListLicenseApps :many +SELECT a.id, a.appid, a.secret, a.name, a.enabled, a.created_at, (SELECT COUNT(*) FROM license_cards c WHERE c.app_id = a.id) AS card_count +FROM license_apps a ORDER BY a.id DESC +` + +type ListLicenseAppsRow struct { + ID int64 + Appid string + Secret string + Name string + Enabled int64 + CreatedAt int64 + CardCount int64 +} + +func (q *Queries) ListLicenseApps(ctx context.Context) ([]ListLicenseAppsRow, error) { + rows, err := q.db.QueryContext(ctx, listLicenseApps) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListLicenseAppsRow{} + for rows.Next() { + var i ListLicenseAppsRow + if err := rows.Scan( + &i.ID, + &i.Appid, + &i.Secret, + &i.Name, + &i.Enabled, + &i.CreatedAt, + &i.CardCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLicenseCards = `-- name: ListLicenseCards :many +SELECT id, app_id, card, hours, domain, activated_at, expires_at, created_at FROM license_cards +WHERE app_id = ? AND (? = '' OR card LIKE '%' || ? || '%' OR domain LIKE '%' || ? || '%') +ORDER BY id DESC LIMIT ? OFFSET ? +` + +type ListLicenseCardsParams struct { + AppID int64 + Column2 interface{} + Column3 sql.NullString + Column4 sql.NullString + Limit int64 + Offset int64 +} + +func (q *Queries) ListLicenseCards(ctx context.Context, arg ListLicenseCardsParams) ([]LicenseCard, error) { + rows, err := q.db.QueryContext(ctx, listLicenseCards, + arg.AppID, + arg.Column2, + arg.Column3, + arg.Column4, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []LicenseCard{} + for rows.Next() { + var i LicenseCard + if err := rows.Scan( + &i.ID, + &i.AppID, + &i.Card, + &i.Hours, + &i.Domain, + &i.ActivatedAt, + &i.ExpiresAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMemories = `-- name: ListMemories :many +SELECT k, content, project, tags, written_by, revision, created_at, updated_at FROM memories +WHERE (? = '' OR project = ?) AND (? = '' OR tags LIKE '%"' || ? || '"%') +ORDER BY updated_at DESC LIMIT ? +` + +type ListMemoriesParams struct { + Column1 interface{} + Project string + Column3 interface{} + Column4 sql.NullString + Limit int64 +} + +func (q *Queries) ListMemories(ctx context.Context, arg ListMemoriesParams) ([]Memory, error) { + rows, err := q.db.QueryContext(ctx, listMemories, + arg.Column1, + arg.Project, + arg.Column3, + arg.Column4, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Memory{} + for rows.Next() { + var i Memory + if err := rows.Scan( + &i.K, + &i.Content, + &i.Project, + &i.Tags, + &i.WrittenBy, + &i.Revision, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPayOrders = `-- name: ListPayOrders :many +SELECT id, trade_no, out_trade_no, channel, subject, money, fee, status, notify_url, return_url, client_ip, notified, notify_attempts, paid_at, expired_at, upstream_trade_no, goods_key, quantity, unit_price, payurl, qrcode, buyer_contact, query_pwd, cards, created_at FROM pay_orders +WHERE (? = -1 OR status = ?) AND (? = '' OR trade_no LIKE '%' || ? || '%' OR out_trade_no LIKE '%' || ? || '%') +ORDER BY id DESC LIMIT ? OFFSET ? +` + +type ListPayOrdersParams struct { + Column1 interface{} + Status int64 + Column3 interface{} + Column4 sql.NullString + Column5 sql.NullString + Limit int64 + Offset int64 +} + +func (q *Queries) ListPayOrders(ctx context.Context, arg ListPayOrdersParams) ([]PayOrder, error) { + rows, err := q.db.QueryContext(ctx, listPayOrders, + arg.Column1, + arg.Status, + arg.Column3, + arg.Column4, + arg.Column5, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PayOrder{} + for rows.Next() { + var i PayOrder + if err := rows.Scan( + &i.ID, + &i.TradeNo, + &i.OutTradeNo, + &i.Channel, + &i.Subject, + &i.Money, + &i.Fee, + &i.Status, + &i.NotifyUrl, + &i.ReturnUrl, + &i.ClientIp, + &i.Notified, + &i.NotifyAttempts, + &i.PaidAt, + &i.ExpiredAt, + &i.UpstreamTradeNo, + &i.GoodsKey, + &i.Quantity, + &i.UnitPrice, + &i.Payurl, + &i.Qrcode, + &i.BuyerContact, + &i.QueryPwd, + &i.Cards, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingPayOrders = `-- name: ListPendingPayOrders :many +SELECT id, trade_no, out_trade_no, channel, subject, money, fee, status, notify_url, return_url, client_ip, notified, notify_attempts, paid_at, expired_at, upstream_trade_no, goods_key, quantity, unit_price, payurl, qrcode, buyer_contact, query_pwd, cards, created_at FROM pay_orders WHERE status = 0 AND upstream_trade_no != '' ORDER BY id LIMIT 200 +` + +func (q *Queries) ListPendingPayOrders(ctx context.Context) ([]PayOrder, error) { + rows, err := q.db.QueryContext(ctx, listPendingPayOrders) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PayOrder{} + for rows.Next() { + var i PayOrder + if err := rows.Scan( + &i.ID, + &i.TradeNo, + &i.OutTradeNo, + &i.Channel, + &i.Subject, + &i.Money, + &i.Fee, + &i.Status, + &i.NotifyUrl, + &i.ReturnUrl, + &i.ClientIp, + &i.Notified, + &i.NotifyAttempts, + &i.PaidAt, + &i.ExpiredAt, + &i.UpstreamTradeNo, + &i.GoodsKey, + &i.Quantity, + &i.UnitPrice, + &i.Payurl, + &i.Qrcode, + &i.BuyerContact, + &i.QueryPwd, + &i.Cards, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxiesDue = `-- name: ListProxiesDue :many +SELECT id, ip, port, protocols, fail_count FROM proxies WHERE alive = 1 AND last_checked_at < ? ORDER BY last_checked_at LIMIT 20000 +` + +type ListProxiesDueRow struct { + ID int64 + Ip string + Port int64 + Protocols string + FailCount int64 +} + +func (q *Queries) ListProxiesDue(ctx context.Context, lastCheckedAt int64) ([]ListProxiesDueRow, error) { + rows, err := q.db.QueryContext(ctx, listProxiesDue, lastCheckedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProxiesDueRow{} + for rows.Next() { + var i ListProxiesDueRow + if err := rows.Scan( + &i.ID, + &i.Ip, + &i.Port, + &i.Protocols, + &i.FailCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxiesFiltered = `-- name: ListProxiesFiltered :many +SELECT p.id, p.ip, p.port, p.protocols, p.anonymity, p.source, p.region_id, p.latency, p.speed, p.uptime, p.alive, p.fail_count, p.last_checked_at, p.last_alive_at, p.last_seen_at, p.created_at, r.code AS region_code, r.name AS region_name, r.zh_name AS region_zh_name +FROM proxies p JOIN proxy_regions r ON r.id = p.region_id +WHERE (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR p.ip LIKE '%' || ? || '%') AND (? < 0 OR p.alive = ?) +ORDER BY p.alive DESC, p.latency LIMIT ? OFFSET ? +` + +type ListProxiesFilteredParams struct { + Column1 interface{} + RegionID int64 + Column3 interface{} + Column4 sql.NullString + Column5 interface{} + Column6 sql.NullString + Column7 interface{} + Alive int64 + Limit int64 + Offset int64 +} + +type ListProxiesFilteredRow struct { + ID int64 + Ip string + Port int64 + Protocols string + Anonymity string + Source string + RegionID int64 + Latency int64 + Speed int64 + Uptime int64 + Alive int64 + FailCount int64 + LastCheckedAt int64 + LastAliveAt int64 + LastSeenAt int64 + CreatedAt int64 + RegionCode string + RegionName string + RegionZhName string +} + +func (q *Queries) ListProxiesFiltered(ctx context.Context, arg ListProxiesFilteredParams) ([]ListProxiesFilteredRow, error) { + rows, err := q.db.QueryContext(ctx, listProxiesFiltered, + arg.Column1, + arg.RegionID, + arg.Column3, + arg.Column4, + arg.Column5, + arg.Column6, + arg.Column7, + arg.Alive, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProxiesFilteredRow{} + for rows.Next() { + var i ListProxiesFilteredRow + if err := rows.Scan( + &i.ID, + &i.Ip, + &i.Port, + &i.Protocols, + &i.Anonymity, + &i.Source, + &i.RegionID, + &i.Latency, + &i.Speed, + &i.Uptime, + &i.Alive, + &i.FailCount, + &i.LastCheckedAt, + &i.LastAliveAt, + &i.LastSeenAt, + &i.CreatedAt, + &i.RegionCode, + &i.RegionName, + &i.RegionZhName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxyApiKeys = `-- name: ListProxyApiKeys :many +SELECT k.id, k.kkey, k.name, k.region_id, k.enabled, k.used_count, k.last_used_at, k.created_at, r.code AS region_code, r.zh_name AS region_zh_name, r.name AS region_name +FROM proxy_keys k LEFT JOIN proxy_regions r ON r.id = k.region_id +ORDER BY k.id DESC +` + +type ListProxyApiKeysRow struct { + ID int64 + Kkey string + Name string + RegionID int64 + Enabled int64 + UsedCount int64 + LastUsedAt int64 + CreatedAt int64 + RegionCode sql.NullString + RegionZhName sql.NullString + RegionName sql.NullString +} + +func (q *Queries) ListProxyApiKeys(ctx context.Context) ([]ListProxyApiKeysRow, error) { + rows, err := q.db.QueryContext(ctx, listProxyApiKeys) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProxyApiKeysRow{} + for rows.Next() { + var i ListProxyApiKeysRow + if err := rows.Scan( + &i.ID, + &i.Kkey, + &i.Name, + &i.RegionID, + &i.Enabled, + &i.UsedCount, + &i.LastUsedAt, + &i.CreatedAt, + &i.RegionCode, + &i.RegionZhName, + &i.RegionName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxyKeys = `-- name: ListProxyKeys :many +SELECT id, ip, port, source FROM proxies +` + +type ListProxyKeysRow struct { + ID int64 + Ip string + Port int64 + Source string +} + +func (q *Queries) ListProxyKeys(ctx context.Context) ([]ListProxyKeysRow, error) { + rows, err := q.db.QueryContext(ctx, listProxyKeys) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProxyKeysRow{} + for rows.Next() { + var i ListProxyKeysRow + if err := rows.Scan( + &i.ID, + &i.Ip, + &i.Port, + &i.Source, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxyRegions = `-- name: ListProxyRegions :many +SELECT r.id, r.code, r.name, r.zh_name, r.region, r.slug, (SELECT COUNT(*) FROM proxies p WHERE p.region_id = r.id AND p.alive = 1) AS alive_count +FROM proxy_regions r ORDER BY alive_count DESC, r.code +` + +type ListProxyRegionsRow struct { + ID int64 + Code string + Name string + ZhName string + Region string + Slug string + AliveCount int64 +} + +func (q *Queries) ListProxyRegions(ctx context.Context) ([]ListProxyRegionsRow, error) { + rows, err := q.db.QueryContext(ctx, listProxyRegions) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProxyRegionsRow{} + for rows.Next() { + var i ListProxyRegionsRow + if err := rows.Scan( + &i.ID, + &i.Code, + &i.Name, + &i.ZhName, + &i.Region, + &i.Slug, + &i.AliveCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProxySyncs = `-- name: ListProxySyncs :many +SELECT id, started_at, finished_at, status, source, total, added, removed, updated, detail, error FROM proxy_syncs ORDER BY id DESC LIMIT ? +` + +func (q *Queries) ListProxySyncs(ctx context.Context, limit int64) ([]ProxySync, error) { + rows, err := q.db.QueryContext(ctx, listProxySyncs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ProxySync{} + for rows.Next() { + var i ProxySync + if err := rows.Scan( + &i.ID, + &i.StartedAt, + &i.FinishedAt, + &i.Status, + &i.Source, + &i.Total, + &i.Added, + &i.Removed, + &i.Updated, + &i.Detail, + &i.Error, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listServers = `-- name: ListServers :many +SELECT id, name, host, port, username, auth_type, secret_enc, note, created_at, updated_at FROM servers ORDER BY id +` + +func (q *Queries) ListServers(ctx context.Context) ([]Server, error) { + rows, err := q.db.QueryContext(ctx, listServers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Server{} + for rows.Next() { + var i Server + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Host, + &i.Port, + &i.Username, + &i.AuthType, + &i.SecretEnc, + &i.Note, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listShares = `-- name: ListShares :many +SELECT id, code, file_id, user_id, file_name, expires_at, downloads, created_at FROM shares WHERE user_id = ? ORDER BY id DESC +` + +func (q *Queries) ListShares(ctx context.Context, userID int64) ([]Share, error) { + rows, err := q.db.QueryContext(ctx, listShares, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Share{} + for rows.Next() { + var i Share + if err := rows.Scan( + &i.ID, + &i.Code, + &i.FileID, + &i.UserID, + &i.FileName, + &i.ExpiresAt, + &i.Downloads, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTools = `-- name: ListTools :many +SELECT id, name, icon, url, description, sort, enabled FROM tools ORDER BY sort, id +` + +func (q *Queries) ListTools(ctx context.Context) ([]Tool, error) { + rows, err := q.db.QueryContext(ctx, listTools) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tool{} + for rows.Next() { + var i Tool + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Icon, + &i.Url, + &i.Description, + &i.Sort, + &i.Enabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsers = `-- name: ListUsers :many +SELECT id, name, password_hash, role, status, created_at, updated_at FROM users +WHERE name LIKE '%' || ?3 || '%' +ORDER BY id LIMIT ? OFFSET ? +` + +type ListUsersParams struct { + Keyword sql.NullString + Limit int64 + Offset int64 +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsers, arg.Keyword, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Name, + &i.PasswordHash, + &i.Role, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listVault = `-- name: ListVault :many +SELECT id, user_id, title, username, password_enc, url, note, totp_enc, created_at, updated_at FROM vault WHERE user_id = ? + AND (title LIKE '%' || ?2 || '%' + OR username LIKE '%' || ?2 || '%' + OR url LIKE '%' || ?2 || '%') +ORDER BY updated_at DESC +` + +type ListVaultParams struct { + UserID int64 + Keyword sql.NullString +} + +func (q *Queries) ListVault(ctx context.Context, arg ListVaultParams) ([]Vault, error) { + rows, err := q.db.QueryContext(ctx, listVault, arg.UserID, arg.Keyword) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Vault{} + for rows.Next() { + var i Vault + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Title, + &i.Username, + &i.PasswordEnc, + &i.Url, + &i.Note, + &i.TotpEnc, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const loginTrend = `-- name: LoginTrend :many +SELECT date(created_at, 'unixepoch', 'localtime') AS d, COUNT(*) AS c +FROM sessions WHERE created_at >= ? GROUP BY d +` + +type LoginTrendRow struct { + D interface{} + C int64 +} + +func (q *Queries) LoginTrend(ctx context.Context, createdAt int64) ([]LoginTrendRow, error) { + rows, err := q.db.QueryContext(ctx, loginTrend, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []LoginTrendRow{} + for rows.Next() { + var i LoginTrendRow + if err := rows.Scan(&i.D, &i.C); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markProxyAlive = `-- name: MarkProxyAlive :exec +UPDATE proxies SET last_checked_at = ?, alive = 1, fail_count = 0, last_alive_at = ?, latency = ? WHERE id = ? +` + +type MarkProxyAliveParams struct { + LastCheckedAt int64 + LastAliveAt int64 + Latency int64 + ID int64 +} + +func (q *Queries) MarkProxyAlive(ctx context.Context, arg MarkProxyAliveParams) error { + _, err := q.db.ExecContext(ctx, markProxyAlive, + arg.LastCheckedAt, + arg.LastAliveAt, + arg.Latency, + arg.ID, + ) + return err +} + +const markProxyFailed = `-- name: MarkProxyFailed :exec +UPDATE proxies SET last_checked_at = ?, alive = 0, fail_count = ? WHERE id = ? +` + +type MarkProxyFailedParams struct { + LastCheckedAt int64 + FailCount int64 + ID int64 +} + +func (q *Queries) MarkProxyFailed(ctx context.Context, arg MarkProxyFailedParams) error { + _, err := q.db.ExecContext(ctx, markProxyFailed, arg.LastCheckedAt, arg.FailCount, arg.ID) + return err +} + +const memoryProjects = `-- name: MemoryProjects :many +SELECT project AS name, COUNT(*) AS cnt FROM memories +WHERE project != '' GROUP BY project ORDER BY MAX(updated_at) DESC +` + +type MemoryProjectsRow struct { + Name string + Cnt int64 +} + +func (q *Queries) MemoryProjects(ctx context.Context) ([]MemoryProjectsRow, error) { + rows, err := q.db.QueryContext(ctx, memoryProjects) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MemoryProjectsRow{} + for rows.Next() { + var i MemoryProjectsRow + if err := rows.Scan(&i.Name, &i.Cnt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const payDailyPaid = `-- name: PayDailyPaid :many +SELECT CAST(strftime('%Y-%m-%d', paid_at, 'unixepoch', 'localtime') AS TEXT) AS day, + COALESCE(SUM(money), 0) AS money, COUNT(*) AS cnt +FROM pay_orders WHERE status = 1 AND paid_at >= ? GROUP BY day +` + +type PayDailyPaidRow struct { + Day string + Money interface{} + Cnt int64 +} + +func (q *Queries) PayDailyPaid(ctx context.Context, paidAt int64) ([]PayDailyPaidRow, error) { + rows, err := q.db.QueryContext(ctx, payDailyPaid, paidAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PayDailyPaidRow{} + for rows.Next() { + var i PayDailyPaidRow + if err := rows.Scan(&i.Day, &i.Money, &i.Cnt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const payOrderStats = `-- name: PayOrderStats :one +SELECT COUNT(*) AS cnt, COALESCE(SUM(CASE WHEN status = 1 THEN money ELSE 0 END), 0) AS money, + COALESCE(SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END), 0) AS pending, + COALESCE(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END), 0) AS paid +FROM pay_orders WHERE created_at >= ? +` + +type PayOrderStatsRow struct { + Cnt int64 + Money interface{} + Pending interface{} + Paid interface{} +} + +func (q *Queries) PayOrderStats(ctx context.Context, createdAt int64) (PayOrderStatsRow, error) { + row := q.db.QueryRowContext(ctx, payOrderStats, createdAt) + var i PayOrderStatsRow + err := row.Scan( + &i.Cnt, + &i.Money, + &i.Pending, + &i.Paid, + ) + return i, err +} + +const randomProxies = `-- name: RandomProxies :many +SELECT p.id, p.ip, p.port, p.protocols, p.fail_count, r.code AS region_code, r.zh_name AS region_zh_name +FROM proxies p JOIN proxy_regions r ON r.id = p.region_id +WHERE p.alive = 1 AND (? = 0 OR p.region_id = ?) AND (? = '' OR p.protocols LIKE '%' || ? || '%') +AND (? = '' OR r.code = ?) +ORDER BY RANDOM() LIMIT ? +` + +type RandomProxiesParams struct { + Column1 interface{} + RegionID int64 + Column3 interface{} + Column4 sql.NullString + Column5 interface{} + Code string + Limit int64 +} + +type RandomProxiesRow struct { + ID int64 + Ip string + Port int64 + Protocols string + FailCount int64 + RegionCode string + RegionZhName string +} + +func (q *Queries) RandomProxies(ctx context.Context, arg RandomProxiesParams) ([]RandomProxiesRow, error) { + rows, err := q.db.QueryContext(ctx, randomProxies, + arg.Column1, + arg.RegionID, + arg.Column3, + arg.Column4, + arg.Column5, + arg.Code, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []RandomProxiesRow{} + for rows.Next() { + var i RandomProxiesRow + if err := rows.Scan( + &i.ID, + &i.Ip, + &i.Port, + &i.Protocols, + &i.FailCount, + &i.RegionCode, + &i.RegionZhName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recentPaidOrders = `-- name: RecentPaidOrders :many +SELECT trade_no, subject, money, paid_at FROM pay_orders WHERE status = 1 ORDER BY paid_at DESC LIMIT ? +` + +type RecentPaidOrdersRow struct { + TradeNo string + Subject string + Money int64 + PaidAt int64 +} + +func (q *Queries) RecentPaidOrders(ctx context.Context, limit int64) ([]RecentPaidOrdersRow, error) { + rows, err := q.db.QueryContext(ctx, recentPaidOrders, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []RecentPaidOrdersRow{} + for rows.Next() { + var i RecentPaidOrdersRow + if err := rows.Scan( + &i.TradeNo, + &i.Subject, + &i.Money, + &i.PaidAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setPayNotified = `-- name: SetPayNotified :exec +UPDATE pay_orders SET notified = 1 WHERE id = ? +` + +func (q *Queries) SetPayNotified(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, setPayNotified, id) + return err +} + +const setPayOrderQrcode = `-- name: SetPayOrderQrcode :exec +UPDATE pay_orders SET qrcode = ? WHERE id = ? +` + +type SetPayOrderQrcodeParams struct { + Qrcode string + ID int64 +} + +func (q *Queries) SetPayOrderQrcode(ctx context.Context, arg SetPayOrderQrcodeParams) error { + _, err := q.db.ExecContext(ctx, setPayOrderQrcode, arg.Qrcode, arg.ID) + return err +} + +const setPayOrderStatus = `-- name: SetPayOrderStatus :exec +UPDATE pay_orders SET status = ?, paid_at = ?, cards = ? WHERE id = ? +` + +type SetPayOrderStatusParams struct { + Status int64 + PaidAt int64 + Cards string + ID int64 +} + +func (q *Queries) SetPayOrderStatus(ctx context.Context, arg SetPayOrderStatusParams) error { + _, err := q.db.ExecContext(ctx, setPayOrderStatus, + arg.Status, + arg.PaidAt, + arg.Cards, + arg.ID, + ) + return err +} + +const setPayOrderUpstream = `-- name: SetPayOrderUpstream :exec +UPDATE pay_orders SET upstream_trade_no = ?, goods_key = ?, quantity = ?, unit_price = ?, payurl = ?, buyer_contact = ?, query_pwd = ? WHERE id = ? +` + +type SetPayOrderUpstreamParams struct { + UpstreamTradeNo string + GoodsKey string + Quantity int64 + UnitPrice int64 + Payurl string + BuyerContact string + QueryPwd string + ID int64 +} + +func (q *Queries) SetPayOrderUpstream(ctx context.Context, arg SetPayOrderUpstreamParams) error { + _, err := q.db.ExecContext(ctx, setPayOrderUpstream, + arg.UpstreamTradeNo, + arg.GoodsKey, + arg.Quantity, + arg.UnitPrice, + arg.Payurl, + arg.BuyerContact, + arg.QueryPwd, + arg.ID, + ) + return err +} + +const touchProxyApiKey = `-- name: TouchProxyApiKey :exec +UPDATE proxy_keys SET used_count = used_count + 1, last_used_at = ? WHERE id = ? +` + +type TouchProxyApiKeyParams struct { + LastUsedAt int64 + ID int64 +} + +func (q *Queries) TouchProxyApiKey(ctx context.Context, arg TouchProxyApiKeyParams) error { + _, err := q.db.ExecContext(ctx, touchProxyApiKey, arg.LastUsedAt, arg.ID) + return err +} + +const touchSession = `-- name: TouchSession :exec +UPDATE sessions SET expires_at = ? WHERE token = ? +` + +type TouchSessionParams struct { + ExpiresAt int64 + Token string +} + +func (q *Queries) TouchSession(ctx context.Context, arg TouchSessionParams) error { + _, err := q.db.ExecContext(ctx, touchSession, arg.ExpiresAt, arg.Token) + return err +} + +const updateBookmark = `-- name: UpdateBookmark :execrows +UPDATE bookmarks SET title = ?, url = ?, icon = ?, sort = ? WHERE id = ? AND user_id = ? +` + +type UpdateBookmarkParams struct { + Title string + Url string + Icon string + Sort int64 + ID int64 + UserID int64 +} + +func (q *Queries) UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateBookmark, + arg.Title, + arg.Url, + arg.Icon, + arg.Sort, + arg.ID, + arg.UserID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateDbconn = `-- name: UpdateDbconn :execrows +UPDATE dbconns SET name = ?, engine = ?, host = ?, port = ?, username = ?, password_enc = ?, database = ?, params = ?, updated_at = ? +WHERE id = ? +` + +type UpdateDbconnParams struct { + Name string + Engine string + Host string + Port int64 + Username string + PasswordEnc string + Database string + Params string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateDbconn(ctx context.Context, arg UpdateDbconnParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateDbconn, + arg.Name, + arg.Engine, + arg.Host, + arg.Port, + arg.Username, + arg.PasswordEnc, + arg.Database, + arg.Params, + arg.UpdatedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateLicenseApp = `-- name: UpdateLicenseApp :exec +UPDATE license_apps SET name = ?, enabled = ? WHERE id = ? +` + +type UpdateLicenseAppParams struct { + Name string + Enabled int64 + ID int64 +} + +func (q *Queries) UpdateLicenseApp(ctx context.Context, arg UpdateLicenseAppParams) error { + _, err := q.db.ExecContext(ctx, updateLicenseApp, arg.Name, arg.Enabled, arg.ID) + return err +} + +const updateProxySeen = `-- name: UpdateProxySeen :exec +UPDATE proxies SET protocols = ?, anonymity = ?, region_id = ?, latency = ?, speed = ?, uptime = ?, last_seen_at = ?, alive = 1, fail_count = 0 +WHERE id = ? +` + +type UpdateProxySeenParams struct { + Protocols string + Anonymity string + RegionID int64 + Latency int64 + Speed int64 + Uptime int64 + LastSeenAt int64 + ID int64 +} + +func (q *Queries) UpdateProxySeen(ctx context.Context, arg UpdateProxySeenParams) error { + _, err := q.db.ExecContext(ctx, updateProxySeen, + arg.Protocols, + arg.Anonymity, + arg.RegionID, + arg.Latency, + arg.Speed, + arg.Uptime, + arg.LastSeenAt, + arg.ID, + ) + return err +} + +const updateServer = `-- name: UpdateServer :execrows +UPDATE servers SET name = ?, host = ?, port = ?, username = ?, auth_type = ?, secret_enc = ?, note = ?, updated_at = ? +WHERE id = ? +` + +type UpdateServerParams struct { + Name string + Host string + Port int64 + Username string + AuthType string + SecretEnc string + Note string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateServer(ctx context.Context, arg UpdateServerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateServer, + arg.Name, + arg.Host, + arg.Port, + arg.Username, + arg.AuthType, + arg.SecretEnc, + arg.Note, + arg.UpdatedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateTool = `-- name: UpdateTool :exec +UPDATE tools SET name = ?, icon = ?, url = ?, description = ?, sort = ?, enabled = ? WHERE id = ? +` + +type UpdateToolParams struct { + Name string + Icon string + Url string + Description string + Sort int64 + Enabled bool + ID int64 +} + +func (q *Queries) UpdateTool(ctx context.Context, arg UpdateToolParams) error { + _, err := q.db.ExecContext(ctx, updateTool, + arg.Name, + arg.Icon, + arg.Url, + arg.Description, + arg.Sort, + arg.Enabled, + arg.ID, + ) + return err +} + +const updateUser = `-- name: UpdateUser :exec +UPDATE users SET name = ?, role = ?, status = ?, updated_at = ? WHERE id = ? +` + +type UpdateUserParams struct { + Name string + Role string + Status string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { + _, err := q.db.ExecContext(ctx, updateUser, + arg.Name, + arg.Role, + arg.Status, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const updateUserPassword = `-- name: UpdateUserPassword :exec +UPDATE users SET name = ?, role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ? +` + +type UpdateUserPasswordParams struct { + Name string + Role string + Status string + PasswordHash string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { + _, err := q.db.ExecContext(ctx, updateUserPassword, + arg.Name, + arg.Role, + arg.Status, + arg.PasswordHash, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const updateVault = `-- name: UpdateVault :execrows +UPDATE vault SET title = ?, username = ?, password_enc = ?, url = ?, note = ?, totp_enc = ?, updated_at = ? +WHERE id = ? AND user_id = ? +` + +type UpdateVaultParams struct { + Title string + Username string + PasswordEnc string + Url string + Note string + TotpEnc string + UpdatedAt int64 + ID int64 + UserID int64 +} + +func (q *Queries) UpdateVault(ctx context.Context, arg UpdateVaultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateVault, + arg.Title, + arg.Username, + arg.PasswordEnc, + arg.Url, + arg.Note, + arg.TotpEnc, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const upsertMemory = `-- name: UpsertMemory :exec +INSERT INTO memories (k, content, project, tags, written_by, revision, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, 1, ?, ?) +ON CONFLICT(k) DO UPDATE SET + content = excluded.content, + project = excluded.project, + tags = excluded.tags, + written_by = excluded.written_by, + revision = memories.revision + 1, + updated_at = excluded.updated_at +` + +type UpsertMemoryParams struct { + K string + Content string + Project string + Tags string + WrittenBy string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) UpsertMemory(ctx context.Context, arg UpsertMemoryParams) error { + _, err := q.db.ExecContext(ctx, upsertMemory, + arg.K, + arg.Content, + arg.Project, + arg.Tags, + arg.WrittenBy, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const upsertProxyRegion = `-- name: UpsertProxyRegion :exec +INSERT INTO proxy_regions(code, name, zh_name, region, slug) VALUES(?, ?, ?, ?, ?) +ON CONFLICT(code) DO UPDATE SET name = excluded.name, zh_name = excluded.zh_name, + region = excluded.region, slug = excluded.slug +` + +type UpsertProxyRegionParams struct { + Code string + Name string + ZhName string + Region string + Slug string +} + +func (q *Queries) UpsertProxyRegion(ctx context.Context, arg UpsertProxyRegionParams) error { + _, err := q.db.ExecContext(ctx, upsertProxyRegion, + arg.Code, + arg.Name, + arg.ZhName, + arg.Region, + arg.Slug, + ) + return err +} + +const upsertSetting = `-- name: UpsertSetting :exec +INSERT INTO settings (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v +` + +type UpsertSettingParams struct { + K string + V string +} + +func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) error { + _, err := q.db.ExecContext(ctx, upsertSetting, arg.K, arg.V) + return err +} diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql new file mode 100644 index 0000000..51d5fd5 --- /dev/null +++ b/backend/internal/db/schema.sql @@ -0,0 +1,228 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS tools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS vault ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + password_enc TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + totp_enc TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bookmarks(user_id); + +CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'password', + secret_enc TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + stored TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + mime TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_files_user ON files(user_id); + +CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_name TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL DEFAULT 0, + downloads INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id); +CREATE INDEX IF NOT EXISTS idx_shares_file ON shares(file_id); + +CREATE TABLE IF NOT EXISTS dbconns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + engine TEXT NOT NULL DEFAULT 'mysql', + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 0, + username TEXT NOT NULL DEFAULT '', + password_enc TEXT NOT NULL DEFAULT '', + database TEXT NOT NULL DEFAULT '', + params TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS proxy_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + zh_name TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + protocols TEXT NOT NULL DEFAULT '', + anonymity TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + region_id INTEGER NOT NULL REFERENCES proxy_regions(id) ON DELETE CASCADE, + latency INTEGER NOT NULL DEFAULT 0, + speed INTEGER NOT NULL DEFAULT 0, + uptime INTEGER NOT NULL DEFAULT 0, + alive INTEGER NOT NULL DEFAULT 1, + fail_count INTEGER NOT NULL DEFAULT 0, + last_checked_at INTEGER NOT NULL DEFAULT 0, + last_alive_at INTEGER NOT NULL DEFAULT 0, + last_seen_at INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_proxies_addr ON proxies(ip, port); +CREATE INDEX IF NOT EXISTS idx_proxies_region ON proxies(region_id); +CREATE INDEX IF NOT EXISTS idx_proxies_alive ON proxies(alive); +CREATE INDEX IF NOT EXISTS idx_proxies_check ON proxies(alive, last_checked_at); + +CREATE TABLE IF NOT EXISTS proxy_syncs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + total INTEGER NOT NULL DEFAULT 0, + added INTEGER NOT NULL DEFAULT 0, + removed INTEGER NOT NULL DEFAULT 0, + updated INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS proxy_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kkey TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + region_id INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS pay_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_no TEXT NOT NULL UNIQUE, + out_trade_no TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL DEFAULT 'alipay', + subject TEXT NOT NULL DEFAULT '', + money INTEGER NOT NULL, + fee INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, -- 0 待支付 1 已支付 3 已关闭 + notify_url TEXT NOT NULL DEFAULT '', + return_url TEXT NOT NULL DEFAULT '', + client_ip TEXT NOT NULL DEFAULT '', + notified INTEGER NOT NULL DEFAULT 0, + notify_attempts INTEGER NOT NULL DEFAULT 0, + paid_at INTEGER NOT NULL DEFAULT 0, + expired_at INTEGER NOT NULL DEFAULT 0, + upstream_trade_no TEXT NOT NULL DEFAULT '', + goods_key TEXT NOT NULL DEFAULT '', + quantity INTEGER NOT NULL DEFAULT 0, + unit_price INTEGER NOT NULL DEFAULT 0, + payurl TEXT NOT NULL DEFAULT '', + qrcode TEXT NOT NULL DEFAULT '', + buyer_contact TEXT NOT NULL DEFAULT '', + query_pwd TEXT NOT NULL DEFAULT '', + cards TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pay_orders_status ON pay_orders(status, created_at); + +CREATE TABLE IF NOT EXISTS license_apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + appid TEXT NOT NULL UNIQUE, + secret TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS license_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id INTEGER NOT NULL DEFAULT 0, + card TEXT NOT NULL UNIQUE, + hours INTEGER NOT NULL DEFAULT 24, + domain TEXT NOT NULL DEFAULT '', + activated_at INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- agent 共享记忆库:key 形态 "/",跨会话/跨 agent 持久事实 +CREATE TABLE IF NOT EXISTS memories ( + k TEXT PRIMARY KEY, + content TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + written_by TEXT NOT NULL DEFAULT '', + revision INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project, updated_at); diff --git a/backend/internal/dbx/dbx.go b/backend/internal/dbx/dbx.go new file mode 100644 index 0000000..4e6d5ac --- /dev/null +++ b/backend/internal/dbx/dbx.go @@ -0,0 +1,400 @@ +// Package dbx 通用数据库连接层:按 engine 生成 DSN、库/表/列 introspection、 +// 受限执行 SQL(超时 + 行数上限)并把结果序列化为可 JSON 化结构。 +// 支持 mysql / postgres / sqlite。 +package dbx + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/jackc/pgx/v5/stdlib" + _ "modernc.org/sqlite" +) + +// Conn 已解密的连接信息(service 层组装) +type Conn struct { + Engine string + Host string + Port int64 + Username string + Password string + Database string // mysql/pg:默认库;sqlite:文件路径 + Params string // 附加 DSN 参数,形如 "charset=utf8mb4&sslmode=disable" +} + +func (c Conn) driverName() (string, error) { + switch c.Engine { + case "mysql": + return "mysql", nil + case "postgres", "postgresql", "pg": + return "pgx", nil + case "sqlite", "sqlite3": + return "sqlite", nil + } + return "", fmt.Errorf("dbx: 不支持的引擎 %q", c.Engine) +} + +// dsn 按引擎拼连接串;db 为空用连接默认库 +func (c Conn) dsn(db string) string { + if db == "" { + db = c.Database + } + switch c.Engine { + case "mysql": + params := "parseTime=true&timeout=6s" + if c.Params != "" { + params = c.Params + if !strings.Contains(params, "parseTime") { + params += "&parseTime=true" + } + if !strings.Contains(params, "timeout") { + params += "&timeout=6s" + } + } + host := c.Host + if c.Port > 0 { + host += ":" + strconv.FormatInt(c.Port, 10) + } + return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.Username, c.Password, host, db, params) + case "postgres", "postgresql", "pg": + u := &url.URL{Scheme: "postgres", Host: c.Host, User: url.UserPassword(c.Username, c.Password), Path: "/" + db} + if c.Port > 0 { + u.Host += ":" + strconv.FormatInt(c.Port, 10) + } + q := u.Query() + if c.Params != "" { + if extra, err := url.ParseQuery(c.Params); err == nil { + for k, vs := range extra { + for _, v := range vs { + q.Add(k, v) + } + } + } + } + if q.Get("sslmode") == "" { + q.Set("sslmode", "disable") + } + u.RawQuery = q.Encode() + return u.String() + default: // sqlite:host/port/user 不用,database 是文件路径 + path := db + if path == "" { + path = c.Database + } + return "file:" + path + } +} + +// Open 建连并 ping 验证 +func Open(c Conn, db string) (*sql.DB, error) { + driver, err := c.driverName() + if err != nil { + return nil, err + } + d, err := sql.Open(driver, c.dsn(db)) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(2) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if err := d.PingContext(ctx); err != nil { + d.Close() + return nil, fmt.Errorf("dbx: 连接失败: %w", err) + } + return d, nil +} + +// ---- introspection ---- + +// Databases 列出可访问的库;sqlite 恒为 main +func Databases(d *sql.DB, engine string) ([]string, error) { + var q string + switch engine { + case "mysql": + q = `SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('mysql','sys','information_schema','performance_schema') ORDER BY schema_name` + case "postgres", "postgresql", "pg": + q = `SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname` + default: // sqlite + return []string{"main"}, nil + } + return queryStrings(d, q) +} + +type Table struct { + Name string + Type string // table | view +} + +type Column struct { + Table string + Name string + Type string + Pk bool +} + +// TablesAndColumns 某库下的表/视图与列(sqlite 用 sqlite_master+pragma) +func TablesAndColumns(d *sql.DB, engine, database string) ([]Table, []Column, error) { + if engine == "sqlite" || engine == "sqlite3" { + return sqliteSchema(d) + } + var tq, cq string + if engine == "mysql" { + tq = `SELECT table_name, table_type FROM information_schema.tables + WHERE table_schema = ? ORDER BY table_name` + cq = `SELECT table_name, column_name, column_type, + (column_key = 'PRI') AS pk FROM information_schema.columns + WHERE table_schema = ? ORDER BY table_name, ordinal_position` + } else { // postgres:只取 public 及其余非系统 schema 的用户表 + tq = `SELECT table_name, table_type FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog','information_schema') + ORDER BY table_name` + cq = `SELECT c.table_name, c.column_name, c.data_type, + EXISTS ( + SELECT 1 FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace n ON n.oid = rel.relnamespace + JOIN pg_attribute att ON att.attrelid = con.conrelid + AND att.attnum = ANY(con.conkey) + WHERE con.contype = 'p' + AND n.nspname = c.table_schema + AND rel.relname = c.table_name + AND att.attname = c.column_name + ) AS pk + FROM information_schema.columns c + WHERE c.table_schema NOT IN ('pg_catalog','information_schema') + ORDER BY c.table_name, c.ordinal_position` + } + tables := []Table{} + rows, err := d.Query(tq) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var t Table + var ty string + if err := rows.Scan(&t.Name, &ty); err != nil { + rows.Close() + return nil, nil, err + } + t.Type = "table" + if strings.Contains(strings.ToLower(ty), "view") { + t.Type = "view" + } + tables = append(tables, t) + } + rows.Close() + + cols := []Column{} + rows2, err := d.Query(cq) + if err != nil { + return tables, cols, nil // 列拿不到不致命:补全降级 + } + defer rows2.Close() + for rows2.Next() { + var c Column + var pk any + if err := rows2.Scan(&c.Table, &c.Name, &c.Type, &pk); err == nil { + if b, ok := pk.(int64); ok { + c.Pk = b != 0 + } else if b, ok := pk.(bool); ok { + c.Pk = b + } + cols = append(cols, c) + } + } + return tables, cols, nil +} + +func sqliteSchema(d *sql.DB) ([]Table, []Column, error) { + tables := []Table{} + rows, err := d.Query( + `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') + AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + return nil, nil, err + } + defer rows.Close() + for rows.Next() { + var t Table + if err := rows.Scan(&t.Name, &t.Type); err != nil { + return nil, nil, err + } + tables = append(tables, t) + } + rows.Close() + + cols := []Column{} + for _, t := range tables { + if t.Type != "table" { + continue + } + pr, err := d.Query(`SELECT name, type, pk FROM pragma_table_info(?)`, t.Name) + if err != nil { + continue + } + for pr.Next() { + var c Column + var pk int64 + if err := pr.Scan(&c.Name, &c.Type, &pk); err == nil { + c.Table = t.Name + c.Pk = pk != 0 + cols = append(cols, c) + } + } + pr.Close() + } + return tables, cols, nil +} + +func queryStrings(d *sql.DB, q string, args ...any) ([]string, error) { + rows, err := d.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ---- 查询执行 ---- + +type Result struct { + IsSelect bool + Columns []string + Rows [][]any + RowCount int64 + AffectedRows int64 + Truncated bool + ElapsedMs int64 +} + +const maxRowsCap = 10000 + +// returnsRows 按首个关键字预判语句是否产生结果集; +// 不能只靠 QueryContext 试探——sqlite 会把 INSERT 当空结果集执行掉,拿不到 affected +func returnsRows(sqlText string) bool { + s := strings.TrimLeft(sqlText, " \t\r\n(") + for strings.HasPrefix(s, "--") { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = strings.TrimLeft(s[i+1:], " \t\r\n(") + } else { + return true + } + } + if i := strings.IndexAny(s, " \t\r\n("); i > 0 { + s = s[:i] + } + switch strings.ToUpper(s) { + case "SELECT", "WITH", "SHOW", "PRAGMA", "EXPLAIN", "VALUES", "TABLE", "DESC", "DESCRIBE": + return true + } + return false +} + +// Query 执行单条 SQL:有结果集走行收集(cap 截断),否则回 affected rows +func Query(ctx context.Context, d *sql.DB, sqlText string, maxRows int64) (*Result, error) { + if maxRows <= 0 || maxRows > maxRowsCap { + maxRows = 500 + } + start := time.Now() + if !returnsRows(sqlText) { + res, err := d.ExecContext(ctx, sqlText) + if err != nil { + return nil, err + } + aff, _ := res.RowsAffected() + return &Result{IsSelect: false, AffectedRows: aff, + ElapsedMs: time.Since(start).Milliseconds(), + Columns: []string{}, Rows: [][]any{}}, nil + } + r := &Result{Columns: []string{}, Rows: [][]any{}} + + rows, err := d.QueryContext(ctx, sqlText) + if err != nil { + return nil, err + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, err + } + r.IsSelect = true + r.Columns = cols + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + row := make([]any, len(cols)) + for i, v := range vals { + row[i] = cellJSON(v) + } + r.Rows = append(r.Rows, row) + if int64(len(r.Rows)) >= maxRows { + r.Truncated = true + break + } + } + r.RowCount = int64(len(r.Rows)) + r.ElapsedMs = time.Since(start).Milliseconds() + return r, nil +} + +// cellJSON 把驱动返回值规整为 JSON 友好类型 +func cellJSON(v any) any { + switch t := v.(type) { + case nil: + return nil + case []byte: + if utf8.Valid(t) { + return string(t) + } + return "0x" + base64.StdEncoding.EncodeToString(t) + case time.Time: + return t.Format("2006-01-02 15:04:05") + case driver.Valuer: // 防御:部分驱动包一层 + vv, _ := t.Value() + return cellJSON(vv) + default: + return v + } +} + +// Exec 明确写路径(删改/DDL),回受影响行数 +func Exec(ctx context.Context, d *sql.DB, sqlText string) (*Result, error) { + start := time.Now() + res, err := d.ExecContext(ctx, sqlText) + if err != nil { + return nil, err + } + aff, _ := res.RowsAffected() + return &Result{IsSelect: false, AffectedRows: aff, + ElapsedMs: time.Since(start).Milliseconds(), + Columns: []string{}, Rows: [][]any{}}, nil +} + +var ErrNoPK = errors.New("dbx: no primary key") diff --git a/backend/internal/dto/dto.go b/backend/internal/dto/dto.go new file mode 100644 index 0000000..9d43703 --- /dev/null +++ b/backend/internal/dto/dto.go @@ -0,0 +1,755 @@ +// Package dto RPC 线协议类型。遵守 fun DTO 约束:定宽整型、无 map/any, +// 可空字段一律 *T,响应键自动转 camelCase。 +package dto + +type OkView struct { + Message string +} + +// ---- auth ---- + +type LoginDto struct { + Name string + Password string +} + +type UserView struct { + Id int64 + Name string + Role string + Status string + CreatedAt int64 +} + +type SessionView struct { + User *UserView + Token *string +} + +type ChangePasswordDto struct { + OldPassword string + NewPassword string +} + +// ---- user admin ---- + +type ListUsersDto struct { + Page int64 + PageSize int64 + Keyword *string +} + +type UserPageView struct { + Total int64 + Items []UserView +} + +// SaveUserDto 创建/更新一体:Id 为空为新建;Password 更新时可空(不填不改密码) +type SaveUserDto struct { + Id *int64 + Name string + Password *string + Role string + Status *string +} + +type DeleteUserDto struct { + Id int64 +} + +// ---- dashboard ---- + +type TrendPoint struct { + Day string + Count int64 +} + +type StatsView struct { + TotalUsers int64 + ActiveSessions int64 + TodayLogins int64 + UptimeSeconds int64 + LoginTrend []TrendPoint + + Servers int64 + Proxies int64 + AliveProxies int64 + TodayOrders int64 + TodayMoney int64 + TotalMoney int64 + PendingOrders int64 + LicenseApps int64 + LicenseCards int64 + LicenseActive int64 + Memories int64 + MemoryProjects int64 + Bookmarks int64 + VaultItems int64 + Dbconns int64 + Files int64 + + PayTrend []PayTrendPoint + RecentOrders []RecentOrderView + RecentMemories []RecentMemoryView +} + +type PayTrendPoint struct { + Day string + Money int64 + Count int64 +} + +type RecentOrderView struct { + TradeNo string + Subject string + Money int64 + PaidAt int64 +} + +type RecentMemoryView struct { + Key string + Project string + UpdatedAt int64 +} + +// ---- tools ---- + +type ToolView struct { + Id int64 + Name string + Icon string + Url string + Description string + Sort int64 + Enabled bool +} + +type SaveToolDto struct { + Id *int64 + Name string + Icon *string + Url string + Description *string + Sort *int64 + Enabled *bool +} + +type DeleteToolDto struct { + Id int64 +} + +// ---- vault 密码箱 ---- + +// VaultItemView 列表视图:密文字段不下发,只给 hasXxx 标记 +type VaultItemView struct { + Id int64 + Title string + Username *string + Url *string + Note *string + HasPassword bool + HasTotp bool + UpdatedAt int64 +} + +// SaveVaultDto Password/TotpSecret 更新时传空指针表示不变; +// TotpSecret 支持裸 base32 或 otpauth://totp/... 链接;传空字符串表示清除 +type SaveVaultDto struct { + Id *int64 + Title string + Username *string + Password *string + Url *string + Note *string + TotpSecret *string +} + +type VaultIdDto struct { + Id int64 +} + +type SecretView struct { + Value string +} + +type TotpView struct { + Code string + SecondsLeft int64 +} + +// ---- bookmarks 书签 ---- + +type BookmarkView struct { + Id int64 + Title string + Url string + Icon string + Sort int64 +} + +type SaveBookmarkDto struct { + Id *int64 + Title string + Url string + Icon *string + Sort *int64 +} + +type BookmarkIdDto struct { + Id int64 +} + +// ---- servers 服务器管理 ---- + +// ServerView 列表视图:secret 密文不下发,只给 hasSecret 标记 +type ServerView struct { + Id int64 + Name string + Host string + Port int64 + Username string + AuthType string + HasSecret bool + Note *string + CreatedAt int64 +} + +// SaveServerDto Secret 为密码或 PEM 私钥(按 AuthType);nil = 不变,"" = 清除 +type SaveServerDto struct { + Id *int64 + Name string + Host string + Port *int64 + Username string + AuthType string + Secret *string + Note *string +} + +type ServerIdDto struct { + Id int64 +} + +type DiskMountView struct { + Mount string + Fs string + Total int64 + Used int64 + Avail int64 + Percent int64 +} + +// ServerMetricsView 百分比直接给 0-100 整数;字节量单位 byte;速率单位 byte/s +type ServerMetricsView struct { + Hostname string + Os string + Kernel string + UptimeSeconds int64 + CpuPercent int64 + Load1 string + Load5 string + Load15 string + MemTotal int64 + MemUsed int64 + MemAvail int64 + SwapTotal int64 + SwapUsed int64 + DiskTotal int64 + DiskUsed int64 + DiskAvail int64 + NetRxRate int64 + NetTxRate int64 + NetRxTotal int64 + NetTxTotal int64 + Disks []DiskMountView + CollectedAt int64 +} + +// ---- sftp ---- + +type SftpPathDto struct { + Id int64 + Path string +} + +type SftpEntryView struct { + Name string + Size int64 + Mode string + IsDir bool + ModTime int64 +} + +type SftpListView struct { + Path string + Entries []SftpEntryView +} + +// SftpRenameDto Path 为原完整路径,NewName 为同目录新名字 +type SftpRenameDto struct { + Id int64 + Path string + NewName string +} + +// ---- dbm 通用数据库管理 ---- + +// DbconnView 连接视图:密码密文不下发 +type DbconnView struct { + Id int64 + Name string + Engine string + Host string + Port int64 + Username string + Database string + HasPassword bool + CreatedAt int64 +} + +// SaveDbconnDto Password 为 nil=不变、""=清除;sqlite 时 Host 可空、Database 为文件路径 +type SaveDbconnDto struct { + Id *int64 + Name string + Engine string + Host *string + Port *int64 + Username *string + Password *string + Database *string + Params *string +} + +type DbconnIdDto struct { + Id int64 +} + +// DbScopeDto 带可选库的查询范围 +type DbScopeDto struct { + Id int64 + Database *string +} + +type DbDatabasesView struct { + Names []string +} + +type DbTableView struct { + Name string + Type string +} + +type DbColumnView struct { + Table string + Name string + Type string + Pk bool +} + +type DbTablesView struct { + Tables []DbTableView + Columns []DbColumnView +} + +// DbQueryDto Sql 单条语句;MaxRows 服务端封顶 +type DbQueryDto struct { + Id int64 + Database *string + Sql string + MaxRows *int64 +} + +// DbQueryView RowsJson 为 [][]any 的 JSON 串(DTO 不支持嵌套数组/any,前端自行 parse) +type DbQueryView struct { + IsSelect bool + Columns []string + RowsJson string + RowCount int64 + AffectedRows int64 + Truncated bool + ElapsedMs int64 +} + +type DbImportView struct { + Inserted int64 + Skipped int64 +} + +// ---- webreq HTTP 代理 ---- + +// WebFetchDto HeadersJson 为 {"k":"v"} 对象 JSON 串 +type WebFetchDto struct { + Method string + Url string + HeadersJson string + Body string +} + +type WebFetchView struct { + Status int64 + HeadersJson string + Body string + ElapsedMs int64 + Truncated bool +} + +// ---- disk 网盘 ---- + +type FileView struct { + Id int64 + Name string + Size int64 + Mime string + CreatedAt int64 +} + +// ShareView expiresAt=0 表示永久有效;expired 由服务端算好给前端直接显示 +type ShareView struct { + Id int64 + Code string + FileName string + ExpiresAt int64 + Expired bool + Downloads int64 + CreatedAt int64 +} + +// CreateShareDto ExpireMinutes:0=永久,>0=从现在起 N 分钟 +type CreateShareDto struct { + FileId int64 + ExpireMinutes int64 +} + +type IdDto struct { + Id int64 +} + +// ---- proxy 代理池 ---- + +type ProxyRegionView struct { + Id int64 + Code string + Name string + ZhName string + Region string + AliveCount int64 +} + +type ProxyView struct { + Id int64 + Ip string + Port int64 + Address string + Protocols string + Anonymity string + RegionId int64 + RegionCode string + RegionName string + RegionZhName string + Latency int64 + Speed int64 + Uptime int64 + Alive bool + FailCount int64 + LastCheckedAt int64 + LastAliveAt int64 + LastSeenAt int64 +} + +type ProxyListDto struct { + RegionId int64 + Protocol string + Keyword string + Alive int64 + Page int64 + PageSize int64 +} + +type ProxyListView struct { + Total int64 + Items []ProxyView +} + +type ProxySyncView struct { + Id int64 + StartedAt int64 + FinishedAt int64 + Status string + Source string + Total int64 + Added int64 + Removed int64 + Updated int64 + DetailJson string + Error string +} + +type ProxyStatsView struct { + Total int64 + Regions int64 + LastSyncAt int64 + LastCheckAt int64 + LastStatus string + Checking bool + Syncing bool +} + +// ---- 代理提取密钥 ---- + +type ProxyKeyView struct { + Id int64 + Key string + Name string + RegionId int64 + RegionCode string + RegionZhName string + Enabled bool + UsedCount int64 + LastUsedAt int64 + CreatedAt int64 +} + +// ProxyKeySaveDto regionId=0 表示全部地区;key 由服务端生成 +type ProxyKeySaveDto struct { + Name string + RegionId int64 +} + +// ---- settings ---- + +type SettingDto struct { + Key string + Value string +} + +type SettingView struct { + Value string +} + +// ---- pay ---- + +type PayOrderListDto struct { + Page int64 + PageSize int64 + Status *int64 + Kw *string +} + +type PayOrderView struct { + Id int64 + TradeNo string + OutTradeNo string + Channel string + Subject string + Money int64 + Status int64 + Notified int64 + Payurl string + PaidAt int64 + ExpiredAt int64 + CreatedAt int64 +} + +type PayOrderPageView struct { + Total int64 + Items []PayOrderView +} + +type PayStatsView struct { + TodayCount int64 + TodayMoney int64 + TotalCount int64 + TotalMoney int64 + PendingCount int64 + TodayPaidCount int64 + TotalPaidCount int64 +} + +type PayUpstreamDto struct { + Username string + Password string +} + +type PayUpstreamStatusView struct { + Configured int64 + Username string + Nickname string + Shop string + TokenAge int64 + GoodsId int64 + GoodsKey string + GoodsName string + UnitPrice int64 + StockCount int64 + TargetMoney int64 + WalletOk int64 + WalletAvail int64 + WalletFrozen int64 + Ensuring int64 + LastError string +} + +type PayTestPayDto struct { + Money string +} + +type PayTestPayView struct { + TradeNo string + Payurl string + Qrcode string + Money int64 + Cashier string +} + +type PayCashierDto struct { + TradeNo string +} + +type PayCashierView struct { + TradeNo string + OutTradeNo string + Subject string + Money int64 + Channel string + Status int64 + Payurl string + Qrcode string + Cards string + CreatedAt int64 + ExpiredAt int64 +} + +type PayCashierStatusView struct { + Status int64 + ReturnUrl string +} + +type LicenseListDto struct { + AppId int64 + Page int64 + PageSize int64 + Kw *string +} + +type LicenseCardView struct { + Id int64 + Card string + Hours int64 + Domain string + Status int64 + ActivatedAt int64 + ExpiresAt int64 + CreatedAt int64 +} + +type LicensePageView struct { + Total int64 + Items []LicenseCardView +} + +type LicenseStatsView struct { + Total int64 + Unused int64 + Active int64 + Expired int64 +} + +type LicenseGenDto struct { + AppId int64 + Count int64 + Hours int64 +} + +type LicenseGenView struct { + Cards []string +} + +type LicenseIdDto struct { + Id int64 +} + +type LicenseStatsDto struct { + AppId int64 +} + +type LicenseAppView struct { + Id int64 + Appid string + Name string + Enabled int64 + CardCount int64 + CreatedAt int64 +} + +type LicenseAppSaveDto struct { + Id int64 + Name string + Enabled *int64 +} + +type LicenseAppsView struct { + Items []LicenseAppView +} + +type MemoryListDto struct { + Project string + Tag string + Query string + Limit int64 +} + +type MemoryMetaView struct { + Key string + Project string + Tags []string + WrittenBy string + Revision int64 + UpdatedAt int64 +} + +type MemoryListView struct { + Items []MemoryMetaView +} + +type MemoryView struct { + Key string + Url string + Content string + Project string + Tags []string + WrittenBy string + Revision int64 + CreatedAt int64 + UpdatedAt int64 +} + +type MemoryKeyDto struct { + Key string +} + +type MemorySaveDto struct { + Key string + Content string + Project string + Tags []string +} + +type MemorySaveView struct { + Key string + Url string + Revision int64 +} + +type MemoryProjectView struct { + Name string + Count int64 +} + +type MemoryProjectsView struct { + Items []MemoryProjectView +} + +type MemoryMcpView struct { + Url string + Token string +} diff --git a/backend/internal/memx/memx.go b/backend/internal/memx/memx.go new file mode 100644 index 0000000..808ed2e --- /dev/null +++ b/backend/internal/memx/memx.go @@ -0,0 +1,276 @@ +// Package memx 共享记忆库的对外协议面:Streamable HTTP MCP server(/mcp), +// 工具与 git.cyi.cc/chiyi/memory-mcp 完全同构,agent 改指 cyi-box 地址即可无缝迁移。 +package memx + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +const maxContentLen = 64 * 1024 + +// 高置信度密钥格式拦截——记忆库绝不允许落凭证 +var secretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`AKIA[0-9A-Z]{16}`), + regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY`), + regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}`), + regexp.MustCompile(`github_pat_[A-Za-z0-9_]{20,}`), + regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), + regexp.MustCompile(`(?i)\b(password|passwd|api[_-]?key|secret)\s*[:=]\s*["']?[^\s"'$]{8,}`), +} + +// LintContent 内容体检:超长与疑似凭证直接拒(管理页与 MCP 共用) +func LintContent(content string) error { + if len(content) > maxContentLen { + return fmt.Errorf("content too large (%d bytes, max %d)", len(content), maxContentLen) + } + for _, p := range secretPatterns { + if loc := p.FindStringIndex(content); loc != nil { + return fmt.Errorf("content rejected: looks like it contains a credential near offset %d; store secrets in a secrets manager, not here", loc[0]) + } + } + return nil +} + +func toJSON(v any) *mcp.CallToolResult { + b, _ := json.MarshalIndent(v, "", " ") + return mcp.NewToolResultText(string(b)) +} + +func errResult(err error) *mcp.CallToolResult { + return mcp.NewToolResultError(err.Error()) +} + +type ctxKeyBase struct{} + +// WithBase 把当前请求推导出的站点基址注入 ctx,memory_save 用它拼 /m/ 链接 +func WithBase(ctx context.Context, base string) context.Context { + return context.WithValue(ctx, ctxKeyBase{}, base) +} + +func baseFrom(ctx context.Context) string { + if v, ok := ctx.Value(ctxKeyBase{}).(string); ok { + return v + } + return "" +} + +// keyFrom 容忍传整段分享链接:带 "/m/" 时只取其后段 +func keyFrom(ctx context.Context, key string) string { + if base := baseFrom(ctx); base != "" { + key = strings.TrimPrefix(key, base+"/m/") + } + if i := strings.LastIndex(key, "/m/"); i >= 0 { + key = key[i+3:] + } + return key +} + +// NewMCPServer 建 Streamable HTTP MCP 服务 +func NewMCPServer(st *store.Store) *server.MCPServer { + s := server.NewMCPServer("cyi-box-memory", "1.0.0", + server.WithToolCapabilities(true), + server.WithPromptCapabilities(true), + server.WithResourceCapabilities(false, true), + ) + + s.AddTool(mcp.NewTool("memory_save", + mcp.WithDescription(`Save a durable, reusable fact to the shared memory bank so future sessions (possibly other agents) can recall it. + +WHEN TO CALL: when you learn non-obvious, persistent facts — project architecture, stack/framework, server addresses and how to reach them, build/test/deploy commands, repo conventions, recurring pitfalls, external service endpoints. + +DO NOT SAVE: task progress or TODO state, anything derivable by reading the repo (file lists, function names), secrets/credentials/tokens (rejected server-side), one-off command output. + +Content should be concise markdown. Re-saving the same key overwrites it (revision+1), so update facts in place rather than duplicating.`), + mcp.WithString("key", mcp.Required(), mcp.Description(`Stable identifier like "vivid/deploy" — use "project/topic" naming.`)), + mcp.WithString("content", mcp.Required(), mcp.Description("Markdown body of the memory")), + mcp.WithString("project", mcp.Description("Project slug, e.g. repo name — groups memories for memory_list")), + mcp.WithArray("tags", mcp.Items(map[string]any{"type": "string"}), mcp.Description(`e.g. ["server","stack","gotcha","command"]`)), + mcp.WithString("written_by", mcp.Description("Which agent is writing: devin / codex / claude / human / ...")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + content, err := r.RequireString("content") + if err != nil { + return errResult(err), nil + } + key, err := r.RequireString("key") + if err != nil { + return errResult(err), nil + } + key = strings.TrimSpace(key) + if key == "" { + return errResult(fmt.Errorf("key 不能为空,形如 project/topic")), nil + } + if err := LintContent(content); err != nil { + return errResult(err), nil + } + project := strings.TrimSpace(r.GetString("project", "")) + m := &store.Memory{ + Key: key, + Content: content, + Project: project, + Tags: r.GetStringSlice("tags", nil), + WrittenBy: r.GetString("written_by", ""), + } + if err := st.SaveMemory(m); err != nil { + return errResult(err), nil + } + url, _ := st.SignMemoryReadURL(baseFrom(ctx), m.Key) + return toJSON(map[string]any{ + "ok": true, "key": m.Key, "revision": m.Revision, + "url": url, "resource": "memory://" + m.Key, + }), nil + }) + + s.AddTool(mcp.NewTool("memory_get", + mcp.WithDescription("Read one memory by exact key. Use keys from memory_list/memory_search results or a shared URL like {base}/m/{key}."), + mcp.WithString("key", mcp.Required(), mcp.Description(`e.g. "vivid/deploy"`)), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + key, err := r.RequireString("key") + if err != nil { + return errResult(err), nil + } + key = keyFrom(ctx, key) + m, err := st.GetMemory(key) + if err != nil { + return errResult(err), nil + } + if m == nil { + return mcp.NewToolResultErrorf("no memory with key %q", key), nil + } + return toJSON(m), nil + }) + + s.AddTool(mcp.NewTool("memory_search", + mcp.WithDescription("Keyword-search the memory bank. All terms must match (AND). Returns full memories ordered by recency."), + mcp.WithString("query", mcp.Required(), mcp.Description("Space-separated keywords")), + mcp.WithString("project", mcp.Description("Restrict to one project")), + mcp.WithNumber("limit", mcp.Description("Max results, default 20")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := r.RequireString("query") + if err != nil { + return errResult(err), nil + } + ms, err := st.SearchMemories(query, r.GetString("project", ""), int64(r.GetInt("limit", 20))) + if err != nil { + return errResult(err), nil + } + return toJSON(ms), nil + }) + + s.AddTool(mcp.NewTool("memory_list", + mcp.WithDescription("List memory keys (metadata only). CALL THIS at the start of a session to see what is already known about the current project before doing exploration work."), + mcp.WithString("project", mcp.Description("Restrict to one project")), + mcp.WithString("tag", mcp.Description(`Restrict to one tag, e.g. "server"`)), + mcp.WithNumber("limit", mcp.Description("Max results, default 20")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ms, err := st.ListMemories(r.GetString("project", ""), r.GetString("tag", ""), int64(r.GetInt("limit", 20))) + if err != nil { + return errResult(err), nil + } + type summary struct { + Key string `json:"key"` + Project string `json:"project,omitempty"` + Tags []string `json:"tags,omitempty"` + WrittenBy string `json:"written_by,omitempty"` + UpdatedAt int64 `json:"updated_at"` + } + out := make([]summary, 0, len(ms)) + for _, m := range ms { + out = append(out, summary{m.Key, m.Project, m.Tags, m.WrittenBy, m.UpdatedAt}) + } + return toJSON(out), nil + }) + + s.AddTool(mcp.NewTool("memory_projects", + mcp.WithDescription("List project groups in the memory bank with memory counts. Use to discover which projects have memories."), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ps, err := st.MemoryProjects() + if err != nil { + return errResult(err), nil + } + type proj struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + out := make([]proj, 0, len(ps)) + for _, p := range ps { + out = append(out, proj{p.Name, p.Count}) + } + return toJSON(out), nil + }) + + s.AddTool(mcp.NewTool("memory_delete", + mcp.WithDescription("Permanently delete a memory by key. Use when a fact is stale or was saved by mistake."), + mcp.WithString("key", mcp.Required()), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + key, err := r.RequireString("key") + if err != nil { + return errResult(err), nil + } + ok, err := st.DeleteMemory(keyFrom(ctx, key)) + if err != nil { + return errResult(err), nil + } + return toJSON(map[string]any{"ok": ok, "key": key}), nil + }) + + s.AddPrompt(mcp.NewPrompt("save_context", + mcp.WithPromptDescription("Collect this project's durable context (repo, stack, servers, commands, gotchas) and save it to the shared memory bank."), + ), func(ctx context.Context, r mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return mcp.NewGetPromptResult("Save project context to shared memory", []mcp.PromptMessage{ + mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(`Collect this project's durable context and save it with the memory_save tool: + +1. Gather: git remote URL, default branch, current commit; language/framework/build system; external servers or endpoints the project talks to; non-obvious build/test/deploy commands; known pitfalls. +2. Do NOT include secrets, tokens, or task progress. +3. Call memory_save once per coherent topic using keys like "/" (e.g. "vivid/deploy", "vivid/stack"), with project and tags set. +4. Reply with the list of saved keys and their URLs.`)), + }), nil + }) + + // memory:// 资源:客户端可直接把记忆钉进上下文,省一轮 tools/call + s.AddResourceTemplate( + mcp.NewResourceTemplate("memory:/{/key*}", "memory", + mcp.WithTemplateDescription("Read a memory by key (e.g. memory://vivid/deploy)"), + mcp.WithTemplateMIMEType("text/markdown"), + ), + func(ctx context.Context, r mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + var parts []string + switch v := r.Params.Arguments["key"].(type) { + case []string: + parts = v + case []any: + for _, s := range v { + if str, ok := s.(string); ok { + parts = append(parts, str) + } + } + case string: + parts = []string{v} + } + key := strings.Join(parts, "/") + m, err := st.GetMemory(key) + if err != nil { + return nil, err + } + if m == nil { + return nil, fmt.Errorf("no memory with key %q", key) + } + return []mcp.ResourceContents{mcp.TextResourceContents{ + URI: r.Params.URI, + MIMEType: "text/markdown", + Text: m.Content, + }}, nil + }, + ) + + return s +} diff --git a/backend/internal/payx/upstream.go b/backend/internal/payx/upstream.go new file mode 100644 index 0000000..18ae2e4 --- /dev/null +++ b/backend/internal/payx/upstream.go @@ -0,0 +1,612 @@ +// Package payx catfk.com 上游发卡平台对接:商户接口(merchantApi)走 Merchant-Token 鉴权, +// 买家接口(shopApi)无鉴权。提供登录、分类/商品自检、卡密库存维护、下单与支付状态查询。 +package payx + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// apiError 上游业务错误。 +type apiError struct{ msg string } + +func (e *apiError) Error() string { return e.msg } + +// envelope 上游统一响应信封 {code,msg,data};code==1 为成功。 +type envelope struct { + Code int64 `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +// Client 上游平台客户端(fun.Wired 单例)。 +type Client struct { + base string + httpCli *http.Client + + mu sync.Mutex + token string + tokenAt int64 + username string + password string + shop string + nickname string + channelID int64 + channelAt int64 + + // 库存维护锁:确保同一时刻只有一个补库存流程(全程带锁)。 + stockMu sync.Mutex + ensuring chan struct{} +} + +func (c *Client) New() error { + c.base = "https://catfk.com" + c.httpCli = &http.Client{Timeout: 30 * time.Second} + return nil +} + +// Username 已配置的上游账号。 +func (c *Client) Username() string { c.mu.Lock(); defer c.mu.Unlock(); return c.username } + +// Shop 当前店铺买家链接 token。 +func (c *Client) Shop() string { c.mu.Lock(); defer c.mu.Unlock(); return c.shop } + +// Nickname 商户昵称。 +func (c *Client) Nickname() string { c.mu.Lock(); defer c.mu.Unlock(); return c.nickname } + +// TokenAge 当前令牌已存活秒数(0=未登录)。 +func (c *Client) TokenAge() int64 { + c.mu.Lock() + defer c.mu.Unlock() + if c.token == "" { + return 0 + } + return time.Now().Unix() - c.tokenAt +} + +// SetCredentials 设定/更新上游账号凭据并立即登录验证。 +func (c *Client) SetCredentials(username, password string) error { + c.mu.Lock() + c.username, c.password = username, password + c.token, c.tokenAt = "", 0 + c.mu.Unlock() + _, err := c.login() + return err +} + +// SetCredentialsLazy 仅落凭据不登录(启动时装配,首个请求时惰性登录)。 +func (c *Client) SetCredentialsLazy(username, password string) { + c.mu.Lock() + c.username, c.password = username, password + c.mu.Unlock() +} + +// ClearCredentials 清除凭据(账号删除时)。 +func (c *Client) ClearCredentials() { + c.mu.Lock() + c.username, c.password, c.token = "", "", "" + c.mu.Unlock() +} + +func (c *Client) login() (string, error) { + c.mu.Lock() + user, pass := c.username, c.password + c.mu.Unlock() + if user == "" || pass == "" { + return "", &apiError{"上游账号未配置"} + } + var out struct { + MerchantToken string `json:"merchant_token"` + } + if err := c.post(context.Background(), "/merchantApi/user/login", + map[string]any{"username": user, "password": pass}, "", &out); err != nil { + return "", err + } + if out.MerchantToken == "" { + return "", &apiError{"上游登录未返回令牌"} + } + c.mu.Lock() + c.token, c.tokenAt = out.MerchantToken, time.Now().Unix() + c.mu.Unlock() + return out.MerchantToken, nil +} + +// ensureSession 返回有效令牌:无令牌或调用方要求重登时重新登录。 +func (c *Client) ensureSession() (string, error) { + c.mu.Lock() + tok := c.token + c.mu.Unlock() + if tok != "" { + return tok, nil + } + return c.login() +} + +// merchantPost 商户接口调用:带 Merchant-Token;失败原样透出,不自动重登。 +func (c *Client) merchantPost(ctx context.Context, path string, body, out any) error { + tok, err := c.ensureSession() + if err != nil { + return err + } + return c.postOnce(ctx, path, body, tok, out) +} + +// post 通用调用:merchantToken 为空=买家接口;data 解码到 out。 +func (c *Client) post(ctx context.Context, path string, body any, merchantToken string, out any) error { + return c.postOnce(ctx, path, body, merchantToken, out) +} + +func (c *Client) postOnce(ctx context.Context, path string, body any, merchantToken string, out any) error { + raw, err := c.postRawOnce(ctx, path, body, merchantToken) + if err != nil { + return err + } + trimmed := bytes.TrimSpace(raw) + // 令牌失效/被风控时上游会回 HTML 登录页,统一归为鉴权类错误触发重登 + if len(trimmed) > 0 && trimmed[0] == '<' { + return &apiError{"上游返回登录页(token 已失效)"} + } + var env envelope + if err := json.Unmarshal(trimmed, &env); err != nil { + return fmt.Errorf("上游响应解析失败: %w", err) + } + if env.Code != 1 { + return &apiError{env.Msg} + } + if out != nil && len(env.Data) > 0 { + return json.Unmarshal(env.Data, out) + } + return nil +} + +func (c *Client) postRawOnce(ctx context.Context, path string, body any, merchantToken string) ([]byte, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if merchantToken != "" { + req.Header.Set("merchant-token", merchantToken) + } + resp, err := c.httpCli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + // 5xx/HTML 网关故障单独报错;200 + HTML = 令牌失效被踢回登录页 + if resp.StatusCode >= 500 { + return nil, &apiError{fmt.Sprintf("上游网关故障(HTTP %d)", resp.StatusCode)} + } + return data, nil +} + +// postRaw 返回原始响应体(code!=1 也是合法应答的接口用)。 +func (c *Client) postRaw(ctx context.Context, path string, body any) ([]byte, error) { + return c.postRawOnce(ctx, path, body, "") +} + +// ---------- 商户接口 ---------- + +// Userinfo 拉取商户资料,顺带刷新昵称与店铺公开 token。 +func (c *Client) Userinfo(ctx context.Context) (nickname, shop string, err error) { + var out struct { + Nickname string `json:"nickname"` + LinkWebsite string `json:"link_website"` + } + if err := c.merchantPost(ctx, "/merchantApi/user/userinfo", map[string]any{}, &out); err != nil { + return "", "", err + } + // link_website 可能是旧值,以 shop/getLink 返回的 /shop/ 为准 + shop = out.LinkWebsite + var link struct { + Link string `json:"link"` + } + if err := c.merchantPost(ctx, "/merchantApi/shop/getLink", map[string]any{}, &link); err == nil && link.Link != "" { + if u, perr := url.Parse(link.Link); perr == nil && strings.HasPrefix(u.Path, "/shop/") { + if seg := strings.Trim(u.Path[len("/shop/"):], "/"); seg != "" && !strings.Contains(seg, "/") { + shop = seg + } + } + } + c.mu.Lock() + if c.shop != shop { + c.channelID, c.channelAt = 0, 0 + } + c.nickname, c.shop = out.Nickname, shop + c.mu.Unlock() + return out.Nickname, shop, nil +} + +// MerchantGoods 商户商品列表项。 +type MerchantGoods struct { + ID int64 `json:"id"` + GoodsKey string `json:"goods_key"` + Name string `json:"name"` + Price int64 `json:"-"` // 分 + PriceYuan float64 `json:"price"` + Status int64 `json:"status"` + Stock int64 `json:"-"` + Extend struct { + StockCount int64 `json:"stock_count"` + } `json:"extend"` +} + +// GoodsList 商户商品列表(卡密类)。 +func (c *Client) GoodsList(ctx context.Context, keywords string, current int64) ([]MerchantGoods, int64, error) { + var out struct { + Total int64 `json:"total"` + List []MerchantGoods `json:"list"` + } + body := map[string]any{ + "current": current, "pageSize": 20, "goods_type": "card", + "status": 999, "name": keywords, "is_proxy": "0", + } + if err := c.merchantPost(ctx, "/merchantApi/Goods/list", body, &out); err != nil { + return nil, 0, err + } + for i := range out.List { + out.List[i].Price = yuanFen(out.List[i].PriceYuan) + out.List[i].Stock = out.List[i].Extend.StockCount + } + return out.List, out.Total, nil +} + +// GoodsInfo 商品详情:售价(分)、名称、库存张数。 +func (c *Client) GoodsInfo(ctx context.Context, goodsID int64) (priceFen int64, name string, stock int64, err error) { + var out struct { + Name string `json:"name"` + Price float64 `json:"price"` + Extend struct { + StockCount int64 `json:"stock_count"` + } `json:"extend"` + } + if err := c.merchantPost(ctx, "/merchantApi/Goods/info", map[string]any{"id": fmt.Sprint(goodsID)}, &out); err != nil { + return 0, "", 0, err + } + return yuanFen(out.Price), out.Name, out.Extend.StockCount, nil +} + +// CategoryListAll 全部卡密分类(value=分类 ID)。 +func (c *Client) CategoryListAll(ctx context.Context) ([]Category, error) { + var out []struct { + Value int64 `json:"value"` + Label string `json:"label"` + } + if err := c.merchantPost(ctx, "/merchantApi/GoodsCategory/listAll", map[string]any{"goods_type": "card"}, &out); err != nil { + return nil, err + } + cats := make([]Category, 0, len(out)) + for _, v := range out { + cats = append(cats, Category{ID: v.Value, Name: v.Label}) + } + return cats, nil +} + +// Category 卡密分类。 +type Category struct { + ID int64 + Name string +} + +// CategoryAdd 新建卡密分类(响应 data 为 null,需再查 listAll 拿 ID)。 +func (c *Client) CategoryAdd(ctx context.Context, name string) error { + return c.merchantPost(ctx, "/merchantApi/GoodsCategory/update", map[string]any{ + "id": 0, "name": name, "image": "", "sort": 0, "goods_type": "card", + }, nil) +} + +// GoodsAdd 新建卡密商品,返回商品 ID 与 goods_key。 +func (c *Client) GoodsAdd(ctx context.Context, name string, categoryID int64, priceYuan float64) (int64, string, error) { + var out struct { + ID int64 `json:"id"` + GoodsKey string `json:"goods_key"` + } + body := map[string]any{ + "goods_type": "card", "id": 0, "name": name, "image": "", "category_id": categoryID, + "price": priceYuan, "market_price": 0, "description": "", "sort": 0, "coupon_status": 1, + "status": 1, "fee_payer": -1, "show": 1, "contact_format": "any", "agent_status": 0, + "agent_price1": 0, "agent_price2": 0, "agent_price3": 0, "agent_price_limit": 0, + "description_sync": 0, "name_sync": 0, "parent_id": 0, "cost_price": 0, + "add_type": 1, "add_rate": 0, "add_price": 0, + "extend": map[string]any{ + "instructions": "


", "stock_notice": 0, "lock_card": 0, + "limit_count": 1, "limit_count_max": 0, "show_stock_type": 0, + "send_order": 0, "query_password_status": 0, + }, + } + if err := c.merchantPost(ctx, "/merchantApi/Goods/update", body, &out); err != nil { + return 0, "", err + } + return out.ID, out.GoodsKey, nil +} + +// CardAdd 导入卡密(一行一张)。 +func (c *Client) CardAdd(ctx context.Context, goodsID int64, content string) error { + return c.merchantPost(ctx, "/merchantApi/GoodsCardStorage/add", map[string]any{ + "goods_id": goodsID, "content": content, "first": 0, "remove_repeat": 0, + }, nil) +} + +// cardAddBatch 上游单次导入上限(1w 张/批)。 +const cardAddBatch = int64(10000) + +// CardAddN 批量导入 n 张随机卡密,每次 1 万张分批。 +func (c *Client) CardAddN(ctx context.Context, goodsID, n int64) error { + if n <= 0 || n > 1_000_000 { + return fmt.Errorf("补库存数量超出限制: %d", n) + } + for left := n; left > 0; { + b := left + if b > cardAddBatch { + b = cardAddBatch + } + if err := c.CardAdd(ctx, goodsID, RandCards(b)); err != nil { + return err + } + left -= b + } + return nil +} + +// StockLock 获取库存全局锁(调用方负责释放),确保补库存/下单串行。 +func (c *Client) StockLock() func() { + c.stockMu.Lock() + return c.stockMu.Unlock +} + +// EnsureStock 补库存到 target 张(调用前需持有 StockLock)。 +func (c *Client) EnsureStock(ctx context.Context, goodsID, target int64) (int64, error) { + _, _, stock, err := c.GoodsInfo(ctx, goodsID) + if err != nil { + return 0, err + } + if stock < target { + if err := c.CardAddN(ctx, goodsID, target-stock); err != nil { + return stock, err + } + if _, _, stock, err = c.GoodsInfo(ctx, goodsID); err != nil { + return target, nil + } + } + return stock, nil +} + +// ---------- 买家接口(无鉴权) ---------- + +// Channel 支付通道。 +type Channel struct { + ID int64 `json:"id"` + Name string `json:"name"` + ShowName string `json:"show_name"` + Status int64 `json:"status"` + CustomStatus int64 `json:"custom_status"` +} + +// AlipayChannelID 店铺可用的支付宝支付通道 id,缓存 10 分钟。 +func (c *Client) AlipayChannelID(ctx context.Context, shop string) (int64, error) { + c.mu.Lock() + if c.channelID > 0 && time.Now().Unix()-c.channelAt < 600 { + id := c.channelID + c.mu.Unlock() + return id, nil + } + c.mu.Unlock() + var list []Channel + if err := c.post(ctx, "/shopApi/Shop/getUserChannel", map[string]any{"token": shop}, "", &list); err != nil { + if strings.Contains(err.Error(), "Attempt to read property") { + return 0, &apiError{"上游店铺支付通道未开通"} + } + return 0, err + } + var pick int64 + for _, ch := range list { + if ch.Status != 1 || ch.CustomStatus != 1 { + continue + } + if pick == 0 { + pick = ch.ID + } + if strings.Contains(ch.Name, "支付宝") || strings.Contains(ch.ShowName, "支付宝") { + pick = ch.ID + break + } + } + if pick == 0 { + return 0, &apiError{"上游没有可用的支付宝支付通道"} + } + c.mu.Lock() + c.channelID, c.channelAt = pick, time.Now().Unix() + c.mu.Unlock() + return pick, nil +} + +// PayOrderResult 上游下单结果。 +type PayOrderResult struct { + TradeNo string + TotalAmount int64 // 分 + PayURL string +} + +// CreateOrder 上游买家下单(微信支付通道),返回平台订单号与收银台地址。 +func (c *Client) CreateOrder(ctx context.Context, goodsKey string, quantity, channelID int64, contact, queryPwd string) (PayOrderResult, error) { + var raw struct { + TradeNo string `json:"trade_no"` + TotalAmount float64 `json:"total_amount"` + PayURL string `json:"payurl"` + } + err := c.post(ctx, "/shopApi/Pay/order", map[string]any{ + "goods_key": goodsKey, "quantity": quantity, "coupon_code": "", + "channel_id": channelID, "contact": contact, "query_password": queryPwd, + "select_cards_ids": []any{}, + "extend": map[string]any{"juuid": randHex(12)}, + }, "", &raw) + if err != nil { + return PayOrderResult{}, err + } + return PayOrderResult{TradeNo: raw.TradeNo, TotalAmount: yuanFen(raw.TotalAmount), PayURL: raw.PayURL}, nil +} + +// FetchQRCode 提取支付宝扫码付二维码内容。 +// 流程:GET payurl 收银台(自动提交表单)→ POST openapi.alipay.com 网关 → +// 302 到 excashier 收银页 → 页面内嵌 。 +func (c *Client) FetchQRCode(ctx context.Context, tradeNo string) (string, error) { + jar, _ := cookiejar.New(nil) + cli := &http.Client{Timeout: 30 * time.Second, Jar: jar} + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.base+"/shopApi/Pay/payment?trade_no="+tradeNo, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", ua) + resp, err := cli.Do(req) + if err != nil { + return "", fmt.Errorf("获取收银台页面失败: %w", err) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + resp.Body.Close() + if err != nil { + return "", err + } + action := formActionRe.FindSubmatch(body) + if len(action) < 2 { + return "", fmt.Errorf("收银台页面未找到支付表单") + } + form := url.Values{} + for _, m := range formFieldRe.FindAllSubmatch(body, -1) { + form.Set(string(m[1]), string(m[2])) + } + req2, err := http.NewRequestWithContext(ctx, http.MethodPost, string(action[1]), + strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req2.Header.Set("User-Agent", ua) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp2, err := cli.Do(req2) + if err != nil { + return "", fmt.Errorf("收银台跳转失败: %w", err) + } + body2, err := io.ReadAll(io.LimitReader(resp2.Body, 2<<20)) + resp2.Body.Close() + if err != nil { + return "", err + } + m := qrInputRe.FindSubmatch(body2) + if len(m) < 2 { + return "", fmt.Errorf("收银台页面未找到二维码") + } + return string(m[1]), nil +} + +var ( + formActionRe = regexp.MustCompile(`action='([^']+)'`) + formFieldRe = regexp.MustCompile(`name='([^']+)' value='([^']*)'`) + qrInputRe = regexp.MustCompile(`name="qrCode"\s+[^>]*value="([^"]+)"`) +) + +// OrderPaid 上游订单是否已支付(Pay/query:未支付 code=0,已支付 code=1)。 +func (c *Client) OrderPaid(ctx context.Context, tradeNo string) (bool, error) { + raw, err := c.postRaw(ctx, "/shopApi/Pay/query", map[string]any{"trade_no": tradeNo}) + if err != nil { + return false, err + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return false, err + } + return env.Code == 1, nil +} + +// OrderCards 已支付订单交付的卡密列表。 +func (c *Client) OrderCards(ctx context.Context, tradeNo string) ([]string, error) { + var out struct { + Status int64 `json:"status"` + Response struct { + Cards []string `json:"cards"` + } `json:"response"` + } + if err := c.post(ctx, "/shopApi/Order/info", map[string]any{"trade_no": tradeNo, "dump": 1}, "", &out); err != nil { + return nil, err + } + return out.Response.Cards, nil +} + +// WalletInfo 商户钱包(可提现/冻结,分)。 +func (c *Client) WalletInfo(ctx context.Context) (availableFen, frozenFen int64, err error) { + var out struct { + Platform struct { + AvailableMoney float64 `json:"available_money"` + FreezeMoney float64 `json:"freeze_money"` + } `json:"platform"` + } + if err := c.merchantPost(ctx, "/merchantApi/wallet/info", map[string]any{}, &out); err != nil { + return 0, 0, err + } + return yuanFen(out.Platform.AvailableMoney), yuanFen(out.Platform.FreezeMoney), nil +} + +// ---------- 工具 ---------- + +func yuanFen(y float64) int64 { return int64(math.Round(y * 100)) } + +// RandContact 随机买家联系邮箱。 +func RandContact() string { return "p" + randHex(10) + "@outlook.com" } + +// RandQueryPwd 随机订单查询密码。 +func RandQueryPwd() string { return randHex(8) } + +// RandCards 生成 n 张随机唯一卡密(一行一张)。 +func RandCards(n int64) string { + var b strings.Builder + seen := make(map[string]struct{}, n) + for i := int64(0); i < n; { + card := randHex(16) + if _, ok := seen[card]; ok { + continue + } + seen[card] = struct{}{} + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(card) + i++ + } + return b.String() +} + +func randHex(n int) string { + raw := make([]byte, n) + if _, err := rand.Read(raw); err != nil { + for i := range raw { + raw[i] = byte(time.Now().UnixNano() >> (i % 8)) + } + } + const digits = "0123456789abcdef" + b := make([]byte, n) + for i := range b { + b[i] = digits[raw[i]%16] + } + return string(b) +} diff --git a/backend/internal/proxyx/proxyx.go b/backend/internal/proxyx/proxyx.go new file mode 100644 index 0000000..284928a --- /dev/null +++ b/backend/internal/proxyx/proxyx.go @@ -0,0 +1,432 @@ +package proxyx + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "strings" + "time" +) + +const ( + SourceURL = "https://proxy5.net/api/free-proxies.php" + pageURL = "https://proxy5.net/cn/free-proxy" + // version window used by the site's own JS: floor(now / 30min) + versionWindowSec = 1800 + ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) + +type Remote struct { + IP string `json:"ip_address"` + Port int `json:"port"` + Protocols []string `json:"protocols"` + Anonymity string `json:"anonymity"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + CountrySlug string `json:"country_slug"` + Region string `json:"region"` + Latency int `json:"latency"` + Speed int `json:"download_speed"` + Uptime int `json:"uptime"` + Working bool `json:"is_working"` +} + +type payload struct { + CreatedAt string `json:"created_at_utc"` + Count int `json:"count"` + Results []Remote `json:"results"` +} + +var client = newClient() + +func newClient() *http.Client { + jar, _ := cookiejar.New(nil) + return &http.Client{ + Jar: jar, + Timeout: 90 * time.Second, + } +} + +func version() string { + return fmt.Sprint(time.Now().Unix() / versionWindowSec) +} + +func get(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", ua) + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", pageURL) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, 64<<20)) +} + +// Fetch pulls the full proxy catalog. If the API answers with a Cloudflare +// challenge it warms up the cookie jar by loading the listing page once, then retries. +func Fetch(ctx context.Context) ([]Remote, string, error) { + u := SourceURL + "?v=" + version() + body, err := get(ctx, u) + var p payload + if err == nil { + err = json.Unmarshal(body, &p) + } + if err != nil { + if _, warmErr := get(ctx, pageURL); warmErr == nil { + if body, err = get(ctx, u); err == nil { + err = json.Unmarshal(body, &p) + } + } + } + if err != nil { + return nil, "", err + } + out := p.Results[:0] + for _, r := range p.Results { + if r.IP != "" && r.Port > 0 { + out = append(out, r) + } + } + return out, p.CreatedAt, nil +} + +// ---- rola-ip 源 ---- + +const rolaURL = "https://rola-ip.co/proxy-api/api/v1/proxies" +const rolaReferer = "https://rola-ip.co/zh/tools/free-proxy-list/" +const rolaPageSize = 500 +const rolaMaxPages = 40 + +type rolaRow struct { + IP string `json:"ip"` + Port int `json:"port"` + Protocols []string `json:"protocols"` + Code string `json:"code"` + Country string `json:"country"` + Anonymity string `json:"anonymity"` + Latency *float64 `json:"latency"` + Speed *float64 `json:"speed"` + Uptime *float64 `json:"uptime"` +} + +type rolaPayload struct { + Data []rolaRow `json:"data"` + Pagination struct { + Page int `json:"page"` + Total int `json:"total"` + TotalPages int `json:"totalPages"` + } `json:"pagination"` +} + +func rolaAnonymity(a string) string { + switch strings.ToLower(a) { + case "elite": + return "Elite" + case "anonymous": + return "Anonymous" + case "transparent": + return "Transparent" + } + return a +} + +// FetchRola 拉取 rola-ip 免费代理列表:公开前缀 /proxy-api,500/页翻到底。 +func FetchRola(ctx context.Context) ([]Remote, error) { + out := []Remote{} + for page := 1; page <= rolaMaxPages; page++ { + u := fmt.Sprintf("%s?page=%d&pageSize=%d", rolaURL, page, rolaPageSize) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", ua) + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", rolaReferer) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + var p rolaPayload + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + for _, r := range p.Data { + if r.IP == "" || r.Port <= 0 { + continue + } + protos := make([]string, 0, len(r.Protocols)) + for _, pr := range r.Protocols { + protos = append(protos, strings.ToUpper(pr)) + } + var lat, spd, upt int + if r.Latency != nil { + lat = int(*r.Latency) + } + if r.Speed != nil { + spd = int(*r.Speed) + } + if r.Uptime != nil { + upt = int(*r.Uptime) + } + out = append(out, Remote{ + IP: r.IP, Port: r.Port, Protocols: protos, + Anonymity: rolaAnonymity(r.Anonymity), + Country: r.Country, CountryCode: strings.ToUpper(r.Code), + Region: Continent(r.Code), + Latency: lat, Speed: spd, Uptime: upt, + }) + } + if page >= p.Pagination.TotalPages || len(p.Data) == 0 { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(300 * time.Millisecond): + } + } + return out, nil +} + +// Continent 国家码 → 大洲(与 proxy5 的 region 字段取值对齐) +func Continent(code string) string { + if c, ok := continents[strings.ToUpper(code)]; ok { + return c + } + return "Other" +} + +var continents = map[string]string{ + // Asia + "AF": "Asia", "AM": "Asia", "AZ": "Asia", "BH": "Asia", "BD": "Asia", + "BN": "Asia", "KH": "Asia", "CN": "Asia", "HK": "Asia", "MO": "Asia", + "TW": "Asia", "CY": "Asia", "GE": "Asia", "IN": "Asia", "ID": "Asia", + "IR": "Asia", "IQ": "Asia", "IL": "Asia", "JP": "Asia", "JO": "Asia", + "KZ": "Asia", "KW": "Asia", "KG": "Asia", "LA": "Asia", "LB": "Asia", + "MY": "Asia", "MV": "Asia", "MN": "Asia", "MM": "Asia", "NP": "Asia", + "KP": "Asia", "OM": "Asia", "PK": "Asia", "PS": "Asia", "PH": "Asia", + "QA": "Asia", "SA": "Asia", "SG": "Asia", "KR": "Asia", "LK": "Asia", + "SY": "Asia", "TJ": "Asia", "TH": "Asia", "TL": "Asia", "TR": "Asia", + "TM": "Asia", "AE": "Asia", "UZ": "Asia", "VN": "Asia", "YE": "Asia", + // Europe + "AL": "Europe", "AD": "Europe", "AT": "Europe", "BY": "Europe", "BE": "Europe", + "BA": "Europe", "BG": "Europe", "HR": "Europe", "CZ": "Europe", "DK": "Europe", + "EE": "Europe", "FI": "Europe", "FR": "Europe", "DE": "Europe", "GR": "Europe", + "HU": "Europe", "IS": "Europe", "IE": "Europe", "IT": "Europe", "LV": "Europe", + "LI": "Europe", "LT": "Europe", "LU": "Europe", "MT": "Europe", "MD": "Europe", + "MC": "Europe", "ME": "Europe", "NL": "Europe", "MK": "Europe", "NO": "Europe", + "PL": "Europe", "PT": "Europe", "RO": "Europe", "RU": "Europe", "SM": "Europe", + "RS": "Europe", "SK": "Europe", "SI": "Europe", "ES": "Europe", "SE": "Europe", + "CH": "Europe", "UA": "Europe", "GB": "Europe", "XK": "Europe", "VA": "Europe", + // Americas + "AG": "Americas", "AR": "Americas", "BS": "Americas", "BB": "Americas", "BZ": "Americas", + "BO": "Americas", "BR": "Americas", "CA": "Americas", "CL": "Americas", "CO": "Americas", + "CR": "Americas", "CU": "Americas", "DM": "Americas", "DO": "Americas", "EC": "Americas", + "SV": "Americas", "GD": "Americas", "GT": "Americas", "GY": "Americas", "HT": "Americas", + "HN": "Americas", "JM": "Americas", "MX": "Americas", "NI": "Americas", "PA": "Americas", + "PY": "Americas", "PE": "Americas", "PR": "Americas", "KN": "Americas", "LC": "Americas", + "VC": "Americas", "SR": "Americas", "TT": "Americas", "US": "Americas", "UY": "Americas", + "VE": "Americas", "VG": "Americas", "AI": "Americas", "AW": "Americas", "BM": "Americas", + // Africa + "DZ": "Africa", "AO": "Africa", "BJ": "Africa", "BW": "Africa", "BF": "Africa", + "BI": "Africa", "CM": "Africa", "CF": "Africa", "TD": "Africa", "KM": "Africa", + "CG": "Africa", "CD": "Africa", "CI": "Africa", "DJ": "Africa", "EG": "Africa", + "GQ": "Africa", "ER": "Africa", "SZ": "Africa", "ET": "Africa", "GA": "Africa", + "GM": "Africa", "GH": "Africa", "GN": "Africa", "GW": "Africa", "KE": "Africa", + "LS": "Africa", "LR": "Africa", "LY": "Africa", "MG": "Africa", "MW": "Africa", + "ML": "Africa", "MR": "Africa", "MU": "Africa", "MA": "Africa", "MZ": "Africa", + "NA": "Africa", "NE": "Africa", "NG": "Africa", "RW": "Africa", "ST": "Africa", + "SN": "Africa", "SC": "Africa", "SL": "Africa", "SO": "Africa", "ZA": "Africa", + "SS": "Africa", "SD": "Africa", "TZ": "Africa", "TG": "Africa", "TN": "Africa", + "UG": "Africa", "ZM": "Africa", "ZW": "Africa", + // Pacific + "AU": "Pacific", "FJ": "Pacific", "KI": "Pacific", "MH": "Pacific", "FM": "Pacific", + "NR": "Pacific", "NZ": "Pacific", "PW": "Pacific", "PG": "Pacific", "WS": "Pacific", + "SB": "Pacific", "TO": "Pacific", "TV": "Pacific", "VU": "Pacific", "GU": "Pacific", +} + +// probeTarget 测活拨测目标:公网稳定 HTTP 站点 +const probeHost = "www.gstatic.com" +const probePort = 80 + +// Check 协议感知测活:按 protocols 依次尝试 SOCKS5/SOCKS4/HTTP 握手, +// 纯 TCP 连通不算数——必须说出协议正确的应答才算活。返回拨测耗时。 +func Check(ctx context.Context, ip string, port int, protocols string, timeout time.Duration) (int, error) { + probes := []func(context.Context, string, int, time.Duration) error{ + probeSOCKS5, probeSOCKS4, probeHTTP, + } + upper := strings.ToUpper(protocols) + order := []int{} + for i, name := range []string{"SOCKS5", "SOCKS4", "HTTP"} { + if strings.Contains(upper, name) { + order = append(order, i) + } + } + if len(order) == 0 { + order = []int{0, 1, 2} + } + var lastErr error + for _, i := range order { + start := time.Now() + if err := probes[i](ctx, ip, port, timeout); err == nil { + return int(time.Since(start).Milliseconds()), nil + } else { + lastErr = err + } + } + return 0, lastErr +} + +func dial(ctx context.Context, ip string, port int, timeout time.Duration) (net.Conn, error) { + d := net.Dialer{Timeout: timeout} + return d.DialContext(ctx, "tcp", net.JoinHostPort(ip, fmt.Sprint(port))) +} + +func probeSOCKS5(ctx context.Context, ip string, port int, timeout time.Duration) error { + conn, err := dial(ctx, ip, port, timeout) + if err != nil { + return err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + // greeting: VER=5, 1 method, no-auth + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + return err + } + buf := make([]byte, 2) + if _, err := io.ReadFull(conn, buf); err != nil { + return err + } + if buf[0] != 0x05 || buf[1] == 0xff { + return fmt.Errorf("socks5 greeting rejected: %x", buf) + } + // CONNECT domain:port + req := append([]byte{0x05, 0x01, 0x00, 0x03, byte(len(probeHost))}, []byte(probeHost)...) + req = append(req, byte(probePort>>8), byte(probePort)) + if _, err := conn.Write(req); err != nil { + return err + } + // reply: VER REP RSV ATYP ... — 收到合法的 socks5 应答头即算活 + head := make([]byte, 5) + if _, err := io.ReadFull(conn, head); err != nil { + return err + } + if head[0] != 0x05 { + return fmt.Errorf("bad socks5 reply: %x", head[0]) + } + return nil +} + +func probeSOCKS4(ctx context.Context, ip string, port int, timeout time.Duration) error { + conn, err := dial(ctx, ip, port, timeout) + if err != nil { + return err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + // CONNECT 1.1.1.1:80 + req := []byte{0x04, 0x01, 0x00, 0x50, 1, 1, 1, 1, 0x00} + if _, err := conn.Write(req); err != nil { + return err + } + buf := make([]byte, 8) + if _, err := io.ReadFull(conn, buf); err != nil { + return err + } + if buf[0] != 0x00 && buf[0] != 0x04 { + return fmt.Errorf("bad socks4 reply: %x", buf[0]) + } + return nil +} + +func probeHTTP(ctx context.Context, ip string, port int, timeout time.Duration) error { + conn, err := dial(ctx, ip, port, timeout) + if err != nil { + return err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + req := fmt.Sprintf("GET http://%s/generate_204 HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", probeHost, probeHost) + if _, err := conn.Write([]byte(req)); err != nil { + return err + } + buf := make([]byte, 5) + if _, err := io.ReadFull(conn, buf); err != nil { + return err + } + if string(buf) != "HTTP/" { + return fmt.Errorf("bad http reply: %q", string(buf)) + } + return nil +} + +// ZhName maps an ISO-like country code to a Chinese display name. +func ZhName(code string) string { + if n, ok := zhNames[strings.ToUpper(code)]; ok { + return n + } + return "" +} + +var zhNames = map[string]string{ + "AF": "阿富汗", "AL": "阿尔巴尼亚", "DZ": "阿尔及利亚", "AD": "安道尔", "AO": "安哥拉", + "AG": "安提瓜和巴布达", "AR": "阿根廷", "AM": "亚美尼亚", "AU": "澳大利亚", "AT": "奥地利", + "AZ": "阿塞拜疆", "BS": "巴哈马", "BH": "巴林", "BD": "孟加拉国", "BB": "巴巴多斯", + "BY": "白俄罗斯", "BE": "比利时", "BZ": "伯利兹", "BJ": "贝宁", "BO": "玻利维亚", + "BA": "波黑", "BW": "博茨瓦纳", "BR": "巴西", "BN": "文莱", "BG": "保加利亚", + "BF": "布基纳法索", "BI": "布隆迪", "KH": "柬埔寨", "CM": "喀麦隆", "CA": "加拿大", + "CF": "中非", "TD": "乍得", "CL": "智利", "CN": "中国", "HK": "中国香港", + "MO": "中国澳门", "TW": "中国台湾", "CO": "哥伦比亚", "KM": "科摩罗", "CG": "刚果", + "CD": "刚果(金)", "CR": "哥斯达黎加", "CI": "科特迪瓦", "HR": "克罗地亚", "CU": "古巴", + "CY": "塞浦路斯", "CZ": "捷克", "DK": "丹麦", "DJ": "吉布提", "DM": "多米尼克", + "DO": "多米尼加", "EC": "厄瓜多尔", "EG": "埃及", "SV": "萨尔瓦多", "GQ": "赤道几内亚", + "ER": "厄立特里亚", "EE": "爱沙尼亚", "SZ": "斯威士兰", "ET": "埃塞俄比亚", "FJ": "斐济", + "FI": "芬兰", "FR": "法国", "GA": "加蓬", "GM": "冈比亚", "GE": "格鲁吉亚", + "DE": "德国", "GH": "加纳", "GR": "希腊", "GD": "格林纳达", "GT": "危地马拉", + "GN": "几内亚", "GW": "几内亚比绍", "GY": "圭亚那", "HT": "海地", "HN": "洪都拉斯", + "HU": "匈牙利", "IS": "冰岛", "IN": "印度", "ID": "印度尼西亚", "IR": "伊朗", + "IQ": "伊拉克", "IE": "爱尔兰", "IL": "以色列", "IT": "意大利", "JM": "牙买加", + "JP": "日本", "JO": "约旦", "KZ": "哈萨克斯坦", "KE": "肯尼亚", "KI": "基里巴斯", + "KP": "朝鲜", "KR": "韩国", "KW": "科威特", "KG": "吉尔吉斯斯坦", "LA": "老挝", + "LV": "拉脱维亚", "LB": "黎巴嫩", "LS": "莱索托", "LR": "利比里亚", "LY": "利比亚", + "LI": "列支敦士登", "LT": "立陶宛", "LU": "卢森堡", "MG": "马达加斯加", "MW": "马拉维", + "MY": "马来西亚", "MV": "马尔代夫", "ML": "马里", "MT": "马耳他", "MH": "马绍尔群岛", + "MR": "毛里塔尼亚", "MU": "毛里求斯", "MX": "墨西哥", "FM": "密克罗尼西亚", "MD": "摩尔多瓦", + "MC": "摩纳哥", "MN": "蒙古", "ME": "黑山", "MA": "摩洛哥", "MZ": "莫桑比克", + "MM": "缅甸", "NA": "纳米比亚", "NR": "瑙鲁", "NP": "尼泊尔", "NL": "荷兰", + "NZ": "新西兰", "NI": "尼加拉瓜", "NE": "尼日尔", "NG": "尼日利亚", "MK": "北马其顿", + "NO": "挪威", "OM": "阿曼", "PK": "巴基斯坦", "PW": "帕劳", "PA": "巴拿马", + "PG": "巴布亚新几内亚", "PY": "巴拉圭", "PE": "秘鲁", "PH": "菲律宾", "PL": "波兰", + "PT": "葡萄牙", "QA": "卡塔尔", "RO": "罗马尼亚", "RU": "俄罗斯", "RW": "卢旺达", + "WS": "萨摩亚", "SM": "圣马力诺", "ST": "圣多美和普林西比", "SA": "沙特阿拉伯", "SN": "塞内加尔", + "RS": "塞尔维亚", "SC": "塞舌尔", "SL": "塞拉利昂", "SG": "新加坡", "SK": "斯洛伐克", + "SI": "斯洛文尼亚", "SB": "所罗门群岛", "SO": "索马里", "ZA": "南非", "SS": "南苏丹", + "ES": "西班牙", "LK": "斯里兰卡", "SD": "苏丹", "SR": "苏里南", "SE": "瑞典", + "CH": "瑞士", "SY": "叙利亚", "TJ": "塔吉克斯坦", "TZ": "坦桑尼亚", "TH": "泰国", + "TL": "东帝汶", "TG": "多哥", "TO": "汤加", "TT": "特立尼达和多巴哥", "TN": "突尼斯", + "TR": "土耳其", "TM": "土库曼斯坦", "TV": "图瓦卢", "UG": "乌干达", "UA": "乌克兰", + "AE": "阿联酋", "GB": "英国", "US": "美国", "UY": "乌拉圭", "UZ": "乌兹别克斯坦", + "VU": "瓦努阿图", "VE": "委内瑞拉", "VN": "越南", "YE": "也门", "ZM": "赞比亚", + "ZW": "津巴布韦", "PS": "巴勒斯坦", "XK": "科索沃", "PR": "波多黎各", "VG": "英属维尔京群岛", +} diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go new file mode 100644 index 0000000..8ef57bd --- /dev/null +++ b/backend/internal/service/auth.go @@ -0,0 +1,123 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/cyi-cc/fun" + "golang.org/x/crypto/bcrypt" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// 登录态:24h 滑动过期;距上次续期超过 2h 的请求自动把有效期顺延回 24h +const sessionTTL = 24 * time.Hour +const sessionRenewAfter = 2 * time.Hour + +type AuthSvc struct { + fun.Ctx + Store *store.Store +} + +// Login 用户名+密码 → 签发会话 token(前端存 localStorage,随 state 透传) +func (s *AuthSvc) Login(d dto.LoginDto) (dto.SessionView, error) { + name := strings.TrimSpace(d.Name) + if name == "" || d.Password == "" { + return dto.SessionView{}, fun.Error(4001, "请输入用户名和密码") + } + user, err := s.Store.GetUserByName(name) + if errors.Is(err, store.ErrNotFound) { + return dto.SessionView{}, fun.Error(4001, "用户名或密码错误") + } + if err != nil { + return dto.SessionView{}, fmt.Errorf("auth: query user failed: %w", err) + } + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(d.Password)) != nil { + return dto.SessionView{}, fun.Error(4001, "用户名或密码错误") + } + if user.Status != "active" { + return dto.SessionView{}, fun.Error(4003, "账号已被禁用") + } + token, err := newToken() + if err != nil { + return dto.SessionView{}, err + } + if err := s.Store.CreateSession(token, user.Id, sessionTTL); err != nil { + return dto.SessionView{}, fmt.Errorf("auth: create session failed: %w", err) + } + log.Printf("auth: user %q logged in from %s", user.Name, s.Ip) + view := userView(user) + return dto.SessionView{User: &view, Token: &token}, nil +} + +// Me 返回当前登录用户(不重复签发 token) +func (s *AuthSvc) Me() (dto.SessionView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.SessionView{}, err + } + view := userView(su.User) + return dto.SessionView{User: &view}, nil +} + +// Logout 幂等:删掉当前会话即可 +func (s *AuthSvc) Logout() (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{Message: "已退出"}, nil + } + if err := s.Store.DeleteSession(su.Token); err != nil { + return dto.OkView{}, fmt.Errorf("auth: logout failed: %w", err) + } + return dto.OkView{Message: "已退出"}, nil +} + +// ChangePassword 校验旧密码 → 改密 → 吊销其他会话(当前会话保留) +func (s *AuthSvc) ChangePassword(d dto.ChangePasswordDto) (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{}, err + } + if len(d.NewPassword) < 6 { + return dto.OkView{}, fun.Error(4001, "新密码至少 6 位") + } + if bcrypt.CompareHashAndPassword([]byte(su.User.PasswordHash), []byte(d.OldPassword)) != nil { + return dto.OkView{}, fun.Error(4001, "旧密码错误") + } + hash, err := bcrypt.GenerateFromPassword([]byte(d.NewPassword), bcrypt.DefaultCost) + if err != nil { + return dto.OkView{}, err + } + h := string(hash) + if err := s.Store.UpdateUser(su.User.Id, su.User.Name, su.User.Role, su.User.Status, &h); err != nil { + return dto.OkView{}, fmt.Errorf("auth: update password failed: %w", err) + } + if err := s.Store.DeleteOtherSessions(su.User.Id, su.Token); err != nil { + return dto.OkView{}, fmt.Errorf("auth: revoke sessions failed: %w", err) + } + return dto.OkView{Message: "密码已修改"}, nil +} + +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func userView(u store.User) dto.UserView { + return dto.UserView{ + Id: u.Id, + Name: u.Name, + Role: u.Role, + Status: u.Status, + CreatedAt: u.CreatedAt, + } +} diff --git a/backend/internal/service/auth_guard.go b/backend/internal/service/auth_guard.go new file mode 100644 index 0000000..e7742eb --- /dev/null +++ b/backend/internal/service/auth_guard.go @@ -0,0 +1,152 @@ +package service + +import ( + "errors" + + "github.com/cyi-cc/fun" + "github.com/valyala/fasthttp" + + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// 认证统一业务错误码:前端响应拦截器据此清理登录态并跳登录页 +const authErrCode = 4011 + +type authPolicy uint8 + +const ( + policyDeny authPolicy = iota + policyPublic + policyAuthenticated + policyAdmin +) + +// endpointPolicies 显式端点策略表:缺省拒绝,新端点必须登记 +var endpointPolicies = map[string]authPolicy{ + "AuthSvc.Login": policyPublic, + "AuthSvc.Me": policyAuthenticated, + "AuthSvc.Logout": policyAuthenticated, + "AuthSvc.ChangePassword": policyAuthenticated, + "UserSvc.ListUsers": policyAdmin, + "UserSvc.SaveUser": policyAdmin, + "UserSvc.DeleteUser": policyAdmin, + "DashboardSvc.Stats": policyAuthenticated, + "ToolSvc.List": policyAuthenticated, + "ToolSvc.AdminList": policyAdmin, + "ToolSvc.SaveTool": policyAdmin, + "ToolSvc.DeleteTool": policyAdmin, + "VaultSvc.List": policyAuthenticated, + "VaultSvc.Save": policyAuthenticated, + "VaultSvc.Reveal": policyAuthenticated, + "VaultSvc.Totp": policyAuthenticated, + "VaultSvc.Delete": policyAuthenticated, + "BookmarkSvc.List": policyAuthenticated, + "BookmarkSvc.Save": policyAuthenticated, + "BookmarkSvc.Delete": policyAuthenticated, + "ServerSvc.List": policyAdmin, + "ServerSvc.Save": policyAdmin, + "ServerSvc.Delete": policyAdmin, + "ServerSvc.Metrics": policyAdmin, + "ServerSvc.SftpList": policyAdmin, + "ServerSvc.SftpMkdir": policyAdmin, + "ServerSvc.SftpRemove": policyAdmin, + "ServerSvc.SftpRename": policyAdmin, + "DbMgrSvc.List": policyAdmin, + "DbMgrSvc.Save": policyAdmin, + "DbMgrSvc.Delete": policyAdmin, + "DbMgrSvc.Test": policyAdmin, + "DbMgrSvc.Databases": policyAdmin, + "DbMgrSvc.Tables": policyAdmin, + "DbMgrSvc.Query": policyAdmin, + "WebSvc.Fetch": policyAuthenticated, + "DiskSvc.List": policyAuthenticated, + "DiskSvc.DeleteFile": policyAuthenticated, + "DiskSvc.CreateShare": policyAuthenticated, + "DiskSvc.ListShares": policyAuthenticated, + "DiskSvc.DeleteShare": policyAuthenticated, + "ProxySvc.List": policyAuthenticated, + "ProxySvc.Regions": policyAuthenticated, + "ProxySvc.Syncs": policyAdmin, + "ProxySvc.Stats": policyAuthenticated, + "ProxySvc.SyncNow": policyAdmin, + "ProxySvc.CheckNow": policyAdmin, + "ProxySvc.ListKeys": policyAdmin, + "ProxySvc.CreateKey": policyAdmin, + "ProxySvc.DeleteKey": policyAdmin, + "SettingSvc.Get": policyAuthenticated, + "SettingSvc.Set": policyAdmin, + "PaySvc.List": policyAdmin, + "PaySvc.Stats": policyAdmin, + "PaySvc.UpstreamStatus": policyAdmin, + "PaySvc.SaveUpstream": policyAdmin, + "PaySvc.TestPay": policyAdmin, + "PaySvc.CashierInfo": policyPublic, + "PaySvc.CashierStatus": policyPublic, + "PaySvc.Watch": policyPublic, + "LicenseSvc.Apps": policyAdmin, + "LicenseSvc.SaveApp": policyAdmin, + "LicenseSvc.DeleteApp": policyAdmin, + "LicenseSvc.List": policyAdmin, + "LicenseSvc.Stats": policyAdmin, + "LicenseSvc.Generate": policyAdmin, + "LicenseSvc.Delete": policyAdmin, + "MemorySvc.Projects": policyAdmin, + "MemorySvc.List": policyAdmin, + "MemorySvc.Get": policyAdmin, + "MemorySvc.Save": policyAdmin, + "MemorySvc.Delete": policyAdmin, + "MemorySvc.McpInfo": policyAdmin, + "MemorySvc.ResetToken": policyAdmin, +} + +// AuthGuard 唯一 RPC 鉴权边界:token 由前端请求拦截器写入 ctx.State["token"], +// SQLite 会话表校验有效性,命中后用户经 SetUserValue 传给服务层 +type AuthGuard struct { + Store *store.Store +} + +func (g *AuthGuard) Guard(ctx fun.Ctx) error { + policy := endpointPolicies[ctx.ServiceName+"."+ctx.MethodName] + switch policy { + case policyPublic: + return nil + case policyDeny: + return fun.Error(4003, "接口未登记鉴权策略") + } + if ctx.RequestCtx == nil { + return errors.New("auth guard: missing request context") + } + token := ctx.State["token"] + if token == "" { + return fun.Error(authErrCode, "请先登录") + } + su, err := g.Store.GetSessionUser(token, sessionTTL, sessionRenewAfter) + if errors.Is(err, store.ErrNotFound) { + return fun.Error(authErrCode, "登录已失效,请重新登录") + } + if err != nil { + return err + } + if su.User.Status != "active" { + return fun.Error(4003, "账号已被禁用") + } + if policy == policyAdmin && su.User.Role != "admin" { + return fun.Error(4003, "仅管理员可访问") + } + ctx.RequestCtx.SetUserValue(sessionKey{}, su) + return nil +} + +type sessionKey struct{} + +// sessionFrom 取 Guard 校验过的会话;策略表保证进入服务层时必然已设置 +func sessionFrom(ctx *fasthttp.RequestCtx) (store.SessionUser, error) { + if ctx == nil { + return store.SessionUser{}, errors.New("auth session: missing request context") + } + su, ok := ctx.UserValue(sessionKey{}).(store.SessionUser) + if !ok || su.User.Id == 0 { + return store.SessionUser{}, errors.New("auth session: validated session missing") + } + return su, nil +} diff --git a/backend/internal/service/bookmark.go b/backend/internal/service/bookmark.go new file mode 100644 index 0000000..cd42d29 --- /dev/null +++ b/backend/internal/service/bookmark.go @@ -0,0 +1,91 @@ +package service + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// BookmarkSvc 个人书签:列表驱动侧边栏快捷入口与书签页 +type BookmarkSvc struct { + fun.Ctx + Store *store.Store +} + +func (s *BookmarkSvc) List() ([]dto.BookmarkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return nil, err + } + items, err := s.Store.ListBookmarks(su.User.Id) + if err != nil { + return nil, fmt.Errorf("bookmark: list failed: %w", err) + } + views := make([]dto.BookmarkView, 0, len(items)) + for _, b := range items { + views = append(views, bookmarkView(b)) + } + return views, nil +} + +func (s *BookmarkSvc) Save(d dto.SaveBookmarkDto) (dto.BookmarkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.BookmarkView{}, err + } + title := strings.TrimSpace(d.Title) + raw := strings.TrimSpace(d.Url) + if title == "" || len(title) > 64 { + return dto.BookmarkView{}, fun.Error(4001, "名称必填且不超过 64 字符") + } + if raw != "" && !strings.Contains(raw, "://") { + raw = "https://" + raw // 裸域名自动补协议 + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" || + (u.Scheme != "http" && u.Scheme != "https") { + return dto.BookmarkView{}, fun.Error(4001, "网址无效,需 http(s) 地址") + } + + b := store.Bookmark{UserID: su.User.Id, Title: title, Url: raw} + if d.Id != nil { + b.Id = *d.Id + } + if d.Icon != nil { + b.Icon = strings.TrimSpace(*d.Icon) + } + if d.Sort != nil { + b.Sort = *d.Sort + } + if err := s.Store.SaveBookmark(&b); err != nil { + if errors.Is(err, store.ErrNotFound) { + return dto.BookmarkView{}, fun.Error(4001, "书签不存在") + } + return dto.BookmarkView{}, fmt.Errorf("bookmark: save failed: %w", err) + } + return bookmarkView(b), nil +} + +func (s *BookmarkSvc) Delete(d dto.BookmarkIdDto) (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{}, err + } + if err := s.Store.DeleteBookmark(su.User.Id, d.Id); err != nil { + if errors.Is(err, store.ErrNotFound) { + return dto.OkView{}, fun.Error(4001, "书签不存在") + } + return dto.OkView{}, fmt.Errorf("bookmark: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +func bookmarkView(b store.Bookmark) dto.BookmarkView { + return dto.BookmarkView{Id: b.Id, Title: b.Title, Url: b.Url, Icon: b.Icon, Sort: b.Sort} +} diff --git a/backend/internal/service/dashboard.go b/backend/internal/service/dashboard.go new file mode 100644 index 0000000..4e59fe4 --- /dev/null +++ b/backend/internal/service/dashboard.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/db" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +var bootTime = time.Now() + +type DashboardSvc struct { + fun.Ctx + Store *store.Store +} + +// Stats 仪表盘聚合:会话概况 + 各业务模块计数 + 收入趋势 + 最近动态 +func (s *DashboardSvc) Stats() (dto.StatsView, error) { + ctx := context.Background() + q := s.Store.Q() + now := time.Now() + view := dto.StatsView{UptimeSeconds: int64(time.Since(bootTime).Seconds())} + + // 会话概况 + var err error + if view.TotalUsers, err = s.Store.CountUsers(); err != nil { + return view, fmt.Errorf("dashboard: count users failed: %w", err) + } + if view.ActiveSessions, err = s.Store.CountActiveSessions(); err != nil { + return view, fmt.Errorf("dashboard: count sessions failed: %w", err) + } + if view.TodayLogins, err = s.Store.CountTodayLogins(); err != nil { + return view, fmt.Errorf("dashboard: count today logins failed: %w", err) + } + const days = 14 + trend, err := s.Store.LoginTrend(days) + if err != nil { + return view, fmt.Errorf("dashboard: login trend failed: %w", err) + } + view.LoginTrend = make([]dto.TrendPoint, 0, days) + for i := days - 1; i >= 0; i-- { + day := now.AddDate(0, 0, -int(i)).Format("2006-01-02") + view.LoginTrend = append(view.LoginTrend, dto.TrendPoint{Day: day, Count: trend[day]}) + } + + // 业务模块计数 + view.Servers, _ = s.Store.CountServers() + view.Proxies, _ = q.CountProxiesAll(ctx) + view.AliveProxies, _ = s.Store.CountAliveProxies() + view.LicenseApps, _ = q.CountLicenseApps(ctx) + view.Dbconns, _ = q.CountDbconns(ctx) + view.Files, _ = q.CountFilesAll(ctx) + view.Memories, _ = q.CountMemories(ctx) + if ps, err := s.Store.MemoryProjects(); err == nil { + view.MemoryProjects = int64(len(ps)) + } + if ls, err := q.LicenseCardStatsAll(ctx, now.Unix()); err == nil { + view.LicenseCards, view.LicenseActive = ls.Total, anyI64(ls.Active) + } + if su, err := sessionFrom(s.RequestCtx); err == nil { + view.Bookmarks, _ = q.CountBookmarksByUser(ctx, su.User.Id) + view.VaultItems, _ = q.CountVaultByUser(ctx, su.User.Id) + } + + // 支付:今日 + 累计 + 14 天收入趋势 + 最近成交 + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix() + if st, err := q.PayOrderStats(ctx, dayStart); err == nil { + view.TodayOrders, view.TodayMoney = st.Cnt, anyI64(st.Money) + } + if st, err := q.PayOrderStats(ctx, 0); err == nil { + view.TotalMoney, view.PendingOrders = anyI64(st.Money), anyI64(st.Pending) + } + daily := map[string]db.PayDailyPaidRow{} + if rows, err := q.PayDailyPaid(ctx, dayStart-int64((days-1)*86400)); err == nil { + for _, r := range rows { + daily[r.Day] = r + } + } + view.PayTrend = make([]dto.PayTrendPoint, 0, days) + for i := days - 1; i >= 0; i-- { + t := now.AddDate(0, 0, -int(i)) + p := dto.PayTrendPoint{Day: t.Format("2006-01-02")} + if r, ok := daily[p.Day]; ok { + p.Money, p.Count = anyI64(r.Money), r.Cnt + } + view.PayTrend = append(view.PayTrend, p) + } + if rows, err := q.RecentPaidOrders(ctx, 6); err == nil { + view.RecentOrders = make([]dto.RecentOrderView, 0, len(rows)) + for _, r := range rows { + subject := r.Subject + if subject == "" { + subject = r.TradeNo + } + view.RecentOrders = append(view.RecentOrders, dto.RecentOrderView{ + TradeNo: r.TradeNo, Subject: subject, Money: r.Money, PaidAt: r.PaidAt, + }) + } + } + if ms, err := s.Store.ListMemories("", "", 5); err == nil { + view.RecentMemories = make([]dto.RecentMemoryView, 0, len(ms)) + for _, m := range ms { + view.RecentMemories = append(view.RecentMemories, dto.RecentMemoryView{ + Key: m.Key, Project: m.Project, UpdatedAt: m.UpdatedAt, + }) + } + } + return view, nil +} diff --git a/backend/internal/service/dbm.go b/backend/internal/service/dbm.go new file mode 100644 index 0000000..df078ff --- /dev/null +++ b/backend/internal/service/dbm.go @@ -0,0 +1,242 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/dbx" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" + "github.com/cyi-cc/cyi-box/backend/internal/vault" +) + +// DbMgrSvc 通用数据库管理器:连接密码 AES-GCM 落库,全部端点仅管理员; +// 查询固定 20s 超时 + 行数封顶,防止一条烂 SQL 拖死进程 +type DbMgrSvc struct { + fun.Ctx + Store *store.Store + Cfg *config.Config `fun:"auto"` +} + +func (s *DbMgrSvc) List() ([]dto.DbconnView, error) { + items, err := s.Store.ListDbconns() + if err != nil { + return nil, fmt.Errorf("dbm: list failed: %w", err) + } + views := make([]dto.DbconnView, 0, len(items)) + for _, e := range items { + views = append(views, dbconnView(e)) + } + return views, nil +} + +func (s *DbMgrSvc) Save(d dto.SaveDbconnDto) (dto.DbconnView, error) { + name := strings.TrimSpace(d.Name) + engine := strings.TrimSpace(d.Engine) + switch engine { + case "mysql", "postgres", "sqlite": + default: + return dto.DbconnView{}, fun.Error(4001, "引擎只支持 mysql / postgres / sqlite") + } + if name == "" || len(name) > 64 { + return dto.DbconnView{}, fun.Error(4001, "名称必填且不超过 64 字符") + } + + e := store.Dbconn{Name: name, Engine: engine} + if d.Id != nil { + old, err := s.Store.GetDbconn(*d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.DbconnView{}, fun.Error(4001, "连接不存在") + } + if err != nil { + return dto.DbconnView{}, err + } + e = old + e.Name, e.Engine = name, engine + } + if d.Host != nil { + e.Host = strings.TrimSpace(*d.Host) + } + if d.Port != nil { + e.Port = *d.Port + } + if d.Username != nil { + e.Username = strings.TrimSpace(*d.Username) + } + if d.Database != nil { + e.Database = strings.TrimSpace(*d.Database) + } + if d.Params != nil { + e.Params = strings.TrimSpace(*d.Params) + } + if d.Password != nil { + pw := *d.Password + if pw == "" { + e.PasswordEnc = "" + } else { + enc, err := vault.Encrypt(s.Cfg.VaultKey, pw) + if err != nil { + return dto.DbconnView{}, err + } + e.PasswordEnc = enc + } + } + if e.Engine != "sqlite" && e.Host == "" { + return dto.DbconnView{}, fun.Error(4001, "主机地址必填") + } + if e.Engine == "sqlite" && e.Database == "" { + return dto.DbconnView{}, fun.Error(4001, "sqlite 需在「数据库」填文件路径") + } + if err := s.Store.SaveDbconn(&e); err != nil { + return dto.DbconnView{}, fmt.Errorf("dbm: save failed: %w", err) + } + return dbconnView(e), nil +} + +func (s *DbMgrSvc) Delete(d dto.DbconnIdDto) (dto.OkView, error) { + if err := s.Store.DeleteDbconn(d.Id); err != nil { + if errors.Is(err, store.ErrNotFound) { + return dto.OkView{}, fun.Error(4001, "连接不存在") + } + return dto.OkView{}, fmt.Errorf("dbm: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +// openConn 解密码 + 建连 + ping +func openConn(st *store.Store, key []byte, id int64, database string) (*sql.DB, store.Dbconn, error) { + e, err := st.GetDbconn(id) + if errors.Is(err, store.ErrNotFound) { + return nil, e, fun.Error(4001, "连接不存在") + } + if err != nil { + return nil, e, err + } + pw := "" + if e.PasswordEnc != "" { + pw, err = vault.Decrypt(key, e.PasswordEnc) + if err != nil { + return nil, e, fmt.Errorf("dbm: decrypt password failed: %w", err) + } + } + d, err := dbx.Open(dbx.Conn{ + Engine: e.Engine, Host: e.Host, Port: e.Port, Username: e.Username, + Password: pw, Database: e.Database, Params: e.Params, + }, database) + return d, e, err +} + +func (s *DbMgrSvc) open(id int64, database string) (*sql.DB, store.Dbconn, error) { + return openConn(s.Store, s.Cfg.VaultKey, id, database) +} + +// Test 连通性测试 +func (s *DbMgrSvc) Test(d dto.DbconnIdDto) (dto.OkView, error) { + c, _, err := s.open(d.Id, "") + if err != nil { + return dto.OkView{}, fun.Error(4001, err.Error()) + } + defer c.Close() + return dto.OkView{Message: "连接成功"}, nil +} + +func (s *DbMgrSvc) Databases(d dto.DbScopeDto) (dto.DbDatabasesView, error) { + c, e, err := s.open(d.Id, "") + if err != nil { + return dto.DbDatabasesView{}, err + } + defer c.Close() + names, err := dbx.Databases(c, e.Engine) + if err != nil { + return dto.DbDatabasesView{}, fmt.Errorf("dbm: list databases failed: %w", err) + } + return dto.DbDatabasesView{Names: names}, nil +} + +func (s *DbMgrSvc) Tables(d dto.DbScopeDto) (dto.DbTablesView, error) { + db := "" + if d.Database != nil { + db = *d.Database + } + c, e, err := s.open(d.Id, db) + if err != nil { + return dto.DbTablesView{}, err + } + defer c.Close() + tables, cols, err := dbx.TablesAndColumns(c, e.Engine, db) + if err != nil { + return dto.DbTablesView{}, fmt.Errorf("dbm: list tables failed: %w", err) + } + v := dto.DbTablesView{Tables: []dto.DbTableView{}, Columns: []dto.DbColumnView{}} + for _, t := range tables { + v.Tables = append(v.Tables, dto.DbTableView{Name: t.Name, Type: t.Type}) + } + for _, cl := range cols { + v.Columns = append(v.Columns, dto.DbColumnView{ + Table: cl.Table, Name: cl.Name, Type: cl.Type, Pk: cl.Pk, + }) + } + return v, nil +} + +const dbQueryTimeout = 20 * time.Second + +// Query 执行单条 SQL:SELECT 回结果集(JSON 行串),其余回 affected rows +func (s *DbMgrSvc) Query(d dto.DbQueryDto) (dto.DbQueryView, error) { + sqlText := strings.TrimSpace(d.Sql) + if sqlText == "" { + return dto.DbQueryView{}, fun.Error(4001, "SQL 不能为空") + } + db := "" + if d.Database != nil { + db = *d.Database + } + c, _, err := s.open(d.Id, db) + if err != nil { + return dto.DbQueryView{}, err + } + defer c.Close() + + maxRows := int64(500) + if d.MaxRows != nil && *d.MaxRows > 0 { + maxRows = *d.MaxRows + } + ctx, cancel := context.WithTimeout(context.Background(), dbQueryTimeout) + defer cancel() + res, err := dbx.Query(ctx, c, sqlText, maxRows) + if err != nil { + return dto.DbQueryView{}, fun.Error(4002, "执行失败:"+err.Error()) + } + rowsJson, _ := json.Marshal(res.Rows) + return dto.DbQueryView{ + IsSelect: res.IsSelect, + Columns: res.Columns, + RowsJson: string(rowsJson), + RowCount: res.RowCount, + AffectedRows: res.AffectedRows, + Truncated: res.Truncated, + ElapsedMs: res.ElapsedMs, + }, nil +} + +func dbconnView(e store.Dbconn) dto.DbconnView { + return dto.DbconnView{ + Id: e.Id, + Name: e.Name, + Engine: e.Engine, + Host: e.Host, + Port: e.Port, + Username: e.Username, + Database: e.Database, + HasPassword: e.PasswordEnc != "", + CreatedAt: e.CreatedAt, + } +} diff --git a/backend/internal/service/dbm_routes.go b/backend/internal/service/dbm_routes.go new file mode 100644 index 0000000..3f55ea7 --- /dev/null +++ b/backend/internal/service/dbm_routes.go @@ -0,0 +1,394 @@ +package service + +import ( + "bytes" + "context" + "database/sql" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/cyi-cc/fun" + "github.com/xuri/excelize/v2" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/dbx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +const exportRowCap = 50000 + +// DbExportHandler POST /dbm/export 表单字段:id, db, sql, format(json|csv|xlsx) +func DbExportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + if err := routeAdmin(st, ctx); err != nil { + ctx.SetBodyString(`{"status":2,"msg":"未授权"}`) + return nil + } + id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64) + if id == 0 { + id, _ = strconv.ParseInt(rc.Data["id"], 10, 64) + } + dbName := rc.Data["db"] + sqlText := strings.TrimSpace(rc.Data["sql"]) + format := rc.Data["format"] + if format == "" { + format = "json" + } + if id == 0 || sqlText == "" { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`) + return nil + } + c, _, err := openConn(st, cfg.VaultKey, id, dbName) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString("连接失败:" + err.Error()) + return nil + } + defer c.Close() + + qctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + res, err := dbx.Query(qctx, c, sqlText, exportRowCap) + if err != nil { + ctx.SetStatusCode(500) + ctx.SetBodyString("执行失败:" + err.Error()) + return nil + } + if !res.IsSelect { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"非查询语句没有可导出的结果集"}`) + return nil + } + + var body []byte + var ext, mime string + switch format { + case "xlsx": + body, err = toXLSX(res) + ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case "csv": + body, err = toCSV(res) + ext, mime = "csv", "text/csv; charset=utf-8" + default: + ext, mime = "json", "application/json" + body, err = toJSONRows(res) + } + if err != nil { + ctx.SetStatusCode(500) + ctx.SetBodyString("导出失败:" + err.Error()) + return nil + } + fname := fmt.Sprintf("export_%d.%s", time.Now().Unix(), ext) + ctx.Response.Header.Set("Content-Disposition", "attachment; filename=\""+fname+"\"") + ctx.SetContentType(mime) + ctx.SetBody(body) + return nil + } +} + +func toJSONRows(res *dbx.Result) ([]byte, error) { + list := make([]map[string]any, 0, len(res.Rows)) + for _, row := range res.Rows { + obj := map[string]any{} + for i, col := range res.Columns { + obj[col] = row[i] + } + list = append(list, obj) + } + return json.MarshalIndent(list, "", " ") +} + +func toCSV(res *dbx.Result) ([]byte, error) { + var buf bytes.Buffer + buf.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM,Excel 打开不乱码 + w := csv.NewWriter(&buf) + if err := w.Write(res.Columns); err != nil { + return nil, err + } + for _, row := range res.Rows { + rec := make([]string, len(row)) + for i, v := range row { + rec[i] = cellText(v) + } + if err := w.Write(rec); err != nil { + return nil, err + } + } + w.Flush() + return buf.Bytes(), w.Error() +} + +func toXLSX(res *dbx.Result) ([]byte, error) { + f := excelize.NewFile() + defer f.Close() + sheet := "Sheet1" + for i, c := range res.Columns { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + if err := f.SetCellValue(sheet, cell, c); err != nil { + return nil, err + } + } + for ri, row := range res.Rows { + for ci, v := range row { + cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2) + if v == nil { + continue + } + if err := f.SetCellValue(sheet, cell, v); err != nil { + return nil, err + } + } + } + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func cellText(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + if t { + return "1" + } + return "0" + default: + return fmt.Sprint(t) + } +} + +// DbImportHandler POST /dbm/import multipart:file + 字段 id/db/table; +// 按扩展名解析 json(对象数组) / csv / xlsx(首行为表头),列名与表列交集后批量插入 +func DbImportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + if err := routeAdmin(st, ctx); err != nil { + ctx.SetBodyString(`{"status":2,"msg":"未授权"}`) + return nil + } + id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64) + dbName := string(ctx.QueryArgs().Peek("db")) + table := strings.TrimSpace(string(ctx.QueryArgs().Peek("table"))) + if id == 0 || table == "" { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`) + return nil + } + fh, err := ctx.FormFile("file") + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`) + return nil + } + src, err := fh.Open() + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`) + return nil + } + defer src.Close() + raw, err := io.ReadAll(io.LimitReader(src, 64<<20)) + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"读取文件失败"}`) + return nil + } + rows, err := parseImport(strings.ToLower(fh.Filename), raw) + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"` + err.Error() + `"}`) + return nil + } + if len(rows) == 0 { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"文件里没有数据行"}`) + return nil + } + + c, e, err := openConn(st, cfg.VaultKey, id, dbName) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString(`{"status":2,"msg":"连接失败"}`) + return nil + } + defer c.Close() + + // 表名校验:必须在 introspection 列表里,顺便拿到列清单做交集 + tables, cols, err := dbx.TablesAndColumns(c, e.Engine, dbName) + if err != nil { + ctx.SetStatusCode(500) + ctx.SetBodyString(`{"status":2,"msg":"读表结构失败"}`) + return nil + } + validTable := false + tableCols := map[string]bool{} + for _, t := range tables { + if t.Name == table { + validTable = true + } + } + for _, cl := range cols { + if cl.Table == table { + tableCols[cl.Name] = true + } + } + if !validTable { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"表不存在"}`) + return nil + } + + // 列名 = 文件表头 ∩ 表列 + fileCols := orderedKeys(rows) + useCols := []string{} + for _, k := range fileCols { + if tableCols[k] { + useCols = append(useCols, k) + } + } + if len(useCols) == 0 { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"文件列名与表列没有交集"}`) + return nil + } + + inserted, skipped, err := bulkInsert(c, e.Engine, table, useCols, rows) + if err != nil { + ctx.SetStatusCode(500) + ctx.SetBodyString(`{"status":2,"msg":"导入失败:` + err.Error() + `"}`) + return nil + } + ctx.SetContentType("application/json") + fmt.Fprintf(ctx, `{"status":0,"inserted":%d,"skipped":%d}`, inserted, skipped) + return nil + } +} + +// bulkInsert 逐行插入(不开事务:PG 事务内单行失败会整批回滚, +// 逐行保证「能导多少导多少」,跳过坏行计数返回)。单次最多 1 万行。 +func bulkInsert(d *sql.DB, engine, table string, cols []string, rows []map[string]any) (int64, int64, error) { + if len(rows) > 10000 { + rows = rows[:10000] + } + quote := func(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } + if engine == "mysql" { + quote = func(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" } + } + quoted := make([]string, len(cols)) + placeholders := make([]string, len(cols)) + for i, c := range cols { + quoted[i] = quote(c) + if engine == "postgres" { + placeholders[i] = "$" + strconv.Itoa(i+1) + } else { + placeholders[i] = "?" + } + } + stmt := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + quote(table), strings.Join(quoted, ","), strings.Join(placeholders, ",")) + ps, err := d.Prepare(stmt) + if err != nil { + return 0, 0, err + } + defer ps.Close() + var inserted, skipped int64 + for _, r := range rows { + vals := make([]any, len(cols)) + for i, c := range cols { + vals[i] = r[c] + } + if _, err := ps.Exec(vals...); err != nil { + skipped++ + continue + } + inserted++ + } + return inserted, skipped, nil +} + +// parseImport 按扩展名分派:.json 对象数组 / .xlsx 首行表头 / 其余按 CSV(兼容 BOM) +func parseImport(filename string, raw []byte) ([]map[string]any, error) { + switch { + case strings.HasSuffix(filename, ".json"): + var rows []map[string]any + if err := json.Unmarshal(raw, &rows); err != nil { + return nil, fmt.Errorf("JSON 解析失败:文件应为对象数组 [{...}, ...]") + } + return rows, nil + case strings.HasSuffix(filename, ".xlsx"): + f, err := excelize.OpenReader(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("Excel 解析失败") + } + defer f.Close() + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("Excel 文件没有工作表") + } + recs, err := f.GetRows(sheets[0]) + if err != nil { + return nil, fmt.Errorf("读取工作表失败") + } + return rowsFromTable(recs), nil + default: + r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF}))) + r.FieldsPerRecord = -1 // 容忍长短不一的行 + recs, err := r.ReadAll() + if err != nil { + return nil, fmt.Errorf("CSV 解析失败") + } + return rowsFromTable(recs), nil + } +} + +// rowsFromTable 二维表(首行表头)→ 对象数组 +func rowsFromTable(recs [][]string) []map[string]any { + if len(recs) < 2 { + return nil + } + head := recs[0] + out := []map[string]any{} + for _, rec := range recs[1:] { + m := map[string]any{} + for i, h := range head { + h = strings.TrimSpace(h) + if h == "" { + continue + } + if i < len(rec) { + m[h] = rec[i] + } + } + out = append(out, m) + } + return out +} + +// orderedKeys 取文件列名(按首行出现顺序) +func orderedKeys(rows []map[string]any) []string { + seen := map[string]bool{} + out := []string{} + for _, r := range rows { + for k := range r { + if !seen[k] { + seen[k] = true + out = append(out, k) + } + } + } + return out +} diff --git a/backend/internal/service/disk.go b/backend/internal/service/disk.go new file mode 100644 index 0000000..8775124 --- /dev/null +++ b/backend/internal/service/disk.go @@ -0,0 +1,163 @@ +package service + +import ( + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +const shareCodeLen = 10 +const shareAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// DiskSvc 网盘:文件列表/删除/分享链接管理(按用户隔离,属主校验) +type DiskSvc struct { + fun.Ctx + Store *store.Store + Cfg *config.Config +} + +func shareCode() (string, error) { + buf := make([]byte, shareCodeLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i := range buf { + buf[i] = shareAlphabet[int(buf[i])%len(shareAlphabet)] + } + return string(buf), nil +} + +func fileViews(files []store.File) []dto.FileView { + out := make([]dto.FileView, 0, len(files)) + for _, f := range files { + out = append(out, dto.FileView{ + Id: f.Id, Name: f.Name, Size: f.Size, Mime: f.Mime, CreatedAt: f.CreatedAt, + }) + } + return out +} + +func shareViews(shares []store.Share) []dto.ShareView { + now := time.Now().Unix() + out := make([]dto.ShareView, 0, len(shares)) + for _, s := range shares { + out = append(out, dto.ShareView{ + Id: s.Id, Code: s.Code, FileName: s.FileName, + ExpiresAt: s.ExpiresAt, Expired: s.ExpiresAt > 0 && now > s.ExpiresAt, + Downloads: s.Downloads, CreatedAt: s.CreatedAt, + }) + } + return out +} + +func (s *DiskSvc) List() ([]dto.FileView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return nil, err + } + files, err := s.Store.ListFiles(su.User.Id) + if err != nil { + return nil, fmt.Errorf("disk: list failed: %w", err) + } + return fileViews(files), nil +} + +// DeleteFile 删文件:库记录 + 磁盘文件 + 关联分享一起清 +func (s *DiskSvc) DeleteFile(d dto.IdDto) (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{}, err + } + f, err := s.Store.GetFile(d.Id) + if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id { + return dto.OkView{}, fun.Error(4004, "文件不存在") + } + if err != nil { + return dto.OkView{}, err + } + if _, err := s.Store.DeleteFile(d.Id, su.User.Id); err != nil { + return dto.OkView{}, fmt.Errorf("disk: delete failed: %w", err) + } + _ = s.Store.DeleteSharesByFile(d.Id) + _ = os.Remove(filepath.Join(s.Cfg.DiskDir, f.Stored)) + return dto.OkView{Message: "已删除"}, nil +} + +// CreateShare ExpireMinutes=0 永久;>0 限时分享 +func (s *DiskSvc) CreateShare(d dto.CreateShareDto) (dto.ShareView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.ShareView{}, err + } + f, err := s.Store.GetFile(d.FileId) + if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id { + return dto.ShareView{}, fun.Error(4004, "文件不存在") + } + if err != nil { + return dto.ShareView{}, err + } + if d.ExpireMinutes < 0 || d.ExpireMinutes > 60*24*365 { + return dto.ShareView{}, fun.Error(4001, "有效期不合法") + } + code, err := shareCode() + if err != nil { + return dto.ShareView{}, err + } + var expires int64 + if d.ExpireMinutes > 0 { + expires = time.Now().Add(time.Duration(d.ExpireMinutes) * time.Minute).Unix() + } + sh := store.Share{ + Code: code, FileID: f.Id, UserID: su.User.Id, + FileName: f.Name, ExpiresAt: expires, + } + if err := s.Store.InsertShare(&sh); err != nil { + return dto.ShareView{}, fmt.Errorf("disk: create share failed: %w", err) + } + return shareViews([]store.Share{sh})[0], nil +} + +func (s *DiskSvc) ListShares() ([]dto.ShareView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return nil, err + } + shares, err := s.Store.ListShares(su.User.Id) + if err != nil { + return nil, fmt.Errorf("disk: list shares failed: %w", err) + } + return shareViews(shares), nil +} + +func (s *DiskSvc) DeleteShare(d dto.IdDto) (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{}, err + } + n, err := s.Store.DeleteShare(d.Id, su.User.Id) + if err != nil { + return dto.OkView{}, fmt.Errorf("disk: delete share failed: %w", err) + } + if n == 0 { + return dto.OkView{}, fun.Error(4004, "分享不存在") + } + return dto.OkView{Message: "已删除"}, nil +} + +// safeStored 落盘文件名清洗:只留安全字符,防路径穿越 +func safeStored(name string) string { + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "..", "_") + return name +} diff --git a/backend/internal/service/disk_routes.go b/backend/internal/service/disk_routes.go new file mode 100644 index 0000000..d36b609 --- /dev/null +++ b/backend/internal/service/disk_routes.go @@ -0,0 +1,167 @@ +package service + +import ( + "errors" + "fmt" + "io" + "mime" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cyi-cc/fun" + "github.com/valyala/fasthttp" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// routeUser 自定义路由的登录用户校验(区别于 routeAdmin:任何登录用户可用) +func routeUser(st *store.Store, ctx *fasthttp.RequestCtx) (store.SessionUser, error) { + token := string(ctx.QueryArgs().Peek("token")) + if token == "" { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + return store.SessionUser{}, errors.New("missing token") + } + su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter) + if err != nil || su.User.Status != "active" { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + return store.SessionUser{}, errors.New("invalid token") + } + return su, nil +} + +func routeJSONErr(ctx *fasthttp.RequestCtx, code int, msg string) { + ctx.SetStatusCode(code) + ctx.SetContentType("application/json") + ctx.SetBodyString(`{"status":2,"msg":"` + msg + `"}`) +} + +// DiskUploadHandler POST /disk/upload?token= multipart 字段 file +func DiskUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + su, err := routeUser(st, ctx) + if err != nil { + routeJSONErr(ctx, 401, "未登录或登录已失效") + return nil + } + fh, err := ctx.FormFile("file") + if err != nil { + routeJSONErr(ctx, 400, "缺少文件") + return nil + } + src, err := fh.Open() + if err != nil { + routeJSONErr(ctx, 400, "读取上传失败") + return nil + } + defer src.Close() + + // 落盘名 = 时间戳+随机后缀(不沿用原名,天然免冲突与穿越) + stored := fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeStored(filepath.Base(fh.Filename))) + dstPath := filepath.Join(cfg.DiskDir, stored) + dst, err := os.Create(dstPath) + if err != nil { + routeJSONErr(ctx, 500, "写入失败") + return nil + } + size, err := io.Copy(dst, src) + dst.Close() + if err != nil { + _ = os.Remove(dstPath) + routeJSONErr(ctx, 500, "保存失败") + return nil + } + + mimeType := fh.Header.Get("Content-Type") + f := store.File{ + UserID: su.User.Id, Name: filepath.Base(fh.Filename), + Stored: stored, Size: size, Mime: mimeType, + } + if err := st.InsertFile(&f); err != nil { + _ = os.Remove(dstPath) + routeJSONErr(ctx, 500, "记录失败") + return nil + } + ctx.SetContentType("application/json") + fmt.Fprintf(ctx, `{"status":0,"id":%d,"name":%q,"size":%d}`, f.Id, f.Name, f.Size) + return nil + } +} + +// serveDiskFile 共用:按原始文件名附件回传 +func serveDiskFile(ctx *fasthttp.RequestCtx, dir, stored, name, mimeType string, size int64) { + path := filepath.Join(dir, stored) + f, err := os.Open(path) + if err != nil { + ctx.SetStatusCode(404) + ctx.SetBodyString("文件已不存在") + return + } + defer f.Close() + // RFC 5987:中文文件名走 filename*,再补一份 filename 兜底 + disp := "attachment; filename*=UTF-8''" + url.PathEscape(name) + ctx.Response.Header.Set("Content-Disposition", disp) + if mimeType != "" { + ctx.SetContentType(mimeType) + } else if mt := mime.TypeByExtension(filepath.Ext(name)); mt != "" { + ctx.SetContentType(mt) + } else { + ctx.SetContentType("application/octet-stream") + } + ctx.Response.Header.Set("Content-Length", strconv.FormatInt(size, 10)) + _, _ = io.Copy(ctx, f) +} + +// DiskDownloadHandler GET /disk/download?id=&token= 属主下载 +func DiskDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + su, err := routeUser(st, ctx) + if err != nil { + routeJSONErr(ctx, 401, "未登录或登录已失效") + return nil + } + id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64) + f, err := st.GetFile(id) + if err != nil || f.UserID != su.User.Id { + ctx.SetStatusCode(404) + ctx.SetBodyString("文件不存在") + return nil + } + serveDiskFile(ctx, cfg.DiskDir, f.Stored, f.Name, f.Mime, f.Size) + return nil + } +} + +// ShareDownloadHandler GET /d/* 公开分享下载:code 即 wildcard 路径,过期返回 410 +func ShareDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + code := strings.Trim(rc.Wildcard, "/") + if code == "" { + ctx.SetStatusCode(404) + ctx.SetBodyString("链接无效") + return nil + } + v, err := st.GetShareFile(code) + if err != nil { + ctx.SetStatusCode(404) + ctx.SetBodyString("分享链接不存在或已删除") + return nil + } + if v.ExpiresAt > 0 && time.Now().Unix() > v.ExpiresAt { + ctx.SetStatusCode(410) + ctx.SetContentType("text/html; charset=utf-8") + ctx.SetBodyString(`

链接已过期

这个分享已经过了有效期,找文件的主人再要一个新链接吧

`) + return nil + } + _ = st.IncShareDownloads(code) + serveDiskFile(ctx, cfg.DiskDir, v.Stored, v.FileName, v.Mime, v.Size) + return nil + } +} diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go new file mode 100644 index 0000000..d6e7bab --- /dev/null +++ b/backend/internal/service/license.go @@ -0,0 +1,203 @@ +// license.go 授权系统管理:项目(appid/secret)+ 激活卡密(绑域名、按时长计时)。 +// 对外校验走 /v1/license,仅供其他项目客户端调用。 +package service + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/db" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// LicenseSvc 授权管理(仅管理员)。 +type LicenseSvc struct { + fun.Ctx + Store *store.Store +} + +const ( + licenseUnused = 0 // 未使用 + licenseActive = 1 // 已激活(有效期内) + licenseExpired = 2 // 已过期 +) + +// ---- 项目管理 ---- + +func (s *LicenseSvc) Apps() (dto.LicenseAppsView, error) { + rows, err := s.Store.Q().ListLicenseApps(context.Background()) + if err != nil { + return dto.LicenseAppsView{}, err + } + items := make([]dto.LicenseAppView, 0, len(rows)) + for _, r := range rows { + items = append(items, dto.LicenseAppView{ + Id: r.ID, Appid: r.Appid, Name: r.Name, + Enabled: r.Enabled, CardCount: r.CardCount, CreatedAt: r.CreatedAt, + }) + } + return dto.LicenseAppsView{Items: items}, nil +} + +// SaveApp id=0 新建(自动生成 appid+secret);否则改名/开关/重置 secret。 +func (s *LicenseSvc) SaveApp(d dto.LicenseAppSaveDto) (dto.LicenseAppView, error) { + ctx := context.Background() + if d.Id == 0 { + name := strings.TrimSpace(d.Name) + if name == "" || len(name) > 50 { + return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字") + } + appid := "app-" + randLowerHex(4) + id, err := s.Store.Q().CreateLicenseApp(ctx, db.CreateLicenseAppParams{ + Appid: appid, Secret: "", Name: name, CreatedAt: time.Now().Unix(), + }) + if err != nil { + return dto.LicenseAppView{}, fmt.Errorf("创建项目失败: %w", err) + } + return dto.LicenseAppView{Id: id, Appid: appid, Name: name, Enabled: 1}, nil + } + name := strings.TrimSpace(d.Name) + if name == "" || len(name) > 50 { + return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字") + } + enabled := int64(1) + if d.Enabled != nil { + enabled = *d.Enabled + } + if err := s.Store.Q().UpdateLicenseApp(ctx, db.UpdateLicenseAppParams{ + Name: name, Enabled: enabled, ID: d.Id, + }); err != nil { + return dto.LicenseAppView{}, err + } + return dto.LicenseAppView{Id: d.Id, Name: name, Enabled: enabled}, nil +} + +func (s *LicenseSvc) DeleteApp(d dto.LicenseIdDto) (dto.OkView, error) { + if err := s.Store.Q().DeleteLicenseApp(context.Background(), d.Id); err != nil { + return dto.OkView{}, err + } + return dto.OkView{Message: "已删除"}, nil +} + +// ---- 卡密管理 ---- + +func licenseStatus(c db.LicenseCard, now int64) int64 { + if c.ActivatedAt == 0 { + return licenseUnused + } + if c.ExpiresAt > 0 && c.ExpiresAt <= now { // expires_at=0 表示永久 + return licenseExpired + } + return licenseActive +} + +func licenseView(c db.LicenseCard, now int64) dto.LicenseCardView { + return dto.LicenseCardView{ + Id: c.ID, Card: c.Card, Hours: c.Hours, Domain: c.Domain, + Status: licenseStatus(c, now), + ActivatedAt: c.ActivatedAt, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt, + } +} + +func (s *LicenseSvc) List(d dto.LicenseListDto) (dto.LicensePageView, error) { + kw := sql.NullString{} + if d.Kw != nil { + kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true} + } + page := max(d.Page, 1) + size := min(max(d.PageSize, 1), 200) + ctx := context.Background() + total, err := s.Store.Q().CountLicenseCards(ctx, db.CountLicenseCardsParams{ + AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw, + }) + if err != nil { + return dto.LicensePageView{}, err + } + rows, err := s.Store.Q().ListLicenseCards(ctx, db.ListLicenseCardsParams{ + AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw, + Limit: size, Offset: (page - 1) * size, + }) + if err != nil { + return dto.LicensePageView{}, err + } + now := time.Now().Unix() + items := make([]dto.LicenseCardView, 0, len(rows)) + for _, r := range rows { + items = append(items, licenseView(r, now)) + } + return dto.LicensePageView{Total: total, Items: items}, nil +} + +func (s *LicenseSvc) Stats(d dto.LicenseStatsDto) (dto.LicenseStatsView, error) { + now := time.Now().Unix() + r, err := s.Store.Q().LicenseCardStats(context.Background(), db.LicenseCardStatsParams{ + ExpiresAt: now, ExpiresAt_2: now, AppID: d.AppId, + }) + if err != nil { + return dto.LicenseStatsView{}, err + } + return dto.LicenseStatsView{Total: r.Total, Unused: anyI64(r.Unused), Active: anyI64(r.Active), Expired: anyI64(r.Expired)}, nil +} + +// Generate 批量生成卡密:大写分组格式(XXXX-XXXX-XXXX-XXXX),一次最多 500。 +func (s *LicenseSvc) Generate(d dto.LicenseGenDto) (dto.LicenseGenView, error) { + if d.AppId <= 0 { + return dto.LicenseGenView{}, fun.Error(4001, "请先选择项目") + } + count := min(max(d.Count, 1), 500) + hours := min(max(d.Hours, 0), 24*3650) // 0 = 永久 + ctx := context.Background() + now := time.Now().Unix() + cards := make([]string, 0, count) + for i := int64(0); i < count; i++ { + card := genLicenseCard() + if err := s.Store.Q().CreateLicenseCard(ctx, db.CreateLicenseCardParams{ + AppID: d.AppId, Card: card, Hours: hours, CreatedAt: now, + }); err != nil { + return dto.LicenseGenView{}, fmt.Errorf("生成卡密失败(%d/%d): %w", i, count, err) + } + cards = append(cards, card) + } + return dto.LicenseGenView{Cards: cards}, nil +} + +func (s *LicenseSvc) Delete(d dto.LicenseIdDto) (dto.OkView, error) { + if err := s.Store.Q().DeleteLicenseCard(context.Background(), d.Id); err != nil { + return dto.OkView{}, err + } + return dto.OkView{Message: "已删除"}, nil +} + +// genLicenseCard XXXX-XXXX-XXXX-XXXX 大写卡密,排除易混淆字符(0/O、1/I/L)。 +func genLicenseCard() string { + const chars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + var sb strings.Builder + for i, v := range b { + if i > 0 && i%4 == 0 { + sb.WriteByte('-') + } + sb.WriteByte(chars[int(v)%len(chars)]) + } + return sb.String() +} + +// randLowerHex n 字节随机小写 hex。 +func randLowerHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/backend/internal/service/license_routes.go b/backend/internal/service/license_routes.go new file mode 100644 index 0000000..c9727fb --- /dev/null +++ b/backend/internal/service/license_routes.go @@ -0,0 +1,82 @@ +// license_routes.go 卡密公开校验端点:GET/POST /v1/license?appid=xx&card=xx&domain=xx +// 首次调用激活并绑定域名(开始计时),之后仅限同域名校验;供其他项目做网络验证。 +package service + +import ( + "context" + "strings" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/db" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +func LicenseVerifyHandler(st *store.Store) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + appid := strings.TrimSpace(c.Param("appid")) + card := strings.ToUpper(strings.TrimSpace(c.Param("card"))) + domain := normalizeDomain(c.Param("domain")) + if appid == "" || card == "" || domain == "" { + return licenseJSON(c, 0, "缺少参数:appid / card / domain", nil) + } + ctx := context.Background() + app, err := st.Q().GetLicenseAppByAppid(ctx, appid) + if err != nil || app.Enabled != 1 { + return licenseJSON(c, 0, "项目不存在或已停用", nil) + } + lc, err := st.Q().GetLicenseCard(ctx, db.GetLicenseCardParams{AppID: app.ID, Card: card}) + if err != nil { + return licenseJSON(c, 0, "卡密无效", nil) + } + now := time.Now().Unix() + if lc.ActivatedAt == 0 { + // 首激:绑域名 + 计时开始;hours=0 为永久卡(expires_at=0 永不过期) + expires := int64(0) + if lc.Hours > 0 { + expires = now + lc.Hours*3600 + } + if err := st.Q().ActivateLicenseCard(ctx, db.ActivateLicenseCardParams{ + Domain: domain, ActivatedAt: now, ExpiresAt: expires, ID: lc.ID, + }); err != nil { + return licenseJSON(c, 0, "激活失败", nil) + } + return licenseJSON(c, 1, "激活成功", map[string]any{ + "domain": domain, "hours": lc.Hours, "expires_at": expires, + }) + } + if lc.Domain != domain { + return licenseJSON(c, 0, "卡密已绑定其他域名", nil) + } + if lc.ExpiresAt > 0 && lc.ExpiresAt <= now { + return licenseJSON(c, 0, "卡密已过期", map[string]any{"expires_at": lc.ExpiresAt}) + } + return licenseJSON(c, 1, "验证通过", map[string]any{ + "domain": lc.Domain, "hours": lc.Hours, "expires_at": lc.ExpiresAt, + }) + } +} + +// normalizeDomain 取纯主机名:去协议/路径/端口/末尾点,小写。 +func normalizeDomain(raw string) string { + d := strings.ToLower(strings.TrimSpace(raw)) + if i := strings.Index(d, "://"); i >= 0 { + d = d[i+3:] + } + if i := strings.IndexAny(d, "/?#"); i >= 0 { + d = d[:i] + } + if i := strings.Index(d, ":"); i >= 0 { + d = d[:i] + } + return strings.TrimSuffix(d, ".") +} + +func licenseJSON(c *fun.RouteCtx, code int, msg string, extra map[string]any) error { + obj := map[string]any{"code": code, "msg": msg} + for k, v := range extra { + obj[k] = v + } + return epayJSON(c, obj) +} diff --git a/backend/internal/service/memory.go b/backend/internal/service/memory.go new file mode 100644 index 0000000..2b8acce --- /dev/null +++ b/backend/internal/service/memory.go @@ -0,0 +1,122 @@ +// memory.go 共享记忆库管理(仅管理员):项目分组、搜索、读写删除、MCP 接入信息。 +// 对外协议端点见 memory_routes.go(POST /mcp + GET /m/)。 +package service + +import ( + "strings" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/memx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// MemorySvc 记忆库管理(仅管理员)。 +type MemorySvc struct { + fun.Ctx + Store *store.Store +} + +func memoryMeta(m store.Memory) dto.MemoryMetaView { + return dto.MemoryMetaView{ + Key: m.Key, Project: m.Project, Tags: m.Tags, + WrittenBy: m.WrittenBy, Revision: m.Revision, UpdatedAt: m.UpdatedAt, + } +} + +func (s *MemorySvc) Projects() (dto.MemoryProjectsView, error) { + rows, err := s.Store.MemoryProjects() + if err != nil { + return dto.MemoryProjectsView{}, err + } + items := make([]dto.MemoryProjectView, 0, len(rows)) + for _, r := range rows { + items = append(items, dto.MemoryProjectView{Name: r.Name, Count: r.Count}) + } + return dto.MemoryProjectsView{Items: items}, nil +} + +// List query 非空走全文 AND 搜索;否则按 project/tag 过滤列元数据 +func (s *MemorySvc) List(d dto.MemoryListDto) (dto.MemoryListView, error) { + var ms []store.Memory + var err error + if q := strings.TrimSpace(d.Query); q != "" { + ms, err = s.Store.SearchMemories(q, strings.TrimSpace(d.Project), d.Limit) + } else { + ms, err = s.Store.ListMemories(strings.TrimSpace(d.Project), strings.TrimSpace(d.Tag), d.Limit) + } + if err != nil { + return dto.MemoryListView{}, err + } + items := make([]dto.MemoryMetaView, 0, len(ms)) + for _, m := range ms { + items = append(items, memoryMeta(m)) + } + return dto.MemoryListView{Items: items}, nil +} + +func (s *MemorySvc) Get(d dto.MemoryKeyDto) (dto.MemoryView, error) { + m, err := s.Store.GetMemory(strings.TrimSpace(d.Key)) + if err != nil { + return dto.MemoryView{}, err + } + if m == nil { + return dto.MemoryView{}, fun.Error(4004, "记忆不存在: "+d.Key) + } + readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key) + return dto.MemoryView{ + Key: m.Key, Url: readURL, Content: m.Content, Project: m.Project, Tags: m.Tags, + WrittenBy: m.WrittenBy, Revision: m.Revision, + CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, + }, nil +} + +func (s *MemorySvc) Save(d dto.MemorySaveDto) (dto.MemorySaveView, error) { + if err := memx.LintContent(d.Content); err != nil { + return dto.MemorySaveView{}, fun.Error(4002, err.Error()) + } + project := strings.TrimSpace(d.Project) + key := strings.TrimSpace(d.Key) + if key == "" { + return dto.MemorySaveView{}, fun.Error(4001, "key 必填,形如 project/topic") + } + m := &store.Memory{ + Key: key, Content: d.Content, Project: project, + Tags: d.Tags, WrittenBy: "admin", + } + if err := s.Store.SaveMemory(m); err != nil { + return dto.MemorySaveView{}, err + } + readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key) + return dto.MemorySaveView{ + Key: m.Key, Revision: m.Revision, Url: readURL, + }, nil +} + +func (s *MemorySvc) Delete(d dto.MemoryKeyDto) (dto.OkView, error) { + if _, err := s.Store.DeleteMemory(strings.TrimSpace(d.Key)); err != nil { + return dto.OkView{}, err + } + return dto.OkView{Message: "已删除"}, nil +} + +// McpInfo 返回对外 MCP 端点与 Bearer 令牌(缺省懒生成一个) +func (s *MemorySvc) McpInfo() (dto.MemoryMcpView, error) { + tok, err := s.Store.McpToken() + if err != nil { + return dto.MemoryMcpView{}, err + } + return dto.MemoryMcpView{ + Url: requestBase(s.Ctx.RequestCtx) + "/mcp", + Token: tok, + }, nil +} + +func (s *MemorySvc) ResetToken() (dto.MemoryMcpView, error) { + if _, err := s.Store.ResetMcpToken(); err != nil { + return dto.MemoryMcpView{}, err + } + return s.McpInfo() +} + diff --git a/backend/internal/service/memory_routes.go b/backend/internal/service/memory_routes.go new file mode 100644 index 0000000..c685506 --- /dev/null +++ b/backend/internal/service/memory_routes.go @@ -0,0 +1,124 @@ +// memory_routes.go 记忆库对外协议端点: +// POST /mcp Streamable HTTP MCP(Bearer 令牌鉴权,令牌见 MemorySvc.McpInfo) +// GET /m 阅读链接索引(Bearer 鉴权——key 可枚举,不能裸奔) +// GET /m/* 记忆阅读链接(?sig= 签名鉴权,HMAC(key, 令牌) 签发,令牌重置即吊销) +package service + +import ( + "context" + "crypto/subtle" + "net/http" + + "github.com/valyala/fasthttp" + + "github.com/cyi-cc/fun" + "github.com/mark3labs/mcp-go/server" + "github.com/valyala/fasthttp/fasthttpadaptor" + + "github.com/cyi-cc/cyi-box/backend/internal/memx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// requestBase 按当前请求 Host + X-Forwarded-Proto 推导站点基址, +// 反代换域名后生成的 /m/ 链接与 MCP 接入地址始终可点。 +func requestBase(ctx *fasthttp.RequestCtx) string { + proto := string(ctx.Request.Header.Peek("X-Forwarded-Proto")) + if proto == "" { + proto = "http" + } + return proto + "://" + string(ctx.Host()) +} + +// McpHandler POST /mcp:Authorization Bearer 校验后交给 Streamable HTTP 服务。 +// 令牌存 settings(mcp.token),首次访问懒生成。 +func McpHandler(st *store.Store) fun.RouteHandler { + mcpSrv := server.NewStreamableHTTPServer( + memx.NewMCPServer(st), + server.WithStateLess(true), + server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + proto = "http" + if r.TLS != nil { + proto = "https" + } + } + return memx.WithBase(ctx, proto+"://"+r.Host) + }), + ) + h := fasthttpadaptor.NewFastHTTPHandler(mcpSrv) + return func(c *fun.RouteCtx) error { + tok, err := st.McpToken() + if err != nil { + c.RequestCtx.SetStatusCode(500) + c.RequestCtx.WriteString(err.Error()) + return nil + } + auth := string(c.RequestCtx.Request.Header.Peek("Authorization")) + if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 { + c.RequestCtx.SetStatusCode(401) + c.RequestCtx.WriteString("unauthorized") + return nil + } + h(c.RequestCtx) + return nil + } +} + +// MemoryReadHandler GET /m/*:输出 markdown 原文;裸 /m/ 列出全部阅读链接。 +func MemoryReadHandler(st *store.Store) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + key := c.Wildcard + base := requestBase(c.RequestCtx) + if key == "" { + // 索引收进 Bearer 鉴权:key 形如 project/topic 可枚举,裸索引等于全库暴露 + tok, err := st.McpToken() + if err != nil { + c.RequestCtx.SetStatusCode(500) + c.RequestCtx.WriteString(err.Error()) + return nil + } + auth := string(c.RequestCtx.Request.Header.Peek("Authorization")) + if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 { + c.RequestCtx.SetStatusCode(401) + c.RequestCtx.WriteString("unauthorized") + return nil + } + ms, err := st.ListMemories("", "", 200) + if err != nil { + c.RequestCtx.SetStatusCode(500) + c.RequestCtx.WriteString(err.Error()) + return nil + } + c.RequestCtx.SetContentType("text/plain; charset=utf-8") + for _, m := range ms { + readURL, _ := st.SignMemoryReadURL(base, m.Key) + line := readURL + if m.Project != "" { + line += "\t[" + m.Project + "]" + } + c.RequestCtx.WriteString(line + "\n") + } + return nil + } + if !st.CheckMemoryReadSig(key, string(c.RequestCtx.QueryArgs().Peek("sig"))) { + c.RequestCtx.SetStatusCode(403) + c.RequestCtx.WriteString("bad or missing sig") + return nil + } + m, err := st.GetMemory(key) + if err != nil { + c.RequestCtx.SetStatusCode(500) + c.RequestCtx.WriteString(err.Error()) + return nil + } + if m == nil { + c.RequestCtx.SetStatusCode(404) + c.RequestCtx.WriteString("not found") + return nil + } + c.RequestCtx.SetContentType("text/markdown; charset=utf-8") + c.RequestCtx.WriteString(m.Content) + return nil + } +} diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go new file mode 100644 index 0000000..8facf24 --- /dev/null +++ b/backend/internal/service/pay.go @@ -0,0 +1,867 @@ +// pay.go 支付核心:上游 catfk 账号托管、分类/商品自检、卡密库存保障(全程带锁)、 +// 订单 RPC、支付轮询与商户异步通知。易支付协议端点在 pay_routes.go。 +package service + +import ( + "context" + "crypto/md5" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/db" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/payx" + "github.com/cyi-cc/cyi-box/backend/internal/store" + "github.com/cyi-cc/cyi-box/backend/internal/vault" +) + +// ---- 常量与 settings 键 ---- + +const ( + payStatusPending = int64(0) + payStatusPaid = int64(1) + payStatusClosed = int64(3) + + payUnitFen = int64(1) // 上游商品单价 0.01 元 = 1 分 + payTargetFen = int64(100000) // 库存目标 1000 元 = 100000 分 = 10w 张卡 + payCatName = "CDK" + payGoodsName = "CDK0.01元" + payMaxFen = int64(200000) // 单笔上限 2000 元 +) + +const ( + keyPayPid = "pay_pid" + keyPayKey = "pay_key" + keyUpUsername = "up_username" + keyUpPassword = "up_password" // AES 加密存储 + keyUpNickname = "up_nickname" + keyUpShop = "up_shop" + keyUpGoodsID = "up_goods_id" + keyUpGoodsKey = "up_goods_key" + keyUpGoodsName = "up_goods_name" + keyUpLastErr = "up_last_error" +) + +// ---- 易支付签名与金额工具 ---- + +// epaySign 标准 MD5 签名:参数名 ASCII 升序拼 k=v&…(忽略 sign/sign_type 与空值),末尾直接拼接商户密钥。 +func epaySign(params map[string]string, key string) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if k == "sign" || k == "sign_type" || v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + if b.Len() > 0 { + b.WriteByte('&') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(params[k]) + } + b.WriteString(key) + sum := md5.Sum([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +func epayVerify(params map[string]string, key, sign string) bool { + if params["sign_type"] != "MD5" { + return false + } + return sign != "" && epaySign(params, key) == sign +} + +// fenToYuan 分转元字符串,如 199 -> "1.99"。 +func fenToYuan(fen int64) string { + sign := "" + if fen < 0 { + sign, fen = "-", -fen + } + return fmt.Sprintf("%s%d.%02d", sign, fen/100, fen%100) +} + +// yuanToFen 元字符串转分(最多两位小数)。 +func yuanToFen(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("金额不能为空") + } + if strings.HasPrefix(s, "-") { + return 0, fmt.Errorf("金额不能为负") + } + parts := strings.SplitN(s, ".", 2) + whole, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + if err != nil { + return 0, fmt.Errorf("金额格式错误") + } + fen := whole * 100 + if len(parts) == 2 { + frac := parts[1] + if len(frac) > 2 { + frac = frac[:2] + } + frac += strings.Repeat("0", 2-len(frac)) + f, err := strconv.ParseInt(frac, 10, 64) + if err != nil { + return 0, fmt.Errorf("金额格式错误") + } + fen += f + } + return fen, nil +} + +// genTradeNo 平台订单号:P + 时间戳 + 16 位随机 hex(不可枚举)。 +func genTradeNo() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return "P" + strconv.FormatInt(time.Now().Unix(), 10) + hex.EncodeToString(b) +} + +// ---- 商户凭证 ---- + +func payMerchant(st *store.Store) (pid, key string, err error) { + if pid, err = st.GetSetting(keyPayPid); err != nil { + return "", "", err + } + if key, err = st.GetSetting(keyPayKey); err != nil { + return "", "", err + } + return pid, key, nil +} + +func strSetting(st *store.Store, key string) string { + v, _ := st.GetSetting(key) + return v +} + +func intSetting(st *store.Store, key string) int64 { + v, _ := strconv.ParseInt(strSetting(st, key), 10, 64) + return v +} + +func anyI64(v any) int64 { + switch t := v.(type) { + case int64: + return t + case float64: + return int64(t) + case []byte: + n, _ := strconv.ParseInt(string(t), 10, 64) + return n + case string: + n, _ := strconv.ParseInt(t, 10, 64) + return n + } + return 0 +} + +// ---- PaySvc 管理端 RPC ---- + +type PaySvc struct { + fun.Ctx + Store *store.Store + Up *payx.Client + Cfg *config.Config +} + +// List 订单分页。 +func (s *PaySvc) List(d dto.PayOrderListDto) (dto.PayOrderPageView, error) { + page, size := d.Page, d.PageSize + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + status := int64(-1) + if d.Status != nil { + status = *d.Status + } + kw := sql.NullString{} + if d.Kw != nil { + kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true} + } + ctx := context.Background() + arg := db.ListPayOrdersParams{ + Column1: status, Status: status, + Column3: kw.String, Column4: kw, Column5: kw, + Limit: size, Offset: (page - 1) * size, + } + rows, err := s.Store.Q().ListPayOrders(ctx, arg) + if err != nil { + return dto.PayOrderPageView{}, fun.Error(5000, "查询订单失败") + } + total, err := s.Store.Q().CountPayOrders(ctx, db.CountPayOrdersParams{ + Column1: arg.Column1, Status: arg.Status, + Column3: arg.Column3, Column4: arg.Column4, Column5: arg.Column5, + }) + if err != nil { + return dto.PayOrderPageView{}, fun.Error(5000, "统计订单失败") + } + items := make([]dto.PayOrderView, 0, len(rows)) + for _, r := range rows { + items = append(items, toPayOrderView(r)) + } + return dto.PayOrderPageView{Total: total, Items: items}, nil +} + +// Stats 今日与累计统计。 +func (s *PaySvc) Stats() (dto.PayStatsView, error) { + now := time.Now() + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix() + ctx := context.Background() + today, err := s.Store.Q().PayOrderStats(ctx, dayStart) + if err != nil { + return dto.PayStatsView{}, fun.Error(5000, "统计失败") + } + total, err := s.Store.Q().PayOrderStats(ctx, 0) + if err != nil { + return dto.PayStatsView{}, fun.Error(5000, "统计失败") + } + return dto.PayStatsView{ + TodayCount: today.Cnt, TodayMoney: anyI64(today.Money), TodayPaidCount: anyI64(today.Paid), + TotalCount: total.Cnt, TotalMoney: anyI64(total.Money), PendingCount: anyI64(total.Pending), + TotalPaidCount: anyI64(total.Paid), + }, nil +} + +// UpstreamStatus 上游账号与商品状态(管理页展示)。 +func (s *PaySvc) UpstreamStatus() (dto.PayUpstreamStatusView, error) { + v := dto.PayUpstreamStatusView{ + Username: strSetting(s.Store, keyUpUsername), + Nickname: strSetting(s.Store, keyUpNickname), + Shop: strSetting(s.Store, keyUpShop), + GoodsName: strSetting(s.Store, keyUpGoodsName), + LastError: strSetting(s.Store, keyUpLastErr), + TokenAge: s.Up.TokenAge(), + GoodsId: intSetting(s.Store, keyUpGoodsID), + GoodsKey: strSetting(s.Store, keyUpGoodsKey), + UnitPrice: payUnitFen, + TargetMoney: payTargetFen, + } + if payEnsuring.Load() { + v.Ensuring = 1 + } + if v.Username != "" { + v.Configured = 1 + } + if v.GoodsId > 0 && v.Configured == 1 { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, _, stock, err := s.Up.GoodsInfo(ctx, v.GoodsId); err == nil { + v.StockCount = stock + } + if avail, frozen, err := s.Up.WalletInfo(ctx); err == nil { + v.WalletOk, v.WalletAvail, v.WalletFrozen = 1, avail, frozen + } + } + return v, nil +} + +// SaveUpstream 保存上游账号并登录验证;成功后异步触发分类/商品自检与库存补齐。 +func (s *PaySvc) SaveUpstream(d dto.PayUpstreamDto) (dto.PayUpstreamStatusView, error) { + username := strings.TrimSpace(d.Username) + if username == "" { + return dto.PayUpstreamStatusView{}, fun.Error(4001, "上游账号不能为空") + } + password := d.Password + if password == "" { + u, p, err := s.decryptUpPassword() + if err != nil || u != username || p == "" { + return dto.PayUpstreamStatusView{}, fun.Error(4001, "请输入上游密码") + } + password = p + } + if err := s.Up.SetCredentials(username, password); err != nil { + return dto.PayUpstreamStatusView{}, fun.Error(4002, "上游登录失败:"+err.Error()) + } + nickname, shop, err := s.Up.Userinfo(context.Background()) + if err != nil { + return dto.PayUpstreamStatusView{}, fun.Error(5000, "获取商户信息失败:"+err.Error()) + } + enc, err := vault.Encrypt(s.Cfg.VaultKey, password) + if err != nil { + return dto.PayUpstreamStatusView{}, fun.Error(5000, "凭据加密失败") + } + old := strSetting(s.Store, keyUpUsername) + sets := map[string]string{ + keyUpUsername: username, keyUpPassword: enc, + keyUpNickname: nickname, keyUpShop: shop, + } + if old != "" && old != username { + sets[keyUpGoodsID], sets[keyUpGoodsKey], sets[keyUpGoodsName] = "", "", "" + } + for k, v := range sets { + if err := s.Store.SetSetting(k, v); err != nil { + return dto.PayUpstreamStatusView{}, fun.Error(5000, "保存配置失败") + } + } + ensureGoodsAsync(s.Store, s.Up) + return s.UpstreamStatus() +} + +// TestPay 创建一笔真实订单走完整上游流程(收银台二维码 + 轮询结算)。 +func (s *PaySvc) TestPay(d dto.PayTestPayDto) (dto.PayTestPayView, error) { + money, err := yuanToFen(d.Money) + if err != nil || money <= 0 || money > payMaxFen { + return dto.PayTestPayView{}, fun.Error(4001, "金额非法(0-2000 元)") + } + order, err := createPayOrder(s.Store, s.Up, createPayOrderIn{ + Channel: "alipay", Subject: "测试支付", Money: money, + }) + if err != nil { + return dto.PayTestPayView{}, fun.Error(5000, err.Error()) + } + return dto.PayTestPayView{ + TradeNo: order.TradeNo, Payurl: order.Payurl, Qrcode: order.Qrcode, + Money: order.Money, Cashier: "/cashier/" + order.TradeNo, + }, nil +} + +// CashierInfo 收银台公开信息(trade_no 不可枚举即凭证)。 +func (s *PaySvc) CashierInfo(d dto.PayCashierDto) (dto.PayCashierView, error) { + o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo)) + if err != nil { + return dto.PayCashierView{}, fun.Error(4004, "订单不存在") + } + lazyCloseExpired(s.Store, &o) + return dto.PayCashierView{ + TradeNo: o.TradeNo, OutTradeNo: o.OutTradeNo, Subject: o.Subject, + Money: o.Money, Channel: o.Channel, Status: o.Status, + Payurl: o.Payurl, Qrcode: o.Qrcode, Cards: o.Cards, + CreatedAt: o.CreatedAt, ExpiredAt: o.ExpiredAt, + }, nil +} + +// Watch 收银台状态流:服务端推送订单状态变化,终态(已支付/已关闭)自动关闭。 +func (s *PaySvc) Watch(d dto.PayCashierDto) (*fun.Stream[dto.PayCashierStatusView], error) { + tradeNo := strings.TrimSpace(d.TradeNo) + o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo) + if err != nil { + return nil, fun.Error(4004, "订单不存在") + } + st := &fun.Stream[dto.PayCashierStatusView]{} + go s.watchLoop(st, tradeNo, o) + return st, nil +} + +func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo string, first db.PayOrder) { + defer st.Close() + last := first.Status + push := func(status int64, o db.PayOrder) bool { + msg := dto.PayCashierStatusView{Status: status} + if status == payStatusPaid && o.ReturnUrl != "" { + if pid, key, err := payMerchant(s.Store); err == nil { + msg.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key) + } + } + return st.Send(msg) == nil + } + if !push(last, first) || last != payStatusPending { + return + } + deadline := time.Now().Add(40 * time.Minute) + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for time.Now().Before(deadline) { + <-t.C + o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo) + if err != nil { + return + } + lazyCloseExpired(s.Store, &o) + if o.Status != last { + if !push(o.Status, o) { + return + } + last = o.Status + if o.Status != payStatusPending { + return + } + } + } +} + +// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。 +func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) { + o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo)) + if err != nil { + return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在") + } + res := dto.PayCashierStatusView{Status: o.Status} + if o.Status == payStatusPaid && o.ReturnUrl != "" { + pid, key, err := payMerchant(s.Store) + if err == nil { + res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key) + } + } + return res, nil +} + +func toPayOrderView(r db.PayOrder) dto.PayOrderView { + return dto.PayOrderView{ + Id: r.ID, TradeNo: r.TradeNo, OutTradeNo: r.OutTradeNo, Channel: r.Channel, + Subject: r.Subject, Money: r.Money, Status: r.Status, Notified: r.Notified, + Payurl: r.Payurl, PaidAt: r.PaidAt, ExpiredAt: r.ExpiredAt, CreatedAt: r.CreatedAt, + } +} + +func lazyCloseExpired(st *store.Store, o *db.PayOrder) { + if o.Status == payStatusPending && o.ExpiredAt > 0 && o.ExpiredAt < time.Now().Unix() { + _ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{ + Status: payStatusClosed, PaidAt: o.PaidAt, Cards: o.Cards, ID: o.ID, + }) + o.Status = payStatusClosed + } +} + +func (s *PaySvc) decryptUpPassword() (string, string, error) { + u := strSetting(s.Store, keyUpUsername) + enc := strSetting(s.Store, keyUpPassword) + if u == "" || enc == "" { + return "", "", fmt.Errorf("未保存上游凭据") + } + p, err := vault.Decrypt(s.Cfg.VaultKey, enc) + return u, p, err +} + +// ---- 商品自检 + 库存保障 ---- + +var payEnsuring atomic.Bool + +// ensureGoodsAsync 后台幂等确保分类「CDK」与商品「CDK0.01元」存在并补齐库存。 +func ensureGoodsAsync(st *store.Store, up *payx.Client) { + if !payEnsuring.CompareAndSwap(false, true) { + return + } + go func() { + defer payEnsuring.Store(false) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + if err := ensureGoods(ctx, st, up); err != nil { + _ = st.SetSetting(keyUpLastErr, err.Error()) + log.Printf("[pay] 上游商品自检失败: %v", err) + return + } + _ = st.SetSetting(keyUpLastErr, "") + }() +} + +// ensureGoods 幂等自检:分类 CDK → 商品 CDK0.01元(¥0.01)→ 库存补到 1000 元(10w 张,1w/批)。 +func ensureGoods(ctx context.Context, st *store.Store, up *payx.Client) error { + goodsID := intSetting(st, keyUpGoodsID) + goodsKey := strSetting(st, keyUpGoodsKey) + target := payTargetFen / payUnitFen // 10w 张 + if goodsID > 0 && goodsKey != "" { + return ensureStockTo(ctx, up, goodsID, target) + } + list, total, err := up.GoodsList(ctx, "", 1) + if err != nil { + return err + } + for page := int64(1); ; page++ { + for _, it := range list { + if it.Name == payGoodsName && it.Price == payUnitFen && it.Status == 1 { + goodsID, goodsKey = it.ID, it.GoodsKey + } + } + if goodsID != 0 || page*20 >= total { + break + } + list, _, err = up.GoodsList(ctx, "", page+1) + if err != nil { + return err + } + } + if goodsID == 0 { + var catID int64 + cats, err := up.CategoryListAll(ctx) + if err != nil { + return err + } + for _, cat := range cats { + if cat.Name == payCatName { + catID = cat.ID + break + } + } + if catID == 0 { + if err := up.CategoryAdd(ctx, payCatName); err != nil { + return err + } + cats, err = up.CategoryListAll(ctx) + if err != nil { + return err + } + for _, cat := range cats { + if cat.Name == payCatName { + catID = cat.ID + break + } + } + } + if catID == 0 { + return fmt.Errorf("上游分类「%s」创建失败", payCatName) + } + if goodsID, goodsKey, err = up.GoodsAdd(ctx, payGoodsName, catID, 0.01); err != nil { + return err + } + if goodsID <= 0 || goodsKey == "" { + return fmt.Errorf("上游商品创建未返回有效标识") + } + log.Printf("[pay] 已创建上游商品「%s」id=%d", payGoodsName, goodsID) + } + for k, v := range map[string]string{ + keyUpGoodsID: strconv.FormatInt(goodsID, 10), + keyUpGoodsKey: goodsKey, + keyUpGoodsName: payGoodsName, + } { + if err := st.SetSetting(k, v); err != nil { + return err + } + } + return ensureStockTo(ctx, up, goodsID, target) +} + +// ensureStockTo 全程带锁把库存补到 target 张。 +func ensureStockTo(ctx context.Context, up *payx.Client, goodsID, target int64) error { + unlock := up.StockLock() + defer unlock() + _, err := up.EnsureStock(ctx, goodsID, target) + return err +} + +// ---- 下单 ---- + +type createPayOrderIn struct { + OutTradeNo string + Channel string + Subject string + Money int64 // 分 + NotifyURL string + ReturnURL string + ClientIP string +} + +// createPayOrder 本地建单 → 库存保障(带锁)→ 上游下单 → 写回 payurl/二维码。 +// 幂等:同 out_trade_no 复用未支付订单。 +func createPayOrder(st *store.Store, up *payx.Client, in createPayOrderIn) (db.PayOrder, error) { + now := time.Now().Unix() + tradeNo := genTradeNo() + outNo := in.OutTradeNo + if outNo == "" { + outNo = tradeNo // 内部测试单:商户单号=平台单号 + } + if exist, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo); err == nil { + switch { + case exist.Money != in.Money: + return db.PayOrder{}, fmt.Errorf("商户订单号重复且金额不一致") + case exist.Status == payStatusPaid: + return db.PayOrder{}, fmt.Errorf("该订单已支付") + case exist.Status != payStatusPending || (exist.ExpiredAt > 0 && exist.ExpiredAt < now): + return db.PayOrder{}, fmt.Errorf("订单已关闭,请更换订单号") + default: + return exist, nil + } + } + goodsID := intSetting(st, keyUpGoodsID) + goodsKey := strSetting(st, keyUpGoodsKey) + if goodsID <= 0 || goodsKey == "" { + return db.PayOrder{}, fmt.Errorf("上游商品未就绪:请先在支付配置页保存上游账号") + } + if in.Money%payUnitFen != 0 || in.Money/payUnitFen <= 0 { + return db.PayOrder{}, fmt.Errorf("金额必须是 ¥0.01 的整数倍") + } + quantity := in.Money / payUnitFen + id, err := st.Q().CreatePayOrder(context.Background(), db.CreatePayOrderParams{ + TradeNo: tradeNo, OutTradeNo: outNo, Channel: in.Channel, + Subject: in.Subject, Money: in.Money, Fee: 0, + NotifyUrl: in.NotifyURL, ReturnUrl: in.ReturnURL, ClientIp: in.ClientIP, + ExpiredAt: now + 30*60, CreatedAt: now, + }) + if err != nil { + return db.PayOrder{}, fmt.Errorf("创建订单失败") + } + o, err := st.Q().GetPayOrder(context.Background(), id) + if err != nil { + return db.PayOrder{}, fmt.Errorf("创建订单失败") + } + if err := placeUpstream(st, up, &o, goodsID, goodsKey, quantity); err != nil { + if o.UpstreamTradeNo == "" { + _ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{ + Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID, + }) + } + return db.PayOrder{}, err + } + return o, nil +} + +// placeUpstream 库存保障(全程带锁)→ 上游下单 → 回写。 +func placeUpstream(st *store.Store, up *payx.Client, o *db.PayOrder, goodsID int64, goodsKey string, quantity int64) error { + ctx := context.Background() + shop := strSetting(st, keyUpShop) + if shop == "" { + return fmt.Errorf("上游店铺未配置") + } + channelID, err := up.AlipayChannelID(ctx, shop) + if err != nil { + return fmt.Errorf("上游支付通道不可用:%v", err) + } + contact, queryPwd := payx.RandContact(), payx.RandQueryPwd() + + unlock := up.StockLock() + res, err := placeUnderLock(ctx, up, goodsID, goodsKey, quantity, channelID, contact, queryPwd) + unlock() + if err != nil { + return err + } + o.Payurl = res.PayURL + o.UpstreamTradeNo = res.TradeNo + if res.TotalAmount != o.Money { + log.Printf("[pay] 订单 %s 上游金额 %s 与本地 %s 不一致", o.TradeNo, fenToYuan(res.TotalAmount), fenToYuan(o.Money)) + } + if err := st.Q().SetPayOrderUpstream(ctx, db.SetPayOrderUpstreamParams{ + UpstreamTradeNo: res.TradeNo, GoodsKey: goodsKey, Quantity: quantity, + UnitPrice: payUnitFen, Payurl: res.PayURL, BuyerContact: contact, + QueryPwd: queryPwd, ID: o.ID, + }); err != nil { + return fmt.Errorf("回写上游订单失败") + } + if qr, qrErr := up.FetchQRCode(ctx, res.TradeNo); qrErr == nil && qr != "" { + _ = st.Q().SetPayOrderQrcode(ctx, db.SetPayOrderQrcodeParams{Qrcode: qr, ID: o.ID}) + o.Qrcode = qr + } else if qrErr != nil { + log.Printf("[pay] 订单 %s 取二维码失败: %v(payurl 仍可用)", o.TradeNo, qrErr) + } + return nil +} + +// placeUnderLock 持锁执行:库存不足先补齐(补到 max(10w, 当前+需求))再下单。 +func placeUnderLock(ctx context.Context, up *payx.Client, goodsID int64, goodsKey string, quantity, channelID int64, contact, queryPwd string) (payx.PayOrderResult, error) { + target := payTargetFen / payUnitFen + _, _, stock, err := up.GoodsInfo(ctx, goodsID) + if err != nil { + return payx.PayOrderResult{}, fmt.Errorf("上游库存查询失败:%v", err) + } + if stock < quantity { + goal := target + if stock+quantity > goal { + goal = stock + quantity + } + log.Printf("[pay] 库存 %d 不足订单需求 %d,补到 %d", stock, quantity, goal) + if _, err := up.EnsureStock(ctx, goodsID, goal); err != nil { + return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", err) + } + } + res, err := up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd) + if err != nil && strings.Contains(err.Error(), "库存") { + if _, serr := up.EnsureStock(ctx, goodsID, target+quantity); serr != nil { + return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", serr) + } + res, err = up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd) + } + if err != nil { + return payx.PayOrderResult{}, fmt.Errorf("上游下单失败:%v", err) + } + return res, nil +} + +// ---- 支付轮询 ---- + +// PayPoller 待支付订单轮询器(fun.Wired 单例,5s 一扫)。 +type PayPoller struct { + Store *store.Store `fun:"auto"` + Up *payx.Client `fun:"auto"` + Notify *PayNotifier `fun:"auto"` +} + +func (p *PayPoller) New() { + go p.loop() +} + +func (p *PayPoller) loop() { + t := time.NewTicker(5 * time.Second) + defer t.Stop() + for range t.C { + p.sweep() + } +} + +func (p *PayPoller) sweep() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + rows, err := p.Store.Q().ListPendingPayOrders(ctx) + if err != nil { + return + } + now := time.Now().Unix() + for _, o := range rows { + if o.ExpiredAt > 0 && o.ExpiredAt < now { + _ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{ + Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID, + }) + continue + } + paid, err := p.Up.OrderPaid(ctx, o.UpstreamTradeNo) + if err != nil || !paid { + continue + } + cards, _ := p.Up.OrderCards(ctx, o.UpstreamTradeNo) + _ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{ + Status: payStatusPaid, PaidAt: now, Cards: strings.Join(cards, "\n"), ID: o.ID, + }) + log.Printf("[pay] 订单 %s 已支付", o.TradeNo) + p.Notify.OnPaid(o.TradeNo) + // 库存被消耗:异步补回目标水位 + if g := intSetting(p.Store, keyUpGoodsID); g > 0 { + ensureStockAsync(p.Store, p.Up, g, payTargetFen/payUnitFen) + } + } +} + +var stockFilling atomic.Bool + +func ensureStockAsync(st *store.Store, up *payx.Client, goodsID, target int64) { + if !stockFilling.CompareAndSwap(false, true) { + return + } + go func() { + defer stockFilling.Store(false) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + if err := ensureStockTo(ctx, up, goodsID, target); err != nil { + log.Printf("[pay] 库存回充失败: %v", err) + } + }() +} + +// ---- 商户异步通知 ---- + +// PayNotifier 商户异步通知器(fun.Wired 单例)。 +type PayNotifier struct { + Store *store.Store `fun:"auto"` +} + +var notifyBackoff = []time.Duration{0, 15 * time.Second, time.Minute, 5 * time.Minute, 15 * time.Minute} + +// notifyParams 通知/回跳参数(未签名)。 +func notifyParams(o db.PayOrder, pid string) map[string]string { + return map[string]string{ + "pid": pid, + "trade_no": o.TradeNo, + "out_trade_no": o.OutTradeNo, + "type": o.Channel, + "name": o.Subject, + "money": fenToYuan(o.Money), + "trade_status": "TRADE_SUCCESS", + } +} + +// buildSignedURL 签名参数拼接到回调地址。 +func buildSignedURL(base string, params map[string]string, key string) string { + u, err := url.Parse(base) + if err != nil { + return base + } + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + q.Set("sign_type", "MD5") + q.Set("sign", epaySign(params, key)) + u.RawQuery = q.Encode() + return u.String() +} + +// OnPaid 异步投递商户 notify_url(退避:立即/15s/1m/5m/15m)。 +func (n *PayNotifier) OnPaid(tradeNo string) { + go n.deliver(tradeNo) +} + +func (n *PayNotifier) deliver(tradeNo string) { + for i, delay := range notifyBackoff { + if delay > 0 { + time.Sleep(delay) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + o, err := n.Store.Q().GetPayOrderByTradeNo(ctx, tradeNo) + cancel() + if err != nil || o.Status != payStatusPaid || o.Notified == 1 || o.NotifyUrl == "" { + return + } + if n.attempt(o) { + _ = n.Store.Q().SetPayNotified(context.Background(), o.ID) + return + } + _ = n.Store.Q().IncPayNotifyAttempts(context.Background(), o.ID) + log.Printf("[pay] %s 第 %d 次通知未确认", tradeNo, i+1) + } +} + +func (n *PayNotifier) attempt(o db.PayOrder) bool { + pid, key, err := payMerchant(n.Store) + if err != nil { + return false + } + params := notifyParams(o, pid) + params["sign"] = epaySign(params, key) + params["sign_type"] = "MD5" + form := url.Values{} + for k, v := range params { + form.Set(k, v) + } + cli := &http.Client{Timeout: 10 * time.Second} + if resp, err := cli.Post(o.NotifyUrl, "application/x-www-form-urlencoded", + strings.NewReader(form.Encode())); err == nil && notifyRespOK(resp) { + return true + } + sep := "?" + if strings.Contains(o.NotifyUrl, "?") { + sep = "&" + } + if resp, err := cli.Get(o.NotifyUrl + sep + form.Encode()); err == nil { + return notifyRespOK(resp) + } + return false +} + +func notifyRespOK(resp *http.Response) bool { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(body)), "success") +} + +// BootPay 启动时装配上游凭据(若有)并触发商品自检。 +func BootPay(st *store.Store, up *payx.Client, cfg *config.Config) { + username := strSetting(st, keyUpUsername) + enc := strSetting(st, keyUpPassword) + if username == "" || enc == "" { + return + } + pass, err := vault.Decrypt(cfg.VaultKey, enc) + if err != nil || pass == "" { + log.Printf("[pay] 上游凭据解密失败: %v", err) + return + } + up.SetCredentialsLazy(username, pass) + ensureGoodsAsync(st, up) +} diff --git a/backend/internal/service/pay_routes.go b/backend/internal/service/pay_routes.go new file mode 100644 index 0000000..f16c6cf --- /dev/null +++ b/backend/internal/service/pay_routes.go @@ -0,0 +1,189 @@ +// pay_routes.go 易支付兼容协议端点:/submit.php(跳收银台)、/mapi.php(JSON下单)、/api.php(查单)。 +package service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/payx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// paySubmitHandler GET/POST /submit.php:验签建单后 302 到收银台。 +func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + order, err := epayCreateOrder(c, st, up) + if err != nil { + return epayFail(c, err) + } + // 当码支付:优先跳二维码支付链接(qr.alipay.com),退化到本地收银台 + target := order.Qrcode + if target == "" { + target = "/cashier/" + order.TradeNo + } + c.RequestCtx.Redirect(target, 302) + return nil + } +} + +// payMapiHandler GET/POST /mapi.php:验签建单,JSON 返回 payurl/qrcode。 +func PayMapiHandler(st *store.Store, up *payx.Client) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + order, err := epayCreateOrder(c, st, up) + if err != nil { + return epayFail(c, err) + } + payurl := order.Qrcode + if payurl == "" { + payurl = order.Payurl + } + return epayJSON(c, map[string]any{ + "code": 1, "msg": "success", + "trade_no": order.TradeNo, + "out_trade_no": order.OutTradeNo, + "type": order.Channel, + "name": order.Subject, + "money": fenToYuan(order.Money), + "payurl": payurl, + "qrcode": order.Qrcode, + "urlscheme": order.Qrcode, + }) + } +} + +// PayApiHandler GET/POST /api.php:act=order 查单(pid+key 验签)。 +func PayApiHandler(st *store.Store) fun.RouteHandler { + return func(c *fun.RouteCtx) error { + act := strings.TrimSpace(c.Param("act")) + pidStr := strings.TrimSpace(c.Param("pid")) + key := strings.TrimSpace(c.Param("key")) + merPid, merKey, err := payMerchant(st) + if err != nil || pidStr != merPid || key != merKey { + return epayJSON(c, map[string]any{"code": -1, "msg": "商户校验失败"}) + } + if act != "order" { + return epayJSON(c, map[string]any{"code": -1, "msg": "不支持的 act"}) + } + tradeNo := strings.TrimSpace(c.Param("trade_no")) + outNo := strings.TrimSpace(c.Param("out_trade_no")) + var order struct { + TradeNo string + OutTradeNo string + Subject string + Money int64 + Status int64 + } + if tradeNo != "" { + r, err := st.Q().GetPayOrderByTradeNo(context.Background(), tradeNo) + if err != nil { + return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"}) + } + order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject + order.Money, order.Status = r.Money, r.Status + } else if outNo != "" { + r, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo) + if err != nil { + return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"}) + } + order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject + order.Money, order.Status = r.Money, r.Status + } else { + return epayJSON(c, map[string]any{"code": -1, "msg": "请传入 out_trade_no 或 trade_no"}) + } + status := int64(0) + if order.Status == payStatusPaid { + status = 1 + } + return epayJSON(c, map[string]any{ + "code": 1, "msg": "查询订单号成功", + "trade_no": order.TradeNo, + "out_trade_no": order.OutTradeNo, + "name": order.Subject, + "money": fenToYuan(order.Money), + "status": status, + }) + } +} + +// epayOrderResult 建单结果(submit/mapi 共用)。 +type epayOrderResult struct { + TradeNo string + OutTradeNo string + Channel string + Subject string + Money int64 + Payurl string + Qrcode string +} + +// epayCreateOrder submit/mapi 共用下单逻辑:参数校验 → 验签 → 幂等建单。 +func epayCreateOrder(c *fun.RouteCtx, st *store.Store, up *payx.Client) (epayOrderResult, error) { + var out epayOrderResult + pid := strings.TrimSpace(c.Param("pid")) + merPid, merKey, err := payMerchant(st) + if err != nil || merPid == "" { + return out, errParam("商户未配置") + } + if pid == "" || pid != merPid { + return out, errParam("PID 参数错误") + } + channel := strings.TrimSpace(c.Param("type")) + if channel == "" { + channel = "alipay" + } + if channel != "alipay" { + return out, errParam("当前仅支持支付宝支付(alipay)") + } + outTradeNo := strings.TrimSpace(c.Param("out_trade_no")) + if outTradeNo == "" || len(outTradeNo) > 64 { + return out, errParam("商户订单号 out_trade_no 需为 1-64 位") + } + subject := strings.TrimSpace(c.Param("name")) + if subject == "" || len(subject) > 100 { + return out, errParam("商品名称 name 需为 1-100 个字符") + } + money, err := yuanToFen(c.Param("money")) + if err != nil || money <= 0 || money > payMaxFen { + return out, errParam("订单金额非法(0-2000 元)") + } + notifyURL := strings.TrimSpace(c.Param("notify_url")) + returnURL := strings.TrimSpace(c.Param("return_url")) + for _, u := range []string{notifyURL, returnURL} { + if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return out, errParam("notify_url / return_url 须为合法 URL") + } + } + if !epayVerify(c.Data, merKey, strings.TrimSpace(c.Param("sign"))) { + return out, errParam("签名验证失败") + } + order, err := createPayOrder(st, up, createPayOrderIn{ + OutTradeNo: outTradeNo, Channel: channel, Subject: subject, + Money: money, NotifyURL: notifyURL, ReturnURL: returnURL, + ClientIP: c.RequestCtx.RemoteIP().String(), + }) + if err != nil { + return out, err + } + out.TradeNo, out.OutTradeNo, out.Channel = order.TradeNo, order.OutTradeNo, order.Channel + out.Subject, out.Money, out.Payurl, out.Qrcode = order.Subject, order.Money, order.Payurl, order.Qrcode + return out, nil +} + +func epayJSON(c *fun.RouteCtx, obj map[string]any) error { + raw, err := json.Marshal(obj) + if err != nil { + return err + } + c.RequestCtx.Response.Header.Set("Content-Type", "application/json; charset=utf-8") + c.RequestCtx.Write(raw) + return nil +} + +func epayFail(c *fun.RouteCtx, err error) error { + return epayJSON(c, map[string]any{"code": -1, "msg": err.Error()}) +} + +func errParam(msg string) error { return fun.Error(4001, msg) } diff --git a/backend/internal/service/proxy.go b/backend/internal/service/proxy.go new file mode 100644 index 0000000..dbf3f0e --- /dev/null +++ b/backend/internal/service/proxy.go @@ -0,0 +1,416 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/proxyx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// 代理池后台任务参数 +const ( + proxyCheckInterval = time.Hour // 测活节奏:约每小时一轮 + proxyCheckStale = 55 * time.Minute // 距上次检测超过该时长才重测 + proxyCheckTimeout = 4 * time.Second // 单次 TCP 拨测超时 + proxyCheckWorkers = 128 // 测活并发 + proxyPurgeAge = 7 * 24 * time.Hour // 死代理记录保留时长 + syncDetailCap = 200 // 同步详情里记录的地址上限 +) + +var ( + proxySyncing atomic.Bool + proxyChecking atomic.Bool + proxyManualSem = make(chan struct{}, 1) // 手动触发同步信号 +) + +// ProxySvc 代理池:地区分组浏览 + 同步记录;抓取/测活由后台调度器跑 +type ProxySvc struct { + fun.Ctx + Store *store.Store +} + +func proxyView(p store.Proxy) dto.ProxyView { + return dto.ProxyView{ + Id: p.Id, Ip: p.Ip, Port: p.Port, Address: fmt.Sprintf("%s:%d", p.Ip, p.Port), + Protocols: p.Protocols, Anonymity: p.Anonymity, RegionId: p.RegionID, + RegionCode: p.RegionCode, RegionName: p.RegionName, RegionZhName: p.RegionZhName, + Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime, Alive: p.Alive, + FailCount: p.FailCount, LastCheckedAt: p.LastCheckedAt, + LastAliveAt: p.LastAliveAt, LastSeenAt: p.LastSeenAt, + } +} + +func (s *ProxySvc) List(d dto.ProxyListDto) (dto.ProxyListView, error) { + alive := d.Alive + if alive != 0 && alive != 1 { + alive = -1 + } + items, total, err := s.Store.ListProxies(store.ProxyFilter{ + RegionID: d.RegionId, Protocol: strings.ToUpper(strings.TrimSpace(d.Protocol)), + Keyword: strings.TrimSpace(d.Keyword), Alive: alive, + Page: d.Page, PageSize: d.PageSize, + }) + if err != nil { + return dto.ProxyListView{}, err + } + out := dto.ProxyListView{Total: total, Items: make([]dto.ProxyView, 0, len(items))} + for _, p := range items { + out.Items = append(out.Items, proxyView(p)) + } + return out, nil +} + +func (s *ProxySvc) Regions() ([]dto.ProxyRegionView, error) { + regions, err := s.Store.ListRegions() + if err != nil { + return nil, err + } + out := make([]dto.ProxyRegionView, 0, len(regions)) + for _, r := range regions { + out = append(out, dto.ProxyRegionView{ + Id: r.Id, Code: r.Code, Name: r.Name, ZhName: r.ZhName, + Region: r.Region, AliveCount: r.AliveCount, + }) + } + return out, nil +} + +func (s *ProxySvc) Syncs() ([]dto.ProxySyncView, error) { + syncs, err := s.Store.ListProxySyncs(30) + if err != nil { + return nil, err + } + out := make([]dto.ProxySyncView, 0, len(syncs)) + for _, v := range syncs { + out = append(out, dto.ProxySyncView{ + Id: v.Id, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, Status: v.Status, + Source: v.Source, Total: v.Total, Added: v.Added, Removed: v.Removed, + Updated: v.Updated, DetailJson: v.Detail, Error: v.Error, + }) + } + return out, nil +} + +func (s *ProxySvc) Stats() (dto.ProxyStatsView, error) { + total, err := s.Store.CountAliveProxies() + if err != nil { + return dto.ProxyStatsView{}, err + } + regions, err := s.Store.ListRegions() + if err != nil { + return dto.ProxyStatsView{}, err + } + v := dto.ProxyStatsView{ + Total: total, Regions: int64(len(regions)), + Syncing: proxySyncing.Load(), Checking: proxyChecking.Load(), + LastCheckAt: s.Store.LastProxyCheckAt(), + } + if syncs, err := s.Store.ListProxySyncs(1); err == nil && len(syncs) > 0 { + v.LastSyncAt = syncs[0].FinishedAt + v.LastStatus = syncs[0].Status + } + return v, nil +} + +// SyncNow 手动触发一次同步(后台执行,不等结果) +func (s *ProxySvc) SyncNow() (dto.OkView, error) { + select { + case proxyManualSem <- struct{}{}: + default: + } + return dto.OkView{Message: "ok"}, nil +} + +// CheckNow 手动触发一轮测活(后台执行) +func (s *ProxySvc) CheckNow() (dto.OkView, error) { + go runProxyCheck(context.Background(), s.Store) + return dto.OkView{Message: "ok"}, nil +} + +// ---- 同步与测活 ---- + +type syncDetail struct { + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` +} + +// runProxySync 全量同步:拉取源数据 → 区域 upsert → 代理按 ip:port diff。 +// 源上消失的代理直接删除;已存在的刷新指标并复活。 +func runProxySync(ctx context.Context, st *store.Store) { + if !proxySyncing.CompareAndSwap(false, true) { + return + } + defer proxySyncing.Store(false) + + now := time.Now() + sync := &store.ProxySync{Source: "proxy5", StartedAt: now.Unix()} + syncID, err := st.BeginProxySync(sync.Source, sync.StartedAt) + if err != nil { + log.Printf("proxy: begin sync failed: %v", err) + return + } + sync.Id = syncID + finish := func(status string, detail syncDetail, err error) { + sync.FinishedAt = time.Now().Unix() + sync.Status = status + if b, e := json.Marshal(detail); e == nil { + sync.Detail = string(b) + } + if err != nil { + sync.Error = err.Error() + } + if e := st.FinishProxySync(sync); e != nil { + log.Printf("proxy: finish sync failed: %v", e) + } + } + + // 多源抓取:任一源失败不致命,只影响该源名下代理的删除判定 + type srcData struct { + name string + remotes []proxyx.Remote + } + sources := []srcData{} + failed := map[string]error{} + if p5, _, err := proxyx.Fetch(ctx); err != nil { + failed["proxy5"] = err + } else { + sources = append(sources, srcData{"proxy5", p5}) + } + if rl, err := proxyx.FetchRola(ctx); err != nil { + failed["rola"] = err + } else { + sources = append(sources, srcData{"rola", rl}) + } + if len(sources) == 0 { + err := fmt.Errorf("all sources failed: proxy5=%v rola=%v", failed["proxy5"], failed["rola"]) + finish("error", syncDetail{}, err) + log.Printf("proxy: %v", err) + return + } + + // 按 ip:port 合并各源记录:首个来源保留字段,协议取并集 + type merged struct { + r proxyx.Remote + source string + } + pool := map[string]*merged{} + order := []string{} + for _, src := range sources { + for _, r := range src.remotes { + key := fmt.Sprintf("%s:%d", r.IP, r.Port) + if m, ok := pool[key]; ok { + seen := map[string]bool{} + for _, p := range m.r.Protocols { + seen[p] = true + } + for _, p := range r.Protocols { + if !seen[p] { + m.r.Protocols = append(m.r.Protocols, p) + } + } + continue + } + pool[key] = &merged{r: r, source: src.name} + order = append(order, key) + } + } + sync.Total = int64(len(order)) + seenKeys := map[string]bool{} + for k := range pool { + seenKeys[k] = true + } + + keys, err := st.ProxyKeys() + if err != nil { + finish("error", syncDetail{}, err) + return + } + + var detail syncDetail + regionIDs := map[string]int64{} + for _, key := range order { + m := pool[key] + r := m.r + code := strings.ToUpper(strings.TrimSpace(r.CountryCode)) + if code == "" { + code = "UN" + } + regionID, ok := regionIDs[code] + if !ok { + regionID, err = st.UpsertRegion(code, r.Country, proxyx.ZhName(code), r.Region, r.CountrySlug) + if err != nil { + continue + } + regionIDs[code] = regionID + } + p := &store.Proxy{ + Ip: r.IP, Port: int64(r.Port), Protocols: strings.Join(r.Protocols, ","), + Anonymity: r.Anonymity, Source: m.source, RegionID: regionID, + Latency: int64(r.Latency), Speed: int64(r.Speed), Uptime: int64(r.Uptime), + LastSeenAt: now.Unix(), CreatedAt: now.Unix(), + } + if k, exists := keys[key]; exists { + delete(keys, key) + if err := st.UpdateProxySeen(k.Id, p); err == nil { + sync.Updated++ + } + continue + } + if err := st.InsertProxy(p); err == nil { + sync.Added++ + if len(detail.Added) < syncDetailCap { + detail.Added = append(detail.Added, key) + } + } + } + // 删除:出现在任一成功源里的都保留;只属于失败源的也保留(可能还在源上) + for key, k := range keys { + if seenKeys[key] { + continue + } + if _, srcFailed := failed[k.Source]; srcFailed { + continue + } + if err := st.DeleteProxy(k.Id); err == nil { + sync.Removed++ + if len(detail.Removed) < syncDetailCap { + detail.Removed = append(detail.Removed, key) + } + } + } + var syncErr error + if len(failed) > 0 { + syncErr = fmt.Errorf("partial: %v", failed) + } + finish("ok", detail, syncErr) + log.Printf("proxy: sync done total=%d added=%d updated=%d removed=%d failed=%v", + sync.Total, sync.Added, sync.Updated, sync.Removed, failed) +} + +// runProxyCheck 测活一轮:取距上次检测超过阈值的存活代理,并发 TCP 拨测; +// 连不上即标记为死(alive=0),死记录超过保留期后物理清除。 +func runProxyCheck(ctx context.Context, st *store.Store) { + if !proxyChecking.CompareAndSwap(false, true) { + return + } + defer proxyChecking.Store(false) + + due, err := st.DueProxies(time.Now().Add(-proxyCheckStale).Unix()) + if err != nil { + log.Printf("proxy: due list failed: %v", err) + return + } + if len(due) == 0 { + return + } + now := time.Now().Unix() + sem := make(chan struct{}, proxyCheckWorkers) + var wg sync.WaitGroup + for _, p := range due { + wg.Add(1) + go func(p store.DueProxy) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + latency, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, proxyCheckTimeout) + if err != nil { + _ = st.MarkProxyFailed(p.Id, now, p.FailCount+1) + return + } + _ = st.MarkProxyAlive(p.Id, now, now, int64(latency)) + }(p) + } + wg.Wait() + if n, err := st.PurgeDeadProxies(time.Now().Add(-proxyPurgeAge).Unix()); err == nil && n > 0 { + log.Printf("proxy: purged %d dead records", n) + } + log.Printf("proxy: checked %d proxies", len(due)) +} + +// StartProxyScheduler 调度器:启动补一次同步(数据为空时)→ 每小时测活 → 每日 00:00 同步。 +func StartProxyScheduler(ctx context.Context, st *store.Store) { + if syncs, err := st.ListProxySyncs(1); err == nil { + if len(syncs) == 0 || time.Since(time.Unix(syncs[0].StartedAt, 0)) > 20*time.Hour { + go runProxySync(ctx, st) + } + } + go func() { + checkTick := time.NewTicker(proxyCheckInterval) + defer checkTick.Stop() + for { + next := nextMidnight() + syncTimer := time.NewTimer(time.Until(next)) + select { + case <-ctx.Done(): + syncTimer.Stop() + return + case <-checkTick.C: + runProxyCheck(ctx, st) + case <-proxyManualSem: + go runProxySync(ctx, st) + case <-syncTimer.C: + runProxySync(ctx, st) + } + } + }() + log.Printf("proxy: scheduler started (check hourly, sync daily 00:00)") +} + +func nextMidnight() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) +} + +// ---- 提取密钥管理(admin) ---- + +func proxyKeyView(k store.ProxyApiKey) dto.ProxyKeyView { + return dto.ProxyKeyView{ + Id: k.Id, Key: k.Key, Name: k.Name, RegionId: k.RegionID, + RegionCode: k.RegionCode, RegionZhName: k.RegionZhName, + Enabled: k.Enabled, UsedCount: k.UsedCount, + LastUsedAt: k.LastUsedAt, CreatedAt: k.CreatedAt, + } +} + +func (s *ProxySvc) ListKeys() ([]dto.ProxyKeyView, error) { + keys, err := s.Store.ListProxyApiKeys() + if err != nil { + return nil, err + } + out := make([]dto.ProxyKeyView, 0, len(keys)) + for _, k := range keys { + out = append(out, proxyKeyView(k)) + } + return out, nil +} + +// CreateKey 生成一个提取密钥:绑定地区(0=全部),key 随机生成落库 +func (s *ProxySvc) CreateKey(d dto.ProxyKeySaveDto) (dto.ProxyKeyView, error) { + key, err := shareCode() + if err != nil { + return dto.ProxyKeyView{}, err + } + key = "pk_" + key + key // 20 位随机串,够长防穷举 + id, err := s.Store.InsertProxyApiKey(key, strings.TrimSpace(d.Name), d.RegionId) + if err != nil { + return dto.ProxyKeyView{}, err + } + return dto.ProxyKeyView{Id: id, Key: key, Name: d.Name, RegionId: d.RegionId, Enabled: true}, nil +} + +func (s *ProxySvc) DeleteKey(d dto.IdDto) (dto.OkView, error) { + if err := s.Store.DeleteProxyApiKey(d.Id); err != nil { + return dto.OkView{}, err + } + return dto.OkView{Message: "ok"}, nil +} diff --git a/backend/internal/service/proxy_routes.go b/backend/internal/service/proxy_routes.go new file mode 100644 index 0000000..33d2082 --- /dev/null +++ b/backend/internal/service/proxy_routes.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/proxyx" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +const ( + extractBatchSize = 15 // 每批并行探测的候选数 + extractBatches = 4 // 最多取几批 + extractProbeTO = 3 * time.Second // 单代理探测超时 + extractDeadline = 20 * time.Second // 整个请求的最长耗时 +) + +type extractResult struct { + addr string + lat int + ok bool +} + +// ProxyExtractHandler GET /v1/proxy?key=xxx +// 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。 +// 并行探测一批候选,谁先探活返回谁,纯文本 "ip:port";两批全死返回 503。 +func ProxyExtractHandler(st *store.Store) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + reqCtx := rc.RequestCtx + key := strings.TrimSpace(string(reqCtx.QueryArgs().Peek("key"))) + if key == "" { + routeJSONErr(reqCtx, 401, "missing key") + return nil + } + k, err := st.GetProxyApiKey(key) + if err != nil { + routeJSONErr(reqCtx, 401, "invalid key") + return nil + } + + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline)) + defer cancel() + + for b := 0; b < extractBatches; b++ { + items, err := st.RandomProxies(k.RegionID, "", "", extractBatchSize) + if err != nil { + routeJSONErr(reqCtx, 500, "query failed") + return nil + } + if len(items) == 0 { + break + } + if p, _, ok := probeBatch(ctx, st, items); ok { + st.TouchProxyApiKey(k.Id) + reqCtx.SetContentType("text/plain; charset=utf-8") + reqCtx.SetBodyString(fmt.Sprintf("%s:%d\n", p.Ip, p.Port)) + return nil + } + } + routeJSONErr(reqCtx, 503, "no alive proxy") + return nil + } +} + +// probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。 +func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, int, bool) { + type hit struct { + p store.RandomProxy + lat int + } + hits := make(chan hit, 1) + var wg sync.WaitGroup + for _, p := range items { + wg.Add(1) + go func(p store.RandomProxy) { + defer wg.Done() + lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO) + if err != nil { + _ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1) + return + } + select { + case hits <- hit{p, lat}: + case <-ctx.Done(): + } + }(p) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case h := <-hits: + now := time.Now().Unix() + _ = st.MarkProxyAlive(h.p.Id, now, now, int64(h.lat)) + return h.p, h.lat, true + case <-done: + return store.RandomProxy{}, 0, false + case <-ctx.Done(): + return store.RandomProxy{}, 0, false + } +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go new file mode 100644 index 0000000..133b54d --- /dev/null +++ b/backend/internal/service/server.go @@ -0,0 +1,569 @@ +package service + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strconv" + "strings" + "time" + + "github.com/cyi-cc/fun" + "github.com/fasthttp/websocket" + "github.com/pkg/sftp" + "github.com/valyala/fasthttp" + "golang.org/x/crypto/ssh" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/sshx" + "github.com/cyi-cc/cyi-box/backend/internal/store" + "github.com/cyi-cc/cyi-box/backend/internal/vault" +) + +// ServerSvc 服务器管理:连接密钥加密落库,所有 SSH/SFTP 端点仅管理员。 +// 指标采集与文件操作每次新拨号——管理场景连接低频,免去连接池维护成本。 +type ServerSvc struct { + fun.Ctx + Store *store.Store + Cfg *config.Config `fun:"auto"` +} + +func (s *ServerSvc) List() ([]dto.ServerView, error) { + items, err := s.Store.ListServers() + if err != nil { + return nil, fmt.Errorf("servers: list failed: %w", err) + } + views := make([]dto.ServerView, 0, len(items)) + for _, e := range items { + views = append(views, serverView(e)) + } + return views, nil +} + +func (s *ServerSvc) Save(d dto.SaveServerDto) (dto.ServerView, error) { + name := strings.TrimSpace(d.Name) + host := strings.TrimSpace(d.Host) + username := strings.TrimSpace(d.Username) + if name == "" || len(name) > 64 { + return dto.ServerView{}, fun.Error(4001, "名称必填且不超过 64 字符") + } + if host == "" || len(host) > 255 { + return dto.ServerView{}, fun.Error(4001, "主机地址必填") + } + if username == "" { + return dto.ServerView{}, fun.Error(4001, "用户名必填") + } + authType := "password" + if d.AuthType == "key" { + authType = "key" + } + port := int64(22) + if d.Port != nil { + if *d.Port < 1 || *d.Port > 65535 { + return dto.ServerView{}, fun.Error(4001, "端口范围 1-65535") + } + port = *d.Port + } + + e := store.Server{Name: name, Host: host, Port: port, Username: username, AuthType: authType} + if d.Id != nil { + old, err := s.Store.GetServer(*d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.ServerView{}, fun.Error(4001, "服务器不存在") + } + if err != nil { + return dto.ServerView{}, err + } + e = old + e.Name, e.Host, e.Port, e.Username, e.AuthType = name, host, port, username, authType + } + if d.Note != nil { + e.Note = strings.TrimSpace(*d.Note) + } + if d.Secret != nil { + secret := strings.TrimSpace(*d.Secret) + if secret == "" { + e.SecretEnc = "" + } else { + enc, err := vault.Encrypt(s.Cfg.VaultKey, secret) + if err != nil { + return dto.ServerView{}, err + } + e.SecretEnc = enc + } + } + if e.SecretEnc == "" { + return dto.ServerView{}, fun.Error(4001, "请填写密码或私钥") + } + if err := s.Store.SaveServer(&e); err != nil { + return dto.ServerView{}, fmt.Errorf("servers: save failed: %w", err) + } + return serverView(e), nil +} + +func (s *ServerSvc) Delete(d dto.ServerIdDto) (dto.OkView, error) { + if err := s.Store.DeleteServer(d.Id); err != nil { + if errors.Is(err, store.ErrNotFound) { + return dto.OkView{}, fun.Error(4001, "服务器不存在") + } + return dto.OkView{}, fmt.Errorf("servers: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +// Metrics SSH 拨号 + 采集;拨不通直接返回带因的错误,前端即「离线」 +func (s *ServerSvc) Metrics(d dto.ServerIdDto) (dto.ServerMetricsView, error) { + client, err := s.dial(d.Id) + if err != nil { + return dto.ServerMetricsView{}, err + } + defer client.Close() + m, err := sshx.CollectMetrics(client) + if err != nil { + return dto.ServerMetricsView{}, err + } + v := dto.ServerMetricsView{ + Hostname: m.Hostname, + Os: m.OS, + Kernel: m.Kernel, + UptimeSeconds: m.UptimeSeconds, + CpuPercent: m.CpuPercent, + Load1: m.Load1, + Load5: m.Load5, + Load15: m.Load15, + MemTotal: m.MemTotal, + MemUsed: m.MemUsed, + MemAvail: m.MemAvail, + SwapTotal: m.SwapTotal, + SwapUsed: m.SwapUsed, + DiskTotal: m.DiskTotal, + DiskUsed: m.DiskUsed, + DiskAvail: m.DiskAvail, + NetRxRate: m.NetRxRate, + NetTxRate: m.NetTxRate, + NetRxTotal: m.NetRxTotal, + NetTxTotal: m.NetTxTotal, + CollectedAt: time.Now().Unix(), + } + for _, dsk := range m.Disks { + v.Disks = append(v.Disks, dto.DiskMountView{ + Mount: dsk.Mount, Fs: dsk.Fs, Total: dsk.Total, + Used: dsk.Used, Avail: dsk.Avail, Percent: dsk.Percent, + }) + } + return v, nil +} + +func (s *ServerSvc) dial(id int64) (*ssh.Client, error) { + e, err := s.Store.GetServer(id) + if errors.Is(err, store.ErrNotFound) { + return nil, fun.Error(4001, "服务器不存在") + } + if err != nil { + return nil, err + } + return dialServer(e, s.Cfg.VaultKey) +} + +// dialServer 解密 secret 并拨号;route 处理器与 RPC 共用 +func dialServer(e store.Server, key []byte) (*ssh.Client, error) { + if e.SecretEnc == "" { + return nil, fun.Error(4001, "该服务器未配置密码/私钥") + } + secret, err := vault.Decrypt(key, e.SecretEnc) + if err != nil { + return nil, fmt.Errorf("servers: decrypt secret failed: %w", err) + } + return sshx.Dial(e.Host, e.Port, e.Username, e.AuthType, secret) +} + +func serverView(e store.Server) dto.ServerView { + v := dto.ServerView{ + Id: e.Id, + Name: e.Name, + Host: e.Host, + Port: e.Port, + Username: e.Username, + AuthType: e.AuthType, + HasSecret: e.SecretEnc != "", + CreatedAt: e.CreatedAt, + } + if e.Note != "" { + v.Note = &e.Note + } + return v +} + +// ---- SFTP RPC ---- + +// cleanPath 规整远程路径:缺省 "/",防穿越由 SSH 权限兜底 +func cleanPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + return path.Clean(p) +} + +func (s *ServerSvc) sftpClient(id int64) (*sftp.Client, *ssh.Client, error) { + c, err := s.dial(id) + if err != nil { + return nil, nil, err + } + fc, err := sftp.NewClient(c) + if err != nil { + c.Close() + return nil, nil, fmt.Errorf("sftp: 初始化失败: %w", err) + } + return fc, c, nil +} + +func (s *ServerSvc) SftpList(d dto.SftpPathDto) (dto.SftpListView, error) { + fc, c, err := s.sftpClient(d.Id) + if err != nil { + return dto.SftpListView{}, err + } + defer fc.Close() + defer c.Close() + p := cleanPath(d.Path) + fis, err := fc.ReadDir(p) + if err != nil { + return dto.SftpListView{}, fun.Error(4001, "无法读取目录:"+err.Error()) + } + v := dto.SftpListView{Path: p, Entries: []dto.SftpEntryView{}} + for _, fi := range fis { + v.Entries = append(v.Entries, dto.SftpEntryView{ + Name: fi.Name(), + Size: fi.Size(), + Mode: fi.Mode().String(), + IsDir: fi.IsDir(), + ModTime: fi.ModTime().Unix(), + }) + } + return v, nil +} + +func (s *ServerSvc) SftpMkdir(d dto.SftpPathDto) (dto.OkView, error) { + fc, c, err := s.sftpClient(d.Id) + if err != nil { + return dto.OkView{}, err + } + defer fc.Close() + defer c.Close() + if err := fc.Mkdir(cleanPath(d.Path)); err != nil { + return dto.OkView{}, fun.Error(4001, "创建目录失败:"+err.Error()) + } + return dto.OkView{Message: "已创建"}, nil +} + +// SftpRemove 删文件或空目录;非空目录拒绝递归删除,避免误删整棵目录树 +func (s *ServerSvc) SftpRemove(d dto.SftpPathDto) (dto.OkView, error) { + fc, c, err := s.sftpClient(d.Id) + if err != nil { + return dto.OkView{}, err + } + defer fc.Close() + defer c.Close() + p := cleanPath(d.Path) + fi, err := fc.Stat(p) + if err != nil { + return dto.OkView{}, fun.Error(4001, "路径不存在") + } + if fi.IsDir() { + err = fc.RemoveDirectory(p) + } else { + err = fc.Remove(p) + } + if err != nil { + return dto.OkView{}, fun.Error(4001, "删除失败:"+err.Error()+"(目录需为空)") + } + return dto.OkView{Message: "已删除"}, nil +} + +func (s *ServerSvc) SftpRename(d dto.SftpRenameDto) (dto.OkView, error) { + newName := strings.TrimSpace(d.NewName) + if newName == "" || strings.ContainsAny(newName, "/\\") { + return dto.OkView{}, fun.Error(4001, "新名字无效") + } + fc, c, err := s.sftpClient(d.Id) + if err != nil { + return dto.OkView{}, err + } + defer fc.Close() + defer c.Close() + old := cleanPath(d.Path) + if err := fc.PosixRename(old, path.Join(path.Dir(old), newName)); err != nil { + return dto.OkView{}, fun.Error(4001, "重命名失败:"+err.Error()) + } + return dto.OkView{Message: "已重命名"}, nil +} + +// ---- 自定义路由:上传 / 下载 / WS 终端 ---- + +// routeAdmin 自定义路由不走 AuthGuard,手动校验 query token 且要求管理员 +func routeAdmin(st *store.Store, ctx *fasthttp.RequestCtx) error { + token := string(ctx.QueryArgs().Peek("token")) + if token == "" { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + return errors.New("missing token") + } + su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter) + if err != nil { + ctx.SetStatusCode(fasthttp.StatusUnauthorized) + return errors.New("invalid token") + } + if su.User.Role != "admin" || su.User.Status != "active" { + ctx.SetStatusCode(fasthttp.StatusForbidden) + return errors.New("admin only") + } + return nil +} + +func routeServer(st *store.Store, ctx *fasthttp.RequestCtx) (store.Server, error) { + id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64) + e, err := st.GetServer(id) + if err != nil { + ctx.SetStatusCode(fasthttp.StatusNotFound) + ctx.SetBodyString("server not found") + } + return e, err +} + +// SftpDownloadHandler GET /files/download?id=&path=&token= 流式回传文件 +func SftpDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + if err := routeAdmin(st, ctx); err != nil { + return nil + } + e, err := routeServer(st, ctx) + if err != nil { + return nil + } + client, err := dialServer(e, cfg.VaultKey) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString("SSH 连接失败:" + err.Error()) + return nil + } + fc, err := sftp.NewClient(client) + if err != nil { + client.Close() + ctx.SetStatusCode(502) + ctx.SetBodyString("SFTP 初始化失败") + return nil + } + p := cleanPath(string(ctx.QueryArgs().Peek("path"))) + f, err := fc.Open(p) + if err != nil { + fc.Close() + client.Close() + ctx.SetStatusCode(404) + ctx.SetBodyString("文件不可读:" + err.Error()) + return nil + } + if fi, err := f.Stat(); err == nil { + ctx.Response.Header.Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) + } + name := path.Base(p) + ctx.Response.Header.Set("Content-Disposition", "attachment; filename*=UTF-8''"+urlPathEscape(name)) + ctx.SetContentType("application/octet-stream") + // 流式回传:连接生命周期交给 StreamWriter 收尾 + ctx.SetBodyStreamWriter(func(w *bufio.Writer) { + _, _ = io.Copy(w, f) + f.Close() + fc.Close() + client.Close() + }) + return nil + } +} + +func urlPathEscape(s string) string { + var b strings.Builder + for _, c := range []byte(s) { + if c < 0x20 || c > 0x7e || strings.ContainsRune("\"%;+", rune(c)) { + fmt.Fprintf(&b, "%%%02X", c) + } else { + b.WriteByte(c) + } + } + return b.String() +} + +// SftpUploadHandler POST /files/upload?id=&dir=&token= multipart 字段 file +func SftpUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + if err := routeAdmin(st, ctx); err != nil { + ctx.SetBodyString(`{"status":2,"msg":"未授权"}`) + return nil + } + e, err := routeServer(st, ctx) + if err != nil { + return nil + } + fh, err := ctx.FormFile("file") + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`) + return nil + } + src, err := fh.Open() + if err != nil { + ctx.SetStatusCode(400) + ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`) + return nil + } + defer src.Close() + + client, err := dialServer(e, cfg.VaultKey) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString(`{"status":2,"msg":"SSH 连接失败"}`) + return nil + } + defer client.Close() + fc, err := sftp.NewClient(client) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString(`{"status":2,"msg":"SFTP 初始化失败"}`) + return nil + } + defer fc.Close() + + dir := cleanPath(string(ctx.QueryArgs().Peek("dir"))) + dst := path.Join(dir, path.Base(fh.Filename)) + w, err := fc.Create(dst) + if err != nil { + ctx.SetStatusCode(500) + ctx.SetBodyString(`{"status":2,"msg":"创建远端文件失败:` + err.Error() + `"}`) + return nil + } + if _, err := io.Copy(w, src); err != nil { + w.Close() + ctx.SetStatusCode(500) + ctx.SetBodyString(`{"status":2,"msg":"写入失败:` + err.Error() + `"}`) + return nil + } + w.Close() + ctx.SetContentType("application/json") + ctx.SetBodyString(`{"status":0,"msg":"ok"}`) + return nil + } +} + +// TerminalHandler GET /ws/terminal?id=&token= 升级 WebSocket → SSH PTY。 +// 协议:客户端→服务端首字节 0x01 为 JSON 控制帧 {"cols":N,"rows":N}(resize), +// 其余原样写入 shell stdin;服务端→客户端全部原样回显。 +func TerminalHandler(st *store.Store, cfg *config.Config) fun.RouteHandler { + upgrader := websocket.FastHTTPUpgrader{ + CheckOrigin: func(*fasthttp.RequestCtx) bool { return true }, + } + return func(rc *fun.RouteCtx) error { + ctx := rc.RequestCtx + if err := routeAdmin(st, ctx); err != nil { + ctx.SetBodyString("unauthorized") + return nil + } + e, err := routeServer(st, ctx) + if err != nil { + return nil + } + client, err := dialServer(e, cfg.VaultKey) + if err != nil { + ctx.SetStatusCode(502) + ctx.SetBodyString("SSH 连接失败:" + err.Error()) + return nil + } + // Upgrade 内部 hijack 连接后异步跑回调;ssh.Client 生命周期归 runTerm + if err := upgrader.Upgrade(ctx, func(ws *websocket.Conn) { + runTerm(ws, client) + }); err != nil { + client.Close() + return nil + } + return nil + } +} + +type termResize struct { + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +func runTerm(ws *websocket.Conn, client *ssh.Client) { + defer ws.Close() + defer client.Close() + sess, err := client.NewSession() + if err != nil { + _ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nopen session failed: "+err.Error()+"\r\n")) + return + } + defer sess.Close() + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil { + _ = ws.WriteMessage(websocket.TextMessage, []byte("\r\npty failed: "+err.Error()+"\r\n")) + return + } + stdin, err := sess.StdinPipe() + if err != nil { + return + } + stdout, err := sess.StdoutPipe() + if err != nil { + return + } + sess.Stderr = sess.Stdout + if err := sess.Shell(); err != nil { + _ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nshell failed: "+err.Error()+"\r\n")) + return + } + + done := make(chan struct{}) + // ws → stdin + go func() { + defer close(done) + for { + _, data, err := ws.ReadMessage() + if err != nil { + _ = sess.Close() + return + } + if len(data) > 0 && data[0] == 0x01 { + var r termResize + if json.Unmarshal(data[1:], &r) == nil && r.Cols > 0 && r.Rows > 0 { + _ = sess.WindowChange(r.Rows, r.Cols) + } + continue + } + if _, err := stdin.Write(data); err != nil { + return + } + } + }() + + // stdout → ws + buf := make([]byte, 32*1024) + for { + n, err := stdout.Read(buf) + if n > 0 { + if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil { + break + } + } + if err != nil { + break + } + } + _ = sess.Close() + <-done +} diff --git a/backend/internal/service/setting.go b/backend/internal/service/setting.go new file mode 100644 index 0000000..46c84de --- /dev/null +++ b/backend/internal/service/setting.go @@ -0,0 +1,39 @@ +package service + +import ( + "strings" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// SettingSvc 站点级 KV 设置:读取对所有登录用户开放,写入仅管理员 +type SettingSvc struct { + fun.Ctx + Store *store.Store +} + +func (s *SettingSvc) Get(d dto.SettingDto) (dto.SettingView, error) { + key := strings.TrimSpace(d.Key) + if key == "" || len(key) > 64 { + return dto.SettingView{}, fun.Error(4001, "key 必填且不超过 64 字符") + } + v, err := s.Store.GetSetting(key) + if err != nil { + return dto.SettingView{}, err + } + return dto.SettingView{Value: v}, nil +} + +func (s *SettingSvc) Set(d dto.SettingDto) (dto.OkView, error) { + key := strings.TrimSpace(d.Key) + if key == "" || len(key) > 64 || len(d.Value) > 2000 { + return dto.OkView{}, fun.Error(4001, "key 必填且不超过 64 字符,value 不超过 2000 字符") + } + if err := s.Store.SetSetting(key, strings.TrimSpace(d.Value)); err != nil { + return dto.OkView{}, err + } + return dto.OkView{Message: "已保存"}, nil +} diff --git a/backend/internal/service/tool.go b/backend/internal/service/tool.go new file mode 100644 index 0000000..06555c5 --- /dev/null +++ b/backend/internal/service/tool.go @@ -0,0 +1,90 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// ToolSvc 工具箱注册表:List 登录用户可见(驱动侧边导航),管理端点为 admin +type ToolSvc struct { + fun.Ctx + Store *store.Store +} + +func (s *ToolSvc) List() ([]dto.ToolView, error) { + tools, err := s.Store.ListTools(true) + if err != nil { + return nil, fmt.Errorf("tool: list failed: %w", err) + } + return toolViews(tools), nil +} + +func (s *ToolSvc) AdminList() ([]dto.ToolView, error) { + tools, err := s.Store.ListTools(false) + if err != nil { + return nil, fmt.Errorf("tool: list failed: %w", err) + } + return toolViews(tools), nil +} + +func (s *ToolSvc) SaveTool(d dto.SaveToolDto) (dto.ToolView, error) { + name := strings.TrimSpace(d.Name) + url := strings.TrimSpace(d.Url) + if name == "" || len(name) > 32 { + return dto.ToolView{}, fun.Error(4001, "工具名必填且不超过 32 字符") + } + t := store.Tool{ + Name: name, + Url: url, + Sort: 0, + Enabled: true, + } + if d.Id != nil { + t.Id = *d.Id + } + if d.Icon != nil { + t.Icon = strings.TrimSpace(*d.Icon) + } + if d.Description != nil { + t.Description = strings.TrimSpace(*d.Description) + } + if d.Sort != nil { + t.Sort = *d.Sort + } + if d.Enabled != nil { + t.Enabled = *d.Enabled + } + if err := s.Store.SaveTool(&t); err != nil { + return dto.ToolView{}, fmt.Errorf("tool: save failed: %w", err) + } + views := toolViews([]store.Tool{t}) + return views[0], nil +} + +func (s *ToolSvc) DeleteTool(d dto.DeleteToolDto) (dto.OkView, error) { + if err := s.Store.DeleteTool(d.Id); err != nil { + return dto.OkView{}, fmt.Errorf("tool: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +func toolViews(tools []store.Tool) []dto.ToolView { + views := make([]dto.ToolView, 0, len(tools)) + for _, t := range tools { + views = append(views, dto.ToolView{ + Id: t.Id, + Name: t.Name, + Icon: t.Icon, + Url: t.Url, + Description: t.Description, + Sort: t.Sort, + Enabled: t.Enabled, + }) + } + return views +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go new file mode 100644 index 0000000..b61397c --- /dev/null +++ b/backend/internal/service/user.go @@ -0,0 +1,147 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + "github.com/cyi-cc/fun" + "golang.org/x/crypto/bcrypt" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" +) + +// UserSvc 用户管理:全部 policyAdmin, Guard 已保证仅管理员可达 +type UserSvc struct { + fun.Ctx + Store *store.Store +} + +func (s *UserSvc) ListUsers(d dto.ListUsersDto) (dto.UserPageView, error) { + page := max(d.Page, 1) + pageSize := min(max(d.PageSize, 1), 100) + keyword := "" + if d.Keyword != nil { + keyword = strings.TrimSpace(*d.Keyword) + } + users, total, err := s.Store.ListUsers(keyword, page, pageSize) + if err != nil { + return dto.UserPageView{}, fmt.Errorf("user: list failed: %w", err) + } + items := make([]dto.UserView, 0, len(users)) + for _, u := range users { + items = append(items, userView(u)) + } + return dto.UserPageView{Total: total, Items: items}, nil +} + +// SaveUser Id 为空创建(密码必填),非空更新(密码留空不变) +func (s *UserSvc) SaveUser(d dto.SaveUserDto) (dto.UserView, error) { + name := strings.TrimSpace(d.Name) + if name == "" || len(name) > 32 { + return dto.UserView{}, fun.Error(4001, "用户名必填且不超过 32 字符") + } + if d.Role != "admin" && d.Role != "user" { + return dto.UserView{}, fun.Error(4001, "角色无效") + } + + if d.Id == nil { + if d.Password == nil || len(*d.Password) < 6 { + return dto.UserView{}, fun.Error(4001, "密码至少 6 位") + } + if _, err := s.Store.GetUserByName(name); err == nil { + return dto.UserView{}, fun.Error(4001, "用户名已存在") + } else if !errors.Is(err, store.ErrNotFound) { + return dto.UserView{}, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost) + if err != nil { + return dto.UserView{}, err + } + u, err := s.Store.CreateUser(name, string(hash), d.Role) + if err != nil { + return dto.UserView{}, fmt.Errorf("user: create failed: %w", err) + } + return userView(u), nil + } + + u, err := s.Store.GetUserByID(*d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.UserView{}, fun.Error(4001, "用户不存在") + } + if err != nil { + return dto.UserView{}, err + } + if existing, err := s.Store.GetUserByName(name); err == nil && existing.Id != u.Id { + return dto.UserView{}, fun.Error(4001, "用户名已存在") + } + status := u.Status + if d.Status != nil { + if *d.Status != "active" && *d.Status != "disabled" { + return dto.UserView{}, fun.Error(4001, "状态无效") + } + status = *d.Status + } + if err := s.guardLastAdmin(u, d.Role, status); err != nil { + return dto.UserView{}, err + } + var hash *string + if d.Password != nil && *d.Password != "" { + if len(*d.Password) < 6 { + return dto.UserView{}, fun.Error(4001, "密码至少 6 位") + } + h, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost) + if err != nil { + return dto.UserView{}, err + } + hs := string(h) + hash = &hs + } + if err := s.Store.UpdateUser(u.Id, name, d.Role, status, hash); err != nil { + return dto.UserView{}, fmt.Errorf("user: update failed: %w", err) + } + u, err = s.Store.GetUserByID(u.Id) + if err != nil { + return dto.UserView{}, err + } + return userView(u), nil +} + +func (s *UserSvc) DeleteUser(d dto.DeleteUserDto) (dto.OkView, error) { + su, err := sessionFrom(s.RequestCtx) + if err != nil { + return dto.OkView{}, err + } + if d.Id == su.User.Id { + return dto.OkView{}, fun.Error(4001, "不能删除当前登录账号") + } + u, err := s.Store.GetUserByID(d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.OkView{}, fun.Error(4001, "用户不存在") + } + if err != nil { + return dto.OkView{}, err + } + if err := s.guardLastAdmin(u, "user", "disabled"); err != nil { + return dto.OkView{}, err + } + if err := s.Store.DeleteUser(d.Id); err != nil { + return dto.OkView{}, fmt.Errorf("user: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +// guardLastAdmin 阻止把最后一个可用管理员降级/禁用/删除 +func (s *UserSvc) guardLastAdmin(u store.User, newRole, newStatus string) error { + if u.Role == "admin" && u.Status == "active" && (newRole != "admin" || newStatus != "active") { + n, err := s.Store.CountAdmins() + if err != nil { + return err + } + if n <= 1 { + return fun.Error(4001, "至少需要保留一个可用管理员") + } + } + return nil +} diff --git a/backend/internal/service/vault.go b/backend/internal/service/vault.go new file mode 100644 index 0000000..7275eba --- /dev/null +++ b/backend/internal/service/vault.go @@ -0,0 +1,188 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/cyi-cc/fun" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/dto" + "github.com/cyi-cc/cyi-box/backend/internal/store" + "github.com/cyi-cc/cyi-box/backend/internal/vault" +) + +// VaultSvc 密码箱:密文只在服务端出现;列表只回元数据, +// 密码经 Reveal 显式取回,2FA 只回当前验证码不回密钥。 +type VaultSvc struct { + fun.Ctx + Store *store.Store + Cfg *config.Config `fun:"auto"` +} + +func (s *VaultSvc) owner() (store.SessionUser, error) { + return sessionFrom(s.RequestCtx) +} + +func (s *VaultSvc) List() ([]dto.VaultItemView, error) { + su, err := s.owner() + if err != nil { + return nil, err + } + items, err := s.Store.ListVault(su.User.Id, "") + if err != nil { + return nil, fmt.Errorf("vault: list failed: %w", err) + } + views := make([]dto.VaultItemView, 0, len(items)) + for _, e := range items { + views = append(views, vaultView(e)) + } + return views, nil +} + +func (s *VaultSvc) Save(d dto.SaveVaultDto) (dto.VaultItemView, error) { + su, err := s.owner() + if err != nil { + return dto.VaultItemView{}, err + } + title := strings.TrimSpace(d.Title) + if title == "" || len(title) > 64 { + return dto.VaultItemView{}, fun.Error(4001, "标题必填且不超过 64 字符") + } + + e := store.VaultEntry{UserID: su.User.Id, Title: title} + if d.Id != nil { + old, err := s.Store.GetVault(su.User.Id, *d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.VaultItemView{}, fun.Error(4001, "条目不存在") + } + if err != nil { + return dto.VaultItemView{}, err + } + e = old + e.Title = title + } + if d.Username != nil { + e.Username = strings.TrimSpace(*d.Username) + } + if d.Url != nil { + e.Url = strings.TrimSpace(*d.Url) + } + if d.Note != nil { + e.Note = strings.TrimSpace(*d.Note) + } + + if d.Password != nil { + enc, err := vault.Encrypt(s.Cfg.VaultKey, *d.Password) + if err != nil { + return dto.VaultItemView{}, err + } + e.PasswordEnc = enc + } + if d.TotpSecret != nil { + secret := strings.TrimSpace(*d.TotpSecret) + if secret == "" { + e.TotpEnc = "" // 空字符串 = 清除 2FA + } else { + parsed, err := vault.ParseSecret(secret) + if err != nil { + return dto.VaultItemView{}, fun.Error(4001, "2FA 密钥无效:支持 base32 或 otpauth:// 链接") + } + enc, err := vault.Encrypt(s.Cfg.VaultKey, parsed) + if err != nil { + return dto.VaultItemView{}, err + } + e.TotpEnc = enc + } + } + if err := s.Store.SaveVault(&e); err != nil { + return dto.VaultItemView{}, fmt.Errorf("vault: save failed: %w", err) + } + return vaultView(e), nil +} + +// Reveal 显式取回明文密码(前端点「查看」才调用,列表永不携带) +func (s *VaultSvc) Reveal(d dto.VaultIdDto) (dto.SecretView, error) { + su, err := s.owner() + if err != nil { + return dto.SecretView{}, err + } + e, err := s.Store.GetVault(su.User.Id, d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.SecretView{}, fun.Error(4001, "条目不存在") + } + if err != nil { + return dto.SecretView{}, err + } + if e.PasswordEnc == "" { + return dto.SecretView{}, fun.Error(4001, "该条目没有密码") + } + pt, err := vault.Decrypt(s.Cfg.VaultKey, e.PasswordEnc) + if err != nil { + return dto.SecretView{}, fmt.Errorf("vault: decrypt failed: %w", err) + } + return dto.SecretView{Value: pt}, nil +} + +// Totp 返回当前 6 位验证码与剩余秒数;密钥不出服务端 +func (s *VaultSvc) Totp(d dto.VaultIdDto) (dto.TotpView, error) { + su, err := s.owner() + if err != nil { + return dto.TotpView{}, err + } + e, err := s.Store.GetVault(su.User.Id, d.Id) + if errors.Is(err, store.ErrNotFound) { + return dto.TotpView{}, fun.Error(4001, "条目不存在") + } + if err != nil { + return dto.TotpView{}, err + } + if e.TotpEnc == "" { + return dto.TotpView{}, fun.Error(4001, "该条目未设置 2FA") + } + secret, err := vault.Decrypt(s.Cfg.VaultKey, e.TotpEnc) + if err != nil { + return dto.TotpView{}, fmt.Errorf("vault: decrypt totp failed: %w", err) + } + code, left, err := vault.TOTP(secret, time.Now()) + if err != nil { + return dto.TotpView{}, fun.Error(4001, "2FA 密钥无效") + } + return dto.TotpView{Code: code, SecondsLeft: left}, nil +} + +func (s *VaultSvc) Delete(d dto.VaultIdDto) (dto.OkView, error) { + su, err := s.owner() + if err != nil { + return dto.OkView{}, err + } + if err := s.Store.DeleteVault(su.User.Id, d.Id); err != nil { + if errors.Is(err, store.ErrNotFound) { + return dto.OkView{}, fun.Error(4001, "条目不存在") + } + return dto.OkView{}, fmt.Errorf("vault: delete failed: %w", err) + } + return dto.OkView{Message: "已删除"}, nil +} + +func vaultView(e store.VaultEntry) dto.VaultItemView { + v := dto.VaultItemView{ + Id: e.Id, + Title: e.Title, + HasPassword: e.PasswordEnc != "", + HasTotp: e.TotpEnc != "", + UpdatedAt: e.UpdatedAt, + } + if e.Username != "" { + v.Username = &e.Username + } + if e.Url != "" { + v.Url = &e.Url + } + if e.Note != "" { + v.Note = &e.Note + } + return v +} diff --git a/backend/internal/service/webreq.go b/backend/internal/service/webreq.go new file mode 100644 index 0000000..249c710 --- /dev/null +++ b/backend/internal/service/webreq.go @@ -0,0 +1,105 @@ +package service + +import ( + "encoding/json" + "strings" + "time" + + "github.com/cyi-cc/fun" + "github.com/valyala/fasthttp" + + "github.com/cyi-cc/cyi-box/backend/internal/dto" +) + +const ( + webReqTimeout = 30 * time.Second + webRespMaxBody = 2 << 20 // 响应体截断上限 2MB + webReqMaxBody = 1 << 20 // 请求体上限 1MB + webMaxRedirect = 5 + webMaxHeaderPairs = 64 +) + +// fun autowired 只处理导出字段,client 用包级单例复用 +var webClient = &fasthttp.Client{ + Name: "cyi-box-webreq", + MaxConnsPerHost: 16, + ReadTimeout: webReqTimeout, + WriteTimeout: webReqTimeout, + MaxConnWaitTimeout: 5 * time.Second, +} + +// WebSvc HTTP 请求代理(Postman 工具):服务端代发以绕开浏览器 CORS。 +// 只放行 http/https;headersJson 为 {"k":"v"} 对象。 +type WebSvc struct { + fun.Ctx +} + +func (s *WebSvc) Fetch(d dto.WebFetchDto) (dto.WebFetchView, error) { + method := strings.ToUpper(strings.TrimSpace(d.Method)) + switch method { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": + default: + return dto.WebFetchView{}, fun.Error(4001, "不支持的请求方法: "+method) + } + url := strings.TrimSpace(d.Url) + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return dto.WebFetchView{}, fun.Error(4001, "URL 必须以 http:// 或 https:// 开头") + } + if len(d.Body) > webReqMaxBody { + return dto.WebFetchView{}, fun.Error(4001, "请求体超过 1MB 上限") + } + + var headers map[string]string + if strings.TrimSpace(d.HeadersJson) != "" { + if err := json.Unmarshal([]byte(d.HeadersJson), &headers); err != nil { + return dto.WebFetchView{}, fun.Error(4001, "请求头 JSON 解析失败: "+err.Error()) + } + if len(headers) > webMaxHeaderPairs { + return dto.WebFetchView{}, fun.Error(4001, "请求头数量超过上限") + } + } + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + req.Header.SetMethod(method) + req.SetRequestURI(url) + for k, v := range headers { + k = strings.TrimSpace(k) + if k == "" || strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") { + continue + } + req.Header.Set(k, v) + } + if d.Body != "" { + req.SetBodyString(d.Body) + } + + start := time.Now() + err := webClient.DoRedirects(req, resp, webMaxRedirect) + elapsed := time.Since(start).Milliseconds() + if err != nil { + return dto.WebFetchView{}, fun.Error(4002, "请求失败: "+err.Error()) + } + + view := dto.WebFetchView{ + Status: int64(resp.StatusCode()), + ElapsedMs: elapsed, + } + respHeaders := map[string]string{} + resp.Header.VisitAll(func(k, v []byte) { + respHeaders[string(k)] = string(v) + }) + if b, e := json.Marshal(respHeaders); e == nil { + view.HeadersJson = string(b) + } + body := resp.Body() + if len(body) > webRespMaxBody { + body = body[:webRespMaxBody] + view.Truncated = true + } + view.Body = string(body) + return view, nil +} diff --git a/backend/internal/sshx/sshx.go b/backend/internal/sshx/sshx.go new file mode 100644 index 0000000..46284e8 --- /dev/null +++ b/backend/internal/sshx/sshx.go @@ -0,0 +1,295 @@ +// Package sshx SSH 连接与指标采集:Dial 负责建连(密码/私钥), +// CollectMetrics 用一个 shell 脚本两采样算出 CPU% 与网卡速率。 +package sshx + +import ( + "bytes" + "fmt" + "net" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +// Dial 建立 SSH 连接。authType: "password" | "key";secret 为明文密码或 PEM 私钥。 +// hostKey 校验用 InsecureIgnoreHostKey——自管工具箱场景,目标机由管理员自己登记。 +func Dial(host string, port int64, username, authType, secret string) (*ssh.Client, error) { + cfg := &ssh.ClientConfig{ + User: username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 8 * time.Second, + } + switch authType { + case "key": + signer, err := ssh.ParsePrivateKey([]byte(secret)) + if err != nil { + return nil, fmt.Errorf("sshx: 私钥无效(暂不支持加密私钥): %w", err) + } + cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + default: + cfg.Auth = []ssh.AuthMethod{ssh.Password(secret), ssh.KeyboardInteractive(func(_ string, _ string, questions []string, _ []bool) ([]string, error) { + ans := make([]string, len(questions)) + for i := range ans { + ans[i] = secret + } + return ans, nil + })} + } + addr := net.JoinHostPort(host, strconv.FormatInt(port, 10)) + conn, err := net.DialTimeout("tcp", addr, 8*time.Second) + if err != nil { + return nil, fmt.Errorf("sshx: 连接 %s 失败: %w", addr, err) + } + c, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg) + if err != nil { + conn.Close() + return nil, fmt.Errorf("sshx: SSH 握手失败: %w", err) + } + return ssh.NewClient(c, chans, reqs), nil +} + +// Metrics 一次采集的全部指标(字节/百分比原始值,展示层格式化) +type Metrics struct { + Hostname string + OS string + Kernel string + UptimeSeconds int64 + CpuPercent int64 + Load1 string + Load5 string + Load15 string + MemTotal int64 + MemAvail int64 + MemUsed int64 + SwapTotal int64 + SwapUsed int64 + DiskTotal int64 + DiskUsed int64 + DiskAvail int64 + NetRxRate int64 + NetTxRate int64 + NetRxTotal int64 + NetTxTotal int64 + Disks []DiskMount +} + +type DiskMount struct { + Mount string + Fs string + Total int64 + Used int64 + Avail int64 + Percent int64 +} + +// 采集脚本:两帧 /proc/stat + /proc/net/dev 中间 sleep 0.5s 算速率, +// 其余一次性读取;==NAME== 段落标记用于切分解析 +const metricsScript = ` +echo ==CPU1==; grep '^cpu ' /proc/stat +echo ==NET1==; cat /proc/net/dev 2>/dev/null +sleep 0.5 +echo ==CPU2==; grep '^cpu ' /proc/stat +echo ==NET2==; cat /proc/net/dev 2>/dev/null +echo ==MEM==; grep -E '^(MemTotal|MemAvailable|MemFree|SwapTotal|SwapFree)' /proc/meminfo +echo ==DISKS==; df -B1 -x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs 2>/dev/null | tail -n +2 +echo ==LOAD==; cat /proc/loadavg 2>/dev/null +echo ==UP==; cat /proc/uptime 2>/dev/null +echo ==OS==; (. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"); uname -r +echo ==HOST==; hostname +` + +// CollectMetrics 跑采集脚本并解析;整体超时 20s +func CollectMetrics(c *ssh.Client) (Metrics, error) { + type result struct { + out []byte + err error + } + ch := make(chan result, 1) + go func() { + sess, err := c.NewSession() + if err != nil { + ch <- result{nil, fmt.Errorf("sshx: 建立会话失败: %w", err)} + return + } + defer sess.Close() + // 脚本走 stdin 喂给 sh -s:内容含单引号,拼进 sh -c '...' 会被顶断(exit 2) + sess.Stdin = strings.NewReader(metricsScript) + out, err := sess.Output("sh -s") + ch <- result{out, err} + }() + var out []byte + select { + case r := <-ch: + if r.err != nil { + return Metrics{}, fmt.Errorf("sshx: 采集命令失败: %w", r.err) + } + out = r.out + case <-time.After(20 * time.Second): + return Metrics{}, fmt.Errorf("sshx: 采集超时") + } + return parseMetrics(string(out)), nil +} + +func parseMetrics(text string) Metrics { + sections := map[string]string{} + cur := "" + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "==") && strings.HasSuffix(trimmed, "==") { + cur = strings.Trim(trimmed, "=") + continue + } + if cur != "" { + sections[cur] += line + "\n" + } + } + + m := Metrics{Disks: []DiskMount{}} + c1, c2 := cpuFields(sections["CPU1"]), cpuFields(sections["CPU2"]) + if len(c1) >= 8 && len(c2) >= 8 { + m.CpuPercent = cpuPercent(c1, c2) + } + n1, n2 := netTotals(sections["NET1"]), netTotals(sections["NET2"]) + // 脚本固定 sleep 0.5s → 速率 = 差值 × 2 + m.NetRxRate = max64(n2[0]-n1[0], 0) * 2 + m.NetTxRate = max64(n2[1]-n1[1], 0) * 2 + m.NetRxTotal, m.NetTxTotal = n2[0], n2[1] + parseMem(&m, sections["MEM"]) + parseDisks(&m, sections["DISKS"]) + if l := strings.Fields(sections["LOAD"]); len(l) >= 3 { + m.Load1, m.Load5, m.Load15 = l[0], l[1], l[2] + } + if f := strings.Fields(sections["UP"]); len(f) > 0 { + if v, err := strconv.ParseFloat(f[0], 64); err == nil { + m.UptimeSeconds = int64(v) + } + } + if lines := strings.Split(strings.TrimSpace(sections["OS"]), "\n"); len(lines) > 0 { + m.OS = strings.TrimSpace(lines[0]) + if len(lines) > 1 { + m.Kernel = strings.TrimSpace(lines[1]) + } + } + m.Hostname = strings.TrimSpace(sections["HOST"]) + return m +} + +// cpuFields /proc/stat 的 cpu 行 → [user nice system idle iowait irq softirq steal] +func cpuFields(s string) []int64 { + for _, line := range strings.Split(s, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "cpu ") { + continue + } + f := strings.Fields(line)[1:] + vals := make([]int64, len(f)) + for i, v := range f { + vals[i], _ = strconv.ParseInt(v, 10, 64) + } + return vals + } + return nil +} + +func cpuPercent(a, b []int64) int64 { + total := func(v []int64) int64 { + var s int64 + for _, x := range v { + s += x + } + return s + } + idle := func(v []int64) int64 { return v[3] + v[4] } // idle + iowait + dt, di := total(b)-total(a), idle(b)-idle(a) + if dt <= 0 { + return 0 + } + return (dt - di) * 100 / dt +} + +// netTotals /proc/net/dev → [rx_bytes合计, tx_bytes合计](跳过 lo) +func netTotals(s string) [2]int64 { + var rx, tx int64 + for _, line := range strings.Split(s, "\n") { + i := strings.Index(line, ":") + if i < 0 { + continue + } + if strings.TrimSpace(line[:i]) == "lo" { + continue + } + f := strings.Fields(line[i+1:]) + if len(f) < 9 { + continue + } + r, _ := strconv.ParseInt(f[0], 10, 64) + t, _ := strconv.ParseInt(f[8], 10, 64) + rx, tx = rx+r, tx+t + } + return [2]int64{rx, tx} +} + +func parseMem(m *Metrics, s string) { + kv := map[string]int64{} + for _, line := range strings.Split(s, "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + v, _ := strconv.ParseInt(f[1], 10, 64) + kv[strings.TrimSuffix(f[0], ":")] = v * 1024 // kB → byte + } + } + m.MemTotal = kv["MemTotal"] + m.MemAvail = kv["MemAvailable"] + if m.MemAvail == 0 { + m.MemAvail = kv["MemFree"] + } + m.MemUsed = max64(m.MemTotal-m.MemAvail, 0) + m.SwapTotal = kv["SwapTotal"] + m.SwapUsed = m.SwapTotal - kv["SwapFree"] +} + +func parseDisks(m *Metrics, s string) { + for _, line := range strings.Split(s, "\n") { + f := strings.Fields(line) + if len(f) < 6 { + continue + } + d := DiskMount{Fs: f[0], Mount: f[5]} + d.Total, _ = strconv.ParseInt(f[1], 10, 64) + d.Used, _ = strconv.ParseInt(f[2], 10, 64) + d.Avail, _ = strconv.ParseInt(f[3], 10, 64) + d.Percent, _ = strconv.ParseInt(strings.TrimSuffix(f[4], "%"), 10, 64) + m.Disks = append(m.Disks, d) + if d.Mount == "/" { + m.DiskTotal, m.DiskUsed, m.DiskAvail = d.Total, d.Used, d.Avail + } + } + // 没有独立 / 挂载(罕见)时取第一块盘作概览 + if m.DiskTotal == 0 && len(m.Disks) > 0 { + m.DiskTotal, m.DiskUsed, m.DiskAvail = m.Disks[0].Total, m.Disks[0].Used, m.Disks[0].Avail + } +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} + +// RunScript 终端之外保留:跑一次命令返回输出(SFTP 预热/调试用) +func RunScript(c *ssh.Client, script string) ([]byte, error) { + sess, err := c.NewSession() + if err != nil { + return nil, err + } + defer sess.Close() + var buf bytes.Buffer + sess.Stdout = &buf + sess.Stderr = &buf + if err := sess.Run(script); err != nil { + return buf.Bytes(), err + } + return buf.Bytes(), nil +} diff --git a/backend/internal/store/bookmark.go b/backend/internal/store/bookmark.go new file mode 100644 index 0000000..042f589 --- /dev/null +++ b/backend/internal/store/bookmark.go @@ -0,0 +1,73 @@ +package store + +import ( + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +type Bookmark struct { + Id int64 + UserID int64 + Title string + Url string + Icon string + Sort int64 + CreatedAt int64 +} + +func bookmarkFromRow(b db.Bookmark) Bookmark { + return Bookmark{ + Id: b.ID, UserID: b.UserID, Title: b.Title, Url: b.Url, + Icon: b.Icon, Sort: b.Sort, CreatedAt: b.CreatedAt, + } +} + +func (s *Store) ListBookmarks(userID int64) ([]Bookmark, error) { + rows, err := s.q.ListBookmarks(storeCtx, userID) + if err != nil { + return nil, err + } + items := make([]Bookmark, 0, len(rows)) + for _, r := range rows { + items = append(items, bookmarkFromRow(r)) + } + return items, nil +} + +func (s *Store) SaveBookmark(b *Bookmark) error { + if b.Id > 0 { + n, err := s.q.UpdateBookmark(storeCtx, db.UpdateBookmarkParams{ + Title: b.Title, Url: b.Url, Icon: b.Icon, Sort: b.Sort, + ID: b.Id, UserID: b.UserID, + }) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil + } + now := time.Now().Unix() + id, err := s.q.InsertBookmark(storeCtx, db.InsertBookmarkParams{ + UserID: b.UserID, Title: b.Title, Url: b.Url, Icon: b.Icon, + Sort: b.Sort, CreatedAt: now, + }) + if err != nil { + return err + } + b.Id, b.CreatedAt = id, now + return nil +} + +func (s *Store) DeleteBookmark(userID, id int64) error { + n, err := s.q.DeleteBookmark(storeCtx, db.DeleteBookmarkParams{UserID: userID, ID: id}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/store/dbconn.go b/backend/internal/store/dbconn.go new file mode 100644 index 0000000..094d6e8 --- /dev/null +++ b/backend/internal/store/dbconn.go @@ -0,0 +1,91 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// Dbconn 数据库连接条目:password_enc 为 AES-GCM 密文;sqlite 时 database 存文件路径 +type Dbconn struct { + Id int64 + Name string + Engine string // mysql | postgres | sqlite + Host string + Port int64 + Username string + PasswordEnc string + Database string + Params string // DSN 附加参数,如 mysql 的 charset、pg 的 sslmode + CreatedAt int64 + UpdatedAt int64 +} + +func dbconnFromRow(e db.Dbconn) Dbconn { + return Dbconn{ + Id: e.ID, Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port, + Username: e.Username, PasswordEnc: e.PasswordEnc, Database: e.Database, + Params: e.Params, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + } +} + +func (s *Store) ListDbconns() ([]Dbconn, error) { + rows, err := s.q.ListDbconns(storeCtx) + if err != nil { + return nil, err + } + items := make([]Dbconn, 0, len(rows)) + for _, r := range rows { + items = append(items, dbconnFromRow(r)) + } + return items, nil +} + +func (s *Store) GetDbconn(id int64) (Dbconn, error) { + e, err := s.q.GetDbconn(storeCtx, id) + if errors.Is(err, sql.ErrNoRows) { + return Dbconn{}, ErrNotFound + } + return dbconnFromRow(e), err +} + +func (s *Store) SaveDbconn(e *Dbconn) error { + now := time.Now().Unix() + if e.Id > 0 { + n, err := s.q.UpdateDbconn(storeCtx, db.UpdateDbconnParams{ + Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port, + Username: e.Username, PasswordEnc: e.PasswordEnc, + Database: e.Database, Params: e.Params, UpdatedAt: now, ID: e.Id, + }) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil + } + id, err := s.q.InsertDbconn(storeCtx, db.InsertDbconnParams{ + Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port, + Username: e.Username, PasswordEnc: e.PasswordEnc, + Database: e.Database, Params: e.Params, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return err + } + e.Id, e.CreatedAt, e.UpdatedAt = id, now, now + return nil +} + +func (s *Store) DeleteDbconn(id int64) error { + n, err := s.q.DeleteDbconn(storeCtx, id) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/store/disk.go b/backend/internal/store/disk.go new file mode 100644 index 0000000..0083567 --- /dev/null +++ b/backend/internal/store/disk.go @@ -0,0 +1,148 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// ---- disk 网盘 ---- + +type File struct { + Id int64 + UserID int64 + Name string // 原始文件名 + Stored string // 落盘文件名(随机) + Size int64 + Mime string + CreatedAt int64 +} + +type Share struct { + Id int64 + Code string + FileID int64 + UserID int64 + FileName string + ExpiresAt int64 // 0 = 永久 + Downloads int64 + CreatedAt int64 +} + +func fileFromRow(f db.File) File { + return File{ + Id: f.ID, UserID: f.UserID, Name: f.Name, Stored: f.Stored, + Size: f.Size, Mime: f.Mime, CreatedAt: f.CreatedAt, + } +} + +func shareFromRow(s db.Share) Share { + return Share{ + Id: s.ID, Code: s.Code, FileID: s.FileID, UserID: s.UserID, + FileName: s.FileName, ExpiresAt: s.ExpiresAt, + Downloads: s.Downloads, CreatedAt: s.CreatedAt, + } +} + +func (s *Store) InsertFile(f *File) error { + id, err := s.q.InsertFile(storeCtx, db.InsertFileParams{ + UserID: f.UserID, Name: f.Name, Stored: f.Stored, + Size: f.Size, Mime: f.Mime, CreatedAt: time.Now().Unix(), + }) + if err != nil { + return err + } + f.Id = id + return nil +} + +func (s *Store) ListFiles(userID int64) ([]File, error) { + rows, err := s.q.ListFiles(storeCtx, userID) + if err != nil { + return nil, err + } + files := make([]File, 0, len(rows)) + for _, r := range rows { + files = append(files, fileFromRow(r)) + } + return files, nil +} + +func (s *Store) GetFile(id int64) (File, error) { + f, err := s.q.GetFile(storeCtx, id) + if errors.Is(err, sql.ErrNoRows) { + return File{}, ErrNotFound + } + return fileFromRow(f), err +} + +// DeleteFile 仅限属主;返回被删行数(0 = 不存在或非属主) +func (s *Store) DeleteFile(id, userID int64) (int64, error) { + return s.q.DeleteFile(storeCtx, db.DeleteFileParams{ID: id, UserID: userID}) +} + +func (s *Store) InsertShare(sh *Share) error { + sh.CreatedAt = time.Now().Unix() + id, err := s.q.InsertShare(storeCtx, db.InsertShareParams{ + Code: sh.Code, FileID: sh.FileID, UserID: sh.UserID, + FileName: sh.FileName, ExpiresAt: sh.ExpiresAt, + CreatedAt: sh.CreatedAt, + }) + if err != nil { + return err + } + sh.Id = id + return nil +} + +func (s *Store) ListShares(userID int64) ([]Share, error) { + rows, err := s.q.ListShares(storeCtx, userID) + if err != nil { + return nil, err + } + shares := make([]Share, 0, len(rows)) + for _, r := range rows { + shares = append(shares, shareFromRow(r)) + } + return shares, nil +} + +// ShareFileView 公开下载需要的联表视图 +type ShareFileView struct { + Share + Stored string + Size int64 + Mime string +} + +func (s *Store) GetShareFile(code string) (ShareFileView, error) { + r, err := s.q.GetShareByCode(storeCtx, code) + if errors.Is(err, sql.ErrNoRows) { + return ShareFileView{}, ErrNotFound + } + if err != nil { + return ShareFileView{}, err + } + return ShareFileView{ + Share: Share{ + Id: r.ID, Code: r.Code, FileID: r.FileID, UserID: r.UserID, + FileName: r.FileName, ExpiresAt: r.ExpiresAt, + Downloads: r.Downloads, CreatedAt: r.CreatedAt, + }, + Stored: r.Stored, Size: r.Size, Mime: r.Mime, + }, nil +} + +func (s *Store) DeleteShare(id, userID int64) (int64, error) { + return s.q.DeleteShare(storeCtx, db.DeleteShareParams{ID: id, UserID: userID}) +} + +func (s *Store) DeleteSharesByFile(fileID int64) error { + return s.q.DeleteSharesByFile(storeCtx, fileID) +} + +func (s *Store) IncShareDownloads(code string) error { + return s.q.IncShareDownloads(storeCtx, code) +} diff --git a/backend/internal/store/mem.go b/backend/internal/store/mem.go new file mode 100644 index 0000000..fec9d4d --- /dev/null +++ b/backend/internal/store/mem.go @@ -0,0 +1,272 @@ +// mem.go 共享记忆库门面:key 形态 "/",写覆盖即 revision+1。 +// 同一套数据同时服务后台管理页(MemorySvc)与对外 MCP 协议端点(internal/memx)。 +package store + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// memFTSDDL 全文索引:外挂内容表(不复制数据)+ 增删改触发器同步。 +// trigram 分词器:中英文都能做子串匹配(代价是 <3 字符的词无法索引,搜索侧兜底) +const memFTSDDL = ` +CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + k, content, project, tags, + content='memories', content_rowid='rowid', + tokenize='trigram' +); +CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, k, content, project, tags) + VALUES (new.rowid, new.k, new.content, new.project, new.tags); +END; +CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, k, content, project, tags) + VALUES ('delete', old.rowid, old.k, old.content, old.project, old.tags); +END; +CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, k, content, project, tags) + VALUES ('delete', old.rowid, old.k, old.content, old.project, old.tags); + INSERT INTO memories_fts(rowid, k, content, project, tags) + VALUES (new.rowid, new.k, new.content, new.project, new.tags); +END;` + +// Memory 一条持久记忆;Tags 经 JSON 数组落库 +type Memory struct { + Key string + Content string + Project string + Tags []string + WrittenBy string + Revision int64 + CreatedAt int64 + UpdatedAt int64 +} + +func memoryFromRow(r db.Memory) Memory { + m := Memory{ + Key: r.K, Content: r.Content, Project: r.Project, WrittenBy: r.WrittenBy, + Revision: r.Revision, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } + _ = json.Unmarshal([]byte(r.Tags), &m.Tags) + if m.Tags == nil { + m.Tags = []string{} + } + return m +} + +// SaveMemory 覆盖式写入:同 key 保留 created_at、revision 自增 +func (s *Store) SaveMemory(m *Memory) error { + now := time.Now().Unix() + tags, _ := json.Marshal(m.Tags) + if err := s.q.UpsertMemory(storeCtx, db.UpsertMemoryParams{ + K: m.Key, Content: m.Content, Project: m.Project, Tags: string(tags), + WrittenBy: m.WrittenBy, CreatedAt: now, UpdatedAt: now, + }); err != nil { + return err + } + row, err := s.q.GetMemory(storeCtx, m.Key) + if err != nil { + return err + } + m.Revision, m.CreatedAt, m.UpdatedAt = row.Revision, row.CreatedAt, row.UpdatedAt + return nil +} + +func (s *Store) GetMemory(key string) (*Memory, error) { + row, err := s.q.GetMemory(storeCtx, key) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + m := memoryFromRow(row) + return &m, nil +} + +func (s *Store) DeleteMemory(key string) (bool, error) { + n, err := s.q.DeleteMemory(storeCtx, key) + return n > 0, err +} + +// ListMemories project/tag 可空过滤,limit<=0 取默认 50,上限 500 +func (s *Store) ListMemories(project, tag string, limit int64) ([]Memory, error) { + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + rows, err := s.q.ListMemories(storeCtx, db.ListMemoriesParams{ + Column1: project, Project: project, + Column3: tag, Column4: optStr(tag), Limit: limit, + }) + if err != nil { + return nil, err + } + out := make([]Memory, 0, len(rows)) + for _, r := range rows { + out = append(out, memoryFromRow(r)) + } + return out, nil +} + +// SearchMemories 每个空白分隔的词都要命中(AND 语义)。 +// ≥3 字符的词走 FTS5 trigram 索引(按相关度排序);出现短词或索引异常时退回内存 LIKE。 +func (s *Store) SearchMemories(query, project string, limit int64) ([]Memory, error) { + terms := strings.Fields(query) + if len(terms) == 0 { + return s.ListMemories(project, "", limit) + } + if limit <= 0 { + limit = 50 + } + canFTS := true + for _, t := range terms { + if len([]rune(t)) < 3 { + canFTS = false + break + } + } + if canFTS { + if ms, err := s.searchMemoriesFTS(terms, project, limit); err == nil { + return ms, nil + } + } + return s.searchMemoriesLike(terms, project, limit) +} + +// searchMemoriesFTS 索引命中:词项加引号消除 MATCH 语法注入面 +func (s *Store) searchMemoriesFTS(terms []string, project string, limit int64) ([]Memory, error) { + var b strings.Builder + for i, t := range terms { + if i > 0 { + b.WriteString(" AND ") + } + b.WriteString(`"` + strings.ReplaceAll(t, `"`, `""`) + `"`) + } + rows, err := s.DB.Query(`SELECT m.k, m.content, m.project, m.tags, m.written_by, m.revision, m.created_at, m.updated_at + FROM memories_fts f JOIN memories m ON m.rowid = f.rowid + WHERE f MATCH ? AND (? = '' OR m.project = ?) + ORDER BY f.rank LIMIT ?`, b.String(), project, project, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Memory + for rows.Next() { + var m Memory + var tags string + if err := rows.Scan(&m.Key, &m.Content, &m.Project, &tags, &m.WrittenBy, &m.Revision, &m.CreatedAt, &m.UpdatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(tags), &m.Tags) + if m.Tags == nil { + m.Tags = []string{} + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) searchMemoriesLike(terms []string, project string, limit int64) ([]Memory, error) { + all, err := s.ListMemories(project, "", 500) + if err != nil { + return nil, err + } + var out []Memory + for _, m := range all { + hay := strings.ToLower(m.Key + "\n" + m.Project + "\n" + m.Content) + ok := true + for _, t := range terms { + if !strings.Contains(hay, strings.ToLower(t)) { + ok = false + break + } + } + if ok { + out = append(out, m) + } + if int64(len(out)) >= limit { + break + } + } + return out, nil +} + +type MemoryProject struct { + Name string + Count int64 +} + +func (s *Store) MemoryProjects() ([]MemoryProject, error) { + rows, err := s.q.MemoryProjects(storeCtx) + if err != nil { + return nil, err + } + out := make([]MemoryProject, 0, len(rows)) + for _, r := range rows { + out = append(out, MemoryProject{Name: r.Name, Count: r.Cnt}) + } + return out, nil +} + +const mcpTokenKey = "mcp.token" + +// McpToken 读取对外 MCP 端点的 Bearer 令牌;不存在则生成一个并落库 +func (s *Store) McpToken() (string, error) { + if v, err := s.GetSetting(mcpTokenKey); err == nil && v != "" { + return v, nil + } + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("store: gen mcp token failed: %w", err) + } + tok := hex.EncodeToString(b) + if err := s.SetSetting(mcpTokenKey, tok); err != nil { + return "", err + } + return tok, nil +} + +// SignMemoryReadURL 签发 /m/ 阅读链接:?sig=HMAC(token, "read:"+key)[:24] +// 与 MCP 共用同一令牌——重置令牌即吊销全部已发链接 +func (s *Store) SignMemoryReadURL(base, key string) (string, error) { + tok, err := s.McpToken() + if err != nil { + return "", err + } + return base + "/m/" + key + "?sig=" + memReadSig(tok, key), nil +} + +// CheckMemoryReadSig 校验阅读链接签名 +func (s *Store) CheckMemoryReadSig(key, sig string) bool { + tok, err := s.McpToken() + if err != nil || sig == "" { + return false + } + return hmac.Equal([]byte(sig), []byte(memReadSig(tok, key))) +} + +func memReadSig(token, key string) string { + h := hmac.New(sha256.New, []byte(token)) + h.Write([]byte("read:" + key)) + return hex.EncodeToString(h.Sum(nil))[:24] +} + +func (s *Store) ResetMcpToken() (string, error) { + if err := s.SetSetting(mcpTokenKey, ""); err != nil { + return "", err + } + return s.McpToken() +} diff --git a/backend/internal/store/proxy.go b/backend/internal/store/proxy.go new file mode 100644 index 0000000..468932e --- /dev/null +++ b/backend/internal/store/proxy.go @@ -0,0 +1,362 @@ +package store + +import ( + "database/sql" + "errors" + "strconv" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// ---- proxies ---- + +type ProxyRegion struct { + Id int64 + Code string + Name string + ZhName string + Region string + Slug string + AliveCount int64 +} + +type Proxy struct { + Id int64 + Ip string + Port int64 + Protocols string + Anonymity string + Source string + RegionID int64 + RegionCode string + RegionName string + RegionZhName string + Latency int64 + Speed int64 + Uptime int64 + Alive bool + FailCount int64 + LastCheckedAt int64 + LastAliveAt int64 + LastSeenAt int64 + CreatedAt int64 +} + +type ProxySync struct { + Id int64 + StartedAt int64 + FinishedAt int64 + Status string + Source string + Total int64 + Added int64 + Removed int64 + Updated int64 + Detail string + Error string +} + +type ProxyFilter struct { + RegionID int64 + Protocol string + Keyword string + Alive int64 + Page int64 + PageSize int64 +} + +// UpsertRegion returns the region id for code, creating/updating the row. +func (s *Store) UpsertRegion(code, name, zhName, region, slug string) (int64, error) { + if err := s.q.UpsertProxyRegion(storeCtx, db.UpsertProxyRegionParams{ + Code: code, Name: name, ZhName: zhName, Region: region, Slug: slug, + }); err != nil { + return 0, err + } + r, err := s.q.GetProxyRegionByCode(storeCtx, code) + if err != nil { + return 0, err + } + return r.ID, nil +} + +func (s *Store) ListRegions() ([]ProxyRegion, error) { + rows, err := s.q.ListProxyRegions(storeCtx) + if err != nil { + return nil, err + } + out := make([]ProxyRegion, 0, len(rows)) + for _, r := range rows { + out = append(out, ProxyRegion{ + Id: r.ID, Code: r.Code, Name: r.Name, ZhName: r.ZhName, + Region: r.Region, Slug: r.Slug, AliveCount: r.AliveCount, + }) + } + return out, nil +} + +// ProxyKey 同步 diff 用的键信息:来源决定「消失即删」的作用域 +type ProxyKey struct { + Id int64 + Source string +} + +// ProxyKeys maps "ip:port" to ProxyKey, used by sync diffing. +func (s *Store) ProxyKeys() (map[string]ProxyKey, error) { + rows, err := s.q.ListProxyKeys(storeCtx) + if err != nil { + return nil, err + } + m := make(map[string]ProxyKey, len(rows)) + for _, r := range rows { + m[r.Ip+":"+strconv.FormatInt(r.Port, 10)] = ProxyKey{Id: r.ID, Source: r.Source} + } + return m, nil +} + +func (s *Store) InsertProxy(p *Proxy) error { + id, err := s.q.InsertProxy(storeCtx, db.InsertProxyParams{ + Ip: p.Ip, Port: p.Port, Protocols: p.Protocols, Anonymity: p.Anonymity, + Source: p.Source, + RegionID: p.RegionID, Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime, + LastSeenAt: p.LastSeenAt, CreatedAt: p.CreatedAt, + }) + if err != nil { + return err + } + p.Id = id + return nil +} + +func (s *Store) UpdateProxySeen(id int64, p *Proxy) error { + return s.q.UpdateProxySeen(storeCtx, db.UpdateProxySeenParams{ + Protocols: p.Protocols, Anonymity: p.Anonymity, RegionID: p.RegionID, + Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime, + LastSeenAt: p.LastSeenAt, ID: id, + }) +} + +func (s *Store) DeleteProxy(id int64) error { + return s.q.DeleteProxy(storeCtx, id) +} + +type DueProxy struct { + Id int64 + Ip string + Port int64 + Protocols string + FailCount int64 +} + +func (s *Store) DueProxies(checkedBefore int64) ([]DueProxy, error) { + rows, err := s.q.ListProxiesDue(storeCtx, checkedBefore) + if err != nil { + return nil, err + } + out := make([]DueProxy, 0, len(rows)) + for _, r := range rows { + out = append(out, DueProxy{Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols, FailCount: r.FailCount}) + } + return out, nil +} + +func (s *Store) MarkProxyAlive(id, checkedAt, aliveAt, latency int64) error { + return s.q.MarkProxyAlive(storeCtx, db.MarkProxyAliveParams{ + LastCheckedAt: checkedAt, LastAliveAt: aliveAt, Latency: latency, ID: id, + }) +} + +func (s *Store) MarkProxyFailed(id, checkedAt, failCount int64) error { + return s.q.MarkProxyFailed(storeCtx, db.MarkProxyFailedParams{ + LastCheckedAt: checkedAt, FailCount: failCount, ID: id, + }) +} + +func (s *Store) PurgeDeadProxies(before int64) (int64, error) { + return s.q.DeleteDeadProxies(storeCtx, before) +} + +func (s *Store) ListProxies(f ProxyFilter) ([]Proxy, int64, error) { + if f.Page < 1 { + f.Page = 1 + } + if f.PageSize <= 0 || f.PageSize > 200 { + f.PageSize = 50 + } + total, err := s.q.CountProxiesFiltered(storeCtx, db.CountProxiesFilteredParams{ + Column1: f.RegionID, RegionID: f.RegionID, + Column3: f.Protocol, Column4: optStr(f.Protocol), + Column5: f.Keyword, Column6: optStr(f.Keyword), + Column7: f.Alive, Alive: f.Alive, + }) + if err != nil { + return nil, 0, err + } + rows, err := s.q.ListProxiesFiltered(storeCtx, db.ListProxiesFilteredParams{ + Column1: f.RegionID, RegionID: f.RegionID, + Column3: f.Protocol, Column4: optStr(f.Protocol), + Column5: f.Keyword, Column6: optStr(f.Keyword), + Column7: f.Alive, Alive: f.Alive, + Limit: f.PageSize, Offset: (f.Page - 1) * f.PageSize, + }) + if err != nil { + return nil, 0, err + } + out := make([]Proxy, 0, len(rows)) + for _, r := range rows { + out = append(out, Proxy{ + Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols, Anonymity: r.Anonymity, + RegionID: r.RegionID, RegionCode: r.RegionCode, RegionName: r.RegionName, + RegionZhName: r.RegionZhName, Latency: r.Latency, Speed: r.Speed, Uptime: r.Uptime, + Alive: r.Alive == 1, FailCount: r.FailCount, LastCheckedAt: r.LastCheckedAt, + LastAliveAt: r.LastAliveAt, LastSeenAt: r.LastSeenAt, CreatedAt: r.CreatedAt, + }) + } + return out, total, nil +} + +func (s *Store) CountAliveProxies() (int64, error) { + return s.q.CountProxies(storeCtx) +} + +func (s *Store) LastProxyCheckAt() int64 { + v, err := s.q.LastProxyCheckAt(storeCtx) + if err != nil { + return 0 + } + if n, ok := v.(int64); ok { + return n + } + return 0 +} + +func (s *Store) BeginProxySync(source string, startedAt int64) (int64, error) { + return s.q.InsertProxySync(storeCtx, db.InsertProxySyncParams{ + StartedAt: startedAt, Source: source, + }) +} + +func (s *Store) FinishProxySync(sync *ProxySync) error { + return s.q.FinishProxySync(storeCtx, db.FinishProxySyncParams{ + FinishedAt: sync.FinishedAt, Status: sync.Status, Total: sync.Total, + Added: sync.Added, Removed: sync.Removed, Updated: sync.Updated, + Detail: sync.Detail, Error: sync.Error, ID: sync.Id, + }) +} + +func (s *Store) ListProxySyncs(limit int64) ([]ProxySync, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.q.ListProxySyncs(storeCtx, limit) + if err != nil { + return nil, err + } + out := make([]ProxySync, 0, len(rows)) + for _, r := range rows { + out = append(out, ProxySync{ + Id: r.ID, StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, Status: r.Status, + Source: r.Source, Total: r.Total, Added: r.Added, Removed: r.Removed, + Updated: r.Updated, Detail: r.Detail, Error: r.Error, + }) + } + return out, nil +} + +// ---- 提取密钥 ---- + +type ProxyApiKey struct { + Id int64 + Key string + Name string + RegionID int64 + RegionCode string + RegionZhName string + Enabled bool + UsedCount int64 + LastUsedAt int64 + CreatedAt int64 +} + +func (s *Store) InsertProxyApiKey(key, name string, regionID int64) (int64, error) { + return s.q.InsertProxyApiKey(storeCtx, db.InsertProxyApiKeyParams{ + Kkey: key, Name: name, RegionID: regionID, CreatedAt: nowUnix(), + }) +} + +func (s *Store) ListProxyApiKeys() ([]ProxyApiKey, error) { + rows, err := s.q.ListProxyApiKeys(storeCtx) + if err != nil { + return nil, err + } + out := make([]ProxyApiKey, 0, len(rows)) + for _, r := range rows { + out = append(out, ProxyApiKey{ + Id: r.ID, Key: r.Kkey, Name: r.Name, RegionID: r.RegionID, + RegionCode: r.RegionCode.String, RegionZhName: r.RegionZhName.String, + Enabled: r.Enabled == 1, UsedCount: r.UsedCount, + LastUsedAt: r.LastUsedAt, CreatedAt: r.CreatedAt, + }) + } + return out, nil +} + +func (s *Store) GetProxyApiKey(key string) (ProxyApiKey, error) { + r, err := s.q.GetProxyApiKey(storeCtx, key) + if errors.Is(err, sql.ErrNoRows) { + return ProxyApiKey{}, ErrNotFound + } + if err != nil { + return ProxyApiKey{}, err + } + return ProxyApiKey{ + Id: r.ID, Key: r.Kkey, Name: r.Name, RegionID: r.RegionID, + Enabled: r.Enabled == 1, UsedCount: r.UsedCount, + LastUsedAt: r.LastUsedAt, CreatedAt: r.CreatedAt, + }, nil +} + +func (s *Store) TouchProxyApiKey(id int64) { + _ = s.q.TouchProxyApiKey(storeCtx, db.TouchProxyApiKeyParams{ + LastUsedAt: nowUnix(), ID: id, + }) +} + +func (s *Store) DeleteProxyApiKey(id int64) error { + _, err := s.q.DeleteProxyApiKey(storeCtx, id) + return err +} + +func nowUnix() int64 { return time.Now().Unix() } + +type RandomProxy struct { + Id int64 + Ip string + Port int64 + Protocols string + FailCount int64 + RegionCode string + RegionZhName string +} + +// RandomProxies 随机取存活代理:regionID=0 全部地区,code 可按国家码二次过滤 +func (s *Store) RandomProxies(regionID int64, protocol, code string, limit int64) ([]RandomProxy, error) { + rows, err := s.q.RandomProxies(storeCtx, db.RandomProxiesParams{ + Column1: regionID, RegionID: regionID, + Column3: protocol, Column4: optStr(protocol), + Column5: code, Code: code, + Limit: limit, + }) + if err != nil { + return nil, err + } + out := make([]RandomProxy, 0, len(rows)) + for _, r := range rows { + out = append(out, RandomProxy{ + Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols, + FailCount: r.FailCount, + RegionCode: r.RegionCode, RegionZhName: r.RegionZhName, + }) + } + return out, nil +} diff --git a/backend/internal/store/server.go b/backend/internal/store/server.go new file mode 100644 index 0000000..afeb795 --- /dev/null +++ b/backend/internal/store/server.go @@ -0,0 +1,95 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// Server SSH 服务器条目:secret_enc 为 AES-GCM 密文,存密码或 PEM 私钥, +// 明文只在连接的瞬间存在于内存,不落盘不下发 +type Server struct { + Id int64 + Name string + Host string + Port int64 + Username string + AuthType string // password | key + SecretEnc string + Note string + CreatedAt int64 + UpdatedAt int64 +} + +func serverFromRow(e db.Server) Server { + return Server{ + Id: e.ID, Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username, + AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note, + CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + } +} + +func (s *Store) ListServers() ([]Server, error) { + rows, err := s.q.ListServers(storeCtx) + if err != nil { + return nil, err + } + items := make([]Server, 0, len(rows)) + for _, r := range rows { + items = append(items, serverFromRow(r)) + } + return items, nil +} + +func (s *Store) GetServer(id int64) (Server, error) { + e, err := s.q.GetServer(storeCtx, id) + if errors.Is(err, sql.ErrNoRows) { + return Server{}, ErrNotFound + } + return serverFromRow(e), err +} + +func (s *Store) SaveServer(e *Server) error { + now := time.Now().Unix() + if e.Id > 0 { + n, err := s.q.UpdateServer(storeCtx, db.UpdateServerParams{ + Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username, + AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note, + UpdatedAt: now, ID: e.Id, + }) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil + } + id, err := s.q.InsertServer(storeCtx, db.InsertServerParams{ + Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username, + AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return err + } + e.Id, e.CreatedAt, e.UpdatedAt = id, now, now + return nil +} + +func (s *Store) DeleteServer(id int64) error { + n, err := s.q.DeleteServer(storeCtx, id) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) CountServers() (int64, error) { + return s.q.CountServers(storeCtx) +} diff --git a/backend/internal/store/setting.go b/backend/internal/store/setting.go new file mode 100644 index 0000000..a42491a --- /dev/null +++ b/backend/internal/store/setting.go @@ -0,0 +1,20 @@ +package store + +import ( + "database/sql" + "errors" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +func (s *Store) GetSetting(key string) (string, error) { + v, err := s.q.GetSetting(storeCtx, key) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return v, err +} + +func (s *Store) SetSetting(key, value string) error { + return s.q.UpsertSetting(storeCtx, db.UpsertSettingParams{K: key, V: value}) +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..5f9d1ed --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,371 @@ +// Package store 数据门面层:CRUD 委托给 sqlc 生成的 internal/db.Queries(类型安全), +// 只保留 sqlc 表达不了的逻辑——会话滑动续期、聚合映射、种子数据,以及对服务层稳定的 +// 领域结构体(Id 命名、Enabled bool)与 ErrNotFound 语义。 +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "strings" + "time" + + _ "modernc.org/sqlite" + + "github.com/cyi-cc/cyi-box/backend/internal/config" + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +type Store struct { + Cfg *config.Config `fun:"auto"` + DB *sql.DB + q *db.Queries +} + +// Q 暴露 sqlc 查询器:新代码可直接走类型安全 API,门面方法逐步收敛 +func (s *Store) Q() *db.Queries { return s.q } + +func (s *Store) New() error { + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", + s.Cfg.DBPath) + conn, err := sql.Open("sqlite", dsn) + if err != nil { + return fmt.Errorf("store: open sqlite failed: %w", err) + } + // SQLite 单写者:串行化所有连接,避免 database is locked + conn.SetMaxOpenConns(1) + s.DB = conn + s.q = db.New(conn) + if err := s.migrate(); err != nil { + return err + } + return nil +} + +// migrate 表结构唯一来源是 internal/db/schema.sql(sqlc 与运行时共用); +// 列级增量(老库补列)用探测式 ALTER,CREATE TABLE IF NOT EXISTS 覆盖不到。 +func (s *Store) migrate() error { + if _, err := s.DB.Exec(db.SchemaDDL); err != nil { + return fmt.Errorf("store: migrate failed: %w", err) + } + // proxies.source 是后加的列:老库探测缺失则 ALTER(COUNT 空表也返回行,不误判) + var dummy int64 + if err := s.DB.QueryRow("SELECT COUNT(source) FROM proxies").Scan(&dummy); err != nil { + if _, err := s.DB.Exec("ALTER TABLE proxies ADD COLUMN source TEXT NOT NULL DEFAULT ''"); err != nil { + return fmt.Errorf("store: migrate proxies.source failed: %w", err) + } + } + if err := s.DB.QueryRow("SELECT COUNT(app_id) FROM license_cards").Scan(&dummy); err != nil { + if _, err := s.DB.Exec("ALTER TABLE license_cards ADD COLUMN app_id INTEGER NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("store: migrate license_cards.app_id failed: %w", err) + } + } + if _, err := s.DB.Exec("CREATE INDEX IF NOT EXISTS idx_license_cards_app ON license_cards(app_id)"); err != nil { + return fmt.Errorf("store: migrate license_cards index failed: %w", err) + } + // 记忆库全文索引:FTS5 外挂内容表 + 触发器同步(放这里而非 schema.sql, + // sqlc 的 sqlite 解析器不认 CREATE VIRTUAL TABLE/TRIGGER) + if _, err := s.DB.Exec(memFTSDDL); err != nil { + return fmt.Errorf("store: migrate memories_fts failed: %w", err) + } + // rebuild 幂等:给触发器建成前已存在的行补索引 + if _, err := s.DB.Exec(`INSERT INTO memories_fts(memories_fts) VALUES('rebuild')`); err != nil { + return fmt.Errorf("store: rebuild memories_fts failed: %w", err) + } + return nil +} + +var storeCtx = context.Background() + +func optStr(s string) sql.NullString { + return sql.NullString{String: s, Valid: true} +} + +// SeedAdmin 空库时写入首个管理员;已有用户则跳过 +func (s *Store) SeedAdmin(passwordHash string) error { + n, err := s.q.CountAllUsers(storeCtx) + if err != nil { + return fmt.Errorf("store: count users failed: %w", err) + } + if n > 0 { + return nil + } + now := time.Now().Unix() + _, err = s.q.InsertUser(storeCtx, db.InsertUserParams{ + Name: s.Cfg.AdminName, PasswordHash: passwordHash, Role: "admin", + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("store: seed admin failed: %w", err) + } + log.Printf("store: seeded admin account %q", s.Cfg.AdminName) + return nil +} + +// SeedTools 空表时写入两条示例工具,演示工具箱菜单的数据驱动渲染 +func (s *Store) SeedTools() error { + tools, err := s.q.ListTools(storeCtx) + if err != nil { + return fmt.Errorf("store: count tools failed: %w", err) + } + if len(tools) > 0 { + return nil + } + for _, t := range []db.InsertToolParams{ + {Name: "JSON 格式化", Icon: "code-outline", Url: "/tools/json", Description: "粘贴即格式化与校验 JSON", Sort: 1, Enabled: true}, + {Name: "时间戳转换", Icon: "time-outline", Url: "/tools/timestamp", Description: "Unix 时间戳与日期互转", Sort: 2, Enabled: true}, + } { + if _, err := s.q.InsertTool(storeCtx, t); err != nil { + return err + } + } + return nil +} + +// ---- users ---- + +type User struct { + Id int64 + Name string + PasswordHash string + Role string + Status string + CreatedAt int64 + UpdatedAt int64 +} + +var ErrNotFound = errors.New("record not found") + +func userFromRow(u db.User) User { + return User{ + Id: u.ID, Name: u.Name, PasswordHash: u.PasswordHash, Role: u.Role, + Status: u.Status, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, + } +} + +func (s *Store) GetUserByName(name string) (User, error) { + u, err := s.q.GetUserByName(storeCtx, name) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + return userFromRow(u), err +} + +func (s *Store) GetUserByID(id int64) (User, error) { + u, err := s.q.GetUserByID(storeCtx, id) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + return userFromRow(u), err +} + +func (s *Store) ListUsers(keyword string, page, pageSize int64) ([]User, int64, error) { + total, err := s.q.CountUsers(storeCtx, optStr(keyword)) + if err != nil { + return nil, 0, err + } + rows, err := s.q.ListUsers(storeCtx, db.ListUsersParams{ + Keyword: optStr(keyword), + Limit: pageSize, + Offset: (page - 1) * pageSize, + }) + if err != nil { + return nil, 0, err + } + users := make([]User, 0, len(rows)) + for _, r := range rows { + users = append(users, userFromRow(r)) + } + return users, total, nil +} + +func (s *Store) CreateUser(name, passwordHash, role string) (User, error) { + now := time.Now().Unix() + id, err := s.q.InsertUser(storeCtx, db.InsertUserParams{ + Name: name, PasswordHash: passwordHash, Role: role, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return User{}, err + } + return s.GetUserByID(id) +} + +func (s *Store) UpdateUser(id int64, name, role, status string, passwordHash *string) error { + now := time.Now().Unix() + if passwordHash != nil { + return s.q.UpdateUserPassword(storeCtx, db.UpdateUserPasswordParams{ + Name: name, Role: role, Status: status, PasswordHash: *passwordHash, + UpdatedAt: now, ID: id, + }) + } + return s.q.UpdateUser(storeCtx, db.UpdateUserParams{ + Name: name, Role: role, Status: status, UpdatedAt: now, ID: id, + }) +} + +func (s *Store) DeleteUser(id int64) error { + return s.q.DeleteUser(storeCtx, id) +} + +func (s *Store) CountUsers() (int64, error) { + return s.q.CountAllUsers(storeCtx) +} + +func (s *Store) CountActiveUsers() (int64, error) { + return s.q.CountActiveUsers(storeCtx) +} + +func (s *Store) CountAdmins() (int64, error) { + return s.q.CountAdmins(storeCtx) +} + +// ---- sessions ---- + +func (s *Store) CreateSession(token string, userID int64, ttl time.Duration) error { + now := time.Now() + return s.q.InsertSession(storeCtx, db.InsertSessionParams{ + Token: token, UserID: userID, + CreatedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(), + }) +} + +// SessionUser 会话命中后的用户视图:登录态校验只走这一处,过期/禁用统一判负 +type SessionUser struct { + User User + Token string +} + +// GetSessionUser 校验 token 对应的会话与用户。 +// 滑动续期:会话本身 ttl 过期即失效;存活但距签发/上次续期超过 renewAfter 时, +// 顺手把 expires_at 顺延回 now+ttl——活跃用户不掉线,闲置 ttl 时长强制下线。 +func (s *Store) GetSessionUser(token string, ttl, renewAfter time.Duration) (SessionUser, error) { + row, err := s.q.GetSessionUser(storeCtx, token) + if errors.Is(err, sql.ErrNoRows) { + return SessionUser{}, ErrNotFound + } + if err != nil { + return SessionUser{}, err + } + su := SessionUser{ + Token: token, + User: User{ + Id: row.ID, Name: row.Name, PasswordHash: row.PasswordHash, + Role: row.Role, Status: row.Status, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + }, + } + now := time.Now() + if now.Unix() > row.ExpiresAt { + _ = s.q.DeleteSession(storeCtx, token) + return SessionUser{}, ErrNotFound + } + lastRefresh := time.Unix(row.ExpiresAt, 0).Add(-ttl) + if now.Sub(lastRefresh) >= renewAfter { + _ = s.q.TouchSession(storeCtx, db.TouchSessionParams{ + ExpiresAt: now.Add(ttl).Unix(), Token: token, + }) + } + return su, nil +} + +func (s *Store) DeleteSession(token string) error { + return s.q.DeleteSession(storeCtx, token) +} + +// DeleteOtherSessions 改密后吊销除当前外的全部会话 +func (s *Store) DeleteOtherSessions(userID int64, keepToken string) error { + return s.q.DeleteOtherSessions(storeCtx, db.DeleteOtherSessionsParams{ + UserID: userID, Token: keepToken, + }) +} + +func (s *Store) CountActiveSessions() (int64, error) { + return s.q.CountActiveSessions(storeCtx, time.Now().Unix()) +} + +func (s *Store) CountTodayLogins() (int64, error) { + return s.q.CountTodayLogins(storeCtx) +} + +// LoginTrend 最近 days 天每日登录数(按会话创建数统计),缺口补零由调用方做 +func (s *Store) LoginTrend(days int64) (map[string]int64, error) { + rows, err := s.q.LoginTrend(storeCtx, time.Now().AddDate(0, 0, int(-days)).Unix()) + if err != nil { + return nil, err + } + m := map[string]int64{} + for _, r := range rows { + if d, ok := r.D.(string); ok { + m[d] = r.C + } + } + return m, nil +} + +// ---- tools ---- + +type Tool struct { + Id int64 + Name string + Icon string + Url string + Description string + Sort int64 + Enabled bool +} + +func toolFromRow(t db.Tool) Tool { + return Tool{ + Id: t.ID, Name: t.Name, Icon: t.Icon, Url: t.Url, + Description: t.Description, Sort: t.Sort, Enabled: t.Enabled, + } +} + +func (s *Store) ListTools(enabledOnly bool) ([]Tool, error) { + var rows []db.Tool + var err error + if enabledOnly { + rows, err = s.q.ListEnabledTools(storeCtx) + } else { + rows, err = s.q.ListTools(storeCtx) + } + if err != nil { + return nil, err + } + tools := make([]Tool, 0, len(rows)) + for _, r := range rows { + tools = append(tools, toolFromRow(r)) + } + return tools, nil +} + +func (s *Store) SaveTool(t *Tool) error { + if t.Id > 0 { + return s.q.UpdateTool(storeCtx, db.UpdateToolParams{ + Name: t.Name, Icon: t.Icon, Url: t.Url, Description: t.Description, + Sort: t.Sort, Enabled: t.Enabled, ID: t.Id, + }) + } + id, err := s.q.InsertTool(storeCtx, db.InsertToolParams{ + Name: t.Name, Icon: t.Icon, Url: t.Url, Description: t.Description, + Sort: t.Sort, Enabled: t.Enabled, + }) + if err != nil { + return err + } + t.Id = id + return nil +} + +func (s *Store) DeleteTool(id int64) error { + return s.q.DeleteTool(storeCtx, id) +} + +func (s *Store) CountTools() (int64, error) { + return s.q.CountTools(storeCtx) +} + +// NormalizeName 服务层共用的名字规整 +func NormalizeName(s string) string { return strings.TrimSpace(s) } diff --git a/backend/internal/store/vault.go b/backend/internal/store/vault.go new file mode 100644 index 0000000..593298e --- /dev/null +++ b/backend/internal/store/vault.go @@ -0,0 +1,93 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + "github.com/cyi-cc/cyi-box/backend/internal/db" +) + +// VaultEntry 密码箱条目:password_enc / totp_enc 为 AES-GCM 密文,离开 store 前必须解密 +type VaultEntry struct { + Id int64 + UserID int64 + Title string + Username string + PasswordEnc string + Url string + Note string + TotpEnc string + CreatedAt int64 + UpdatedAt int64 +} + +func vaultFromRow(v db.Vault) VaultEntry { + return VaultEntry{ + Id: v.ID, UserID: v.UserID, Title: v.Title, Username: v.Username, + PasswordEnc: v.PasswordEnc, Url: v.Url, Note: v.Note, TotpEnc: v.TotpEnc, + CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, + } +} + +func (s *Store) ListVault(userID int64, keyword string) ([]VaultEntry, error) { + rows, err := s.q.ListVault(storeCtx, db.ListVaultParams{ + UserID: userID, + Keyword: optStr(keyword), + }) + if err != nil { + return nil, err + } + items := make([]VaultEntry, 0, len(rows)) + for _, r := range rows { + items = append(items, vaultFromRow(r)) + } + return items, nil +} + +func (s *Store) GetVault(userID, id int64) (VaultEntry, error) { + v, err := s.q.GetVault(storeCtx, db.GetVaultParams{UserID: userID, ID: id}) + if errors.Is(err, sql.ErrNoRows) { + return VaultEntry{}, ErrNotFound + } + return vaultFromRow(v), err +} + +func (s *Store) SaveVault(e *VaultEntry) error { + now := time.Now().Unix() + if e.Id > 0 { + n, err := s.q.UpdateVault(storeCtx, db.UpdateVaultParams{ + Title: e.Title, Username: e.Username, PasswordEnc: e.PasswordEnc, + Url: e.Url, Note: e.Note, TotpEnc: e.TotpEnc, + UpdatedAt: now, ID: e.Id, UserID: e.UserID, + }) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil + } + id, err := s.q.InsertVault(storeCtx, db.InsertVaultParams{ + UserID: e.UserID, Title: e.Title, Username: e.Username, + PasswordEnc: e.PasswordEnc, Url: e.Url, Note: e.Note, TotpEnc: e.TotpEnc, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return err + } + e.Id, e.CreatedAt, e.UpdatedAt = id, now, now + return nil +} + +func (s *Store) DeleteVault(userID, id int64) error { + n, err := s.q.DeleteVault(storeCtx, db.DeleteVaultParams{UserID: userID, ID: id}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/vault/vault.go b/backend/internal/vault/vault.go new file mode 100644 index 0000000..2099d10 --- /dev/null +++ b/backend/internal/vault/vault.go @@ -0,0 +1,142 @@ +// Package vault 密码箱原语:AES-256-GCM 字段加密 + RFC 6238 TOTP + otpauth:// 解析。 +// 不引入第三方依赖,全部标准库实现。 +package vault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// DeriveKey 任意密钥材料规整为 32 字节 AES key(hex/base64 解码失败则取 sha256) +func DeriveKey(material string) []byte { + material = strings.TrimSpace(material) + if b, err := hex.DecodeString(material); err == nil && len(b) >= 16 { + sum := sha256.Sum256(b) + return sum[:] + } + if b, err := base64.StdEncoding.DecodeString(material); err == nil && len(b) >= 16 { + sum := sha256.Sum256(b) + return sum[:] + } + sum := sha256.Sum256([]byte(material)) + return sum[:] +} + +// RandomKey 生成 32 字节随机 key 的 hex 表示 +func RandomKey() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// Encrypt AES-256-GCM → base64(nonce|ct) +func Encrypt(key []byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ct), nil +} + +// Decrypt 解密 Encrypt 产物 +func Decrypt(key []byte, encoded string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("vault: ciphertext too short") + } + pt, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if err != nil { + return "", errors.New("vault: decrypt failed") + } + return string(pt), nil +} + +// ParseSecret 接受裸 base32 密钥或 otpauth://totp/...?secret=... 链接,统一返回 base32 密钥。 +// 输入非法返回 error,服务层转成业务错误码。 +func ParseSecret(input string) (string, error) { + s := strings.TrimSpace(input) + if s == "" { + return "", errors.New("empty secret") + } + if strings.HasPrefix(strings.ToLower(s), "otpauth://") { + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("vault: bad otpauth uri: %w", err) + } + secret := u.Query().Get("secret") + if secret == "" { + return "", errors.New("vault: otpauth uri missing secret") + } + s = secret + } + // 容错:去空格、横线,转大写;无 padding 形式也接受 + clean := strings.NewReplacer(" ", "", "-", "").Replace(s) + clean = strings.ToUpper(clean) + if _, err := decodeBase32(clean); err != nil { + return "", fmt.Errorf("vault: invalid base32 secret: %w", err) + } + return clean, nil +} + +func decodeBase32(s string) ([]byte, error) { + if b, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s); err == nil { + return b, nil + } + return base32.StdEncoding.DecodeString(s) +} + +// TOTP 计算当前验证码(30s 步长,6 位,SHA1),同时返回本码剩余秒数 +func TOTP(secret string, t time.Time) (string, int64, error) { + key, err := decodeBase32(secret) + if err != nil { + return "", 0, fmt.Errorf("vault: invalid totp secret: %w", err) + } + counter := uint64(t.Unix() / 30) + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + mac := hmac.New(sha1.New, key) + mac.Write(buf[:]) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + code := (uint32(sum[offset])&0x7f)<<24 | + (uint32(sum[offset+1]))<<16 | + (uint32(sum[offset+2]))<<8 | + uint32(sum[offset+3]) + secondsLeft := 30 - (t.Unix() % 30) + return fmt.Sprintf("%06d", code%1000000), secondsLeft, nil +} diff --git a/backend/main b/backend/main new file mode 100644 index 0000000..e69de29 diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000..a6298f4 --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,14 @@ +version: "2" +sql: + - engine: "sqlite" + schema: "internal/db/schema.sql" + queries: "internal/db/queries.sql" + gen: + go: + package: "db" + out: "internal/db" + emit_interface: true + emit_empty_slices: true + overrides: + - column: "tools.enabled" + go_type: "bool" diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..7418c14 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/dbm.png b/docs/screenshots/dbm.png new file mode 100644 index 0000000..a917ddf Binary files /dev/null and b/docs/screenshots/dbm.png differ diff --git a/docs/screenshots/licenses.png b/docs/screenshots/licenses.png new file mode 100644 index 0000000..6d1e240 Binary files /dev/null and b/docs/screenshots/licenses.png differ diff --git a/docs/screenshots/memories.png b/docs/screenshots/memories.png new file mode 100644 index 0000000..2b11536 Binary files /dev/null and b/docs/screenshots/memories.png differ diff --git a/docs/screenshots/pay.png b/docs/screenshots/pay.png new file mode 100644 index 0000000..4dc699e Binary files /dev/null and b/docs/screenshots/pay.png differ diff --git a/docs/screenshots/proxies.png b/docs/screenshots/proxies.png new file mode 100644 index 0000000..17d3f5d Binary files /dev/null and b/docs/screenshots/proxies.png differ diff --git a/docs/screenshots/servers.png b/docs/screenshots/servers.png new file mode 100644 index 0000000..2400055 Binary files /dev/null and b/docs/screenshots/servers.png differ diff --git a/docs/screenshots/tools-json.png b/docs/screenshots/tools-json.png new file mode 100644 index 0000000..3453a48 Binary files /dev/null and b/docs/screenshots/tools-json.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a1be704 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + 池易工作箱 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ea086a9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2439 @@ +{ + "name": "cyi-box-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cyi-box-frontend", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@vicons/ionicons5": "^0.13.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "echarts": "^5.6.0", + "markdown-it": "^14.1.0", + "naive-ui": "^2.45.0", + "pinia": "^4.0.3", + "qrcode": "^1.5.4", + "vue": "^3.5.40", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^6.0.8", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vue-tsc": "^3.3.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", + "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.45.3", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.45.3.tgz", + "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.66" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nostics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.3.0.tgz", + "integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.3.tgz", + "integrity": "sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==", + "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz", + "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.7", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.1.tgz", + "integrity": "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==", + "license": "MIT", + "dependencies": { + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.66", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.66.tgz", + "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0535a43 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "cyi-box-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@vicons/ionicons5": "^0.13.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "echarts": "^5.6.0", + "markdown-it": "^14.1.0", + "naive-ui": "^2.45.0", + "pinia": "^4.0.3", + "qrcode": "^1.5.4", + "vue": "^3.5.40", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^6.0.8", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vue-tsc": "^3.3.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..fdc0cb8 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/api/authSvc.ts b/frontend/src/api/authSvc.ts new file mode 100644 index 0000000..eeca801 --- /dev/null +++ b/frontend/src/api/authSvc.ts @@ -0,0 +1,24 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type changePasswordDto from "./changePasswordDto"; +import type loginDto from "./loginDto"; +import type okView from "./okView"; +import type sessionView from "./sessionView"; + +export default class authSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async changePassword(dto:changePasswordDto, options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "changePassword", dto, options) + } + async login(dto:loginDto, options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "login", dto, options) + } + async logout(options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "logout", undefined, options) + } + async me(options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "me", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/bookmarkIdDto.ts b/frontend/src/api/bookmarkIdDto.ts new file mode 100644 index 0000000..fc4a4e1 --- /dev/null +++ b/frontend/src/api/bookmarkIdDto.ts @@ -0,0 +1,3 @@ +export default interface bookmarkIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/bookmarkSvc.ts b/frontend/src/api/bookmarkSvc.ts new file mode 100644 index 0000000..9505ba8 --- /dev/null +++ b/frontend/src/api/bookmarkSvc.ts @@ -0,0 +1,21 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type bookmarkIdDto from "./bookmarkIdDto"; +import type bookmarkView from "./bookmarkView"; +import type okView from "./okView"; +import type saveBookmarkDto from "./saveBookmarkDto"; + +export default class bookmarkSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:bookmarkIdDto, options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "list", undefined, options) + } + async save(dto:saveBookmarkDto, options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "save", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/bookmarkView.ts b/frontend/src/api/bookmarkView.ts new file mode 100644 index 0000000..41913d1 --- /dev/null +++ b/frontend/src/api/bookmarkView.ts @@ -0,0 +1,7 @@ +export default interface bookmarkView { + id:number + title:string + url:string + icon:string + sort:number +} \ No newline at end of file diff --git a/frontend/src/api/changePasswordDto.ts b/frontend/src/api/changePasswordDto.ts new file mode 100644 index 0000000..ece2f50 --- /dev/null +++ b/frontend/src/api/changePasswordDto.ts @@ -0,0 +1,4 @@ +export default interface changePasswordDto { + oldPassword:string + newPassword:string +} \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..09077af --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,665 @@ +// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量 +// 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。 +// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可 +const BIGINT_PREFIX = "\u0000fun-bigint:"; + +function reviveBigint(value: any): any { + if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) && + /^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) { + return BigInt(value.slice(BIGINT_PREFIX.length)); + } + if (Array.isArray(value)) return value.map(reviveBigint); + if (value !== null && typeof value === "object") { + for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]); + return value; + } + return value; +} + +function parseLossless(text: string): any { + // 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先, + // 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt + const guarded = text.replace( + /"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, + token => { + if (token.startsWith('"')) return token; + if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) { + // 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符), + // JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头) + return '"\\u0000fun-bigint:' + token + '"'; + } + return token; + } + ); + return reviveBigint(JSON.parse(guarded)); +} + +function stringifyLossless(value: any): string | undefined { + const raw = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v + ); + if (raw === undefined) return undefined; + // 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"(NUL 被转义),去引号还原为数字字面量 + return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1"); +} + +export type result = { + code?: number; + data?: T; + msg?: string; + status: number; +}; + +export type resultStatus = 0 | 1 | 2 | 4 | 5; +// 0 success; 1 framework/client protocol error; 2 business error; 4 external request error; 5 external timeout + +export type RequestOptions = { + signal?: AbortSignal; + state?: Record; +}; + +export type StreamOptions = { + signal?: AbortSignal; + state?: Record; + /** + * 原生断线重连:流因传输失败(status 4/5)结束时按指数退避自动重连。 + * dto 传工厂函数时每次重连重新求值,用于刷新续传游标。 + * 未传时关闭(默认),与 request 语义一致。 + */ + retry?: StreamRetryOptions | false; + /** 空闲看门狗:超过该毫秒数未收到任何字节(含心跳空行)即判定流死亡并走重连。0 关闭。 */ + idleTimeoutMs?: number; +}; + +export type StreamRetryOptions = { + /** 最大重连次数,默认 Infinity(直到 signal 取消或成功)。 */ + maxAttempts?: number; + /** 首次重连延迟(毫秒),默认 500。 */ + baseDelayMs?: number; + /** 退避上限(毫秒),默认 10000。实际延迟为指数退避 × 0.75~1.25 抖动。 */ + maxDelayMs?: number; +}; + +export type RequestInterceptor = ( + serviceName: string, + methodName: string, + state: Record, + dto?: any +) => Promise | void; + +export type ResponseContext = { + readonly requestState: Readonly>; + readonly response?: Response; +}; + +export type ResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result +) => Promise | void> | result | void; + +export type ContextResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result, + context: ResponseContext +) => Promise | void> | result | void; + +function messageOf(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + return "unknown error"; +} + +function failure(status: resultStatus, msg: string): result { + return { status, msg }; +} + +function isTimeout(error: unknown, signal?: AbortSignal): boolean { + const errorName = error !== null && typeof error === "object" + ? (error as { name?: unknown }).name + : undefined; + const reason = signal?.reason; + const reasonName = reason !== null && typeof reason === "object" + ? (reason as { name?: unknown }).name + : undefined; + return errorName === "TimeoutError" || reasonName === "TimeoutError"; +} + +function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result { + if (isTimeout(error, signal)) { + return failure(5, stream ? "Stream timed out" : "Request timed out"); + } + if (signal?.aborted === true) { + return failure(4, stream ? "Stream aborted" : "Request aborted"); + } + const kind = stream ? "External stream request" : "External request"; + return failure(4, `${kind} failed: ${messageOf(error)}`); +} + +function isResult(value: unknown): value is result { + return value !== null && typeof value === "object" && + typeof (value as { status?: unknown }).status === "number"; +} + +function mediaType(response: Response): string { + return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase(); +} + +function excerpt(text: string, limit = 180): string { + const value = text.replace(/\s+/g, " ").trim(); + return value.length <= limit ? value : `${value.slice(0, limit)}...`; +} + +function externalFailure(response: Response, detail?: string): result { + const timeout = response.status === 408 || response.status === 504; + const statusText = response.statusText || (timeout ? "timeout" : "request failed"); + const suffix = detail ? `: ${detail}` : ""; + return failure(timeout ? 5 : 4, `HTTP ${response.status} ${statusText}${suffix}`); +} + +function responseReadFailure( + error: unknown, + response: Response, + signal: AbortSignal | undefined, + stream: boolean +): result { + if (isTimeout(error, signal)) { + return failure(5, stream ? "Stream timed out" : "Request timed out"); + } + if (signal?.aborted === true) { + return failure(4, stream ? "Stream aborted" : "Request aborted"); + } + const kind = stream ? "Stream" : "Response body"; + return response.ok + ? failure(4, `${kind} failed: ${messageOf(error)}`) + : externalFailure(response, `response body failed: ${messageOf(error)}`); +} + +function parseResult(response: Response, text: string): result { + const body = text.trim(); + if (!body) { + return response.ok + ? failure(1, "Empty response body") + : externalFailure(response); + } + + let value: unknown; + try { + value = parseLossless(body); + } catch { + if (!response.ok) return externalFailure(response, excerpt(body)); + const type = mediaType(response); + if (type === "text/html" || /^\s*(?: = {}; + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ContextResponseInterceptor[] = []; + + constructor(url: string) { + this.url = url.replace(/\/+$/, ""); + } + + setState(state: Record) { + this.state = state; + } + + addRequestInterceptor(interceptor: RequestInterceptor) { + this.requestInterceptors.push(interceptor); + } + + addResponseInterceptor(interceptor: ResponseInterceptor): void; + addResponseInterceptor(interceptor: ContextResponseInterceptor): void; + addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) { + this.responseInterceptors.push(interceptor as ContextResponseInterceptor); + } + + private async interceptResponse( + serviceName: string, + methodName: string, + initial: result, + requestState: Readonly>, + response?: Response + ): Promise> { + let current = initial; + const context: ResponseContext = Object.freeze( + response === undefined ? { requestState } : { requestState, response } + ); + for (const interceptor of this.responseInterceptors) { + try { + const replaced = await interceptor(serviceName, methodName, current, context); + if (replaced) current = replaced; + } catch (error) { + current = failure(1, `Response interceptor failed: ${messageOf(error)}`); + } + } + return current; + } + + private async interceptRequest( + serviceName: string, + methodName: string, + state: Record, + dto: any + ): Promise { + for (const interceptor of this.requestInterceptors) { + await interceptor(serviceName, methodName, state, dto); + } + } + + private snapshotState(state: Record): Readonly> { + return Object.freeze({ ...state }); + } + + async request( + serviceName: string, + methodName: string, + dto?: any, + options?: RequestOptions + ): Promise> { + let state: Record; + try { + state = { ...this.state, ...options?.state }; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not prepare request state: ${messageOf(error)}`), + Object.freeze({}) + ) as result; + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (snapshotError) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(snapshotError)}`), + Object.freeze({}) + ) as result; + } + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Request interceptor failed: ${messageOf(error)}`), + requestState + ) as result; + } + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(error)}`), + Object.freeze({}) + ) as result; + } + + let body: string; + try { + const serialized = stringifyLossless({ + serviceName, + methodName, + data: dto, + ...(Object.keys(requestState).length ? { state: requestState } : {}), + }); + if (serialized === undefined) throw new Error("serialization produced no output"); + body = serialized; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not serialize request: ${messageOf(error)}`), + requestState + ) as result; + } + + let output: result; + let response: Response | undefined; + try { + response = await fetch(`${this.url}/cell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: options?.signal, + }); + try { + output = parseResult(response, await response.text()); + } catch (error) { + output = responseReadFailure(error, response, options?.signal, false); + } + } catch (error) { + output = requestFailure(error, options?.signal, false); + } + return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result; + } + + async stream( + serviceName: string, + methodName: string, + dto: any | undefined | (() => any | undefined), + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + // dto 工厂:重连时重新求值,用于刷新续传游标(如 afterId) + const getDto = typeof dto === "function" ? (dto as () => any | undefined) : () => dto; + // 重连默认关闭(与 request 语义一致);传 retry: {...} 显式开启 + const retry = options?.retry + ? { + maxAttempts: Infinity, + baseDelayMs: 500, + maxDelayMs: 10000, + ...options.retry, + } + : null; + for (let attempt = 0; ; attempt++) { + const r = await this.streamOnce(serviceName, methodName, getDto(), onMessage, options); + // 0 成功、1 协议错误、2 业务错误:终结态,不重连 + if (r.status !== 4 && r.status !== 5) return r; + if (!retry) return r; + if (options?.signal?.aborted) return r; + if (attempt + 1 > retry.maxAttempts) return r; + const delay = + Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** attempt) * (0.75 + Math.random() * 0.5); + // 退避等待:AbortSignal.timeout 免定时器 API,且后台标签页照常走时 + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(); + return; + } + AbortSignal.timeout(delay).addEventListener("abort", () => resolve(), { once: true }); + options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + if (options?.signal?.aborted) return r; + } + } + + private async streamOnce( + serviceName: string, + methodName: string, + dto: any | undefined, + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + let state: Record; + try { + state = { ...this.state, ...options?.state }; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not prepare request state: ${messageOf(error)}`), + Object.freeze({}) + ); + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (snapshotError) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(snapshotError)}`), + Object.freeze({}) + ); + } + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Request interceptor failed: ${messageOf(error)}`), + requestState + ); + } + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(error)}`), + Object.freeze({}) + ); + } + + let body: string; + try { + const serialized = stringifyLossless({ + serviceName, + methodName, + data: dto, + ...(Object.keys(requestState).length ? { state: requestState } : {}), + }); + if (serialized === undefined) throw new Error("serialization produced no output"); + body = serialized; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not serialize request: ${messageOf(error)}`), + requestState + ); + } + + let response: Response; + try { + response = await fetch(`${this.url}/cell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: options?.signal, + }); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + requestFailure(error, options?.signal, true), + requestState + ); + } + + if (!response.ok) { + let text: string; + try { + text = await response.text(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + return await this.interceptResponse( + serviceName, + methodName, + parseResult(response, text), + requestState, + response + ); + } + + if (mediaType(response) !== "application/x-ndjson") { + let text: string; + try { + text = await response.text(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + const rpcResult = parseResult(response, text); + return await this.interceptResponse( + serviceName, + methodName, + rpcResult.status === 0 + ? failure(1, "Expected application/x-ndjson response") + : rpcResult, + requestState, + response + ); + } + + if (!response.body) { + return await this.interceptResponse( + serviceName, + methodName, + { status: 0 }, + requestState, + response + ); + } + + let reader: ReadableStreamDefaultReader; + try { + reader = response.body.getReader(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let lineNumber = 0; + let failed: result | undefined; + let cause: unknown; + + const emitLine = async (line: string) => { + const payload = line.replace(/\r$/, "").trim(); + if (!payload) return; + let data: T; + try { + data = parseLossless(payload) as T; + } catch (error) { + failed = failure(1, `Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`); + cause = error; + return; + } + try { + await onMessage(data); + } catch (error) { + failed = failure(1, `Stream callback failed: ${messageOf(error)}`); + cause = error; + } + }; + + try { + // 空闲看门狗:idleTimeoutMs 内未收到任何字节(含心跳空行)判定流死亡, + // 抛出哨兵错误 → status 5 → 交给上层重连。服务端 25s 心跳时建议 35000。 + const STREAM_IDLE = Symbol("stream-idle"); + for (;;) { + let part: ReadableStreamReadResult; + try { + if (options?.idleTimeoutMs) { + const idlePromise = new Promise((_, reject) => { + AbortSignal.timeout(options.idleTimeoutMs!).addEventListener( + "abort", + () => reject(STREAM_IDLE), + { once: true } + ); + }); + idlePromise.catch(() => {}); // read 先返回时防 unhandled rejection + part = await Promise.race([reader.read(), idlePromise]); + } else { + part = await reader.read(); + } + } catch (error) { + cause = error; + if (error === STREAM_IDLE) { + failed = failure(5, "Stream idle timeout"); + } else if (isTimeout(error, options?.signal)) { + failed = failure(5, "Stream timed out"); + } else if (options?.signal?.aborted === true) { + failed = failure(4, "Stream aborted"); + } else { + failed = failure(4, `Stream read failed: ${messageOf(error)}`); + } + break; + } + if (part.done) break; + try { + buffer += decoder.decode(part.value, { stream: true }); + } catch (error) { + failed = failure(1, `Invalid UTF-8 stream data: ${messageOf(error)}`); + cause = error; + break; + } + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + lineNumber++; + await emitLine(line); + if (failed) break; + } + if (failed) break; + } + + if (!failed) { + try { + buffer += decoder.decode(); + } catch (error) { + failed = failure(1, `Invalid UTF-8 stream data: ${messageOf(error)}`); + cause = error; + } + } + if (!failed && buffer.length > 0) { + lineNumber++; + await emitLine(buffer); + } + + } finally { + if (failed) { + try { + await reader.cancel(cause); + } catch { + // The reader may already be closed by the runtime. + } + } + try { + reader.releaseLock(); + } catch { + // The reader may already be errored or released. + } + } + return await this.interceptResponse( + serviceName, + methodName, + failed || { status: 0 }, + requestState, + response + ); + } +} \ No newline at end of file diff --git a/frontend/src/api/createShareDto.ts b/frontend/src/api/createShareDto.ts new file mode 100644 index 0000000..5d110a8 --- /dev/null +++ b/frontend/src/api/createShareDto.ts @@ -0,0 +1,4 @@ +export default interface createShareDto { + fileId:number + expireMinutes:number +} \ No newline at end of file diff --git a/frontend/src/api/dashboardSvc.ts b/frontend/src/api/dashboardSvc.ts new file mode 100644 index 0000000..df6e279 --- /dev/null +++ b/frontend/src/api/dashboardSvc.ts @@ -0,0 +1,12 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type statsView from "./statsView"; + +export default class dashboardSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("dashboardSvc", "stats", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/dbColumnView.ts b/frontend/src/api/dbColumnView.ts new file mode 100644 index 0000000..2313a4d --- /dev/null +++ b/frontend/src/api/dbColumnView.ts @@ -0,0 +1,6 @@ +export default interface dbColumnView { + table:string + name:string + type:string + pk:boolean +} \ No newline at end of file diff --git a/frontend/src/api/dbDatabasesView.ts b/frontend/src/api/dbDatabasesView.ts new file mode 100644 index 0000000..ba7630c --- /dev/null +++ b/frontend/src/api/dbDatabasesView.ts @@ -0,0 +1,3 @@ +export default interface dbDatabasesView { + names:string[] +} \ No newline at end of file diff --git a/frontend/src/api/dbMgrSvc.ts b/frontend/src/api/dbMgrSvc.ts new file mode 100644 index 0000000..33c1063 --- /dev/null +++ b/frontend/src/api/dbMgrSvc.ts @@ -0,0 +1,38 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type dbDatabasesView from "./dbDatabasesView"; +import type dbQueryDto from "./dbQueryDto"; +import type dbQueryView from "./dbQueryView"; +import type dbScopeDto from "./dbScopeDto"; +import type dbTablesView from "./dbTablesView"; +import type dbconnIdDto from "./dbconnIdDto"; +import type dbconnView from "./dbconnView"; +import type okView from "./okView"; +import type saveDbconnDto from "./saveDbconnDto"; + +export default class dbMgrSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async databases(dto:dbScopeDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "databases", dto, options) + } + async delete(dto:dbconnIdDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "list", undefined, options) + } + async query(dto:dbQueryDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "query", dto, options) + } + async save(dto:saveDbconnDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "save", dto, options) + } + async tables(dto:dbScopeDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "tables", dto, options) + } + async test(dto:dbconnIdDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "test", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/dbQueryDto.ts b/frontend/src/api/dbQueryDto.ts new file mode 100644 index 0000000..0298521 --- /dev/null +++ b/frontend/src/api/dbQueryDto.ts @@ -0,0 +1,6 @@ +export default interface dbQueryDto { + id:number + database?:string | null + sql:string + maxRows?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/dbQueryView.ts b/frontend/src/api/dbQueryView.ts new file mode 100644 index 0000000..df39f49 --- /dev/null +++ b/frontend/src/api/dbQueryView.ts @@ -0,0 +1,9 @@ +export default interface dbQueryView { + isSelect:boolean + columns:string[] + rowsJson:string + rowCount:number + affectedRows:number + truncated:boolean + elapsedMs:number +} \ No newline at end of file diff --git a/frontend/src/api/dbScopeDto.ts b/frontend/src/api/dbScopeDto.ts new file mode 100644 index 0000000..6dfe44a --- /dev/null +++ b/frontend/src/api/dbScopeDto.ts @@ -0,0 +1,4 @@ +export default interface dbScopeDto { + id:number + database?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/dbTableView.ts b/frontend/src/api/dbTableView.ts new file mode 100644 index 0000000..b56b145 --- /dev/null +++ b/frontend/src/api/dbTableView.ts @@ -0,0 +1,4 @@ +export default interface dbTableView { + name:string + type:string +} \ No newline at end of file diff --git a/frontend/src/api/dbTablesView.ts b/frontend/src/api/dbTablesView.ts new file mode 100644 index 0000000..e3aad2a --- /dev/null +++ b/frontend/src/api/dbTablesView.ts @@ -0,0 +1,6 @@ +import type dbColumnView from "./dbColumnView"; +import type dbTableView from "./dbTableView"; +export default interface dbTablesView { + tables:dbTableView[] + columns:dbColumnView[] +} \ No newline at end of file diff --git a/frontend/src/api/dbconnIdDto.ts b/frontend/src/api/dbconnIdDto.ts new file mode 100644 index 0000000..9058dc8 --- /dev/null +++ b/frontend/src/api/dbconnIdDto.ts @@ -0,0 +1,3 @@ +export default interface dbconnIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/dbconnView.ts b/frontend/src/api/dbconnView.ts new file mode 100644 index 0000000..460bf9f --- /dev/null +++ b/frontend/src/api/dbconnView.ts @@ -0,0 +1,11 @@ +export default interface dbconnView { + id:number + name:string + engine:string + host:string + port:number + username:string + database:string + hasPassword:boolean + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/deleteToolDto.ts b/frontend/src/api/deleteToolDto.ts new file mode 100644 index 0000000..7f165f7 --- /dev/null +++ b/frontend/src/api/deleteToolDto.ts @@ -0,0 +1,3 @@ +export default interface deleteToolDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/deleteUserDto.ts b/frontend/src/api/deleteUserDto.ts new file mode 100644 index 0000000..46ae2aa --- /dev/null +++ b/frontend/src/api/deleteUserDto.ts @@ -0,0 +1,3 @@ +export default interface deleteUserDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/diskMountView.ts b/frontend/src/api/diskMountView.ts new file mode 100644 index 0000000..5471301 --- /dev/null +++ b/frontend/src/api/diskMountView.ts @@ -0,0 +1,8 @@ +export default interface diskMountView { + mount:string + fs:string + total:number + used:number + avail:number + percent:number +} \ No newline at end of file diff --git a/frontend/src/api/diskSvc.ts b/frontend/src/api/diskSvc.ts new file mode 100644 index 0000000..3f81cd5 --- /dev/null +++ b/frontend/src/api/diskSvc.ts @@ -0,0 +1,28 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type createShareDto from "./createShareDto"; +import type fileView from "./fileView"; +import type idDto from "./idDto"; +import type okView from "./okView"; +import type shareView from "./shareView"; + +export default class diskSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async createShare(dto:createShareDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "createShare", dto, options) + } + async deleteFile(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "deleteFile", dto, options) + } + async deleteShare(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "deleteShare", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "list", undefined, options) + } + async listShares(options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "listShares", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/fileView.ts b/frontend/src/api/fileView.ts new file mode 100644 index 0000000..020f18d --- /dev/null +++ b/frontend/src/api/fileView.ts @@ -0,0 +1,7 @@ +export default interface fileView { + id:number + name:string + size:number + mime:string + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/fun.ts b/frontend/src/api/fun.ts new file mode 100644 index 0000000..f016894 --- /dev/null +++ b/frontend/src/api/fun.ts @@ -0,0 +1,43 @@ +import { Client } from "./client"; +import authSvc from "./authSvc"; +import bookmarkSvc from "./bookmarkSvc"; +import dashboardSvc from "./dashboardSvc"; +import dbMgrSvc from "./dbMgrSvc"; +import diskSvc from "./diskSvc"; +import licenseSvc from "./licenseSvc"; +import memorySvc from "./memorySvc"; +import paySvc from "./paySvc"; +import proxySvc from "./proxySvc"; +import serverSvc from "./serverSvc"; +import settingSvc from "./settingSvc"; +import toolSvc from "./toolSvc"; +import userSvc from "./userSvc"; +import vaultSvc from "./vaultSvc"; +import webSvc from "./webSvc"; + +export class defaultApi extends Client { + constructor(url: string) { + super(url); + } + public authSvc: authSvc = new authSvc(this); + public bookmarkSvc: bookmarkSvc = new bookmarkSvc(this); + public dashboardSvc: dashboardSvc = new dashboardSvc(this); + public dbMgrSvc: dbMgrSvc = new dbMgrSvc(this); + public diskSvc: diskSvc = new diskSvc(this); + public licenseSvc: licenseSvc = new licenseSvc(this); + public memorySvc: memorySvc = new memorySvc(this); + public paySvc: paySvc = new paySvc(this); + public proxySvc: proxySvc = new proxySvc(this); + public serverSvc: serverSvc = new serverSvc(this); + public settingSvc: settingSvc = new settingSvc(this); + public toolSvc: toolSvc = new toolSvc(this); + public userSvc: userSvc = new userSvc(this); + public vaultSvc: vaultSvc = new vaultSvc(this); + public webSvc: webSvc = new webSvc(this); +} + +export default class api { + static create(url: string): defaultApi { + return new defaultApi(url); + } +} \ No newline at end of file diff --git a/frontend/src/api/idDto.ts b/frontend/src/api/idDto.ts new file mode 100644 index 0000000..f69ce11 --- /dev/null +++ b/frontend/src/api/idDto.ts @@ -0,0 +1,3 @@ +export default interface idDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseAppSaveDto.ts b/frontend/src/api/licenseAppSaveDto.ts new file mode 100644 index 0000000..52277b2 --- /dev/null +++ b/frontend/src/api/licenseAppSaveDto.ts @@ -0,0 +1,5 @@ +export default interface licenseAppSaveDto { + id:number + name:string + enabled?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/licenseAppView.ts b/frontend/src/api/licenseAppView.ts new file mode 100644 index 0000000..b7d4748 --- /dev/null +++ b/frontend/src/api/licenseAppView.ts @@ -0,0 +1,8 @@ +export default interface licenseAppView { + id:number + appid:string + name:string + enabled:number + cardCount:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseAppsView.ts b/frontend/src/api/licenseAppsView.ts new file mode 100644 index 0000000..d176362 --- /dev/null +++ b/frontend/src/api/licenseAppsView.ts @@ -0,0 +1,4 @@ +import type licenseAppView from "./licenseAppView"; +export default interface licenseAppsView { + items:licenseAppView[] +} \ No newline at end of file diff --git a/frontend/src/api/licenseCardView.ts b/frontend/src/api/licenseCardView.ts new file mode 100644 index 0000000..d440cea --- /dev/null +++ b/frontend/src/api/licenseCardView.ts @@ -0,0 +1,10 @@ +export default interface licenseCardView { + id:number + card:string + hours:number + domain:string + status:number + activatedAt:number + expiresAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseGenDto.ts b/frontend/src/api/licenseGenDto.ts new file mode 100644 index 0000000..0bb7e32 --- /dev/null +++ b/frontend/src/api/licenseGenDto.ts @@ -0,0 +1,5 @@ +export default interface licenseGenDto { + appId:number + count:number + hours:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseGenView.ts b/frontend/src/api/licenseGenView.ts new file mode 100644 index 0000000..c07d4b1 --- /dev/null +++ b/frontend/src/api/licenseGenView.ts @@ -0,0 +1,3 @@ +export default interface licenseGenView { + cards:string[] +} \ No newline at end of file diff --git a/frontend/src/api/licenseIdDto.ts b/frontend/src/api/licenseIdDto.ts new file mode 100644 index 0000000..906a6a1 --- /dev/null +++ b/frontend/src/api/licenseIdDto.ts @@ -0,0 +1,3 @@ +export default interface licenseIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseListDto.ts b/frontend/src/api/licenseListDto.ts new file mode 100644 index 0000000..d24da9d --- /dev/null +++ b/frontend/src/api/licenseListDto.ts @@ -0,0 +1,6 @@ +export default interface licenseListDto { + appId:number + page:number + pageSize:number + kw?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/licensePageView.ts b/frontend/src/api/licensePageView.ts new file mode 100644 index 0000000..5d28f99 --- /dev/null +++ b/frontend/src/api/licensePageView.ts @@ -0,0 +1,5 @@ +import type licenseCardView from "./licenseCardView"; +export default interface licensePageView { + total:number + items:licenseCardView[] +} \ No newline at end of file diff --git a/frontend/src/api/licenseStatsDto.ts b/frontend/src/api/licenseStatsDto.ts new file mode 100644 index 0000000..65ce456 --- /dev/null +++ b/frontend/src/api/licenseStatsDto.ts @@ -0,0 +1,3 @@ +export default interface licenseStatsDto { + appId:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseStatsView.ts b/frontend/src/api/licenseStatsView.ts new file mode 100644 index 0000000..35f3496 --- /dev/null +++ b/frontend/src/api/licenseStatsView.ts @@ -0,0 +1,6 @@ +export default interface licenseStatsView { + total:number + unused:number + active:number + expired:number +} \ No newline at end of file diff --git a/frontend/src/api/licenseSvc.ts b/frontend/src/api/licenseSvc.ts new file mode 100644 index 0000000..cf43aca --- /dev/null +++ b/frontend/src/api/licenseSvc.ts @@ -0,0 +1,40 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type licenseAppSaveDto from "./licenseAppSaveDto"; +import type licenseAppView from "./licenseAppView"; +import type licenseAppsView from "./licenseAppsView"; +import type licenseGenDto from "./licenseGenDto"; +import type licenseGenView from "./licenseGenView"; +import type licenseIdDto from "./licenseIdDto"; +import type licenseListDto from "./licenseListDto"; +import type licensePageView from "./licensePageView"; +import type licenseStatsDto from "./licenseStatsDto"; +import type licenseStatsView from "./licenseStatsView"; +import type okView from "./okView"; + +export default class licenseSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async apps(options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "apps", undefined, options) + } + async delete(dto:licenseIdDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "delete", dto, options) + } + async deleteApp(dto:licenseIdDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "deleteApp", dto, options) + } + async generate(dto:licenseGenDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "generate", dto, options) + } + async list(dto:licenseListDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "list", dto, options) + } + async saveApp(dto:licenseAppSaveDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "saveApp", dto, options) + } + async stats(dto:licenseStatsDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "stats", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/listUsersDto.ts b/frontend/src/api/listUsersDto.ts new file mode 100644 index 0000000..201d57d --- /dev/null +++ b/frontend/src/api/listUsersDto.ts @@ -0,0 +1,5 @@ +export default interface listUsersDto { + page:number + pageSize:number + keyword?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/loginDto.ts b/frontend/src/api/loginDto.ts new file mode 100644 index 0000000..9fc8857 --- /dev/null +++ b/frontend/src/api/loginDto.ts @@ -0,0 +1,4 @@ +export default interface loginDto { + name:string + password:string +} \ No newline at end of file diff --git a/frontend/src/api/memoryKeyDto.ts b/frontend/src/api/memoryKeyDto.ts new file mode 100644 index 0000000..3f12499 --- /dev/null +++ b/frontend/src/api/memoryKeyDto.ts @@ -0,0 +1,3 @@ +export default interface memoryKeyDto { + key:string +} \ No newline at end of file diff --git a/frontend/src/api/memoryListDto.ts b/frontend/src/api/memoryListDto.ts new file mode 100644 index 0000000..f331c2a --- /dev/null +++ b/frontend/src/api/memoryListDto.ts @@ -0,0 +1,6 @@ +export default interface memoryListDto { + project:string + tag:string + query:string + limit:number +} \ No newline at end of file diff --git a/frontend/src/api/memoryListView.ts b/frontend/src/api/memoryListView.ts new file mode 100644 index 0000000..df96fe0 --- /dev/null +++ b/frontend/src/api/memoryListView.ts @@ -0,0 +1,4 @@ +import type memoryMetaView from "./memoryMetaView"; +export default interface memoryListView { + items:memoryMetaView[] +} \ No newline at end of file diff --git a/frontend/src/api/memoryMcpView.ts b/frontend/src/api/memoryMcpView.ts new file mode 100644 index 0000000..07bf2df --- /dev/null +++ b/frontend/src/api/memoryMcpView.ts @@ -0,0 +1,4 @@ +export default interface memoryMcpView { + url:string + token:string +} \ No newline at end of file diff --git a/frontend/src/api/memoryMetaView.ts b/frontend/src/api/memoryMetaView.ts new file mode 100644 index 0000000..502e85a --- /dev/null +++ b/frontend/src/api/memoryMetaView.ts @@ -0,0 +1,8 @@ +export default interface memoryMetaView { + key:string + project:string + tags:string[] + writtenBy:string + revision:number + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/memoryProjectView.ts b/frontend/src/api/memoryProjectView.ts new file mode 100644 index 0000000..15d8e59 --- /dev/null +++ b/frontend/src/api/memoryProjectView.ts @@ -0,0 +1,4 @@ +export default interface memoryProjectView { + name:string + count:number +} \ No newline at end of file diff --git a/frontend/src/api/memoryProjectsView.ts b/frontend/src/api/memoryProjectsView.ts new file mode 100644 index 0000000..f6ede5d --- /dev/null +++ b/frontend/src/api/memoryProjectsView.ts @@ -0,0 +1,4 @@ +import type memoryProjectView from "./memoryProjectView"; +export default interface memoryProjectsView { + items:memoryProjectView[] +} \ No newline at end of file diff --git a/frontend/src/api/memorySaveDto.ts b/frontend/src/api/memorySaveDto.ts new file mode 100644 index 0000000..141a4d3 --- /dev/null +++ b/frontend/src/api/memorySaveDto.ts @@ -0,0 +1,6 @@ +export default interface memorySaveDto { + key:string + content:string + project:string + tags:string[] +} \ No newline at end of file diff --git a/frontend/src/api/memorySaveView.ts b/frontend/src/api/memorySaveView.ts new file mode 100644 index 0000000..7e9a666 --- /dev/null +++ b/frontend/src/api/memorySaveView.ts @@ -0,0 +1,5 @@ +export default interface memorySaveView { + key:string + url:string + revision:number +} \ No newline at end of file diff --git a/frontend/src/api/memorySvc.ts b/frontend/src/api/memorySvc.ts new file mode 100644 index 0000000..b546d0e --- /dev/null +++ b/frontend/src/api/memorySvc.ts @@ -0,0 +1,38 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type memoryKeyDto from "./memoryKeyDto"; +import type memoryListDto from "./memoryListDto"; +import type memoryListView from "./memoryListView"; +import type memoryMcpView from "./memoryMcpView"; +import type memoryProjectsView from "./memoryProjectsView"; +import type memorySaveDto from "./memorySaveDto"; +import type memorySaveView from "./memorySaveView"; +import type memoryView from "./memoryView"; +import type okView from "./okView"; + +export default class memorySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:memoryKeyDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "delete", dto, options) + } + async get(dto:memoryKeyDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "get", dto, options) + } + async list(dto:memoryListDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "list", dto, options) + } + async mcpInfo(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "mcpInfo", undefined, options) + } + async projects(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "projects", undefined, options) + } + async resetToken(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "resetToken", undefined, options) + } + async save(dto:memorySaveDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "save", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/memoryView.ts b/frontend/src/api/memoryView.ts new file mode 100644 index 0000000..55784d5 --- /dev/null +++ b/frontend/src/api/memoryView.ts @@ -0,0 +1,11 @@ +export default interface memoryView { + key:string + url:string + content:string + project:string + tags:string[] + writtenBy:string + revision:number + createdAt:number + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/okView.ts b/frontend/src/api/okView.ts new file mode 100644 index 0000000..3f1f6ee --- /dev/null +++ b/frontend/src/api/okView.ts @@ -0,0 +1,3 @@ +export default interface okView { + message:string +} \ No newline at end of file diff --git a/frontend/src/api/payCashierDto.ts b/frontend/src/api/payCashierDto.ts new file mode 100644 index 0000000..453c260 --- /dev/null +++ b/frontend/src/api/payCashierDto.ts @@ -0,0 +1,3 @@ +export default interface payCashierDto { + tradeNo:string +} \ No newline at end of file diff --git a/frontend/src/api/payCashierStatusView.ts b/frontend/src/api/payCashierStatusView.ts new file mode 100644 index 0000000..82fb580 --- /dev/null +++ b/frontend/src/api/payCashierStatusView.ts @@ -0,0 +1,4 @@ +export default interface payCashierStatusView { + status:number + returnUrl:string +} \ No newline at end of file diff --git a/frontend/src/api/payCashierView.ts b/frontend/src/api/payCashierView.ts new file mode 100644 index 0000000..29f571a --- /dev/null +++ b/frontend/src/api/payCashierView.ts @@ -0,0 +1,13 @@ +export default interface payCashierView { + tradeNo:string + outTradeNo:string + subject:string + money:number + channel:string + status:number + payurl:string + qrcode:string + cards:string + createdAt:number + expiredAt:number +} \ No newline at end of file diff --git a/frontend/src/api/payOrderListDto.ts b/frontend/src/api/payOrderListDto.ts new file mode 100644 index 0000000..dc14713 --- /dev/null +++ b/frontend/src/api/payOrderListDto.ts @@ -0,0 +1,6 @@ +export default interface payOrderListDto { + page:number + pageSize:number + status?:number | null + kw?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/payOrderPageView.ts b/frontend/src/api/payOrderPageView.ts new file mode 100644 index 0000000..7995104 --- /dev/null +++ b/frontend/src/api/payOrderPageView.ts @@ -0,0 +1,5 @@ +import type payOrderView from "./payOrderView"; +export default interface payOrderPageView { + total:number + items:payOrderView[] +} \ No newline at end of file diff --git a/frontend/src/api/payOrderView.ts b/frontend/src/api/payOrderView.ts new file mode 100644 index 0000000..9f59c23 --- /dev/null +++ b/frontend/src/api/payOrderView.ts @@ -0,0 +1,14 @@ +export default interface payOrderView { + id:number + tradeNo:string + outTradeNo:string + channel:string + subject:string + money:number + status:number + notified:number + payurl:string + paidAt:number + expiredAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/payStatsView.ts b/frontend/src/api/payStatsView.ts new file mode 100644 index 0000000..aca80cb --- /dev/null +++ b/frontend/src/api/payStatsView.ts @@ -0,0 +1,9 @@ +export default interface payStatsView { + todayCount:number + todayMoney:number + totalCount:number + totalMoney:number + pendingCount:number + todayPaidCount:number + totalPaidCount:number +} \ No newline at end of file diff --git a/frontend/src/api/paySvc.ts b/frontend/src/api/paySvc.ts new file mode 100644 index 0000000..75cec01 --- /dev/null +++ b/frontend/src/api/paySvc.ts @@ -0,0 +1,42 @@ +import { Client, type result, type RequestOptions, type StreamOptions } from "./client"; +import type payCashierDto from "./payCashierDto"; +import type payCashierStatusView from "./payCashierStatusView"; +import type payCashierView from "./payCashierView"; +import type payOrderListDto from "./payOrderListDto"; +import type payOrderPageView from "./payOrderPageView"; +import type payStatsView from "./payStatsView"; +import type payTestPayDto from "./payTestPayDto"; +import type payTestPayView from "./payTestPayView"; +import type payUpstreamDto from "./payUpstreamDto"; +import type payUpstreamStatusView from "./payUpstreamStatusView"; + +export default class paySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async cashierInfo(dto:payCashierDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "cashierInfo", dto, options) + } + async cashierStatus(dto:payCashierDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "cashierStatus", dto, options) + } + async list(dto:payOrderListDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "list", dto, options) + } + async saveUpstream(dto:payUpstreamDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "saveUpstream", dto, options) + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "stats", undefined, options) + } + async testPay(dto:payTestPayDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "testPay", dto, options) + } + async upstreamStatus(options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "upstreamStatus", undefined, options) + } + async watch(dto:payCashierDto | (() => payCashierDto), onMessage: (data: payCashierStatusView) => unknown, options?: StreamOptions): Promise> { + return await this.client.stream("paySvc", "watch", dto, onMessage, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/payTestPayDto.ts b/frontend/src/api/payTestPayDto.ts new file mode 100644 index 0000000..ccc77a3 --- /dev/null +++ b/frontend/src/api/payTestPayDto.ts @@ -0,0 +1,3 @@ +export default interface payTestPayDto { + money:string +} \ No newline at end of file diff --git a/frontend/src/api/payTestPayView.ts b/frontend/src/api/payTestPayView.ts new file mode 100644 index 0000000..8dd173e --- /dev/null +++ b/frontend/src/api/payTestPayView.ts @@ -0,0 +1,7 @@ +export default interface payTestPayView { + tradeNo:string + payurl:string + qrcode:string + money:number + cashier:string +} \ No newline at end of file diff --git a/frontend/src/api/payTrendPoint.ts b/frontend/src/api/payTrendPoint.ts new file mode 100644 index 0000000..68571b5 --- /dev/null +++ b/frontend/src/api/payTrendPoint.ts @@ -0,0 +1,5 @@ +export default interface payTrendPoint { + day:string + money:number + count:number +} \ No newline at end of file diff --git a/frontend/src/api/payUpstreamDto.ts b/frontend/src/api/payUpstreamDto.ts new file mode 100644 index 0000000..312b862 --- /dev/null +++ b/frontend/src/api/payUpstreamDto.ts @@ -0,0 +1,4 @@ +export default interface payUpstreamDto { + username:string + password:string +} \ No newline at end of file diff --git a/frontend/src/api/payUpstreamStatusView.ts b/frontend/src/api/payUpstreamStatusView.ts new file mode 100644 index 0000000..eaf3fda --- /dev/null +++ b/frontend/src/api/payUpstreamStatusView.ts @@ -0,0 +1,18 @@ +export default interface payUpstreamStatusView { + configured:number + username:string + nickname:string + shop:string + tokenAge:number + goodsId:number + goodsKey:string + goodsName:string + unitPrice:number + stockCount:number + targetMoney:number + walletOk:number + walletAvail:number + walletFrozen:number + ensuring:number + lastError:string +} \ No newline at end of file diff --git a/frontend/src/api/proxyKeySaveDto.ts b/frontend/src/api/proxyKeySaveDto.ts new file mode 100644 index 0000000..0a9aa66 --- /dev/null +++ b/frontend/src/api/proxyKeySaveDto.ts @@ -0,0 +1,4 @@ +export default interface proxyKeySaveDto { + name:string + regionId:number +} \ No newline at end of file diff --git a/frontend/src/api/proxyKeyView.ts b/frontend/src/api/proxyKeyView.ts new file mode 100644 index 0000000..76a7d61 --- /dev/null +++ b/frontend/src/api/proxyKeyView.ts @@ -0,0 +1,12 @@ +export default interface proxyKeyView { + id:number + key:string + name:string + regionId:number + regionCode:string + regionZhName:string + enabled:boolean + usedCount:number + lastUsedAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/proxyListDto.ts b/frontend/src/api/proxyListDto.ts new file mode 100644 index 0000000..68cb224 --- /dev/null +++ b/frontend/src/api/proxyListDto.ts @@ -0,0 +1,8 @@ +export default interface proxyListDto { + regionId:number + protocol:string + keyword:string + alive:number + page:number + pageSize:number +} \ No newline at end of file diff --git a/frontend/src/api/proxyListView.ts b/frontend/src/api/proxyListView.ts new file mode 100644 index 0000000..ad8612a --- /dev/null +++ b/frontend/src/api/proxyListView.ts @@ -0,0 +1,5 @@ +import type proxyView from "./proxyView"; +export default interface proxyListView { + total:number + items:proxyView[] +} \ No newline at end of file diff --git a/frontend/src/api/proxyRegionView.ts b/frontend/src/api/proxyRegionView.ts new file mode 100644 index 0000000..613c5c9 --- /dev/null +++ b/frontend/src/api/proxyRegionView.ts @@ -0,0 +1,8 @@ +export default interface proxyRegionView { + id:number + code:string + name:string + zhName:string + region:string + aliveCount:number +} \ No newline at end of file diff --git a/frontend/src/api/proxyStatsView.ts b/frontend/src/api/proxyStatsView.ts new file mode 100644 index 0000000..3d51a14 --- /dev/null +++ b/frontend/src/api/proxyStatsView.ts @@ -0,0 +1,9 @@ +export default interface proxyStatsView { + total:number + regions:number + lastSyncAt:number + lastCheckAt:number + lastStatus:string + checking:boolean + syncing:boolean +} \ No newline at end of file diff --git a/frontend/src/api/proxySvc.ts b/frontend/src/api/proxySvc.ts new file mode 100644 index 0000000..1d7d8f9 --- /dev/null +++ b/frontend/src/api/proxySvc.ts @@ -0,0 +1,44 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type idDto from "./idDto"; +import type okView from "./okView"; +import type proxyKeySaveDto from "./proxyKeySaveDto"; +import type proxyKeyView from "./proxyKeyView"; +import type proxyListDto from "./proxyListDto"; +import type proxyListView from "./proxyListView"; +import type proxyRegionView from "./proxyRegionView"; +import type proxyStatsView from "./proxyStatsView"; +import type proxySyncView from "./proxySyncView"; + +export default class proxySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async checkNow(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "checkNow", undefined, options) + } + async createKey(dto:proxyKeySaveDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "createKey", dto, options) + } + async deleteKey(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "deleteKey", dto, options) + } + async list(dto:proxyListDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "list", dto, options) + } + async listKeys(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "listKeys", undefined, options) + } + async regions(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "regions", undefined, options) + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "stats", undefined, options) + } + async syncNow(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "syncNow", undefined, options) + } + async syncs(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "syncs", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/proxySyncView.ts b/frontend/src/api/proxySyncView.ts new file mode 100644 index 0000000..1f09eeb --- /dev/null +++ b/frontend/src/api/proxySyncView.ts @@ -0,0 +1,13 @@ +export default interface proxySyncView { + id:number + startedAt:number + finishedAt:number + status:string + source:string + total:number + added:number + removed:number + updated:number + detailJson:string + error:string +} \ No newline at end of file diff --git a/frontend/src/api/proxyView.ts b/frontend/src/api/proxyView.ts new file mode 100644 index 0000000..ed241d3 --- /dev/null +++ b/frontend/src/api/proxyView.ts @@ -0,0 +1,20 @@ +export default interface proxyView { + id:number + ip:string + port:number + address:string + protocols:string + anonymity:string + regionId:number + regionCode:string + regionName:string + regionZhName:string + latency:number + speed:number + uptime:number + alive:boolean + failCount:number + lastCheckedAt:number + lastAliveAt:number + lastSeenAt:number +} \ No newline at end of file diff --git a/frontend/src/api/recentMemoryView.ts b/frontend/src/api/recentMemoryView.ts new file mode 100644 index 0000000..9f05cc2 --- /dev/null +++ b/frontend/src/api/recentMemoryView.ts @@ -0,0 +1,5 @@ +export default interface recentMemoryView { + key:string + project:string + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/recentOrderView.ts b/frontend/src/api/recentOrderView.ts new file mode 100644 index 0000000..00dc655 --- /dev/null +++ b/frontend/src/api/recentOrderView.ts @@ -0,0 +1,6 @@ +export default interface recentOrderView { + tradeNo:string + subject:string + money:number + paidAt:number +} \ No newline at end of file diff --git a/frontend/src/api/saveBookmarkDto.ts b/frontend/src/api/saveBookmarkDto.ts new file mode 100644 index 0000000..83592d9 --- /dev/null +++ b/frontend/src/api/saveBookmarkDto.ts @@ -0,0 +1,7 @@ +export default interface saveBookmarkDto { + id?:number | null + title:string + url:string + icon?:string | null + sort?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/saveDbconnDto.ts b/frontend/src/api/saveDbconnDto.ts new file mode 100644 index 0000000..371446b --- /dev/null +++ b/frontend/src/api/saveDbconnDto.ts @@ -0,0 +1,11 @@ +export default interface saveDbconnDto { + id?:number | null + name:string + engine:string + host?:string | null + port?:number | null + username?:string | null + password?:string | null + database?:string | null + params?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/saveServerDto.ts b/frontend/src/api/saveServerDto.ts new file mode 100644 index 0000000..af22b4b --- /dev/null +++ b/frontend/src/api/saveServerDto.ts @@ -0,0 +1,10 @@ +export default interface saveServerDto { + id?:number | null + name:string + host:string + port?:number | null + username:string + authType:string + secret?:string | null + note?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/saveToolDto.ts b/frontend/src/api/saveToolDto.ts new file mode 100644 index 0000000..6a32aef --- /dev/null +++ b/frontend/src/api/saveToolDto.ts @@ -0,0 +1,9 @@ +export default interface saveToolDto { + id?:number | null + name:string + icon?:string | null + url:string + description?:string | null + sort?:number | null + enabled?:boolean | null +} \ No newline at end of file diff --git a/frontend/src/api/saveUserDto.ts b/frontend/src/api/saveUserDto.ts new file mode 100644 index 0000000..85d9442 --- /dev/null +++ b/frontend/src/api/saveUserDto.ts @@ -0,0 +1,7 @@ +export default interface saveUserDto { + id?:number | null + name:string + password?:string | null + role:string + status?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/saveVaultDto.ts b/frontend/src/api/saveVaultDto.ts new file mode 100644 index 0000000..23e6b0c --- /dev/null +++ b/frontend/src/api/saveVaultDto.ts @@ -0,0 +1,9 @@ +export default interface saveVaultDto { + id?:number | null + title:string + username?:string | null + password?:string | null + url?:string | null + note?:string | null + totpSecret?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/secretView.ts b/frontend/src/api/secretView.ts new file mode 100644 index 0000000..9908339 --- /dev/null +++ b/frontend/src/api/secretView.ts @@ -0,0 +1,3 @@ +export default interface secretView { + value:string +} \ No newline at end of file diff --git a/frontend/src/api/serverIdDto.ts b/frontend/src/api/serverIdDto.ts new file mode 100644 index 0000000..6de47e4 --- /dev/null +++ b/frontend/src/api/serverIdDto.ts @@ -0,0 +1,3 @@ +export default interface serverIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/serverMetricsView.ts b/frontend/src/api/serverMetricsView.ts new file mode 100644 index 0000000..d842fbd --- /dev/null +++ b/frontend/src/api/serverMetricsView.ts @@ -0,0 +1,25 @@ +import type diskMountView from "./diskMountView"; +export default interface serverMetricsView { + hostname:string + os:string + kernel:string + uptimeSeconds:number + cpuPercent:number + load1:string + load5:string + load15:string + memTotal:number + memUsed:number + memAvail:number + swapTotal:number + swapUsed:number + diskTotal:number + diskUsed:number + diskAvail:number + netRxRate:number + netTxRate:number + netRxTotal:number + netTxTotal:number + disks:diskMountView[] + collectedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/serverSvc.ts b/frontend/src/api/serverSvc.ts new file mode 100644 index 0000000..5f771b8 --- /dev/null +++ b/frontend/src/api/serverSvc.ts @@ -0,0 +1,40 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type saveServerDto from "./saveServerDto"; +import type serverIdDto from "./serverIdDto"; +import type serverMetricsView from "./serverMetricsView"; +import type serverView from "./serverView"; +import type sftpListView from "./sftpListView"; +import type sftpPathDto from "./sftpPathDto"; +import type sftpRenameDto from "./sftpRenameDto"; + +export default class serverSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:serverIdDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "list", undefined, options) + } + async metrics(dto:serverIdDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "metrics", dto, options) + } + async save(dto:saveServerDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "save", dto, options) + } + async sftpList(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpList", dto, options) + } + async sftpMkdir(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpMkdir", dto, options) + } + async sftpRemove(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpRemove", dto, options) + } + async sftpRename(dto:sftpRenameDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpRename", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/serverView.ts b/frontend/src/api/serverView.ts new file mode 100644 index 0000000..bf3e189 --- /dev/null +++ b/frontend/src/api/serverView.ts @@ -0,0 +1,11 @@ +export default interface serverView { + id:number + name:string + host:string + port:number + username:string + authType:string + hasSecret:boolean + note?:string | null + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/sessionView.ts b/frontend/src/api/sessionView.ts new file mode 100644 index 0000000..7a1b0ed --- /dev/null +++ b/frontend/src/api/sessionView.ts @@ -0,0 +1,5 @@ +import type userView from "./userView"; +export default interface sessionView { + user?:userView | null + token?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/settingDto.ts b/frontend/src/api/settingDto.ts new file mode 100644 index 0000000..46463d5 --- /dev/null +++ b/frontend/src/api/settingDto.ts @@ -0,0 +1,4 @@ +export default interface settingDto { + key:string + value:string +} \ No newline at end of file diff --git a/frontend/src/api/settingSvc.ts b/frontend/src/api/settingSvc.ts new file mode 100644 index 0000000..7f0fe3f --- /dev/null +++ b/frontend/src/api/settingSvc.ts @@ -0,0 +1,17 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type settingDto from "./settingDto"; +import type settingView from "./settingView"; + +export default class settingSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async get(dto:settingDto, options?: RequestOptions): Promise> { + return await this.client.request("settingSvc", "get", dto, options) + } + async set(dto:settingDto, options?: RequestOptions): Promise> { + return await this.client.request("settingSvc", "set", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/settingView.ts b/frontend/src/api/settingView.ts new file mode 100644 index 0000000..9bab4a1 --- /dev/null +++ b/frontend/src/api/settingView.ts @@ -0,0 +1,3 @@ +export default interface settingView { + value:string +} \ No newline at end of file diff --git a/frontend/src/api/sftpEntryView.ts b/frontend/src/api/sftpEntryView.ts new file mode 100644 index 0000000..1a467cd --- /dev/null +++ b/frontend/src/api/sftpEntryView.ts @@ -0,0 +1,7 @@ +export default interface sftpEntryView { + name:string + size:number + mode:string + isDir:boolean + modTime:number +} \ No newline at end of file diff --git a/frontend/src/api/sftpListView.ts b/frontend/src/api/sftpListView.ts new file mode 100644 index 0000000..ece61b2 --- /dev/null +++ b/frontend/src/api/sftpListView.ts @@ -0,0 +1,5 @@ +import type sftpEntryView from "./sftpEntryView"; +export default interface sftpListView { + path:string + entries:sftpEntryView[] +} \ No newline at end of file diff --git a/frontend/src/api/sftpPathDto.ts b/frontend/src/api/sftpPathDto.ts new file mode 100644 index 0000000..9ff76fd --- /dev/null +++ b/frontend/src/api/sftpPathDto.ts @@ -0,0 +1,4 @@ +export default interface sftpPathDto { + id:number + path:string +} \ No newline at end of file diff --git a/frontend/src/api/sftpRenameDto.ts b/frontend/src/api/sftpRenameDto.ts new file mode 100644 index 0000000..aed374d --- /dev/null +++ b/frontend/src/api/sftpRenameDto.ts @@ -0,0 +1,5 @@ +export default interface sftpRenameDto { + id:number + path:string + newName:string +} \ No newline at end of file diff --git a/frontend/src/api/shareView.ts b/frontend/src/api/shareView.ts new file mode 100644 index 0000000..eaee1d5 --- /dev/null +++ b/frontend/src/api/shareView.ts @@ -0,0 +1,9 @@ +export default interface shareView { + id:number + code:string + fileName:string + expiresAt:number + expired:boolean + downloads:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/statsView.ts b/frontend/src/api/statsView.ts new file mode 100644 index 0000000..c778dc7 --- /dev/null +++ b/frontend/src/api/statsView.ts @@ -0,0 +1,30 @@ +import type payTrendPoint from "./payTrendPoint"; +import type recentMemoryView from "./recentMemoryView"; +import type recentOrderView from "./recentOrderView"; +import type trendPoint from "./trendPoint"; +export default interface statsView { + totalUsers:number + activeSessions:number + todayLogins:number + uptimeSeconds:number + loginTrend:trendPoint[] + servers:number + proxies:number + aliveProxies:number + todayOrders:number + todayMoney:number + totalMoney:number + pendingOrders:number + licenseApps:number + licenseCards:number + licenseActive:number + memories:number + memoryProjects:number + bookmarks:number + vaultItems:number + dbconns:number + files:number + payTrend:payTrendPoint[] + recentOrders:recentOrderView[] + recentMemories:recentMemoryView[] +} \ No newline at end of file diff --git a/frontend/src/api/toolSvc.ts b/frontend/src/api/toolSvc.ts new file mode 100644 index 0000000..2c59efa --- /dev/null +++ b/frontend/src/api/toolSvc.ts @@ -0,0 +1,24 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type deleteToolDto from "./deleteToolDto"; +import type okView from "./okView"; +import type saveToolDto from "./saveToolDto"; +import type toolView from "./toolView"; + +export default class toolSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async adminList(options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "adminList", undefined, options) + } + async deleteTool(dto:deleteToolDto, options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "deleteTool", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "list", undefined, options) + } + async saveTool(dto:saveToolDto, options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "saveTool", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/toolView.ts b/frontend/src/api/toolView.ts new file mode 100644 index 0000000..6ad8559 --- /dev/null +++ b/frontend/src/api/toolView.ts @@ -0,0 +1,9 @@ +export default interface toolView { + id:number + name:string + icon:string + url:string + description:string + sort:number + enabled:boolean +} \ No newline at end of file diff --git a/frontend/src/api/totpView.ts b/frontend/src/api/totpView.ts new file mode 100644 index 0000000..530be9b --- /dev/null +++ b/frontend/src/api/totpView.ts @@ -0,0 +1,4 @@ +export default interface totpView { + code:string + secondsLeft:number +} \ No newline at end of file diff --git a/frontend/src/api/trendPoint.ts b/frontend/src/api/trendPoint.ts new file mode 100644 index 0000000..f4338eb --- /dev/null +++ b/frontend/src/api/trendPoint.ts @@ -0,0 +1,4 @@ +export default interface trendPoint { + day:string + count:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/authSvc.ts b/frontend/src/api/ts/authSvc.ts new file mode 100644 index 0000000..eeca801 --- /dev/null +++ b/frontend/src/api/ts/authSvc.ts @@ -0,0 +1,24 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type changePasswordDto from "./changePasswordDto"; +import type loginDto from "./loginDto"; +import type okView from "./okView"; +import type sessionView from "./sessionView"; + +export default class authSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async changePassword(dto:changePasswordDto, options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "changePassword", dto, options) + } + async login(dto:loginDto, options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "login", dto, options) + } + async logout(options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "logout", undefined, options) + } + async me(options?: RequestOptions): Promise> { + return await this.client.request("authSvc", "me", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/bookmarkIdDto.ts b/frontend/src/api/ts/bookmarkIdDto.ts new file mode 100644 index 0000000..fc4a4e1 --- /dev/null +++ b/frontend/src/api/ts/bookmarkIdDto.ts @@ -0,0 +1,3 @@ +export default interface bookmarkIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/bookmarkSvc.ts b/frontend/src/api/ts/bookmarkSvc.ts new file mode 100644 index 0000000..9505ba8 --- /dev/null +++ b/frontend/src/api/ts/bookmarkSvc.ts @@ -0,0 +1,21 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type bookmarkIdDto from "./bookmarkIdDto"; +import type bookmarkView from "./bookmarkView"; +import type okView from "./okView"; +import type saveBookmarkDto from "./saveBookmarkDto"; + +export default class bookmarkSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:bookmarkIdDto, options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "list", undefined, options) + } + async save(dto:saveBookmarkDto, options?: RequestOptions): Promise> { + return await this.client.request("bookmarkSvc", "save", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/bookmarkView.ts b/frontend/src/api/ts/bookmarkView.ts new file mode 100644 index 0000000..41913d1 --- /dev/null +++ b/frontend/src/api/ts/bookmarkView.ts @@ -0,0 +1,7 @@ +export default interface bookmarkView { + id:number + title:string + url:string + icon:string + sort:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/changePasswordDto.ts b/frontend/src/api/ts/changePasswordDto.ts new file mode 100644 index 0000000..ece2f50 --- /dev/null +++ b/frontend/src/api/ts/changePasswordDto.ts @@ -0,0 +1,4 @@ +export default interface changePasswordDto { + oldPassword:string + newPassword:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/client.ts b/frontend/src/api/ts/client.ts new file mode 100644 index 0000000..09077af --- /dev/null +++ b/frontend/src/api/ts/client.ts @@ -0,0 +1,665 @@ +// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量 +// 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。 +// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可 +const BIGINT_PREFIX = "\u0000fun-bigint:"; + +function reviveBigint(value: any): any { + if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) && + /^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) { + return BigInt(value.slice(BIGINT_PREFIX.length)); + } + if (Array.isArray(value)) return value.map(reviveBigint); + if (value !== null && typeof value === "object") { + for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]); + return value; + } + return value; +} + +function parseLossless(text: string): any { + // 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先, + // 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt + const guarded = text.replace( + /"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, + token => { + if (token.startsWith('"')) return token; + if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) { + // 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符), + // JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头) + return '"\\u0000fun-bigint:' + token + '"'; + } + return token; + } + ); + return reviveBigint(JSON.parse(guarded)); +} + +function stringifyLossless(value: any): string | undefined { + const raw = JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v + ); + if (raw === undefined) return undefined; + // 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"(NUL 被转义),去引号还原为数字字面量 + return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1"); +} + +export type result = { + code?: number; + data?: T; + msg?: string; + status: number; +}; + +export type resultStatus = 0 | 1 | 2 | 4 | 5; +// 0 success; 1 framework/client protocol error; 2 business error; 4 external request error; 5 external timeout + +export type RequestOptions = { + signal?: AbortSignal; + state?: Record; +}; + +export type StreamOptions = { + signal?: AbortSignal; + state?: Record; + /** + * 原生断线重连:流因传输失败(status 4/5)结束时按指数退避自动重连。 + * dto 传工厂函数时每次重连重新求值,用于刷新续传游标。 + * 未传时关闭(默认),与 request 语义一致。 + */ + retry?: StreamRetryOptions | false; + /** 空闲看门狗:超过该毫秒数未收到任何字节(含心跳空行)即判定流死亡并走重连。0 关闭。 */ + idleTimeoutMs?: number; +}; + +export type StreamRetryOptions = { + /** 最大重连次数,默认 Infinity(直到 signal 取消或成功)。 */ + maxAttempts?: number; + /** 首次重连延迟(毫秒),默认 500。 */ + baseDelayMs?: number; + /** 退避上限(毫秒),默认 10000。实际延迟为指数退避 × 0.75~1.25 抖动。 */ + maxDelayMs?: number; +}; + +export type RequestInterceptor = ( + serviceName: string, + methodName: string, + state: Record, + dto?: any +) => Promise | void; + +export type ResponseContext = { + readonly requestState: Readonly>; + readonly response?: Response; +}; + +export type ResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result +) => Promise | void> | result | void; + +export type ContextResponseInterceptor = ( + serviceName: string, + methodName: string, + result: result, + context: ResponseContext +) => Promise | void> | result | void; + +function messageOf(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + return "unknown error"; +} + +function failure(status: resultStatus, msg: string): result { + return { status, msg }; +} + +function isTimeout(error: unknown, signal?: AbortSignal): boolean { + const errorName = error !== null && typeof error === "object" + ? (error as { name?: unknown }).name + : undefined; + const reason = signal?.reason; + const reasonName = reason !== null && typeof reason === "object" + ? (reason as { name?: unknown }).name + : undefined; + return errorName === "TimeoutError" || reasonName === "TimeoutError"; +} + +function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result { + if (isTimeout(error, signal)) { + return failure(5, stream ? "Stream timed out" : "Request timed out"); + } + if (signal?.aborted === true) { + return failure(4, stream ? "Stream aborted" : "Request aborted"); + } + const kind = stream ? "External stream request" : "External request"; + return failure(4, `${kind} failed: ${messageOf(error)}`); +} + +function isResult(value: unknown): value is result { + return value !== null && typeof value === "object" && + typeof (value as { status?: unknown }).status === "number"; +} + +function mediaType(response: Response): string { + return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase(); +} + +function excerpt(text: string, limit = 180): string { + const value = text.replace(/\s+/g, " ").trim(); + return value.length <= limit ? value : `${value.slice(0, limit)}...`; +} + +function externalFailure(response: Response, detail?: string): result { + const timeout = response.status === 408 || response.status === 504; + const statusText = response.statusText || (timeout ? "timeout" : "request failed"); + const suffix = detail ? `: ${detail}` : ""; + return failure(timeout ? 5 : 4, `HTTP ${response.status} ${statusText}${suffix}`); +} + +function responseReadFailure( + error: unknown, + response: Response, + signal: AbortSignal | undefined, + stream: boolean +): result { + if (isTimeout(error, signal)) { + return failure(5, stream ? "Stream timed out" : "Request timed out"); + } + if (signal?.aborted === true) { + return failure(4, stream ? "Stream aborted" : "Request aborted"); + } + const kind = stream ? "Stream" : "Response body"; + return response.ok + ? failure(4, `${kind} failed: ${messageOf(error)}`) + : externalFailure(response, `response body failed: ${messageOf(error)}`); +} + +function parseResult(response: Response, text: string): result { + const body = text.trim(); + if (!body) { + return response.ok + ? failure(1, "Empty response body") + : externalFailure(response); + } + + let value: unknown; + try { + value = parseLossless(body); + } catch { + if (!response.ok) return externalFailure(response, excerpt(body)); + const type = mediaType(response); + if (type === "text/html" || /^\s*(?: = {}; + private requestInterceptors: RequestInterceptor[] = []; + private responseInterceptors: ContextResponseInterceptor[] = []; + + constructor(url: string) { + this.url = url.replace(/\/+$/, ""); + } + + setState(state: Record) { + this.state = state; + } + + addRequestInterceptor(interceptor: RequestInterceptor) { + this.requestInterceptors.push(interceptor); + } + + addResponseInterceptor(interceptor: ResponseInterceptor): void; + addResponseInterceptor(interceptor: ContextResponseInterceptor): void; + addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) { + this.responseInterceptors.push(interceptor as ContextResponseInterceptor); + } + + private async interceptResponse( + serviceName: string, + methodName: string, + initial: result, + requestState: Readonly>, + response?: Response + ): Promise> { + let current = initial; + const context: ResponseContext = Object.freeze( + response === undefined ? { requestState } : { requestState, response } + ); + for (const interceptor of this.responseInterceptors) { + try { + const replaced = await interceptor(serviceName, methodName, current, context); + if (replaced) current = replaced; + } catch (error) { + current = failure(1, `Response interceptor failed: ${messageOf(error)}`); + } + } + return current; + } + + private async interceptRequest( + serviceName: string, + methodName: string, + state: Record, + dto: any + ): Promise { + for (const interceptor of this.requestInterceptors) { + await interceptor(serviceName, methodName, state, dto); + } + } + + private snapshotState(state: Record): Readonly> { + return Object.freeze({ ...state }); + } + + async request( + serviceName: string, + methodName: string, + dto?: any, + options?: RequestOptions + ): Promise> { + let state: Record; + try { + state = { ...this.state, ...options?.state }; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not prepare request state: ${messageOf(error)}`), + Object.freeze({}) + ) as result; + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (snapshotError) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(snapshotError)}`), + Object.freeze({}) + ) as result; + } + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Request interceptor failed: ${messageOf(error)}`), + requestState + ) as result; + } + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(error)}`), + Object.freeze({}) + ) as result; + } + + let body: string; + try { + const serialized = stringifyLossless({ + serviceName, + methodName, + data: dto, + ...(Object.keys(requestState).length ? { state: requestState } : {}), + }); + if (serialized === undefined) throw new Error("serialization produced no output"); + body = serialized; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not serialize request: ${messageOf(error)}`), + requestState + ) as result; + } + + let output: result; + let response: Response | undefined; + try { + response = await fetch(`${this.url}/cell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: options?.signal, + }); + try { + output = parseResult(response, await response.text()); + } catch (error) { + output = responseReadFailure(error, response, options?.signal, false); + } + } catch (error) { + output = requestFailure(error, options?.signal, false); + } + return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result; + } + + async stream( + serviceName: string, + methodName: string, + dto: any | undefined | (() => any | undefined), + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + // dto 工厂:重连时重新求值,用于刷新续传游标(如 afterId) + const getDto = typeof dto === "function" ? (dto as () => any | undefined) : () => dto; + // 重连默认关闭(与 request 语义一致);传 retry: {...} 显式开启 + const retry = options?.retry + ? { + maxAttempts: Infinity, + baseDelayMs: 500, + maxDelayMs: 10000, + ...options.retry, + } + : null; + for (let attempt = 0; ; attempt++) { + const r = await this.streamOnce(serviceName, methodName, getDto(), onMessage, options); + // 0 成功、1 协议错误、2 业务错误:终结态,不重连 + if (r.status !== 4 && r.status !== 5) return r; + if (!retry) return r; + if (options?.signal?.aborted) return r; + if (attempt + 1 > retry.maxAttempts) return r; + const delay = + Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** attempt) * (0.75 + Math.random() * 0.5); + // 退避等待:AbortSignal.timeout 免定时器 API,且后台标签页照常走时 + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(); + return; + } + AbortSignal.timeout(delay).addEventListener("abort", () => resolve(), { once: true }); + options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + if (options?.signal?.aborted) return r; + } + } + + private async streamOnce( + serviceName: string, + methodName: string, + dto: any | undefined, + onMessage: (data: T) => unknown, + options?: StreamOptions + ): Promise> { + let state: Record; + try { + state = { ...this.state, ...options?.state }; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not prepare request state: ${messageOf(error)}`), + Object.freeze({}) + ); + } + try { + await this.interceptRequest(serviceName, methodName, state, dto); + } catch (error) { + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (snapshotError) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(snapshotError)}`), + Object.freeze({}) + ); + } + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Request interceptor failed: ${messageOf(error)}`), + requestState + ); + } + let requestState: Readonly>; + try { + requestState = this.snapshotState(state); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not snapshot request state: ${messageOf(error)}`), + Object.freeze({}) + ); + } + + let body: string; + try { + const serialized = stringifyLossless({ + serviceName, + methodName, + data: dto, + ...(Object.keys(requestState).length ? { state: requestState } : {}), + }); + if (serialized === undefined) throw new Error("serialization produced no output"); + body = serialized; + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + failure(1, `Could not serialize request: ${messageOf(error)}`), + requestState + ); + } + + let response: Response; + try { + response = await fetch(`${this.url}/cell`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: options?.signal, + }); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + requestFailure(error, options?.signal, true), + requestState + ); + } + + if (!response.ok) { + let text: string; + try { + text = await response.text(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + return await this.interceptResponse( + serviceName, + methodName, + parseResult(response, text), + requestState, + response + ); + } + + if (mediaType(response) !== "application/x-ndjson") { + let text: string; + try { + text = await response.text(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + const rpcResult = parseResult(response, text); + return await this.interceptResponse( + serviceName, + methodName, + rpcResult.status === 0 + ? failure(1, "Expected application/x-ndjson response") + : rpcResult, + requestState, + response + ); + } + + if (!response.body) { + return await this.interceptResponse( + serviceName, + methodName, + { status: 0 }, + requestState, + response + ); + } + + let reader: ReadableStreamDefaultReader; + try { + reader = response.body.getReader(); + } catch (error) { + return await this.interceptResponse( + serviceName, + methodName, + responseReadFailure(error, response, options?.signal, true), + requestState, + response + ); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let lineNumber = 0; + let failed: result | undefined; + let cause: unknown; + + const emitLine = async (line: string) => { + const payload = line.replace(/\r$/, "").trim(); + if (!payload) return; + let data: T; + try { + data = parseLossless(payload) as T; + } catch (error) { + failed = failure(1, `Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`); + cause = error; + return; + } + try { + await onMessage(data); + } catch (error) { + failed = failure(1, `Stream callback failed: ${messageOf(error)}`); + cause = error; + } + }; + + try { + // 空闲看门狗:idleTimeoutMs 内未收到任何字节(含心跳空行)判定流死亡, + // 抛出哨兵错误 → status 5 → 交给上层重连。服务端 25s 心跳时建议 35000。 + const STREAM_IDLE = Symbol("stream-idle"); + for (;;) { + let part: ReadableStreamReadResult; + try { + if (options?.idleTimeoutMs) { + const idlePromise = new Promise((_, reject) => { + AbortSignal.timeout(options.idleTimeoutMs!).addEventListener( + "abort", + () => reject(STREAM_IDLE), + { once: true } + ); + }); + idlePromise.catch(() => {}); // read 先返回时防 unhandled rejection + part = await Promise.race([reader.read(), idlePromise]); + } else { + part = await reader.read(); + } + } catch (error) { + cause = error; + if (error === STREAM_IDLE) { + failed = failure(5, "Stream idle timeout"); + } else if (isTimeout(error, options?.signal)) { + failed = failure(5, "Stream timed out"); + } else if (options?.signal?.aborted === true) { + failed = failure(4, "Stream aborted"); + } else { + failed = failure(4, `Stream read failed: ${messageOf(error)}`); + } + break; + } + if (part.done) break; + try { + buffer += decoder.decode(part.value, { stream: true }); + } catch (error) { + failed = failure(1, `Invalid UTF-8 stream data: ${messageOf(error)}`); + cause = error; + break; + } + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + lineNumber++; + await emitLine(line); + if (failed) break; + } + if (failed) break; + } + + if (!failed) { + try { + buffer += decoder.decode(); + } catch (error) { + failed = failure(1, `Invalid UTF-8 stream data: ${messageOf(error)}`); + cause = error; + } + } + if (!failed && buffer.length > 0) { + lineNumber++; + await emitLine(buffer); + } + + } finally { + if (failed) { + try { + await reader.cancel(cause); + } catch { + // The reader may already be closed by the runtime. + } + } + try { + reader.releaseLock(); + } catch { + // The reader may already be errored or released. + } + } + return await this.interceptResponse( + serviceName, + methodName, + failed || { status: 0 }, + requestState, + response + ); + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/createShareDto.ts b/frontend/src/api/ts/createShareDto.ts new file mode 100644 index 0000000..5d110a8 --- /dev/null +++ b/frontend/src/api/ts/createShareDto.ts @@ -0,0 +1,4 @@ +export default interface createShareDto { + fileId:number + expireMinutes:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/dashboardSvc.ts b/frontend/src/api/ts/dashboardSvc.ts new file mode 100644 index 0000000..df6e279 --- /dev/null +++ b/frontend/src/api/ts/dashboardSvc.ts @@ -0,0 +1,12 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type statsView from "./statsView"; + +export default class dashboardSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("dashboardSvc", "stats", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbColumnView.ts b/frontend/src/api/ts/dbColumnView.ts new file mode 100644 index 0000000..2313a4d --- /dev/null +++ b/frontend/src/api/ts/dbColumnView.ts @@ -0,0 +1,6 @@ +export default interface dbColumnView { + table:string + name:string + type:string + pk:boolean +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbDatabasesView.ts b/frontend/src/api/ts/dbDatabasesView.ts new file mode 100644 index 0000000..ba7630c --- /dev/null +++ b/frontend/src/api/ts/dbDatabasesView.ts @@ -0,0 +1,3 @@ +export default interface dbDatabasesView { + names:string[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbMgrSvc.ts b/frontend/src/api/ts/dbMgrSvc.ts new file mode 100644 index 0000000..33c1063 --- /dev/null +++ b/frontend/src/api/ts/dbMgrSvc.ts @@ -0,0 +1,38 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type dbDatabasesView from "./dbDatabasesView"; +import type dbQueryDto from "./dbQueryDto"; +import type dbQueryView from "./dbQueryView"; +import type dbScopeDto from "./dbScopeDto"; +import type dbTablesView from "./dbTablesView"; +import type dbconnIdDto from "./dbconnIdDto"; +import type dbconnView from "./dbconnView"; +import type okView from "./okView"; +import type saveDbconnDto from "./saveDbconnDto"; + +export default class dbMgrSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async databases(dto:dbScopeDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "databases", dto, options) + } + async delete(dto:dbconnIdDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "list", undefined, options) + } + async query(dto:dbQueryDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "query", dto, options) + } + async save(dto:saveDbconnDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "save", dto, options) + } + async tables(dto:dbScopeDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "tables", dto, options) + } + async test(dto:dbconnIdDto, options?: RequestOptions): Promise> { + return await this.client.request("dbMgrSvc", "test", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbQueryDto.ts b/frontend/src/api/ts/dbQueryDto.ts new file mode 100644 index 0000000..0298521 --- /dev/null +++ b/frontend/src/api/ts/dbQueryDto.ts @@ -0,0 +1,6 @@ +export default interface dbQueryDto { + id:number + database?:string | null + sql:string + maxRows?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbQueryView.ts b/frontend/src/api/ts/dbQueryView.ts new file mode 100644 index 0000000..df39f49 --- /dev/null +++ b/frontend/src/api/ts/dbQueryView.ts @@ -0,0 +1,9 @@ +export default interface dbQueryView { + isSelect:boolean + columns:string[] + rowsJson:string + rowCount:number + affectedRows:number + truncated:boolean + elapsedMs:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbScopeDto.ts b/frontend/src/api/ts/dbScopeDto.ts new file mode 100644 index 0000000..6dfe44a --- /dev/null +++ b/frontend/src/api/ts/dbScopeDto.ts @@ -0,0 +1,4 @@ +export default interface dbScopeDto { + id:number + database?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbTableView.ts b/frontend/src/api/ts/dbTableView.ts new file mode 100644 index 0000000..b56b145 --- /dev/null +++ b/frontend/src/api/ts/dbTableView.ts @@ -0,0 +1,4 @@ +export default interface dbTableView { + name:string + type:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbTablesView.ts b/frontend/src/api/ts/dbTablesView.ts new file mode 100644 index 0000000..e3aad2a --- /dev/null +++ b/frontend/src/api/ts/dbTablesView.ts @@ -0,0 +1,6 @@ +import type dbColumnView from "./dbColumnView"; +import type dbTableView from "./dbTableView"; +export default interface dbTablesView { + tables:dbTableView[] + columns:dbColumnView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbconnIdDto.ts b/frontend/src/api/ts/dbconnIdDto.ts new file mode 100644 index 0000000..9058dc8 --- /dev/null +++ b/frontend/src/api/ts/dbconnIdDto.ts @@ -0,0 +1,3 @@ +export default interface dbconnIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/dbconnView.ts b/frontend/src/api/ts/dbconnView.ts new file mode 100644 index 0000000..460bf9f --- /dev/null +++ b/frontend/src/api/ts/dbconnView.ts @@ -0,0 +1,11 @@ +export default interface dbconnView { + id:number + name:string + engine:string + host:string + port:number + username:string + database:string + hasPassword:boolean + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/deleteToolDto.ts b/frontend/src/api/ts/deleteToolDto.ts new file mode 100644 index 0000000..7f165f7 --- /dev/null +++ b/frontend/src/api/ts/deleteToolDto.ts @@ -0,0 +1,3 @@ +export default interface deleteToolDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/deleteUserDto.ts b/frontend/src/api/ts/deleteUserDto.ts new file mode 100644 index 0000000..46ae2aa --- /dev/null +++ b/frontend/src/api/ts/deleteUserDto.ts @@ -0,0 +1,3 @@ +export default interface deleteUserDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/diskMountView.ts b/frontend/src/api/ts/diskMountView.ts new file mode 100644 index 0000000..5471301 --- /dev/null +++ b/frontend/src/api/ts/diskMountView.ts @@ -0,0 +1,8 @@ +export default interface diskMountView { + mount:string + fs:string + total:number + used:number + avail:number + percent:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/diskSvc.ts b/frontend/src/api/ts/diskSvc.ts new file mode 100644 index 0000000..3f81cd5 --- /dev/null +++ b/frontend/src/api/ts/diskSvc.ts @@ -0,0 +1,28 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type createShareDto from "./createShareDto"; +import type fileView from "./fileView"; +import type idDto from "./idDto"; +import type okView from "./okView"; +import type shareView from "./shareView"; + +export default class diskSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async createShare(dto:createShareDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "createShare", dto, options) + } + async deleteFile(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "deleteFile", dto, options) + } + async deleteShare(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "deleteShare", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "list", undefined, options) + } + async listShares(options?: RequestOptions): Promise> { + return await this.client.request("diskSvc", "listShares", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/fileView.ts b/frontend/src/api/ts/fileView.ts new file mode 100644 index 0000000..020f18d --- /dev/null +++ b/frontend/src/api/ts/fileView.ts @@ -0,0 +1,7 @@ +export default interface fileView { + id:number + name:string + size:number + mime:string + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/fun.ts b/frontend/src/api/ts/fun.ts new file mode 100644 index 0000000..f016894 --- /dev/null +++ b/frontend/src/api/ts/fun.ts @@ -0,0 +1,43 @@ +import { Client } from "./client"; +import authSvc from "./authSvc"; +import bookmarkSvc from "./bookmarkSvc"; +import dashboardSvc from "./dashboardSvc"; +import dbMgrSvc from "./dbMgrSvc"; +import diskSvc from "./diskSvc"; +import licenseSvc from "./licenseSvc"; +import memorySvc from "./memorySvc"; +import paySvc from "./paySvc"; +import proxySvc from "./proxySvc"; +import serverSvc from "./serverSvc"; +import settingSvc from "./settingSvc"; +import toolSvc from "./toolSvc"; +import userSvc from "./userSvc"; +import vaultSvc from "./vaultSvc"; +import webSvc from "./webSvc"; + +export class defaultApi extends Client { + constructor(url: string) { + super(url); + } + public authSvc: authSvc = new authSvc(this); + public bookmarkSvc: bookmarkSvc = new bookmarkSvc(this); + public dashboardSvc: dashboardSvc = new dashboardSvc(this); + public dbMgrSvc: dbMgrSvc = new dbMgrSvc(this); + public diskSvc: diskSvc = new diskSvc(this); + public licenseSvc: licenseSvc = new licenseSvc(this); + public memorySvc: memorySvc = new memorySvc(this); + public paySvc: paySvc = new paySvc(this); + public proxySvc: proxySvc = new proxySvc(this); + public serverSvc: serverSvc = new serverSvc(this); + public settingSvc: settingSvc = new settingSvc(this); + public toolSvc: toolSvc = new toolSvc(this); + public userSvc: userSvc = new userSvc(this); + public vaultSvc: vaultSvc = new vaultSvc(this); + public webSvc: webSvc = new webSvc(this); +} + +export default class api { + static create(url: string): defaultApi { + return new defaultApi(url); + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/idDto.ts b/frontend/src/api/ts/idDto.ts new file mode 100644 index 0000000..f69ce11 --- /dev/null +++ b/frontend/src/api/ts/idDto.ts @@ -0,0 +1,3 @@ +export default interface idDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseAppSaveDto.ts b/frontend/src/api/ts/licenseAppSaveDto.ts new file mode 100644 index 0000000..52277b2 --- /dev/null +++ b/frontend/src/api/ts/licenseAppSaveDto.ts @@ -0,0 +1,5 @@ +export default interface licenseAppSaveDto { + id:number + name:string + enabled?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseAppView.ts b/frontend/src/api/ts/licenseAppView.ts new file mode 100644 index 0000000..b7d4748 --- /dev/null +++ b/frontend/src/api/ts/licenseAppView.ts @@ -0,0 +1,8 @@ +export default interface licenseAppView { + id:number + appid:string + name:string + enabled:number + cardCount:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseAppsView.ts b/frontend/src/api/ts/licenseAppsView.ts new file mode 100644 index 0000000..d176362 --- /dev/null +++ b/frontend/src/api/ts/licenseAppsView.ts @@ -0,0 +1,4 @@ +import type licenseAppView from "./licenseAppView"; +export default interface licenseAppsView { + items:licenseAppView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseCardView.ts b/frontend/src/api/ts/licenseCardView.ts new file mode 100644 index 0000000..d440cea --- /dev/null +++ b/frontend/src/api/ts/licenseCardView.ts @@ -0,0 +1,10 @@ +export default interface licenseCardView { + id:number + card:string + hours:number + domain:string + status:number + activatedAt:number + expiresAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseGenDto.ts b/frontend/src/api/ts/licenseGenDto.ts new file mode 100644 index 0000000..0bb7e32 --- /dev/null +++ b/frontend/src/api/ts/licenseGenDto.ts @@ -0,0 +1,5 @@ +export default interface licenseGenDto { + appId:number + count:number + hours:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseGenView.ts b/frontend/src/api/ts/licenseGenView.ts new file mode 100644 index 0000000..c07d4b1 --- /dev/null +++ b/frontend/src/api/ts/licenseGenView.ts @@ -0,0 +1,3 @@ +export default interface licenseGenView { + cards:string[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseIdDto.ts b/frontend/src/api/ts/licenseIdDto.ts new file mode 100644 index 0000000..906a6a1 --- /dev/null +++ b/frontend/src/api/ts/licenseIdDto.ts @@ -0,0 +1,3 @@ +export default interface licenseIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseListDto.ts b/frontend/src/api/ts/licenseListDto.ts new file mode 100644 index 0000000..d24da9d --- /dev/null +++ b/frontend/src/api/ts/licenseListDto.ts @@ -0,0 +1,6 @@ +export default interface licenseListDto { + appId:number + page:number + pageSize:number + kw?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/licensePageView.ts b/frontend/src/api/ts/licensePageView.ts new file mode 100644 index 0000000..5d28f99 --- /dev/null +++ b/frontend/src/api/ts/licensePageView.ts @@ -0,0 +1,5 @@ +import type licenseCardView from "./licenseCardView"; +export default interface licensePageView { + total:number + items:licenseCardView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseStatsDto.ts b/frontend/src/api/ts/licenseStatsDto.ts new file mode 100644 index 0000000..65ce456 --- /dev/null +++ b/frontend/src/api/ts/licenseStatsDto.ts @@ -0,0 +1,3 @@ +export default interface licenseStatsDto { + appId:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseStatsView.ts b/frontend/src/api/ts/licenseStatsView.ts new file mode 100644 index 0000000..35f3496 --- /dev/null +++ b/frontend/src/api/ts/licenseStatsView.ts @@ -0,0 +1,6 @@ +export default interface licenseStatsView { + total:number + unused:number + active:number + expired:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/licenseSvc.ts b/frontend/src/api/ts/licenseSvc.ts new file mode 100644 index 0000000..cf43aca --- /dev/null +++ b/frontend/src/api/ts/licenseSvc.ts @@ -0,0 +1,40 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type licenseAppSaveDto from "./licenseAppSaveDto"; +import type licenseAppView from "./licenseAppView"; +import type licenseAppsView from "./licenseAppsView"; +import type licenseGenDto from "./licenseGenDto"; +import type licenseGenView from "./licenseGenView"; +import type licenseIdDto from "./licenseIdDto"; +import type licenseListDto from "./licenseListDto"; +import type licensePageView from "./licensePageView"; +import type licenseStatsDto from "./licenseStatsDto"; +import type licenseStatsView from "./licenseStatsView"; +import type okView from "./okView"; + +export default class licenseSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async apps(options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "apps", undefined, options) + } + async delete(dto:licenseIdDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "delete", dto, options) + } + async deleteApp(dto:licenseIdDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "deleteApp", dto, options) + } + async generate(dto:licenseGenDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "generate", dto, options) + } + async list(dto:licenseListDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "list", dto, options) + } + async saveApp(dto:licenseAppSaveDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "saveApp", dto, options) + } + async stats(dto:licenseStatsDto, options?: RequestOptions): Promise> { + return await this.client.request("licenseSvc", "stats", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/listUsersDto.ts b/frontend/src/api/ts/listUsersDto.ts new file mode 100644 index 0000000..201d57d --- /dev/null +++ b/frontend/src/api/ts/listUsersDto.ts @@ -0,0 +1,5 @@ +export default interface listUsersDto { + page:number + pageSize:number + keyword?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/loginDto.ts b/frontend/src/api/ts/loginDto.ts new file mode 100644 index 0000000..9fc8857 --- /dev/null +++ b/frontend/src/api/ts/loginDto.ts @@ -0,0 +1,4 @@ +export default interface loginDto { + name:string + password:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryKeyDto.ts b/frontend/src/api/ts/memoryKeyDto.ts new file mode 100644 index 0000000..3f12499 --- /dev/null +++ b/frontend/src/api/ts/memoryKeyDto.ts @@ -0,0 +1,3 @@ +export default interface memoryKeyDto { + key:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryListDto.ts b/frontend/src/api/ts/memoryListDto.ts new file mode 100644 index 0000000..f331c2a --- /dev/null +++ b/frontend/src/api/ts/memoryListDto.ts @@ -0,0 +1,6 @@ +export default interface memoryListDto { + project:string + tag:string + query:string + limit:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryListView.ts b/frontend/src/api/ts/memoryListView.ts new file mode 100644 index 0000000..df96fe0 --- /dev/null +++ b/frontend/src/api/ts/memoryListView.ts @@ -0,0 +1,4 @@ +import type memoryMetaView from "./memoryMetaView"; +export default interface memoryListView { + items:memoryMetaView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryMcpView.ts b/frontend/src/api/ts/memoryMcpView.ts new file mode 100644 index 0000000..07bf2df --- /dev/null +++ b/frontend/src/api/ts/memoryMcpView.ts @@ -0,0 +1,4 @@ +export default interface memoryMcpView { + url:string + token:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryMetaView.ts b/frontend/src/api/ts/memoryMetaView.ts new file mode 100644 index 0000000..502e85a --- /dev/null +++ b/frontend/src/api/ts/memoryMetaView.ts @@ -0,0 +1,8 @@ +export default interface memoryMetaView { + key:string + project:string + tags:string[] + writtenBy:string + revision:number + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryProjectView.ts b/frontend/src/api/ts/memoryProjectView.ts new file mode 100644 index 0000000..15d8e59 --- /dev/null +++ b/frontend/src/api/ts/memoryProjectView.ts @@ -0,0 +1,4 @@ +export default interface memoryProjectView { + name:string + count:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryProjectsView.ts b/frontend/src/api/ts/memoryProjectsView.ts new file mode 100644 index 0000000..f6ede5d --- /dev/null +++ b/frontend/src/api/ts/memoryProjectsView.ts @@ -0,0 +1,4 @@ +import type memoryProjectView from "./memoryProjectView"; +export default interface memoryProjectsView { + items:memoryProjectView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/memorySaveDto.ts b/frontend/src/api/ts/memorySaveDto.ts new file mode 100644 index 0000000..141a4d3 --- /dev/null +++ b/frontend/src/api/ts/memorySaveDto.ts @@ -0,0 +1,6 @@ +export default interface memorySaveDto { + key:string + content:string + project:string + tags:string[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/memorySaveView.ts b/frontend/src/api/ts/memorySaveView.ts new file mode 100644 index 0000000..7e9a666 --- /dev/null +++ b/frontend/src/api/ts/memorySaveView.ts @@ -0,0 +1,5 @@ +export default interface memorySaveView { + key:string + url:string + revision:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/memorySvc.ts b/frontend/src/api/ts/memorySvc.ts new file mode 100644 index 0000000..b546d0e --- /dev/null +++ b/frontend/src/api/ts/memorySvc.ts @@ -0,0 +1,38 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type memoryKeyDto from "./memoryKeyDto"; +import type memoryListDto from "./memoryListDto"; +import type memoryListView from "./memoryListView"; +import type memoryMcpView from "./memoryMcpView"; +import type memoryProjectsView from "./memoryProjectsView"; +import type memorySaveDto from "./memorySaveDto"; +import type memorySaveView from "./memorySaveView"; +import type memoryView from "./memoryView"; +import type okView from "./okView"; + +export default class memorySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:memoryKeyDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "delete", dto, options) + } + async get(dto:memoryKeyDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "get", dto, options) + } + async list(dto:memoryListDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "list", dto, options) + } + async mcpInfo(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "mcpInfo", undefined, options) + } + async projects(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "projects", undefined, options) + } + async resetToken(options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "resetToken", undefined, options) + } + async save(dto:memorySaveDto, options?: RequestOptions): Promise> { + return await this.client.request("memorySvc", "save", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/memoryView.ts b/frontend/src/api/ts/memoryView.ts new file mode 100644 index 0000000..55784d5 --- /dev/null +++ b/frontend/src/api/ts/memoryView.ts @@ -0,0 +1,11 @@ +export default interface memoryView { + key:string + url:string + content:string + project:string + tags:string[] + writtenBy:string + revision:number + createdAt:number + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/okView.ts b/frontend/src/api/ts/okView.ts new file mode 100644 index 0000000..3f1f6ee --- /dev/null +++ b/frontend/src/api/ts/okView.ts @@ -0,0 +1,3 @@ +export default interface okView { + message:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payCashierDto.ts b/frontend/src/api/ts/payCashierDto.ts new file mode 100644 index 0000000..453c260 --- /dev/null +++ b/frontend/src/api/ts/payCashierDto.ts @@ -0,0 +1,3 @@ +export default interface payCashierDto { + tradeNo:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payCashierStatusView.ts b/frontend/src/api/ts/payCashierStatusView.ts new file mode 100644 index 0000000..82fb580 --- /dev/null +++ b/frontend/src/api/ts/payCashierStatusView.ts @@ -0,0 +1,4 @@ +export default interface payCashierStatusView { + status:number + returnUrl:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payCashierView.ts b/frontend/src/api/ts/payCashierView.ts new file mode 100644 index 0000000..29f571a --- /dev/null +++ b/frontend/src/api/ts/payCashierView.ts @@ -0,0 +1,13 @@ +export default interface payCashierView { + tradeNo:string + outTradeNo:string + subject:string + money:number + channel:string + status:number + payurl:string + qrcode:string + cards:string + createdAt:number + expiredAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/payOrderListDto.ts b/frontend/src/api/ts/payOrderListDto.ts new file mode 100644 index 0000000..dc14713 --- /dev/null +++ b/frontend/src/api/ts/payOrderListDto.ts @@ -0,0 +1,6 @@ +export default interface payOrderListDto { + page:number + pageSize:number + status?:number | null + kw?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/payOrderPageView.ts b/frontend/src/api/ts/payOrderPageView.ts new file mode 100644 index 0000000..7995104 --- /dev/null +++ b/frontend/src/api/ts/payOrderPageView.ts @@ -0,0 +1,5 @@ +import type payOrderView from "./payOrderView"; +export default interface payOrderPageView { + total:number + items:payOrderView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/payOrderView.ts b/frontend/src/api/ts/payOrderView.ts new file mode 100644 index 0000000..9f59c23 --- /dev/null +++ b/frontend/src/api/ts/payOrderView.ts @@ -0,0 +1,14 @@ +export default interface payOrderView { + id:number + tradeNo:string + outTradeNo:string + channel:string + subject:string + money:number + status:number + notified:number + payurl:string + paidAt:number + expiredAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/payStatsView.ts b/frontend/src/api/ts/payStatsView.ts new file mode 100644 index 0000000..aca80cb --- /dev/null +++ b/frontend/src/api/ts/payStatsView.ts @@ -0,0 +1,9 @@ +export default interface payStatsView { + todayCount:number + todayMoney:number + totalCount:number + totalMoney:number + pendingCount:number + todayPaidCount:number + totalPaidCount:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/paySvc.ts b/frontend/src/api/ts/paySvc.ts new file mode 100644 index 0000000..75cec01 --- /dev/null +++ b/frontend/src/api/ts/paySvc.ts @@ -0,0 +1,42 @@ +import { Client, type result, type RequestOptions, type StreamOptions } from "./client"; +import type payCashierDto from "./payCashierDto"; +import type payCashierStatusView from "./payCashierStatusView"; +import type payCashierView from "./payCashierView"; +import type payOrderListDto from "./payOrderListDto"; +import type payOrderPageView from "./payOrderPageView"; +import type payStatsView from "./payStatsView"; +import type payTestPayDto from "./payTestPayDto"; +import type payTestPayView from "./payTestPayView"; +import type payUpstreamDto from "./payUpstreamDto"; +import type payUpstreamStatusView from "./payUpstreamStatusView"; + +export default class paySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async cashierInfo(dto:payCashierDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "cashierInfo", dto, options) + } + async cashierStatus(dto:payCashierDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "cashierStatus", dto, options) + } + async list(dto:payOrderListDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "list", dto, options) + } + async saveUpstream(dto:payUpstreamDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "saveUpstream", dto, options) + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "stats", undefined, options) + } + async testPay(dto:payTestPayDto, options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "testPay", dto, options) + } + async upstreamStatus(options?: RequestOptions): Promise> { + return await this.client.request("paySvc", "upstreamStatus", undefined, options) + } + async watch(dto:payCashierDto | (() => payCashierDto), onMessage: (data: payCashierStatusView) => unknown, options?: StreamOptions): Promise> { + return await this.client.stream("paySvc", "watch", dto, onMessage, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/payTestPayDto.ts b/frontend/src/api/ts/payTestPayDto.ts new file mode 100644 index 0000000..ccc77a3 --- /dev/null +++ b/frontend/src/api/ts/payTestPayDto.ts @@ -0,0 +1,3 @@ +export default interface payTestPayDto { + money:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payTestPayView.ts b/frontend/src/api/ts/payTestPayView.ts new file mode 100644 index 0000000..8dd173e --- /dev/null +++ b/frontend/src/api/ts/payTestPayView.ts @@ -0,0 +1,7 @@ +export default interface payTestPayView { + tradeNo:string + payurl:string + qrcode:string + money:number + cashier:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payTrendPoint.ts b/frontend/src/api/ts/payTrendPoint.ts new file mode 100644 index 0000000..68571b5 --- /dev/null +++ b/frontend/src/api/ts/payTrendPoint.ts @@ -0,0 +1,5 @@ +export default interface payTrendPoint { + day:string + money:number + count:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/payUpstreamDto.ts b/frontend/src/api/ts/payUpstreamDto.ts new file mode 100644 index 0000000..312b862 --- /dev/null +++ b/frontend/src/api/ts/payUpstreamDto.ts @@ -0,0 +1,4 @@ +export default interface payUpstreamDto { + username:string + password:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/payUpstreamStatusView.ts b/frontend/src/api/ts/payUpstreamStatusView.ts new file mode 100644 index 0000000..eaf3fda --- /dev/null +++ b/frontend/src/api/ts/payUpstreamStatusView.ts @@ -0,0 +1,18 @@ +export default interface payUpstreamStatusView { + configured:number + username:string + nickname:string + shop:string + tokenAge:number + goodsId:number + goodsKey:string + goodsName:string + unitPrice:number + stockCount:number + targetMoney:number + walletOk:number + walletAvail:number + walletFrozen:number + ensuring:number + lastError:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyKeySaveDto.ts b/frontend/src/api/ts/proxyKeySaveDto.ts new file mode 100644 index 0000000..0a9aa66 --- /dev/null +++ b/frontend/src/api/ts/proxyKeySaveDto.ts @@ -0,0 +1,4 @@ +export default interface proxyKeySaveDto { + name:string + regionId:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyKeyView.ts b/frontend/src/api/ts/proxyKeyView.ts new file mode 100644 index 0000000..76a7d61 --- /dev/null +++ b/frontend/src/api/ts/proxyKeyView.ts @@ -0,0 +1,12 @@ +export default interface proxyKeyView { + id:number + key:string + name:string + regionId:number + regionCode:string + regionZhName:string + enabled:boolean + usedCount:number + lastUsedAt:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyListDto.ts b/frontend/src/api/ts/proxyListDto.ts new file mode 100644 index 0000000..68cb224 --- /dev/null +++ b/frontend/src/api/ts/proxyListDto.ts @@ -0,0 +1,8 @@ +export default interface proxyListDto { + regionId:number + protocol:string + keyword:string + alive:number + page:number + pageSize:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyListView.ts b/frontend/src/api/ts/proxyListView.ts new file mode 100644 index 0000000..ad8612a --- /dev/null +++ b/frontend/src/api/ts/proxyListView.ts @@ -0,0 +1,5 @@ +import type proxyView from "./proxyView"; +export default interface proxyListView { + total:number + items:proxyView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyRegionView.ts b/frontend/src/api/ts/proxyRegionView.ts new file mode 100644 index 0000000..613c5c9 --- /dev/null +++ b/frontend/src/api/ts/proxyRegionView.ts @@ -0,0 +1,8 @@ +export default interface proxyRegionView { + id:number + code:string + name:string + zhName:string + region:string + aliveCount:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyStatsView.ts b/frontend/src/api/ts/proxyStatsView.ts new file mode 100644 index 0000000..3d51a14 --- /dev/null +++ b/frontend/src/api/ts/proxyStatsView.ts @@ -0,0 +1,9 @@ +export default interface proxyStatsView { + total:number + regions:number + lastSyncAt:number + lastCheckAt:number + lastStatus:string + checking:boolean + syncing:boolean +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxySvc.ts b/frontend/src/api/ts/proxySvc.ts new file mode 100644 index 0000000..1d7d8f9 --- /dev/null +++ b/frontend/src/api/ts/proxySvc.ts @@ -0,0 +1,44 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type idDto from "./idDto"; +import type okView from "./okView"; +import type proxyKeySaveDto from "./proxyKeySaveDto"; +import type proxyKeyView from "./proxyKeyView"; +import type proxyListDto from "./proxyListDto"; +import type proxyListView from "./proxyListView"; +import type proxyRegionView from "./proxyRegionView"; +import type proxyStatsView from "./proxyStatsView"; +import type proxySyncView from "./proxySyncView"; + +export default class proxySvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async checkNow(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "checkNow", undefined, options) + } + async createKey(dto:proxyKeySaveDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "createKey", dto, options) + } + async deleteKey(dto:idDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "deleteKey", dto, options) + } + async list(dto:proxyListDto, options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "list", dto, options) + } + async listKeys(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "listKeys", undefined, options) + } + async regions(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "regions", undefined, options) + } + async stats(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "stats", undefined, options) + } + async syncNow(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "syncNow", undefined, options) + } + async syncs(options?: RequestOptions): Promise> { + return await this.client.request("proxySvc", "syncs", undefined, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxySyncView.ts b/frontend/src/api/ts/proxySyncView.ts new file mode 100644 index 0000000..1f09eeb --- /dev/null +++ b/frontend/src/api/ts/proxySyncView.ts @@ -0,0 +1,13 @@ +export default interface proxySyncView { + id:number + startedAt:number + finishedAt:number + status:string + source:string + total:number + added:number + removed:number + updated:number + detailJson:string + error:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/proxyView.ts b/frontend/src/api/ts/proxyView.ts new file mode 100644 index 0000000..ed241d3 --- /dev/null +++ b/frontend/src/api/ts/proxyView.ts @@ -0,0 +1,20 @@ +export default interface proxyView { + id:number + ip:string + port:number + address:string + protocols:string + anonymity:string + regionId:number + regionCode:string + regionName:string + regionZhName:string + latency:number + speed:number + uptime:number + alive:boolean + failCount:number + lastCheckedAt:number + lastAliveAt:number + lastSeenAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/recentMemoryView.ts b/frontend/src/api/ts/recentMemoryView.ts new file mode 100644 index 0000000..9f05cc2 --- /dev/null +++ b/frontend/src/api/ts/recentMemoryView.ts @@ -0,0 +1,5 @@ +export default interface recentMemoryView { + key:string + project:string + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/recentOrderView.ts b/frontend/src/api/ts/recentOrderView.ts new file mode 100644 index 0000000..00dc655 --- /dev/null +++ b/frontend/src/api/ts/recentOrderView.ts @@ -0,0 +1,6 @@ +export default interface recentOrderView { + tradeNo:string + subject:string + money:number + paidAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveBookmarkDto.ts b/frontend/src/api/ts/saveBookmarkDto.ts new file mode 100644 index 0000000..83592d9 --- /dev/null +++ b/frontend/src/api/ts/saveBookmarkDto.ts @@ -0,0 +1,7 @@ +export default interface saveBookmarkDto { + id?:number | null + title:string + url:string + icon?:string | null + sort?:number | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveDbconnDto.ts b/frontend/src/api/ts/saveDbconnDto.ts new file mode 100644 index 0000000..371446b --- /dev/null +++ b/frontend/src/api/ts/saveDbconnDto.ts @@ -0,0 +1,11 @@ +export default interface saveDbconnDto { + id?:number | null + name:string + engine:string + host?:string | null + port?:number | null + username?:string | null + password?:string | null + database?:string | null + params?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveServerDto.ts b/frontend/src/api/ts/saveServerDto.ts new file mode 100644 index 0000000..af22b4b --- /dev/null +++ b/frontend/src/api/ts/saveServerDto.ts @@ -0,0 +1,10 @@ +export default interface saveServerDto { + id?:number | null + name:string + host:string + port?:number | null + username:string + authType:string + secret?:string | null + note?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveToolDto.ts b/frontend/src/api/ts/saveToolDto.ts new file mode 100644 index 0000000..6a32aef --- /dev/null +++ b/frontend/src/api/ts/saveToolDto.ts @@ -0,0 +1,9 @@ +export default interface saveToolDto { + id?:number | null + name:string + icon?:string | null + url:string + description?:string | null + sort?:number | null + enabled?:boolean | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveUserDto.ts b/frontend/src/api/ts/saveUserDto.ts new file mode 100644 index 0000000..85d9442 --- /dev/null +++ b/frontend/src/api/ts/saveUserDto.ts @@ -0,0 +1,7 @@ +export default interface saveUserDto { + id?:number | null + name:string + password?:string | null + role:string + status?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/saveVaultDto.ts b/frontend/src/api/ts/saveVaultDto.ts new file mode 100644 index 0000000..23e6b0c --- /dev/null +++ b/frontend/src/api/ts/saveVaultDto.ts @@ -0,0 +1,9 @@ +export default interface saveVaultDto { + id?:number | null + title:string + username?:string | null + password?:string | null + url?:string | null + note?:string | null + totpSecret?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/secretView.ts b/frontend/src/api/ts/secretView.ts new file mode 100644 index 0000000..9908339 --- /dev/null +++ b/frontend/src/api/ts/secretView.ts @@ -0,0 +1,3 @@ +export default interface secretView { + value:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/serverIdDto.ts b/frontend/src/api/ts/serverIdDto.ts new file mode 100644 index 0000000..6de47e4 --- /dev/null +++ b/frontend/src/api/ts/serverIdDto.ts @@ -0,0 +1,3 @@ +export default interface serverIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/serverMetricsView.ts b/frontend/src/api/ts/serverMetricsView.ts new file mode 100644 index 0000000..d842fbd --- /dev/null +++ b/frontend/src/api/ts/serverMetricsView.ts @@ -0,0 +1,25 @@ +import type diskMountView from "./diskMountView"; +export default interface serverMetricsView { + hostname:string + os:string + kernel:string + uptimeSeconds:number + cpuPercent:number + load1:string + load5:string + load15:string + memTotal:number + memUsed:number + memAvail:number + swapTotal:number + swapUsed:number + diskTotal:number + diskUsed:number + diskAvail:number + netRxRate:number + netTxRate:number + netRxTotal:number + netTxTotal:number + disks:diskMountView[] + collectedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/serverSvc.ts b/frontend/src/api/ts/serverSvc.ts new file mode 100644 index 0000000..5f771b8 --- /dev/null +++ b/frontend/src/api/ts/serverSvc.ts @@ -0,0 +1,40 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type saveServerDto from "./saveServerDto"; +import type serverIdDto from "./serverIdDto"; +import type serverMetricsView from "./serverMetricsView"; +import type serverView from "./serverView"; +import type sftpListView from "./sftpListView"; +import type sftpPathDto from "./sftpPathDto"; +import type sftpRenameDto from "./sftpRenameDto"; + +export default class serverSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:serverIdDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "list", undefined, options) + } + async metrics(dto:serverIdDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "metrics", dto, options) + } + async save(dto:saveServerDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "save", dto, options) + } + async sftpList(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpList", dto, options) + } + async sftpMkdir(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpMkdir", dto, options) + } + async sftpRemove(dto:sftpPathDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpRemove", dto, options) + } + async sftpRename(dto:sftpRenameDto, options?: RequestOptions): Promise> { + return await this.client.request("serverSvc", "sftpRename", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/serverView.ts b/frontend/src/api/ts/serverView.ts new file mode 100644 index 0000000..bf3e189 --- /dev/null +++ b/frontend/src/api/ts/serverView.ts @@ -0,0 +1,11 @@ +export default interface serverView { + id:number + name:string + host:string + port:number + username:string + authType:string + hasSecret:boolean + note?:string | null + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/sessionView.ts b/frontend/src/api/ts/sessionView.ts new file mode 100644 index 0000000..7a1b0ed --- /dev/null +++ b/frontend/src/api/ts/sessionView.ts @@ -0,0 +1,5 @@ +import type userView from "./userView"; +export default interface sessionView { + user?:userView | null + token?:string | null +} \ No newline at end of file diff --git a/frontend/src/api/ts/settingDto.ts b/frontend/src/api/ts/settingDto.ts new file mode 100644 index 0000000..46463d5 --- /dev/null +++ b/frontend/src/api/ts/settingDto.ts @@ -0,0 +1,4 @@ +export default interface settingDto { + key:string + value:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/settingSvc.ts b/frontend/src/api/ts/settingSvc.ts new file mode 100644 index 0000000..7f0fe3f --- /dev/null +++ b/frontend/src/api/ts/settingSvc.ts @@ -0,0 +1,17 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type settingDto from "./settingDto"; +import type settingView from "./settingView"; + +export default class settingSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async get(dto:settingDto, options?: RequestOptions): Promise> { + return await this.client.request("settingSvc", "get", dto, options) + } + async set(dto:settingDto, options?: RequestOptions): Promise> { + return await this.client.request("settingSvc", "set", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/settingView.ts b/frontend/src/api/ts/settingView.ts new file mode 100644 index 0000000..9bab4a1 --- /dev/null +++ b/frontend/src/api/ts/settingView.ts @@ -0,0 +1,3 @@ +export default interface settingView { + value:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/sftpEntryView.ts b/frontend/src/api/ts/sftpEntryView.ts new file mode 100644 index 0000000..1a467cd --- /dev/null +++ b/frontend/src/api/ts/sftpEntryView.ts @@ -0,0 +1,7 @@ +export default interface sftpEntryView { + name:string + size:number + mode:string + isDir:boolean + modTime:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/sftpListView.ts b/frontend/src/api/ts/sftpListView.ts new file mode 100644 index 0000000..ece61b2 --- /dev/null +++ b/frontend/src/api/ts/sftpListView.ts @@ -0,0 +1,5 @@ +import type sftpEntryView from "./sftpEntryView"; +export default interface sftpListView { + path:string + entries:sftpEntryView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/sftpPathDto.ts b/frontend/src/api/ts/sftpPathDto.ts new file mode 100644 index 0000000..9ff76fd --- /dev/null +++ b/frontend/src/api/ts/sftpPathDto.ts @@ -0,0 +1,4 @@ +export default interface sftpPathDto { + id:number + path:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/sftpRenameDto.ts b/frontend/src/api/ts/sftpRenameDto.ts new file mode 100644 index 0000000..aed374d --- /dev/null +++ b/frontend/src/api/ts/sftpRenameDto.ts @@ -0,0 +1,5 @@ +export default interface sftpRenameDto { + id:number + path:string + newName:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/shareView.ts b/frontend/src/api/ts/shareView.ts new file mode 100644 index 0000000..eaee1d5 --- /dev/null +++ b/frontend/src/api/ts/shareView.ts @@ -0,0 +1,9 @@ +export default interface shareView { + id:number + code:string + fileName:string + expiresAt:number + expired:boolean + downloads:number + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/statsView.ts b/frontend/src/api/ts/statsView.ts new file mode 100644 index 0000000..c778dc7 --- /dev/null +++ b/frontend/src/api/ts/statsView.ts @@ -0,0 +1,30 @@ +import type payTrendPoint from "./payTrendPoint"; +import type recentMemoryView from "./recentMemoryView"; +import type recentOrderView from "./recentOrderView"; +import type trendPoint from "./trendPoint"; +export default interface statsView { + totalUsers:number + activeSessions:number + todayLogins:number + uptimeSeconds:number + loginTrend:trendPoint[] + servers:number + proxies:number + aliveProxies:number + todayOrders:number + todayMoney:number + totalMoney:number + pendingOrders:number + licenseApps:number + licenseCards:number + licenseActive:number + memories:number + memoryProjects:number + bookmarks:number + vaultItems:number + dbconns:number + files:number + payTrend:payTrendPoint[] + recentOrders:recentOrderView[] + recentMemories:recentMemoryView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/toolSvc.ts b/frontend/src/api/ts/toolSvc.ts new file mode 100644 index 0000000..2c59efa --- /dev/null +++ b/frontend/src/api/ts/toolSvc.ts @@ -0,0 +1,24 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type deleteToolDto from "./deleteToolDto"; +import type okView from "./okView"; +import type saveToolDto from "./saveToolDto"; +import type toolView from "./toolView"; + +export default class toolSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async adminList(options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "adminList", undefined, options) + } + async deleteTool(dto:deleteToolDto, options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "deleteTool", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "list", undefined, options) + } + async saveTool(dto:saveToolDto, options?: RequestOptions): Promise> { + return await this.client.request("toolSvc", "saveTool", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/toolView.ts b/frontend/src/api/ts/toolView.ts new file mode 100644 index 0000000..6ad8559 --- /dev/null +++ b/frontend/src/api/ts/toolView.ts @@ -0,0 +1,9 @@ +export default interface toolView { + id:number + name:string + icon:string + url:string + description:string + sort:number + enabled:boolean +} \ No newline at end of file diff --git a/frontend/src/api/ts/totpView.ts b/frontend/src/api/ts/totpView.ts new file mode 100644 index 0000000..530be9b --- /dev/null +++ b/frontend/src/api/ts/totpView.ts @@ -0,0 +1,4 @@ +export default interface totpView { + code:string + secondsLeft:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/trendPoint.ts b/frontend/src/api/ts/trendPoint.ts new file mode 100644 index 0000000..f4338eb --- /dev/null +++ b/frontend/src/api/ts/trendPoint.ts @@ -0,0 +1,4 @@ +export default interface trendPoint { + day:string + count:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/userPageView.ts b/frontend/src/api/ts/userPageView.ts new file mode 100644 index 0000000..dbf48b4 --- /dev/null +++ b/frontend/src/api/ts/userPageView.ts @@ -0,0 +1,5 @@ +import type userView from "./userView"; +export default interface userPageView { + total:number + items:userView[] +} \ No newline at end of file diff --git a/frontend/src/api/ts/userSvc.ts b/frontend/src/api/ts/userSvc.ts new file mode 100644 index 0000000..64b54fd --- /dev/null +++ b/frontend/src/api/ts/userSvc.ts @@ -0,0 +1,23 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type deleteUserDto from "./deleteUserDto"; +import type listUsersDto from "./listUsersDto"; +import type okView from "./okView"; +import type saveUserDto from "./saveUserDto"; +import type userPageView from "./userPageView"; +import type userView from "./userView"; + +export default class userSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async deleteUser(dto:deleteUserDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "deleteUser", dto, options) + } + async listUsers(dto:listUsersDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "listUsers", dto, options) + } + async saveUser(dto:saveUserDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "saveUser", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/userView.ts b/frontend/src/api/ts/userView.ts new file mode 100644 index 0000000..416bec2 --- /dev/null +++ b/frontend/src/api/ts/userView.ts @@ -0,0 +1,7 @@ +export default interface userView { + id:number + name:string + role:string + status:string + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/vaultIdDto.ts b/frontend/src/api/ts/vaultIdDto.ts new file mode 100644 index 0000000..99f286a --- /dev/null +++ b/frontend/src/api/ts/vaultIdDto.ts @@ -0,0 +1,3 @@ +export default interface vaultIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/vaultItemView.ts b/frontend/src/api/ts/vaultItemView.ts new file mode 100644 index 0000000..b1e60cd --- /dev/null +++ b/frontend/src/api/ts/vaultItemView.ts @@ -0,0 +1,10 @@ +export default interface vaultItemView { + id:number + title:string + username?:string | null + url?:string | null + note?:string | null + hasPassword:boolean + hasTotp:boolean + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/ts/vaultSvc.ts b/frontend/src/api/ts/vaultSvc.ts new file mode 100644 index 0000000..f6126e4 --- /dev/null +++ b/frontend/src/api/ts/vaultSvc.ts @@ -0,0 +1,29 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type saveVaultDto from "./saveVaultDto"; +import type secretView from "./secretView"; +import type totpView from "./totpView"; +import type vaultIdDto from "./vaultIdDto"; +import type vaultItemView from "./vaultItemView"; + +export default class vaultSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "list", undefined, options) + } + async reveal(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "reveal", dto, options) + } + async save(dto:saveVaultDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "save", dto, options) + } + async totp(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "totp", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/ts/webFetchDto.ts b/frontend/src/api/ts/webFetchDto.ts new file mode 100644 index 0000000..a1e0931 --- /dev/null +++ b/frontend/src/api/ts/webFetchDto.ts @@ -0,0 +1,6 @@ +export default interface webFetchDto { + method:string + url:string + headersJson:string + body:string +} \ No newline at end of file diff --git a/frontend/src/api/ts/webFetchView.ts b/frontend/src/api/ts/webFetchView.ts new file mode 100644 index 0000000..39e7fe9 --- /dev/null +++ b/frontend/src/api/ts/webFetchView.ts @@ -0,0 +1,7 @@ +export default interface webFetchView { + status:number + headersJson:string + body:string + elapsedMs:number + truncated:boolean +} \ No newline at end of file diff --git a/frontend/src/api/ts/webSvc.ts b/frontend/src/api/ts/webSvc.ts new file mode 100644 index 0000000..ab61aba --- /dev/null +++ b/frontend/src/api/ts/webSvc.ts @@ -0,0 +1,13 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type webFetchDto from "./webFetchDto"; +import type webFetchView from "./webFetchView"; + +export default class webSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async fetch(dto:webFetchDto, options?: RequestOptions): Promise> { + return await this.client.request("webSvc", "fetch", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/userPageView.ts b/frontend/src/api/userPageView.ts new file mode 100644 index 0000000..dbf48b4 --- /dev/null +++ b/frontend/src/api/userPageView.ts @@ -0,0 +1,5 @@ +import type userView from "./userView"; +export default interface userPageView { + total:number + items:userView[] +} \ No newline at end of file diff --git a/frontend/src/api/userSvc.ts b/frontend/src/api/userSvc.ts new file mode 100644 index 0000000..64b54fd --- /dev/null +++ b/frontend/src/api/userSvc.ts @@ -0,0 +1,23 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type deleteUserDto from "./deleteUserDto"; +import type listUsersDto from "./listUsersDto"; +import type okView from "./okView"; +import type saveUserDto from "./saveUserDto"; +import type userPageView from "./userPageView"; +import type userView from "./userView"; + +export default class userSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async deleteUser(dto:deleteUserDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "deleteUser", dto, options) + } + async listUsers(dto:listUsersDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "listUsers", dto, options) + } + async saveUser(dto:saveUserDto, options?: RequestOptions): Promise> { + return await this.client.request("userSvc", "saveUser", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/userView.ts b/frontend/src/api/userView.ts new file mode 100644 index 0000000..416bec2 --- /dev/null +++ b/frontend/src/api/userView.ts @@ -0,0 +1,7 @@ +export default interface userView { + id:number + name:string + role:string + status:string + createdAt:number +} \ No newline at end of file diff --git a/frontend/src/api/vaultIdDto.ts b/frontend/src/api/vaultIdDto.ts new file mode 100644 index 0000000..99f286a --- /dev/null +++ b/frontend/src/api/vaultIdDto.ts @@ -0,0 +1,3 @@ +export default interface vaultIdDto { + id:number +} \ No newline at end of file diff --git a/frontend/src/api/vaultItemView.ts b/frontend/src/api/vaultItemView.ts new file mode 100644 index 0000000..b1e60cd --- /dev/null +++ b/frontend/src/api/vaultItemView.ts @@ -0,0 +1,10 @@ +export default interface vaultItemView { + id:number + title:string + username?:string | null + url?:string | null + note?:string | null + hasPassword:boolean + hasTotp:boolean + updatedAt:number +} \ No newline at end of file diff --git a/frontend/src/api/vaultSvc.ts b/frontend/src/api/vaultSvc.ts new file mode 100644 index 0000000..f6126e4 --- /dev/null +++ b/frontend/src/api/vaultSvc.ts @@ -0,0 +1,29 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type okView from "./okView"; +import type saveVaultDto from "./saveVaultDto"; +import type secretView from "./secretView"; +import type totpView from "./totpView"; +import type vaultIdDto from "./vaultIdDto"; +import type vaultItemView from "./vaultItemView"; + +export default class vaultSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async delete(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "delete", dto, options) + } + async list(options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "list", undefined, options) + } + async reveal(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "reveal", dto, options) + } + async save(dto:saveVaultDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "save", dto, options) + } + async totp(dto:vaultIdDto, options?: RequestOptions): Promise> { + return await this.client.request("vaultSvc", "totp", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/api/webFetchDto.ts b/frontend/src/api/webFetchDto.ts new file mode 100644 index 0000000..a1e0931 --- /dev/null +++ b/frontend/src/api/webFetchDto.ts @@ -0,0 +1,6 @@ +export default interface webFetchDto { + method:string + url:string + headersJson:string + body:string +} \ No newline at end of file diff --git a/frontend/src/api/webFetchView.ts b/frontend/src/api/webFetchView.ts new file mode 100644 index 0000000..39e7fe9 --- /dev/null +++ b/frontend/src/api/webFetchView.ts @@ -0,0 +1,7 @@ +export default interface webFetchView { + status:number + headersJson:string + body:string + elapsedMs:number + truncated:boolean +} \ No newline at end of file diff --git a/frontend/src/api/webSvc.ts b/frontend/src/api/webSvc.ts new file mode 100644 index 0000000..ab61aba --- /dev/null +++ b/frontend/src/api/webSvc.ts @@ -0,0 +1,13 @@ +import { Client, type result, type RequestOptions } from "./client"; +import type webFetchDto from "./webFetchDto"; +import type webFetchView from "./webFetchView"; + +export default class webSvc { + private client: Client; + constructor(client: Client) { + this.client = client; + } + async fetch(dto:webFetchDto, options?: RequestOptions): Promise> { + return await this.client.request("webSvc", "fetch", dto, options) + } +} \ No newline at end of file diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..233c7f5 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.vue @@ -0,0 +1,207 @@ + + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..e5cebfd --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,38 @@ +import api from '../api/fun' +import { createDiscreteApi } from 'naive-ui' + +const TOKEN_KEY = 'cyibox_token' + +export const readToken = () => localStorage.getItem(TOKEN_KEY) ?? '' +export const writeToken = (t: string) => localStorage.setItem(TOKEN_KEY, t) +export const clearToken = () => localStorage.removeItem(TOKEN_KEY) + +// 后端统一走 /api(vite 代理剥前缀);生产部署时前端同源反代即可 +export const client = api.create('/api') + +// 组件外使用的离散 message/dialog(拦截器、store 里提示用) +export const { message, dialog } = createDiscreteApi(['message', 'dialog']) + +// token 走请求 state 透传,后端 Guard 从 ctx.State["token"] 取 +client.addRequestInterceptor((_svc, _method, state) => { + const token = readToken() + if (token) state.token = token +}) + +let redirecting = false +client.addResponseInterceptor(async (_svc, _method, result) => { + // 4011 = authErrCode:登录态失效,清 token + 清 store 用户态,跳登录页 + if (result?.code === 4011) { + clearToken() + // user 不清的话守卫会把 /login 又弹回 /,死循环 + const { useAuthStore } = await import('../stores/auth') + useAuthStore().user = null + if (!redirecting && !location.pathname.startsWith('/login')) { + redirecting = true + message.warning(result?.msg || '登录已失效,请重新登录') + const { default: router } = await import('../router') + await router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) + redirecting = false + } + } +}) diff --git a/frontend/src/lib/icons.ts b/frontend/src/lib/icons.ts new file mode 100644 index 0000000..b37efd0 --- /dev/null +++ b/frontend/src/lib/icons.ts @@ -0,0 +1,56 @@ +// 工具图标名 → ionicons5 组件映射。工具表 icon 字段存这里的 key; +// 新增图标时往 map 里加一行即可,缺省回退 ConstructOutline。 +import type { Component } from 'vue' +import { + ConstructOutline, CodeOutline, TimeOutline, ImageOutline, LinkOutline, + DocumentTextOutline, ColorPaletteOutline, KeyOutline, CalculatorOutline, + GlobeOutline, TerminalOutline, WifiOutline, QrCodeOutline, TextOutline, + FileTrayOutline, BugOutline, ChatboxOutline, SearchOutline, + BookmarkOutline, StarOutline, HeartOutline, HomeOutline, CartOutline, + GameControllerOutline, MusicalNotesOutline, VideocamOutline, NewspaperOutline, + LogoGithub, FolderOutline, CloudOutline +} from '@vicons/ionicons5' + +const map: Record = { + 'construct-outline': ConstructOutline, + 'code-outline': CodeOutline, + 'time-outline': TimeOutline, + 'image-outline': ImageOutline, + 'link-outline': LinkOutline, + 'document-text-outline': DocumentTextOutline, + 'color-palette-outline': ColorPaletteOutline, + 'key-outline': KeyOutline, + 'calculator-outline': CalculatorOutline, + 'globe-outline': GlobeOutline, + 'terminal-outline': TerminalOutline, + 'wifi-outline': WifiOutline, + 'qr-code-outline': QrCodeOutline, + 'text-outline': TextOutline, + 'file-tray-outline': FileTrayOutline, + 'bug-outline': BugOutline, + 'chatbox-outline': ChatboxOutline, + 'search-outline': SearchOutline, + 'bookmark-outline': BookmarkOutline, + 'star-outline': StarOutline, + 'heart-outline': HeartOutline, + 'home-outline': HomeOutline, + 'cart-outline': CartOutline, + 'game-controller-outline': GameControllerOutline, + 'musical-notes-outline': MusicalNotesOutline, + 'videocam-outline': VideocamOutline, + 'newspaper-outline': NewspaperOutline, + 'logo-github': LogoGithub, + 'folder-outline': FolderOutline, + 'cloud-outline': CloudOutline +} + +// Memphis 色板:图标块底色按 id 轮转 +const colors = ['#fef08a', '#bae6fd', '#a7f3d0', '#ffc1cc', '#d9b3ff', '#ffd8b0'] + +export function toolIcon(name: string): Component { + return map[name] ?? ConstructOutline +} + +export function toolGradient(id: number): string { + return colors[Math.abs(id) % colors.length] +} diff --git a/frontend/src/lib/kit.ts b/frontend/src/lib/kit.ts new file mode 100644 index 0000000..09a0023 --- /dev/null +++ b/frontend/src/lib/kit.ts @@ -0,0 +1,44 @@ +// 通用小工具:剪贴板复制 + 文件下载,供工具页复用 +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + ta.remove() + return ok + } +} + +export function downloadText(filename: string, text: string, mime = 'text/plain') { + const blob = new Blob([text], { type: `${mime};charset=utf-8` }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +// JSON 语法高亮:输入必须已是格式化后的 JSON 文本,返回安全 HTML +export function highlightJson(code: string): string { + const esc = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + return esc(code).replace( + /("(\\u[a-z0-9]{4}|\\[^u]|[^\\"])*")(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?/g, + (m, str, _u, colon) => { + if (str !== undefined) { + return `${str}${colon ?? ''}` + } + if (/^(true|false)$/.test(m)) return `${m}` + if (m === 'null') return `${m}` + return `${m}` + } + ) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..501dee0 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..3eb73c1 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,187 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('../views/Login.vue'), + meta: { title: '登录' } + }, + { + path: '/cashier/:tradeNo', + name: 'cashier', + component: () => import('../views/Cashier.vue'), + meta: { title: '收银台' } + }, + { + path: '/', + component: () => import('../layouts/AdminLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: '/dashboard' }, + { + path: 'dashboard', + name: 'dashboard', + component: () => import('../views/Dashboard.vue'), + meta: { title: '仪表盘' } + }, + { + path: 'tools/json', + name: 'tool-json', + component: () => import('../views/tools/JsonTool.vue'), + meta: { title: 'JSON 工具' } + }, + { + path: 'tools/timestamp', + name: 'tool-timestamp', + component: () => import('../views/tools/TimestampTool.vue'), + meta: { title: '时间戳转换' } + }, + { + path: 'tools/markdown', + name: 'tool-markdown', + component: () => import('../views/tools/MarkdownTool.vue'), + meta: { title: 'Markdown 编辑器' } + }, + { + path: 'tools/postman', + name: 'tool-postman', + component: () => import('../views/tools/PostmanTool.vue'), + meta: { title: '接口调试' } + }, + { + path: 'tools/color', + name: 'tool-color', + component: () => import('../views/tools/ColorTool.vue'), + meta: { title: '取色板' } + }, + { + path: 'tools/color-image', + name: 'tool-color-image', + component: () => import('../views/tools/ColorImage.vue'), + meta: { title: '图片拾色' } + }, + { + path: 'tools/gradient', + name: 'tool-gradient', + component: () => import('../views/tools/GradientTool.vue'), + meta: { title: '渐变图鉴' } + }, + { + path: 'vault', + name: 'vault', + component: () => import('../views/Vault.vue'), + meta: { title: '密码管理' } + }, + { + path: 'bookmarks', + name: 'bookmarks', + component: () => import('../views/Bookmarks.vue'), + meta: { title: '书签导航' } + }, + { + path: 'disk', + name: 'disk', + component: () => import('../views/Disk.vue'), + meta: { title: '我的文件' } + }, + { + path: 'disk/shares', + name: 'disk-shares', + component: () => import('../views/DiskShares.vue'), + meta: { title: '分享链接' } + }, + { + path: 'proxies', + name: 'proxies', + component: () => import('../views/Proxies.vue'), + meta: { title: '代理列表' } + }, + { + path: 'proxies/extract', + name: 'proxy-extract', + component: () => import('../views/ProxyExtract.vue'), + meta: { title: '提取链接', requiresAdmin: true } + }, + { + path: 'servers', + name: 'servers', + component: () => import('../views/Servers.vue'), + meta: { title: '服务器管理', requiresAdmin: true } + }, + { + path: 'servers/:id(\\d+)', + name: 'server-detail', + component: () => import('../views/ServerDetail.vue'), + meta: { title: '服务器详情', requiresAdmin: true } + }, + { + path: 'dbm', + name: 'dbm', + component: () => import('../views/DbManager.vue'), + meta: { title: '数据库', requiresAdmin: true } + }, + { + path: 'settings', + name: 'settings', + component: () => import('../views/Settings.vue'), + meta: { title: '系统设置', requiresAdmin: true } + }, + { + path: 'pay', + name: 'pay-orders', + component: () => import('../views/PayOrders.vue'), + meta: { title: '订单管理', requiresAdmin: true } + }, + { + path: 'pay/config', + name: 'pay-config', + component: () => import('../views/PayConfig.vue'), + meta: { title: '支付配置', requiresAdmin: true } + }, + { + path: 'licenses', + name: 'licenses', + component: () => import('../views/Licenses.vue'), + meta: { title: '卡密管理', requiresAdmin: true } + }, + { + path: 'memories', + name: 'memories', + component: () => import('../views/Memories.vue'), + meta: { title: '记忆库', requiresAdmin: true } + }, + { + path: 'licenses/apps', + name: 'license-apps', + component: () => import('../views/LicenseApps.vue'), + meta: { title: '项目管理', requiresAdmin: true } + }, + ] + }, + { path: '/:pathMatch(.*)*', redirect: '/' } + ] +}) + +router.beforeEach(async to => { + const auth = useAuthStore() + if (!auth.ready) await auth.fetchMe() + if (to.meta.requiresAuth && !auth.user) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (to.meta.requiresAdmin && !auth.isAdmin) { + return { path: '/' } + } + if (to.name === 'login' && auth.user) { + return { path: '/' } + } +}) + +router.afterEach(to => { + document.title = to.meta.title ? `${to.meta.title} · 池易工作箱` : '池易工作箱' +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..0b09259 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,44 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { client, readToken, writeToken, clearToken } from '../lib/api' +import type userView from '../api/userView' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const ready = ref(false) // 首次 Me 探测是否完成(路由守卫用) + + const isAdmin = computed(() => user.value?.role === 'admin') + + async function fetchMe() { + if (!readToken()) { + user.value = null + ready.value = true + return + } + try { + const r = await client.authSvc.me() + user.value = r.status === 0 && r.data?.user ? r.data.user : null + } catch { + user.value = null + } + ready.value = true + } + + async function login(name: string, password: string): Promise { + const r = await client.authSvc.login({ name, password }) + if (r.status !== 0 || !r.data?.token || !r.data.user) { + return r.msg || '登录失败' + } + writeToken(r.data.token) + user.value = r.data.user + return null + } + + async function logout() { + try { await client.authSvc.logout() } catch { /* 忽略 */ } + clearToken() + user.value = null + } + + return { user, ready, isAdmin, fetchMe, login, logout } +}) diff --git a/frontend/src/stores/bookmarks.ts b/frontend/src/stores/bookmarks.ts new file mode 100644 index 0000000..d0bc322 --- /dev/null +++ b/frontend/src/stores/bookmarks.ts @@ -0,0 +1,20 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { client } from '../lib/api' +import type bookmarkView from '../api/bookmarkView' + +// 个人书签:驱动侧边栏「我的书签」组与书签页 +export const useBookmarksStore = defineStore('bookmarks', () => { + const items = ref([]) + const loaded = ref(false) + + async function refresh() { + const r = await client.bookmarkSvc.list() + if (r.status === 0 && r.data) { + items.value = r.data + loaded.value = true + } + } + + return { items, loaded, refresh } +}) diff --git a/frontend/src/stores/servers.ts b/frontend/src/stores/servers.ts new file mode 100644 index 0000000..059daf0 --- /dev/null +++ b/frontend/src/stores/servers.ts @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { client } from '../lib/api' +import type serverView from '../api/serverView' + +export const useServersStore = defineStore('servers', () => { + const items = ref([]) + const loaded = ref(false) + + async function refresh() { + const r = await client.serverSvc.list() + if (r.status === 0 && r.data) { + items.value = r.data + loaded.value = true + } + } + + function find(id: number) { + return items.value.find(s => s.id === id) + } + + return { items, loaded, refresh, find } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..4b35dcb --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,639 @@ +:root { + --cb-primary: #ec5b13; + --cb-ink: #1a1a1a; + --cb-ink-2: #57534d; + --cb-ink-3: #8a857e; + --cb-bg: #f8f6f6; + --cb-panel: #ffffff; + --cb-line: #1a1a1a; + --cb-yellow: #fef08a; + --cb-blue: #bae6fd; + --cb-mint: #a7f3d0; + --cb-pink: #ffc1cc; + --cb-purple: #d9b3ff; + --cb-radius: 14px; + --cb-border: 3px solid var(--cb-ink); + --cb-shadow: 6px 6px 0 var(--cb-ink); + --cb-shadow-sm: 4px 4px 0 var(--cb-ink); + --cb-shadow-lg: 10px 10px 0 var(--cb-ink); +} + +* { box-sizing: border-box; } + +html, body, #app { + height: 100%; + margin: 0; + padding: 0; +} + +/* naive-ui provider 会渲染真实包装元素,必须把高度接力传下去 */ +.n-config-provider, +.n-message-provider, +.n-dialog-provider { + height: 100%; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background: var(--cb-bg); + color: var(--cb-ink); + -webkit-font-smoothing: antialiased; + font-feature-settings: 'tnum'; +} + +::-webkit-scrollbar { width: 12px; height: 12px; } +::-webkit-scrollbar-thumb { + background: var(--cb-ink); + border-radius: 8px; + border: 3px solid var(--cb-bg); +} +::-webkit-scrollbar-track { background: transparent; } + +/* ================= naive-ui 粗野化 ================= */ +/* 卡片:3px 黑边 + 硬投影(弹窗同样生效,风格统一) */ +.n-card { + border: var(--cb-border) !important; + border-radius: 16px !important; + box-shadow: var(--cb-shadow-sm) !important; +} +.n-card--hoverable:hover { transform: translate(-2px, -2px); box-shadow: var(--cb-shadow) !important; } + +/* 按钮:黑边 + 小硬影 + 按压回弹 */ +.n-button { + border: 2.5px solid var(--cb-ink) !important; + font-weight: 700 !important; + box-shadow: 3px 3px 0 var(--cb-ink); + transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease !important; +} +.n-button:not(.n-button--disabled):hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--cb-ink); } +.n-button:not(.n-button--disabled):active { transform: translate(2px, 2px) !important; box-shadow: 0 0 0 var(--cb-ink) !important; } +.n-button--quaternary-type, .n-button--tertiary-type { box-shadow: none !important; } + +/* 输入/选择:黑边 */ +.n-input, +.n-base-selection, +.n-input-number, +.n-select .n-base-selection { + border: 2.5px solid var(--cb-ink) !important; + border-radius: 10px !important; + box-shadow: 3px 3px 0 rgba(26, 26, 26, 0.15); +} +.n-input:focus-within, +.n-base-selection:focus-within, +.n-input-number:focus-within { + box-shadow: 4px 4px 0 var(--cb-primary) !important; +} + +/* 表格:黑边框架 + 表头色块 */ +.n-data-table { + border: 2.5px solid var(--cb-ink); + border-radius: 12px !important; + overflow: hidden; +} +.n-data-table .n-data-table-th { + background: var(--cb-yellow) !important; + color: var(--cb-ink) !important; + font-weight: 800 !important; + border-bottom: 2.5px solid var(--cb-ink) !important; +} +.n-data-table .n-data-table-td { border-bottom: 1.5px solid rgba(26, 26, 26, 0.15) !important; } + +/* 标签/弹层 */ +.n-tag { border: 2px solid var(--cb-ink) !important; font-weight: 700; } +.n-dropdown-menu, .n-base-select-menu, .n-modal .n-card__content { border-radius: 12px; } +.n-dropdown-menu { + border: 2.5px solid var(--cb-ink) !important; + box-shadow: var(--cb-shadow-sm) !important; +} +.n-pagination .n-pagination-item { + border: 2px solid var(--cb-ink) !important; + font-weight: 700; +} + +/* ================= 登录页 ================= */ +.login-page { + min-height: 100vh; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(circle at 12% 20%, rgba(236, 91, 19, 0.08), transparent 35%), + radial-gradient(circle at 88% 80%, rgba(186, 230, 253, 0.5), transparent 40%), + var(--cb-bg); + position: relative; + overflow: hidden; + padding: 24px; +} + +/* 漂浮的 Memphis 几何装饰 */ +.deco { position: absolute; pointer-events: none; z-index: 0; } +.deco-circle { + width: 90px; height: 90px; border-radius: 50%; + background: var(--cb-blue); + border: var(--cb-border); + top: 8%; left: 7%; + animation: memphis-float 7s ease-in-out infinite; +} +.deco-triangle { + width: 0; height: 0; + border-left: 46px solid transparent; + border-right: 46px solid transparent; + border-bottom: 78px solid var(--cb-yellow); + filter: drop-shadow(4px 4px 0 var(--cb-ink)); + bottom: 12%; right: 9%; + transform: rotate(12deg); + animation: memphis-float 9s ease-in-out -3s infinite; +} +.deco-cross { + width: 64px; height: 64px; + top: 12%; right: 14%; + animation: memphis-spin 14s linear infinite; +} +.deco-cross::before, .deco-cross::after { + content: ''; + position: absolute; + background: var(--cb-ink); +} +.deco-cross::before { left: 27px; top: 0; width: 10px; height: 64px; } +.deco-cross::after { top: 27px; left: 0; height: 10px; width: 64px; } +.deco-zigzag { + width: 130px; height: 30px; + bottom: 16%; left: 12%; + background: + linear-gradient(135deg, var(--cb-ink) 25%, transparent 25%) -15px 0/30px 30px, + linear-gradient(225deg, var(--cb-ink) 25%, transparent 25%) -15px 0/30px 30px; + animation: memphis-float 8s ease-in-out -5s infinite; +} +.deco-semicircle { + width: 80px; height: 40px; + border-radius: 80px 80px 0 0; + background: var(--cb-pink); + border: var(--cb-border); + border-bottom: none; + top: 55%; left: 4%; + transform: rotate(-18deg); + animation: memphis-float 10s ease-in-out -7s infinite; +} +@keyframes memphis-float { + 0%, 100% { transform: translateY(0) rotate(var(--r, 0deg)); } + 50% { transform: translateY(-16px) rotate(var(--r, 0deg)); } +} +@keyframes memphis-spin { to { transform: rotate(360deg); } } + +.login-card { + position: relative; + z-index: 1; + display: flex; + width: 860px; + max-width: 100%; + min-height: 520px; + background: #fff; + border: var(--cb-border); + border-radius: 24px; + box-shadow: 12px 12px 0 var(--cb-ink); + overflow: hidden; +} +@media (max-width: 820px) { + .login-brand { display: none; } +} + +.login-brand { + flex: 1; + position: relative; + padding: 48px 44px; + background: var(--cb-primary); + border-right: var(--cb-border); + color: #fff; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; +} +/* 橙色底上的白色圆点纹理 */ +.login-brand::before { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(255, 255, 255, 0.22) 2.5px, transparent 2.5px); + background-size: 26px 26px; +} + +.login-brand-top { position: relative; display: flex; align-items: center; gap: 12px; } +.login-brand-top .brand-name { font-size: 17px; font-weight: 800; letter-spacing: 1px; } +.login-brand-top .brand-tag { + font-size: 10px; + font-weight: 800; + letter-spacing: 2px; + color: var(--cb-ink); + background: var(--cb-yellow); + border: 2px solid var(--cb-ink); + padding: 3px 9px; + border-radius: 8px; + transform: rotate(-3deg); +} + +.brand-badge { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: var(--cb-ink); + background: var(--cb-yellow); + border: 2.5px solid var(--cb-ink); + box-shadow: 4px 4px 0 var(--cb-ink); +} + +.login-brand-mid { position: relative; } +.login-brand-mid h2 { + font-size: 40px; + line-height: 1.25; + margin: 0 0 18px; + font-weight: 900; + letter-spacing: 1px; + text-shadow: 3px 3px 0 rgba(26, 26, 26, 0.35); +} +.login-brand-mid h2 em { + font-style: normal; + color: var(--cb-yellow); + -webkit-text-stroke: 1.5px var(--cb-ink); + text-shadow: 3px 3px 0 var(--cb-ink); +} +.login-brand-mid p { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-weight: 500; + line-height: 1.9; + margin: 0; +} + +.login-brand-bottom { + position: relative; + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.brand-chip { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: var(--cb-ink); + background: #fff; + border: 2px solid var(--cb-ink); + border-radius: 999px; + padding: 5px 12px; + box-shadow: 3px 3px 0 var(--cb-ink); +} + +.login-form-side { + width: 400px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + padding: 48px 40px; +} +@media (max-width: 820px) { + .login-form-side { width: 100%; } +} + +.login-form { width: 100%; } +.login-form h1 { + font-size: 30px; + font-weight: 900; + margin: 0 0 8px; + color: var(--cb-ink); +} +.login-form .sub { color: var(--cb-ink-2); font-size: 13px; margin: 0 0 32px; font-weight: 500; } +.login-form .n-input { margin-bottom: 18px; } +.login-err { + min-height: 26px; + font-size: 13px; + font-weight: 700; + color: #e11d48; + display: flex; + align-items: center; +} +.login-btn { + height: 48px !important; + font-size: 16px !important; + letter-spacing: 6px; + border-radius: 12px !important; +} +.login-foot { margin-top: 24px; text-align: center; font-size: 12px; color: var(--cb-ink-3); } + +/* ================= 后台布局 ================= */ +.cb-sider { + background: #fff !important; + border-right: var(--cb-border); +} +.cb-sider .n-layout-sider-scroll-container { + display: flex; + flex-direction: column; +} + +.cb-logo { + height: 64px; + display: flex; + align-items: center; + gap: 11px; + padding: 0 20px; + overflow: hidden; + white-space: nowrap; + border-bottom: var(--cb-border); +} +.cb-logo-text { + color: var(--cb-ink); + font-weight: 900; + font-size: 16px; + letter-spacing: 1px; + line-height: 1.25; +} +.cb-logo-text small { + display: block; + font-size: 9px; + font-weight: 700; + color: var(--cb-ink-3); + letter-spacing: 3px; +} + +.cb-menu-wrap { flex: 1; padding: 14px 12px; overflow-y: auto; } +.cb-menu-wrap .n-menu .n-menu-item-content { border-radius: 10px !important; } +.cb-menu-wrap .n-menu .n-menu-item-group-title { + font-size: 10px; + font-weight: 800; + letter-spacing: 2px; + padding: 16px 14px 6px !important; +} + +/* 菜单激活项:黄底 + 黑边 + 小硬影(覆盖主题色) */ +.cb-menu-wrap .n-menu .n-menu-item-content.n-menu-item-content--selected { + background: var(--cb-yellow) !important; + border: 2.5px solid var(--cb-ink); + box-shadow: 3px 3px 0 var(--cb-ink); +} +.cb-menu-wrap .n-menu .n-menu-item-content { + border: 2.5px solid transparent; + margin-bottom: 4px; +} +.cb-menu-wrap .n-menu .n-menu-item-content:hover { + background: #fdf6d8 !important; +} +.cb-menu-wrap .n-menu .n-menu-item-content::before { display: none !important; } + +.cb-sider-foot { + padding: 12px 14px; + border-top: var(--cb-border); + display: flex; + align-items: center; + gap: 6px; +} +.sider-user { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.sider-user-name { + font-size: 13px; + font-weight: 800; + color: var(--cb-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.logout-btn { + border: 2px solid var(--cb-ink) !important; + border-radius: 8px !important; + font-weight: 800; + box-shadow: 2px 2px 0 var(--cb-ink); +} +.logout-btn:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--cb-ink); } + +.cb-header { + height: 64px; + display: flex; + align-items: center; + padding: 0 24px; + background: rgba(248, 246, 246, 0.9); + backdrop-filter: blur(10px); + border-bottom: var(--cb-border); + position: sticky; + top: 0; + z-index: 10; +} +.page-title { font-size: 17px; font-weight: 900; color: var(--cb-ink); } +.page-sub { font-size: 12px; color: var(--cb-ink-3); margin-top: 1px; font-weight: 500; } + +.cb-user-chip { + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + padding: 5px 12px 5px 5px; + border-radius: 999px; + border: 2.5px solid var(--cb-ink); + background: #fff; + box-shadow: 3px 3px 0 var(--cb-ink); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.cb-user-chip:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--cb-ink); } +.cb-user-chip .name { font-size: 14px; color: var(--cb-ink); font-weight: 700; } + +/* ================= 页签栏 ================= */ +.cb-tabs { + display: flex; gap: 8px; padding: 10px 18px 0; + background: var(--cb-bg); border-bottom: var(--cb-border); + overflow-x: auto; flex: none; +} +.cb-tabs::-webkit-scrollbar { height: 6px; } +.cb-tab { + display: flex; align-items: center; gap: 8px; flex: none; + padding: 5px 10px 5px 14px; + border: 2.5px solid var(--cb-ink); border-bottom: none; + border-radius: 10px 10px 0 0; background: #fff; + font-size: 12.5px; font-weight: 800; color: var(--cb-ink-2); + cursor: pointer; user-select: none; white-space: nowrap; +} +.cb-tab:hover { background: var(--cb-blue); } +.cb-tab.on { background: var(--cb-yellow); color: var(--cb-ink); box-shadow: 3px 3px 0 var(--cb-ink); } +.cb-tab .x { + font-size: 15px; line-height: 1; color: var(--cb-ink-3); + border-radius: 4px; padding: 0 3px; font-weight: 700; +} +.cb-tab .x:hover { background: var(--cb-pink); color: var(--cb-ink); } + +.cb-content { + padding: 26px; + min-height: calc(100vh - 64px); + background: var(--cb-bg); +} + +/* ================= 通用面板 ================= */ +.panel { + background: var(--cb-panel); + border-radius: 16px; + padding: 20px 22px; + border: var(--cb-border); + box-shadow: var(--cb-shadow-sm); +} +.panel-title { + font-size: 15px; + font-weight: 800; + color: var(--cb-ink); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.panel-title .hint { + font-size: 12px; + color: var(--cb-ink-3); + font-weight: 500; + letter-spacing: 0.3px; +} +.panel-title .hint::before { content: '· '; color: var(--cb-ink-3); } + +/* ================= 仪表盘 ================= */ +.dash-hello h2 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 900; + color: var(--cb-ink); +} +.dash-hello p { margin: 0; color: var(--cb-ink-2); font-size: 13px; font-weight: 500; } + +.stat-card { + border-radius: 16px; + padding: 20px 22px; + background: var(--cb-panel); + border: var(--cb-border); + box-shadow: var(--cb-shadow-sm); + display: flex; + align-items: center; + gap: 16px; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.stat-card:hover { + transform: translate(-2px, -2px); + box-shadow: var(--cb-shadow); +} +.stat-icon { + width: 52px; + height: 52px; + flex: none; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + color: var(--cb-ink); + border: 2.5px solid var(--cb-ink); +} +.stat-icon.i1 { background: var(--cb-yellow); } +.stat-icon.i2 { background: var(--cb-mint); } +.stat-icon.i3 { background: var(--cb-pink); } +.stat-icon.i4 { background: var(--cb-blue); } +.stat-label { font-size: 13px; color: var(--cb-ink-2); font-weight: 600; } +.stat-value { + font-size: 30px; + font-weight: 900; + color: var(--cb-ink); + line-height: 1.15; + margin-top: 3px; +} + +/* ================= 工具卡片 ================= */ +.tool-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 18px; +} +.tool-card { + background: var(--cb-panel); + border-radius: 16px; + padding: 22px; + cursor: pointer; + border: var(--cb-border); + box-shadow: var(--cb-shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease; + position: relative; +} +.tool-card:hover { + transform: translate(-3px, -3px) rotate(-0.5deg); + box-shadow: var(--cb-shadow-lg); +} +.tool-card .arrow { + position: absolute; + top: 18px; + right: 18px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 2px solid var(--cb-ink); + background: var(--cb-yellow); + color: var(--cb-ink); + opacity: 0; + transform: translateX(-4px); + transition: all 0.15s ease; +} +.tool-card:hover .arrow { opacity: 1; transform: translateX(0); } +.tool-icon { + width: 50px; + height: 50px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: var(--cb-ink); + border: 2.5px solid var(--cb-ink); + margin-bottom: 16px; +} +.tool-name { font-weight: 800; color: var(--cb-ink); margin-bottom: 6px; font-size: 15px; } +.tool-desc { font-size: 12px; color: var(--cb-ink-2); line-height: 1.6; min-height: 38px; font-weight: 500; } + +/* ---- 2FA 验证码:填充层整段线性 scaleX 收缩,右缘从右往左退去 ---- + duration 由内联 style 注入(=剩余秒数),transform 走 GPU 合成不触发布局 */ +.totp-chip { + position: relative; + display: inline-flex; + align-items: center; + overflow: hidden; + border: 2px solid var(--cb-ink); + border-radius: 9px; + background: #fff; + box-shadow: 3px 3px 0 var(--cb-ink); + user-select: none; +} +.totp-chip .totp-fill { + position: absolute; + inset: 0; + background: var(--cb-mint); + transform-origin: left center; + animation: totp-drain linear forwards; +} +.totp-chip.totp-low .totp-fill { background: var(--cb-pink); } +@keyframes totp-drain { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} +.totp-chip .totp-code { + position: relative; + padding: 3px 10px; + font-size: 13px; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--cb-ink); +} diff --git a/frontend/src/views/Bookmarks.vue b/frontend/src/views/Bookmarks.vue new file mode 100644 index 0000000..8c88edf --- /dev/null +++ b/frontend/src/views/Bookmarks.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/views/Cashier.vue b/frontend/src/views/Cashier.vue new file mode 100644 index 0000000..ca9d61c --- /dev/null +++ b/frontend/src/views/Cashier.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..5cc750d --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/src/views/DbManager.vue b/frontend/src/views/DbManager.vue new file mode 100644 index 0000000..dfd3391 --- /dev/null +++ b/frontend/src/views/DbManager.vue @@ -0,0 +1,608 @@ + + + + + diff --git a/frontend/src/views/Disk.vue b/frontend/src/views/Disk.vue new file mode 100644 index 0000000..72193fb --- /dev/null +++ b/frontend/src/views/Disk.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/frontend/src/views/DiskShares.vue b/frontend/src/views/DiskShares.vue new file mode 100644 index 0000000..471f6f3 --- /dev/null +++ b/frontend/src/views/DiskShares.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/views/LicenseApps.vue b/frontend/src/views/LicenseApps.vue new file mode 100644 index 0000000..1a3fdbb --- /dev/null +++ b/frontend/src/views/LicenseApps.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/views/Licenses.vue b/frontend/src/views/Licenses.vue new file mode 100644 index 0000000..4c6ce28 --- /dev/null +++ b/frontend/src/views/Licenses.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..d5d1c2f --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/views/Memories.vue b/frontend/src/views/Memories.vue new file mode 100644 index 0000000..94eaeb8 --- /dev/null +++ b/frontend/src/views/Memories.vue @@ -0,0 +1,504 @@ + + + + + diff --git a/frontend/src/views/PayConfig.vue b/frontend/src/views/PayConfig.vue new file mode 100644 index 0000000..2ea9fe8 --- /dev/null +++ b/frontend/src/views/PayConfig.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/frontend/src/views/PayOrders.vue b/frontend/src/views/PayOrders.vue new file mode 100644 index 0000000..aaad2ac --- /dev/null +++ b/frontend/src/views/PayOrders.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/Proxies.vue b/frontend/src/views/Proxies.vue new file mode 100644 index 0000000..6c95aae --- /dev/null +++ b/frontend/src/views/Proxies.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/views/ProxyExtract.vue b/frontend/src/views/ProxyExtract.vue new file mode 100644 index 0000000..4aa6e65 --- /dev/null +++ b/frontend/src/views/ProxyExtract.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue new file mode 100644 index 0000000..844c549 --- /dev/null +++ b/frontend/src/views/ServerDetail.vue @@ -0,0 +1,537 @@ + + + + + diff --git a/frontend/src/views/Servers.vue b/frontend/src/views/Servers.vue new file mode 100644 index 0000000..cf3d8be --- /dev/null +++ b/frontend/src/views/Servers.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..faa6935 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/views/Vault.vue b/frontend/src/views/Vault.vue new file mode 100644 index 0000000..b61fd79 --- /dev/null +++ b/frontend/src/views/Vault.vue @@ -0,0 +1,311 @@ + + + diff --git a/frontend/src/views/tools/ColorImage.vue b/frontend/src/views/tools/ColorImage.vue new file mode 100644 index 0000000..23d4eda --- /dev/null +++ b/frontend/src/views/tools/ColorImage.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/frontend/src/views/tools/ColorTool.vue b/frontend/src/views/tools/ColorTool.vue new file mode 100644 index 0000000..c79758a --- /dev/null +++ b/frontend/src/views/tools/ColorTool.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/frontend/src/views/tools/GradientTool.vue b/frontend/src/views/tools/GradientTool.vue new file mode 100644 index 0000000..a16cef0 --- /dev/null +++ b/frontend/src/views/tools/GradientTool.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/tools/JsonTool.vue b/frontend/src/views/tools/JsonTool.vue new file mode 100644 index 0000000..57206f6 --- /dev/null +++ b/frontend/src/views/tools/JsonTool.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/views/tools/MarkdownTool.vue b/frontend/src/views/tools/MarkdownTool.vue new file mode 100644 index 0000000..4d6df01 --- /dev/null +++ b/frontend/src/views/tools/MarkdownTool.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/views/tools/PostmanTool.vue b/frontend/src/views/tools/PostmanTool.vue new file mode 100644 index 0000000..c92acb7 --- /dev/null +++ b/frontend/src/views/tools/PostmanTool.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/views/tools/TimestampTool.vue b/frontend/src/views/tools/TimestampTool.vue new file mode 100644 index 0000000..9f929c3 --- /dev/null +++ b/frontend/src/views/tools/TimestampTool.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a02f4fe --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8c97df6 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..34a677d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// 开发期 /api 代理到 fun 后端并剥掉前缀:/api/cell → /cell +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8890', + changeOrigin: true, + ws: true, + rewrite: p => p.replace(/^\/api/, '') + } + } + } +})