Files
chiyiandDevin 834ecacdb9 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>
2026-10-01 01:58:47 +08:00

372 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) }