Quellcode durchsuchen

feat(backend): add models, init_db, and idempotent seed data

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu vor 2 Monaten
Ursprung
Commit
27c9c404cd

+ 34 - 0
backend/app/core/database.py

@@ -0,0 +1,34 @@
+"""数据库引擎、会话与建表"""
+from sqlalchemy import create_engine
+from sqlalchemy.orm import declarative_base, sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.core.config import get_settings
+
+settings = get_settings()
+
+_engine_kwargs = {}
+if settings.DATABASE_URL.startswith("sqlite"):
+    _engine_kwargs["connect_args"] = {"check_same_thread": False}
+    # :memory: 需 StaticPool,否则不同连接看不到同一库
+    if ":memory:" in settings.DATABASE_URL:
+        _engine_kwargs["poolclass"] = StaticPool
+
+engine = create_engine(settings.DATABASE_URL, **_engine_kwargs)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()
+
+
+def init_db() -> None:
+    # Import models so metadata is registered before create_all
+    import app.models  # noqa: F401
+
+    Base.metadata.create_all(bind=engine)

+ 17 - 1
backend/app/main.py

@@ -1,12 +1,28 @@
+from contextlib import asynccontextmanager
+
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from app.core.config import get_settings
+from app.core.database import SessionLocal, init_db
 from app.core.response import success_response
+from app.services.seed import seed_if_empty
 
 settings = get_settings()
 
-app = FastAPI(title=settings.APP_NAME)
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    init_db()
+    db = SessionLocal()
+    try:
+        seed_if_empty(db)
+    finally:
+        db.close()
+    yield
+
+
+app = FastAPI(title=settings.APP_NAME, lifespan=lifespan)
 
 _cors_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
 if not _cors_origins:

+ 22 - 0
backend/app/models/__init__.py

@@ -0,0 +1,22 @@
+"""ORM 模型导出"""
+from app.models.system import SysUser, SysRole, SysMenu, SysRoleMenu, OperLog
+from app.models.business import (
+    Feedback,
+    Suggestion,
+    HotlineConfig,
+    QrcodeConfig,
+    Attachment,
+)
+
+__all__ = [
+    "SysUser",
+    "SysRole",
+    "SysMenu",
+    "SysRoleMenu",
+    "OperLog",
+    "Feedback",
+    "Suggestion",
+    "HotlineConfig",
+    "QrcodeConfig",
+    "Attachment",
+]

+ 100 - 0
backend/app/models/business.py

@@ -0,0 +1,100 @@
+"""业务域模型:反馈、建言、热线、二维码、附件"""
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, Integer, String, Text
+from sqlalchemy import JSON
+
+from app.core.database import Base
+
+
+def _uuid() -> str:
+    return str(uuid.uuid4())
+
+
+class Feedback(Base):
+    __tablename__ = "feedback"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    biz_no = Column(String(64), nullable=False, unique=True, index=True)
+    fb_type = Column(String(16), nullable=False)  # 投诉 | 表扬
+    target_name = Column(String(64), nullable=True)
+    target_dept = Column(String(128), nullable=True)
+    target_post = Column(String(64), nullable=True)
+    content = Column(Text, nullable=False)
+    reporter_name = Column(String(64), nullable=True)
+    reporter_phone = Column(String(32), nullable=True)
+    attachments = Column(JSON, nullable=False, default=list)
+    status = Column(String(16), nullable=False, default="待处理")  # 待处理|处理中|已办结
+    handle_remark = Column(Text, nullable=True)
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class Suggestion(Base):
+    __tablename__ = "suggestion"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    biz_no = Column(String(64), nullable=False, unique=True, index=True)
+    content = Column(Text, nullable=False)
+    contact_name = Column(String(64), nullable=True)
+    contact_unit = Column(String(128), nullable=True)
+    contact_post = Column(String(64), nullable=True)
+    contact_phone = Column(String(32), nullable=True)
+    attachments = Column(JSON, nullable=False, default=list)
+    status = Column(String(16), nullable=False, default="待查阅")  # 待查阅|已采纳|已回复
+    handle_remark = Column(Text, nullable=True)
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class HotlineConfig(Base):
+    __tablename__ = "hotline_config"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    phone = Column(String(64), nullable=True, default="")
+    email = Column(String(128), nullable=True, default="")
+    address = Column(String(255), nullable=True, default="")
+    work_time = Column(String(255), nullable=True, default="")
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class QrcodeConfig(Base):
+    __tablename__ = "qrcode_config"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    land_url = Column(String(512), nullable=True, default="")
+    title = Column(String(128), nullable=True, default="")
+    image_url = Column(String(512), nullable=True, default="")
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class Attachment(Base):
+    __tablename__ = "attachment"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    file_name = Column(String(255), nullable=False)
+    object_key = Column(String(512), nullable=False)
+    url = Column(String(512), nullable=False)
+    size = Column(Integer, nullable=True, default=0)
+    biz_type = Column(String(32), nullable=True)
+    biz_id = Column(String(64), nullable=True, index=True)
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)

