feat: 实现 Admin 首次初始化与网页登录 (#50)
This commit is contained in:
+36
-1
@@ -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 只列出不能靠“表存在”发现的关键追加列。
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user