fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、 scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status) 过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成 服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED, message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。 - 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的 原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文, 日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数, 拦截点不再退化成 response.go 里的构造函数。 - /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」), 超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号, 数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。 - 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential): 401 的语义是「本请求没有有效会话」,而这里会话是好的。 - 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动, 否则堆键数就能冲掉别人的计数)。 - createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。 数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results / score_ledger 的级联,风险高不做),这一层先兜住。 - 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、 5xx 不外泄原文、限流键清理不误删。
This commit is contained in:
1 parent
23fee5b818
commit
2c983bcfdf
14 files changed
+1112
-21
No files matched your search
@@ -94,6 +94,26 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
eventType, err := normalizeEventType(in.EventType)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
resultMode, err := normalizeResultMode(in.ResultMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
resultType, err := normalizeResultType(in.ResultType)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
compare, err := normalizeCompare(in.Compare)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
status, err := normalizeEventStatus(in.Status)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -105,9 +125,8 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
@@ -137,6 +156,26 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
eventType, err := normalizeEventType(in.EventType)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
resultMode, err := normalizeResultMode(in.ResultMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
resultType, err := normalizeResultType(in.ResultType)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
compare, err := normalizeCompare(in.Compare)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
status, err := normalizeEventStatus(in.Status)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -149,9 +188,8 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
|
||||
WHERE id = ?`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
@@ -485,11 +523,18 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
|
||||
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
||||
}
|
||||
status, err := normalizeEntryStatus(in.Status)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -507,11 +552,24 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
}
|
||||
|
||||
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
||||
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
|
||||
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
|
||||
eventID, err := s.entryEventID(ctx, id)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
status, err := normalizeEntryStatus(in.Status)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
||||
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -527,6 +585,39 @@ func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryD
|
||||
return s.getEntry(ctx, id)
|
||||
}
|
||||
|
||||
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
|
||||
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
|
||||
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
|
||||
var eventID int64
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, notFound("报名项不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, internal(err)
|
||||
}
|
||||
return eventID, nil
|
||||
}
|
||||
|
||||
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
|
||||
//
|
||||
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
|
||||
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
|
||||
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
|
||||
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
|
||||
var catEvent int64
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil
|
||||
case err != nil:
|
||||
return internal(err)
|
||||
case catEvent != eventID:
|
||||
return badRequest("categoryId 不属于该小项", categoryID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
||||
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
|
||||
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
|
||||
|
||||
Reference in new issue
Block a user