1) 管理员颜色 - users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效 - 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝 2) 客户归属打手 - customers 表新增 assignee_id(可空,指向 users) - 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派 - 语义:一个客户一个打手,该客户之后的所有单子都归这个打手 - 停用的账户不出现在打手候选里;未指派显示灰色「未指派」 3) 日历 - 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段) - 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响 - 未指派的任务用中性灰段落 4) 待办与大屏 - 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字 - 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色 - 加急样式改为红底,避免与打手色左边框冲突 数据库兼容性: - 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移 - 原有表结构、字段、页面接口不变;未指派的存量数据照常工作 测试: - 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选 - 旧库迁移测试扩展:老 users / customers 表自动补列并可写 - 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过 - 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
265 lines
6.9 KiB
Go
265 lines
6.9 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"daili/internal/pw"
|
|
)
|
|
|
|
// User 管理员账户。
|
|
type User struct {
|
|
ID int64
|
|
Username string
|
|
DisplayName string
|
|
IsActive bool
|
|
Color string
|
|
CreatedAt string
|
|
LastLoginAt string
|
|
}
|
|
|
|
// AssigneePalette 是新建管理员时自动分配的颜色(区分度较高的深色系)。
|
|
var AssigneePalette = []string{
|
|
"#2F5D50", "#8C4A3C", "#3E5C8A", "#7A5AA6",
|
|
"#A66A1E", "#2C7A7B", "#8A3B5C", "#5B6B2F",
|
|
}
|
|
|
|
// NormalizeColor 校验并规范化 #RRGGBB;空值返回空。
|
|
func NormalizeColor(c string) (string, error) {
|
|
c = strings.TrimSpace(c)
|
|
if c == "" {
|
|
return "", nil
|
|
}
|
|
if len(c) != 7 || c[0] != '#' {
|
|
return "", errors.New("颜色格式应为 #RRGGBB")
|
|
}
|
|
for _, r := range c[1:] {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
|
return "", errors.New("颜色格式应为 #RRGGBB")
|
|
}
|
|
}
|
|
return strings.ToUpper(c), nil
|
|
}
|
|
|
|
func scanUser(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*User, error) {
|
|
var u User
|
|
var active int
|
|
var last sql.NullString
|
|
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last, &u.Color); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
u.IsActive = active == 1
|
|
u.LastLoginAt = last.String
|
|
return &u, nil
|
|
}
|
|
|
|
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')`
|
|
|
|
// CountUsers 返回账户数量。
|
|
func (db *DB) CountUsers() (int, error) {
|
|
var n int
|
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// CreateUser 新建账户。
|
|
func (db *DB) CreateUser(username, displayName, password string) (*User, error) {
|
|
hash, err := pw.Hash(password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := nowStamp()
|
|
color := db.nextAssigneeColor()
|
|
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at)
|
|
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
|
|
if err != nil {
|
|
if isUnique(err) {
|
|
return nil, ErrDuplicate
|
|
}
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, CreatedAt: now}, nil
|
|
}
|
|
|
|
// nextAssigneeColor 依据现有账户数循环取调色板,尽量不重复。
|
|
func (db *DB) nextAssigneeColor() string {
|
|
n, _ := db.CountUsers()
|
|
return AssigneePalette[n%len(AssigneePalette)]
|
|
}
|
|
|
|
// SetUserColor 设置管理员颜色(空字符串表示清除)。
|
|
func (db *DB) SetUserColor(userID int64, color string) error {
|
|
c, err := NormalizeColor(color)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := db.sql.Exec(`UPDATE users SET color=? WHERE id=?`, c, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetCustomerAssignee 把客户指派给某个打手;userID=0 表示取消指派。
|
|
func (db *DB) SetCustomerAssignee(customerID, userID int64) error {
|
|
var v any
|
|
if userID > 0 {
|
|
if _, err := db.UserByID(userID); err != nil {
|
|
return errors.New("打手不存在")
|
|
}
|
|
v = userID
|
|
}
|
|
res, err := db.sql.Exec(`UPDATE customers SET assignee_id=?, updated_at=? WHERE id=?`, v, nowStamp(), customerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ActiveUsers 返回在用的管理员(打手候选)。
|
|
func (db *DB) ActiveUsers() ([]User, error) {
|
|
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users WHERE is_active=1 ORDER BY id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []User{}
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Authenticate 校验用户名口令。
|
|
func (db *DB) Authenticate(username, password string) (*User, error) {
|
|
var hash string
|
|
var active int
|
|
var id int64
|
|
err := db.sql.QueryRow(`SELECT id, password_hash, is_active FROM users WHERE username=?`, username).
|
|
Scan(&id, &hash, &active)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if active != 1 || !pw.Verify(password, hash) {
|
|
return nil, ErrNotFound
|
|
}
|
|
_, _ = db.sql.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, nowStamp(), id)
|
|
return db.UserByID(id)
|
|
}
|
|
|
|
// UserByID 按 ID 取账户。
|
|
func (db *DB) UserByID(id int64) (*User, error) {
|
|
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
|
|
}
|
|
|
|
// UserByUsername 按用户名取账户。
|
|
func (db *DB) UserByUsername(name string) (*User, error) {
|
|
return scanUser(db.sql.QueryRow(`SELECT `+userCols+` FROM users WHERE username=?`, name))
|
|
}
|
|
|
|
// ListUsers 列出账户。
|
|
func (db *DB) ListUsers() ([]User, error) {
|
|
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users ORDER BY id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []User
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdatePassword 修改口令。
|
|
func (db *DB) UpdatePassword(userID int64, password string) error {
|
|
hash, err := pw.Hash(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
|
|
return err
|
|
}
|
|
|
|
// SetUserActive 启用/停用账户。
|
|
func (db *DB) SetUserActive(userID int64, active bool) error {
|
|
v := 0
|
|
if active {
|
|
v = 1
|
|
}
|
|
_, err := db.sql.Exec(`UPDATE users SET is_active=? WHERE id=?`, v, userID)
|
|
return err
|
|
}
|
|
|
|
// ---------- 会话 ----------
|
|
|
|
// CreateSession 新建登录会话。
|
|
func (db *DB) CreateSession(token string, userID int64, ttl time.Duration, ip string) error {
|
|
now := time.Now()
|
|
_, err := db.sql.Exec(`INSERT INTO sessions(token,user_id,created_at,expires_at,ip) VALUES(?,?,?,?,?)`,
|
|
token, userID, now.Format("2006-01-02 15:04:05"), now.Add(ttl).Format("2006-01-02 15:04:05"), ip)
|
|
return err
|
|
}
|
|
|
|
// SessionUser 通过令牌取账户,过期则返回 ErrNotFound。
|
|
func (db *DB) SessionUser(token string) (*User, error) {
|
|
var userID int64
|
|
var expires string
|
|
err := db.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, token).Scan(&userID, &expires)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
exp, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.Local)
|
|
if err != nil || exp.Before(time.Now()) {
|
|
_ = db.DeleteSession(token)
|
|
return nil, ErrNotFound
|
|
}
|
|
u, err := db.UserByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !u.IsActive {
|
|
return nil, ErrNotFound
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// DeleteSession 删除会话。
|
|
func (db *DB) DeleteSession(token string) error {
|
|
_, err := db.sql.Exec(`DELETE FROM sessions WHERE token=?`, token)
|
|
return err
|
|
}
|
|
|
|
// PurgeSessions 清理过期会话。
|
|
func (db *DB) PurgeSessions() {
|
|
_, _ = db.sql.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowStamp())
|
|
}
|