+ 80 - 0
backend/app/models/system.py

@@ -0,0 +1,80 @@
+"""系统域模型:用户、角色、菜单、操作日志"""
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, Integer, String, Text
+
+from app.core.database import Base
+
+
+def _uuid() -> str:
+    return str(uuid.uuid4())
+
+
+class SysUser(Base):
+    __tablename__ = "sys_user"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    username = Column(String(64), nullable=False, unique=True, index=True)
+    password_hash = Column(String(255), nullable=False)
+    display_name = Column(String(64), nullable=False, default="")
+    role_id = Column(String(64), nullable=False, index=True)
+    status = Column(String(1), nullable=False, default="0")  # 0启用 / 1停用
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class SysRole(Base):
+    __tablename__ = "sys_role"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    role_code = Column(String(64), nullable=False, unique=True, index=True)
+    role_name = Column(String(64), nullable=False)
+    remark = Column(String(255), nullable=True)
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class SysMenu(Base):
+    __tablename__ = "sys_menu"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    parent_id = Column(String(64), nullable=False, default="0", index=True)
+    menu_code = Column(String(64), nullable=False, unique=True, index=True)
+    menu_name = Column(String(64), nullable=False)
+    path = Column(String(255), nullable=True, default="")
+    menu_type = Column(String(1), nullable=False)  # M目录 / C菜单 / F按钮
+    sort = Column(Integer, nullable=False, default=0)
+    visible = Column(String(1), nullable=False, default="1")  # 1显示 / 0隐藏
+    del_flag = Column(String(1), nullable=False, default="0")
+    create_time = Column(DateTime, default=datetime.utcnow)
+    update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    create_by = Column(String(64), nullable=True)
+    update_by = Column(String(64), nullable=True)
+
+
+class SysRoleMenu(Base):
+    __tablename__ = "sys_role_menu"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    role_id = Column(String(64), nullable=False, index=True)
+    menu_id = Column(String(64), nullable=False, index=True)
+
+
+class OperLog(Base):
+    __tablename__ = "oper_log"
+
+    id = Column(String(64), primary_key=True, default=_uuid)
+    user_id = Column(String(64), nullable=True, index=True)
+    username = Column(String(64), nullable=True)
+    module = Column(String(64), nullable=True)
+    action = Column(String(64), nullable=True)
+    detail = Column(Text, nullable=True)
+    ip = Column(String(64), nullable=True)
+    create_time = Column(DateTime, default=datetime.utcnow)

+ 0 - 0
backend/app/services/__init__.py


+ 165 - 0
backend/app/services/seed.py

