From e0c0dac7a0b246d00ec700eda1849de202076a65 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 16:02:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=87=87=E8=B4=AD=E5=91=98=E5=BD=92=E5=B1=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/client_assignment_integration_test.go | 102 +++++++++++++ admin/handler/web/others.go | 81 +++++++++- admin/handler/web/user.go | 4 +- admin/handler/web/web.go | 5 +- admin/model/model.go | 13 ++ admin/repository/client.go | 161 ++++++++++++++++++++ admin/repository/db.go | 39 ++++- admin/repository/migrate_test.go | 25 +-- admin/service/client_test.go | 130 ++++++++++++++++ admin/service/service.go | 79 +++++++++- admin/service/user.go | 2 +- admin/static/js/app.js | 24 +++ admin/templates/client/list.html | 67 +++++++- docs/admin/01-requirements.md | 15 +- docs/admin/03-data-model.md | 34 +++++ docs/admin/05-ui-specification.md | 15 +- docs/admin/06-quality-security.md | 7 + 17 files changed, 766 insertions(+), 37 deletions(-) create mode 100644 admin/client_assignment_integration_test.go diff --git a/admin/client_assignment_integration_test.go b/admin/client_assignment_integration_test.go new file mode 100644 index 0000000..51c2be9 --- /dev/null +++ b/admin/client_assignment_integration_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/service" +) + +func Test客户端归属页面与管理员写权限(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) + } + now := time.Now() + if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", now); err != nil { + t.Fatal(err) + } + admin, _ := repository.FindUserByUsername(db, "admin") + for _, name := range []string{"buyer-a", "buyer-b"} { + if err := service.CreatePurchaser(db, admin, name, "buyer-password", "buyer-password", now); err != nil { + t.Fatal(err) + } + } + buyerA, _ := repository.FindUserByUsername(db, "buyer-a") + for _, client := range []model.Client{ + {ClientID: "client-a", Name: "采购一号机"}, + {ClientID: "client-free", Name: "未绑定机器"}, + } { + if err := service.RegisterClient(db, client, true); err != nil { + t.Fatal(err) + } + } + router, err := newRouter(db) + if err != nil { + t.Fatal(err) + } + adminToken, _, _, _ := service.Login(db, "admin", "admin-password", now) + adminPageRequest := httptest.NewRequest(http.MethodGet, "/clients", nil) + adminPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminToken}) + adminPage := httptest.NewRecorder() + router.ServeHTTP(adminPage, adminPageRequest) + if adminPage.Code != http.StatusOK { + t.Fatalf("管理员客户端页 = %d: %s", adminPage.Code, adminPage.Body.String()) + } + for _, want := range []string{"当前负责人", "绑定", "buyer-a", "buyer-b", "/clients/delete"} { + if !strings.Contains(adminPage.Body.String(), want) { + t.Errorf("管理员客户端页缺少 %q", want) + } + } + csrf := findResponseCookie(t, adminPage, "cmautobuy_csrf") + assign := postFormRequest("/clients/assign", url.Values{ + "csrf_token": {csrf.Value}, "client_id": {"client-a"}, "user_id": {buyerA.UserID}, + }, csrf) + assign.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: adminToken}) + assignResponse := httptest.NewRecorder() + router.ServeHTTP(assignResponse, assign) + if assignResponse.Code != http.StatusSeeOther || !strings.HasPrefix(assignResponse.Header().Get("Location"), "/clients?") { + t.Fatalf("管理员绑定响应 = %d %s", assignResponse.Code, assignResponse.Header().Get("Location")) + } + + buyerToken, _, _, _ := service.Login(db, "buyer-a", "buyer-password", now) + buyerPageRequest := httptest.NewRequest(http.MethodGet, "/clients", nil) + buyerPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken}) + buyerPage := httptest.NewRecorder() + router.ServeHTTP(buyerPage, buyerPageRequest) + body := buyerPage.Body.String() + if buyerPage.Code != http.StatusOK || !strings.Contains(body, "采购一号机") || + strings.Contains(body, "未绑定机器") { + t.Fatalf("采购员客户端可见范围不正确: %d %s", buyerPage.Code, body) + } + for _, forbidden := range []string{"/clients/assign", "/clients/unassign", "/clients/delete", "转交"} { + if strings.Contains(body, forbidden) { + t.Errorf("采购员只读页面不应包含 %q", forbidden) + } + } + + buyerCSRF := findResponseCookie(t, buyerPage, "cmautobuy_csrf") + forbiddenAssign := postFormRequest("/clients/assign", url.Values{ + "csrf_token": {buyerCSRF.Value}, "client_id": {"client-free"}, "user_id": {buyerA.UserID}, + }, buyerCSRF) + forbiddenAssign.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken}) + forbiddenResponse := httptest.NewRecorder() + router.ServeHTTP(forbiddenResponse, forbiddenAssign) + if forbiddenResponse.Code != http.StatusForbidden || !strings.Contains(forbiddenResponse.Body.String(), "只有管理员") { + t.Fatalf("采购员绑定应返回 403,实际 %d %s", forbiddenResponse.Code, forbiddenResponse.Body.String()) + } + rows, err := service.ListClientViewsForUser(db, buyerA, "", time.Minute) + if err != nil || len(rows) != 1 || rows[0].ClientID != "client-a" { + t.Fatalf("越权请求后归属不应变化,rows=%+v err=%v", rows, err) + } +} diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 5c11714..60b8902 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "cmautobuy/admin/config" + "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/service" "cmautobuy/admin/syb" @@ -535,8 +536,9 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) { // (没有心跳是有意的,见 docs/admin/04-client-api.md §3)。 func (h *Handler) ClientList(c *gin.Context) { keyword := c.Query("name") + actor := currentUser(c) - views, err := service.ListClientViews(h.db, keyword, h.onlineThreshold) + views, err := service.ListClientViewsForUser(h.db, actor, keyword, h.onlineThreshold) if err != nil { fail(c, http.StatusInternalServerError, "读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。") @@ -552,16 +554,85 @@ func (h *Handler) ClientList(c *gin.Context) { status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d", len(views), online, len(views)-online) if len(views) == 0 { - status = "还没有客户端。客户端第一次调用领取接口时会自动登记。" + if actor != nil && actor.IsAdmin() { + status = "还没有客户端。客户端第一次调用领取接口时会自动登记。" + } else { + status = "当前没有绑定给你的客户端,请联系管理员。" + } + } + + purchasers := []model.User{} + if actor != nil && actor.IsAdmin() { + purchasers, err = service.ListActivePurchasers(h.db, actor) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取可绑定采购员失败,数据没有被改动。请稍后重试。") + return + } } c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{ - "Keyword": keyword, - "Rows": views, - "Status": status, + "Keyword": keyword, "Rows": views, "Status": status, + "Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"), })) } +// ClientAssign 由管理员绑定或转交客户端。只改归属历史,不改 tasks。 +func (h *Handler) ClientAssign(c *gin.Context) { + changed, transferred, err := service.AssignClient(h.db, currentUser(c), + c.PostForm("client_id"), c.PostForm("user_id"), time.Now()) + if err != nil { + h.clientActionFailure(c, err, "绑定客户端失败") + return + } + message := "客户端已经绑定给该采购员,无需重复操作" + if changed { + message = "客户端绑定成功" + } + if transferred { + message = "客户端已转交,新负责人立即生效;既有任务保持不变" + } + log.Printf("client_assignment_changed actor=%s client_id=%s user_id=%s transferred=%t changed=%t", + currentUser(c).Username, c.PostForm("client_id"), c.PostForm("user_id"), transferred, changed) + redirectClients(c, message, "") +} + +// ClientUnassign 由管理员解绑客户端。前端确认用于防误触,服务端权限仍是硬边界。 +func (h *Handler) ClientUnassign(c *gin.Context) { + err := service.UnassignClient(h.db, currentUser(c), c.PostForm("client_id"), time.Now()) + if err != nil { + h.clientActionFailure(c, err, "解绑客户端失败") + return + } + log.Printf("client_assignment_ended actor=%s client_id=%s", + currentUser(c).Username, c.PostForm("client_id")) + redirectClients(c, "客户端已解绑;归属历史和既有任务均已保留", "") +} + +func (h *Handler) clientActionFailure(c *gin.Context, err error, fallback string) { + if service.IsValidationError(err) || errors.Is(err, repository.ErrClientNotFound) || + errors.Is(err, repository.ErrPurchaserNotActive) || errors.Is(err, repository.ErrClientNotAssigned) { + redirectClients(c, "", err.Error()) + return + } + fail(c, http.StatusInternalServerError, fallback+",已有归属和任务数据保持不变。刷新后重试。") +} + +func redirectClients(c *gin.Context, message, errorMessage string) { + query := url.Values{} + if message != "" { + query.Set("msg", message) + } + if errorMessage != "" { + query.Set("error", errorMessage) + } + target := "/clients" + if encoded := query.Encode(); encoded != "" { + target += "?" + encoded + } + c.Redirect(http.StatusSeeOther, target) +} + // ClientDelete 批量删除客户端。 // // 二次确认在前端做(见 static/js/app.js),这里直接删。 diff --git a/admin/handler/web/user.go b/admin/handler/web/user.go index 2cbc0ca..2a3478e 100644 --- a/admin/handler/web/user.go +++ b/admin/handler/web/user.go @@ -15,7 +15,7 @@ import ( "cmautobuy/admin/service" ) -// AdminRequired 叠加在已认证的 /users 路由上。采购员能使用业务页,但不能管理账号。 +// AdminRequired 叠加在已认证路由上。采购员能使用业务页,但不能执行管理操作。 func AdminRequired() gin.HandlerFunc { return func(c *gin.Context) { user := currentUser(c) @@ -23,7 +23,7 @@ func AdminRequired() gin.HandlerFunc { c.Next() return } - fail(c, http.StatusForbidden, "只有管理员可以访问用户管理。当前账号仍可使用其他业务页面。") + fail(c, http.StatusForbidden, "只有管理员可以执行此管理操作。当前账号仍可使用其他业务页面。") c.Abort() } } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index fa6c402..7144596 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -82,7 +82,10 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { // 5. 客户端列表 pages.GET("/clients", h.ClientList) - pages.POST("/clients/delete", h.ClientDelete) + clients := pages.Group("/clients", AdminRequired()) + clients.POST("/assign", h.ClientAssign) + clients.POST("/unassign", h.ClientUnassign) + clients.POST("/delete", h.ClientDelete) // 6. 用户管理:先经过网页登录,再叠加管理员角色校验。 users := pages.Group("/users", AdminRequired()) diff --git a/admin/model/model.go b/admin/model/model.go index bbe7e04..23b76cf 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -327,6 +327,19 @@ type Client struct { UpdatedAt string } +// ClientUserAssignment 记录客户端在一段时间内由哪位采购员负责。 +// EndedAt 为空表示当前归属;转交和解绑只结束旧记录,不删除历史。 +type ClientUserAssignment struct { + AssignmentID string + ClientID string + UserID string + StartedAt string + EndedAt string + AssignedByUserID string + EndedByUserID string + EndReason string +} + // IsOnline 判断客户端此刻算不算在线。 // // 规则很简单:最近活动时间在 threshold 之内就算在线。 diff --git a/admin/repository/client.go b/admin/repository/client.go index 672e696..2929454 100644 --- a/admin/repository/client.go +++ b/admin/repository/client.go @@ -2,12 +2,26 @@ package repository import ( "database/sql" + "errors" "fmt" "strings" "cmautobuy/admin/model" ) +var ( + ErrClientNotFound = errors.New("客户端不存在") + ErrPurchaserNotActive = errors.New("采购员不存在或已禁用") + ErrClientNotAssigned = errors.New("客户端当前未绑定采购员") +) + +// ClientWithAssignee 是客户端列表查询结果。归属为空表示尚未绑定。 +type ClientWithAssignee struct { + model.Client + AssignedUserID string + AssignedUsername string +} + // UpsertClient 登记或更新一台客户端。 // // # 名称的更新规则(两个接口不一样,这是有意的) @@ -112,6 +126,153 @@ func ListClients(db *sql.DB, keyword string) ([]model.Client, error) { return out, rows.Err() } +// ListClientsForUser 返回带当前负责人的客户端。 +// visibleUserID 为空表示管理员全量视图,否则只返回当前绑定给该用户的客户端。 +func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWithAssignee, error) { + query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package, + c.capabilities, c.last_seen_at, c.created_at, c.updated_at, + a.user_id, u.username + FROM clients c + LEFT JOIN client_user_assignments a + ON a.client_id = c.client_id AND a.ended_at IS NULL + LEFT JOIN users u ON u.user_id = a.user_id` + where := make([]string, 0, 2) + args := make([]any, 0, 3) + if visibleUserID != "" { + where = append(where, `a.user_id = ?`) + args = append(args, visibleUserID) + } + if kw := strings.TrimSpace(keyword); kw != "" { + where = append(where, `(c.name LIKE ? OR c.client_id LIKE ?)`) + like := "%" + kw + "%" + args = append(args, like, like) + } + if len(where) > 0 { + query += ` WHERE ` + strings.Join(where, ` AND `) + } + query += ` ORDER BY c.last_seen_at DESC, c.client_id` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("查询客户端归属列表失败: %w", err) + } + defer rows.Close() + out := make([]ClientWithAssignee, 0) + for rows.Next() { + var row ClientWithAssignee + var name, addr, platform, pkg, caps, userID, username sql.NullString + if err := rows.Scan(&row.ClientID, &name, &addr, &platform, &pkg, &caps, + &row.LastSeenAt, &row.CreatedAt, &row.UpdatedAt, &userID, &username); err != nil { + return nil, fmt.Errorf("读取客户端归属行失败: %w", err) + } + row.Name, row.DeviceAddress, row.Platform = name.String, addr.String, platform.String + row.PddPackage, row.Capabilities = pkg.String, caps.String + row.AssignedUserID, row.AssignedUsername = userID.String, username.String + out = append(out, row) + } + return out, rows.Err() +} + +// ListActivePurchasers 返回可成为新负责人的启用采购员。 +func ListActivePurchasers(db *sql.DB) ([]model.User, error) { + rows, err := db.Query(` + SELECT user_id, username, role, status, password_changed_at, created_at, updated_at + FROM users WHERE role = ? AND status = ? ORDER BY username`, + model.RolePurchaser, model.UserActive) + if err != nil { + return nil, fmt.Errorf("查询可绑定采购员失败: %w", err) + } + defer rows.Close() + users := make([]model.User, 0) + for rows.Next() { + var user model.User + if err := rows.Scan(&user.UserID, &user.Username, &user.Role, &user.Status, + &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt); err != nil { + return nil, fmt.Errorf("读取可绑定采购员失败: %w", err) + } + users = append(users, user) + } + return users, rows.Err() +} + +// AssignClient 原子完成首次绑定或转交。返回 changed、transferred。 +func AssignClient(db *sql.DB, assignment model.ClientUserAssignment) (bool, bool, error) { + tx, err := db.Begin() + if err != nil { + return false, false, fmt.Errorf("开始绑定客户端事务失败: %w", err) + } + defer tx.Rollback() + var exists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM clients WHERE client_id = ?`, assignment.ClientID).Scan(&exists); err != nil { + return false, false, fmt.Errorf("检查客户端失败: %w", err) + } + if exists == 0 { + return false, false, ErrClientNotFound + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE user_id = ? AND role = ? AND status = ?`, + assignment.UserID, model.RolePurchaser, model.UserActive).Scan(&exists); err != nil { + return false, false, fmt.Errorf("检查采购员失败: %w", err) + } + if exists == 0 { + return false, false, ErrPurchaserNotActive + } + + var currentID, currentUserID string + err = tx.QueryRow(`SELECT assignment_id, user_id FROM client_user_assignments + WHERE client_id = ? AND ended_at IS NULL`, assignment.ClientID).Scan(¤tID, ¤tUserID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, false, fmt.Errorf("读取当前客户端归属失败: %w", err) + } + if err == nil && currentUserID == assignment.UserID { + return false, false, nil + } + transferred := err == nil + if transferred { + if _, err := tx.Exec(`UPDATE client_user_assignments + SET ended_at = ?, ended_by_user_id = ?, end_reason = 'transfer' + WHERE assignment_id = ? AND ended_at IS NULL`, + assignment.StartedAt, assignment.AssignedByUserID, currentID); err != nil { + return false, false, fmt.Errorf("结束原客户端归属失败: %w", err) + } + } + if _, err := tx.Exec(`INSERT INTO client_user_assignments + (assignment_id, client_id, user_id, started_at, assigned_by_user_id) + VALUES (?, ?, ?, ?, ?)`, assignment.AssignmentID, assignment.ClientID, + assignment.UserID, assignment.StartedAt, assignment.AssignedByUserID); err != nil { + return false, false, fmt.Errorf("保存客户端归属失败: %w", err) + } + if err := tx.Commit(); err != nil { + return false, false, fmt.Errorf("提交客户端绑定事务失败: %w", err) + } + return true, transferred, nil +} + +// UnassignClient 原子结束当前归属,历史记录保留。 +func UnassignClient(db *sql.DB, clientID, actorUserID, endedAt string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始解绑客户端事务失败: %w", err) + } + defer tx.Rollback() + res, err := tx.Exec(`UPDATE client_user_assignments + SET ended_at = ?, ended_by_user_id = ?, end_reason = 'unbind' + WHERE client_id = ? AND ended_at IS NULL`, endedAt, actorUserID, clientID) + if err != nil { + return fmt.Errorf("结束客户端归属失败: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("读取解绑结果失败: %w", err) + } + if n == 0 { + return ErrClientNotAssigned + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交客户端解绑事务失败: %w", err) + } + return nil +} + // DeleteClients 批量删除客户端。返回实际删除的条数。 func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) { if len(clientIDs) == 0 { diff --git a/admin/repository/db.go b/admin/repository/db.go index 154921d..9bbb7fa 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 = 6 +const schemaVersion = 7 // migrationV4 给 PDD 商品增加店铺名。 // @@ -326,6 +326,33 @@ var migrationV6 = []string{ `CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at);`, } +// migrationV7 记录客户端当前负责人及完整转交历史,见工单 #54。 +// client_id 故意不加 clients 外键:客户端记录可删除后由同一稳定编号重新登记, +// 归属和审计历史不能因此丢失。 +var migrationV7 = []string{ + `CREATE TABLE client_user_assignments ( + assignment_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + assigned_by_user_id TEXT NOT NULL, + ended_by_user_id TEXT, + end_reason TEXT CHECK (end_reason IS NULL OR end_reason IN ('unbind', 'transfer')), + FOREIGN KEY (user_id) REFERENCES users(user_id), + FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id), + FOREIGN KEY (ended_by_user_id) REFERENCES users(user_id), + CHECK ((ended_at IS NULL AND ended_by_user_id IS NULL AND end_reason IS NULL) + OR (ended_at IS NOT NULL AND ended_by_user_id IS NOT NULL AND end_reason IS NOT NULL)) + );`, + `CREATE UNIQUE INDEX idx_client_assignment_current + ON client_user_assignments(client_id) WHERE ended_at IS NULL;`, + `CREATE INDEX idx_client_assignment_user + ON client_user_assignments(user_id, ended_at, client_id);`, + `CREATE INDEX idx_client_assignment_history + ON client_user_assignments(client_id, started_at DESC);`, +} + // Migrate 把数据库升到最新版本。 // 已经是最新的就什么都不做,可以重复调用。 func Migrate(db *sql.DB) error { @@ -396,6 +423,14 @@ func Migrate(db *sql.DB) error { if err := runSQLMigration(db, 6, migrationV6); err != nil { return err } + reached = 6 + } + + // v7 是纯追加的客户端归属历史表和索引。 + if reached < 7 { + if err := runSQLMigration(db, 7, migrationV7); err != nil { + return err + } } return nil @@ -857,7 +892,7 @@ var requiredTables = []string{ "syb_orders", "sku_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", - "users", "web_sessions", + "users", "web_sessions", "client_user_assignments", } // requiredColumns 只列出不能靠“表存在”发现的关键追加列。 diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go index 3a3b81b..e2e5cb0 100644 --- a/admin/repository/migrate_test.go +++ b/admin/repository/migrate_test.go @@ -593,7 +593,7 @@ func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T } } -func TestMigrate_v6新增用户和WebSession表(t *testing.T) { +func TestMigrate_v6用户表与v7客户端归属表均存在(t *testing.T) { for _, c := range []struct { name string db *sql.DB @@ -608,7 +608,7 @@ func TestMigrate_v6新增用户和WebSession表(t *testing.T) { } } tables := existingTableSet(t, c.db) - for _, table := range []string{"users", "web_sessions"} { + for _, table := range []string{"users", "web_sessions", "client_user_assignments"} { if !tables[table] { t.Errorf("%s:迁移后应该有表 %s", c.name, table) } @@ -617,21 +617,21 @@ func TestMigrate_v6新增用户和WebSession表(t *testing.T) { 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) + if version != schemaVersion { + t.Errorf("%s user_version = %d,期望 %d", c.name, version, schemaVersion) } } } -// #52:网页登录上线后显式覆盖每一个已发布版本起点,证明 Client 现实库 -// 不会因为 v6 的用户表而卡在中间版本。v2 的两种历史结构另由上面的收敛 +// #52/#54:显式覆盖每一个已发布版本起点,证明 Client 现实库 +// 不会因为 v6/v7 的新增表而卡在中间版本。v2 的两种历史结构另由上面的收敛 // 测试持续覆盖;这里验证顺序发布的 v1-v5 路径。 -func TestMigrate_v1到v5均可升级到v6(t *testing.T) { +func TestMigrate_v1到v5均可升级到最新版本(t *testing.T) { for version := 1; version <= 5; version++ { t.Run(fmt.Sprintf("v%d", version), func(t *testing.T) { db := newPublishedVersionDB(t, version) if err := Migrate(db); err != nil { - t.Fatalf("v%d 迁移到 v6 失败: %v", version, err) + t.Fatalf("v%d 迁移到最新版本失败: %v", version, err) } if err := CheckSchema(db); err != nil { t.Fatalf("v%d 迁移后 schema 自检失败: %v", version, err) @@ -640,8 +640,8 @@ func TestMigrate_v1到v5均可升级到v6(t *testing.T) { if err := db.QueryRow(`PRAGMA user_version`).Scan(&gotVersion); err != nil { t.Fatal(err) } - if gotVersion != 6 { - t.Fatalf("v%d 迁移后 user_version = %d,期望 6", version, gotVersion) + if gotVersion != schemaVersion { + t.Fatalf("v%d 迁移后 user_version = %d,期望 %d", version, gotVersion, schemaVersion) } }) } @@ -1120,7 +1120,7 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) { if err := migrateV3(db); err != nil { t.Fatalf("准备 v3 数据库失败: %v", err) } - // 故意跳过 v4(不加 shop_name),但把 v5/v6 补上——否则 CheckSchema 会先 + // 故意跳过 v4(不加 shop_name),但把 v5-v7 补上——否则 CheckSchema 会先 // 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。 if err := runSQLMigration(db, 5, migrationV5); err != nil { t.Fatalf("准备 v5 数据库失败: %v", err) @@ -1128,6 +1128,9 @@ func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) { if err := runSQLMigration(db, 6, migrationV6); err != nil { t.Fatalf("准备 v6 数据库失败: %v", err) } + if err := runSQLMigration(db, 7, migrationV7); err != nil { + t.Fatalf("准备 v7 数据库失败: %v", err) + } err := CheckSchema(db) if err == nil || !strings.Contains(err.Error(), "shop_name") { diff --git a/admin/service/client_test.go b/admin/service/client_test.go index 8cf10fe..5f9c59d 100644 --- a/admin/service/client_test.go +++ b/admin/service/client_test.go @@ -2,6 +2,7 @@ package service import ( "database/sql" + "errors" "path/filepath" "sync" "testing" @@ -252,6 +253,135 @@ func TestDeleteClients_批量删除(t *testing.T) { } } +// ── 采购员归属(#54)────────────────────────────────── + +func prepareClientAssignmentUsers(t *testing.T, db *sql.DB) (*model.User, *model.User, *model.User) { + t.Helper() + now := time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC) + admin := prepareAdminUser(t, db, now) + for _, username := range []string{"buyer-a", "buyer-b"} { + if err := CreatePurchaser(db, admin, username, "buyer-password", "buyer-password", now); err != nil { + t.Fatalf("准备采购员 %s 失败: %v", username, err) + } + } + a, _ := repository.FindUserByUsername(db, "buyer-a") + b, _ := repository.FindUserByUsername(db, "buyer-b") + return admin, a, b +} + +func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true) + RegisterClient(db, model.Client{ClientID: "c-2", Name: "二号机"}, true) + now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + for _, clientID := range []string{"c-1", "c-2"} { + changed, transferred, err := AssignClient(db, admin, clientID, buyerA.UserID, now) + if err != nil || !changed || transferred { + t.Fatalf("绑定 %s 失败: changed=%t transferred=%t err=%v", clientID, changed, transferred, err) + } + } + + aRows, err := ListClientViewsForUser(db, buyerA, "", time.Minute) + if err != nil || len(aRows) != 2 { + t.Fatalf("采购员 A 应看到两台客户端,rows=%+v err=%v", aRows, err) + } + bRows, err := ListAssignableClients(db, buyerB, time.Minute) + if err != nil || len(bRows) != 0 { + t.Fatalf("采购员 B 不应看到 A 的客户端,rows=%+v err=%v", bRows, err) + } + adminRows, _ := ListClientViewsForUser(db, admin, "", time.Minute) + if len(adminRows) != 2 || adminRows[0].AssignedUsername != "buyer-a" { + t.Fatalf("管理员应看到全部客户端及负责人,实际 %+v", adminRows) + } +} + +func TestClientAssignment_转交解绑保留审计且不改任务(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true) + insertTask(t, db, "TASK-KEEP", "c-1") + t1 := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + t2 := t1.Add(time.Hour) + t3 := t2.Add(time.Hour) + if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, t1); err != nil { + t.Fatal(err) + } + changed, transferred, err := AssignClient(db, admin, "c-1", buyerB.UserID, t2) + if err != nil || !changed || !transferred { + t.Fatalf("转交失败: changed=%t transferred=%t err=%v", changed, transferred, err) + } + if err := UnassignClient(db, admin, "c-1", t3); err != nil { + t.Fatalf("解绑失败: %v", err) + } + + rows, err := db.Query(`SELECT user_id, started_at, ended_at, assigned_by_user_id, + ended_by_user_id, end_reason FROM client_user_assignments + WHERE client_id = 'c-1' ORDER BY started_at`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + type audit struct{ user, started, ended, assignedBy, endedBy, reason string } + audits := make([]audit, 0, 2) + for rows.Next() { + var a audit + if err := rows.Scan(&a.user, &a.started, &a.ended, &a.assignedBy, &a.endedBy, &a.reason); err != nil { + t.Fatal(err) + } + audits = append(audits, a) + } + if len(audits) != 2 || audits[0].reason != "transfer" || audits[1].reason != "unbind" { + t.Fatalf("归属历史不完整: %+v", audits) + } + for _, a := range audits { + if a.assignedBy != admin.UserID || a.endedBy != admin.UserID || a.ended == "" { + t.Errorf("操作管理员或结束时间未记录: %+v", a) + } + } + var assignedClient string + var status model.TaskStatus + if err := db.QueryRow(`SELECT assigned_client, status FROM tasks WHERE task_id = 'TASK-KEEP'`).Scan(&assignedClient, &status); err != nil { + t.Fatal(err) + } + if assignedClient != "c-1" || status != model.TaskAssigned { + t.Errorf("归属变化不应修改任务,client=%q status=%q", assignedClient, status) + } +} + +func TestClientAssignment_权限禁用账号唯一约束及重新登记(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true) + now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + if _, _, err := AssignClient(db, buyerA, "c-1", buyerB.UserID, now); !errors.Is(err, ErrAdminRequired) { + t.Fatalf("采购员不应有绑定权限,实际 %v", err) + } + if err := ChangeUserStatus(db, admin, buyerB.UserID, model.UserDisabled, now); err != nil { + t.Fatal(err) + } + if _, _, err := AssignClient(db, admin, "c-1", buyerB.UserID, now); !errors.Is(err, repository.ErrPurchaserNotActive) { + t.Fatalf("禁用采购员不应成为目标,实际 %v", err) + } + if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, now); err != nil { + t.Fatal(err) + } + // 数据库索引是最后一道并发保护,直接插入第二条当前归属必须失败。 + if _, err := db.Exec(`INSERT INTO client_user_assignments + (assignment_id, client_id, user_id, started_at, assigned_by_user_id) + VALUES ('duplicate', 'c-1', ?, ?, ?)`, buyerA.UserID, model.NowISO(), admin.UserID); err == nil { + t.Fatal("同一客户端插入第二个当前负责人应该被唯一索引拒绝") + } + if n, err := DeleteClients(db, []string{"c-1"}); err != nil || n != 1 { + t.Fatalf("删除客户端失败: n=%d err=%v", n, err) + } + RegisterClient(db, model.Client{ClientID: "c-1", Name: "重新登记的一号机"}, true) + views, err := ListClientViewsForUser(db, buyerA, "", time.Minute) + if err != nil || len(views) != 1 || views[0].AssignedUsername != "buyer-a" { + t.Fatalf("同编号重新登记后应恢复当前归属,views=%+v err=%v", views, err) + } +} + // ── 领取任务 ─────────────────────────────────────────── func TestClaimNextTask_没有任务返回nil(t *testing.T) { diff --git a/admin/service/service.go b/admin/service/service.go index 7526f52..e35f25e 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -10,6 +10,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "cmautobuy/admin/model" @@ -118,12 +119,33 @@ func TouchClient(db *sql.DB, clientID string) error { // Status 是**算出来的**,数据库里没有这个字段。 type ClientView struct { model.Client - Status string + Status string + AssignedUserID string + AssignedUsername string } // ListClientViews 查客户端列表,并把在线状态算出来。 func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]ClientView, error) { - clients, err := repository.ListClients(db, keyword) + return listClientViews(db, keyword, "", threshold) +} + +// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。 +func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) { + if actor == nil { + return nil, ErrUnauthenticated + } + visibleUserID := "" + if !actor.IsAdmin() { + if actor.Role != model.RolePurchaser { + return nil, ErrAdminRequired + } + visibleUserID = actor.UserID + } + return listClientViews(db, keyword, visibleUserID, threshold) +} + +func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) { + clients, err := repository.ListClientsForUser(db, keyword, visibleUserID) if err != nil { return nil, err } @@ -132,13 +154,62 @@ func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]Cli views := make([]ClientView, 0, len(clients)) for _, c := range clients { views = append(views, ClientView{ - Client: c, - Status: c.StatusText(now, threshold), + Client: c.Client, + Status: c.StatusText(now, threshold), + AssignedUserID: c.AssignedUserID, + AssignedUsername: c.AssignedUsername, }) } return views, nil } +// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。 +// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。 +func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) { + return ListClientViewsForUser(db, actor, "", threshold) +} + +// ListActivePurchasers 返回管理员可选择的绑定目标。 +func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) { + if actor == nil || !actor.IsAdmin() { + return nil, ErrAdminRequired + } + return repository.ListActivePurchasers(db) +} + +// AssignClient 由管理员完成首次绑定或转交。 +func AssignClient(db *sql.DB, actor *model.User, clientID, purchaserUserID string, now time.Time) (bool, bool, error) { + if actor == nil || !actor.IsAdmin() { + return false, false, ErrAdminRequired + } + clientID = strings.TrimSpace(clientID) + purchaserUserID = strings.TrimSpace(purchaserUserID) + if clientID == "" || purchaserUserID == "" { + return false, false, invalidInput("客户端和采购员都不能为空") + } + id, err := randomID("CA-", 16) + if err != nil { + return false, false, err + } + at := now.UTC().Format(model.TimeLayout) + return repository.AssignClient(db, model.ClientUserAssignment{ + AssignmentID: id, ClientID: clientID, UserID: purchaserUserID, + StartedAt: at, AssignedByUserID: actor.UserID, + }) +} + +// UnassignClient 由管理员结束当前归属,不改动任何任务。 +func UnassignClient(db *sql.DB, actor *model.User, clientID string, now time.Time) error { + if actor == nil || !actor.IsAdmin() { + return ErrAdminRequired + } + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return invalidInput("客户端不能为空") + } + return repository.UnassignClient(db, clientID, actor.UserID, now.UTC().Format(model.TimeLayout)) +} + // DeleteClients 批量删除,返回实际删除条数。 func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) { return repository.DeleteClients(db, clientIDs) diff --git a/admin/service/user.go b/admin/service/user.go index 6979966..8ff5d18 100644 --- a/admin/service/user.go +++ b/admin/service/user.go @@ -11,7 +11,7 @@ import ( "cmautobuy/admin/repository" ) -var ErrAdminRequired = errors.New("只有管理员可以管理用户") +var ErrAdminRequired = errors.New("只有管理员可以执行此管理操作") // UserListResult 是用户管理页的分页结果。 type UserListResult struct { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index ef5a6d5..0dc6627 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -111,6 +111,29 @@ }); } + /* 客户端列表共用绑定/转交弹窗。这里只回填行数据,权限和写入由服务端处理。 */ + function setupClientAssignmentModal() { + var modal = document.getElementById("assign-client-modal"); + if (!modal) return; + var idInput = modal.querySelector("[data-assign-client-id-input]"); + var nameLabel = modal.querySelector("[data-assign-client-label]"); + var select = modal.querySelector("select[name=user_id]"); + var title = modal.querySelector("#assign-client-title"); + var note = modal.querySelector("[data-assignment-note]"); + document.querySelectorAll("[data-assign-client-id]").forEach(function (button) { + button.addEventListener("click", function () { + var currentName = button.getAttribute("data-assign-username") || ""; + idInput.value = button.getAttribute("data-assign-client-id") || ""; + nameLabel.textContent = button.getAttribute("data-assign-client-name") || ""; + if (select) select.value = button.getAttribute("data-assign-user-id") || ""; + title.textContent = currentName ? "转交客户端" : "绑定客户端"; + note.textContent = currentName + ? "当前负责人是“" + currentName + "”。保存后新负责人立即生效;既有任务不变。" + : "绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。"; + }); + }); + } + /* ── 弹窗 ────────────────────────────────── 弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。 不要在这里拼业务数据——价格格式、规格顺序都是业务规则, @@ -220,6 +243,7 @@ setupConfirmDelete(); setupConfirmSubmit(); setupUserResetModal(); + setupClientAssignmentModal(); setupModals(); setupRowDetail(); setupCaptchaRefresh(); diff --git a/admin/templates/client/list.html b/admin/templates/client/list.html index 4f99491..cc13d1d 100644 --- a/admin/templates/client/list.html +++ b/admin/templates/client/list.html @@ -8,42 +8,64 @@ -
+ {{if .CurrentUser.IsAdmin}} -
+ {{end}} +{{if .Message}}

{{.Message}}

{{end}} +{{if .Error}}{{end}} +
- + {{if .CurrentUser.IsAdmin}}{{end}} + + {{if .CurrentUser.IsAdmin}}{{end}} {{range .Rows}} - + {{end}} {{/* 状态是算出来的,不是数据库字段。必须有文字,不能只靠颜色 */}} + + {{if $.CurrentUser.IsAdmin}}{{end}} {{else}} - {{end}} @@ -56,5 +78,36 @@ 本项目有意不做心跳,见 docs/admin/04-client-api.md §3。

+{{if .CurrentUser.IsAdmin}} + +{{end}} + {{template "footer" .}} {{end}} diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index a9cf12a..e0820b7 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -295,9 +295,10 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 ### 4.5 客户端列表模块 -**顶部工具条:** 客户端名称搜索框、搜索按钮、删除按钮。 +**顶部工具条:** 客户端名称搜索框、搜索按钮;管理员另有删除按钮。 -**中间表格:** 勾选、名称、序列号、状态、最近活动时间、更新时间。 +**中间表格:** 名称、序列号、状态、当前负责人、最近活动时间、更新时间; +管理员另有勾选和操作列。 **注册方式:** @@ -308,6 +309,15 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 最近活动时间在 N 分钟内算"在线",否则"离线"。`[建议]` N 默认 10 分钟。 - 名称由客户端上报,操作员可以在 Admin 这边改成好记的名字。 +**采购员归属:** + +- 一个采购员可以绑定多台客户端;一台客户端同一时间最多绑定一个采购员,也允许未绑定。 +- 只有管理员能绑定、转交、解绑和删除客户端;采购员只读看到当前绑定给自己的客户端。 +- 转交和解绑保留负责人、操作管理员、开始/结束时间等完整历史。 +- 归属只影响 Web 可见范围和采购任务创建时的客户端候选,不改变 Client 四接口, + 也不改动已分配、领取或执行中的任务。 +- 新绑定目标必须是启用中的采购员。已禁用采购员的历史不删除。 + **底部状态条:** 在线 / 离线数量统计。 ## 5. 创建采购任务的校验 @@ -402,6 +412,7 @@ MVP 包含: - 第一次启动时初始化一个管理员; - Admin 网页登录、退出和 Session; - 管理员创建、禁用和重置采购员账号。 +- 管理员绑定、转交和解绑客户端;采购员只读查看自己的客户端。 MVP 之后: diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index f2967a8..0830859 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -96,6 +96,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、 | v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 | | v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 | | v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 | +| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 | **v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`, 这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句 @@ -689,6 +690,8 @@ shopee_products ──1:N──→ shopee_skus (skus_json 里是所有规格和价格) syb_orders ──创建──→ tasks ──分配──→ clients + ↑ +users(采购员)──1:N 当前归属──────────┘ ``` 两条关联都可以变,这是有意的: @@ -753,3 +756,34 @@ CREATE INDEX idx_web_sessions_expiry ON web_sessions(expires_at); - Session 使用固定过期时间,MVP 默认 12 小时,不做复杂刷新令牌。 - 退出登录、密码重置或账号禁用时,删除该用户对应的 Session 记录。 - 过期 Session 可以在登录、退出或定期维护时清理,不需要后台常驻线程。 + +## 13. `client_user_assignments` 客户端负责人历史(v7) + +`clients` 是执行任务的软件实例,`users` 是登录 Admin 的人,两者生命周期不同, +因此保持两张独立实体表,用归属历史表连接,不能合并字段。 + +```sql +CREATE TABLE client_user_assignments ( + assignment_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + assigned_by_user_id TEXT NOT NULL, + ended_by_user_id TEXT, + end_reason TEXT CHECK (end_reason IS NULL OR end_reason IN ('unbind', 'transfer')), + FOREIGN KEY (user_id) REFERENCES users(user_id), + FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id), + FOREIGN KEY (ended_by_user_id) REFERENCES users(user_id) +); + +CREATE UNIQUE INDEX idx_client_assignment_current + ON client_user_assignments(client_id) WHERE ended_at IS NULL; +``` + +- `ended_at IS NULL` 表示当前归属;部分唯一索引保证一台客户端最多一个当前负责人。 +- 首次绑定只新增记录;转交在同一事务结束旧记录并新增记录;解绑只结束旧记录。 +- `assigned_by_user_id` / `ended_by_user_id` 都是执行操作的管理员,不是目标采购员。 +- `client_id` 故意不设指向 `clients` 的外键:客户端清单允许删除后由同一稳定编号 + 重新登记,归属和审计历史不能随临时清单记录丢失。 +- 归属记录不参与 Client API 的登记和领取判断,也不更新 `tasks.assigned_client`。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 39b97c0..1d6c98e 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -609,9 +609,11 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD ## 8. 客户端列表页 -工具条:`名称 [____] [搜索] [删除]` +管理员工具条:`名称 [____] [搜索] [删除]`;采购员不显示删除按钮。 -表格列:☐ / 名称 / 序列号 / 状态 / 最近活动 / 更新时间 +管理员表格列:☐ / 名称 / 序列号 / 状态 / 当前负责人 / 最近活动 / 更新时间 / 操作 + +采购员表格列:名称 / 序列号 / 状态 / 当前负责人 / 最近活动 / 更新时间 - `[必须]` **状态是算出来的**:`最近活动` 在 N 分钟内为"在线",否则"离线"。 `[建议]` N 默认 10 分钟。 @@ -619,6 +621,13 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD **客户端上报的名称不再覆盖它**。 - `[建议]` 界面上说明一句:"客户端执行长任务期间可能显示为离线,属正常现象。" 因为没有心跳,这是已知且接受的取舍(见 [04](04-client-api.md) §3)。 +- `[必须]` 管理员看到全部客户端;采购员只看到当前绑定给自己的客户端,页面只读。 +- `[必须]` 未绑定显示文字“未绑定”,不能只靠颜色或留空表达。 +- 管理员操作列:未绑定显示“绑定”;已绑定显示“转交”和“解绑”。 +- 绑定/转交共用弹窗,负责人下拉框只列启用中的采购员。转交时明确显示当前负责人。 +- 解绑前二次确认,说明采购员会立即看不到客户端,但归属历史和既有任务都会保留。 +- 弹窗说明:“只改变负责人和网页可见范围,不影响 Client 接口或既有任务。” +- 采购员没有客户端时显示“请联系管理员绑定”,不显示容易误解为系统无数据的通用空状态。 ### 8.1 首次管理员初始化与登录 @@ -675,6 +684,8 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD | 校验不通过 | 保留用户输入,**焦点移到第一个错误字段**,就近显示错误 | | 批量操作部分失败 | 列出失败项和原因,不要只说"部分失败" | | 删除 | 二次确认框,写明"将删除 N 条,不可恢复" | +| 客户端绑定/转交 | 弹窗确认目标采购员;成功后刷新负责人并显示结果 | +| 客户端解绑 | 二次确认,写明可见范围立即变化、历史和既有任务保留 | `[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。 堆栈写日志。 diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 163e7c7..189ea8f 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -31,6 +31,8 @@ - 密码哈希校验:正确密码成功,错误密码失败,数据库不出现明文密码; - 角色校验:管理员可以管理用户,采购员访问用户管理返回 `403`; - 最后管理员保护:不能禁用最后一个有效管理员。 +- 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整; +- 归属权限:采购员只看到自己的客户端,不能绑定、解绑或删除;禁用采购员不能成为新目标。 `[必须]` 导入相关的测试用 `admin/testdata/` 下的**小样本**(几十行), 不要读完整报表。 @@ -68,6 +70,8 @@ - 未登录访问业务页面跳转登录页,登录后正常渲染; - 退出、Session 过期、密码重置和账号禁用后不能继续访问; - Client API 不返回登录页或 302 重定向。 +- 管理员客户端页显示全量和负责人;采购员客户端页只读且只显示自己的客户端; +- 绑定/转交弹窗有明确标签和焦点,解绑有二次确认,操作结果可被辅助技术读到。 ### 2.4 契约测试 @@ -107,6 +111,7 @@ - `[必须]` 登录 Cookie 设置 `HttpOnly`、`SameSite=Lax`、`Path=/`;HTTPS 部署时设置 `Secure`。 - `[必须]` Session 默认 12 小时过期;退出、密码重置和账号禁用立即撤销对应 Session。 - `[必须]` `/setup`、`/login`、`/logout` 和用户管理写操作都保留 CSRF 防护。 +- `[必须]` 客户端绑定、转交、解绑和删除只能由管理员执行,并保留 CSRF 防护。 - `[必须]` Web 登录中间件只保护 HTML 路由,不得覆盖 `/api/v1/client/*`。 - `[建议]` 对连续登录失败做简单限速;错误提示不区分用户名不存在和密码错误。 @@ -149,6 +154,7 @@ - `client_registered` - `admin_initialized`、`user_login_succeeded`、`user_login_failed` - `user_created`、`user_disabled`、`user_enabled`、`user_password_reset` +- `client_assignment_changed`、`client_assignment_ended`(只记录管理员用户名和相关 ID) `[必须]` **不得记录** token、密码、Cookie,也不要把完整请求体无脑打进日志。 @@ -178,6 +184,7 @@ | 11 | 登录与角色测试通过 | 初始化、登录、退出、禁用、最后管理员保护 | 开发者 | | 12 | Client API 回归通过 | 四接口不重定向、不返回 HTML,契约测试全绿 | 开发者 | | 13 | Session 安全属性正确 | 检查 Cookie 属性、过期和撤销 | 开发者 | +| 14 | 客户端归属正确 | 唯一当前负责人、历史、角色可见范围、既有任务不变 | 开发者 | 第 10 项开发者**不要自己判断放行**,把包名和许可证类型报给项目负责人。
名称 序列号 状态当前负责人 最近活动 更新时间操作
+ {{if $.CurrentUser.IsAdmin}} - {{.Name}} {{.ClientID}}{{.Status}}{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}未绑定{{end}} {{.LastSeenAt}} {{.UpdatedAt}} +
+ + {{if .AssignedUserID}}
+ + + +
{{end}} +
+
- 还没有客户端。
+
+ {{if $.CurrentUser.IsAdmin}}还没有客户端。
客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。 + {{else}}当前没有绑定给你的客户端。
如需使用客户端,请联系管理员绑定。{{end}}