Initial commit
This commit is contained in:
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""core 包:配置、数据库访问、字体。"""
|
||||
65
core/db.py
Normal file
65
core/db.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""数据库访问层。
|
||||
|
||||
提供 run_query:读取 .sql 文件、剥离注释、参数化绑定执行、返回 list[dict]。
|
||||
所有报表查询必须走参数绑定,禁止字符串拼接 SQL(防注入)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from core.settings import settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""惰性创建并缓存 SQLAlchemy engine。"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def load_sql(sql_path: str | Path) -> str:
|
||||
"""读取 .sql 文件并剥离行级 `--` 注释,返回纯 SQL 文本。
|
||||
|
||||
支持 SQL 文件里自由书写中文注释(便于阅读与 AI 理解)。
|
||||
仅剥离以 `--` 开头的整行注释(行首可有空白),不处理行内注释,
|
||||
避免误删含 `--` 的字符串字面量。
|
||||
"""
|
||||
path = Path(sql_path)
|
||||
if not path.is_absolute():
|
||||
from core.settings import PROJECT_ROOT
|
||||
|
||||
path = PROJECT_ROOT / path
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
lines = []
|
||||
for line in raw.splitlines():
|
||||
# 去掉行首空白后判断是否为注释行
|
||||
if re.match(r"\s*--", line):
|
||||
continue
|
||||
lines.append(line)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def run_query(sql_path: str | Path, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""执行参数化查询,返回 list[dict](每行一个 dict,键为列别名)。
|
||||
|
||||
:param sql_path: .sql 文件路径(相对项目根或绝对路径)。
|
||||
:param params: 绑定参数,键名对应 SQL 中的 :name 占位符。
|
||||
"""
|
||||
sql_text = load_sql(sql_path)
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(sql_text), params)
|
||||
cols = list(result.keys())
|
||||
return [dict(zip(cols, row)) for row in result.fetchall()]
|
||||
46
core/fonts.py
Normal file
46
core/fonts.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""中文字体配置。
|
||||
|
||||
reportbro-lib 的核心字体(helvetica 等)无法编码 CJK,需通过 additional_fonts
|
||||
注册支持中文的 TrueType 字体。本机使用 Windows 自带的 simhei.ttf(黑体)。
|
||||
|
||||
模板里所有样式的 font 字段必须设为这里的 value(如 "simhei")。
|
||||
若将来跨机器部署,将字体文件放入 assets/ 并改为相对路径。
|
||||
"""
|
||||
import os
|
||||
|
||||
# 模板中引用的字体名(小写,reportbro 内部按小写存储)
|
||||
CJK_FONT_NAME = "simhei"
|
||||
|
||||
# 默认字体文件路径(Windows 系统字体目录)
|
||||
_DEFAULT_FONT_PATH = r"C:/Windows/Fonts/simhei.ttf"
|
||||
_BAHNSCHRIFT_PATH = r"C:/Windows/Fonts/bahnschrift.ttf"
|
||||
|
||||
|
||||
def additional_fonts() -> list[dict]:
|
||||
"""返回 reportbro Report(additional_fonts=...) 所需的字体清单。
|
||||
|
||||
注册两类字体:
|
||||
- simhei:中文(CJK)主字体
|
||||
- bahnschrift:西文等宽风格字体,用于装箱单信息条(排产号/订单号等编号)
|
||||
若环境变量 REPORT_CJK_FONT 指定了备用 ttf 路径,则改用该路径。
|
||||
simhei 无独立 bold/italic 文件,四种字形全部映射到同一个 ttf。
|
||||
"""
|
||||
font_path = os.environ.get("REPORT_CJK_FONT", _DEFAULT_FONT_PATH)
|
||||
if not os.path.exists(font_path):
|
||||
raise FileNotFoundError(
|
||||
f"中文字体文件不存在: {font_path}\n"
|
||||
f"请安装 simhei.ttf,或通过环境变量 REPORT_CJK_FONT 指定其它支持中文的 ttf 路径。"
|
||||
)
|
||||
fonts = [
|
||||
{
|
||||
"value": CJK_FONT_NAME,
|
||||
"filename": font_path,
|
||||
# bold_filename / italic_filename / bold_italic_filename 省略,
|
||||
# reportbro 会自动把所有字形映射到 filename(见 FPDFRB 初始化)。
|
||||
}
|
||||
]
|
||||
# Bahnschrift(西文编号字体,可选;缺失不致命)
|
||||
bahn_path = os.environ.get("REPORT_LATIN_FONT", _BAHNSCHRIFT_PATH)
|
||||
if os.path.exists(bahn_path):
|
||||
fonts.append({"value": "bahnschrift", "filename": bahn_path})
|
||||
return fonts
|
||||
111
core/settings.py
Normal file
111
core/settings.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""数据库配置加载。
|
||||
|
||||
与 services/fastapi(CargoTrace 后端)的 config/settings.py 保持同构,
|
||||
复用同一份 config/settings.yaml 凭据。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic_yaml import parse_yaml_raw_as
|
||||
from sqlalchemy.engine import URL
|
||||
|
||||
|
||||
class SqlServerConfig(BaseModel):
|
||||
"""SQL Server 连接配置"""
|
||||
|
||||
host: str
|
||||
port: int = 1433
|
||||
database: str
|
||||
username: str
|
||||
password: str
|
||||
driver: str = "{ODBC Driver 18 for SQL Server}"
|
||||
trust_server_certificate: str = "yes"
|
||||
|
||||
|
||||
class PostgreSqlConfig(BaseModel):
|
||||
"""PostgreSQL 连接配置"""
|
||||
|
||||
host: str
|
||||
port: int = 5432
|
||||
database: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""数据库配置"""
|
||||
|
||||
active: Literal["sql_server", "postgresql"] = "sql_server"
|
||||
sql_server: SqlServerConfig
|
||||
postgresql: PostgreSqlConfig | None = None
|
||||
|
||||
|
||||
class ReportConfig(BaseModel):
|
||||
"""报表配置"""
|
||||
|
||||
# 调试开关:True 时给所有文本元素加边框,便于核对每个元素的实际占位。
|
||||
debug_border: bool = False
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""应用配置"""
|
||||
|
||||
database: DatabaseConfig
|
||||
report: ReportConfig = ReportConfig()
|
||||
|
||||
@property
|
||||
def database_url(self) -> URL:
|
||||
"""构建数据库连接 URL"""
|
||||
if self.database.active == "postgresql":
|
||||
return self._postgresql_url()
|
||||
return self._sql_server_url()
|
||||
|
||||
def _sql_server_url(self) -> URL:
|
||||
conf = self.database.sql_server
|
||||
return URL.create(
|
||||
"mssql+pyodbc",
|
||||
username=conf.username,
|
||||
password=conf.password,
|
||||
host=conf.host,
|
||||
port=conf.port,
|
||||
database=conf.database,
|
||||
query={
|
||||
"driver": conf.driver.strip("{}"),
|
||||
"TrustServerCertificate": conf.trust_server_certificate,
|
||||
},
|
||||
)
|
||||
|
||||
def _postgresql_url(self) -> URL:
|
||||
conf = self.database.postgresql
|
||||
if conf is None:
|
||||
raise ValueError("已选择 postgresql,但未配置 database.postgresql")
|
||||
return URL.create(
|
||||
"postgresql+psycopg",
|
||||
username=conf.username,
|
||||
password=conf.password,
|
||||
host=conf.host,
|
||||
port=conf.port,
|
||||
database=conf.database,
|
||||
)
|
||||
|
||||
|
||||
# 项目根目录:core/settings.py 的 parent.parent
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load_settings(config_path: str = "config/settings.yaml") -> Settings:
|
||||
"""加载 YAML 配置文件(相对路径基于项目根目录解析)。"""
|
||||
path = Path(config_path)
|
||||
if not path.is_absolute():
|
||||
path = PROJECT_ROOT / config_path
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"配置文件不存在: {config_path}\n"
|
||||
f"请复制 config/settings.example.yaml 为 config/settings.yaml 并填入凭据。"
|
||||
)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return parse_yaml_raw_as(Settings, f)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
Reference in New Issue
Block a user