據(jù)系統(tǒng)實(shí)戰(zhàn):用FastAPI與SQLAlchemy構(gòu)建歐羅巴比賽數(shù)據(jù)管理)
維護(hù)一場歐羅巴聯(lián)賽的比賽數(shù)據(jù)聽起來只是把主隊(duì)、客隊(duì)、比分放進(jìn)表格里。真正做起來會發(fā)現(xiàn)問題都出在細(xì)節(jié)上同一支球隊(duì)在不同文件里叫“安德萊赫特”還是“AND”比賽時間按哪個時區(qū)記錄比分被覆蓋后如何還原第三方數(shù)據(jù)重復(fù)導(dǎo)入時如何避免產(chǎn)生兩場相同的比賽。這些問題在沒有數(shù)據(jù)結(jié)構(gòu)約束和接口約束時會隨著比賽數(shù)量增加而迅速放大。這篇文章從一個具體場景出發(fā)在賽事數(shù)據(jù)系統(tǒng)里維護(hù)“安德萊赫特 vs 塞薩洛尼基”這場歐羅巴比賽包括創(chuàng)建球隊(duì)、創(chuàng)建賽程、記錄比分事件、查詢比賽詳情。技術(shù)實(shí)現(xiàn)使用 FastAPI SQLAlchemy SQLite涉及表結(jié)構(gòu)設(shè)計(jì)、狀態(tài)機(jī)設(shè)計(jì)、冪等處理、時區(qū)處理和常見問題排查。整篇文章既可以作為體育數(shù)據(jù)類應(yīng)用的入門項(xiàng)目也可以作為后端工程師整理賽事領(lǐng)域建模的參考。1. 賽事數(shù)據(jù)管理系統(tǒng)解決什么問題從一份歐羅巴賽程說起1.1 為什么手工表格撐不住賽事數(shù)據(jù)維護(hù)很多體育數(shù)據(jù)項(xiàng)目一開始都是從一個 Excel 文件開始的。維護(hù)者按比賽日期新建一行手動填入主隊(duì)、客隊(duì)、比分和比賽狀態(tài)。比賽少的時候沒有問題一旦聯(lián)賽進(jìn)入資格賽、小組賽、淘汰賽并行階段問題就開始暴露。常見的手工維護(hù)問題包括隊(duì)名不統(tǒng)一。同一個球隊(duì)有人寫“安德萊赫特”有人寫“Anderlecht”也有人寫簡稱“AND”。同一場比賽重復(fù)存在。第三方數(shù)據(jù)源推送一次運(yùn)營人員又手動錄入一次系統(tǒng)里出現(xiàn)兩條主客隊(duì)相同的記錄。比分只能記住最終值。比賽過程中出現(xiàn) 1:0、1:1、2:1 的變化最終表里只有一個 2:1過幾天沒有人能說清楚第二個進(jìn)球發(fā)生在第幾分鐘。比賽狀態(tài)靠人工標(biāo)記。開球后忘了改為“進(jìn)行中”比賽結(jié)束很久還停在“未開始”影響下游統(tǒng)計(jì)和展示。這些問題本質(zhì)上是缺少數(shù)據(jù)建模和約束。賽事數(shù)據(jù)管理系統(tǒng)要做的就是讓球隊(duì)、比賽、比分、狀態(tài)都有明確的結(jié)構(gòu)和規(guī)則讓數(shù)據(jù)在錄入階段就被校驗(yàn)而不是等查詢階段才發(fā)現(xiàn)錯誤。1.2 核心模塊球隊(duì)、賽程、比分、查詢一個最小可用的賽事數(shù)據(jù)系統(tǒng)可以拆成四個模塊。球隊(duì)主數(shù)據(jù)負(fù)責(zé)維護(hù)參賽隊(duì)伍重點(diǎn)是名稱唯一性和基礎(chǔ)信息。賽程管理負(fù)責(zé)創(chuàng)建比賽記錄比賽雙方、所屬賽事、輪次、比賽時間。比分記錄負(fù)責(zé)跟蹤比賽過程中的每一次比分變化。查詢服務(wù)負(fù)責(zé)把比賽詳情、當(dāng)前比分、球隊(duì)信息組裝后返回給調(diào)用方。這四個模塊并不復(fù)雜但它們是后續(xù)做積分榜、射手榜、賽程日歷、數(shù)據(jù)統(tǒng)計(jì)的基礎(chǔ)。如果這一層的數(shù)據(jù)口徑有問題上層的所有統(tǒng)計(jì)都會失真。1.3 技術(shù)方案和項(xiàng)目結(jié)構(gòu)示例采用 FastAPI SQLAlchemy SQLite。選擇這組技術(shù)的原因有三點(diǎn)FastAPI 學(xué)習(xí)成本低能快速提供 REST 接口適合作為內(nèi)部運(yùn)營后臺或數(shù)據(jù)管理服務(wù)。SQLAlchemy 同時支持 SQLite、PostgreSQL、MySQL學(xué)習(xí)環(huán)境用 SQLite 零部署生產(chǎn)環(huán)境可以平滑切換。Pydantic 負(fù)責(zé)請求參數(shù)校驗(yàn)可以在進(jìn)入業(yè)務(wù)邏輯之前攔截非法數(shù)據(jù)。項(xiàng)目結(jié)構(gòu)如下sports_data/ ├── main.py # FastAPI 入口和路由 ├── models.py # SQLAlchemy ORM 模型 ├── schemas.py # Pydantic 請求響應(yīng)模型 └── database.py # 數(shù)據(jù)庫連接和會話管理這是一個最小的單服務(wù)結(jié)構(gòu)。后續(xù)引入遷移工具、定時任務(wù)、緩存層時可以按模塊繼續(xù)拆分。2. 數(shù)據(jù)模型設(shè)計(jì)先定狀態(tài)機(jī)再建表2.1 比賽狀態(tài)機(jī)讓數(shù)據(jù)流轉(zhuǎn)有規(guī)則比賽數(shù)據(jù)里最容易亂的是狀態(tài)。如果狀態(tài)可以隨便填下游在計(jì)算“未開始比賽數(shù)量”時就會混入已經(jīng)結(jié)束的場次。因此要先定義狀態(tài)機(jī)再用數(shù)據(jù)庫約束保證狀態(tài)值合法。狀態(tài)含義進(jìn)入條件后續(xù)狀態(tài)scheduled未開始創(chuàng)建比賽時默認(rèn)in_progress / postponed / cancelledin_progress進(jìn)行中開球后通過接口更新finished / postponed / cancelledfinished已結(jié)束常規(guī)時間或官方判定結(jié)束終態(tài)需要修正時走修正接口postponed延期賽前或賽中出現(xiàn)延期scheduled / cancelledcancelled取消比賽取消終態(tài)比分清空這里有一個容易被忽略的點(diǎn)finished是終態(tài)但不代表數(shù)據(jù)永遠(yuǎn)不可以修正。足球比賽中可能出現(xiàn)官方更正進(jìn)球歸屬、補(bǔ)時時間調(diào)整等場景。正確做法是保留原始比分事件允許管理員通過專門的修正接口調(diào)整最終結(jié)果而不是直接修改事件記錄。注意狀態(tài)字段不要使用無約束的字符串。至少要在數(shù)據(jù)庫層面加 CHECK 約束在應(yīng)用層再用枚舉或常量類管理否則很快就會出現(xiàn)拼寫錯誤導(dǎo)致的臟狀態(tài)。2.2 球隊(duì)表和比賽表核心字段如何設(shè)計(jì)球隊(duì)表的核心是名稱唯一性。同一個球隊(duì)可以有中文名、英文名、簡稱但系統(tǒng)內(nèi)部必須有一個唯一鍵用來關(guān)聯(lián)比賽。推薦使用英文全稱或官方標(biāo)識作為唯一名稱把中文名和簡稱作為輔助字段。比賽表則要承載賽事上下文。competition表示賽事名稱round_name表示輪次external_id用來關(guān)聯(lián)第三方數(shù)據(jù)源。external_id必須加唯一約束這是避免重復(fù)導(dǎo)入的關(guān)鍵。比賽時間字段建議設(shè)計(jì)為可排序的DATETIME類型不要使用字符串。否則后續(xù)按日期篩選比賽時SQL 比較會非常難寫。2.3 比分事件表為什么不能只存幾個數(shù)字很多初版設(shè)計(jì)會把home_score和away_score直接放在比賽表里每次進(jìn)球就 update 一次。這個設(shè)計(jì)的最大問題是丟失歷史。假設(shè)系統(tǒng)里只存最終比分 2:1當(dāng)運(yùn)營人員需要回答“主隊(duì)的第二個進(jìn)球是什么時候進(jìn)的”時數(shù)據(jù)是缺失的。如果用事件表記錄每一次比分變化就能得到完整的時間線第 23 分鐘主隊(duì)進(jìn)球比分變?yōu)?1:0。第 41 分鐘客隊(duì)進(jìn)球比分變?yōu)?1:1。第 67 分鐘主隊(duì)進(jìn)球比分變?yōu)?2:1。比分事件表還有另一個作用防止并發(fā)更新把比分覆蓋錯。直接更新比賽表時如果兩個請求同時寫入后寫入的請求會覆蓋前一個結(jié)果。將每次變更作為一條獨(dú)立記錄插入再更新比賽表的當(dāng)前比分可以保證事件可追溯當(dāng)前比分只是“最新一條事件”的冗余展示。2.4 表關(guān)系與 DDL 設(shè)計(jì)三張表的關(guān)系如下teams與matches是一對多關(guān)系一場比賽有主隊(duì)和客隊(duì)兩個外鍵。matches與match_score_events是一對多關(guān)系一場比賽有多條比分事件。對應(yīng)的 SQLite DDL 如下CREATE TABLE teams ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL UNIQUE, short_name VARCHAR(50), country VARCHAR(50), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, external_id VARCHAR(100) UNIQUE, competition VARCHAR(100) NOT NULL, round_name VARCHAR(100), home_team_id INTEGER NOT NULL, away_team_id INTEGER NOT NULL, match_time DATETIME NOT NULL, status VARCHAR(20) NOT NULL DEFAULT scheduled, current_home_score INTEGER NOT NULL DEFAULT 0, current_away_score INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (home_team_id) REFERENCES teams(id), FOREIGN KEY (away_team_id) REFERENCES teams(id), CHECK (home_team_id away_team_id), CHECK (status IN (scheduled, in_progress, finished, postponed, cancelled)), CHECK (current_home_score 0), CHECK (current_away_score 0) ); CREATE TABLE match_score_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_id INTEGER NOT NULL, event_minute INTEGER, home_score INTEGER NOT NULL, away_score INTEGER NOT NULL, event_type VARCHAR(20) NOT NULL DEFAULT score, note VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (match_id) REFERENCES matches(id) );這個 DDL 里值得注意的設(shè)計(jì)點(diǎn)有三個。第一external_id建了唯一索引但允許為空。學(xué)習(xí)環(huán)境下手工創(chuàng)建的比賽沒有外部 ID多條空值不會觸發(fā)唯一約束沖突這是 SQLite 和多數(shù)數(shù)據(jù)庫對 NULL 的處理規(guī)則。第二matches表增加了CHECK (home_team_id away_team_id)。雖然代碼層面會校驗(yàn)主客隊(duì)不同但數(shù)據(jù)庫約束是最后一道防線能防止臟數(shù)據(jù)被直接寫入。第三match_score_events表只記錄進(jìn)球后的比分不直接修改事件本身。當(dāng)比分從 1:0 變成 1:1 時新增一行記錄即可。3. 用 FastAPI 實(shí)現(xiàn)最小可用的賽事數(shù)據(jù)服務(wù)3.1 初始化項(xiàng)目與依賴示例代碼基于 Python 3.10因?yàn)闀玫絪tr | None這種類型注解語法。需要安裝的依賴如下pip install fastapi uvicorn sqlalchemy啟動和調(diào)試時使用 uvicornuvicorn main:app --reload如果原始環(huán)境中的 Python 版本低于 3.10需要把模型和 Pydantic 里的str | None改成Optional[str]否則會直接報(bào)語法錯誤。3.2 數(shù)據(jù)庫連接與會話管理database.py負(fù)責(zé)創(chuàng)建引擎和會話工廠from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase DATABASE_URL sqlite:///./sports.db engine create_engine( DATABASE_URL, connect_args{check_same_thread: False} ) SessionLocal sessionmaker(bindengine, autoflushFalse, autocommitFalse) class Base(DeclarativeBase): passSQLite 默認(rèn)不允許跨線程訪問同一個連接。check_same_threadFalse是 FastAPI 多線程訪問 SQLite 時常用的配置生產(chǎn)環(huán)境切換到 PostgreSQL 后可以刪除這個參數(shù)。3.3 ORM 模型定義models.py中定義三張表對應(yīng)的 ORM 模型from datetime import datetime from sqlalchemy import ( String, Integer, DateTime, ForeignKey, CheckConstraint ) from sqlalchemy.orm import Mapped, mapped_column, relationship from database import Base class Team(Base): __tablename__ teams id: Mapped[int] mapped_column(primary_keyTrue, indexTrue) name: Mapped[str] mapped_column(String(100), uniqueTrue, indexTrue) short_name: Mapped[str | None] mapped_column(String(50), nullableTrue) country: Mapped[str | None] mapped_column(String(50), nullableTrue) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.utcnow) home_matches: Mapped[list[FootballMatch]] relationship( foreign_keysFootballMatch.home_team_id, back_populateshome_team ) away_matches: Mapped[list[FootballMatch]] relationship( foreign_keysFootballMatch.away_team_id, back_populatesaway_team ) class FootballMatch(Base): __tablename__ matches __table_args__ ( CheckConstraint(home_team_id away_team_id, nameck_match_teams_diff), CheckConstraint( status IN (scheduled, in_progress, finished, postponed, cancelled), nameck_match_status ), ) id: Mapped[int] mapped_column(primary_keyTrue, indexTrue) external_id: Mapped[str | None] mapped_column(String(100), uniqueTrue, nullableTrue, indexTrue) competition: Mapped[str] mapped_column(String(100), indexTrue) round_name: Mapped[str | None] mapped_column(String(100), nullableTrue) home_team_id: Mapped[int] mapped_column(ForeignKey(teams.id)) away_team_id: Mapped[int] mapped_column(ForeignKey(teams.id)) match_time: Mapped[datetime] mapped_column(DateTime, indexTrue) status: Mapped[str] mapped_column(String(20), defaultscheduled, indexTrue) current_home_score: Mapped[int] mapped_column(Integer, default0) current_away_score: Mapped[int] mapped_column(Integer, default0) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.utcnow) updated_at: Mapped[datetime] mapped_column( DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow ) home_team: Mapped[Team] relationship( foreign_keys[home_team_id], back_populateshome_matches ) away_team: Mapped[Team] relationship( foreign_keys[away_team_id], back_populatesaway_matches ) score_events: Mapped[list[MatchScoreEvent]] relationship( back_populatesmatch, cascadeall, delete-orphan ) class MatchScoreEvent(Base): __tablename__ match_score_events id: Mapped[int] mapped_column(primary_keyTrue, indexTrue) match_id: Mapped[int] mapped_column(ForeignKey(matches.id), indexTrue) event_minute: Mapped[int | None] mapped_column(Integer, nullableTrue) home_score: Mapped[int] mapped_column(Integer) away_score: Mapped[int] mapped_column(Integer) event_type: Mapped[str] mapped_column(String(20), defaultscore) note: Mapped[str | None] mapped_column(String(255), nullableTrue) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.utcnow) match: Mapped[FootballMatch] relationship(back_populatesscore_events)這段代碼的關(guān)鍵點(diǎn)有三個。第一FootballMatch類名沒有使用Match是為了避免與 Python 的match關(guān)鍵字在閱讀時產(chǎn)生歧義。第二score_events關(guān)系配置了cascadeall, delete-orphan刪除比賽時會同時刪除它的比分事件避免產(chǎn)生孤兒數(shù)據(jù)。第三updated_at使用onupdatedatetime.utcnow每次更新比賽記錄時自動刷新更新時間。這個字段在排查“比分為什么被修改”時非常有用。3.4 Pydantic 請求響應(yīng)模型schemas.py定義接口的入?yún)⒑统鰠rom datetime import datetime from pydantic import BaseModel, ConfigDict, Field class TeamCreate(BaseModel): name: str Field(min_length1, max_length100) short_name: str | None Field(defaultNone, max_length50) country: str | None Field(defaultNone, max_length50) class TeamOut(BaseModel): model_config ConfigDict(from_attributesTrue) id: int name: str short_name: str | None country: str | None created_at: datetime class MatchCreate(BaseModel): external_id: str | None Field(defaultNone, max_length100) competition: str Field(min_length1, max_length100) round_name: str | None Field(defaultNone, max_length100) home_team_id: int away_team_id: int match_time: datetime class ScoreEventCreate(BaseModel): event_minute: int | None Field(defaultNone, ge0, le130) home_score: int Field(ge0) away_score: int Field(ge0) event_type: str Field(defaultscore, max_length20) note: str | None Field(defaultNone, max_length255) class MatchOut(BaseModel): model_config ConfigDict(from_attributesTrue) id: int external_id: str | None competition: str round_name: str | None home_team: TeamOut away_team: TeamOut match_time: datetime status: str current_home_score: int current_away_score: int created_at: datetime updated_at: datetimeField(ge0)會在請求進(jìn)入業(yè)務(wù)邏輯之前校驗(yàn)比分不能為負(fù)數(shù)。event_minute限制在 0 到 130 之間能攔截明顯不合理的分鐘數(shù)。3.5 FastAPI 路由與業(yè)務(wù)邏輯main.py中實(shí)現(xiàn)創(chuàng)建球隊(duì)、創(chuàng)建比賽、記錄比分、結(jié)束比賽、查詢比賽詳情五個接口from datetime import datetime, timezone from fastapi import FastAPI, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from database import SessionLocal, engine, Base from models import Team, FootballMatch, MatchScoreEvent from schemas import ( TeamCreate, TeamOut, MatchCreate, MatchOut, ScoreEventCreate ) Base.metadata.create_all(bindengine) app FastAPI(titleFootball Match Data API) def get_db(): db SessionLocal() try: yield db finally: db.close() def to_utc_naive(dt: datetime) - datetime: if dt.tzinfo is None: return dt return dt.astimezone(timezone.utc).replace(tzinfoNone) def get_match_or_404(db: Session, match_id: int) - FootballMatch: stmt ( select(FootballMatch) .options( selectinload(FootballMatch.home_team), selectinload(FootballMatch.away_team), ) .where(FootballMatch.id match_id) ) match db.execute(stmt).scalar_one_or_none() if match is None: raise HTTPException(status_code404, detailmatch not found) return match app.post(/teams, response_modelTeamOut) def create_team(payload: TeamCreate, db: Session Depends(get_db)): exists db.execute( select(Team).where(Team.name payload.name) ).scalar_one_or_none() if exists: raise HTTPException(status_code400, detailteam name already exists) team Team(**payload.model_dump()) db.add(team) db.commit() db.refresh(team) return team app.post(/matches, response_modelMatchOut) def create_match(payload: MatchCreate, db: Session Depends(get_db)): home db.get(Team, payload.home_team_id) away db.get(Team, payload.away_team_id) if home is None or away is None: raise HTTPException(