517 lines
16 KiB
Python
517 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
||
|
||
"""基于FastAPI和Uvicorn的RESTfulAPI服务"""
|
||
|
||
from contextlib import asynccontextmanager
|
||
from datetime import date, datetime
|
||
from typing import AsyncGenerator, Literal, Optional, Union
|
||
from urllib.parse import quote_plus
|
||
|
||
import pytz
|
||
from distance import levenshtein
|
||
from fastapi import Depends, FastAPI, HTTPException, Header, status
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||
from lunarcalendar import Lunar
|
||
from pydantic import BaseModel, Field, field_validator
|
||
from sqlalchemy import create_engine, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
from sqlalchemy.ext.automap import automap_base
|
||
|
||
# -------------------------
|
||
# 应用初始化配置
|
||
# -------------------------
|
||
|
||
# 创建FastAPI对象
|
||
application = FastAPI(
|
||
title="api.liubiren.cloud",
|
||
description="刘弼仁工作室的请求服务中心,如需使用请联系liubiren@qq.com",
|
||
version="0.0.1",
|
||
swagger_ui_init_oauth={
|
||
"clientId": "api-docs-client",
|
||
"scopes": {},
|
||
"usePkceWithAuthorizationCodeGrant": True,
|
||
},
|
||
redoc_url=None,
|
||
)
|
||
|
||
# 配置全局安全策略(所有请求均需认证)
|
||
application.openapi_security = [{"BearerAuth": []}]
|
||
|
||
# -------------------------
|
||
# 请求和响应模型
|
||
# -------------------------
|
||
|
||
|
||
class Request(BaseModel):
|
||
"""统一请求模型"""
|
||
|
||
service: Literal["divination", "query_institution", "query_drug"] = Field(
|
||
...,
|
||
description="服务标识,数据类型为枚举,必填",
|
||
json_schema_extra={
|
||
"枚举描述": {
|
||
"divination": "小六壬速断",
|
||
"query_institution": "根据名称精准查询医药机构信息",
|
||
}
|
||
},
|
||
)
|
||
data: Union["DivinationRequest", "QueryInstitutionRequest", "QueryDrugRequest"] = (
|
||
Field(
|
||
...,
|
||
description="请求数据模型,根据服务标识传入相应的请求数据模型",
|
||
)
|
||
)
|
||
|
||
# 根据服务标识校验请求数据模型
|
||
# noinspection PyNestedDecorators
|
||
@field_validator("data")
|
||
@classmethod
|
||
def validate_data(cls, value, values):
|
||
service = values.data.get("service")
|
||
|
||
if service == "divination" and not isinstance(value, DivinationRequest):
|
||
raise ValueError("小六壬速断服务需要 DivinationRequest 请求数据模型")
|
||
|
||
if service == "query_institution" and not isinstance(
|
||
value, QueryInstitutionRequest
|
||
):
|
||
raise ValueError(
|
||
"根据名称精准查询医药机构信息服务需要 QueryInstitutionRequest 请求数据模型"
|
||
)
|
||
|
||
if service == "query_drug" and not isinstance(value, QueryDrugRequest):
|
||
raise ValueError(
|
||
"根据类型和名称模糊查询药品信息服务需要 QueryDrugRequest 请求数据模型"
|
||
)
|
||
|
||
return value
|
||
|
||
class Config:
|
||
json_schema_extra = {
|
||
"example": {
|
||
"service": "query_institution",
|
||
"data": {"name": "浙江大学医学院附属第一医院"},
|
||
}
|
||
}
|
||
|
||
|
||
class Response(BaseModel):
|
||
"""统一响应模型"""
|
||
|
||
code: int = Field(
|
||
default=0, description="错误码,0表示成功,其它表示发生错误或异常"
|
||
)
|
||
message: str = Field(
|
||
default="成功",
|
||
description="错误描述",
|
||
)
|
||
data: Union[
|
||
"DivinationResponse", "QueryInstitutionResponse", "QueryDrugResponse"
|
||
] = Field(default=None, description="响应数据模型")
|
||
|
||
|
||
# noinspection PyUnusedLocal
|
||
@application.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(request: Request, error: RequestValidationError):
|
||
return JSONResponse(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
content=Response(
|
||
code=422,
|
||
message="校验模型失败",
|
||
).model_dump(),
|
||
)
|
||
|
||
|
||
# noinspection PyUnusedLocal
|
||
@application.exception_handler(HTTPException)
|
||
async def http_exception_handler(request: Request, exception: HTTPException):
|
||
return JSONResponse(
|
||
status_code=exception.status_code,
|
||
content=Response(
|
||
code=exception.status_code,
|
||
message="请求发生异常",
|
||
).model_dump(),
|
||
)
|
||
|
||
|
||
# noinspection PyUnusedLocal
|
||
@application.exception_handler(Exception)
|
||
async def general_exception_handler(request: Request, exception: Exception):
|
||
return JSONResponse(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
content=Response(
|
||
code=500,
|
||
message="服务内部发生异常",
|
||
).model_dump(),
|
||
)
|
||
|
||
|
||
class DivinationRequest(BaseModel):
|
||
"""小六壬速断的请求数据模型"""
|
||
|
||
pass
|
||
|
||
|
||
class DivinationResponse(BaseModel):
|
||
"""小六壬速断的响应数据模型"""
|
||
|
||
fallen_palace: str = Field(
|
||
..., description="落宫,数据类型为字符串,非空", examples=["小吉"]
|
||
)
|
||
divination_verse: str = Field(
|
||
...,
|
||
description="卦辞,数据类型为字符串,非空",
|
||
examples=["小吉最吉昌,路上好商量,阴人来报喜,失物在坤方"],
|
||
)
|
||
|
||
|
||
class QueryInstitutionRequest(BaseModel):
|
||
"""根据名称精准查询医药机构信息的请求数据模型"""
|
||
|
||
name: str = Field(
|
||
...,
|
||
max_length=255,
|
||
description="医药机构名称,数据类型为字符串,非空",
|
||
examples=["浙江大学医学院附属第一医院"],
|
||
)
|
||
|
||
# noinspection PyNestedDecorators
|
||
@field_validator("name")
|
||
@classmethod
|
||
def validate_name(cls, value: str) -> str:
|
||
"""删除名称前后空格"""
|
||
return value.strip()
|
||
|
||
|
||
class QueryInstitutionResponse(BaseModel):
|
||
"""根据名称精准查询医药机构信息的响应数据模型"""
|
||
|
||
name: str = Field(
|
||
...,
|
||
description="机构名称,数据类型为字符串,非空",
|
||
examples=["浙江大学医学院附属第一医院"],
|
||
)
|
||
province: str = Field(
|
||
...,
|
||
description="机构所在省,数据类型为字符串,非空",
|
||
examples=["浙江省"],
|
||
)
|
||
city: str = Field(
|
||
...,
|
||
description="机构所在地,数据类型为字符串,非空",
|
||
examples=["杭州市"],
|
||
)
|
||
type: str = Field(
|
||
...,
|
||
description="机构类型,数据类型为字符串,非空",
|
||
examples=["医疗机构"],
|
||
)
|
||
incurred: str = Field(
|
||
...,
|
||
description="是否为医保定点机构,数据类型为字符串,非空",
|
||
examples=["是"],
|
||
)
|
||
level: Optional[str] = Field(
|
||
None,
|
||
description="机构等级,数据类型为字符串,可空",
|
||
examples=["三级甲等"],
|
||
)
|
||
|
||
attribute: Optional[str] = Field(
|
||
None,
|
||
description="机构属性,数据类型为字符串,可空",
|
||
examples=["公立医院、非营利性医院"],
|
||
)
|
||
|
||
|
||
class QueryDrugRequest(BaseModel):
|
||
"""根据类型和名称模糊查询药品信息的请求数据模型"""
|
||
|
||
type: Literal["西药", "中草药", "中成药"] = Field(
|
||
...,
|
||
description="药品类型,数据类型为枚举,非空",
|
||
examples=["西药"],
|
||
)
|
||
name: str = Field(
|
||
...,
|
||
max_length=255,
|
||
description="药品名称,数据类型为字符串,非空",
|
||
examples=["[达悦宁]盐酸二甲双胍缓释片 0.5*30"],
|
||
)
|
||
|
||
# noinspection PyNestedDecorators
|
||
@field_validator("name")
|
||
@classmethod
|
||
def validate_name(cls, value: str) -> str:
|
||
"""删除名称前后空格"""
|
||
return value.strip()
|
||
|
||
|
||
class QueryDrugResponse(BaseModel):
|
||
"""根据类型和名称模糊查询药品信息的响应数据模型"""
|
||
|
||
name: str = Field(
|
||
...,
|
||
description="药品名称,数据类型为字符串,非空",
|
||
examples=["盐酸二甲双胍缓释片"],
|
||
)
|
||
|
||
|
||
# -------------------------
|
||
# 依赖项与工具函数
|
||
# -------------------------
|
||
|
||
|
||
async def authenticate_headers(
|
||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
|
||
content_type: str = Header(
|
||
default="application/json; charset=utf-8",
|
||
description="媒体类型",
|
||
),
|
||
) -> bool:
|
||
"""校验请求头中Content-Type和Authorization Bearer token"""
|
||
|
||
if "application/json" not in content_type:
|
||
raise HTTPException(status_code=415, detail="只接受JSON格式数据")
|
||
|
||
if not credentials or credentials.credentials != "779E0501265CDF7B8124EB87199994B8":
|
||
raise HTTPException(status_code=403, detail="认证失败")
|
||
|
||
return True
|
||
|
||
|
||
# 创建MySQL连接引擎(默认使用DATABASE数据库)
|
||
engine = create_async_engine(
|
||
url=f"mysql+asyncmy://root:{quote_plus('Te198752')}@cdb-7z9lzx4y.cd.tencentcdb.com:10039/database?charset=utf8",
|
||
pool_size=10, # 连接池常驻连接数
|
||
max_overflow=10, # 连接池最大溢出连接数
|
||
pool_recycle=3600, # 连接回收时间(秒)
|
||
pool_pre_ping=True, # 连接前验证有效性
|
||
)
|
||
|
||
# 创建ORM对象
|
||
Base = automap_base()
|
||
Base.prepare(
|
||
autoload_with=create_engine(
|
||
f"mysql+pymysql://root:{quote_plus('Te198752')}@cdb-7z9lzx4y.cd.tencentcdb.com:10039/database?charset=utf8"
|
||
)
|
||
)
|
||
|
||
|
||
# 初始化MySQL会话工厂
|
||
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
||
"""数据库会话上下文管理器"""
|
||
async with AsyncSessionLocal() as session:
|
||
try:
|
||
yield session
|
||
await session.commit()
|
||
except:
|
||
await session.rollback()
|
||
raise
|
||
finally:
|
||
await session.close()
|
||
|
||
|
||
# -------------------------
|
||
# 服务路由
|
||
# -------------------------
|
||
|
||
|
||
@application.post(
|
||
path="/",
|
||
dependencies=[Depends(authenticate_headers)],
|
||
response_model=Response,
|
||
response_description="响应成功",
|
||
responses={
|
||
200: {
|
||
"model": Response,
|
||
"content": {
|
||
"application/json": {
|
||
"example": {
|
||
"code": 200,
|
||
"message": "医药机构信息不存在",
|
||
"data": None,
|
||
}
|
||
}
|
||
},
|
||
"description": "不存在",
|
||
},
|
||
422: {
|
||
"model": Response,
|
||
"content": {
|
||
"application/json": {
|
||
"example": {"code": 422, "message": "校验模型失败", "data": None}
|
||
}
|
||
},
|
||
"description": "校验模型失败",
|
||
},
|
||
500: {
|
||
"model": Response,
|
||
"content": {
|
||
"application/json": {
|
||
"example": {
|
||
"code": 500,
|
||
"message": "服务内部发生异常",
|
||
"data": None,
|
||
}
|
||
}
|
||
},
|
||
"description": "服务内部发生异常",
|
||
},
|
||
},
|
||
name="服务中心",
|
||
description="所有请求均由本中心提供响应服务",
|
||
)
|
||
async def service(
|
||
request: Request,
|
||
) -> Response:
|
||
|
||
# 根据服务标识匹配服务
|
||
# noinspection PyUnreachableCode
|
||
match request.service:
|
||
case "divination":
|
||
return await divination()
|
||
case "query_institution":
|
||
return await query_institution(request)
|
||
case "query_drug":
|
||
return await query_drug(request)
|
||
case _:
|
||
return Response(code=400, message="无效的服务标识")
|
||
|
||
|
||
async def divination() -> Response:
|
||
"""小六壬速断"""
|
||
|
||
# 起算日期时间
|
||
starting = datetime.now(tz=pytz.timezone("Asia/Shanghai"))
|
||
# 起算日期转为农历
|
||
lunar = Lunar.from_date(date(starting.year, starting.month, starting.day))
|
||
|
||
# 根据农历月日和时辰匹配落宫和卦辞
|
||
divination = [
|
||
{
|
||
"fallen_palace": "空亡",
|
||
"divination_verse": "空亡事不详,阴人少乖张,求财无利益,行人有灾殃",
|
||
},
|
||
{
|
||
"fallen_palace": "大安",
|
||
"divination_verse": "大安事事昌,求财在坤方,失物去不远,宅舍保安康",
|
||
},
|
||
{
|
||
"fallen_palace": "留连",
|
||
"divination_verse": "留连事难成,求谋日未明,官事只宜缓,去者未回程",
|
||
},
|
||
{
|
||
"fallen_palace": "速喜",
|
||
"divination_verse": "速喜喜来临,求财向南行,失物申午未,寻人路上寻",
|
||
},
|
||
{
|
||
"fallen_palace": "赤口",
|
||
"divination_verse": "赤口主口舌,是非要紧防,失物速速讨,行人有惊慌",
|
||
},
|
||
{
|
||
"fallen_palace": "小吉",
|
||
"divination_verse": "小吉最吉昌,路上好商量,阴人来报喜,失物在坤方",
|
||
},
|
||
][
|
||
(lunar.month + lunar.day + ((starting.hour + 3) // 2) % 12 + 4) % 6
|
||
] # 需先将24制小时转为时辰,再根据月、日和时辰数落宫
|
||
|
||
return Response(
|
||
data=DivinationResponse(
|
||
fallen_palace=divination["fallen_palace"],
|
||
divination_verse=divination["divination_verse"],
|
||
)
|
||
)
|
||
|
||
|
||
async def query_institution(request: Request) -> Response:
|
||
"""根据名称精准查询医药机构信息"""
|
||
|
||
async with create_session() as session:
|
||
# noinspection PyTypeChecker
|
||
institution = (
|
||
await session.execute(
|
||
select(Base.classes.institution)
|
||
.join(Base.classes.institution_alias)
|
||
.where(Base.classes.institution_alias.name == request.data.name)
|
||
)
|
||
).scalar_one_or_none()
|
||
if institution is None:
|
||
return Response(code=204, message="医药机构信息不存在")
|
||
|
||
return Response(
|
||
data=QueryInstitutionResponse(
|
||
name=institution.name,
|
||
province=institution.province,
|
||
city=institution.city,
|
||
type=institution.type,
|
||
incurred=institution.incurred,
|
||
level=institution.level,
|
||
attribute=institution.attribute,
|
||
)
|
||
)
|
||
|
||
|
||
async def query_drug(request: Request) -> Response:
|
||
"""根据类型和名称模型查询药品信息"""
|
||
|
||
async with create_session() as session:
|
||
# 基于MySQL全文检索能力,召回与检索词高度相关的药品名称
|
||
drugs_name = (
|
||
(
|
||
await session.execute(
|
||
text(
|
||
"""
|
||
SELECT name
|
||
FROM drug
|
||
WHERE MATCH(name) AGAINST(:name IN NATURAL LANGUAGE MODE) AND type = :type
|
||
ORDER BY
|
||
(name = :name) DESC,
|
||
MATCH(name) AGAINST(:name) DESC,
|
||
LENGTH(name) ASC
|
||
LIMIT 10
|
||
"""
|
||
).bindparams(type=request.data.type, name=request.data.name)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
|
||
result = None
|
||
for drug_name in drugs_name:
|
||
# 若检索词包含药品名称则以此作为结果
|
||
if drug_name in request.data.name:
|
||
result = drug_name
|
||
break
|
||
|
||
# 若
|
||
if result is None:
|
||
|
||
round((1 - levenshtein(string1, string2) / (len(string1) + len(string2))) * 100)
|
||
|
||
print(drugs_name)
|
||
|
||
if not drugs_name:
|
||
return Response(code=204, message="药品信息不存在")
|
||
|
||
return Response(
|
||
data=QueryDrugResponse(
|
||
name=drugs_name[0],
|
||
)
|
||
)
|
||
|
||
|
||
"""
|
||
|
||
|
||
|
||
"""
|