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
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"cmautobuy/admin/repository"
)
func TestAdmin首次初始化登录退出完整流程(t *testing.T) {
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := repository.Migrate(db); err != nil {
t.Fatal(err)
}
router, err := newRouter(db)
if err != nil {
t.Fatal(err)
}
setupPage := httptest.NewRecorder()
router.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
if setupPage.Code != http.StatusOK || !strings.Contains(setupPage.Body.String(), "初始化管理员") {
t.Fatalf("GET /setup = %d,响应:%s", setupPage.Code, setupPage.Body.String())
}
csrfCookie := findResponseCookie(t, setupPage, "cmautobuy_csrf")
if csrfCookie.HttpOnly || csrfCookie.Value == "" {
t.Fatalf("CSRF Cookie 应可供双提交表单使用: %#v", csrfCookie)
}
// 初始化和登录都是公开入口,但 POST 同样必须通过 CSRF。
missingCSRF := postFormRequest("/setup", url.Values{
"username": {"admin"}, "password": {"safe-password"},
"password_confirm": {"safe-password"},
}, nil)
missingCSRFResponse := httptest.NewRecorder()
router.ServeHTTP(missingCSRFResponse, missingCSRF)
if missingCSRFResponse.Code != http.StatusForbidden {
t.Fatalf("无 CSRF 初始化 = %d,期望 403", missingCSRFResponse.Code)
}
setup := postFormRequest("/setup", url.Values{
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
"password": {"safe-password"}, "password_confirm": {"safe-password"},
}, csrfCookie)
setupResponse := httptest.NewRecorder()
router.ServeHTTP(setupResponse, setup)
if setupResponse.Code != http.StatusSeeOther || !strings.HasPrefix(setupResponse.Header().Get("Location"), "/login?") {
t.Fatalf("POST /setup = %d %s", setupResponse.Code, setupResponse.Header().Get("Location"))
}
// 初始化完成后入口永久关闭。
setupAgain := httptest.NewRecorder()
router.ServeHTTP(setupAgain, httptest.NewRequest(http.MethodGet, "/setup", nil))
if setupAgain.Code != http.StatusSeeOther || !strings.HasPrefix(setupAgain.Header().Get("Location"), "/login?") {
t.Fatalf("再次 GET /setup = %d %s", setupAgain.Code, setupAgain.Header().Get("Location"))
}
wrongLogin := postFormRequest("/login", url.Values{
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
"password": {"wrong-password"}, "next": {"/pdd"},
}, csrfCookie)
wrongResponse := httptest.NewRecorder()
router.ServeHTTP(wrongResponse, wrongLogin)
if wrongResponse.Code != http.StatusUnauthorized || !strings.Contains(wrongResponse.Body.String(), "用户名或密码错误") {
t.Fatalf("错误密码响应 = %d %s", wrongResponse.Code, wrongResponse.Body.String())
}
login := postFormRequest("/login", url.Values{
"csrf_token": {csrfCookie.Value}, "username": {"admin"},
"password": {"safe-password"}, "next": {"/pdd"},
}, csrfCookie)
loginResponse := httptest.NewRecorder()
router.ServeHTTP(loginResponse, login)
if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != "/pdd" {
t.Fatalf("POST /login = %d %s", loginResponse.Code, loginResponse.Header().Get("Location"))
}
authCookie := findResponseCookie(t, loginResponse, "cmautobuy_session")
if !authCookie.HttpOnly || authCookie.Secure || authCookie.SameSite != http.SameSiteLaxMode ||
authCookie.Path != "/" || authCookie.MaxAge != 12*60*60 {
t.Fatalf("认证 Cookie 属性不正确: %#v", authCookie)
}
protected := httptest.NewRequest(http.MethodGet, "/shopee", nil)
protected.AddCookie(authCookie)
protectedResponse := httptest.NewRecorder()
router.ServeHTTP(protectedResponse, protected)
if protectedResponse.Code != http.StatusOK || !strings.Contains(protectedResponse.Body.String(), "admin") ||
!strings.Contains(protectedResponse.Body.String(), "退出登录") {
t.Fatalf("登录后业务页 = %d,响应:%s", protectedResponse.Code, protectedResponse.Body.String())
}
logout := postFormRequest("/logout", url.Values{"csrf_token": {csrfCookie.Value}}, csrfCookie)
logout.AddCookie(authCookie)
logoutResponse := httptest.NewRecorder()
router.ServeHTTP(logoutResponse, logout)
if logoutResponse.Code != http.StatusSeeOther || !strings.HasPrefix(logoutResponse.Header().Get("Location"), "/login?") {
t.Fatalf("POST /logout = %d %s", logoutResponse.Code, logoutResponse.Header().Get("Location"))
}
oldSession := httptest.NewRequest(http.MethodGet, "/shopee", nil)
oldSession.AddCookie(authCookie)
oldSessionResponse := httptest.NewRecorder()
router.ServeHTTP(oldSessionResponse, oldSession)
if oldSessionResponse.Code != http.StatusSeeOther || !strings.HasPrefix(oldSessionResponse.Header().Get("Location"), "/login?next=") {
t.Fatalf("退出后旧 Session 仍可访问: %d %s", oldSessionResponse.Code, oldSessionResponse.Header().Get("Location"))
}
}
func TestClientAPI不经过网页登录和CSRF(t *testing.T) {
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := repository.Migrate(db); err != nil {
t.Fatal(err)
}
router, err := newRouter(db)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPut, "/api/v1/client/registration", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MISSING_CLIENT_ID") {
t.Fatalf("Client API 不应被网页登录或 CSRF 拦截: %d %s", response.Code, response.Body.String())
}
}
func postFormRequest(path string, form url.Values, cookies ...*http.Cookie) *http.Request {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range cookies {
if cookie != nil {
request.AddCookie(cookie)
}
}
return request
}
func findResponseCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("响应缺少 Cookie %s", name)
return nil
}
+1 -1
View File
@@ -15,6 +15,7 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/goccy/go-yaml v1.18.0
github.com/xuri/excelize/v2 v2.9.1
golang.org/x/crypto v0.40.0
modernc.org/sqlite v1.38.0
)
@@ -50,7 +51,6 @@ require (
github.com/xuri/nfp v0.0.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
+183
View File
@@ -0,0 +1,183 @@
package web
import (
"database/sql"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
const (
authCookieName = "cmautobuy_session"
currentUserKey = "cmautobuy_current_user"
)
// SetupPage 只在数据库完全没有用户时显示首次管理员表单。
func (h *Handler) SetupPage(c *gin.Context) {
hasUsers, err := service.HasUsers(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "检查管理员初始化状态失败,数据没有被改动。刷新后重试。")
return
}
if hasUsers {
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员已经初始化,请登录"))
return
}
h.renderSetup(c, http.StatusOK, "", "admin")
}
// SetupSubmit 创建首位管理员。密码无论成功失败都不回显。
func (h *Handler) SetupSubmit(c *gin.Context) {
username := strings.TrimSpace(c.PostForm("username"))
err := service.SetupInitialAdmin(
h.db, username, c.PostForm("password"), c.PostForm("password_confirm"), time.Now())
if errors.Is(err, repository.ErrUsersAlreadyExist) {
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员已经初始化,请登录"))
return
}
if err != nil {
h.renderSetup(c, http.StatusBadRequest, err.Error(), username)
return
}
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("管理员创建成功,请登录"))
}
func (h *Handler) renderSetup(c *gin.Context, status int, message, username string) {
c.HTML(status, "auth/setup", gin.H{
"Title": "初始化管理员", "CSRFToken": csrfToken(c),
"Message": message, "Username": username,
})
}
// LoginPage 显示登录页。没有用户时先去初始化;已有有效 Session 时直接返回目标页。
func (h *Handler) LoginPage(c *gin.Context) {
hasUsers, err := service.HasUsers(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "检查登录状态失败。刷新后重试。")
return
}
if !hasUsers {
c.Redirect(http.StatusSeeOther, "/setup")
return
}
next := safeNext(c.Query("next"))
if token, err := c.Cookie(authCookieName); err == nil {
if _, authErr := service.Authenticate(h.db, token, time.Now()); authErr == nil {
c.Redirect(http.StatusSeeOther, next)
return
}
}
h.renderLogin(c, http.StatusOK, c.Query("msg"), "", next)
}
// LoginSubmit 校验凭据。用户名不存在、密码错误和账号禁用使用同一句提示。
func (h *Handler) LoginSubmit(c *gin.Context) {
username := strings.TrimSpace(c.PostForm("username"))
next := safeNext(c.PostForm("next"))
token, _, expiresAt, err := service.Login(h.db, username, c.PostForm("password"), time.Now())
if errors.Is(err, service.ErrInvalidCredentials) {
h.renderLogin(c, http.StatusUnauthorized, service.ErrInvalidCredentials.Error(), username, next)
return
}
if err != nil {
fail(c, http.StatusInternalServerError, "登录失败,但账号数据没有被修改。请稍后重试。")
return
}
setAuthCookie(c, token, expiresAt)
c.Redirect(http.StatusSeeOther, next)
}
func (h *Handler) renderLogin(c *gin.Context, status int, message, username, next string) {
c.HTML(status, "auth/login", gin.H{
"Title": "登录 Admin", "CSRFToken": csrfToken(c),
"Message": message, "Username": username, "Next": safeNext(next),
})
}
// Logout 撤销服务端 Session 并清除浏览器 Cookie。退出必须走 POST + CSRF。
func (h *Handler) Logout(c *gin.Context) {
token, _ := c.Cookie(authCookieName)
if err := service.Logout(h.db, token); err != nil {
fail(c, http.StatusInternalServerError, "退出登录失败,请刷新页面后重试。")
return
}
clearAuthCookie(c)
c.Redirect(http.StatusSeeOther, "/login?msg="+url.QueryEscape("已退出登录"))
}
// AuthRequired 只挂在 Web 业务路由组。Client API 注册在另一个组,不能经过这里。
func AuthRequired(db *sql.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token, _ := c.Cookie(authCookieName)
user, err := service.Authenticate(db, token, time.Now())
if err == nil {
c.Set(currentUserKey, user)
c.Next()
return
}
if !errors.Is(err, service.ErrUnauthenticated) {
fail(c, http.StatusInternalServerError, "检查登录状态失败。刷新页面后重试。")
c.Abort()
return
}
hasUsers, countErr := service.HasUsers(db)
if countErr != nil {
fail(c, http.StatusInternalServerError, "检查管理员初始化状态失败。刷新页面后重试。")
c.Abort()
return
}
if !hasUsers {
c.Redirect(http.StatusSeeOther, "/setup")
} else {
next := safeNext(c.Request.URL.RequestURI())
c.Redirect(http.StatusSeeOther, "/login?next="+url.QueryEscape(next))
}
c.Abort()
}
}
func currentUser(c *gin.Context) *model.User {
value, ok := c.Get(currentUserKey)
if !ok {
return nil
}
user, _ := value.(*model.User)
return user
}
// safeNext 只接受本站绝对路径,阻止登录成功后跳转到外部网站。
func safeNext(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "/shopee"
}
parsed, err := url.Parse(raw)
if err != nil || parsed.IsAbs() || parsed.Host != "" {
return "/shopee"
}
return raw
}
func setAuthCookie(c *gin.Context, token string, expiresAt time.Time) {
http.SetCookie(c.Writer, &http.Cookie{
Name: authCookieName, Value: token, Path: "/", HttpOnly: true,
Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode,
Expires: expiresAt, MaxAge: int(service.WebSessionDuration.Seconds()),
})
}
func clearAuthCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: authCookieName, Value: "", Path: "/", HttpOnly: true,
Secure: c.Request.TLS != nil, SameSite: http.SameSiteLaxMode,
Expires: time.Unix(1, 0), MaxAge: -1,
})
}
+66
View File
@@ -0,0 +1,66 @@
package web
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestSafeNext只允许本站绝对路径(t *testing.T) {
for _, test := range []struct {
raw string
want string
}{
{"/pdd?status=pending", "/pdd?status=pending"},
{"", "/shopee"},
{"https://example.com", "/shopee"},
{"//example.com/path", "/shopee"},
{"pdd", "/shopee"},
} {
if got := safeNext(test.raw); got != test.want {
t.Errorf("safeNext(%q) = %q,期望 %q", test.raw, got, test.want)
}
}
}
func TestAuthCookie安全属性(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, test := range []struct {
name string
tls bool
secure bool
}{
{"HTTP", false, false},
{"HTTPS", true, true},
} {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/login", nil)
if test.tls {
request.TLS = &tls.ConnectionState{}
}
context, _ := gin.CreateTestContext(response)
context.Request = request
setAuthCookie(context, "raw-token", time.Now().Add(12*time.Hour))
cookies := response.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("Set-Cookie 数量 = %d,期望 1", len(cookies))
}
cookie := cookies[0]
if cookie.Name != authCookieName || cookie.Value != "raw-token" || cookie.Path != "/" {
t.Errorf("Cookie 名称、值或 Path 不正确: %#v", cookie)
}
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Secure != test.secure {
t.Errorf("Cookie 安全属性不正确: %#v", cookie)
}
if cookie.MaxAge != 12*60*60 {
t.Errorf("Cookie MaxAge = %d,期望 43200", cookie.MaxAge)
}
})
}
}
+14 -5
View File
@@ -33,9 +33,17 @@ type Handler struct {
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
h := &Handler{db: db, onlineThreshold: onlineThreshold}
// CSRF 只挂在页面路由上。
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
pages := r.Group("/", CSRFMiddleware())
public := r.Group("/", CSRFMiddleware())
public.GET("/setup", h.SetupPage)
public.POST("/setup", h.SetupSubmit)
public.GET("/login", h.LoginPage)
public.POST("/login", h.LoginSubmit)
// 登录中间件只挂业务网页组,绝不能挂在整个 Engine。
pages := r.Group("/", CSRFMiddleware(), AuthRequired(db))
pages.POST("/logout", h.Logout)
// 打开根路径直接进第一个模块
pages.GET("/", func(c *gin.Context) {
@@ -80,9 +88,10 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
func page(c *gin.Context, active, title string, extra gin.H) gin.H {
data := gin.H{
"Active": active,
"Title": title,
"CSRFToken": csrfToken(c),
"Active": active,
"Title": title,
"CSRFToken": csrfToken(c),
"CurrentUser": currentUser(c),
}
for k, v := range extra {
data[k] = v
+26
View File
@@ -5,8 +5,10 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
// 五个主页面都走一次真实路由和模板渲染。
@@ -26,9 +28,31 @@ func TestMainPagesReturnOK(t *testing.T) {
t.Fatalf("组装路由失败: %v", err)
}
// 全新库的业务页必须先去初始化,不能匿名打开。
unauthenticated := httptest.NewRequest(http.MethodGet, "/shopee", nil)
unauthenticatedResponse := httptest.NewRecorder()
router.ServeHTTP(unauthenticatedResponse, unauthenticated)
if unauthenticatedResponse.Code != http.StatusSeeOther ||
unauthenticatedResponse.Header().Get("Location") != "/setup" {
t.Fatalf("全新库访问业务页应跳转 /setup,实际 %d %s",
unauthenticatedResponse.Code, unauthenticatedResponse.Header().Get("Location"))
}
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
t.Fatalf("准备测试管理员失败: %v", err)
}
token, _, _, err := service.Login(db, "admin", "test-password", time.Now())
if err != nil {
t.Fatalf("准备测试登录 Session 失败: %v", err)
}
addAuth := func(request *http.Request) {
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: token})
}
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
addAuth(request)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
@@ -39,6 +63,7 @@ func TestMainPagesReturnOK(t *testing.T) {
}
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
addAuth(sybRequest)
sybResponse := httptest.NewRecorder()
router.ServeHTTP(sybResponse, sybRequest)
for _, want := range []string{
@@ -72,6 +97,7 @@ func TestMainPagesReturnOK(t *testing.T) {
}
request := httptest.NewRequest(http.MethodGet, "/pdd/detail?id=1", nil)
addAuth(request)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
+42
View File
@@ -46,6 +46,48 @@ func ParseISO(s string) (time.Time, bool) {
return ts, true
}
// ---------- Admin 用户与网页登录 ----------
// UserRole 是 Admin 网页账号的固定角色。本阶段不做可配置 RBAC。
type UserRole string
const (
RoleAdmin UserRole = "admin"
RolePurchaser UserRole = "purchaser"
)
// UserStatus 表示账号是否允许登录。
type UserStatus string
const (
UserActive UserStatus = "active"
UserDisabled UserStatus = "disabled"
)
// User 是一个 Admin 网页账号。PasswordHash 只在认证服务内部使用,
// 模板和日志都不得输出它。
type User struct {
UserID string
Username string
PasswordHash string
Role UserRole
Status UserStatus
LastLoginAt string
PasswordChangedAt string
CreatedAt string
UpdatedAt string
}
// WebSession 是服务端保存的网页登录状态。SessionHash 是浏览器随机 Token
// 的 SHA-256,不是 Token 原文。
type WebSession struct {
SessionHash string
UserID string
ExpiresAt string
CreatedAt string
LastSeenAt string
}
// ---------- 蝦皮 ----------
// CollectStatus 是一个 **PDD 商品**的采集进度。
+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
}
+153
View File
@@ -0,0 +1,153 @@
// Admin 网页认证:首次管理员、密码校验、随机 Session 和过期判断。
package service
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const (
WebSessionDuration = 12 * time.Hour
minimumPasswordLen = 8
maxUsernameLen = 64
)
var (
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrUnauthenticated = errors.New("未登录或登录已过期")
// 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。
dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost)
)
// HasUsers 判断首次初始化入口是否已经永久关闭。
func HasUsers(db *sql.DB) (bool, error) {
count, err := repository.CountUsers(db)
return count > 0, err
}
// SetupInitialAdmin 校验表单、哈希密码并在事务中创建首位管理员。
func SetupInitialAdmin(db *sql.DB, username, password, confirmation string, now time.Time) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("用户名不能为空")
}
if len([]rune(username)) > maxUsernameLen {
return fmt.Errorf("用户名不能超过 %d 个字符", maxUsernameLen)
}
if len([]rune(password)) < minimumPasswordLen {
return fmt.Errorf("密码至少需要 %d 个字符", minimumPasswordLen)
}
// bcrypt 最多接受 72 字节。中文等字符可能占多个字节,因此不能只靠
// HTML 的 maxlength;服务端需要在哈希前给出可理解的校验错误。
if len([]byte(password)) > 72 {
return fmt.Errorf("密码不能超过 72 个字节")
}
if password != confirmation {
return fmt.Errorf("两次输入的密码不一致")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("生成密码哈希失败: %w", err)
}
userID, err := randomID("USR-", 16)
if err != nil {
return err
}
at := now.UTC().Format(model.TimeLayout)
return repository.CreateInitialAdmin(db, model.User{
UserID: userID, Username: username, PasswordHash: string(hash),
Role: model.RoleAdmin, Status: model.UserActive,
PasswordChangedAt: at, CreatedAt: at, UpdatedAt: at,
})
}
// Login 校验统一凭据并创建一个固定 12 小时有效的 Session。
// 返回的 token 原文只交给 Cookie,数据库仅保存 SHA-256。
func Login(db *sql.DB, username, password string, now time.Time) (token string, user *model.User, expiresAt time.Time, err error) {
username = strings.TrimSpace(username)
user, findErr := repository.FindUserByUsername(db, username)
hash := dummyPasswordHash
if findErr == nil {
hash = []byte(user.PasswordHash)
} else if !errors.Is(findErr, repository.ErrUserNotFound) {
return "", nil, time.Time{}, findErr
}
passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
if findErr != nil || !passwordOK || user.Status != model.UserActive {
return "", nil, time.Time{}, ErrInvalidCredentials
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", nil, time.Time{}, fmt.Errorf("生成 Web Session 失败: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(tokenBytes)
expiresAt = now.Add(WebSessionDuration).UTC()
at := now.UTC().Format(model.TimeLayout)
if err := repository.DeleteExpiredSessions(db, at); err != nil {
return "", nil, time.Time{}, err
}
if err := repository.CreateLoginSession(db, model.WebSession{
SessionHash: SessionTokenHash(token), UserID: user.UserID,
ExpiresAt: expiresAt.Format(model.TimeLayout), CreatedAt: at, LastSeenAt: at,
}, at); err != nil {
return "", nil, time.Time{}, err
}
return token, user, expiresAt, nil
}
// Authenticate 验证 Cookie Token、固定过期时间和账号状态。
func Authenticate(db *sql.DB, token string, now time.Time) (*model.User, error) {
if strings.TrimSpace(token) == "" {
return nil, ErrUnauthenticated
}
session, user, err := repository.FindUserBySessionHash(db, SessionTokenHash(token))
if errors.Is(err, repository.ErrSessionNotFound) {
return nil, ErrUnauthenticated
}
if err != nil {
return nil, err
}
expiresAt, ok := model.ParseISO(session.ExpiresAt)
if !ok || !now.UTC().Before(expiresAt) || user.Status != model.UserActive {
_ = repository.DeleteSession(db, session.SessionHash)
return nil, ErrUnauthenticated
}
return user, nil
}
// Logout 撤销当前 Token 对应的服务端 Session。空 Token 也视为成功。
func Logout(db *sql.DB, token string) error {
if token == "" {
return nil
}
return repository.DeleteSession(db, SessionTokenHash(token))
}
// SessionTokenHash 返回数据库可保存的 Token SHA-256 十六进制文本。
func SessionTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomID(prefix string, byteCount int) (string, error) {
buf := make([]byte, byteCount)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成用户编号失败: %w", err)
}
return prefix + hex.EncodeToString(buf), nil
}
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"errors"
"strings"
"sync"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"cmautobuy/admin/repository"
)
func TestSetupInitialAdmin_保存哈希且入口永久关闭(t *testing.T) {
db := newSyncTestDB(t)
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
t.Fatalf("初始化管理员失败: %v", err)
}
user, err := repository.FindUserByUsername(db, "ADMIN") // username 是 NOCASE
if err != nil {
t.Fatalf("读取管理员失败: %v", err)
}
if user.PasswordHash == "safe-password" || strings.Contains(user.PasswordHash, "safe-password") {
t.Fatal("数据库不能保存或包含明文密码")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("safe-password")); err != nil {
t.Fatalf("密码哈希不能验证原密码: %v", err)
}
if err := SetupInitialAdmin(db, "second", "other-password", "other-password", now); !errors.Is(err, repository.ErrUsersAlreadyExist) {
t.Fatalf("已有用户后初始化入口应永久关闭,实际 %v", err)
}
}
func TestSetupInitialAdmin_校验密码字符数和Bcrypt字节上限(t *testing.T) {
for _, test := range []struct {
name string
password string
want string
}{
{"少于八个字符", "1234567", "至少需要 8 个字符"},
{"超过bcrypt字节上限", strings.Repeat("密", 25), "不能超过 72 个字节"},
} {
t.Run(test.name, func(t *testing.T) {
db := newSyncTestDB(t)
err := SetupInitialAdmin(db, "admin", test.password, test.password, time.Now())
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("校验错误 = %v,期望包含 %q", err, test.want)
}
})
}
}
func TestSetupInitialAdmin_并发最多一个成功(t *testing.T) {
db := newSyncTestDB(t)
start := make(chan struct{})
errorsOut := make(chan error, 2)
var wg sync.WaitGroup
for _, username := range []string{"admin-a", "admin-b"} {
wg.Add(1)
go func(name string) {
defer wg.Done()
<-start
errorsOut <- SetupInitialAdmin(db, name, "safe-password", "safe-password", time.Now())
}(username)
}
close(start)
wg.Wait()
close(errorsOut)
successes := 0
for err := range errorsOut {
if err == nil {
successes++
continue
}
if !errors.Is(err, repository.ErrUsersAlreadyExist) {
t.Errorf("失败请求应明确提示已初始化,实际 %v", err)
}
}
if successes != 1 {
t.Fatalf("并发初始化应恰好一个成功,实际 %d", successes)
}
count, _ := repository.CountUsers(db)
if count != 1 {
t.Fatalf("并发初始化后数据库应只有一个用户,实际 %d", count)
}
}
func TestLoginAuthenticateLogout_数据库只存Token哈希(t *testing.T) {
db := newSyncTestDB(t)
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
t.Fatal(err)
}
token, user, expiresAt, err := Login(db, "admin", "safe-password", now)
if err != nil {
t.Fatalf("登录失败: %v", err)
}
if len(token) < 40 || expiresAt.Sub(now) != WebSessionDuration {
t.Fatalf("Session Token 或 12 小时过期时间不正确")
}
var stored string
if err := db.QueryRow(`SELECT session_hash FROM web_sessions`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token || stored != SessionTokenHash(token) || len(stored) != 64 {
t.Fatalf("数据库应只保存 64 位 SHA-256,stored=%q token=%q", stored, token)
}
got, err := Authenticate(db, token, now.Add(time.Hour))
if err != nil || got.UserID != user.UserID {
t.Fatalf("有效 Session 认证失败: user=%+v err=%v", got, err)
}
if err := Logout(db, token); err != nil {
t.Fatalf("退出失败: %v", err)
}
if _, err := Authenticate(db, token, now.Add(time.Hour)); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("退出后旧 Token 不应继续有效,实际 %v", err)
}
}
func TestLogin_错误凭据和禁用账号统一提示(t *testing.T) {
db := newSyncTestDB(t)
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", time.Now()); err != nil {
t.Fatal(err)
}
for name, password := range map[string]string{
"missing": "safe-password",
"admin": "wrong-password",
} {
if _, _, _, err := Login(db, name, password, time.Now()); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("%s 登录错误应统一,实际 %v", name, err)
}
}
if _, err := db.Exec(`UPDATE users SET status = 'disabled' WHERE username = 'admin'`); err != nil {
t.Fatal(err)
}
if _, _, _, err := Login(db, "admin", "safe-password", time.Now()); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("禁用账号也应使用统一提示,实际 %v", err)
}
}
func TestAuthenticate_过期Session被删除(t *testing.T) {
db := newSyncTestDB(t)
now := time.Date(2026, 8, 9, 6, 0, 0, 0, time.UTC)
if err := SetupInitialAdmin(db, "admin", "safe-password", "safe-password", now); err != nil {
t.Fatal(err)
}
token, _, _, err := Login(db, "admin", "safe-password", now)
if err != nil {
t.Fatal(err)
}
if _, err := Authenticate(db, token, now.Add(WebSessionDuration)); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("到期时刻 Session 必须失效,实际 %v", err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM web_sessions`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("过期 Session 应被清理,实际剩 %d", count)
}
}
+35 -1
View File
@@ -38,6 +38,40 @@ body {
}
.nav a:hover { background: #3a4552; color: #fff; }
.nav a.active { background: #1f6feb; color: #fff; }
.nav-user {
margin-left: auto;
color: #fff;
padding: 0 8px;
}
.nav-logout { margin: 0; }
.nav-logout button {
color: #c8cdd4;
border-color: #56616d;
background: transparent;
}
.nav-logout button:hover { color: #fff; background: #3a4552; }
/* ── 首次初始化 / 登录 ────────────────── */
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px 16px;
}
.auth-card {
width: 100%;
max-width: 420px;
padding: 24px;
background: #fff;
border: 1px solid #e1e4e8;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .08);
}
.auth-card h1 { margin: 0 0 8px; font-size: 22px; }
.auth-card form { display: grid; gap: 16px; margin-top: 20px; }
.auth-card input { width: 100%; }
.auth-card small { color: #777; }
.auth-submit { width: 100%; min-height: 38px; }
/* ── 页面主体 ─────────────────────────── */
.page { padding: 16px; }
@@ -259,7 +293,7 @@ button.primary:hover { background: #1a5fd0; }
/* 创建弹窗里的单个字段,标签在输入框上面 */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { color: #555; }
input[type="text"] {
input[type="text"], input[type="password"] {
padding: 5px 8px;
border: 1px solid #ccd1d6;
border-radius: 3px;
+31
View File
@@ -0,0 +1,31 @@
{{define "auth/login"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 采集采购管理端</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="auth-page">
<main class="auth-card">
<h1>登录 Admin</h1>
<p class="hint">登录后进入采集采购管理端。</p>
{{if .Message}}<p class="{{if eq .Message "用户名或密码错误"}}missing{{else}}hint{{end}}" role="status">{{.Message}}</p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="login-username">用户名</label>
<input id="login-username" type="text" name="username" value="{{.Username}}"
maxlength="64" required autocomplete="username" autofocus>
</div>
<div class="field">
<label for="login-password">密码</label>
<input id="login-password" type="password" name="password" maxlength="72"
required autocomplete="current-password">
</div>
<button type="submit" class="primary auth-submit">登录</button>
</form>
</main>
</body>
</html>{{end}}
+36
View File
@@ -0,0 +1,36 @@
{{define "auth/setup"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · 采集采购管理端</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="auth-page">
<main class="auth-card">
<h1>初始化管理员</h1>
<p class="hint">首次使用需要创建管理员。系统没有固定默认密码,此入口完成后永久关闭。</p>
{{if .Message}}<p class="missing" role="alert">{{.Message}}</p>{{end}}
<form method="post" action="/setup">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="field">
<label for="setup-username">用户名</label>
<input id="setup-username" type="text" name="username" value="{{.Username}}"
maxlength="64" required autocomplete="username" autofocus>
</div>
<div class="field">
<label for="setup-password">密码</label>
<input id="setup-password" type="password" name="password" minlength="8"
maxlength="72" required autocomplete="new-password">
<small>至少 8 个字符;请使用只有你知道的密码。</small>
</div>
<div class="field">
<label for="setup-password-confirm">确认密码</label>
<input id="setup-password-confirm" type="password" name="password_confirm"
minlength="8" maxlength="72" required autocomplete="new-password">
</div>
<button type="submit" class="primary auth-submit">创建管理员</button>
</form>
</main>
</body>
</html>{{end}}
+7
View File
@@ -15,6 +15,13 @@
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
{{if .CurrentUser}}
<span class="nav-user">{{.CurrentUser.Username}}</span>
<form class="nav-logout" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit">退出登录</button>
</form>
{{end}}
</nav>
<main class="page">
+2 -2
View File
@@ -95,7 +95,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 |
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
| v6(规划) | Admin 网页登录:新增 `users` 和 `web_sessions`。正式实现时只能在迁移末尾追加,不得提前改写 v1–v5。 |
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -709,7 +709,7 @@ Admin 和 Client **各有一个 SQLite,互不相通**,只通过接口交换
`[必须]` **两边的状态是两套,不要试图同步。**
Admin 只知道"发出去了 / 收到结果了",中间过程看不到,这是有意的设计。
## 12. Admin 用户和 Web Session(v6 规划)
## 12. Admin 用户和 Web Session(v6)
`users` 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。