feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
1 parent
24fff8dd08
commit
6483acf9ad
50 files changed
+2980
-395
No files matched your search
@@ -2,12 +2,17 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
@@ -534,11 +539,12 @@ type entryImportResult struct {
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。
|
||||
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Existing importAthleteView `json:"existing"`
|
||||
Incoming importAthleteView `json:"incoming"`
|
||||
@@ -551,11 +557,16 @@ type importAthleteView struct {
|
||||
Gender *string `json:"gender"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
EmployeeNo *string `json:"employeeNo"`
|
||||
IDCard *string `json:"idCard"`
|
||||
Bib *string `json:"bib"`
|
||||
Age *int `json:"age"`
|
||||
}
|
||||
|
||||
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
|
||||
// 证件号与号码布二选一作为决策键,证件号优先。
|
||||
type importDecision struct {
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
@@ -610,14 +621,18 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
|
||||
return nil, badRequest("decisions 不是合法 JSON", err.Error())
|
||||
}
|
||||
byCard := make(map[string]string, len(decisions))
|
||||
byKey := make(map[string]string, len(decisions))
|
||||
for _, d := range decisions {
|
||||
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
|
||||
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
|
||||
}
|
||||
byCard[strings.TrimSpace(d.IDCard)] = d.Action
|
||||
key := strings.TrimSpace(d.IDCard)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(d.Bib)
|
||||
}
|
||||
byKey[key] = d.Action
|
||||
}
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byCard)
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -655,7 +670,7 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender 为可选列,缺列不报错,按空值处理。
|
||||
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
}
|
||||
@@ -665,7 +680,7 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理证件号命中的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
|
||||
if len(rows) < 2 {
|
||||
@@ -681,8 +696,9 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。
|
||||
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
|
||||
seenIDCards := make(map[string]bool)
|
||||
seenBibs := make(map[string]bool)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
@@ -735,17 +751,35 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
idCard := cell("id_card")
|
||||
if idCard == "" {
|
||||
bib := cell("bib")
|
||||
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line))
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
|
||||
continue
|
||||
}
|
||||
if seenIDCards[idCard] {
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
if idCard != "" {
|
||||
if seenIDCards[idCard] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
}
|
||||
if bib != "" {
|
||||
if seenBibs[bib] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib))
|
||||
continue
|
||||
}
|
||||
seenBibs[bib] = true
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
@@ -754,6 +788,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
@@ -792,6 +828,18 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。
|
||||
func parseImportAge(raw string) (*int, bool) {
|
||||
if raw == "" {
|
||||
return nil, true
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 || n > 120 {
|
||||
return nil, false
|
||||
}
|
||||
return &n, true
|
||||
}
|
||||
|
||||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||
func normalizeGender(raw string) string {
|
||||
switch raw {
|
||||
@@ -814,3 +862,146 @@ func splitNames(raw string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
|
||||
// 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。
|
||||
const (
|
||||
maxUploadBytes = 5 << 20
|
||||
maxUploadFormBytes = maxUploadBytes + 1<<20
|
||||
)
|
||||
|
||||
// allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。
|
||||
// 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。
|
||||
var allowedUploadImageTypes = map[string]string{
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
// uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。
|
||||
const uploadsCacheControl = "public, max-age=86400"
|
||||
|
||||
// uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 <img src>。
|
||||
type uploadResult struct {
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// uploadsDir 返回当前赛事项目的上传目录(赛事包导出会带上它,跨设备转移靠的就是这里)。
|
||||
// 未配置项目目录时没有落盘位置,返回 503 而不是 panic。
|
||||
func (s *server) uploadsDir() (string, error) {
|
||||
dir := strings.TrimSpace(s.projectDir)
|
||||
if dir == "" {
|
||||
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
|
||||
message: "当前未配置赛事项目目录,无法上传图片"}
|
||||
}
|
||||
return filepath.Join(dir, "uploads"), nil
|
||||
}
|
||||
|
||||
// uploadHandler 保存 multipart file 字段里的图片。
|
||||
// 客户端文件名只用来取扩展名,落盘名是随机十六进制,避免重名覆盖与路径注入。
|
||||
func (s *server) uploadHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFormBytes)
|
||||
if err := r.ParseMultipartForm(maxUploadFormBytes); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return nil, badRequest("图片超过 5 MiB 上限", nil)
|
||||
}
|
||||
return nil, badRequest("上传内容解析失败", err.Error())
|
||||
}
|
||||
defer func() { _ = r.MultipartForm.RemoveAll() }()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return nil, badRequest("缺少 file 字段", nil)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if _, ok := allowedUploadImageTypes[ext]; !ok {
|
||||
return nil, badRequest("只支持 png/jpg/jpeg/webp/gif 图片", ext)
|
||||
}
|
||||
// header.Size 由客户端声明,不可信,按真实读到的字节数判限。
|
||||
body, err := io.ReadAll(io.LimitReader(file, maxUploadBytes+1))
|
||||
if err != nil {
|
||||
return nil, badRequest("读取上传内容失败", err.Error())
|
||||
}
|
||||
if len(body) > maxUploadBytes {
|
||||
return nil, badRequest("图片超过 5 MiB 上限", nil)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, badRequest("上传内容为空", nil)
|
||||
}
|
||||
|
||||
name, err := newUploadName(ext)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
s.audit(r.Context(), "uploads.create", "upload", 0, nil,
|
||||
map[string]any{"file": name, "size": len(body)})
|
||||
path := "/uploads/" + name
|
||||
return uploadResult{Path: path, URL: path}, nil
|
||||
}
|
||||
|
||||
// newUploadName 生成随机文件名:不用客户端文件名,既防重名覆盖也防路径注入。
|
||||
func newUploadName(ext string) (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf) + ext, nil
|
||||
}
|
||||
|
||||
// serveUpload 匿名提供上传目录里的图片:大屏与 <img> 不带会话,不能要求登录。
|
||||
func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
ctype, ok := uploadContentType(name)
|
||||
if !ok {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
dir, err := s.uploadsDir()
|
||||
if err != nil {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
|
||||
file := filepath.Join(dir, filepath.Base(name))
|
||||
if info, err := os.Stat(file); err != nil || info.IsDir() {
|
||||
writeAPIError(w, s.logger, notFound("图片不存在"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", uploadsCacheControl)
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
// uploadContentType 校验文件名并给出 Content-Type:
|
||||
// 只接受「单层文件名 + 白名单扩展名」,含分隔符、.. 或只有扩展名的名字一律拒绝。
|
||||
func uploadContentType(name string) (string, bool) {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
return "", false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if name == ext {
|
||||
return "", false
|
||||
}
|
||||
ctype, ok := allowedUploadImageTypes[ext]
|
||||
return ctype, ok
|
||||
}
|
||||
Reference in new issue
Block a user