## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。
## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。
主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试
## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。
## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败
## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
555 lines
15 KiB
Go
555 lines
15 KiB
Go
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(recordRevision 写入的「同小项上一条版本」);
|
||
// parent_id 为 NULL(每个小项的第一条版本、以及本轮改动之前的老数据)时,退化为按 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), ",")
|
||
}
|