feat: 增加客户端采购员归属管理 (#54)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),这里直接删。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 之内就算在线。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+37
-2
@@ -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 只列出不能靠“表存在”发现的关键追加列。
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
var ErrAdminRequired = errors.New("只有管理员可以管理用户")
|
||||
var ErrAdminRequired = errors.New("只有管理员可以执行此管理操作")
|
||||
|
||||
// UserListResult 是用户管理页的分页结果。
|
||||
type UserListResult struct {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 之后:
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 的错误堆栈贴到页面上。
|
||||
堆栈写日志。
|
||||
|
||||
@@ -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 项开发者**不要自己判断放行**,把包名和许可证类型报给项目负责人。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user