feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入

This commit is contained in:
AskaEth committed 2026-09-09 19:51:50 +08:00
1 parent f3813171d3
commit 72462792ec
39 files changed
+10001 -28

No files matched your search

+74
View File
@@ -286,6 +286,80 @@ type eventSettingsStandings struct {
ShowIndividualBoard bool `json:"showIndividualBoard"`
}
// ---------- 用户管理 ----------
type userListItemDTO struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Active bool `json:"active"`
MustChangePassword bool `json:"mustChangePassword"`
LastLoginAt *string `json:"lastLoginAt"`
}
// resetPasswordResultDTO 里的 password 是唯一一次明文,落库只存哈希。
type resetPasswordResultDTO struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Generated bool `json:"generated"`
MustChangePassword bool `json:"mustChangePassword"`
}
// ---------- 变更历史 ----------
type revisionPageDTO struct {
Items []revisionListItemDTO `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type revisionListItemDTO struct {
ID int64 `json:"id"`
ParentID *int64 `json:"parentId"`
EventID int64 `json:"eventId"`
EventName string `json:"eventName"`
Action string `json:"action"`
ActorID *int64 `json:"actorId"`
ActorName string `json:"actorName"`
Message *string `json:"message"`
CreatedAt string `json:"createdAt"`
ChangedCount int `json:"changedCount"`
}
type revisionDetailDTO struct {
revisionListItemDTO
Snapshot []resultDTO `json:"snapshot"`
Diff revisionDiffDTO `json:"diff"`
}
type revisionDiffDTO struct {
Results []revisionResultChangeDTO `json:"results"`
Teams []revisionTeamDeltaDTO `json:"teams"`
}
type revisionResultChangeDTO struct {
ResultID int64 `json:"resultId"`
EntryID int64 `json:"entryId"`
TeamID *int64 `json:"teamId"`
Change string `json:"change"`
Fields []string `json:"fields,omitempty"`
Before *resultDTO `json:"before"`
After *resultDTO `json:"after"`
}
type revisionTeamDeltaDTO struct {
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
PointsBefore float64 `json:"pointsBefore"`
PointsAfter float64 `json:"pointsAfter"`
PointsDelta float64 `json:"pointsDelta"`
ChangedResults int `json:"changedResults"`
}
// ---------- domain → DTO ----------
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
@@ -0,0 +1,554 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 变更历史(recorder/admin 可读) ----------
// 成绩变化的三种形态,与前端展示文案一一对应。
const (
revisionChangeAdded = "added"
revisionChangeRemoved = "removed"
revisionChangeChanged = "changed"
)
func (s *server) listRevisionsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
limit := intQuery(r, "limit", 50)
if limit < 1 {
limit = 50
}
if limit > 200 {
limit = 200
}
offset := intQuery(r, "offset", 0)
if offset < 0 {
offset = 0
}
return s.listRevisions(r.Context(), eventID, limit, offset)
}
func (s *server) getRevisionDetailHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getRevisionDetail(r.Context(), id)
}
// revisionRow 是 result_revisions 的一行,含解析后的快照。
type revisionRow struct {
id int64
parentID *int64
eventID int64
eventName string
action string
actorID *int64
message *string
createdAt string
snapshot []resultDTO
}
const revisionColumns = `r.id, r.parent_id, r.event_id, r.action, r.actor_id, r.message, r.created_at, r.snapshot_json`
func scanRevision(row interface{ Scan(...any) error }) (revisionRow, error) {
var (
out revisionRow
parentID sql.NullInt64
actorID sql.NullInt64
message sql.NullString
snapshot string
eventName sql.NullString
)
if err := row.Scan(&out.id, &parentID, &out.eventID, &out.action, &actorID, &message,
&out.createdAt, &snapshot, &eventName); err != nil {
return out, err
}
out.parentID = i64Ptr(parentID)
out.actorID = i64Ptr(actorID)
out.message = strPtr(message)
out.eventName = eventName.String
if err := json.Unmarshal([]byte(snapshot), &out.snapshot); err != nil {
return out, internal(err)
}
return out, nil
}
func (s *server) listRevisions(ctx context.Context, eventID int64, limit, offset int) (revisionPageDTO, error) {
where := []string{"1 = 1"}
args := []any{}
if eventID > 0 {
where = append(where, "r.event_id = ?")
args = append(args, eventID)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM result_revisions r`+clause, args...).Scan(&total); err != nil {
return revisionPageDTO{}, internal(err)
}
query := `SELECT ` + revisionColumns + `, COALESCE(e.name, '')
FROM result_revisions r
LEFT JOIN events e ON e.id = r.event_id` + clause + ` ORDER BY r.id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return revisionPageDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
list := make([]revisionRow, 0)
for rows.Next() {
row, err := scanRevision(rows)
if err != nil {
return revisionPageDTO{}, internal(err)
}
list = append(list, row)
}
if err := rows.Err(); err != nil {
return revisionPageDTO{}, internal(err)
}
items, err := s.revisionListItems(ctx, list)
if err != nil {
return revisionPageDTO{}, err
}
return revisionPageDTO{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
// revisionListItems 为每行补上操作人姓名与「与父节点相比的成绩变化条数」。
func (s *server) revisionListItems(ctx context.Context, list []revisionRow) ([]revisionListItemDTO, error) {
actorIDs := make([]int64, 0, len(list))
for _, row := range list {
if row.actorID != nil {
actorIDs = append(actorIDs, *row.actorID)
}
}
names, err := s.userNames(ctx, actorIDs)
if err != nil {
return nil, err
}
items := make([]revisionListItemDTO, 0, len(list))
for _, row := range list {
parent, _, err := s.parentSnapshot(ctx, row)
if err != nil {
return nil, err
}
items = append(items, revisionListItem(row, names, len(diffSnapshots(parent, row.snapshot))))
}
return items, nil
}
func revisionListItem(row revisionRow, names map[int64]string, changedCount int) revisionListItemDTO {
item := revisionListItemDTO{
ID: row.id,
ParentID: row.parentID,
EventID: row.eventID,
EventName: row.eventName,
Action: row.action,
ActorID: row.actorID,
Message: row.message,
CreatedAt: row.createdAt,
ChangedCount: changedCount,
}
if row.actorID != nil {
item.ActorName = names[*row.actorID]
}
return item
}
func (s *server) getRevision(ctx context.Context, id int64) (revisionRow, error) {
row, err := scanRevision(s.ev.QueryRowContext(ctx,
`SELECT `+revisionColumns+`, COALESCE(e.name, '')
FROM result_revisions r
LEFT JOIN events e ON e.id = r.event_id
WHERE r.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return row, notFound("变更记录不存在")
}
if err != nil {
return row, internal(err)
}
return row, nil
}
func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetailDTO, error) {
row, err := s.getRevision(ctx, id)
if err != nil {
return revisionDetailDTO{}, err
}
parent, _, err := s.parentSnapshot(ctx, row)
if err != nil {
return revisionDetailDTO{}, err
}
changes := diffSnapshots(parent, row.snapshot)
entryTeams, err := s.entryTeams(ctx, changeEntryIDs(changes))
if err != nil {
return revisionDetailDTO{}, err
}
for i := range changes {
if teamID := entryTeams[changes[i].EntryID]; teamID != 0 {
changes[i].TeamID = &teamID
}
}
// 分数增减依赖当前计分配置重算快照;配置被删改时降级为只返回成绩差异。
teams, err := s.revisionTeamDeltas(ctx, row.eventID, parent, row.snapshot, changes, entryTeams)
if err != nil {
s.logger.Warn("变更历史分数差异计算失败", "revisionId", id, "eventId", row.eventID, "err", err)
teams = []revisionTeamDeltaDTO{}
}
names, err := s.userNames(ctx, actorIDList(row.actorID))
if err != nil {
return revisionDetailDTO{}, err
}
return revisionDetailDTO{
revisionListItemDTO: revisionListItem(row, names, len(changes)),
Snapshot: row.snapshot,
Diff: revisionDiffDTO{Results: changes, Teams: teams},
}, nil
}
// parentSnapshot 返回父节点快照:优先用显式 parent_id;
// 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。
func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) {
var (
raw string
err error
)
if row.parentID != nil {
err = s.ev.QueryRowContext(ctx, `SELECT snapshot_json FROM result_revisions WHERE id = ?`, *row.parentID).Scan(&raw)
} else {
err = s.ev.QueryRowContext(ctx,
`SELECT snapshot_json FROM result_revisions WHERE event_id = ? AND id < ? ORDER BY id DESC LIMIT 1`,
row.eventID, row.id).Scan(&raw)
}
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, internal(err)
}
var snapshot []resultDTO
if err := json.Unmarshal([]byte(raw), &snapshot); err != nil {
return nil, false, internal(err)
}
return snapshot, true, nil
}
func actorIDList(id *int64) []int64 {
if id == nil {
return nil
}
return []int64{*id}
}
func changeEntryIDs(changes []revisionResultChangeDTO) []int64 {
out := make([]int64, 0, len(changes))
for _, c := range changes {
out = append(out, c.EntryID)
}
return out
}
// diffSnapshots 按成绩 id 对比两份快照,输出新增/删除/修改。
func diffSnapshots(parent, current []resultDTO) []revisionResultChangeDTO {
parentByID := make(map[int64]resultDTO, len(parent))
for _, r := range parent {
parentByID[r.ID] = r
}
currentByID := make(map[int64]resultDTO, len(current))
for _, r := range current {
currentByID[r.ID] = r
}
out := make([]revisionResultChangeDTO, 0)
for _, r := range current {
after := r
old, ok := parentByID[r.ID]
if !ok {
out = append(out, revisionResultChangeDTO{
ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeAdded, After: &after,
})
continue
}
before := old
if fields := changedResultFields(before, after); len(fields) > 0 {
out = append(out, revisionResultChangeDTO{
ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeChanged,
Fields: fields, Before: &before, After: &after,
})
}
}
for _, r := range parent {
if _, ok := currentByID[r.ID]; ok {
continue
}
before := r
out = append(out, revisionResultChangeDTO{
ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeRemoved, Before: &before,
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ResultID < out[j].ResultID })
return out
}
func changedResultFields(a, b resultDTO) []string {
fields := make([]string, 0, 4)
if !intPtrEqual(a.Rank, b.Rank) {
fields = append(fields, "rank")
}
if a.ResultText != b.ResultText {
fields = append(fields, "resultText")
}
if !f64PtrEqual(a.ResultValue, b.ResultValue) {
fields = append(fields, "resultValue")
}
if a.Unit != b.Unit {
fields = append(fields, "unit")
}
if a.IsTie != b.IsTie {
fields = append(fields, "isTie")
}
if !f64PtrEqual(a.PointsOverride, b.PointsOverride) {
fields = append(fields, "pointsOverride")
}
if a.Status != b.Status {
fields = append(fields, "status")
}
if a.State != b.State {
fields = append(fields, "state")
}
if !strPtrEqual(a.Note, b.Note) {
fields = append(fields, "note")
}
return fields
}
func intPtrEqual(a, b *int) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func f64PtrEqual(a, b *float64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func strPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
// revisionTeamDeltas 用快照重算受影响单位的分数增减。
// 快照里没有单位与积分,只能借当前计分配置重算;仅用于展示,不参与任何计算。
func (s *server) revisionTeamDeltas(ctx context.Context, eventID int64, parent, current []resultDTO, changes []revisionResultChangeDTO, entryTeams map[int64]int64) ([]revisionTeamDeltaDTO, error) {
before, err := s.snapshotTeamPoints(ctx, eventID, parent)
if err != nil {
return nil, err
}
after, err := s.snapshotTeamPoints(ctx, eventID, current)
if err != nil {
return nil, err
}
counts := make(map[int64]int)
for _, c := range changes {
if teamID := entryTeams[c.EntryID]; teamID != 0 {
counts[teamID]++
}
}
// 成绩本身没变、但积分因重算而变的单位也要列出来。
for teamID, points := range after {
if points != before[teamID] {
if _, ok := counts[teamID]; !ok {
counts[teamID] = 0
}
}
}
teams, err := s.teamMap(ctx)
if err != nil {
return nil, err
}
out := make([]revisionTeamDeltaDTO, 0, len(counts))
for teamID, n := range counts {
t := teams[teamID]
out = append(out, revisionTeamDeltaDTO{
TeamID: teamID,
TeamCode: t.Code,
TeamName: t.Name,
PointsBefore: before[teamID],
PointsAfter: after[teamID],
PointsDelta: after[teamID] - before[teamID],
ChangedResults: n,
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].TeamID < out[j].TeamID })
return out, nil
}
// snapshotTeamPoints 把一份成绩快照按当前通道/模板重算成「单位 → 总分」。
func (s *server) snapshotTeamPoints(ctx context.Context, eventID int64, snapshot []resultDTO) (map[int64]float64, error) {
out := make(map[int64]float64)
ev, err := loadEventQ(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
categories, err := loadCategoriesQ(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
channels, err := loadChannelsQ(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
profileIDs := make(map[int64]bool, len(channels))
for _, ch := range channels {
profileIDs[ch.ProfileID] = true
}
profiles, err := loadProfilesQ(ctx, s.ev, profileIDs)
if err != nil {
return nil, err
}
entries, err := loadEntriesQ(ctx, s.ev, eventID, 0)
if err != nil {
return nil, err
}
bonus, err := loadRecordBonusQ(ctx, s.ev)
if err != nil {
return nil, err
}
breaks, err := loadRecordBreaksQ(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
results := make([]domain.Result, 0, len(snapshot))
for _, r := range snapshot {
results = append(results, resultDTOToDomain(r))
}
for _, cat := range categories {
score, err := domain.ScoreEvent(domain.EventScoreInput{
Event: ev,
Category: cat,
Channels: channels,
Profiles: profiles,
Entries: entries,
Results: results,
RecordBonus: &bonus,
RecordBreaks: breaks,
})
if err != nil {
return nil, mapDomainError(err)
}
for _, l := range score.Ledger {
out[l.TeamID] += l.Points
}
}
return out, nil
}
func resultDTOToDomain(r resultDTO) domain.Result {
out := domain.Result{
ID: r.ID,
EventID: r.EventID,
EntryID: r.EntryID,
ResultText: r.ResultText,
ResultValue: r.ResultValue,
Unit: r.Unit,
IsTie: r.IsTie,
PointsOverride: r.PointsOverride,
Status: domain.ResultStatus(r.Status),
State: domain.ResultState(r.State),
}
if r.Rank != nil {
out.Rank = *r.Rank
}
if r.Note != nil {
out.Note = *r.Note
}
return out
}
func (s *server) entryTeams(ctx context.Context, ids []int64) (map[int64]int64, error) {
out := make(map[int64]int64)
if len(ids) == 0 {
return out, nil
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, team_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var entryID, teamID int64
if err := rows.Scan(&entryID, &teamID); err != nil {
return nil, internal(err)
}
out[entryID] = teamID
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return out, nil
}
// userNames 从系统库取用户显示名(result_revisions.actor_id 跨库,不能 JOIN)。
func (s *server) userNames(ctx context.Context, ids []int64) (map[int64]string, error) {
out := make(map[int64]string, len(ids))
if len(ids) == 0 {
return out, nil
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.sys.QueryContext(ctx,
`SELECT id, display_name FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return nil, internal(err)
}
out[id] = name
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return out, nil
}
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
+241
View File
@@ -0,0 +1,241 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// ---------- 用户管理(仅 admin,见 devdocs/08 第 2 节) ----------
func (s *server) listUsersHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listUsers(r.Context())
}
func (s *server) createUserHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in createUserInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createUser(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "user.create", "user", created.ID, nil, created)
return created, nil
}
func (s *server) updateUserHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in updateUserInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getUser(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateUser(r.Context(), id, in, middlewareUserID(r))
if err != nil {
return nil, err
}
s.audit(r.Context(), "user.update", "user", id, before, updated)
return updated, nil
}
func (s *server) resetUserPasswordHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in resetUserPasswordInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.resetUserPassword(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "user.reset_password", "user", id, nil, map[string]any{
"username": result.Username,
"generated": result.Generated,
})
return result, nil
}
const userColumns = `id, username, display_name, role, active, must_change_password, last_login_at`
func scanUser(row interface{ Scan(...any) error }) (userListItemDTO, error) {
var (
u userListItemDTO
lastLogin sql.NullString
)
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.Active, &u.MustChangePassword, &lastLogin); err != nil {
return u, err
}
u.LastLoginAt = strPtr(lastLogin)
return u, nil
}
func (s *server) listUsers(ctx context.Context) ([]userListItemDTO, error) {
rows, err := s.sys.QueryContext(ctx, `SELECT `+userColumns+` FROM users ORDER BY id`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
items := make([]userListItemDTO, 0)
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, internal(err)
}
items = append(items, u)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return items, nil
}
func (s *server) getUser(ctx context.Context, id int64) (userListItemDTO, error) {
u, err := scanUser(s.sys.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return u, notFound("用户不存在")
}
if err != nil {
return u, internal(err)
}
return u, nil
}
// userRole 只接受落库的两种角色;guest 是「未登录」,不能建也不能改出来。
func userRole(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case auth.RoleRecorder.String():
return auth.RoleRecorder.String(), nil
case auth.RoleAdmin.String():
return auth.RoleAdmin.String(), nil
default:
return "", badRequest("role 只能是 recorder 或 admin", raw)
}
}
func (s *server) createUser(ctx context.Context, in createUserInput) (userListItemDTO, error) {
username := strings.TrimSpace(in.Username)
if username == "" {
return userListItemDTO{}, badRequest("username 必填", nil)
}
role, err := userRole(in.Role)
if err != nil {
return userListItemDTO{}, err
}
if len(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
displayName := strings.TrimSpace(in.DisplayName)
if displayName == "" {
displayName = username
}
// 先占位插入再交给认证服务写哈希,避免在 httpapi 里复制 bcrypt 成本配置。
res, err := s.sys.ExecContext(ctx,
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, '!', ?, ?, 1, 1)`, username, displayName, role)
if isUniqueViolation(err) {
return userListItemDTO{}, conflict("用户名已存在")
}
if err != nil {
return userListItemDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return userListItemDTO{}, internal(err)
}
if err := s.auth.SetPassword(ctx, username, in.Password, true); err != nil {
// 占位行没有可用哈希,改密失败就删掉,避免留下登不进去的僵尸用户。
_, _ = s.sys.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return userListItemDTO{}, internal(err)
}
return s.getUser(ctx, id)
}
func (s *server) updateUser(ctx context.Context, id int64, in updateUserInput, actorID int64) (userListItemDTO, error) {
before, err := s.getUser(ctx, id)
if err != nil {
return before, err
}
role := before.Role
if in.Role != nil {
role, err = userRole(*in.Role)
if err != nil {
return before, err
}
}
displayName := before.DisplayName
if in.DisplayName != nil {
displayName = strings.TrimSpace(*in.DisplayName)
if displayName == "" {
return before, badRequest("displayName 不能为空", nil)
}
}
active := before.Active
if in.Active != nil {
active = *in.Active
}
// 管理员把自己降级或停用会当场失去管理入口,无法再改回来,直接拒绝。
if id == actorID && (role != auth.RoleAdmin.String() || !active) {
return before, badRequest("不能把自己降级或停用", nil)
}
if _, err := s.sys.ExecContext(ctx,
`UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = datetime('now') WHERE id = ?`,
displayName, role, active, id); err != nil {
return before, internal(err)
}
if !active {
// 停用必须连带踢掉已登录会话,否则旧 Cookie 还能继续写。
if err := s.auth.RevokeUserSessions(ctx, id); err != nil {
return before, internal(err)
}
}
return s.getUser(ctx, id)
}
func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPasswordInput) (resetPasswordResultDTO, error) {
u, err := s.getUser(ctx, id)
if err != nil {
return resetPasswordResultDTO{}, err
}
password := in.Password
generated := false
if password == "" {
password, err = auth.RandomPassword(16)
if err != nil {
return resetPasswordResultDTO{}, internal(err)
}
generated = true
} else if len(password) < 8 {
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
}
// SetPassword 会作废该用户全部会话,被重置的人必须重新登录。
if err := s.auth.SetPassword(ctx, u.Username, password, true); err != nil {
return resetPasswordResultDTO{}, internal(err)
}
return resetPasswordResultDTO{
ID: u.ID,
Username: u.Username,
Password: password,
Generated: generated,
MustChangePassword: true,
}, nil
}
+24
View File
@@ -106,3 +106,27 @@ type categoriesPayload struct {
type channelsPayload struct {
Channels []channelDTO `json:"channels"`
}
// ---------- 用户管理 ----------
type createUserInput struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
RequestID string `json:"requestId"`
}
// updateUserInput 只带需要改的字段:nil 表示保持原值。
type updateUserInput struct {
DisplayName *string `json:"displayName"`
Role *string `json:"role"`
Active *bool `json:"active"`
RequestID string `json:"requestId"`
}
// resetUserPasswordInput 的 password 留空时由服务端随机生成。
type resetUserPasswordInput struct {
Password string `json:"password"`
RequestID string `json:"requestId"`
}
+9 -3
View File
@@ -584,10 +584,16 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin
if err != nil {
return internal(err)
}
// 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。
var parentID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil {
return internal(err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, '[]', ?, ?)`,
eventID, action, string(snapshot), nullID(actorID), message)
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
parentID, eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
}
+10
View File
@@ -207,6 +207,16 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 用户管理(系统配置,仅 admin;guest 读 403、写 401)
mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler))
mux.HandleFunc("POST /api/users", s.write(auth.RoleAdmin, s.createUserHandler))
mux.HandleFunc("PUT /api/users/{id}", s.write(auth.RoleAdmin, s.updateUserHandler))
mux.HandleFunc("POST /api/users/{id}/password", s.write(auth.RoleAdmin, s.resetUserPasswordHandler))
// 变更历史(recorder 及以上可读)
mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler))
mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler))
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
@@ -0,0 +1,288 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 用户管理是系统配置:guest 读 403、写 401,recorder 一律 403(见 devdocs/08 第 2 节)。
func TestUsersAdminOnly(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
guest, _ := env.client()
status, body := env.do(guest, http.MethodGet, "/api/users", nil, false)
if status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读用户列表应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
}
status, body = env.do(guest, http.MethodPost, "/api/users", map[string]any{
"username": "hacker", "role": "admin", "password": "password-1",
}, false)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 建用户应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(guest, http.MethodPost, "/api/users/1/password", map[string]any{}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 重置密码应 401,得到 %d", status)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, body := env.do(client, http.MethodGet, "/api/users", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 读用户列表应 403,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "someone", "role": "recorder", "password": "password-1",
}, true); status != http.StatusForbidden {
t.Fatalf("recorder 建用户应 403,得到 %d", status)
}
if status, _ := env.do(client, http.MethodPut, "/api/users/1", map[string]any{"active": false}, true); status != http.StatusForbidden {
t.Fatalf("recorder 停用用户应 403,得到 %d", status)
}
}
// admin 建用户、改资料、重置密码、停用;并拒绝把自己降级或停用。
func TestAdminManagesUsers(t *testing.T) {
env := newTestEnv(t)
adminID := env.createUser("admin", "admin-pass", "admin")
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
status, body := env.do(admin, http.MethodGet, "/api/users", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("admin 读用户列表应 200,得到 %d %+v", status, body.Error)
}
users := decodeData[[]map[string]any](t, body)
if len(users) != 1 || users[0]["username"] != "admin" || users[0]["role"] != "admin" {
t.Fatalf("用户列表内容不对: %+v", users)
}
if users[0]["lastLoginAt"] == nil {
t.Fatalf("登录后应回填 lastLoginAt: %+v", users[0])
}
status, body = env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "zhang", "displayName": "张三", "role": "recorder", "password": "init-pass-1",
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建用户应 200,得到 %d %+v", status, body.Error)
}
created := decodeData[map[string]any](t, body)
newID := int64(created["id"].(float64))
if created["username"] != "zhang" || created["displayName"] != "张三" ||
created["role"] != "recorder" || created["active"] != true || created["mustChangePassword"] != true {
t.Fatalf("新建用户返回不对: %+v", created)
}
if status, body := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "zhang", "role": "recorder", "password": "init-pass-1",
}, true); status != http.StatusConflict || body.Error == nil || body.Error.Code != "CONFLICT" {
t.Fatalf("重名用户应 409 CONFLICT,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "weak", "role": "recorder", "password": "123",
}, true); status != http.StatusBadRequest {
t.Fatalf("弱密码应 400,得到 %d", status)
}
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "guestish", "role": "guest", "password": "password-1",
}, true); status != http.StatusBadRequest {
t.Fatalf("role=guest 应 400,得到 %d", status)
}
// 新用户拿到初始密码即可登录。
sub, _ := env.client()
env.login(sub, "zhang", "init-pass-1")
// 改资料:displayName + role。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{
"displayName": "张三丰", "role": "admin",
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改用户应 200,得到 %d %+v", status, body.Error)
}
updated := decodeData[map[string]any](t, body)
if updated["displayName"] != "张三丰" || updated["role"] != "admin" || updated["active"] != true {
t.Fatalf("改用户返回不对: %+v", updated)
}
// 管理员重置密码:留空随机生成,返回一次性明文。
status, body = env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", newID), map[string]any{}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
}
reset := decodeData[map[string]any](t, body)
password, _ := reset["password"].(string)
if reset["generated"] != true || len(password) < 12 || reset["mustChangePassword"] != true {
t.Fatalf("随机密码返回不对: %+v", reset)
}
// 旧密码立即失效,新密码可用;被重置者的会话已被作废。
if status, _ := env.do(sub, http.MethodPost, "/api/auth/login", map[string]any{
"username": "zhang", "password": "init-pass-1",
}, false); status != http.StatusUnauthorized {
t.Fatalf("旧密码应失效(401),得到 %d", status)
}
sub2, _ := env.client()
env.login(sub2, "zhang", password)
// 停用后不能登录。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{"active": false}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error)
}
if disabled := decodeData[map[string]any](t, body); disabled["active"] != false {
t.Fatalf("停用后 active 应为 false: %+v", disabled)
}
sub3, _ := env.client()
if status, _ := env.do(sub3, http.MethodPost, "/api/auth/login", map[string]any{
"username": "zhang", "password": password,
}, false); status != http.StatusUnauthorized {
t.Fatalf("停用用户登录应 401,得到 %d", status)
}
// 不能把自己降级或停用。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"role": "recorder"}, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("自己降级应 400 VALIDATION_FAILED,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"active": false}, true); status != http.StatusBadRequest {
t.Fatalf("自己停用应 400,得到 %d", status)
}
// 只改自己的显示名仍然允许。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"displayName": "总管理员"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改自己显示名应 200,得到 %d %+v", status, body.Error)
}
}
// 变更历史:recorder 可读列表与详情,guest 403;详情含快照与成绩/分数差异。
func TestRevisionsListAndDetail(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
guest, _ := env.client()
if status, body := env.do(guest, http.MethodGet, "/api/revisions", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读变更历史应 403,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(guest, http.MethodGet, "/api/revisions/1", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读变更详情应 403,得到 %d", status)
}
// 第一次发布:A 10 分、B 8 分。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读变更列表应 200,得到 %d %+v", status, body.Error)
}
page := decodeData[map[string]any](t, body)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("首次发布后应有 1 条变更记录,得到 %d", len(items))
}
first := items[0].(map[string]any)
if first["action"] != "publish" || first["eventName"] != "100米跑" || first["actorName"] != "recorder" {
t.Fatalf("变更列表字段不对: %+v", first)
}
if first["changedCount"].(float64) != 2 {
t.Fatalf("首次发布应记 2 条成绩变化,得到 %v", first["changedCount"])
}
if first["parentId"] != nil {
t.Fatalf("首条记录没有父节点: %+v", first)
}
firstID := int64(first["id"].(float64))
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", firstID), nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读变更详情应 200,得到 %d %+v", status, body.Error)
}
detail := decodeData[map[string]any](t, body)
if snapshot := detail["snapshot"].([]any); len(snapshot) != 2 {
t.Fatalf("快照应含 2 条成绩,得到 %d", len(snapshot))
}
diff := detail["diff"].(map[string]any)
changes := diff["results"].([]any)
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "added" {
t.Fatalf("首次发布的差异应为 2 条 added: %+v", changes)
}
teams := diff["teams"].([]any)
if len(teams) != 2 {
t.Fatalf("应涉及 2 个单位,得到 %d", len(teams))
}
got := map[string]map[string]any{}
for _, raw := range teams {
m := raw.(map[string]any)
got[m["teamCode"].(string)] = m
}
if got["A"]["pointsAfter"].(float64) != 10 || got["A"]["pointsDelta"].(float64) != 10 {
t.Fatalf("A 单位分数不对: %+v", got["A"])
}
if got["B"]["pointsAfter"].(float64) != 8 || got["B"]["pointsDelta"].(float64) != 8 {
t.Fatalf("B 单位分数不对: %+v", got["B"])
}
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
status, body = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("第二次发布应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
items = decodeData[map[string]any](t, body)["items"].([]any)
if len(items) != 2 {
t.Fatalf("两次发布后应有 2 条变更记录,得到 %d", len(items))
}
second := items[0].(map[string]any)
if second["changedCount"].(float64) != 2 {
t.Fatalf("第二次发布应记 2 条成绩变化,得到 %v", second["changedCount"])
}
secondID := int64(second["id"].(float64))
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", secondID), nil, false)
detail = decodeData[map[string]any](t, body)
diff = detail["diff"].(map[string]any)
changes = diff["results"].([]any)
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "changed" {
t.Fatalf("第二次发布的差异应为 changed: %+v", changes)
}
if fields := changes[0].(map[string]any)["fields"].([]any); len(fields) == 0 {
t.Fatalf("changed 记录应给出变化字段: %+v", changes[0])
}
teams = diff["teams"].([]any)
got = map[string]map[string]any{}
for _, raw := range teams {
m := raw.(map[string]any)
got[m["teamCode"].(string)] = m
}
if got["A"]["pointsDelta"].(float64) != -2 || got["B"]["pointsDelta"].(float64) != 2 {
t.Fatalf("第二次发布的分数增减不对: A=%+v B=%+v", got["A"], got["B"])
}
// 分页:limit/offset 生效。
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=1&offset=1", ids.event), nil, false)
page = decodeData[map[string]any](t, body)
if page["total"].(float64) != 2 || page["limit"].(float64) != 1 || len(page["items"].([]any)) != 1 {
t.Fatalf("分页参数未生效: %+v", page)
}
}