feat: 实现 Admin 首次初始化与网页登录 (#50)

This commit is contained in:
chengma
2026-08-09 13:36:41 +08:00
parent 1064ffed59
commit 0a7db404a1
17 changed files with 1140 additions and 12 deletions
+36 -1
View File
@@ -264,7 +264,7 @@ var migrations = [][]string{
// 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器
// (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个
// 和代码对不上的库静默启动。
const schemaVersion = 5
const schemaVersion = 6
// migrationV4 给 PDD 商品增加店铺名。
//
@@ -300,6 +300,32 @@ var migrationV5 = []string{
`ALTER TABLE syb_orders ADD COLUMN product_spec TEXT;`,
}
// migrationV6 增加 Admin 网页账号和 Session。只追加新表和索引,
// v1-v5 的任何语句都不修改,见工单 #50。
var migrationV6 = []string{
`CREATE TABLE users (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'purchaser')),
status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
last_login_at TEXT,
password_changed_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE web_sessions (
session_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);`,
`CREATE INDEX idx_web_sessions_user ON web_sessions(user_id);`,
`CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at);`,
}
// Migrate 把数据库升到最新版本。
// 已经是最新的就什么都不做,可以重复调用。
func Migrate(db *sql.DB) error {
@@ -362,6 +388,14 @@ func Migrate(db *sql.DB) error {
if err := runSQLMigration(db, 5, migrationV5); err != nil {
return err
}
reached = 5
}
// v6 是纯追加的用户和 Web Session 表。
if reached < 6 {
if err := runSQLMigration(db, 6, migrationV6); err != nil {
return err
}
}
return nil
@@ -823,6 +857,7 @@ var requiredTables = []string{
"syb_orders", "sku_mappings", "tasks", "clients",
"idempotency_keys", "task_claims",
"syb_session", "syb_sync_state",
"users", "web_sessions",
}
// requiredColumns 只列出不能靠“表存在”发现的关键追加列。
+35 -2
View File
@@ -593,6 +593,36 @@ func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T
}
}
func TestMigrate_v6新增用户和WebSession表(t *testing.T) {
for _, c := range []struct {
name string
db *sql.DB
}{
{"全新库", newFreshDB(t)},
{"v2 老结构库", newV2DB(t)},
{"v2 新结构库", newV2NewStructureDB(t)},
} {
if c.name != "全新库" {
if err := Migrate(c.db); err != nil {
t.Fatalf("%s 迁移失败: %v", c.name, err)
}
}
tables := existingTableSet(t, c.db)
for _, table := range []string{"users", "web_sessions"} {
if !tables[table] {
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
}
}
var version int
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
}
if version != 6 {
t.Errorf("%s user_version = %d,期望 6", c.name, version)
}
}
}
// existingTableSet 返回库里当前存在的表名集合。
func existingTableSet(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
@@ -1027,11 +1057,14 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
if err := migrateV3(db); err != nil {
t.Fatalf("准备 v3 数据库失败: %v", err)
}
// 故意跳过 v4(不加 shop_name),但把 v5 补上——否则 CheckSchema 会先
// 因为缺 v5 的表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
// 故意跳过 v4(不加 shop_name),但把 v5/v6 补上——否则 CheckSchema 会先
// 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
if err := runSQLMigration(db, 5, migrationV5); err != nil {
t.Fatalf("准备 v5 数据库失败: %v", err)
}
if err := runSQLMigration(db, 6, migrationV6); err != nil {
t.Fatalf("准备 v6 数据库失败: %v", err)
}
err := CheckSchema(db)
if err == nil || !strings.Contains(err.Error(), "shop_name") {
+150
View File
@@ -0,0 +1,150 @@
// Admin 网页用户与 Session 的数据库读写。
package repository
import (
"database/sql"
"errors"
"fmt"
"cmautobuy/admin/model"
)
var (
// ErrUsersAlreadyExist 表示首次初始化入口已经永久关闭。
ErrUsersAlreadyExist = errors.New("管理员已经初始化")
ErrUserNotFound = errors.New("用户不存在")
ErrSessionNotFound = errors.New("网页登录会话不存在")
)
// CountUsers 返回所有用户数量,disabled 也算。即使所有账号被禁用,
// 首次初始化入口也不能重新开放。
func CountUsers(q Execer) (int, error) {
var count int
if err := q.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return 0, fmt.Errorf("统计 Admin 用户失败: %w", err)
}
return count, nil
}
// CreateInitialAdmin 在写事务里再次确认 users 为空后创建首位管理员。
// Open 的 _txlock=immediate 让并发事务从一开始就串行,最多一个能成功。
func CreateInitialAdmin(db *sql.DB, user model.User) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始初始化管理员事务失败: %w", err)
}
defer tx.Rollback()
count, err := CountUsers(tx)
if err != nil {
return err
}
if count != 0 {
return ErrUsersAlreadyExist
}
_, err = tx.Exec(`
INSERT INTO users
(user_id, username, password_hash, role, status, last_login_at,
password_changed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
user.UserID, user.Username, user.PasswordHash, user.Role, user.Status,
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt)
if err != nil {
return fmt.Errorf("创建首位管理员失败: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交初始化管理员事务失败: %w", err)
}
return nil
}
// FindUserByUsername 使用 users.username 的 NOCASE 规则查账号。
func FindUserByUsername(q Execer, username string) (*model.User, error) {
var user model.User
var lastLogin sql.NullString
err := q.QueryRow(`
SELECT user_id, username, password_hash, role, status, last_login_at,
password_changed_at, created_at, updated_at
FROM users WHERE username = ?`, username,
).Scan(&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("查询 Admin 用户失败: %w", err)
}
user.LastLoginAt = lastLogin.String
return &user, nil
}
// CreateLoginSession 原子写入新 Session 并记录最后登录时间。
func CreateLoginSession(db *sql.DB, session model.WebSession, loginAt string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始登录事务失败: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`
INSERT INTO web_sessions
(session_hash, user_id, expires_at, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?)`,
session.SessionHash, session.UserID, session.ExpiresAt,
session.CreatedAt, session.LastSeenAt); err != nil {
return fmt.Errorf("保存 Web Session 失败: %w", err)
}
if _, err := tx.Exec(`
UPDATE users SET last_login_at = ?, updated_at = ? WHERE user_id = ?`,
loginAt, loginAt, session.UserID); err != nil {
return fmt.Errorf("更新用户最后登录时间失败: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交登录事务失败: %w", err)
}
return nil
}
// FindUserBySessionHash 返回 Session 及其用户。账号状态由 service 判断,
// 这样“禁用账号立即失效”不依赖额外清理是否及时。
func FindUserBySessionHash(q Execer, sessionHash string) (*model.WebSession, *model.User, error) {
var session model.WebSession
var user model.User
var lastLogin sql.NullString
err := q.QueryRow(`
SELECT s.session_hash, s.user_id, s.expires_at, s.created_at, s.last_seen_at,
u.user_id, u.username, u.password_hash, u.role, u.status,
u.last_login_at, u.password_changed_at, u.created_at, u.updated_at
FROM web_sessions s
JOIN users u ON u.user_id = s.user_id
WHERE s.session_hash = ?`, sessionHash,
).Scan(&session.SessionHash, &session.UserID, &session.ExpiresAt,
&session.CreatedAt, &session.LastSeenAt,
&user.UserID, &user.Username, &user.PasswordHash, &user.Role, &user.Status,
&lastLogin, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrSessionNotFound
}
if err != nil {
return nil, nil, fmt.Errorf("查询 Web Session 失败: %w", err)
}
user.LastLoginAt = lastLogin.String
return &session, &user, nil
}
// DeleteSession 删除当前浏览器对应的 Session。不存在也视为成功,退出幂等。
func DeleteSession(q Execer, sessionHash string) error {
if _, err := q.Exec(`DELETE FROM web_sessions WHERE session_hash = ?`, sessionHash); err != nil {
return fmt.Errorf("删除 Web Session 失败: %w", err)
}
return nil
}
// DeleteExpiredSessions 清理固定过期的 Session,不需要后台常驻线程。
func DeleteExpiredSessions(q Execer, nowISO string) error {
if _, err := q.Exec(`DELETE FROM web_sessions WHERE expires_at <= ?`, nowISO); err != nil {
return fmt.Errorf("清理过期 Web Session 失败: %w", err)
}
return nil
}