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:
2026-10-01 01:58:47 +08:00
co-authored by Devin
commit 834ecacdb9
326 changed files with 27813 additions and 0 deletions
+90
View File
@@ -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
}
+31
View File
@@ -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,
}
}
+8
View File
@@ -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
+220
View File
@@ -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
}
+136
View File
@@ -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)
+427
View File
@@ -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
+228
View File
@@ -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);
+400
View File
@@ -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")
+755
View File
@@ -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
}
+276
View File
@@ -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
}
+612
View File
@@ -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)
}
+432
View File
@@ -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": "英属维尔京群岛",
}
+123
View File
@@ -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,
}
}
+152
View File
@@ -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
}
+91
View File
@@ -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}
}
+114
View File
@@ -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
}
+242
View File
@@ -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,
}
}
+394
View File
@@ -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
}
+163
View File
@@ -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
}
+167
View File
@@ -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
}
}
+203
View File
@@ -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)
}
+122
View File
@@ -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()
}
+124
View File
@@ -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
}
}
+867
View File
@@ -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)
}
+189
View File
@@ -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) }
+416
View File
@@ -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
}
+105
View File
@@ -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
}
}
+569
View File
@@ -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
}
+39
View File
@@ -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
}
+90
View File
@@ -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
}
+147
View File
@@ -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
}
+188
View File
@@ -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
}
+105
View File
@@ -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
}
+295
View File
@@ -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
}
+73
View File
@@ -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
}
+91
View File
@@ -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
}
+148
View File
@@ -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)
}
+272
View File
@@ -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()
}
+362
View File
@@ -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
}
+95
View File
@@ -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)
}
+20
View File
@@ -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})
}
+371
View File
@@ -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) }
+93
View File
@@ -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
}
+142
View File
@@ -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
}