Files
wangjia ece51d7e3d refactor(server/db): 多库(2/4)— 时间等 DB 端计算退回 Go(可移植)
auth/admin/httpapi:把 UTC_TIMESTAMP(6) 等 DB 端取值改为 Go time.Now().UTC()
作为 ? 参数传入(两库精度一致、可测、天然跨方言)。仅这三个文件不涉及
upsert/锁,故独立成提交;其余域文件的同类改动与 dialect 改动同语句交错,合入(3/4)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:01:12 +08:00

244 lines
7.4 KiB
Go

package admin
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// ErrAdminNotFound is returned when no admin row matches a lookup.
var ErrAdminNotFound = errors.New("admin: not found")
// Store is the data-access surface the admin backend needs. It is an interface
// so handlers and the authenticator can be unit-tested with a fake; the
// production implementation (DBStore) is backed by MySQL.
type Store interface {
// Admin identity.
GetAdminByUsername(ctx context.Context, username string) (*Admin, error)
CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error)
UpdateLastLogin(ctx context.Context, id int64, at time.Time) error
// Node catalogue (read-only here; mutations go through the #5/#14 services).
ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error)
GetNode(ctx context.Context, id int64) (*NodeRow, error)
// Audit & events.
WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error
QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error)
QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error)
}
// DBStore implements Store over MySQL.
type DBStore struct {
db *sql.DB
}
// NewDBStore creates a DBStore.
func NewDBStore(db *sql.DB) *DBStore { return &DBStore{db: db} }
// GetAdminByUsername loads an admin by username.
func (s *DBStore) GetAdminByUsername(ctx context.Context, username string) (*Admin, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, pw_hash, totp_secret, status, created_at, last_login_at
FROM admins WHERE username = ?`, username)
var a Admin
var last sql.NullTime
err := row.Scan(&a.ID, &a.Username, &a.PwHash, &a.TOTPSecretEnc, &a.Status, &a.CreatedAt, &last)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAdminNotFound
}
if err != nil {
return nil, fmt.Errorf("admin.GetAdminByUsername: %w", err)
}
if last.Valid {
a.LastLoginAt = &last.Time
}
return &a, nil
}
// CreateAdmin inserts a new admin and returns its id.
func (s *DBStore) CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO admins (username, pw_hash, totp_secret, status, created_at)
VALUES (?, ?, ?, 'active', ?)`,
username, pwHash, totpSecretEnc, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("admin.CreateAdmin: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("admin.CreateAdmin last id: %w", err)
}
return id, nil
}
// UpdateLastLogin records a successful login time.
func (s *DBStore) UpdateLastLogin(ctx context.Context, id int64, at time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE admins SET last_login_at = ? WHERE id = ?`, at.UTC(), id)
if err != nil {
return fmt.Errorf("admin.UpdateLastLogin: %w", err)
}
return nil
}
// ListNodes returns all nodes with up to eventsPerNode recent events each.
func (s *DBStore) ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier,
n.endpoint, n.status, n.weight, pr.name
FROM nodes n
JOIN providers pr ON pr.id = n.provider_id
ORDER BY n.id`)
if err != nil {
return nil, fmt.Errorf("admin.ListNodes: %w", err)
}
defer rows.Close()
var out []NodeRow
for rows.Next() {
var n NodeRow
if err := rows.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role,
&n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider); err != nil {
return nil, fmt.Errorf("admin.ListNodes scan: %w", err)
}
out = append(out, n)
}
if err := rows.Err(); err != nil {
return nil, err
}
if eventsPerNode > 0 {
for i := range out {
ev, err := s.QueryNodeEvents(ctx, out[i].ID, eventsPerNode)
if err != nil {
return nil, err
}
out[i].RecentEvents = ev
}
}
return out, nil
}
// GetNode loads a single node by id.
func (s *DBStore) GetNode(ctx context.Context, id int64) (*NodeRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier,
n.endpoint, n.status, n.weight, pr.name
FROM nodes n
JOIN providers pr ON pr.id = n.provider_id
WHERE n.id = ?`, id)
var n NodeRow
err := row.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role,
&n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("admin.GetNode: %w", err)
}
return &n, nil
}
// WriteAudit inserts an audit_log row.
func (s *DBStore) WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error {
if metaJSON == "" {
metaJSON = "null"
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_log (actor, action, target, meta, at)
VALUES (?, ?, ?, ?, ?)`,
actor, action, target, metaJSON, time.Now().UTC())
if err != nil {
return fmt.Errorf("admin.WriteAudit: %w", err)
}
return nil
}
// QueryAudit returns filtered audit_log entries plus the total match count.
func (s *DBStore) QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error) {
where := []string{"1=1"}
args := []any{}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.Target != "" {
where = append(where, "target LIKE ?")
args = append(args, "%"+f.Target+"%")
}
if f.From != nil {
where = append(where, "at >= ?")
args = append(args, f.From.UTC())
}
if f.To != nil {
where = append(where, "at <= ?")
args = append(args, f.To.UTC())
}
clause := strings.Join(where, " AND ")
var total int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM audit_log WHERE `+clause, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit count: %w", err)
}
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 50
}
q := `SELECT id, actor, action, target, COALESCE(meta, ''), at
FROM audit_log WHERE ` + clause + ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`
args = append(args, limit, f.Offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
var meta []byte
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Target, &meta, &e.At); err != nil {
return nil, 0, fmt.Errorf("admin.QueryAudit scan: %w", err)
}
e.Meta = string(meta)
out = append(out, e)
}
return out, total, rows.Err()
}
// QueryNodeEvents returns the most recent events for a node.
func (s *DBStore) QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, node_id, event, COALESCE(detail, ''), at
FROM node_events WHERE node_id = ? ORDER BY at DESC, id DESC LIMIT ?`,
nodeID, limit)
if err != nil {
return nil, fmt.Errorf("admin.QueryNodeEvents: %w", err)
}
defer rows.Close()
var out []NodeEvent
for rows.Next() {
var e NodeEvent
var detail []byte
if err := rows.Scan(&e.ID, &e.NodeID, &e.Event, &detail, &e.At); err != nil {
return nil, fmt.Errorf("admin.QueryNodeEvents scan: %w", err)
}
e.Detail = string(detail)
out = append(out, e)
}
return out, rows.Err()
}