feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -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), ",")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user