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>
This commit is contained in:
@@ -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
|
||||
+263
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
Reference in New Issue
Block a user