ece51d7e3d
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>
244 lines
7.4 KiB
Go
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()
|
|
}
|