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>
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
backend/cyibox
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
| 仪表盘 | 代理池 |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **订单管理** | **授权卡密** |
|
||||||
|
|  |  |
|
||||||
|
| **记忆库** | **数据库管理** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 功能一览
|
||||||
|
|
||||||
|
- **仪表盘**:营收/订单/会话 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://<key>` resources + `save_context` prompt;FTS5 trigram 全文搜索(中文子串可命中);`GET /m/<key>?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/<code>` 免登录)、下载计数
|
||||||
|
- **密码箱**: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` | `<db目录>/vault.key` | AES-GCM 密钥文件(自动生成) |
|
||||||
|
| `CYIBOX_DISK_DIR` | `<db目录>/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://<key>`;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
|
||||||
|
|
||||||
|
## 网盘
|
||||||
|
|
||||||
|
- 文件落盘 `<db目录>/files/`(`CYIBOX_DISK_DIR` 可改),`files` 表记录原始名/大小/mime;
|
||||||
|
- 上传 `POST /disk/upload`(multipart,登录用户)、属主下载 `GET /disk/download?id=&token=`;
|
||||||
|
- 分享:`shares` 表存 code + 到期时间(0=永久),公开下载 `GET /d/<code>` 免登录,
|
||||||
|
过期返回 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。
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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 // 网盘文件落盘目录,默认 <db目录>/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 优先;否则 <db目录>/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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
@@ -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 形态 "<project>/<topic>",跨会话/跨 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);
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/<key> 链接
|
||||||
|
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 "<project>/<topic>" (e.g. "vivid/deploy", "vivid/stack"), with project and tags set.
|
||||||
|
4. Reply with the list of saved keys and their URLs.`)),
|
||||||
|
}), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// memory://<key> 资源:客户端可直接把记忆钉进上下文,省一轮 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
|
||||||
|
}
|
||||||
@@ -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/<token> 为准
|
||||||
|
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": "<p><br></p>", "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 收银页 → 页面内嵌 <input name="qrCode" value="https://qr.alipay.com/...">。
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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": "英属维尔京群岛",
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;display:flex;height:100vh;align-items:center;justify-content:center;background:#f8f6f6"><div style="text-align:center"><h1 style="font-size:48px;margin:0">链接已过期</h1><p style="color:#8a857e">这个分享已经过了有效期,找文件的主人再要一个新链接吧</p></div></body>`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = st.IncShareDownloads(code)
|
||||||
|
serveDiskFile(ctx, cfg.DiskDir, v.Stored, v.FileName, v.Mime, v.Size)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// memory.go 共享记忆库管理(仅管理员):项目分组、搜索、读写删除、MCP 接入信息。
|
||||||
|
// 对外协议端点见 memory_routes.go(POST /mcp + GET /m/<key>)。
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// mem.go 共享记忆库门面:key 形态 "<project>/<topic>",写覆盖即 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/<key> 阅读链接:?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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f8f6f6" />
|
||||||
|
<style>html,body{background:#f8f6f6}</style>
|
||||||
|
<title>池易工作箱</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from 'naive-ui'
|
||||||
|
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||||
|
|
||||||
|
// Memphis / 新粗野主义:亮橙主色 + 大圆角,黑边与硬投影在 style.css 统一覆盖
|
||||||
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
primaryColor: '#ec5b13',
|
||||||
|
primaryColorHover: '#f5712b',
|
||||||
|
primaryColorPressed: '#d14e0e',
|
||||||
|
primaryColorSuppl: '#ec5b13',
|
||||||
|
successColor: '#00a544',
|
||||||
|
warningColor: '#f59e0b',
|
||||||
|
errorColor: '#e40014',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemColorHover: 'transparent',
|
||||||
|
itemColorActive: 'transparent',
|
||||||
|
itemTextColor: '#57534d',
|
||||||
|
itemTextColorHover: '#1a1a1a',
|
||||||
|
itemTextColorActive: '#1a1a1a',
|
||||||
|
itemIconColor: '#8a857e',
|
||||||
|
itemIconColorHover: '#1a1a1a',
|
||||||
|
itemIconColorActive: '#1a1a1a',
|
||||||
|
groupTextColor: '#8a857e',
|
||||||
|
itemHeight: '42px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
itemTextColorActiveHover: '#1a1a1a'
|
||||||
|
},
|
||||||
|
Card: { borderRadius: '16px' },
|
||||||
|
Tag: { fontWeight: '700' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme-overrides="themeOverrides">
|
||||||
|
<n-message-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
|
<router-view />
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-message-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
@@ -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<result<okView>> {
|
||||||
|
return await this.client.request<okView>("authSvc", "changePassword", dto, options)
|
||||||
|
}
|
||||||
|
async login(dto:loginDto, options?: RequestOptions): Promise<result<sessionView>> {
|
||||||
|
return await this.client.request<sessionView>("authSvc", "login", dto, options)
|
||||||
|
}
|
||||||
|
async logout(options?: RequestOptions): Promise<result<okView>> {
|
||||||
|
return await this.client.request<okView>("authSvc", "logout", undefined, options)
|
||||||
|
}
|
||||||
|
async me(options?: RequestOptions): Promise<result<sessionView>> {
|
||||||
|
return await this.client.request<sessionView>("authSvc", "me", undefined, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface bookmarkIdDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||
@@ -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<result<okView>> {
|
||||||
|
return await this.client.request<okView>("bookmarkSvc", "delete", dto, options)
|
||||||
|
}
|
||||||
|
async list(options?: RequestOptions): Promise<result<bookmarkView[]>> {
|
||||||
|
return await this.client.request<bookmarkView[]>("bookmarkSvc", "list", undefined, options)
|
||||||
|
}
|
||||||
|
async save(dto:saveBookmarkDto, options?: RequestOptions): Promise<result<bookmarkView>> {
|
||||||
|
return await this.client.request<bookmarkView>("bookmarkSvc", "save", dto, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default interface bookmarkView {
|
||||||
|
id:number
|
||||||
|
title:string
|
||||||
|
url:string
|
||||||
|
icon:string
|
||||||
|
sort:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default interface changePasswordDto {
|
||||||
|
oldPassword:string
|
||||||
|
newPassword:string
|
||||||
|
}
|
||||||
@@ -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<T> = {
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
state?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* 原生断线重连:流因传输失败(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<string, string>,
|
||||||
|
dto?: any
|
||||||
|
) => Promise<void> | void;
|
||||||
|
|
||||||
|
export type ResponseContext = {
|
||||||
|
readonly requestState: Readonly<Record<string, string>>;
|
||||||
|
readonly response?: Response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResponseInterceptor = (
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
result: result<any>
|
||||||
|
) => Promise<result<any> | void> | result<any> | void;
|
||||||
|
|
||||||
|
export type ContextResponseInterceptor = (
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
result: result<any>,
|
||||||
|
context: ResponseContext
|
||||||
|
) => Promise<result<any> | void> | result<any> | 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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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*(?:<!doctype\s+html|<html\b)/i.test(body)) {
|
||||||
|
return failure(1, `Unexpected HTML response: ${excerpt(body)}`);
|
||||||
|
}
|
||||||
|
return failure(1, `Invalid JSON response: ${excerpt(body)}`);
|
||||||
|
}
|
||||||
|
if (!isResult(value)) {
|
||||||
|
return response.ok
|
||||||
|
? failure(1, "Invalid fun response")
|
||||||
|
: externalFailure(response, "invalid fun response");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Client {
|
||||||
|
private url: string;
|
||||||
|
private state: Record<string, string> = {};
|
||||||
|
private requestInterceptors: RequestInterceptor[] = [];
|
||||||
|
private responseInterceptors: ContextResponseInterceptor[] = [];
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(state: Record<string, string>) {
|
||||||
|
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<any>,
|
||||||
|
requestState: Readonly<Record<string, string>>,
|
||||||
|
response?: Response
|
||||||
|
): Promise<result<any>> {
|
||||||
|
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<string, string>,
|
||||||
|
dto: any
|
||||||
|
): Promise<void> {
|
||||||
|
for (const interceptor of this.requestInterceptors) {
|
||||||
|
await interceptor(serviceName, methodName, state, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
|
||||||
|
return Object.freeze({ ...state });
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
dto?: any,
|
||||||
|
options?: RequestOptions
|
||||||
|
): Promise<result<T>> {
|
||||||
|
let state: Record<string, string>;
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.interceptRequest(serviceName, methodName, state, dto);
|
||||||
|
} catch (error) {
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
return await this.interceptResponse(
|
||||||
|
serviceName,
|
||||||
|
methodName,
|
||||||
|
failure(1, `Request interceptor failed: ${messageOf(error)}`),
|
||||||
|
requestState
|
||||||
|
) as result<T>;
|
||||||
|
}
|
||||||
|
let requestState: Readonly<Record<string, string>>;
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output: result<any>;
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stream<T>(
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
dto: any | undefined | (() => any | undefined),
|
||||||
|
onMessage: (data: T) => unknown,
|
||||||
|
options?: StreamOptions
|
||||||
|
): Promise<result<void>> {
|
||||||
|
// 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<T>(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<void>((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<T>(
|
||||||
|
serviceName: string,
|
||||||
|
methodName: string,
|
||||||
|
dto: any | undefined,
|
||||||
|
onMessage: (data: T) => unknown,
|
||||||
|
options?: StreamOptions
|
||||||
|
): Promise<result<void>> {
|
||||||
|
let state: Record<string, string>;
|
||||||
|
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<Record<string, string>>;
|
||||||
|
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<Record<string, string>>;
|
||||||
|
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<Uint8Array>;
|
||||||
|
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<any> | 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<Uint8Array>;
|
||||||
|
try {
|
||||||
|
if (options?.idleTimeoutMs) {
|
||||||
|
const idlePromise = new Promise<never>((_, 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default interface createShareDto {
|
||||||
|
fileId:number
|
||||||
|
expireMinutes:number
|
||||||
|
}
|
||||||
@@ -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<result<statsView>> {
|
||||||
|
return await this.client.request<statsView>("dashboardSvc", "stats", undefined, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default interface dbColumnView {
|
||||||
|
table:string
|
||||||
|
name:string
|
||||||
|
type:string
|
||||||
|
pk:boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface dbDatabasesView {
|
||||||
|
names:string[]
|
||||||
|
}
|
||||||
@@ -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<result<dbDatabasesView>> {
|
||||||
|
return await this.client.request<dbDatabasesView>("dbMgrSvc", "databases", dto, options)
|
||||||
|
}
|
||||||
|
async delete(dto:dbconnIdDto, options?: RequestOptions): Promise<result<okView>> {
|
||||||
|
return await this.client.request<okView>("dbMgrSvc", "delete", dto, options)
|
||||||
|
}
|
||||||
|
async list(options?: RequestOptions): Promise<result<dbconnView[]>> {
|
||||||
|
return await this.client.request<dbconnView[]>("dbMgrSvc", "list", undefined, options)
|
||||||
|
}
|
||||||
|
async query(dto:dbQueryDto, options?: RequestOptions): Promise<result<dbQueryView>> {
|
||||||
|
return await this.client.request<dbQueryView>("dbMgrSvc", "query", dto, options)
|
||||||
|
}
|
||||||
|
async save(dto:saveDbconnDto, options?: RequestOptions): Promise<result<dbconnView>> {
|
||||||
|
return await this.client.request<dbconnView>("dbMgrSvc", "save", dto, options)
|
||||||
|
}
|
||||||
|
async tables(dto:dbScopeDto, options?: RequestOptions): Promise<result<dbTablesView>> {
|
||||||
|
return await this.client.request<dbTablesView>("dbMgrSvc", "tables", dto, options)
|
||||||
|
}
|
||||||
|
async test(dto:dbconnIdDto, options?: RequestOptions): Promise<result<okView>> {
|
||||||
|
return await this.client.request<okView>("dbMgrSvc", "test", dto, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default interface dbQueryDto {
|
||||||
|
id:number
|
||||||
|
database?:string | null
|
||||||
|
sql:string
|
||||||
|
maxRows?:number | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default interface dbQueryView {
|
||||||
|
isSelect:boolean
|
||||||
|
columns:string[]
|
||||||
|
rowsJson:string
|
||||||
|
rowCount:number
|
||||||
|
affectedRows:number
|
||||||
|
truncated:boolean
|
||||||
|
elapsedMs:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default interface dbScopeDto {
|
||||||
|
id:number
|
||||||
|
database?:string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default interface dbTableView {
|
||||||
|
name:string
|
||||||
|
type:string
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type dbColumnView from "./dbColumnView";
|
||||||
|
import type dbTableView from "./dbTableView";
|
||||||
|
export default interface dbTablesView {
|
||||||
|
tables:dbTableView[]
|
||||||
|
columns:dbColumnView[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface dbconnIdDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface deleteToolDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface deleteUserDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default interface diskMountView {
|
||||||
|
mount:string
|
||||||
|
fs:string
|
||||||
|
total:number
|
||||||
|
used:number
|
||||||
|
avail:number
|
||||||
|
percent:number
|
||||||
|
}
|
||||||
@@ -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<result<shareView>> {
|
||||||
|
return await this.client.request<shareView>("diskSvc", "createShare", dto, options)
|
||||||
|
}
|
||||||
|
async deleteFile(dto:idDto, options?: RequestOptions): Promise<result<okView>> {
|
||||||
|
return await this.client.request<okView>("diskSvc", "deleteFile", dto, options)
|
||||||
|
}
|
||||||
|
async deleteShare(dto:idDto, options?: RequestOptions): Promise<result<okView>> {
|
||||||
|
return await this.client.request<okView>("diskSvc", "deleteShare", dto, options)
|
||||||
|
}
|
||||||
|
async list(options?: RequestOptions): Promise<result<fileView[]>> {
|
||||||
|
return await this.client.request<fileView[]>("diskSvc", "list", undefined, options)
|
||||||
|
}
|
||||||
|
async listShares(options?: RequestOptions): Promise<result<shareView[]>> {
|
||||||
|
return await this.client.request<shareView[]>("diskSvc", "listShares", undefined, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default interface fileView {
|
||||||
|
id:number
|
||||||
|
name:string
|
||||||
|
size:number
|
||||||
|
mime:string
|
||||||
|
createdAt:number
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface idDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default interface licenseAppSaveDto {
|
||||||
|
id:number
|
||||||
|
name:string
|
||||||
|
enabled?:number | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default interface licenseAppView {
|
||||||
|
id:number
|
||||||
|
appid:string
|
||||||
|
name:string
|
||||||
|
enabled:number
|
||||||
|
cardCount:number
|
||||||
|
createdAt:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type licenseAppView from "./licenseAppView";
|
||||||
|
export default interface licenseAppsView {
|
||||||
|
items:licenseAppView[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default interface licenseCardView {
|
||||||
|
id:number
|
||||||
|
card:string
|
||||||
|
hours:number
|
||||||
|
domain:string
|
||||||
|
status:number
|
||||||
|
activatedAt:number
|
||||||
|
expiresAt:number
|
||||||
|
createdAt:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default interface licenseGenDto {
|
||||||
|
appId:number
|
||||||
|
count:number
|
||||||
|
hours:number
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface licenseGenView {
|
||||||
|
cards:string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default interface licenseIdDto {
|
||||||
|
id:number
|
||||||
|
}
|
||||||