cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway (Epay-compatible + CDK stock), license/card system (per-app appid, domain binding), AI memory bank with built-in MCP server (/mcp, FTS5 search, signed read links), server SSH monitor + terminal + SFTP, proxy pool with protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL autocomplete + import/export), disk share, vault (AES-GCM + TOTP), bookmarks, dev tools. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
type Bookmark struct {
|
||||
Id int64
|
||||
UserID int64
|
||||
Title string
|
||||
Url string
|
||||
Icon string
|
||||
Sort int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func bookmarkFromRow(b db.Bookmark) Bookmark {
|
||||
return Bookmark{
|
||||
Id: b.ID, UserID: b.UserID, Title: b.Title, Url: b.Url,
|
||||
Icon: b.Icon, Sort: b.Sort, CreatedAt: b.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListBookmarks(userID int64) ([]Bookmark, error) {
|
||||
rows, err := s.q.ListBookmarks(storeCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Bookmark, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, bookmarkFromRow(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveBookmark(b *Bookmark) error {
|
||||
if b.Id > 0 {
|
||||
n, err := s.q.UpdateBookmark(storeCtx, db.UpdateBookmarkParams{
|
||||
Title: b.Title, Url: b.Url, Icon: b.Icon, Sort: b.Sort,
|
||||
ID: b.Id, UserID: b.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
id, err := s.q.InsertBookmark(storeCtx, db.InsertBookmarkParams{
|
||||
UserID: b.UserID, Title: b.Title, Url: b.Url, Icon: b.Icon,
|
||||
Sort: b.Sort, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Id, b.CreatedAt = id, now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBookmark(userID, id int64) error {
|
||||
n, err := s.q.DeleteBookmark(storeCtx, db.DeleteBookmarkParams{UserID: userID, ID: id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// Dbconn 数据库连接条目:password_enc 为 AES-GCM 密文;sqlite 时 database 存文件路径
|
||||
type Dbconn struct {
|
||||
Id int64
|
||||
Name string
|
||||
Engine string // mysql | postgres | sqlite
|
||||
Host string
|
||||
Port int64
|
||||
Username string
|
||||
PasswordEnc string
|
||||
Database string
|
||||
Params string // DSN 附加参数,如 mysql 的 charset、pg 的 sslmode
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func dbconnFromRow(e db.Dbconn) Dbconn {
|
||||
return Dbconn{
|
||||
Id: e.ID, Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port,
|
||||
Username: e.Username, PasswordEnc: e.PasswordEnc, Database: e.Database,
|
||||
Params: e.Params, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListDbconns() ([]Dbconn, error) {
|
||||
rows, err := s.q.ListDbconns(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Dbconn, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, dbconnFromRow(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDbconn(id int64) (Dbconn, error) {
|
||||
e, err := s.q.GetDbconn(storeCtx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dbconn{}, ErrNotFound
|
||||
}
|
||||
return dbconnFromRow(e), err
|
||||
}
|
||||
|
||||
func (s *Store) SaveDbconn(e *Dbconn) error {
|
||||
now := time.Now().Unix()
|
||||
if e.Id > 0 {
|
||||
n, err := s.q.UpdateDbconn(storeCtx, db.UpdateDbconnParams{
|
||||
Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port,
|
||||
Username: e.Username, PasswordEnc: e.PasswordEnc,
|
||||
Database: e.Database, Params: e.Params, UpdatedAt: now, ID: e.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
id, err := s.q.InsertDbconn(storeCtx, db.InsertDbconnParams{
|
||||
Name: e.Name, Engine: e.Engine, Host: e.Host, Port: e.Port,
|
||||
Username: e.Username, PasswordEnc: e.PasswordEnc,
|
||||
Database: e.Database, Params: e.Params, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Id, e.CreatedAt, e.UpdatedAt = id, now, now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDbconn(id int64) error {
|
||||
n, err := s.q.DeleteDbconn(storeCtx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// ---- disk 网盘 ----
|
||||
|
||||
type File struct {
|
||||
Id int64
|
||||
UserID int64
|
||||
Name string // 原始文件名
|
||||
Stored string // 落盘文件名(随机)
|
||||
Size int64
|
||||
Mime string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
Id int64
|
||||
Code string
|
||||
FileID int64
|
||||
UserID int64
|
||||
FileName string
|
||||
ExpiresAt int64 // 0 = 永久
|
||||
Downloads int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func fileFromRow(f db.File) File {
|
||||
return File{
|
||||
Id: f.ID, UserID: f.UserID, Name: f.Name, Stored: f.Stored,
|
||||
Size: f.Size, Mime: f.Mime, CreatedAt: f.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func shareFromRow(s db.Share) Share {
|
||||
return Share{
|
||||
Id: s.ID, Code: s.Code, FileID: s.FileID, UserID: s.UserID,
|
||||
FileName: s.FileName, ExpiresAt: s.ExpiresAt,
|
||||
Downloads: s.Downloads, CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) InsertFile(f *File) error {
|
||||
id, err := s.q.InsertFile(storeCtx, db.InsertFileParams{
|
||||
UserID: f.UserID, Name: f.Name, Stored: f.Stored,
|
||||
Size: f.Size, Mime: f.Mime, CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Id = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFiles(userID int64) ([]File, error) {
|
||||
rows, err := s.q.ListFiles(storeCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]File, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
files = append(files, fileFromRow(r))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFile(id int64) (File, error) {
|
||||
f, err := s.q.GetFile(storeCtx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return File{}, ErrNotFound
|
||||
}
|
||||
return fileFromRow(f), err
|
||||
}
|
||||
|
||||
// DeleteFile 仅限属主;返回被删行数(0 = 不存在或非属主)
|
||||
func (s *Store) DeleteFile(id, userID int64) (int64, error) {
|
||||
return s.q.DeleteFile(storeCtx, db.DeleteFileParams{ID: id, UserID: userID})
|
||||
}
|
||||
|
||||
func (s *Store) InsertShare(sh *Share) error {
|
||||
sh.CreatedAt = time.Now().Unix()
|
||||
id, err := s.q.InsertShare(storeCtx, db.InsertShareParams{
|
||||
Code: sh.Code, FileID: sh.FileID, UserID: sh.UserID,
|
||||
FileName: sh.FileName, ExpiresAt: sh.ExpiresAt,
|
||||
CreatedAt: sh.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sh.Id = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListShares(userID int64) ([]Share, error) {
|
||||
rows, err := s.q.ListShares(storeCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shares := make([]Share, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
shares = append(shares, shareFromRow(r))
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// ShareFileView 公开下载需要的联表视图
|
||||
type ShareFileView struct {
|
||||
Share
|
||||
Stored string
|
||||
Size int64
|
||||
Mime string
|
||||
}
|
||||
|
||||
func (s *Store) GetShareFile(code string) (ShareFileView, error) {
|
||||
r, err := s.q.GetShareByCode(storeCtx, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ShareFileView{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ShareFileView{}, err
|
||||
}
|
||||
return ShareFileView{
|
||||
Share: Share{
|
||||
Id: r.ID, Code: r.Code, FileID: r.FileID, UserID: r.UserID,
|
||||
FileName: r.FileName, ExpiresAt: r.ExpiresAt,
|
||||
Downloads: r.Downloads, CreatedAt: r.CreatedAt,
|
||||
},
|
||||
Stored: r.Stored, Size: r.Size, Mime: r.Mime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteShare(id, userID int64) (int64, error) {
|
||||
return s.q.DeleteShare(storeCtx, db.DeleteShareParams{ID: id, UserID: userID})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSharesByFile(fileID int64) error {
|
||||
return s.q.DeleteSharesByFile(storeCtx, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) IncShareDownloads(code string) error {
|
||||
return s.q.IncShareDownloads(storeCtx, code)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// mem.go 共享记忆库门面:key 形态 "<project>/<topic>",写覆盖即 revision+1。
|
||||
// 同一套数据同时服务后台管理页(MemorySvc)与对外 MCP 协议端点(internal/memx)。
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// memFTSDDL 全文索引:外挂内容表(不复制数据)+ 增删改触发器同步。
|
||||
// trigram 分词器:中英文都能做子串匹配(代价是 <3 字符的词无法索引,搜索侧兜底)
|
||||
const memFTSDDL = `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
k, content, project, tags,
|
||||
content='memories', content_rowid='rowid',
|
||||
tokenize='trigram'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||||
INSERT INTO memories_fts(rowid, k, content, project, tags)
|
||||
VALUES (new.rowid, new.k, new.content, new.project, new.tags);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, k, content, project, tags)
|
||||
VALUES ('delete', old.rowid, old.k, old.content, old.project, old.tags);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, k, content, project, tags)
|
||||
VALUES ('delete', old.rowid, old.k, old.content, old.project, old.tags);
|
||||
INSERT INTO memories_fts(rowid, k, content, project, tags)
|
||||
VALUES (new.rowid, new.k, new.content, new.project, new.tags);
|
||||
END;`
|
||||
|
||||
// Memory 一条持久记忆;Tags 经 JSON 数组落库
|
||||
type Memory struct {
|
||||
Key string
|
||||
Content string
|
||||
Project string
|
||||
Tags []string
|
||||
WrittenBy string
|
||||
Revision int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func memoryFromRow(r db.Memory) Memory {
|
||||
m := Memory{
|
||||
Key: r.K, Content: r.Content, Project: r.Project, WrittenBy: r.WrittenBy,
|
||||
Revision: r.Revision, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
_ = json.Unmarshal([]byte(r.Tags), &m.Tags)
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SaveMemory 覆盖式写入:同 key 保留 created_at、revision 自增
|
||||
func (s *Store) SaveMemory(m *Memory) error {
|
||||
now := time.Now().Unix()
|
||||
tags, _ := json.Marshal(m.Tags)
|
||||
if err := s.q.UpsertMemory(storeCtx, db.UpsertMemoryParams{
|
||||
K: m.Key, Content: m.Content, Project: m.Project, Tags: string(tags),
|
||||
WrittenBy: m.WrittenBy, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.q.GetMemory(storeCtx, m.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Revision, m.CreatedAt, m.UpdatedAt = row.Revision, row.CreatedAt, row.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetMemory(key string) (*Memory, error) {
|
||||
row, err := s.q.GetMemory(storeCtx, key)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := memoryFromRow(row)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMemory(key string) (bool, error) {
|
||||
n, err := s.q.DeleteMemory(storeCtx, key)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ListMemories project/tag 可空过滤,limit<=0 取默认 50,上限 500
|
||||
func (s *Store) ListMemories(project, tag string, limit int64) ([]Memory, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.q.ListMemories(storeCtx, db.ListMemoriesParams{
|
||||
Column1: project, Project: project,
|
||||
Column3: tag, Column4: optStr(tag), Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Memory, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, memoryFromRow(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchMemories 每个空白分隔的词都要命中(AND 语义)。
|
||||
// ≥3 字符的词走 FTS5 trigram 索引(按相关度排序);出现短词或索引异常时退回内存 LIKE。
|
||||
func (s *Store) SearchMemories(query, project string, limit int64) ([]Memory, error) {
|
||||
terms := strings.Fields(query)
|
||||
if len(terms) == 0 {
|
||||
return s.ListMemories(project, "", limit)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
canFTS := true
|
||||
for _, t := range terms {
|
||||
if len([]rune(t)) < 3 {
|
||||
canFTS = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if canFTS {
|
||||
if ms, err := s.searchMemoriesFTS(terms, project, limit); err == nil {
|
||||
return ms, nil
|
||||
}
|
||||
}
|
||||
return s.searchMemoriesLike(terms, project, limit)
|
||||
}
|
||||
|
||||
// searchMemoriesFTS 索引命中:词项加引号消除 MATCH 语法注入面
|
||||
func (s *Store) searchMemoriesFTS(terms []string, project string, limit int64) ([]Memory, error) {
|
||||
var b strings.Builder
|
||||
for i, t := range terms {
|
||||
if i > 0 {
|
||||
b.WriteString(" AND ")
|
||||
}
|
||||
b.WriteString(`"` + strings.ReplaceAll(t, `"`, `""`) + `"`)
|
||||
}
|
||||
rows, err := s.DB.Query(`SELECT m.k, m.content, m.project, m.tags, m.written_by, m.revision, m.created_at, m.updated_at
|
||||
FROM memories_fts f JOIN memories m ON m.rowid = f.rowid
|
||||
WHERE f MATCH ? AND (? = '' OR m.project = ?)
|
||||
ORDER BY f.rank LIMIT ?`, b.String(), project, project, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Memory
|
||||
for rows.Next() {
|
||||
var m Memory
|
||||
var tags string
|
||||
if err := rows.Scan(&m.Key, &m.Content, &m.Project, &tags, &m.WrittenBy, &m.Revision, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(tags), &m.Tags)
|
||||
if m.Tags == nil {
|
||||
m.Tags = []string{}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) searchMemoriesLike(terms []string, project string, limit int64) ([]Memory, error) {
|
||||
all, err := s.ListMemories(project, "", 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Memory
|
||||
for _, m := range all {
|
||||
hay := strings.ToLower(m.Key + "\n" + m.Project + "\n" + m.Content)
|
||||
ok := true
|
||||
for _, t := range terms {
|
||||
if !strings.Contains(hay, strings.ToLower(t)) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
if int64(len(out)) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type MemoryProject struct {
|
||||
Name string
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (s *Store) MemoryProjects() ([]MemoryProject, error) {
|
||||
rows, err := s.q.MemoryProjects(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]MemoryProject, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, MemoryProject{Name: r.Name, Count: r.Cnt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const mcpTokenKey = "mcp.token"
|
||||
|
||||
// McpToken 读取对外 MCP 端点的 Bearer 令牌;不存在则生成一个并落库
|
||||
func (s *Store) McpToken() (string, error) {
|
||||
if v, err := s.GetSetting(mcpTokenKey); err == nil && v != "" {
|
||||
return v, nil
|
||||
}
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("store: gen mcp token failed: %w", err)
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
if err := s.SetSetting(mcpTokenKey, tok); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// SignMemoryReadURL 签发 /m/<key> 阅读链接:?sig=HMAC(token, "read:"+key)[:24]
|
||||
// 与 MCP 共用同一令牌——重置令牌即吊销全部已发链接
|
||||
func (s *Store) SignMemoryReadURL(base, key string) (string, error) {
|
||||
tok, err := s.McpToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base + "/m/" + key + "?sig=" + memReadSig(tok, key), nil
|
||||
}
|
||||
|
||||
// CheckMemoryReadSig 校验阅读链接签名
|
||||
func (s *Store) CheckMemoryReadSig(key, sig string) bool {
|
||||
tok, err := s.McpToken()
|
||||
if err != nil || sig == "" {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal([]byte(sig), []byte(memReadSig(tok, key)))
|
||||
}
|
||||
|
||||
func memReadSig(token, key string) string {
|
||||
h := hmac.New(sha256.New, []byte(token))
|
||||
h.Write([]byte("read:" + key))
|
||||
return hex.EncodeToString(h.Sum(nil))[:24]
|
||||
}
|
||||
|
||||
func (s *Store) ResetMcpToken() (string, error) {
|
||||
if err := s.SetSetting(mcpTokenKey, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.McpToken()
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// ---- proxies ----
|
||||
|
||||
type ProxyRegion struct {
|
||||
Id int64
|
||||
Code string
|
||||
Name string
|
||||
ZhName string
|
||||
Region string
|
||||
Slug string
|
||||
AliveCount int64
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
Id int64
|
||||
Ip string
|
||||
Port int64
|
||||
Protocols string
|
||||
Anonymity string
|
||||
Source string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
RegionName string
|
||||
RegionZhName string
|
||||
Latency int64
|
||||
Speed int64
|
||||
Uptime int64
|
||||
Alive bool
|
||||
FailCount int64
|
||||
LastCheckedAt int64
|
||||
LastAliveAt int64
|
||||
LastSeenAt int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type ProxySync struct {
|
||||
Id int64
|
||||
StartedAt int64
|
||||
FinishedAt int64
|
||||
Status string
|
||||
Source string
|
||||
Total int64
|
||||
Added int64
|
||||
Removed int64
|
||||
Updated int64
|
||||
Detail string
|
||||
Error string
|
||||
}
|
||||
|
||||
type ProxyFilter struct {
|
||||
RegionID int64
|
||||
Protocol string
|
||||
Keyword string
|
||||
Alive int64
|
||||
Page int64
|
||||
PageSize int64
|
||||
}
|
||||
|
||||
// UpsertRegion returns the region id for code, creating/updating the row.
|
||||
func (s *Store) UpsertRegion(code, name, zhName, region, slug string) (int64, error) {
|
||||
if err := s.q.UpsertProxyRegion(storeCtx, db.UpsertProxyRegionParams{
|
||||
Code: code, Name: name, ZhName: zhName, Region: region, Slug: slug,
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r, err := s.q.GetProxyRegionByCode(storeCtx, code)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRegions() ([]ProxyRegion, error) {
|
||||
rows, err := s.q.ListProxyRegions(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProxyRegion, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ProxyRegion{
|
||||
Id: r.ID, Code: r.Code, Name: r.Name, ZhName: r.ZhName,
|
||||
Region: r.Region, Slug: r.Slug, AliveCount: r.AliveCount,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProxyKey 同步 diff 用的键信息:来源决定「消失即删」的作用域
|
||||
type ProxyKey struct {
|
||||
Id int64
|
||||
Source string
|
||||
}
|
||||
|
||||
// ProxyKeys maps "ip:port" to ProxyKey, used by sync diffing.
|
||||
func (s *Store) ProxyKeys() (map[string]ProxyKey, error) {
|
||||
rows, err := s.q.ListProxyKeys(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]ProxyKey, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.Ip+":"+strconv.FormatInt(r.Port, 10)] = ProxyKey{Id: r.ID, Source: r.Source}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Store) InsertProxy(p *Proxy) error {
|
||||
id, err := s.q.InsertProxy(storeCtx, db.InsertProxyParams{
|
||||
Ip: p.Ip, Port: p.Port, Protocols: p.Protocols, Anonymity: p.Anonymity,
|
||||
Source: p.Source,
|
||||
RegionID: p.RegionID, Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime,
|
||||
LastSeenAt: p.LastSeenAt, CreatedAt: p.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Id = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProxySeen(id int64, p *Proxy) error {
|
||||
return s.q.UpdateProxySeen(storeCtx, db.UpdateProxySeenParams{
|
||||
Protocols: p.Protocols, Anonymity: p.Anonymity, RegionID: p.RegionID,
|
||||
Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime,
|
||||
LastSeenAt: p.LastSeenAt, ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProxy(id int64) error {
|
||||
return s.q.DeleteProxy(storeCtx, id)
|
||||
}
|
||||
|
||||
type DueProxy struct {
|
||||
Id int64
|
||||
Ip string
|
||||
Port int64
|
||||
Protocols string
|
||||
FailCount int64
|
||||
}
|
||||
|
||||
func (s *Store) DueProxies(checkedBefore int64) ([]DueProxy, error) {
|
||||
rows, err := s.q.ListProxiesDue(storeCtx, checkedBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DueProxy, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, DueProxy{Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols, FailCount: r.FailCount})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkProxyAlive(id, checkedAt, aliveAt, latency int64) error {
|
||||
return s.q.MarkProxyAlive(storeCtx, db.MarkProxyAliveParams{
|
||||
LastCheckedAt: checkedAt, LastAliveAt: aliveAt, Latency: latency, ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) MarkProxyFailed(id, checkedAt, failCount int64) error {
|
||||
return s.q.MarkProxyFailed(storeCtx, db.MarkProxyFailedParams{
|
||||
LastCheckedAt: checkedAt, FailCount: failCount, ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) PurgeDeadProxies(before int64) (int64, error) {
|
||||
return s.q.DeleteDeadProxies(storeCtx, before)
|
||||
}
|
||||
|
||||
func (s *Store) ListProxies(f ProxyFilter) ([]Proxy, int64, error) {
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.PageSize <= 0 || f.PageSize > 200 {
|
||||
f.PageSize = 50
|
||||
}
|
||||
total, err := s.q.CountProxiesFiltered(storeCtx, db.CountProxiesFilteredParams{
|
||||
Column1: f.RegionID, RegionID: f.RegionID,
|
||||
Column3: f.Protocol, Column4: optStr(f.Protocol),
|
||||
Column5: f.Keyword, Column6: optStr(f.Keyword),
|
||||
Column7: f.Alive, Alive: f.Alive,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.q.ListProxiesFiltered(storeCtx, db.ListProxiesFilteredParams{
|
||||
Column1: f.RegionID, RegionID: f.RegionID,
|
||||
Column3: f.Protocol, Column4: optStr(f.Protocol),
|
||||
Column5: f.Keyword, Column6: optStr(f.Keyword),
|
||||
Column7: f.Alive, Alive: f.Alive,
|
||||
Limit: f.PageSize, Offset: (f.Page - 1) * f.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]Proxy, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, Proxy{
|
||||
Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols, Anonymity: r.Anonymity,
|
||||
RegionID: r.RegionID, RegionCode: r.RegionCode, RegionName: r.RegionName,
|
||||
RegionZhName: r.RegionZhName, Latency: r.Latency, Speed: r.Speed, Uptime: r.Uptime,
|
||||
Alive: r.Alive == 1, FailCount: r.FailCount, LastCheckedAt: r.LastCheckedAt,
|
||||
LastAliveAt: r.LastAliveAt, LastSeenAt: r.LastSeenAt, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountAliveProxies() (int64, error) {
|
||||
return s.q.CountProxies(storeCtx)
|
||||
}
|
||||
|
||||
func (s *Store) LastProxyCheckAt() int64 {
|
||||
v, err := s.q.LastProxyCheckAt(storeCtx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if n, ok := v.(int64); ok {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Store) BeginProxySync(source string, startedAt int64) (int64, error) {
|
||||
return s.q.InsertProxySync(storeCtx, db.InsertProxySyncParams{
|
||||
StartedAt: startedAt, Source: source,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FinishProxySync(sync *ProxySync) error {
|
||||
return s.q.FinishProxySync(storeCtx, db.FinishProxySyncParams{
|
||||
FinishedAt: sync.FinishedAt, Status: sync.Status, Total: sync.Total,
|
||||
Added: sync.Added, Removed: sync.Removed, Updated: sync.Updated,
|
||||
Detail: sync.Detail, Error: sync.Error, ID: sync.Id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListProxySyncs(limit int64) ([]ProxySync, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.q.ListProxySyncs(storeCtx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProxySync, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ProxySync{
|
||||
Id: r.ID, StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, Status: r.Status,
|
||||
Source: r.Source, Total: r.Total, Added: r.Added, Removed: r.Removed,
|
||||
Updated: r.Updated, Detail: r.Detail, Error: r.Error,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- 提取密钥 ----
|
||||
|
||||
type ProxyApiKey struct {
|
||||
Id int64
|
||||
Key string
|
||||
Name string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
RegionZhName string
|
||||
Enabled bool
|
||||
UsedCount int64
|
||||
LastUsedAt int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (s *Store) InsertProxyApiKey(key, name string, regionID int64) (int64, error) {
|
||||
return s.q.InsertProxyApiKey(storeCtx, db.InsertProxyApiKeyParams{
|
||||
Kkey: key, Name: name, RegionID: regionID, CreatedAt: nowUnix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListProxyApiKeys() ([]ProxyApiKey, error) {
|
||||
rows, err := s.q.ListProxyApiKeys(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProxyApiKey, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ProxyApiKey{
|
||||
Id: r.ID, Key: r.Kkey, Name: r.Name, RegionID: r.RegionID,
|
||||
RegionCode: r.RegionCode.String, RegionZhName: r.RegionZhName.String,
|
||||
Enabled: r.Enabled == 1, UsedCount: r.UsedCount,
|
||||
LastUsedAt: r.LastUsedAt, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetProxyApiKey(key string) (ProxyApiKey, error) {
|
||||
r, err := s.q.GetProxyApiKey(storeCtx, key)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ProxyApiKey{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ProxyApiKey{}, err
|
||||
}
|
||||
return ProxyApiKey{
|
||||
Id: r.ID, Key: r.Kkey, Name: r.Name, RegionID: r.RegionID,
|
||||
Enabled: r.Enabled == 1, UsedCount: r.UsedCount,
|
||||
LastUsedAt: r.LastUsedAt, CreatedAt: r.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) TouchProxyApiKey(id int64) {
|
||||
_ = s.q.TouchProxyApiKey(storeCtx, db.TouchProxyApiKeyParams{
|
||||
LastUsedAt: nowUnix(), ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProxyApiKey(id int64) error {
|
||||
_, err := s.q.DeleteProxyApiKey(storeCtx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nowUnix() int64 { return time.Now().Unix() }
|
||||
|
||||
type RandomProxy struct {
|
||||
Id int64
|
||||
Ip string
|
||||
Port int64
|
||||
Protocols string
|
||||
FailCount int64
|
||||
RegionCode string
|
||||
RegionZhName string
|
||||
}
|
||||
|
||||
// RandomProxies 随机取存活代理:regionID=0 全部地区,code 可按国家码二次过滤
|
||||
func (s *Store) RandomProxies(regionID int64, protocol, code string, limit int64) ([]RandomProxy, error) {
|
||||
rows, err := s.q.RandomProxies(storeCtx, db.RandomProxiesParams{
|
||||
Column1: regionID, RegionID: regionID,
|
||||
Column3: protocol, Column4: optStr(protocol),
|
||||
Column5: code, Code: code,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RandomProxy, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, RandomProxy{
|
||||
Id: r.ID, Ip: r.Ip, Port: r.Port, Protocols: r.Protocols,
|
||||
FailCount: r.FailCount,
|
||||
RegionCode: r.RegionCode, RegionZhName: r.RegionZhName,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// Server SSH 服务器条目:secret_enc 为 AES-GCM 密文,存密码或 PEM 私钥,
|
||||
// 明文只在连接的瞬间存在于内存,不落盘不下发
|
||||
type Server struct {
|
||||
Id int64
|
||||
Name string
|
||||
Host string
|
||||
Port int64
|
||||
Username string
|
||||
AuthType string // password | key
|
||||
SecretEnc string
|
||||
Note string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func serverFromRow(e db.Server) Server {
|
||||
return Server{
|
||||
Id: e.ID, Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username,
|
||||
AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note,
|
||||
CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListServers() ([]Server, error) {
|
||||
rows, err := s.q.ListServers(storeCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Server, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, serverFromRow(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetServer(id int64) (Server, error) {
|
||||
e, err := s.q.GetServer(storeCtx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Server{}, ErrNotFound
|
||||
}
|
||||
return serverFromRow(e), err
|
||||
}
|
||||
|
||||
func (s *Store) SaveServer(e *Server) error {
|
||||
now := time.Now().Unix()
|
||||
if e.Id > 0 {
|
||||
n, err := s.q.UpdateServer(storeCtx, db.UpdateServerParams{
|
||||
Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username,
|
||||
AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note,
|
||||
UpdatedAt: now, ID: e.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
id, err := s.q.InsertServer(storeCtx, db.InsertServerParams{
|
||||
Name: e.Name, Host: e.Host, Port: e.Port, Username: e.Username,
|
||||
AuthType: e.AuthType, SecretEnc: e.SecretEnc, Note: e.Note,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Id, e.CreatedAt, e.UpdatedAt = id, now, now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteServer(id int64) error {
|
||||
n, err := s.q.DeleteServer(storeCtx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CountServers() (int64, error) {
|
||||
return s.q.CountServers(storeCtx)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
func (s *Store) GetSetting(key string) (string, error) {
|
||||
v, err := s.q.GetSetting(storeCtx, key)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
return s.q.UpsertSetting(storeCtx, db.UpsertSettingParams{K: key, V: value})
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Package store 数据门面层:CRUD 委托给 sqlc 生成的 internal/db.Queries(类型安全),
|
||||
// 只保留 sqlc 表达不了的逻辑——会话滑动续期、聚合映射、种子数据,以及对服务层稳定的
|
||||
// 领域结构体(Id 命名、Enabled bool)与 ErrNotFound 语义。
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
Cfg *config.Config `fun:"auto"`
|
||||
DB *sql.DB
|
||||
q *db.Queries
|
||||
}
|
||||
|
||||
// Q 暴露 sqlc 查询器:新代码可直接走类型安全 API,门面方法逐步收敛
|
||||
func (s *Store) Q() *db.Queries { return s.q }
|
||||
|
||||
func (s *Store) New() error {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
s.Cfg.DBPath)
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: open sqlite failed: %w", err)
|
||||
}
|
||||
// SQLite 单写者:串行化所有连接,避免 database is locked
|
||||
conn.SetMaxOpenConns(1)
|
||||
s.DB = conn
|
||||
s.q = db.New(conn)
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrate 表结构唯一来源是 internal/db/schema.sql(sqlc 与运行时共用);
|
||||
// 列级增量(老库补列)用探测式 ALTER,CREATE TABLE IF NOT EXISTS 覆盖不到。
|
||||
func (s *Store) migrate() error {
|
||||
if _, err := s.DB.Exec(db.SchemaDDL); err != nil {
|
||||
return fmt.Errorf("store: migrate failed: %w", err)
|
||||
}
|
||||
// proxies.source 是后加的列:老库探测缺失则 ALTER(COUNT 空表也返回行,不误判)
|
||||
var dummy int64
|
||||
if err := s.DB.QueryRow("SELECT COUNT(source) FROM proxies").Scan(&dummy); err != nil {
|
||||
if _, err := s.DB.Exec("ALTER TABLE proxies ADD COLUMN source TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return fmt.Errorf("store: migrate proxies.source failed: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.DB.QueryRow("SELECT COUNT(app_id) FROM license_cards").Scan(&dummy); err != nil {
|
||||
if _, err := s.DB.Exec("ALTER TABLE license_cards ADD COLUMN app_id INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return fmt.Errorf("store: migrate license_cards.app_id failed: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.DB.Exec("CREATE INDEX IF NOT EXISTS idx_license_cards_app ON license_cards(app_id)"); err != nil {
|
||||
return fmt.Errorf("store: migrate license_cards index failed: %w", err)
|
||||
}
|
||||
// 记忆库全文索引:FTS5 外挂内容表 + 触发器同步(放这里而非 schema.sql,
|
||||
// sqlc 的 sqlite 解析器不认 CREATE VIRTUAL TABLE/TRIGGER)
|
||||
if _, err := s.DB.Exec(memFTSDDL); err != nil {
|
||||
return fmt.Errorf("store: migrate memories_fts failed: %w", err)
|
||||
}
|
||||
// rebuild 幂等:给触发器建成前已存在的行补索引
|
||||
if _, err := s.DB.Exec(`INSERT INTO memories_fts(memories_fts) VALUES('rebuild')`); err != nil {
|
||||
return fmt.Errorf("store: rebuild memories_fts failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var storeCtx = context.Background()
|
||||
|
||||
func optStr(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// SeedAdmin 空库时写入首个管理员;已有用户则跳过
|
||||
func (s *Store) SeedAdmin(passwordHash string) error {
|
||||
n, err := s.q.CountAllUsers(storeCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: count users failed: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, err = s.q.InsertUser(storeCtx, db.InsertUserParams{
|
||||
Name: s.Cfg.AdminName, PasswordHash: passwordHash, Role: "admin",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: seed admin failed: %w", err)
|
||||
}
|
||||
log.Printf("store: seeded admin account %q", s.Cfg.AdminName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedTools 空表时写入两条示例工具,演示工具箱菜单的数据驱动渲染
|
||||
func (s *Store) SeedTools() error {
|
||||
tools, err := s.q.ListTools(storeCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: count tools failed: %w", err)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
return nil
|
||||
}
|
||||
for _, t := range []db.InsertToolParams{
|
||||
{Name: "JSON 格式化", Icon: "code-outline", Url: "/tools/json", Description: "粘贴即格式化与校验 JSON", Sort: 1, Enabled: true},
|
||||
{Name: "时间戳转换", Icon: "time-outline", Url: "/tools/timestamp", Description: "Unix 时间戳与日期互转", Sort: 2, Enabled: true},
|
||||
} {
|
||||
if _, err := s.q.InsertTool(storeCtx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
type User struct {
|
||||
Id int64
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Status string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("record not found")
|
||||
|
||||
func userFromRow(u db.User) User {
|
||||
return User{
|
||||
Id: u.ID, Name: u.Name, PasswordHash: u.PasswordHash, Role: u.Role,
|
||||
Status: u.Status, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByName(name string) (User, error) {
|
||||
u, err := s.q.GetUserByName(storeCtx, name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return userFromRow(u), err
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(id int64) (User, error) {
|
||||
u, err := s.q.GetUserByID(storeCtx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return userFromRow(u), err
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers(keyword string, page, pageSize int64) ([]User, int64, error) {
|
||||
total, err := s.q.CountUsers(storeCtx, optStr(keyword))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.q.ListUsers(storeCtx, db.ListUsersParams{
|
||||
Keyword: optStr(keyword),
|
||||
Limit: pageSize,
|
||||
Offset: (page - 1) * pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
users := make([]User, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
users = append(users, userFromRow(r))
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(name, passwordHash, role string) (User, error) {
|
||||
now := time.Now().Unix()
|
||||
id, err := s.q.InsertUser(storeCtx, db.InsertUserParams{
|
||||
Name: name, PasswordHash: passwordHash, Role: role, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return s.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(id int64, name, role, status string, passwordHash *string) error {
|
||||
now := time.Now().Unix()
|
||||
if passwordHash != nil {
|
||||
return s.q.UpdateUserPassword(storeCtx, db.UpdateUserPasswordParams{
|
||||
Name: name, Role: role, Status: status, PasswordHash: *passwordHash,
|
||||
UpdatedAt: now, ID: id,
|
||||
})
|
||||
}
|
||||
return s.q.UpdateUser(storeCtx, db.UpdateUserParams{
|
||||
Name: name, Role: role, Status: status, UpdatedAt: now, ID: id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(id int64) error {
|
||||
return s.q.DeleteUser(storeCtx, id)
|
||||
}
|
||||
|
||||
func (s *Store) CountUsers() (int64, error) {
|
||||
return s.q.CountAllUsers(storeCtx)
|
||||
}
|
||||
|
||||
func (s *Store) CountActiveUsers() (int64, error) {
|
||||
return s.q.CountActiveUsers(storeCtx)
|
||||
}
|
||||
|
||||
func (s *Store) CountAdmins() (int64, error) {
|
||||
return s.q.CountAdmins(storeCtx)
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
func (s *Store) CreateSession(token string, userID int64, ttl time.Duration) error {
|
||||
now := time.Now()
|
||||
return s.q.InsertSession(storeCtx, db.InsertSessionParams{
|
||||
Token: token, UserID: userID,
|
||||
CreatedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// SessionUser 会话命中后的用户视图:登录态校验只走这一处,过期/禁用统一判负
|
||||
type SessionUser struct {
|
||||
User User
|
||||
Token string
|
||||
}
|
||||
|
||||
// GetSessionUser 校验 token 对应的会话与用户。
|
||||
// 滑动续期:会话本身 ttl 过期即失效;存活但距签发/上次续期超过 renewAfter 时,
|
||||
// 顺手把 expires_at 顺延回 now+ttl——活跃用户不掉线,闲置 ttl 时长强制下线。
|
||||
func (s *Store) GetSessionUser(token string, ttl, renewAfter time.Duration) (SessionUser, error) {
|
||||
row, err := s.q.GetSessionUser(storeCtx, token)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionUser{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return SessionUser{}, err
|
||||
}
|
||||
su := SessionUser{
|
||||
Token: token,
|
||||
User: User{
|
||||
Id: row.ID, Name: row.Name, PasswordHash: row.PasswordHash,
|
||||
Role: row.Role, Status: row.Status,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
},
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Unix() > row.ExpiresAt {
|
||||
_ = s.q.DeleteSession(storeCtx, token)
|
||||
return SessionUser{}, ErrNotFound
|
||||
}
|
||||
lastRefresh := time.Unix(row.ExpiresAt, 0).Add(-ttl)
|
||||
if now.Sub(lastRefresh) >= renewAfter {
|
||||
_ = s.q.TouchSession(storeCtx, db.TouchSessionParams{
|
||||
ExpiresAt: now.Add(ttl).Unix(), Token: token,
|
||||
})
|
||||
}
|
||||
return su, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(token string) error {
|
||||
return s.q.DeleteSession(storeCtx, token)
|
||||
}
|
||||
|
||||
// DeleteOtherSessions 改密后吊销除当前外的全部会话
|
||||
func (s *Store) DeleteOtherSessions(userID int64, keepToken string) error {
|
||||
return s.q.DeleteOtherSessions(storeCtx, db.DeleteOtherSessionsParams{
|
||||
UserID: userID, Token: keepToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) CountActiveSessions() (int64, error) {
|
||||
return s.q.CountActiveSessions(storeCtx, time.Now().Unix())
|
||||
}
|
||||
|
||||
func (s *Store) CountTodayLogins() (int64, error) {
|
||||
return s.q.CountTodayLogins(storeCtx)
|
||||
}
|
||||
|
||||
// LoginTrend 最近 days 天每日登录数(按会话创建数统计),缺口补零由调用方做
|
||||
func (s *Store) LoginTrend(days int64) (map[string]int64, error) {
|
||||
rows, err := s.q.LoginTrend(storeCtx, time.Now().AddDate(0, 0, int(-days)).Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
if d, ok := r.D.(string); ok {
|
||||
m[d] = r.C
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ---- tools ----
|
||||
|
||||
type Tool struct {
|
||||
Id int64
|
||||
Name string
|
||||
Icon string
|
||||
Url string
|
||||
Description string
|
||||
Sort int64
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func toolFromRow(t db.Tool) Tool {
|
||||
return Tool{
|
||||
Id: t.ID, Name: t.Name, Icon: t.Icon, Url: t.Url,
|
||||
Description: t.Description, Sort: t.Sort, Enabled: t.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListTools(enabledOnly bool) ([]Tool, error) {
|
||||
var rows []db.Tool
|
||||
var err error
|
||||
if enabledOnly {
|
||||
rows, err = s.q.ListEnabledTools(storeCtx)
|
||||
} else {
|
||||
rows, err = s.q.ListTools(storeCtx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := make([]Tool, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tools = append(tools, toolFromRow(r))
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveTool(t *Tool) error {
|
||||
if t.Id > 0 {
|
||||
return s.q.UpdateTool(storeCtx, db.UpdateToolParams{
|
||||
Name: t.Name, Icon: t.Icon, Url: t.Url, Description: t.Description,
|
||||
Sort: t.Sort, Enabled: t.Enabled, ID: t.Id,
|
||||
})
|
||||
}
|
||||
id, err := s.q.InsertTool(storeCtx, db.InsertToolParams{
|
||||
Name: t.Name, Icon: t.Icon, Url: t.Url, Description: t.Description,
|
||||
Sort: t.Sort, Enabled: t.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Id = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTool(id int64) error {
|
||||
return s.q.DeleteTool(storeCtx, id)
|
||||
}
|
||||
|
||||
func (s *Store) CountTools() (int64, error) {
|
||||
return s.q.CountTools(storeCtx)
|
||||
}
|
||||
|
||||
// NormalizeName 服务层共用的名字规整
|
||||
func NormalizeName(s string) string { return strings.TrimSpace(s) }
|
||||
@@ -0,0 +1,93 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
)
|
||||
|
||||
// VaultEntry 密码箱条目:password_enc / totp_enc 为 AES-GCM 密文,离开 store 前必须解密
|
||||
type VaultEntry struct {
|
||||
Id int64
|
||||
UserID int64
|
||||
Title string
|
||||
Username string
|
||||
PasswordEnc string
|
||||
Url string
|
||||
Note string
|
||||
TotpEnc string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
func vaultFromRow(v db.Vault) VaultEntry {
|
||||
return VaultEntry{
|
||||
Id: v.ID, UserID: v.UserID, Title: v.Title, Username: v.Username,
|
||||
PasswordEnc: v.PasswordEnc, Url: v.Url, Note: v.Note, TotpEnc: v.TotpEnc,
|
||||
CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListVault(userID int64, keyword string) ([]VaultEntry, error) {
|
||||
rows, err := s.q.ListVault(storeCtx, db.ListVaultParams{
|
||||
UserID: userID,
|
||||
Keyword: optStr(keyword),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]VaultEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, vaultFromRow(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetVault(userID, id int64) (VaultEntry, error) {
|
||||
v, err := s.q.GetVault(storeCtx, db.GetVaultParams{UserID: userID, ID: id})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return VaultEntry{}, ErrNotFound
|
||||
}
|
||||
return vaultFromRow(v), err
|
||||
}
|
||||
|
||||
func (s *Store) SaveVault(e *VaultEntry) error {
|
||||
now := time.Now().Unix()
|
||||
if e.Id > 0 {
|
||||
n, err := s.q.UpdateVault(storeCtx, db.UpdateVaultParams{
|
||||
Title: e.Title, Username: e.Username, PasswordEnc: e.PasswordEnc,
|
||||
Url: e.Url, Note: e.Note, TotpEnc: e.TotpEnc,
|
||||
UpdatedAt: now, ID: e.Id, UserID: e.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
id, err := s.q.InsertVault(storeCtx, db.InsertVaultParams{
|
||||
UserID: e.UserID, Title: e.Title, Username: e.Username,
|
||||
PasswordEnc: e.PasswordEnc, Url: e.Url, Note: e.Note, TotpEnc: e.TotpEnc,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Id, e.CreatedAt, e.UpdatedAt = id, now, now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteVault(userID, id int64) error {
|
||||
n, err := s.q.DeleteVault(storeCtx, db.DeleteVaultParams{UserID: userID, ID: id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user