@@ -0,0 +1,165 @@
+"""幂等种子数据:角色、菜单、管理员、热线/二维码配置"""
+import uuid
+from typing import Dict, List
+
+import bcrypt
+from sqlalchemy.orm import Session
+
+from app.core.config import get_settings
+from app.models.business import HotlineConfig, QrcodeConfig
+from app.models.system import SysMenu, SysRole, SysRoleMenu, SysUser
+
+
+def _uuid() -> str:
+    return str(uuid.uuid4())
+
+
+def _hash_password(plain: str) -> str:
+    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+# 菜单定义:(menu_code, menu_name, path, menu_type, sort, parent_code|None)
+MENU_DEFS = [
+    ("dashboard", "工作台", "/dashboard", "C", 10, None),
+    ("feedback", "在线反馈", "/feedback", "C", 20, None),
+    ("suggestion", "建言献策", "/suggestion", "C", 30, None),
+    ("hotline", "监督热线", "/hotline", "C", 40, None),
+    ("qrcode", "二维码配置", "/qrcode", "C", 50, None),
+    ("user", "用户管理", "/user", "C", 60, None),
+    ("role", "角色授权", "/role", "C", 70, None),
+    ("audit", "操作审计", "/audit", "C", 80, None),
+    ("feedback:handle", "反馈办理", "", "F", 21, "feedback"),
+    ("suggestion:handle", "建言办理", "", "F", 31, "suggestion"),
+    ("hotline:write", "热线编辑", "", "F", 41, "hotline"),
+    ("qrcode:write", "二维码编辑", "", "F", 51, "qrcode"),
+    ("user:manage", "用户管理操作", "", "F", 61, "user"),
+    ("role:grant", "角色授权操作", "", "F", 71, "role"),
+]
+
+ROLE_DEFS = [
+    ("admin", "系统管理员", "全部菜单与按钮权限"),
+    ("handler", "办理员", "工作台、反馈、建言及办理权限"),
+    ("viewer", "查阅员", "工作台、反馈、建言查阅(无办理)"),
+]
+
+# role_code -> menu_code 列表;admin 用特殊标记表示全部
+ROLE_MENU_CODES: Dict[str, List[str] | str] = {
+    "admin": "*",
+    "handler": [
+        "dashboard",
+        "feedback",
+        "suggestion",
+        "feedback:handle",
+        "suggestion:handle",
+    ],
+    "viewer": ["dashboard", "feedback", "suggestion"],
+}
+
+
+def seed_if_empty(db: Session) -> None:
+    """若库中尚无角色则写入种子;已有则直接返回(幂等)。"""
+    if db.query(SysRole).count() > 0:
+        return
+
+    settings = get_settings()
+    now_by = "system"
+
+    # --- roles ---
+    role_by_code: Dict[str, SysRole] = {}
+    for code, name, remark in ROLE_DEFS:
+        role = SysRole(
+            id=_uuid(),
+            role_code=code,
+            role_name=name,
+            remark=remark,
+            del_flag="0",
+            create_by=now_by,
+            update_by=now_by,
+        )
+        db.add(role)
+        role_by_code[code] = role
+    db.flush()
+
+    # --- menus ---
+    menu_by_code: Dict[str, SysMenu] = {}
+    for code, name, path, mtype, sort, parent_code in MENU_DEFS:
+        parent_id = "0"
+        if parent_code:
+            parent_id = menu_by_code[parent_code].id
+        menu = SysMenu(
+            id=_uuid(),
+            parent_id=parent_id,
+            menu_code=code,
+            menu_name=name,
+            path=path,
+            menu_type=mtype,
+            sort=sort,
+            visible="1",
+            del_flag="0",
+            create_by=now_by,
+            update_by=now_by,
+        )
+        db.add(menu)
+        menu_by_code[code] = menu
+    db.flush()
+
+    # --- role_menu ---
+    for role_code, codes in ROLE_MENU_CODES.items():
+        role = role_by_code[role_code]
+        if codes == "*":
+            target_codes = list(menu_by_code.keys())
+        else:
+            target_codes = codes  # type: ignore[assignment]
+        for menu_code in target_codes:
+            db.add(
+                SysRoleMenu(
+                    id=_uuid(),
+                    role_id=role.id,
+                    menu_id=menu_by_code[menu_code].id,
+                )
+            )
+
+    # --- admin user ---
+    admin_role = role_by_code["admin"]
+    db.add(
+        SysUser(
+            id=_uuid(),
+            username=settings.INIT_ADMIN_USERNAME,
+            password_hash=_hash_password(settings.INIT_ADMIN_PASSWORD),
+            display_name="管理员",
+            role_id=admin_role.id,
+            status="0",
+            del_flag="0",
+            create_by=now_by,
+            update_by=now_by,
+        )
+    )
+
+    # --- hotline / qrcode defaults ---
+    if db.query(HotlineConfig).count() == 0:
+        db.add(
+            HotlineConfig(
+                id=_uuid(),
+                phone="0551-00000000",
+                email="jj@example.com",
+                address="安徽建工集团医院纪检室",
+                work_time="工作日 9:00-17:00",
+                del_flag="0",
+                create_by=now_by,
+                update_by=now_by,
+            )
+        )
+    if db.query(QrcodeConfig).count() == 0:
+        db.add(
+            QrcodeConfig(
+                id=_uuid(),
+                land_url="https://example.com/h5/",
+                title="安建医院纪检扫码反馈",
+                image_url="",
+                del_flag="0",
+                create_by=now_by,
+                update_by=now_by,
+            )
+        )
+
+    db.commit()

