feat: 增加客户端采购员归属管理 (#54)

This commit is contained in:
chengma
2026-08-09 16:02:25 +08:00
parent 0181170457
commit e0c0dac7a0
17 changed files with 766 additions and 37 deletions
+102
View File
@@ -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)
}
}
+75 -4
View File
@@ -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 {
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),这里直接删。
+2 -2
View File
@@ -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()
}
}
+4 -1
View File
@@ -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())
+13
View File
@@ -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 之内就算在线。
+161
View File
@@ -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(&currentID, &currentUserID)
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 {
+37 -2
View File
@@ -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 只列出不能靠“表存在”发现的关键追加列。
+14 -11
View File
@@ -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") {
+130
View File
@@ -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) {
+73 -2
View File
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
@@ -119,11 +120,32 @@ func TouchClient(db *sql.DB, clientID string) error {
type ClientView struct {
model.Client
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,
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)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"cmautobuy/admin/repository"
)
var ErrAdminRequired = errors.New("只有管理员可以管理用户")
var ErrAdminRequired = errors.New("只有管理员可以执行此管理操作")
// UserListResult 是用户管理页的分页结果。
type UserListResult struct {
+24
View File
@@ -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();
+60 -7
View File
@@ -8,42 +8,64 @@
<button type="submit">搜索</button>
</form>
<form class="inline" id="client-delete-form" method="post" action="/clients/delete" data-confirm-delete>
{{if .CurrentUser.IsAdmin}}<form class="inline" id="client-delete-form" method="post" action="/clients/delete" data-confirm-delete>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="danger" data-need-checked>删除</button>
</form>
</form>{{end}}
</div>
{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-check"><input type="checkbox" data-check-all></th>
{{if .CurrentUser.IsAdmin}}<th class="col-check"><input type="checkbox" data-check-all aria-label="全选客户端"></th>{{end}}
<th>名称</th>
<th>序列号</th>
<th>状态</th>
<th>当前负责人</th>
<th>最近活动</th>
<th>更新时间</th>
{{if .CurrentUser.IsAdmin}}<th>操作</th>{{end}}
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="col-check">
{{if $.CurrentUser.IsAdmin}}<td class="col-check">
<input type="checkbox" name="ids" value="{{.ClientID}}" form="client-delete-form">
</td>
</td>{{end}}
<td>{{.Name}}</td>
<td>{{.ClientID}}</td>
{{/* 状态是算出来的,不是数据库字段。必须有文字,不能只靠颜色 */}}
<td>{{.Status}}</td>
<td>{{if .AssignedUsername}}{{.AssignedUsername}}{{else}}<span class="hint">未绑定</span>{{end}}</td>
<td>{{.LastSeenAt}}</td>
<td>{{.UpdatedAt}}</td>
{{if $.CurrentUser.IsAdmin}}<td>
<div class="row-actions">
<button type="button" data-modal-open="assign-client-modal"
data-assign-client-id="{{.ClientID}}" data-assign-client-name="{{.Name}}"
data-assign-user-id="{{.AssignedUserID}}" data-assign-username="{{.AssignedUsername}}">
{{if .AssignedUserID}}转交{{else}}绑定{{end}}
</button>
{{if .AssignedUserID}}<form method="post" action="/clients/unassign"
data-confirm-submit="确定解绑客户端“{{.Name}}”吗?采购员“{{.AssignedUsername}}”将立即看不到它;归属历史和既有任务会保留。">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="client_id" value="{{.ClientID}}">
<button type="submit" class="danger">解绑</button>
</form>{{end}}
</div>
</td>{{end}}
</tr>
{{else}}
<tr class="empty">
<td colspan="6">
还没有客户端。<br>
<td colspan="{{if $.CurrentUser.IsAdmin}}8{{else}}6{{end}}">
{{if $.CurrentUser.IsAdmin}}还没有客户端。<br>
<small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small>
{{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}}
</td>
</tr>
{{end}}
@@ -56,5 +78,36 @@
本项目有意不做心跳,见 docs/admin/04-client-api.md §3。
</p>
{{if .CurrentUser.IsAdmin}}
<div class="modal-backdrop" id="assign-client-modal" hidden>
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="assign-client-title">
<div class="modal-head">
<h2 id="assign-client-title">绑定客户端</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/clients/assign">
<div class="modal-body form-stack">
<p>客户端:<strong data-assign-client-label></strong></p>
<div class="field">
<label for="assign-user-id">负责人</label>
<select id="assign-user-id" name="user_id" required autofocus {{if not .Purchasers}}disabled{{end}}>
<option value="">请选择采购员</option>
{{range .Purchasers}}<option value="{{.UserID}}">{{.Username}}</option>{{end}}
</select>
{{if not .Purchasers}}<small class="missing">没有启用中的采购员,请先到用户管理创建或启用账号。</small>{{end}}
</div>
<p class="hint" data-assignment-note>绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。</p>
<input type="hidden" name="client_id" data-assign-client-id-input>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary" {{if not .Purchasers}}disabled{{end}}>保存绑定</button>
</div>
</form>
</div>
</div>
{{end}}
{{template "footer" .}}
{{end}}
+13 -2
View File
@@ -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 之后:
+34
View File
@@ -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`。
+13 -2
View File
@@ -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 的错误堆栈贴到页面上。
堆栈写日志。
+7
View File
@@ -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 项开发者**不要自己判断放行**,把包名和许可证类型报给项目负责人。