需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存) - athleteDTO/CRUD 读写 idCard,重复返回 409 - 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号 - 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员 - 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建) - 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new - 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引 前端: - 运动员表单与列表新增「证件号」,搜索支持证件号 - 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮 + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见 - 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示 - 新增 import-conflicts.test.ts 等,前端测试 39/39 文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。 注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
681 lines
19 KiB
Go
681 lines
19 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
|
)
|
|
|
|
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
|
|
// 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。
|
|
|
|
func strPtr(ns sql.NullString) *string {
|
|
if !ns.Valid {
|
|
return nil
|
|
}
|
|
v := ns.String
|
|
return &v
|
|
}
|
|
|
|
func i64Ptr(ni sql.NullInt64) *int64 {
|
|
if !ni.Valid {
|
|
return nil
|
|
}
|
|
v := ni.Int64
|
|
return &v
|
|
}
|
|
|
|
func intPtr(ni sql.NullInt64) *int {
|
|
if !ni.Valid {
|
|
return nil
|
|
}
|
|
v := int(ni.Int64)
|
|
return &v
|
|
}
|
|
|
|
func f64Ptr(nf sql.NullFloat64) *float64 {
|
|
if !nf.Valid {
|
|
return nil
|
|
}
|
|
v := nf.Float64
|
|
return &v
|
|
}
|
|
|
|
func nullStr(p *string) any {
|
|
if p == nil || *p == "" {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func nullI64(p *int64) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func nullInt(p *int) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func nullF64(p *float64) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func lastInsertID(res sql.Result) (int64, error) {
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("获取自增 id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// requireChanges 把「影响行数为 0」翻译成 404。
|
|
func requireChanges(res sql.Result, message string) error {
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
if n == 0 {
|
|
return notFound(message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed")
|
|
}
|
|
|
|
func isForeignKeyViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
|
|
}
|
|
|
|
func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) {
|
|
var one int
|
|
err := db.QueryRowContext(ctx, query, args...).Scan(&one)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return false, nil
|
|
case err != nil:
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ---------- teams ----------
|
|
|
|
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active`
|
|
|
|
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
|
|
var (
|
|
t teamDTO
|
|
shortName, logoPath, color sql.NullString
|
|
)
|
|
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active)
|
|
t.ShortName = strPtr(shortName)
|
|
t.LogoPath = strPtr(logoPath)
|
|
t.Color = strPtr(color)
|
|
return t, err
|
|
}
|
|
|
|
func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
|
|
rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make([]teamDTO, 0)
|
|
for rows.Next() {
|
|
t, err := scanTeam(rows)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
|
|
return teamMapQ(ctx, s.ev)
|
|
}
|
|
|
|
// teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。
|
|
func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
|
|
rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make(map[int64]teamDTO)
|
|
for rows.Next() {
|
|
t, err := scanTeam(rows)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out[t.ID] = t
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
|
|
return teamByIDQ(ctx, s.ev, id)
|
|
}
|
|
|
|
// teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。
|
|
func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
|
|
t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return teamDTO{}, notFound("单位不存在")
|
|
}
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
|
|
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
|
return teamDTO{}, badRequest("code 与 name 必填", nil)
|
|
}
|
|
tx, err := s.ev.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
|
|
if isUniqueViolation(err) {
|
|
return teamDTO{}, conflict("单位编码已存在")
|
|
}
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
id, err := lastInsertID(res)
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
team, err := teamByIDQ(ctx, tx, id)
|
|
if err != nil {
|
|
return teamDTO{}, err
|
|
}
|
|
// 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。
|
|
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
|
|
return teamDTO{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
return team, nil
|
|
}
|
|
|
|
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
|
|
tx, err := s.ev.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
|
|
WHERE id = ?`,
|
|
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
|
|
if isUniqueViolation(err) {
|
|
return teamDTO{}, conflict("单位编码已存在")
|
|
}
|
|
if err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
if err := requireChanges(res, "单位不存在"); err != nil {
|
|
return teamDTO{}, err
|
|
}
|
|
team, err := teamByIDQ(ctx, tx, id)
|
|
if err != nil {
|
|
return teamDTO{}, err
|
|
}
|
|
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
|
|
return teamDTO{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return teamDTO{}, internal(err)
|
|
}
|
|
return team, nil
|
|
}
|
|
|
|
func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
|
// 业务数据软删除:置 active=0,保留历史成绩与流水。
|
|
tx, err := s.ev.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
if err := requireChanges(res, "单位不存在"); err != nil {
|
|
return err
|
|
}
|
|
if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ---------- athletes ----------
|
|
|
|
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active`
|
|
|
|
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
|
var (
|
|
a athleteDTO
|
|
gender, empNo, idCard sql.NullString
|
|
teamID sql.NullInt64
|
|
)
|
|
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active)
|
|
a.Gender = strPtr(gender)
|
|
a.EmployeeNo = strPtr(empNo)
|
|
a.IDCard = strPtr(idCard)
|
|
if teamID.Valid {
|
|
a.TeamID = teamID.Int64
|
|
}
|
|
return a, err
|
|
}
|
|
|
|
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
|
|
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
|
|
func isIDCardConflict(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
|
|
}
|
|
|
|
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
|
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
|
args := []any{}
|
|
if teamID > 0 {
|
|
query += ` WHERE team_id = ?`
|
|
args = append(args, teamID)
|
|
}
|
|
query += ` ORDER BY name`
|
|
|
|
rows, err := s.ev.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make([]athleteDTO, 0)
|
|
for rows.Next() {
|
|
a, err := scanAthlete(rows)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
|
|
a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return athleteDTO{}, notFound("运动员不存在")
|
|
}
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
|
|
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
|
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
|
}
|
|
// 证件号是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
|
if in.IDCard != nil && *in.IDCard != "" {
|
|
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? LIMIT 1`, *in.IDCard)
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
if dup {
|
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
|
}
|
|
}
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, active) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active)
|
|
if isIDCardConflict(err) {
|
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
|
}
|
|
if isForeignKeyViolation(err) {
|
|
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
|
}
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
id, err := lastInsertID(res)
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
return s.getAthlete(ctx, id)
|
|
}
|
|
|
|
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
|
if in.IDCard != nil && *in.IDCard != "" {
|
|
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id)
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
if dup {
|
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
|
}
|
|
}
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, active = ? WHERE id = ?`,
|
|
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active, id)
|
|
if isIDCardConflict(err) {
|
|
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
|
}
|
|
if isForeignKeyViolation(err) {
|
|
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
|
}
|
|
if err != nil {
|
|
return athleteDTO{}, internal(err)
|
|
}
|
|
if err := requireChanges(res, "运动员不存在"); err != nil {
|
|
return athleteDTO{}, err
|
|
}
|
|
return s.getAthlete(ctx, id)
|
|
}
|
|
|
|
func (s *server) deleteAthlete(ctx context.Context, id int64) error {
|
|
res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
return requireChanges(res, "运动员不存在")
|
|
}
|
|
|
|
// ---------- sports ----------
|
|
|
|
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
|
|
|
|
func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) {
|
|
var (
|
|
sp sportDTO
|
|
shortName sql.NullString
|
|
defaultProfileID sql.NullInt64
|
|
)
|
|
err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active)
|
|
sp.ShortName = strPtr(shortName)
|
|
sp.DefaultProfileID = i64Ptr(defaultProfileID)
|
|
return sp, err
|
|
}
|
|
|
|
func (s *server) listSports(ctx context.Context) ([]sportDTO, error) {
|
|
rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make([]sportDTO, 0)
|
|
for rows.Next() {
|
|
sp, err := scanSport(rows)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out = append(out, sp)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
|
|
sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return sportDTO{}, notFound("大项不存在")
|
|
}
|
|
if err != nil {
|
|
return sportDTO{}, internal(err)
|
|
}
|
|
return sp, nil
|
|
}
|
|
|
|
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
|
|
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
|
return sportDTO{}, badRequest("code 与 name 必填", nil)
|
|
}
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active)
|
|
if isUniqueViolation(err) {
|
|
return sportDTO{}, conflict("大项编码已存在")
|
|
}
|
|
if err != nil {
|
|
return sportDTO{}, internal(err)
|
|
}
|
|
id, err := lastInsertID(res)
|
|
if err != nil {
|
|
return sportDTO{}, internal(err)
|
|
}
|
|
return s.getSport(ctx, id)
|
|
}
|
|
|
|
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
|
|
WHERE id = ?`,
|
|
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id)
|
|
if isUniqueViolation(err) {
|
|
return sportDTO{}, conflict("大项编码已存在")
|
|
}
|
|
if err != nil {
|
|
return sportDTO{}, internal(err)
|
|
}
|
|
if err := requireChanges(res, "大项不存在"); err != nil {
|
|
return sportDTO{}, err
|
|
}
|
|
return s.getSport(ctx, id)
|
|
}
|
|
|
|
func (s *server) deleteSport(ctx context.Context, id int64) error {
|
|
// 大项有子项目时不允许软删,避免项目挂在不可见的大项下。
|
|
has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
if has {
|
|
return conflict("大项下仍有小项,不能删除")
|
|
}
|
|
res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
return requireChanges(res, "大项不存在")
|
|
}
|
|
|
|
// ---------- scoring profiles ----------
|
|
|
|
func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) {
|
|
rows, err := s.ev.QueryContext(ctx,
|
|
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
|
|
FROM scoring_profiles ORDER BY code`)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make([]profileDTO, 0)
|
|
for rows.Next() {
|
|
var p profileDTO
|
|
if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
for i := range out {
|
|
rules, err := s.profileRules(ctx, out[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Rules = rules
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) {
|
|
rows, err := s.ev.QueryContext(ctx,
|
|
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make([]profileRuleDTO, 0)
|
|
for rows.Next() {
|
|
var r profileRuleDTO
|
|
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
|
|
var p profileDTO
|
|
err := s.ev.QueryRowContext(ctx,
|
|
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
|
|
FROM scoring_profiles WHERE id = ?`, id).
|
|
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return profileDTO{}, notFound("积分模板不存在")
|
|
}
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
rules, err := s.profileRules(ctx, p.ID)
|
|
if err != nil {
|
|
return profileDTO{}, err
|
|
}
|
|
p.Rules = rules
|
|
return p, nil
|
|
}
|
|
|
|
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
|
|
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
|
return profileDTO{}, badRequest("code 与 name 必填", nil)
|
|
}
|
|
tx, err := s.ev.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero)
|
|
if isUniqueViolation(err) {
|
|
return profileDTO{}, conflict("积分模板编码已存在")
|
|
}
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
id, err := lastInsertID(res)
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
|
|
return profileDTO{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
return s.getProfile(ctx, id)
|
|
}
|
|
|
|
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
|
|
tx, err := s.ev.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?,
|
|
award_ranks = ?, last_place_zero = ?, updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id)
|
|
if isUniqueViolation(err) {
|
|
return profileDTO{}, conflict("积分模板编码已存在")
|
|
}
|
|
if err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
if err := requireChanges(res, "积分模板不存在"); err != nil {
|
|
return profileDTO{}, err
|
|
}
|
|
if in.Rules != nil {
|
|
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
|
|
return profileDTO{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return profileDTO{}, internal(err)
|
|
}
|
|
return s.getProfile(ctx, id)
|
|
}
|
|
|
|
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
|
|
return internal(err)
|
|
}
|
|
for _, r := range rules {
|
|
if r.Rank < 1 {
|
|
return badRequest("积分名次必须 >= 1", r)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
|
|
profileID, r.Rank, r.Points); err != nil {
|
|
return internal(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if strings.TrimSpace(v) == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|