+ 165 - 0
backend/docs/sql/001_init.sql

@@ -0,0 +1,165 @@
+-- ajyy 初始化 DDL(MySQL 5.7+ / utf8mb4)对照文档
+-- 与 SQLAlchemy ORM 模型对齐;生产可用 Alembic 或手动执行。
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+CREATE TABLE IF NOT EXISTS `sys_role` (
+  `id` varchar(64) NOT NULL,
+  `role_code` varchar(64) NOT NULL,
+  `role_name` varchar(64) NOT NULL,
+  `remark` varchar(255) DEFAULT NULL,
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_sys_role_code` (`role_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `sys_user` (
+  `id` varchar(64) NOT NULL,
+  `username` varchar(64) NOT NULL,
+  `password_hash` varchar(255) NOT NULL,
+  `display_name` varchar(64) NOT NULL DEFAULT '',
+  `role_id` varchar(64) NOT NULL,
+  `status` varchar(1) NOT NULL DEFAULT '0' COMMENT '0启用 1停用',
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_sys_user_username` (`username`),
+  KEY `idx_sys_user_role` (`role_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `sys_menu` (
+  `id` varchar(64) NOT NULL,
+  `parent_id` varchar(64) NOT NULL DEFAULT '0',
+  `menu_code` varchar(64) NOT NULL,
+  `menu_name` varchar(64) NOT NULL,
+  `path` varchar(255) DEFAULT '',
+  `menu_type` varchar(1) NOT NULL COMMENT 'M目录 C菜单 F按钮',
+  `sort` int NOT NULL DEFAULT 0,
+  `visible` varchar(1) NOT NULL DEFAULT '1',
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_sys_menu_code` (`menu_code`),
+  KEY `idx_sys_menu_parent` (`parent_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `sys_role_menu` (
+  `id` varchar(64) NOT NULL,
+  `role_id` varchar(64) NOT NULL,
+  `menu_id` varchar(64) NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_srm_role` (`role_id`),
+  KEY `idx_srm_menu` (`menu_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `feedback` (
+  `id` varchar(64) NOT NULL,
+  `biz_no` varchar(64) NOT NULL,
+  `fb_type` varchar(16) NOT NULL COMMENT '投诉|表扬',
+  `target_name` varchar(64) DEFAULT NULL,
+  `target_dept` varchar(128) DEFAULT NULL,
+  `target_post` varchar(64) DEFAULT NULL,
+  `content` text NOT NULL,
+  `reporter_name` varchar(64) DEFAULT NULL,
+  `reporter_phone` varchar(32) DEFAULT NULL,
+  `attachments` json DEFAULT NULL,
+  `status` varchar(16) NOT NULL DEFAULT '待处理' COMMENT '待处理|处理中|已办结',
+  `handle_remark` text,
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_feedback_biz_no` (`biz_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `suggestion` (
+  `id` varchar(64) NOT NULL,
+  `biz_no` varchar(64) NOT NULL,
+  `content` text NOT NULL,
+  `contact_name` varchar(64) DEFAULT NULL,
+  `contact_unit` varchar(128) DEFAULT NULL,
+  `contact_post` varchar(64) DEFAULT NULL,
+  `contact_phone` varchar(32) DEFAULT NULL,
+  `attachments` json DEFAULT NULL,
+  `status` varchar(16) NOT NULL DEFAULT '待查阅' COMMENT '待查阅|已采纳|已回复',
+  `handle_remark` text,
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_suggestion_biz_no` (`biz_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `hotline_config` (
+  `id` varchar(64) NOT NULL,
+  `phone` varchar(64) DEFAULT '',
+  `email` varchar(128) DEFAULT '',
+  `address` varchar(255) DEFAULT '',
+  `work_time` varchar(255) DEFAULT '',
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `qrcode_config` (
+  `id` varchar(64) NOT NULL,
+  `land_url` varchar(512) DEFAULT '',
+  `title` varchar(128) DEFAULT '',
+  `image_url` varchar(512) DEFAULT '',
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `attachment` (
+  `id` varchar(64) NOT NULL,
+  `file_name` varchar(255) NOT NULL,
+  `object_key` varchar(512) NOT NULL,
+  `url` varchar(512) NOT NULL,
+  `size` int DEFAULT 0,
+  `biz_type` varchar(32) DEFAULT NULL,
+  `biz_id` varchar(64) DEFAULT NULL,
+  `del_flag` varchar(1) NOT NULL DEFAULT '0',
+  `create_time` datetime DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_attachment_biz` (`biz_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `oper_log` (
+  `id` varchar(64) NOT NULL,
+  `user_id` varchar(64) DEFAULT NULL,
+  `username` varchar(64) DEFAULT NULL,
+  `module` varchar(64) DEFAULT NULL,
+  `action` varchar(64) DEFAULT NULL,
+  `detail` text,
+  `ip` varchar(64) DEFAULT NULL,
+  `create_time` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_oper_log_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 27 - 0
backend/tests/conftest.py

@@ -0,0 +1,27 @@
+"""pytest fixtures"""
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.core.database import Base
+import app.models  # noqa: F401 — register metadata
+
+
+@pytest.fixture()
+def db_session():
+    """独立内存库会话,供种子与模型测试使用。"""
+    engine = create_engine(
+        "sqlite+pysqlite:///:memory:",
+        connect_args={"check_same_thread": False},
+        poolclass=StaticPool,
+    )
+    Base.metadata.create_all(bind=engine)
+    TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+    session = TestingSessionLocal()
+    try:
+        yield session
+    finally:
+        session.close()
+        Base.metadata.drop_all(bind=engine)
+        engine.dispose()

+ 10 - 0
backend/tests/test_seed.py

@@ -0,0 +1,10 @@
+def test_seed_creates_admin_and_roles(db_session):
+    from app.services.seed import seed_if_empty
+    from app.models.system import SysUser, SysRole, SysMenu, SysRoleMenu
+
+    seed_if_empty(db_session)
+    seed_if_empty(db_session)  # idempotent
+    assert db_session.query(SysRole).count() == 3
+    assert db_session.query(SysUser).filter_by(username="admin").count() == 1
+    assert db_session.query(SysMenu).count() >= 8
+    assert db_session.query(SysRoleMenu).count() >= 1