From 0a7db404a14d4765bc73415d74dfdc4a44de17da Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 13:36:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Admin=20=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=8E=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=99=BB=E5=BD=95=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/auth_integration_test.go | 159 +++++++++++++++++++++++ admin/go.mod | 2 +- admin/handler/web/auth.go | 183 +++++++++++++++++++++++++++ admin/handler/web/auth_test.go | 66 ++++++++++ admin/handler/web/web.go | 19 ++- admin/main_test.go | 26 ++++ admin/model/model.go | 42 ++++++ admin/repository/db.go | 37 +++++- admin/repository/migrate_test.go | 37 +++++- admin/repository/user.go | 150 ++++++++++++++++++++++ admin/service/auth.go | 153 ++++++++++++++++++++++ admin/service/auth_test.go | 164 ++++++++++++++++++++++++ admin/static/css/app.css | 36 +++++- admin/templates/auth/login.html | 31 +++++ admin/templates/auth/setup.html | 36 ++++++ admin/templates/partials/header.html | 7 + docs/admin/03-data-model.md | 4 +- 17 files changed, 1140 insertions(+), 12 deletions(-) create mode 100644 admin/auth_integration_test.go create mode 100644 admin/handler/web/auth.go create mode 100644 admin/handler/web/auth_test.go create mode 100644 admin/repository/user.go create mode 100644 admin/service/auth.go create mode 100644 admin/service/auth_test.go create mode 100644 admin/templates/auth/login.html create mode 100644 admin/templates/auth/setup.html diff --git a/admin/auth_integration_test.go b/admin/auth_integration_test.go new file mode 100644 index 0000000..dd5f335 --- /dev/null +++ b/admin/auth_integration_test.go @@ -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 +} diff --git a/admin/go.mod b/admin/go.mod index 08eb7b3..d724fcb 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -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 diff --git a/admin/handler/web/auth.go b/admin/handler/web/auth.go new file mode 100644 index 0000000..8bf828a --- /dev/null +++ b/admin/handler/web/auth.go @@ -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, + }) +} diff --git a/admin/handler/web/auth_test.go b/admin/handler/web/auth_test.go new file mode 100644 index 0000000..4ec353b --- /dev/null +++ b/admin/handler/web/auth_test.go @@ -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) + } + }) + } +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 8ea837c..2302815 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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 diff --git a/admin/main_test.go b/admin/main_test.go index 39d6d64..855b841 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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 { diff --git a/admin/model/model.go b/admin/model/model.go index baaaca3..32b5f78 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -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 商品**的采集进度。 diff --git a/admin/repository/db.go b/admin/repository/db.go index 4b29be6..154921d 100644 --- a/admin/repository/db.go +++ b/admin/repository/db.go @@ -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 只列出不能靠“表存在”发现的关键追加列。 diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go index bda6c94..4414b04 100644 --- a/admin/repository/migrate_test.go +++ b/admin/repository/migrate_test.go @@ -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") { diff --git a/admin/repository/user.go b/admin/repository/user.go new file mode 100644 index 0000000..c844690 --- /dev/null +++ b/admin/repository/user.go @@ -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 +} diff --git a/admin/service/auth.go b/admin/service/auth.go new file mode 100644 index 0000000..5790736 --- /dev/null +++ b/admin/service/auth.go @@ -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 +} diff --git a/admin/service/auth_test.go b/admin/service/auth_test.go new file mode 100644 index 0000000..c961088 --- /dev/null +++ b/admin/service/auth_test.go @@ -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) + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index bc50512..baf97e1 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; diff --git a/admin/templates/auth/login.html b/admin/templates/auth/login.html new file mode 100644 index 0000000..4bb389c --- /dev/null +++ b/admin/templates/auth/login.html @@ -0,0 +1,31 @@ +{{define "auth/login"}} + + + + +{{.Title}} · 采集采购管理端 + + + +
+

登录 Admin

+

登录后进入采集采购管理端。

+ {{if .Message}}

{{.Message}}

{{end}} +
+ + +
+ + +
+
+ + +
+ +
+
+ +{{end}} diff --git a/admin/templates/auth/setup.html b/admin/templates/auth/setup.html new file mode 100644 index 0000000..952c28d --- /dev/null +++ b/admin/templates/auth/setup.html @@ -0,0 +1,36 @@ +{{define "auth/setup"}} + + + + +{{.Title}} · 采集采购管理端 + + + +
+

初始化管理员

+

首次使用需要创建管理员。系统没有固定默认密码,此入口完成后永久关闭。

+ {{if .Message}}{{end}} +
+ +
+ + +
+
+ + + 至少 8 个字符;请使用只有你知道的密码。 +
+
+ + +
+ +
+
+ +{{end}} diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index b9b5e1e..f18c58f 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -15,6 +15,13 @@ 顺运宝数据 采集采购 客户端列表 + {{if .CurrentUser}} + {{.CurrentUser.Username}} + + {{end}}
diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index e047697..f2967a8 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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` 保存网页登录账号。没有公开注册,第一条管理员记录只能由首次初始化流程创建。