diff --git a/alembic/versions/0003_training_tables.py b/alembic/versions/0003_training_tables.py new file mode 100644 index 0000000..f648359 --- /dev/null +++ b/alembic/versions/0003_training_tables.py @@ -0,0 +1,40 @@ +"""培训业务表并入唯一总库(accounts/events/bookings/tests/courses/policy_logs/plan_logs/survey_logs,全列可空) + +Revision ID: 0003_training_tables +Revises: 0002_park_screen_bind +Create Date: 2026-08-24 +""" +from __future__ import annotations + +import sys +import os +from alembic import op + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +revision = "0003_training_tables" +down_revision = "0002_park_screen_bind" +branch_labels = None +depends_on = None + +_TABLES = ["accounts", "events", "bookings", "tests", "courses", "policy_logs", "plan_logs", "survey_logs"] + + +def upgrade() -> None: + # 培训表并入 app.db;若此前残留旧表则丢弃后按当前(可空列)模型重建 + for t in _TABLES: + try: + op.drop_table(t) + except Exception: # 表不存在则忽略 + pass + from app.infrastructure.db import Base + import app.infrastructure.models # noqa: F401 + Base.metadata.create_all(bind=op.get_bind()) + + +def downgrade() -> None: + for t in _TABLES: + try: + op.drop_table(t) + except Exception: + pass diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 7faa894..4dc4038 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -587,128 +587,128 @@ class UserIdentity(Base): class TrainingAccount(Base): """培训站账号(原 accounts,含手机/微信登录)。""" - __tablename__ = "training_accounts" - id: Mapped[str] = mapped_column(String, primary_key=True) - username: Mapped[str] = mapped_column(String, unique=True, nullable=False) - wxid: Mapped[str] = mapped_column(String, default="") - phone: Mapped[str] = mapped_column(String, default="") - contact: Mapped[str] = mapped_column(String, default="") - name: Mapped[str] = mapped_column(String, default="") - avatar: Mapped[str] = mapped_column(String, default="") - password: Mapped[str] = mapped_column(String, default="") - phone_bound: Mapped[int] = mapped_column(Integer, default=0) - identities: Mapped[str] = mapped_column(Text, default="[]") - status_label: Mapped[str] = mapped_column(String, default="") - topics: Mapped[str] = mapped_column(Text, default="") - source: Mapped[str] = mapped_column(String, default="") - created_at: Mapped[str] = mapped_column(String, default="") + __tablename__ = "accounts" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + username: Mapped[str | None] = mapped_column(String, unique=True, nullable=False) + wxid: Mapped[str | None] = mapped_column(String, default="") + phone: Mapped[str | None] = mapped_column(String, default="") + contact: Mapped[str | None] = mapped_column(String, default="") + name: Mapped[str | None] = mapped_column(String, default="") + avatar: Mapped[str | None] = mapped_column(String, default="") + password: Mapped[str | None] = mapped_column(String, default="") + phone_bound: Mapped[int | None] = mapped_column(Integer, default=0) + identities: Mapped[str | None] = mapped_column(Text, default="[]") + status_label: Mapped[str | None] = mapped_column(String, default="") + topics: Mapped[str | None] = mapped_column(Text, default="") + source: Mapped[str | None] = mapped_column(String, default="") + created_at: Mapped[str | None] = mapped_column(String, default="") class Event(Base): - __tablename__ = "training_events" - id: Mapped[str] = mapped_column(String, primary_key=True) - type: Mapped[str] = mapped_column(String, default="") - mode: Mapped[str] = mapped_column(String, default="offline") - title: Mapped[str] = mapped_column(String, default="") - subtitle: Mapped[str] = mapped_column(String, default="") - desc: Mapped[str] = mapped_column(Text, default="") - location: Mapped[str] = mapped_column(String, default="") - host: Mapped[str] = mapped_column(String, default="") - image: Mapped[str] = mapped_column(String, default="") - link: Mapped[str] = mapped_column(String, default="") - start_at: Mapped[str] = mapped_column(String, default="") - end_at: Mapped[str] = mapped_column(String, default="") - checkin_at: Mapped[str] = mapped_column(String, default="") - duration_min: Mapped[int] = mapped_column(Integer, default=90) - capacity: Mapped[int] = mapped_column(Integer, default=0) - show_capacity: Mapped[int] = mapped_column(Integer, default=0) - audit_mode: Mapped[str] = mapped_column(String, default="auto") - status: Mapped[str] = mapped_column(String, default="open") + __tablename__ = "events" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + type: Mapped[str | None] = mapped_column(String, default="") + mode: Mapped[str | None] = mapped_column(String, default="offline") + title: Mapped[str | None] = mapped_column(String, default="") + subtitle: Mapped[str | None] = mapped_column(String, default="") + desc: Mapped[str | None] = mapped_column(Text, default="") + location: Mapped[str | None] = mapped_column(String, default="") + host: Mapped[str | None] = mapped_column(String, default="") + image: Mapped[str | None] = mapped_column(String, default="") + link: Mapped[str | None] = mapped_column(String, default="") + start_at: Mapped[str | None] = mapped_column(String, default="") + end_at: Mapped[str | None] = mapped_column(String, default="") + checkin_at: Mapped[str | None] = mapped_column(String, default="") + duration_min: Mapped[int | None] = mapped_column(Integer, default=90) + capacity: Mapped[int | None] = mapped_column(Integer, default=0) + show_capacity: Mapped[int | None] = mapped_column(Integer, default=0) + audit_mode: Mapped[str | None] = mapped_column(String, default="auto") + status: Mapped[str | None] = mapped_column(String, default="open") class Booking(Base): - __tablename__ = "training_bookings" - id: Mapped[str] = mapped_column(String, primary_key=True) - created_at: Mapped[str] = mapped_column(String, default="") - status: Mapped[str] = mapped_column(String, default="pending") - audit_status: Mapped[str] = mapped_column(String, default="pending") - username: Mapped[str] = mapped_column(String, default="") - name: Mapped[str] = mapped_column(String, default="") - contact: Mapped[str] = mapped_column(String, default="") - status_label: Mapped[str] = mapped_column(String, default="") - want: Mapped[str] = mapped_column(Text, default="") - event_id: Mapped[str] = mapped_column(String, default="") - event_title: Mapped[str] = mapped_column(String, default="") - event_start: Mapped[str] = mapped_column(String, default="") - topics: Mapped[str] = mapped_column(Text, default="") - question: Mapped[str] = mapped_column(Text, default="") - source: Mapped[str] = mapped_column(String, default="") - checkin_at: Mapped[str] = mapped_column(String, default="") + __tablename__ = "bookings" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + created_at: Mapped[str | None] = mapped_column(String, default="") + status: Mapped[str | None] = mapped_column(String, default="pending") + audit_status: Mapped[str | None] = mapped_column(String, default="pending") + username: Mapped[str | None] = mapped_column(String, default="") + name: Mapped[str | None] = mapped_column(String, default="") + contact: Mapped[str | None] = mapped_column(String, default="") + status_label: Mapped[str | None] = mapped_column(String, default="") + want: Mapped[str | None] = mapped_column(Text, default="") + event_id: Mapped[str | None] = mapped_column(String, default="") + event_title: Mapped[str | None] = mapped_column(String, default="") + event_start: Mapped[str | None] = mapped_column(String, default="") + topics: Mapped[str | None] = mapped_column(Text, default="") + question: Mapped[str | None] = mapped_column(Text, default="") + source: Mapped[str | None] = mapped_column(String, default="") + checkin_at: Mapped[str | None] = mapped_column(String, default="") class Test(Base): - __tablename__ = "training_tests" - id: Mapped[str] = mapped_column(String, primary_key=True) - created_at: Mapped[str] = mapped_column(String, default="") - username: Mapped[str] = mapped_column(String, default="") - type_code: Mapped[str] = mapped_column(String, default="") - persona: Mapped[str] = mapped_column(String, default="") - adapt_index: Mapped[int] = mapped_column(Integer, default=0) - adapt_level: Mapped[str] = mapped_column(String, default="") - tracks: Mapped[str] = mapped_column(Text, default="") - version: Mapped[str] = mapped_column(String, default="") + __tablename__ = "tests" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + created_at: Mapped[str | None] = mapped_column(String, default="") + username: Mapped[str | None] = mapped_column(String, default="") + type_code: Mapped[str | None] = mapped_column(String, default="") + persona: Mapped[str | None] = mapped_column(String, default="") + adapt_index: Mapped[int | None] = mapped_column(Integer, default=0) + adapt_level: Mapped[str | None] = mapped_column(String, default="") + tracks: Mapped[str | None] = mapped_column(Text, default="") + version: Mapped[str | None] = mapped_column(String, default="") class Course(Base): - __tablename__ = "training_courses" - id: Mapped[str] = mapped_column(String, primary_key=True) - category: Mapped[str] = mapped_column(String, default="") - level: Mapped[str] = mapped_column(String, default="") - title: Mapped[str] = mapped_column(String, default="") - subtitle: Mapped[str] = mapped_column(String, default="") - desc: Mapped[str] = mapped_column(Text, default="") - price: Mapped[float] = mapped_column(Float, default=0.0) - status: Mapped[str] = mapped_column(String, default="draft") - start_at: Mapped[str] = mapped_column(String, default="") - end_at: Mapped[str] = mapped_column(String, default="") - venue: Mapped[str] = mapped_column(String, default="") - quota: Mapped[int] = mapped_column(Integer, default=0) - image: Mapped[str] = mapped_column(String, default="") - host: Mapped[str] = mapped_column(String, default="") - created_at: Mapped[str] = mapped_column(String, default="") + __tablename__ = "courses" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + category: Mapped[str | None] = mapped_column(String, default="") + level: Mapped[str | None] = mapped_column(String, default="") + title: Mapped[str | None] = mapped_column(String, default="") + subtitle: Mapped[str | None] = mapped_column(String, default="") + desc: Mapped[str | None] = mapped_column(Text, default="") + price: Mapped[float | None] = mapped_column(Float, default=0.0) + status: Mapped[str | None] = mapped_column(String, default="draft") + start_at: Mapped[str | None] = mapped_column(String, default="") + end_at: Mapped[str | None] = mapped_column(String, default="") + venue: Mapped[str | None] = mapped_column(String, default="") + quota: Mapped[int | None] = mapped_column(Integer, default=0) + image: Mapped[str | None] = mapped_column(String, default="") + host: Mapped[str | None] = mapped_column(String, default="") + created_at: Mapped[str | None] = mapped_column(String, default="") class PolicyLog(Base): - __tablename__ = "training_policy_logs" - id: Mapped[str] = mapped_column(String, primary_key=True) - created_at: Mapped[str] = mapped_column(String, default="") - username: Mapped[str] = mapped_column(String, default="") - answers: Mapped[str] = mapped_column(Text, default="") - policies_count: Mapped[int] = mapped_column(Integer, default=0) - subsidies_count: Mapped[int] = mapped_column(Integer, default=0) - loans_count: Mapped[int] = mapped_column(Integer, default=0) - summary: Mapped[str] = mapped_column(Text, default="") + __tablename__ = "policy_logs" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + created_at: Mapped[str | None] = mapped_column(String, default="") + username: Mapped[str | None] = mapped_column(String, default="") + answers: Mapped[str | None] = mapped_column(Text, default="") + policies_count: Mapped[int | None] = mapped_column(Integer, default=0) + subsidies_count: Mapped[int | None] = mapped_column(Integer, default=0) + loans_count: Mapped[int | None] = mapped_column(Integer, default=0) + summary: Mapped[str | None] = mapped_column(Text, default="") class PlanLog(Base): - __tablename__ = "training_plan_logs" - id: Mapped[str] = mapped_column(String, primary_key=True) - created_at: Mapped[str] = mapped_column(String, default="") - username: Mapped[str] = mapped_column(String, default="") - region: Mapped[str] = mapped_column(String, default="") - status: Mapped[str] = mapped_column(String, default="") - need_park: Mapped[int] = mapped_column(Integer, default=0) - has_staff: Mapped[int] = mapped_column(Integer, default=0) - steps_count: Mapped[int] = mapped_column(Integer, default=0) + __tablename__ = "plan_logs" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + created_at: Mapped[str | None] = mapped_column(String, default="") + username: Mapped[str | None] = mapped_column(String, default="") + region: Mapped[str | None] = mapped_column(String, default="") + status: Mapped[str | None] = mapped_column(String, default="") + need_park: Mapped[int | None] = mapped_column(Integer, default=0) + has_staff: Mapped[int | None] = mapped_column(Integer, default=0) + steps_count: Mapped[int | None] = mapped_column(Integer, default=0) class SurveyLog(Base): - __tablename__ = "training_survey_logs" - id: Mapped[str] = mapped_column(String, primary_key=True) - created_at: Mapped[str] = mapped_column(String, default="") - username: Mapped[str] = mapped_column(String, default="") - source: Mapped[str] = mapped_column(String, default="") - answers: Mapped[str] = mapped_column(Text, default="") + __tablename__ = "survey_logs" + id: Mapped[str | None] = mapped_column(String, primary_key=True) + created_at: Mapped[str | None] = mapped_column(String, default="") + username: Mapped[str | None] = mapped_column(String, default="") + source: Mapped[str | None] = mapped_column(String, default="") + answers: Mapped[str | None] = mapped_column(Text, default="") # =========================================================================== diff --git a/app/training/db.py b/app/training/db.py index 9675e1c..861ac2a 100644 --- a/app/training/db.py +++ b/app/training/db.py @@ -8,7 +8,7 @@ import time import random BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # server-core 根 -DB_PATH = os.path.join(BASE_DIR, "serverdata", "data", "opc.db") +DB_PATH = os.path.join(BASE_DIR, "serverdata", "data", "app.db") os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) @@ -163,64 +163,7 @@ SEED_COURSES = [ ] -def init_db(): - conn = get_conn() - conn.executescript(SCHEMA) - # 种子:内置超级账号(pine/123456,兼容原 mock) - if conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] == 0: - from .auth import hash_password - conn.execute( - "INSERT INTO accounts (id,username,password,name,identities,created_at) VALUES (?,?,?,?,?,?)", - ("U-PINE", "pine", hash_password("123456"), "Pine", json.dumps(["admin"]), now_iso()), - ) - # 种子:排期 - if conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] == 0: - for e in SEED_EVENTS: - conn.execute( - "INSERT INTO events (id,type,mode,title,subtitle,desc,location,host,image,link,start_at,duration_min,capacity,status) " - "VALUES (:id,:type,:mode,:title,:subtitle,:desc,:location,:host,:image,:link,:start_at,:duration_min,:capacity,:status)", - e, - ) - # 种子:在线课程(运营端课程管理) - if conn.execute("SELECT COUNT(*) AS c FROM courses").fetchone()["c"] == 0: - for c in SEED_COURSES: - conn.execute( - "INSERT INTO courses (id,category,level,title,subtitle,desc,price,status,start_at,end_at,venue,quota,image,host,created_at) " - "VALUES (:id,:category,:level,:title,:subtitle,:desc,:price,:status,:start_at,:end_at,:venue,:quota,:image,:host,:created_at)", - c, - ) - # -- 幂等迁移:为已有 bookings 表补 audit_status 列 -- - cols = [r["name"] for r in conn.execute("PRAGMA table_info(bookings)").fetchall()] - if "audit_status" not in cols: - conn.execute("ALTER TABLE bookings ADD COLUMN audit_status TEXT DEFAULT 'pending'") - # -- 幂等迁移:为已有 accounts 表补 wxid / phone / phone_bound / contact / avatar 列(旧库自愈,避免微信登录报缺列或每次新建账号) -- - acols = [r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()] - for col, ddl in [ - ("wxid", "ALTER TABLE accounts ADD COLUMN wxid TEXT"), - ("phone", "ALTER TABLE accounts ADD COLUMN phone TEXT"), - ("contact", "ALTER TABLE accounts ADD COLUMN contact TEXT"), - ("avatar", "ALTER TABLE accounts ADD COLUMN avatar TEXT"), - ("phone_bound", "ALTER TABLE accounts ADD COLUMN phone_bound INTEGER DEFAULT 0"), - # 报名资料(个人中心设置,报名时自动带入) - ("status_label", "ALTER TABLE accounts ADD COLUMN status_label TEXT"), - ("topics", "ALTER TABLE accounts ADD COLUMN topics TEXT"), - ("source", "ALTER TABLE accounts ADD COLUMN source TEXT"), - ]: - if col not in acols: - conn.execute(ddl) - # -- 幂等迁移:为已有 events 表补 结束时间 / 签到开放时间 列 -- - ecols = [r["name"] for r in conn.execute("PRAGMA table_info(events)").fetchall()] - for col, ddl in [ - ("end_at", "ALTER TABLE events ADD COLUMN end_at TEXT"), - ("checkin_at", "ALTER TABLE events ADD COLUMN checkin_at TEXT"), - ("audit_mode", "ALTER TABLE events ADD COLUMN audit_mode TEXT DEFAULT 'auto'"), - ("show_capacity", "ALTER TABLE events ADD COLUMN show_capacity INTEGER DEFAULT 0"), - ]: - if col not in ecols: - conn.execute(ddl) - conn.commit() - conn.close() - +# 运行时建表/种子已移除:表由 alembic(ORM 模型 accounts/events/... )创建;种子经 scripts/db/seed.py。 # ---------------- 通用读写 helpers ---------------- diff --git a/app/training/main.py b/app/training/main.py index 1c4c32a..cf5c5aa 100644 --- a/app/training/main.py +++ b/app/training/main.py @@ -21,7 +21,7 @@ from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app): - await asyncio.to_thread(db.init_db) + # 建表/种子由 alembic + scripts/db/seed.py 非运行态完成 yield diff --git a/scripts/db/seed.py b/scripts/db/seed.py index 0217fd2..b9ba843 100644 --- a/scripts/db/seed.py +++ b/scripts/db/seed.py @@ -23,14 +23,37 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(messag log = logging.getLogger("db.seed") +def _seed_training() -> None: + """培训种子(唯一总库 app.db):超管账号 + 排期 + 在线课程(幂等)。""" + import sqlite3 + import json + from app.training import db as tdb + conn = sqlite3.connect(tdb.DB_PATH) + conn.row_factory = sqlite3.Row + try: + if conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] == 0: + from app.training.auth import hash_password + conn.execute("INSERT INTO accounts (id,username,password,name,identities,created_at) VALUES (?,?,?,?,?,?)", + ("U-PINE", "pine", hash_password("123456"), "Pine", json.dumps(["admin"]), tdb.now_iso())) + if conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] == 0: + for e in tdb.SEED_EVENTS: + conn.execute("INSERT INTO events (id,type,mode,title,subtitle,desc,location,host,image,link,start_at,duration_min,capacity,status) VALUES (:id,:type,:mode,:title,:subtitle,:desc,:location,:host,:image,:link,:start_at,:duration_min,:capacity,:status)", e) + if conn.execute("SELECT COUNT(*) AS c FROM courses").fetchone()["c"] == 0: + for c in tdb.SEED_COURSES: + conn.execute("INSERT INTO courses (id,category,level,title,subtitle,desc,price,status,start_at,end_at,venue,quota,image,host,created_at) VALUES (:id,:category,:level,:title,:subtitle,:desc,:price,:status,:start_at,:end_at,:venue,:quota,:image,:host,:created_at)", c) + conn.commit() + log.info("培训种子完成(账号/排期/课程)") + finally: + conn.close() + + async def run() -> None: db = Database() try: - # 平台种子(角色/权限/用户/政务/OPC/投资人/工作台等,幂等) await platform_seed(db.session) await db.session.commit() log.info("平台种子完成:%s", config.DATABASE_URL) - # TODO(P2/P3 后):园区默认园区+企业、培训事件课程 经 serverdata/seed/*.json 灌入(当前由各自子应用迁移后接入) + _seed_training() except Exception as e: # noqa: BLE001 await db.session.rollback() log.error("种子失败:%s", e)