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
+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
}