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