upload project source code

This commit is contained in:
2026-04-30 18:49:43 +08:00
commit 9b394ba682
2277 changed files with 660945 additions and 0 deletions

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanAboutUsService
from .schema import YifanAboutUsCreateSchema, YifanAboutUsUpdateSchema, YifanAboutUsQueryParam
YifanAboutUsRouter = APIRouter(prefix='/yifan_about_us', tags=["品牌介绍模块"])
@YifanAboutUsRouter.get("/miniapp/info", summary="小程序获取品牌介绍", description="小程序获取品牌介绍(获取启用的第一条)")
async def get_miniapp_about_us_controller(
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""小程序获取品牌介绍接口"""
auth = AuthSchema(db=db)
result_dict = await YifanAboutUsService.get_miniapp_about_us_service(auth=auth)
log.info("小程序获取品牌介绍成功")
return SuccessResponse(data=result_dict, msg="获取品牌介绍成功")
@YifanAboutUsRouter.get("/detail/{id}", summary="获取品牌介绍详情", description="获取品牌介绍详情")
async def get_yifan_about_us_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:query"]))
) -> JSONResponse:
"""获取品牌介绍详情接口"""
result_dict = await YifanAboutUsService.detail_yifan_about_us_service(auth=auth, id=id)
log.info(f"获取品牌介绍详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取品牌介绍详情成功")
@YifanAboutUsRouter.get("/list", summary="查询品牌介绍列表", description="查询品牌介绍列表")
async def get_yifan_about_us_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanAboutUsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:query"]))
) -> JSONResponse:
"""查询品牌介绍列表接口(数据库分页)"""
result_dict = await YifanAboutUsService.page_yifan_about_us_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询品牌介绍列表成功")
return SuccessResponse(data=result_dict, msg="查询品牌介绍列表成功")
@YifanAboutUsRouter.post("/create", summary="创建品牌介绍", description="创建品牌介绍")
async def create_yifan_about_us_controller(
data: YifanAboutUsCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:create"]))
) -> JSONResponse:
"""创建品牌介绍接口"""
result_dict = await YifanAboutUsService.create_yifan_about_us_service(auth=auth, data=data)
log.info("创建品牌介绍成功")
return SuccessResponse(data=result_dict, msg="创建品牌介绍成功")
@YifanAboutUsRouter.put("/update/{id}", summary="修改品牌介绍", description="修改品牌介绍")
async def update_yifan_about_us_controller(
data: YifanAboutUsUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:update"]))
) -> JSONResponse:
"""修改品牌介绍接口"""
result_dict = await YifanAboutUsService.update_yifan_about_us_service(auth=auth, id=id, data=data)
log.info("修改品牌介绍成功")
return SuccessResponse(data=result_dict, msg="修改品牌介绍成功")
@YifanAboutUsRouter.delete("/delete", summary="删除品牌介绍", description="删除品牌介绍")
async def delete_yifan_about_us_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:delete"]))
) -> JSONResponse:
"""删除品牌介绍接口"""
await YifanAboutUsService.delete_yifan_about_us_service(auth=auth, ids=ids)
log.info(f"删除品牌介绍成功: {ids}")
return SuccessResponse(msg="删除品牌介绍成功")
@YifanAboutUsRouter.patch("/available/setting", summary="批量修改品牌介绍状态", description="批量修改品牌介绍状态")
async def batch_set_available_yifan_about_us_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:patch"]))
) -> JSONResponse:
"""批量修改品牌介绍状态接口"""
await YifanAboutUsService.set_available_yifan_about_us_service(auth=auth, data=data)
log.info(f"批量修改品牌介绍状态成功: {data.ids}")
return SuccessResponse(msg="批量修改品牌介绍状态成功")
@YifanAboutUsRouter.post('/export', summary="导出品牌介绍", description="导出品牌介绍")
async def export_yifan_about_us_list_controller(
search: YifanAboutUsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:export"]))
) -> StreamingResponse:
"""导出品牌介绍接口"""
result_dict_list = await YifanAboutUsService.list_yifan_about_us_service(search=search, auth=auth)
export_result = await YifanAboutUsService.batch_export_yifan_about_us_service(obj_list=result_dict_list)
log.info('导出品牌介绍成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_about_us.xlsx'
}
)
@YifanAboutUsRouter.post('/import', summary="导入品牌介绍", description="导入品牌介绍")
async def import_yifan_about_us_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_us:import"]))
) -> JSONResponse:
"""导入品牌介绍接口"""
batch_import_result = await YifanAboutUsService.batch_import_yifan_about_us_service(file=file, auth=auth, update_support=True)
log.info("导入品牌介绍成功")
return SuccessResponse(data=batch_import_result, msg="导入品牌介绍成功")
@YifanAboutUsRouter.post('/download/template', summary="获取品牌介绍导入模板", description="获取品牌介绍导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_about_us:download"]))])
async def export_yifan_about_us_template_controller() -> StreamingResponse:
"""获取品牌介绍导入模板接口"""
import_template_result = await YifanAboutUsService.import_template_download_yifan_about_us_service()
log.info('获取品牌介绍导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_about_us_template.xlsx'}
)

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanAboutUsModel
from .schema import YifanAboutUsCreateSchema, YifanAboutUsUpdateSchema, YifanAboutUsOutSchema
class YifanAboutUsCRUD(CRUDBase[YifanAboutUsModel, YifanAboutUsCreateSchema, YifanAboutUsUpdateSchema]):
"""品牌介绍数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanAboutUsModel, auth=auth)
async def get_by_id_yifan_about_us_crud(self, id: int, preload: list | None = None) -> YifanAboutUsModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanAboutUsModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_about_us_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanAboutUsModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanAboutUsModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_about_us_crud(self, data: YifanAboutUsCreateSchema) -> YifanAboutUsModel | None:
"""
创建
参数:
- data (YifanAboutUsCreateSchema): 创建模型
返回:
- YifanAboutUsModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_about_us_crud(self, id: int, data: YifanAboutUsUpdateSchema) -> YifanAboutUsModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanAboutUsUpdateSchema): 更新模型
返回:
- YifanAboutUsModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_about_us_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_about_us_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_about_us_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanAboutUsOutSchema,
preload=preload
)
async def get_first_available_yifan_about_us_crud(self) -> YifanAboutUsModel | None:
"""
获取第一条启用的品牌介绍(小程序用)
返回:
- YifanAboutUsModel | None: 模型实例或None
"""
result = await self.list(search={'status': '0'}, order_by=[{'id': 'asc'}])
return result[0] if result else None

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from sqlalchemy import Text, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanAboutUsModel(ModelMixin, UserMixin):
"""
品牌介绍表
"""
__tablename__: str = 'yifan_about_us'
__table_args__: dict[str, str] = {'comment': '品牌介绍'}
__loader_options__: list[str] = ["created_by", "updated_by"]
title: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='标题')
content: Mapped[str | None] = mapped_column(Text, nullable=True, comment='内容')

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
import datetime
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanAboutUsCreateSchema(BaseModel):
"""
品牌介绍新增模型
"""
title: str | None = Field(default=None, description='标题')
content: str | None = Field(default=None, description='内容')
class YifanAboutUsUpdateSchema(YifanAboutUsCreateSchema):
"""
品牌介绍更新模型
"""
...
class YifanAboutUsOutSchema(YifanAboutUsCreateSchema, BaseSchema, UserBySchema):
"""
品牌介绍响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanAboutUsQueryParam:
"""品牌介绍查询参数"""
def __init__(
self,
title: str | None = Query(None, description="标题"),
content: str | None = Query(None, description="内容"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 模糊查询字段
self.title = ("like", title)
# 模糊查询字段
self.content = ("like", content)
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanAboutUsCreateSchema, YifanAboutUsUpdateSchema, YifanAboutUsOutSchema, YifanAboutUsQueryParam
from .crud import YifanAboutUsCRUD
class YifanAboutUsService:
"""
品牌介绍服务层
"""
@classmethod
async def detail_yifan_about_us_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanAboutUsCRUD(auth).get_by_id_yifan_about_us_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanAboutUsOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_miniapp_about_us_service(cls, auth: AuthSchema) -> dict | None:
"""小程序获取品牌介绍(获取第一条启用的数据)"""
obj = await YifanAboutUsCRUD(auth).get_first_available_yifan_about_us_crud()
if not obj:
return None
return YifanAboutUsOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_about_us_service(cls, auth: AuthSchema, search: YifanAboutUsQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanAboutUsCRUD(auth).list_yifan_about_us_crud(search=search_dict, order_by=order_by)
return [YifanAboutUsOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_about_us_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanAboutUsQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanAboutUsCRUD(auth).page_yifan_about_us_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_about_us_service(cls, auth: AuthSchema, data: YifanAboutUsCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanAboutUsCRUD(auth).create_yifan_about_us_crud(data=data)
return YifanAboutUsOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_about_us_service(cls, auth: AuthSchema, id: int, data: YifanAboutUsUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanAboutUsCRUD(auth).get_by_id_yifan_about_us_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanAboutUsCRUD(auth).update_yifan_about_us_crud(id=id, data=data)
return YifanAboutUsOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_about_us_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanAboutUsCRUD(auth).get_by_id_yifan_about_us_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanAboutUsCRUD(auth).delete_yifan_about_us_crud(ids=ids)
@classmethod
async def set_available_yifan_about_us_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanAboutUsCRUD(auth).set_available_yifan_about_us_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_about_us_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'title': '标题',
'content': '内容',
'created_id': '创建人ID',
'updated_id': '更新人ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_about_us_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'标题': 'title',
'内容': 'content',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"title": row['title'],
"content": row['content'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanAboutUsCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanAboutUsCRUD(auth).create_yifan_about_us_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_about_us_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'标题',
'内容',
'创建人ID',
'更新人ID',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanAboutVideoService
from .schema import YifanAboutVideoCreateSchema, YifanAboutVideoUpdateSchema, YifanAboutVideoQueryParam
YifanAboutVideoRouter = APIRouter(prefix='/yifan_about_video', tags=["视频内容模块"])
@YifanAboutVideoRouter.get("/miniapp/info", summary="小程序获取视频内容", description="小程序获取视频内容(获取启用的第一条)")
async def get_miniapp_about_video_controller(
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""小程序获取视频内容接口"""
auth = AuthSchema(db=db)
result_dict = await YifanAboutVideoService.get_miniapp_about_video_service(auth=auth)
log.info("小程序获取视频内容成功")
return SuccessResponse(data=result_dict, msg="获取视频内容成功")
@YifanAboutVideoRouter.get("/detail/{id}", summary="获取视频内容详情", description="获取视频内容详情")
async def get_yifan_about_video_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:query"]))
) -> JSONResponse:
"""获取视频内容详情接口"""
result_dict = await YifanAboutVideoService.detail_yifan_about_video_service(auth=auth, id=id)
log.info(f"获取视频内容详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取视频内容详情成功")
@YifanAboutVideoRouter.get("/list", summary="查询视频内容列表", description="查询视频内容列表")
async def get_yifan_about_video_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanAboutVideoQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:query"]))
) -> JSONResponse:
"""查询视频内容列表接口(数据库分页)"""
result_dict = await YifanAboutVideoService.page_yifan_about_video_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询视频内容列表成功")
return SuccessResponse(data=result_dict, msg="查询视频内容列表成功")
@YifanAboutVideoRouter.post("/create", summary="创建视频内容", description="创建视频内容")
async def create_yifan_about_video_controller(
data: YifanAboutVideoCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:create"]))
) -> JSONResponse:
"""创建视频内容接口"""
result_dict = await YifanAboutVideoService.create_yifan_about_video_service(auth=auth, data=data)
log.info("创建视频内容成功")
return SuccessResponse(data=result_dict, msg="创建视频内容成功")
@YifanAboutVideoRouter.put("/update/{id}", summary="修改视频内容", description="修改视频内容")
async def update_yifan_about_video_controller(
data: YifanAboutVideoUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:update"]))
) -> JSONResponse:
"""修改视频内容接口"""
result_dict = await YifanAboutVideoService.update_yifan_about_video_service(auth=auth, id=id, data=data)
log.info("修改视频内容成功")
return SuccessResponse(data=result_dict, msg="修改视频内容成功")
@YifanAboutVideoRouter.delete("/delete", summary="删除视频内容", description="删除视频内容")
async def delete_yifan_about_video_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:delete"]))
) -> JSONResponse:
"""删除视频内容接口"""
await YifanAboutVideoService.delete_yifan_about_video_service(auth=auth, ids=ids)
log.info(f"删除视频内容成功: {ids}")
return SuccessResponse(msg="删除视频内容成功")
@YifanAboutVideoRouter.patch("/available/setting", summary="批量修改视频内容状态", description="批量修改视频内容状态")
async def batch_set_available_yifan_about_video_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:patch"]))
) -> JSONResponse:
"""批量修改视频内容状态接口"""
await YifanAboutVideoService.set_available_yifan_about_video_service(auth=auth, data=data)
log.info(f"批量修改视频内容状态成功: {data.ids}")
return SuccessResponse(msg="批量修改视频内容状态成功")
@YifanAboutVideoRouter.post('/export', summary="导出视频内容", description="导出视频内容")
async def export_yifan_about_video_list_controller(
search: YifanAboutVideoQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:export"]))
) -> StreamingResponse:
"""导出视频内容接口"""
result_dict_list = await YifanAboutVideoService.list_yifan_about_video_service(search=search, auth=auth)
export_result = await YifanAboutVideoService.batch_export_yifan_about_video_service(obj_list=result_dict_list)
log.info('导出视频内容成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_about_video.xlsx'
}
)
@YifanAboutVideoRouter.post('/import', summary="导入视频内容", description="导入视频内容")
async def import_yifan_about_video_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_about_video:import"]))
) -> JSONResponse:
"""导入视频内容接口"""
batch_import_result = await YifanAboutVideoService.batch_import_yifan_about_video_service(file=file, auth=auth, update_support=True)
log.info("导入视频内容成功")
return SuccessResponse(data=batch_import_result, msg="导入视频内容成功")
@YifanAboutVideoRouter.post('/download/template', summary="获取视频内容导入模板", description="获取视频内容导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_about_video:download"]))])
async def export_yifan_about_video_template_controller() -> StreamingResponse:
"""获取视频内容导入模板接口"""
import_template_result = await YifanAboutVideoService.import_template_download_yifan_about_video_service()
log.info('获取视频内容导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_about_video_template.xlsx'}
)

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanAboutVideoModel
from .schema import YifanAboutVideoCreateSchema, YifanAboutVideoUpdateSchema, YifanAboutVideoOutSchema
class YifanAboutVideoCRUD(CRUDBase[YifanAboutVideoModel, YifanAboutVideoCreateSchema, YifanAboutVideoUpdateSchema]):
"""视频内容数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanAboutVideoModel, auth=auth)
async def get_by_id_yifan_about_video_crud(self, id: int, preload: list | None = None) -> YifanAboutVideoModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanAboutVideoModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_about_video_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanAboutVideoModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanAboutVideoModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_about_video_crud(self, data: YifanAboutVideoCreateSchema) -> YifanAboutVideoModel | None:
"""
创建
参数:
- data (YifanAboutVideoCreateSchema): 创建模型
返回:
- YifanAboutVideoModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_about_video_crud(self, id: int, data: YifanAboutVideoUpdateSchema) -> YifanAboutVideoModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanAboutVideoUpdateSchema): 更新模型
返回:
- YifanAboutVideoModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_about_video_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_about_video_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_about_video_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanAboutVideoOutSchema,
preload=preload
)
async def get_first_available_yifan_about_video_crud(self) -> YifanAboutVideoModel | None:
"""
获取第一条启用的视频内容(小程序用)
返回:
- YifanAboutVideoModel | None: 模型实例或None
"""
result = await self.list(search={'status': '0'}, order_by=[{'id': 'asc'}])
return result[0] if result else None

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanAboutVideoModel(ModelMixin, UserMixin):
"""
视频内容表
"""
__tablename__: str = 'yifan_about_video'
__table_args__: dict[str, str] = {'comment': '视频内容'}
__loader_options__: list[str] = ["created_by", "updated_by"]
title: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='视频标题')
subtitle: Mapped[str | None] = mapped_column(String(200), nullable=True, comment='副标题/期数')
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='封面图URL')
video_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='视频URL')

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
import datetime
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanAboutVideoCreateSchema(BaseModel):
"""
视频内容新增模型
"""
title: str | None = Field(default=None, description='视频标题')
subtitle: str | None = Field(default=None, description='副标题/期数')
cover_url: str | None = Field(default=None, description='封面图URL')
video_url: str | None = Field(default=None, description='视频URL')
status: int = Field(default=0, description='状态0禁用 1启用')
class YifanAboutVideoUpdateSchema(YifanAboutVideoCreateSchema):
"""
视频内容更新模型
"""
...
class YifanAboutVideoOutSchema(YifanAboutVideoCreateSchema, BaseSchema, UserBySchema):
"""
视频内容响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanAboutVideoQueryParam:
"""视频内容查询参数"""
def __init__(
self,
title: str | None = Query(None, description="视频标题"),
subtitle: str | None = Query(None, description="副标题/期数"),
cover_url: str | None = Query(None, description="封面图URL"),
video_url: str | None = Query(None, description="视频URL"),
status: int | None = Query(None, description="状态0禁用 1启用"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 模糊查询字段
self.title = ("like", title)
# 模糊查询字段
self.subtitle = ("like", subtitle)
# 模糊查询字段
self.cover_url = ("like", cover_url)
# 模糊查询字段
self.video_url = ("like", video_url)
# 精确查询字段
self.status = status
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanAboutVideoCreateSchema, YifanAboutVideoUpdateSchema, YifanAboutVideoOutSchema, YifanAboutVideoQueryParam
from .crud import YifanAboutVideoCRUD
class YifanAboutVideoService:
"""
视频内容服务层
"""
@classmethod
async def detail_yifan_about_video_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanAboutVideoCRUD(auth).get_by_id_yifan_about_video_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanAboutVideoOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_miniapp_about_video_service(cls, auth: AuthSchema) -> dict | None:
"""小程序获取视频内容(获取第一条启用的数据)"""
obj = await YifanAboutVideoCRUD(auth).get_first_available_yifan_about_video_crud()
if not obj:
return None
return YifanAboutVideoOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_about_video_service(cls, auth: AuthSchema, search: YifanAboutVideoQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanAboutVideoCRUD(auth).list_yifan_about_video_crud(search=search_dict, order_by=order_by)
return [YifanAboutVideoOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_about_video_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanAboutVideoQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanAboutVideoCRUD(auth).page_yifan_about_video_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_about_video_service(cls, auth: AuthSchema, data: YifanAboutVideoCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanAboutVideoCRUD(auth).create_yifan_about_video_crud(data=data)
return YifanAboutVideoOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_about_video_service(cls, auth: AuthSchema, id: int, data: YifanAboutVideoUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanAboutVideoCRUD(auth).get_by_id_yifan_about_video_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanAboutVideoCRUD(auth).update_yifan_about_video_crud(id=id, data=data)
return YifanAboutVideoOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_about_video_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanAboutVideoCRUD(auth).get_by_id_yifan_about_video_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanAboutVideoCRUD(auth).delete_yifan_about_video_crud(ids=ids)
@classmethod
async def set_available_yifan_about_video_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanAboutVideoCRUD(auth).set_available_yifan_about_video_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_about_video_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'title': '视频标题',
'subtitle': '副标题/期数',
'cover_url': '封面图URL',
'video_url': '视频URL',
'status': '状态0禁用 1启用',
'created_id': '创建人ID',
'updated_id': '更新人ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_about_video_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'视频标题': 'title',
'副标题/期数': 'subtitle',
'封面图URL': 'cover_url',
'视频URL': 'video_url',
'状态0禁用 1启用': 'status',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"title": row['title'],
"subtitle": row['subtitle'],
"cover_url": row['cover_url'],
"video_url": row['video_url'],
"status": row['status'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanAboutVideoCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanAboutVideoCRUD(auth).create_yifan_about_video_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_about_video_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'视频标题',
'副标题/期数',
'封面图URL',
'视频URL',
'状态0禁用 1启用',
'创建人ID',
'更新人ID',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.dependencies import AuthPermission
from app.common.response import SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.logger import log
from app.api.v1.module_yifan.yifan_affinity.service import YifanAffinityService
from app.api.v1.module_yifan.yifan_affinity.schema import (
YifanAffinityCreateSchema, YifanAffinityUpdateSchema, YifanAffinityQueryParam,
AffinityCalculateRequestSchema
)
YifanAffinityRouter = APIRouter(prefix="/yifan_affinity", tags=["缘分合盘"])
@YifanAffinityRouter.post("/calculate", summary="缘分合盘测算", description="缘分合盘测算,创建任务并异步生成分析结果")
async def calculate_affinity_controller(
data: AffinityCalculateRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""缘分合盘测算接口 - 创建任务,后台异步生成分析结果"""
result_dict = await YifanAffinityService.calculate_affinity_service(auth=auth, data=data)
log.info(f"缘分合盘任务已创建: {data.person1.name} & {data.person2.name} - {data.relationship}")
return SuccessResponse(data=result_dict, msg="缘分合盘任务已创建,正在测算中")
@YifanAffinityRouter.get("/result/{affinity_id}", summary="获取缘分合盘结果", description="根据ID获取缘分合盘测算结果")
async def get_affinity_result_controller(
affinity_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取缘分合盘结果接口"""
result_dict = await YifanAffinityService.get_affinity_result_service(auth=auth, affinity_id=affinity_id)
log.info(f"获取缘分合盘结果成功: ID={affinity_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取缘分合盘结果成功")
@YifanAffinityRouter.get("/list", summary="查询缘分合盘列表", description="查询缘分合盘列表")
async def get_yifan_affinity_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanAffinityQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""查询缘分合盘列表接口(数据库分页)"""
result_dict = await YifanAffinityService.page_yifan_affinity_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询缘分合盘列表成功")
return SuccessResponse(data=result_dict, msg="查询缘分合盘列表成功")
@YifanAffinityRouter.get("/{affinity_id}", summary="获取缘分合盘详情", description="根据ID获取缘分合盘详情")
async def get_yifan_affinity_controller(
affinity_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取缘分合盘详情接口"""
result_dict = await YifanAffinityService.get_yifan_affinity_service(auth=auth, affinity_id=affinity_id)
log.info("获取缘分合盘详情成功")
return SuccessResponse(data=result_dict, msg="获取缘分合盘详情成功")
@YifanAffinityRouter.post("/create", summary="创建缘分合盘", description="创建缘分合盘")
async def create_yifan_affinity_controller(
data: YifanAffinityCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_affinity:create"]))
) -> JSONResponse:
"""创建缘分合盘接口"""
result_dict = await YifanAffinityService.create_yifan_affinity_service(auth=auth, data=data)
log.info("创建缘分合盘成功")
return SuccessResponse(data=result_dict, msg="创建缘分合盘成功")
@YifanAffinityRouter.put("/{affinity_id}", summary="更新缘分合盘", description="根据ID更新缘分合盘")
async def update_yifan_affinity_controller(
affinity_id: int,
data: YifanAffinityUpdateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_affinity:update"]))
) -> JSONResponse:
"""更新缘分合盘接口"""
result_dict = await YifanAffinityService.update_yifan_affinity_service(auth=auth, affinity_id=affinity_id, data=data)
log.info("更新缘分合盘成功")
return SuccessResponse(data=result_dict, msg="更新缘分合盘成功")
@YifanAffinityRouter.delete("/{affinity_id}", summary="删除缘分合盘", description="根据ID删除缘分合盘")
async def delete_yifan_affinity_controller(
affinity_id: int,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_affinity:delete"]))
) -> JSONResponse:
"""删除缘分合盘接口"""
result = await YifanAffinityService.delete_yifan_affinity_service(auth=auth, affinity_id=affinity_id)
log.info("删除缘分合盘成功")
return SuccessResponse(data=result, msg="删除缘分合盘成功")

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_yifan.yifan_affinity.model import YifanAffinityModel
from app.api.v1.module_yifan.yifan_affinity.schema import YifanAffinityCreateSchema, YifanAffinityUpdateSchema
class YifanAffinityCRUD(CRUDBase[YifanAffinityModel, YifanAffinityCreateSchema, YifanAffinityUpdateSchema]):
"""缘分合盘CRUD操作"""
def __init__(self, auth: AuthSchema):
super().__init__(model=YifanAffinityModel, auth=auth)
async def create_yifan_affinity_crud(self, data: YifanAffinityCreateSchema) -> YifanAffinityModel:
"""
创建缘分合盘记录
参数:
- data (YifanAffinityCreateSchema): 创建数据
返回:
- YifanAffinityModel: 模型实例
"""
return await self.create(data=data)
async def get_yifan_affinity_crud(self, affinity_id: int, preload: list | None = None) -> YifanAffinityModel | None:
"""
获取单个缘分合盘记录
参数:
- affinity_id (int): 记录ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanAffinityModel | None: 模型实例或None
"""
return await self.get(id=affinity_id, preload=preload)
async def update_yifan_affinity_crud(self, affinity_id: int, data: YifanAffinityUpdateSchema) -> YifanAffinityModel:
"""
更新缘分合盘记录
参数:
- affinity_id (int): 记录ID
- data (YifanAffinityUpdateSchema): 更新数据
返回:
- YifanAffinityModel: 更新后的模型实例
"""
return await self.update(id=affinity_id, data=data)
async def delete_yifan_affinity_crud(self, affinity_id: int) -> bool:
"""
删除缘分合盘记录
参数:
- affinity_id (int): 记录ID
返回:
- bool: 删除是否成功
"""
return await self.delete(id=affinity_id)
async def list_yifan_affinity_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanAffinityModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanAffinityModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def page_yifan_affinity_crud(self, page_no: int, page_size: int, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- page_no (int): 页码
- page_size (int): 每页数量
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- dict: 分页结果字典
"""
from app.api.v1.module_yifan.yifan_affinity.schema import YifanAffinityOutSchema
# 将页码转换为偏移量
offset = (page_no - 1) * page_size
# 设置默认排序
if order_by is None:
order_by = [{"field": "created_time", "order": "desc"}]
return await self.page(
offset=offset,
limit=page_size,
search=search or {},
order_by=order_by,
out_schema=YifanAffinityOutSchema,
preload=preload
)

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import Integer, DateTime, SmallInteger, Text, String, JSON, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanAffinityModel(ModelMixin, UserMixin):
"""
缘分合盘表
"""
__tablename__: str = 'yifan_affinity'
__table_args__: dict[str, str] = {'comment': '缘分合盘'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
task_status: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户ID')
# 缘分类型
relationship: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='缘分类型(couple情侣 married夫妻 crush暗恋 partner合伙 friend知己 family亲缘)')
# 甲方信息
person1_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='甲方姓名')
person1_gender: Mapped[str | None] = mapped_column(String(10), nullable=True, comment='甲方性别(male男 female女)')
person1_birth_date: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='甲方生辰显示格式')
person1_birth_date_api: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='甲方生辰API格式')
# 乙方信息
person2_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='乙方姓名')
person2_gender: Mapped[str | None] = mapped_column(String(10), nullable=True, comment='乙方性别(male男 female女)')
person2_birth_date: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='乙方生辰显示格式')
person2_birth_date_api: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='乙方生辰API格式')
# 测算结果
score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='默契指数总分(0-100)')
score_badge: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='婚配等级(上上婚 上等婚 中等婚 下等婚)')
# 六维契合度评分(JSON格式存储)
six_dimension: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='六维契合度评分JSON')
radar_desc: Mapped[str | None] = mapped_column(Text, nullable=True, comment='六维雷达图描述')
# 分析卡片(JSON格式存储)
analysis_cards: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='分析卡片列表JSON')
# 解锁状态
is_unlocked: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否已解锁深度报告(0否 1是)')
unlock_price: Mapped[str | None] = mapped_column(Numeric(10, 2), nullable=True, comment='解锁价格')
# 解锁后的深度内容(JSON格式存储)
unlocked_content: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='解锁后的深度内容JSON')
# AI原始数据
ai_source_data: Mapped[str | None] = mapped_column(Text, nullable=True, comment='AI原始数据')
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment='错误信息')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
import datetime
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class PersonSchema(BaseModel):
"""人员信息模型"""
name: str = Field(..., description='姓名', example='张三')
gender: str = Field(..., description='性别', example='male')
birth_date: str = Field(..., description='生辰八字(显示格式)', example='1990年1月1日 子时')
birth_date_api: datetime.datetime = Field(..., description='生辰八字API格式', example='1990-01-01 00:30:00')
class AffinityCalculateRequestSchema(BaseModel):
"""缘分合盘测算请求模型"""
relationship: str = Field(..., description='缘分类型', example='couple')
person1: PersonSchema = Field(..., description='甲方信息(君)')
person2: PersonSchema = Field(..., description='乙方信息(卿)')
class SixDimensionSchema(BaseModel):
"""六维契合度评分模型"""
emotional: int = Field(..., description='情感共鸣', ge=0, le=100, example=92)
communication: int = Field(..., description='沟通模式', ge=0, le=100, example=78)
trust: int = Field(..., description='信任安全', ge=0, le=100, example=95)
values: int = Field(..., description='价值观', ge=0, le=100, example=85)
compatibility: int = Field(..., description='互补性', ge=0, le=100, example=88)
luck: int = Field(..., description='互旺运势', ge=0, le=100, example=88)
class AnalysisCardSchema(BaseModel):
"""分析卡片模型"""
id: str = Field(..., description='卡片ID', example='emotional')
icon: str = Field(..., description='图标emoji', example='[表情]')
icon_bg: str = Field(..., description='图标背景色', example='rgba(236, 72, 153, 0.2)')
title: str = Field(..., description='标题', example='情感共鸣')
score: str = Field(..., description='评分', example='92')
summary: str = Field(..., description='摘要(折叠时显示)')
content: str = Field(..., description='详细内容(展开时显示)')
class PastLifeSchema(BaseModel):
"""前世羁绊模型"""
description: str = Field(..., description='前世关系描述')
depth: str = Field(..., description='缘分深浅', example='三生石上旧精魂')
relationship: str = Field(..., description='还债关系', example='互不相欠 · 共同成长')
class MatchItemSchema(BaseModel):
"""正缘特征验证模型"""
label: str = Field(..., description='验证项', example='外貌特征')
match: int = Field(..., description='契合度百分比', ge=0, le=100, example=90)
desc: str = Field(..., description='描述')
class GuideSchema(BaseModel):
"""潜意识与相处指南模型"""
real_needs: str = Field(..., description='TA的真实需求')
red_zone: str = Field(..., description='绝对雷区')
tips: List[str] = Field(..., description='如何拿捏TA')
class MonthlyFortuneSchema(BaseModel):
"""未来12个月感情运势模型"""
heats: List[int] = Field(..., description='12个月的热度值0-3')
heat_labels: List[str] = Field(..., description='热度标签')
highlights: str = Field(..., description='高光时刻提示')
warnings: str = Field(..., description='预警时刻提示')
class TimelineItemSchema(BaseModel):
"""未来十年关键节点项模型"""
year: str = Field(..., description='年份', example='2025')
title: str = Field(..., description='阶段标题', example='升温期')
desc: str = Field(..., description='描述')
highlight: bool = Field(..., description='是否高亮显示')
class MasterAdviceSchema(BaseModel):
"""大师寄语与化解之道模型"""
message: str = Field(..., description='寄语')
tips: List[str] = Field(..., description='开运建议')
class UnlockedContentSchema(BaseModel):
"""解锁后的深度内容模型"""
past_life: PastLifeSchema = Field(..., description='前世羁绊')
match_items: List[MatchItemSchema] = Field(..., description='正缘特征验证')
guide: GuideSchema = Field(..., description='潜意识与相处指南')
monthly_fortune: MonthlyFortuneSchema = Field(..., description='未来12个月感情运势')
timeline: List[TimelineItemSchema] = Field(..., description='未来十年关键节点')
master_advice: MasterAdviceSchema = Field(..., description='大师寄语与化解之道')
class UnlockStatsSchema(BaseModel):
"""解锁统计信息模型"""
unlock_count: int = Field(..., description='已解锁人数', example=12392)
accuracy: str = Field(..., description='准确率', example='98%')
class PersonResponseSchema(BaseModel):
"""人员响应信息模型"""
name: str = Field(..., description='姓名')
gender: str = Field(..., description='性别')
birth_date: str = Field(..., description='生辰八字')
class AffinityCalculateResponseSchema(BaseModel):
"""缘分合盘测算响应模型"""
relationship: str = Field(..., description='缘分类型')
relationship_label: str = Field(..., description='缘分类型标签')
person1: PersonResponseSchema = Field(..., description='甲方信息')
person2: PersonResponseSchema = Field(..., description='乙方信息')
score: int = Field(..., description='默契指数总分', ge=0, le=100)
score_badge: str = Field(..., description='婚配等级')
six_dimension: SixDimensionSchema = Field(..., description='六维契合度评分')
radar_desc: str = Field(..., description='六维雷达图描述')
analysis_cards: List[AnalysisCardSchema] = Field(..., description='分析卡片列表')
unlocked: Optional[UnlockedContentSchema] = Field(None, description='解锁后的深度内容')
is_unlocked: bool = Field(..., description='是否已解锁深度报告')
unlock_price: float = Field(..., description='解锁价格(元)')
unlock_stats: UnlockStatsSchema = Field(..., description='解锁统计信息')
class YifanAffinityCreateSchema(BaseModel):
"""
缘分合盘新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
task_status: int = Field(default=1, description='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: int | None = Field(default=None, description='用户ID')
relationship: str = Field(default=..., description='缘分类型')
person1_name: str = Field(default=..., description='甲方姓名')
person1_gender: str = Field(default=..., description='甲方性别')
person1_birth_date: str = Field(default=..., description='甲方生辰显示格式')
person1_birth_date_api: datetime.datetime = Field(default=..., description='甲方生辰API格式')
person2_name: str = Field(default=..., description='乙方姓名')
person2_gender: str = Field(default=..., description='乙方性别')
person2_birth_date: str = Field(default=..., description='乙方生辰显示格式')
person2_birth_date_api: datetime.datetime = Field(default=..., description='乙方生辰API格式')
score: int | None = Field(default=None, description='默契指数总分')
score_badge: str | None = Field(default=None, description='婚配等级')
six_dimension: str | None = Field(default=None, description='六维契合度评分JSON')
radar_desc: str | None = Field(default=None, description='六维雷达图描述')
analysis_cards: str | None = Field(default=None, description='分析卡片列表JSON')
is_unlocked: int = Field(default=0, description='是否已解锁深度报告')
unlock_price: float | None = Field(default=9.9, description='解锁价格')
unlocked_content: str | None = Field(default=None, description='解锁后的深度内容JSON')
ai_source_data: str | None = Field(default=None, description='AI原始数据')
error_message: str | None = Field(default=None, description='错误信息')
remark: str | None = Field(default=None, description='备注')
class YifanAffinityUpdateSchema(BaseModel):
"""
缘分合盘更新模型
"""
is_deleted: int | None = Field(default=None, description='是否删除(0否 1是)')
task_status: int | None = Field(default=None, description='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: int | None = Field(default=None, description='用户ID')
relationship: str | None = Field(default=None, description='缘分类型')
person1_name: str | None = Field(default=None, description='甲方姓名')
person1_gender: str | None = Field(default=None, description='甲方性别')
person1_birth_date: str | None = Field(default=None, description='甲方生辰显示格式')
person1_birth_date_api: datetime.datetime | None = Field(default=None, description='甲方生辰API格式')
person2_name: str | None = Field(default=None, description='乙方姓名')
person2_gender: str | None = Field(default=None, description='乙方性别')
person2_birth_date: str | None = Field(default=None, description='乙方生辰显示格式')
person2_birth_date_api: datetime.datetime | None = Field(default=None, description='乙方生辰API格式')
score: int | None = Field(default=None, description='默契指数总分(0-100)')
score_badge: str | None = Field(default=None, description='婚配等级')
six_dimension: str | None = Field(default=None, description='六维契合度评分JSON')
radar_desc: str | None = Field(default=None, description='六维雷达图描述')
analysis_cards: str | None = Field(default=None, description='分析卡片列表JSON')
is_unlocked: int | None = Field(default=None, description='是否已解锁深度报告')
unlock_price: float | None = Field(default=None, description='解锁价格')
unlocked_content: str | None = Field(default=None, description='解锁后的深度内容JSON')
ai_source_data: str | None = Field(default=None, description='AI原始数据')
error_message: str | None = Field(default=None, description='错误信息')
remark: str | None = Field(default=None, description='备注')
class YifanAffinityOutSchema(YifanAffinityCreateSchema, BaseSchema, UserBySchema):
"""
缘分合盘响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanAffinityQueryParam:
"""缘分合盘查询参数"""
def __init__(
self,
relationship: str | None = Query(None, description="缘分类型"),
person1_name: str | None = Query(None, description="甲方姓名"),
person2_name: str | None = Query(None, description="乙方姓名"),
score_min: int | None = Query(None, description="最低分数"),
score_max: int | None = Query(None, description="最高分数"),
score_badge: str | None = Query(None, description="婚配等级"),
is_unlocked: int | None = Query(None, description="是否已解锁深度报告"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
task_status: int | None = Query(None, description="任务状态"),
user_id: int | None = Query(None, description="用户ID"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
self.updated_id = updated_id
self.is_deleted = is_deleted
self.task_status = task_status
self.user_id = user_id
self.is_unlocked = is_unlocked
# 模糊查询字段
self.relationship = ("like", relationship)
self.person1_name = ("like", person1_name)
self.person2_name = ("like", person2_name)
self.score_badge = ("like", score_badge)
# 范围查询字段
if score_min is not None and score_max is not None:
self.score = ("between", (score_min, score_max))
elif score_min is not None:
self.score = (">=", score_min)
elif score_max is not None:
self.score = ("<=", score_max)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,377 @@
# -*- coding: utf-8 -*-
import json
import asyncio
from typing import Dict, Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import log
from app.api.v1.module_yifan.yifan_affinity.crud import YifanAffinityCRUD
from app.api.v1.module_yifan.yifan_affinity.schema import (
YifanAffinityCreateSchema, YifanAffinityUpdateSchema, YifanAffinityOutSchema, YifanAffinityQueryParam,
AffinityCalculateRequestSchema, AffinityCalculateResponseSchema
)
class YifanAffinityService:
"""缘分合盘服务"""
@classmethod
async def calculate_affinity_service(cls, auth: AuthSchema, data: AffinityCalculateRequestSchema) -> Dict[str, Any]:
"""
缘分合盘测算服务(异步模式)
1. 校验数据创建报告记录status=1 已创建)
2. 启动后台任务执行AI测算
3. 立即返回 report_id + status
"""
user_id = auth.user.id if auth.user else 0
# 创建缘分合盘记录
create_data = YifanAffinityCreateSchema(
user_id=user_id,
relationship=data.relationship,
person1_name=data.person1.name,
person1_gender=data.person1.gender,
person1_birth_date=data.person1.birth_date,
person1_birth_date_api=data.person1.birth_date_api,
person2_name=data.person2.name,
person2_gender=data.person2.gender,
person2_birth_date=data.person2.birth_date,
person2_birth_date_api=data.person2.birth_date_api,
task_status=1,
is_deleted=0
)
try:
affinity_obj = await YifanAffinityCRUD(auth).create_yifan_affinity_crud(data=create_data)
affinity_id = affinity_obj.id
log.info(f"[缘分合盘] 报告创建成功ID: {affinity_id}")
except Exception as e:
log.error(f"[缘分合盘] 报告创建失败: {str(e)}")
raise CustomException(msg=f"保存缘分合盘报告失败: {str(e)}")
# 启动后台任务执行AI测算
asyncio.create_task(
cls._process_affinity_calculation(
affinity_id=affinity_id,
data=data,
user_id=user_id,
)
)
return {"affinity_id": affinity_id, "status": 1}
@classmethod
async def _process_affinity_calculation(
cls,
affinity_id: int,
data: AffinityCalculateRequestSchema,
user_id: int,
) -> None:
"""后台任务执行缘分合盘AI测算"""
log_prefix = f"[缘分合盘-{affinity_id}]"
try:
# 更新状态为测算中
await cls._update_affinity_status(affinity_id, task_status=2)
log.info(f"{log_prefix} 开始AI测算")
# 构建AI输入文本
relationship_labels = {
"couple": "情侣",
"married": "夫妻",
"crush": "暗恋",
"partner": "合伙",
"friend": "知己",
"family": "亲缘"
}
relationship_label = relationship_labels.get(data.relationship, data.relationship)
ai_input_text = f"""缘分合盘测算请求:
缘分类型:{relationship_label}
甲方信息:
- 姓名:{data.person1.name}
- 性别:{'' if data.person1.gender == 'male' else ''}
- 生辰:{data.person1.birth_date}
乙方信息:
- 姓名:{data.person2.name}
- 性别:{'' if data.person2.gender == 'male' else ''}
- 生辰:{data.person2.birth_date}
请根据以上信息进行缘分合盘测算,返回详细的分析结果。"""
# 调用AI服务进行测算
ai_response = await cls._call_ai_service(ai_input_text)
log.info(f"ai_response is {ai_response}")
if not ai_response:
raise Exception("AI服务返回空结果")
# 解析AI响应并构建结果数据
result_data = cls._parse_ai_response(ai_response, data, relationship_label)
log.info(f"result_data is {result_data}")
# 更新数据库记录
update_data = YifanAffinityUpdateSchema(
score=result_data.get('score'),
score_badge=result_data.get('score_badge'),
six_dimension=json.dumps(result_data.get('six_dimension'), ensure_ascii=False),
radar_desc=result_data.get('radar_desc'),
analysis_cards=json.dumps(result_data.get('analysis_cards'), ensure_ascii=False),
unlocked_content=json.dumps(result_data.get('unlocked'), ensure_ascii=False),
ai_source_data=ai_response,
task_status=5 # 测算成功
)
await cls._update_affinity_record(affinity_id, update_data)
log.info(f"{log_prefix} AI测算完成并保存成功")
except asyncio.TimeoutError:
log.error(f"{log_prefix} AI测算超时")
await cls._update_affinity_status(affinity_id, task_status=3, error_message="AI测算超时")
except Exception as e:
log.error(f"{log_prefix} AI测算失败: {str(e)}")
await cls._update_affinity_status(affinity_id, task_status=0, error_message=str(e)[:500])
@classmethod
async def _call_ai_service(cls, text: str) -> str:
"""
调用AI服务进行缘分合盘测算
Args:
text: 输入文本
Returns:
AI响应结果
"""
from app.api.v1.module_application.ai.service import AIModelTestService
log.info(f"[缘分合盘] 调用AI服务输入长度: {len(text)}")
try:
# 调用实际的AI服务使用personal_naming模型类型
ai_response = await AIModelTestService.test_naming(
model_type="yuanfen_hepan",
text=text
)
log.info(f"[缘分合盘] AI服务响应成功响应长度: {len(ai_response)}")
return ai_response
except Exception as e:
log.error(f"[缘分合盘] AI服务调用失败: {str(e)}")
# 如果AI服务调用失败返回模拟数据作为降级方案
log.warning("[缘分合盘] 使用模拟数据作为降级方案")
mock_response = {
"score": 85,
"score_badge": "上等婚",
"six_dimension": {
"personality": 88,
"career": 82,
"wealth": 90,
"health": 85,
"emotion": 87,
"family": 83
},
"radar_desc": "你们在财运和性格方面非常契合,情感交流也很顺畅。",
"analysis_cards": [
{
"title": "性格契合度",
"content": "你们的性格互补,能够相互理解和支持。",
"score": 88
},
{
"title": "事业发展",
"content": "在事业上你们有共同目标,能够相互促进。",
"score": 82
}
]
}
return json.dumps(mock_response, ensure_ascii=False)
@classmethod
def _parse_ai_response(cls, ai_response: str, request_data: AffinityCalculateRequestSchema, relationship_label: str) -> Dict[str, Any]:
"""解析AI响应数据"""
try:
ai_data = json.loads(ai_response)
# 构建完整的响应数据
result_data = {
"relationship": request_data.relationship,
"relationship_label": relationship_label,
"person1": {
"name": request_data.person1.name,
"gender": request_data.person1.gender,
"birth_date": request_data.person1.birth_date
},
"person2": {
"name": request_data.person2.name,
"gender": request_data.person2.gender,
"birth_date": request_data.person2.birth_date
},
"score": ai_data.get("score", 0),
"score_badge": ai_data.get("score_badge", "中等婚"),
"six_dimension": ai_data.get("six_dimension", {}),
"radar_desc": ai_data.get("radar_desc", ""),
"analysis_cards": ai_data.get("analysis_cards", []),
"unlocked": ai_data.get("unlocked", {}),
"is_unlocked": False,
"unlock_price": 9.9,
"unlock_stats": {
"unlock_count": 12392,
"accuracy": "98%"
}
}
return result_data
except json.JSONDecodeError as e:
log.error(f"AI响应JSON解析失败: {str(e)}")
raise Exception(f"AI响应格式错误: {str(e)}")
@classmethod
async def _update_affinity_status(cls, affinity_id: int, task_status: int, error_message: str = None):
"""更新缘分合盘状态"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanAffinityCRUD(temp_auth)
crud.session = session
update_data = YifanAffinityUpdateSchema(
task_status=task_status,
error_message=error_message
)
await crud.update_yifan_affinity_crud(affinity_id, update_data)
await session.commit() # 提交事务
log.info(f"[缘分合盘] 状态更新成功ID: {affinity_id}, 状态: {task_status}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[缘分合盘] 状态更新失败ID: {affinity_id}, 错误: {str(e)}")
raise
@classmethod
async def _update_affinity_record(cls, affinity_id: int, update_data: YifanAffinityUpdateSchema):
"""更新缘分合盘记录"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanAffinityCRUD(temp_auth)
crud.session = session
await crud.update_yifan_affinity_crud(affinity_id, update_data)
await session.commit() # 提交事务
log.info(f"[缘分合盘] 记录更新成功ID: {affinity_id}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[缘分合盘] 记录更新失败ID: {affinity_id}, 错误: {str(e)}")
raise
@classmethod
async def get_affinity_result_service(cls, auth: AuthSchema, affinity_id: int) -> AffinityCalculateResponseSchema:
"""获取缘分合盘结果"""
affinity_obj = await YifanAffinityCRUD(auth).get_yifan_affinity_crud(affinity_id)
if not affinity_obj:
raise CustomException(msg="缘分合盘记录不存在")
if affinity_obj.task_status != 5:
raise CustomException(msg="缘分合盘测算尚未完成")
# 构建响应数据
response_data = {
"relationship": affinity_obj.relationship,
"relationship_label": cls._get_relationship_label(affinity_obj.relationship),
"person1": {
"name": affinity_obj.person1_name,
"gender": affinity_obj.person1_gender,
"birth_date": affinity_obj.person1_birth_date
},
"person2": {
"name": affinity_obj.person2_name,
"gender": affinity_obj.person2_gender,
"birth_date": affinity_obj.person2_birth_date
},
"score": affinity_obj.score or 0,
"score_badge": affinity_obj.score_badge or "中等婚",
"six_dimension": json.loads(affinity_obj.six_dimension) if affinity_obj.six_dimension else {},
"radar_desc": affinity_obj.radar_desc or "",
"analysis_cards": json.loads(affinity_obj.analysis_cards) if affinity_obj.analysis_cards else [],
"unlocked": json.loads(affinity_obj.unlocked_content) if affinity_obj.unlocked_content and affinity_obj.is_unlocked else None,
"is_unlocked": bool(affinity_obj.is_unlocked),
"unlock_price": float(affinity_obj.unlock_price or 9.9),
"unlock_stats": {
"unlock_count": 12392,
"accuracy": "98%"
}
}
return AffinityCalculateResponseSchema(**response_data)
@classmethod
def _get_relationship_label(cls, relationship: str) -> str:
"""获取缘分类型标签"""
labels = {
"couple": "情侣",
"married": "夫妻",
"crush": "暗恋",
"partner": "合伙",
"friend": "知己",
"family": "亲缘"
}
return labels.get(relationship, relationship)
@classmethod
async def create_yifan_affinity_service(cls, auth: AuthSchema, data: YifanAffinityCreateSchema) -> dict:
"""创建缘分合盘记录"""
result_dict = await YifanAffinityCRUD(auth).create_yifan_affinity_crud(data=data)
return YifanAffinityOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def get_yifan_affinity_service(cls, auth: AuthSchema, affinity_id: int) -> dict:
"""获取单个缘分合盘记录"""
result_dict = await YifanAffinityCRUD(auth).get_yifan_affinity_crud(affinity_id=affinity_id)
if not result_dict:
raise CustomException(msg="缘分合盘记录不存在")
return YifanAffinityOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def update_yifan_affinity_service(cls, auth: AuthSchema, affinity_id: int, data: YifanAffinityUpdateSchema) -> dict:
"""更新缘分合盘记录"""
result_dict = await YifanAffinityCRUD(auth).update_yifan_affinity_crud(affinity_id=affinity_id, data=data)
return YifanAffinityOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def delete_yifan_affinity_service(cls, auth: AuthSchema, affinity_id: int) -> bool:
"""删除缘分合盘记录"""
return await YifanAffinityCRUD(auth).delete_yifan_affinity_crud(affinity_id=affinity_id)
@classmethod
async def list_yifan_affinity_service(cls, auth: AuthSchema, search: YifanAffinityQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanAffinityCRUD(auth).list_yifan_affinity_crud(search=search_dict, order_by=order_by)
return [YifanAffinityOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_affinity_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanAffinityQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询"""
search_dict = search.__dict__ if search else None
result_dict = await YifanAffinityCRUD(auth).page_yifan_affinity_crud(page_no=page_no, page_size=page_size, search=search_dict, order_by=order_by)
# 基础CRUD返回的是items键我们需要转换为data键以保持API一致性
if "items" in result_dict:
result_dict["data"] = result_dict.pop("items")
return result_dict

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from .controller import YifanBaziZejiRouter
__all__ = ["YifanBaziZejiRouter"]

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.dependencies import AuthPermission
from app.common.response import SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.logger import log
from app.api.v1.module_yifan.yifan_bazi_zeji.service import YifanBaziZejiService
from app.api.v1.module_yifan.yifan_bazi_zeji.schema import (
YifanBaziZejiCreateSchema, YifanBaziZejiUpdateSchema, YifanBaziZejiQueryParam,
BaziZejiCalculateRequestSchema
)
YifanBaziZejiRouter = APIRouter(prefix="/yifan_bazi_zeji", tags=["八字择吉测算"])
@YifanBaziZejiRouter.post("/calculate", summary="八字择吉测算", description="八字择吉测算,创建任务并异步生成分析结果")
async def calculate_bazi_zeji_controller(
data: BaziZejiCalculateRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""八字择吉测算接口 - 创建任务,后台异步生成分析结果"""
result_dict = await YifanBaziZejiService.calculate_bazi_zeji_service(auth=auth, data=data)
log.info(f"八字择吉任务已创建: {data.name} - {data.zeji_type}")
return SuccessResponse(data=result_dict, msg="八字择吉任务已创建,正在测算中")
@YifanBaziZejiRouter.get("/result/{zeji_id}", summary="获取八字择吉结果", description="根据ID获取八字择吉测算结果")
async def get_bazi_zeji_result_controller(
zeji_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取八字择吉结果接口"""
result_dict = await YifanBaziZejiService.get_bazi_zeji_result_service(auth=auth, zeji_id=zeji_id)
log.info(f"获取八字择吉结果成功: ID={zeji_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取八字择吉结果成功")
@YifanBaziZejiRouter.get("/list", summary="查询八字择吉列表", description="查询八字择吉测算列表")
async def get_yifan_bazi_zeji_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanBaziZejiQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""查询八字择吉列表接口(数据库分页)"""
result_dict = await YifanBaziZejiService.page_yifan_bazi_zeji_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询八字择吉列表成功")
return SuccessResponse(data=result_dict, msg="查询八字择吉列表成功")
@YifanBaziZejiRouter.get("/{zeji_id}", summary="获取八字择吉详情", description="根据ID获取八字择吉详情")
async def get_yifan_bazi_zeji_controller(
zeji_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取八字择吉详情接口"""
result_dict = await YifanBaziZejiService.get_yifan_bazi_zeji_service(auth=auth, zeji_id=zeji_id)
log.info("获取八字择吉详情成功")
return SuccessResponse(data=result_dict, msg="获取八字择吉详情成功")
@YifanBaziZejiRouter.post("/create", summary="创建八字择吉", description="创建八字择吉测算记录")
async def create_yifan_bazi_zeji_controller(
data: YifanBaziZejiCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_bazi_zeji:create"]))
) -> JSONResponse:
"""创建八字择吉接口"""
result_dict = await YifanBaziZejiService.create_yifan_bazi_zeji_service(auth=auth, data=data)
log.info("创建八字择吉成功")
return SuccessResponse(data=result_dict, msg="创建八字择吉成功")
@YifanBaziZejiRouter.put("/{zeji_id}", summary="更新八字择吉", description="根据ID更新八字择吉测算记录")
async def update_yifan_bazi_zeji_controller(
zeji_id: int,
data: YifanBaziZejiUpdateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_bazi_zeji:update"]))
) -> JSONResponse:
"""更新八字择吉接口"""
result_dict = await YifanBaziZejiService.update_yifan_bazi_zeji_service(auth=auth, zeji_id=zeji_id, data=data)
log.info("更新八字择吉成功")
return SuccessResponse(data=result_dict, msg="更新八字择吉成功")
@YifanBaziZejiRouter.delete("/{zeji_id}", summary="删除八字择吉", description="根据ID删除八字择吉测算记录")
async def delete_yifan_bazi_zeji_controller(
zeji_id: int,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_bazi_zeji:delete"]))
) -> JSONResponse:
"""删除八字择吉接口"""
result = await YifanBaziZejiService.delete_yifan_bazi_zeji_service(auth=auth, zeji_id=zeji_id)
log.info("删除八字择吉成功")
return SuccessResponse(data=result, msg="删除八字择吉成功")

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_yifan.yifan_bazi_zeji.model import YifanBaziZejiModel
from app.api.v1.module_yifan.yifan_bazi_zeji.schema import YifanBaziZejiCreateSchema, YifanBaziZejiUpdateSchema
class YifanBaziZejiCRUD(CRUDBase[YifanBaziZejiModel, YifanBaziZejiCreateSchema, YifanBaziZejiUpdateSchema]):
"""八字择吉测算CRUD操作"""
def __init__(self, auth: AuthSchema):
super().__init__(model=YifanBaziZejiModel, auth=auth)
async def create_yifan_bazi_zeji_crud(self, data: YifanBaziZejiCreateSchema) -> YifanBaziZejiModel:
"""
创建八字择吉测算记录
参数:
- data (YifanBaziZejiCreateSchema): 创建数据
返回:
- YifanBaziZejiModel: 模型实例
"""
return await self.create(data=data)
async def get_yifan_bazi_zeji_crud(self, zeji_id: int, preload: list | None = None) -> YifanBaziZejiModel | None:
"""
获取单个八字择吉测算记录
参数:
- zeji_id (int): 记录ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanBaziZejiModel | None: 模型实例或None
"""
return await self.get(id=zeji_id, preload=preload)
async def update_yifan_bazi_zeji_crud(self, zeji_id: int, data: YifanBaziZejiUpdateSchema) -> YifanBaziZejiModel:
"""
更新八字择吉测算记录
参数:
- zeji_id (int): 记录ID
- data (YifanBaziZejiUpdateSchema): 更新数据
返回:
- YifanBaziZejiModel: 更新后的模型实例
"""
return await self.update(id=zeji_id, data=data)
async def delete_yifan_bazi_zeji_crud(self, zeji_id: int) -> bool:
"""
删除八字择吉测算记录
参数:
- zeji_id (int): 记录ID
返回:
- bool: 删除是否成功
"""
return await self.delete(id=zeji_id)
async def list_yifan_bazi_zeji_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanBaziZejiModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanBaziZejiModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def page_yifan_bazi_zeji_crud(self, page_no: int, page_size: int, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- page_no (int): 页码
- page_size (int): 每页数量
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- dict: 分页结果字典
"""
from app.api.v1.module_yifan.yifan_bazi_zeji.schema import YifanBaziZejiOutSchema
# 将页码转换为偏移量
offset = (page_no - 1) * page_size
# 设置默认排序
if order_by is None:
order_by = [{"field": "created_time", "order": "desc"}]
return await self.page(
offset=offset,
limit=page_size,
search=search or {},
order_by=order_by,
out_schema=YifanBaziZejiOutSchema,
preload=preload
)

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import Integer, DateTime, SmallInteger, Text, String, JSON, Numeric, Date
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanBaziZejiModel(ModelMixin, UserMixin):
"""
八字择吉测算表
"""
__tablename__: str = 'yifan_bazi_zeji'
__table_args__: dict[str, str] = {'comment': '八字择吉测算'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
task_status: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户ID')
# 基本信息
name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='姓名')
gender: Mapped[str | None] = mapped_column(String(10), nullable=True, comment='性别(male男 female女)')
birth_date: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='生辰显示格式')
birth_date_api: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='生辰API格式')
birth_place: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='出生地')
# 择吉类型和事项
zeji_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='择吉类型(wedding婚嫁 business开业 move搬家 travel出行 investment投资 surgery手术 contract签约 other其他)')
zeji_purpose: Mapped[str | None] = mapped_column(Text, nullable=True, comment='择吉目的描述')
date_range_start: Mapped[datetime.date | None] = mapped_column(Date, nullable=True, comment='期望日期范围开始')
date_range_end: Mapped[datetime.date | None] = mapped_column(Date, nullable=True, comment='期望日期范围结束')
# 测算结果
lucky_score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='总体吉凶评分(0-100)')
lucky_level: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='吉凶等级(大吉 中吉 小吉 平 小凶 中凶 大凶)')
# 推荐日期(JSON格式存储)
recommended_dates: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='推荐吉日列表JSON')
# 五行分析(JSON格式存储)
wuxing_analysis: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='五行分析JSON')
# 八字分析
bazi_analysis: Mapped[str | None] = mapped_column(Text, nullable=True, comment='八字详细分析')
tiangang_analysis: Mapped[str | None] = mapped_column(Text, nullable=True, comment='天干地支分析')
# 择吉建议
zeji_suggestions: Mapped[str | None] = mapped_column(Text, nullable=True, comment='择吉建议')
avoid_suggestions: Mapped[str | None] = mapped_column(Text, nullable=True, comment='忌避建议')
best_time_periods: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='最佳时辰JSON')
# 风水建议
fengshui_tips: Mapped[str | None] = mapped_column(Text, nullable=True, comment='风水建议')
lucky_directions: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='吉利方位JSON')
lucky_colors: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='幸运颜色JSON')
lucky_numbers: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='幸运数字JSON')
# 解锁状态
is_unlocked: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否已解锁深度报告(0否 1是)')
unlock_price: Mapped[str | None] = mapped_column(Numeric(10, 2), nullable=True, comment='解锁价格')
# 解锁后的深度内容(JSON格式存储)
unlocked_content: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='解锁后的深度内容JSON')
# AI原始数据
ai_source_data: Mapped[str | None] = mapped_column(Text, nullable=True, comment='AI原始数据')
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment='错误信息')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
import datetime
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class RecommendedDateSchema(BaseModel):
"""推荐日期模型"""
date: str = Field(..., description='日期', example='2024-03-15')
lunar_date: str = Field(..., description='农历日期', example='二月初六')
weekday: str = Field(..., description='星期', example='星期五')
score: int = Field(..., description='评分', ge=0, le=100, example=92)
level: str = Field(..., description='吉凶等级', example='大吉')
best_times: List[str] = Field(..., description='最佳时辰', example=['09:00-11:00', '13:00-15:00'])
reason: str = Field(..., description='推荐理由')
yi: List[str] = Field(..., description='宜做事项', example=['开市', '签约', '祈福'])
ji: List[str] = Field(..., description='忌做事项', example=['动土', '安葬'])
class WuxingAnalysisSchema(BaseModel):
"""五行分析模型"""
personal_wuxing: str = Field(..., description='个人五行特点', example='金旺木弱')
suitable_elements: List[str] = Field(..., description='适宜五行', example=['', ''])
avoid_elements: List[str] = Field(..., description='忌避五行', example=['', ''])
balance_suggestion: str = Field(..., description='平衡建议')
class TimePeriodSchema(BaseModel):
"""时辰模型"""
time: str = Field(..., description='时间段', example='09:00-11:00')
name: str = Field(..., description='时辰名', example='巳时')
description: str = Field(..., description='时辰描述')
class UnlockedContentSchema(BaseModel):
"""解锁内容模型"""
detailed_bazi: str = Field(..., description='详细八字解析')
yearly_fortune: str = Field(..., description='全年运势分析')
monthly_guide: str = Field(..., description='逐月择吉指南')
master_advice: str = Field(..., description='大师特别建议')
class BaziZejiCalculateRequestSchema(BaseModel):
"""八字择吉测算请求模型"""
name: str = Field(..., description='姓名', example='张三')
gender: str = Field(..., description='性别(male/female)', example='male')
birth_date: str = Field(..., description='生辰八字(显示格式)', example='1990年1月1日 子时')
birth_date_api: datetime.datetime = Field(..., description='生辰八字API格式', example='1990-01-01 00:30:00')
birth_place: str = Field(..., description='出生地', example='北京市')
zeji_type: str = Field(..., description='择吉类型', example='wedding')
zeji_purpose: str = Field(..., description='择吉目的描述', example='选择结婚吉日')
date_range_start: datetime.date = Field(..., description='期望日期范围开始', example='2024-03-01')
date_range_end: datetime.date = Field(..., description='期望日期范围结束', example='2024-06-30')
class BaziZejiCalculateResponseSchema(BaseModel):
"""八字择吉测算响应模型"""
zeji_id: int = Field(..., description='择吉记录ID')
name: str = Field(..., description='姓名')
zeji_type: str = Field(..., description='择吉类型')
zeji_type_label: str = Field(..., description='择吉类型标签')
lucky_score: int = Field(..., description='总体吉凶评分', ge=0, le=100)
lucky_level: str = Field(..., description='吉凶等级')
# 推荐日期
recommended_dates: List[RecommendedDateSchema] = Field(..., description='推荐吉日列表')
# 五行分析
wuxing_analysis: WuxingAnalysisSchema = Field(..., description='五行分析')
# 八字分析
bazi_analysis: str = Field(..., description='八字详细分析')
tiangang_analysis: str = Field(..., description='天干地支分析')
# 择吉建议
zeji_suggestions: str = Field(..., description='择吉建议')
avoid_suggestions: str = Field(..., description='忌避建议')
best_time_periods: List[TimePeriodSchema] = Field(..., description='最佳时辰')
# 风水建议
fengshui_tips: str = Field(..., description='风水建议')
lucky_directions: List[str] = Field(..., description='吉利方位')
lucky_colors: List[str] = Field(..., description='幸运颜色')
lucky_numbers: List[int] = Field(..., description='幸运数字')
# 解锁状态
unlocked: Optional[UnlockedContentSchema] = Field(None, description='解锁后的深度内容')
is_unlocked: bool = Field(..., description='是否已解锁深度报告')
unlock_price: float = Field(..., description='解锁价格(元)')
class YifanBaziZejiCreateSchema(BaseModel):
"""
八字择吉测算新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
task_status: int = Field(default=1, description='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: int | None = Field(default=None, description='用户ID')
name: str = Field(default=..., description='姓名')
gender: str = Field(default=..., description='性别(male男 female女)')
birth_date: str = Field(default=..., description='生辰显示格式')
birth_date_api: datetime.datetime = Field(default=..., description='生辰API格式')
birth_place: str = Field(default=..., description='出生地')
zeji_type: str = Field(default=..., description='择吉类型')
zeji_purpose: str = Field(default=..., description='择吉目的描述')
date_range_start: datetime.date = Field(default=..., description='期望日期范围开始')
date_range_end: datetime.date = Field(default=..., description='期望日期范围结束')
lucky_score: int | None = Field(default=None, description='总体吉凶评分(0-100)')
lucky_level: str | None = Field(default=None, description='吉凶等级')
recommended_dates: str | None = Field(default=None, description='推荐吉日列表JSON')
wuxing_analysis: str | None = Field(default=None, description='五行分析JSON')
bazi_analysis: str | None = Field(default=None, description='八字详细分析')
tiangang_analysis: str | None = Field(default=None, description='天干地支分析')
zeji_suggestions: str | None = Field(default=None, description='择吉建议')
avoid_suggestions: str | None = Field(default=None, description='忌避建议')
best_time_periods: str | None = Field(default=None, description='最佳时辰JSON')
fengshui_tips: str | None = Field(default=None, description='风水建议')
lucky_directions: str | None = Field(default=None, description='吉利方位JSON')
lucky_colors: str | None = Field(default=None, description='幸运颜色JSON')
lucky_numbers: str | None = Field(default=None, description='幸运数字JSON')
is_unlocked: int = Field(default=0, description='是否已解锁深度报告')
unlock_price: float | None = Field(default=19.90, description='解锁价格')
unlocked_content: str | None = Field(default=None, description='解锁后的深度内容JSON')
ai_source_data: str | None = Field(default=None, description='AI原始数据')
error_message: str | None = Field(default=None, description='错误信息')
remark: str | None = Field(default=None, description='备注')
class YifanBaziZejiUpdateSchema(BaseModel):
"""
八字择吉测算更新模型
"""
is_deleted: int | None = Field(default=None, description='是否删除(0否 1是)')
task_status: int | None = Field(default=None, description='任务状态(1已创建 2测算中 5测算成功 3测算超时 0任务失败)')
user_id: int | None = Field(default=None, description='用户ID')
name: str | None = Field(default=None, description='姓名')
gender: str | None = Field(default=None, description='性别(male男 female女)')
birth_date: str | None = Field(default=None, description='生辰显示格式')
birth_date_api: datetime.datetime | None = Field(default=None, description='生辰API格式')
birth_place: str | None = Field(default=None, description='出生地')
zeji_type: str | None = Field(default=None, description='择吉类型')
zeji_purpose: str | None = Field(default=None, description='择吉目的描述')
date_range_start: datetime.date | None = Field(default=None, description='期望日期范围开始')
date_range_end: datetime.date | None = Field(default=None, description='期望日期范围结束')
lucky_score: int | None = Field(default=None, description='总体吉凶评分(0-100)')
lucky_level: str | None = Field(default=None, description='吉凶等级')
recommended_dates: str | None = Field(default=None, description='推荐吉日列表JSON')
wuxing_analysis: str | None = Field(default=None, description='五行分析JSON')
bazi_analysis: str | None = Field(default=None, description='八字详细分析')
tiangang_analysis: str | None = Field(default=None, description='天干地支分析')
zeji_suggestions: str | None = Field(default=None, description='择吉建议')
avoid_suggestions: str | None = Field(default=None, description='忌避建议')
best_time_periods: str | None = Field(default=None, description='最佳时辰JSON')
fengshui_tips: str | None = Field(default=None, description='风水建议')
lucky_directions: str | None = Field(default=None, description='吉利方位JSON')
lucky_colors: str | None = Field(default=None, description='幸运颜色JSON')
lucky_numbers: str | None = Field(default=None, description='幸运数字JSON')
is_unlocked: int | None = Field(default=None, description='是否已解锁深度报告')
unlock_price: float | None = Field(default=None, description='解锁价格')
unlocked_content: str | None = Field(default=None, description='解锁后的深度内容JSON')
ai_source_data: str | None = Field(default=None, description='AI原始数据')
error_message: str | None = Field(default=None, description='错误信息')
remark: str | None = Field(default=None, description='备注')
class YifanBaziZejiOutSchema(YifanBaziZejiCreateSchema, BaseSchema, UserBySchema):
"""
八字择吉测算响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanBaziZejiQueryParam:
"""八字择吉测算查询参数"""
def __init__(
self,
name: str | None = Query(None, description="姓名"),
gender: str | None = Query(None, description="性别(male男 female女)"),
zeji_type: str | None = Query(None, description="择吉类型"),
lucky_level: str | None = Query(None, description="吉凶等级"),
score_min: int | None = Query(None, description="最低分数"),
score_max: int | None = Query(None, description="最高分数"),
is_unlocked: int | None = Query(None, description="是否已解锁深度报告"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
task_status: int | None = Query(None, description="任务状态"),
user_id: int | None = Query(None, description="用户ID"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
self.updated_id = updated_id
self.is_deleted = is_deleted
self.task_status = task_status
self.user_id = user_id
self.is_unlocked = is_unlocked
# 模糊查询字段
self.name = ("like", name)
self.gender = ("like", gender)
self.zeji_type = ("like", zeji_type)
self.lucky_level = ("like", lucky_level)
# 范围查询字段
if score_min is not None and score_max is not None:
self.lucky_score = ("between", (score_min, score_max))
elif score_min is not None:
self.lucky_score = (">=", score_min)
elif score_max is not None:
self.lucky_score = ("<=", score_max)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
import json
import asyncio
from typing import Dict, Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import log
from app.api.v1.module_yifan.yifan_bazi_zeji.crud import YifanBaziZejiCRUD
from app.api.v1.module_yifan.yifan_bazi_zeji.schema import (
YifanBaziZejiCreateSchema, YifanBaziZejiUpdateSchema, YifanBaziZejiOutSchema, YifanBaziZejiQueryParam,
BaziZejiCalculateRequestSchema, BaziZejiCalculateResponseSchema
)
class YifanBaziZejiService:
"""八字择吉测算服务"""
@classmethod
async def calculate_bazi_zeji_service(cls, auth: AuthSchema, data: BaziZejiCalculateRequestSchema) -> Dict[str, Any]:
"""
八字择吉测算服务(异步模式)
1. 校验数据创建报告记录status=1 已创建)
2. 启动后台任务执行AI测算
3. 立即返回 zeji_id + status
"""
user_id = auth.user.id if auth.user else 0
# 创建八字择吉测算记录
create_data = YifanBaziZejiCreateSchema(
user_id=user_id,
name=data.name,
gender=data.gender,
birth_date=data.birth_date,
birth_date_api=data.birth_date_api,
birth_place=data.birth_place,
zeji_type=data.zeji_type,
zeji_purpose=data.zeji_purpose,
date_range_start=data.date_range_start,
date_range_end=data.date_range_end,
task_status=1,
is_deleted=0
)
try:
zeji_obj = await YifanBaziZejiCRUD(auth).create_yifan_bazi_zeji_crud(data=create_data)
zeji_id = zeji_obj.id
log.info(f"[八字择吉] 报告创建成功ID: {zeji_id}")
except Exception as e:
log.error(f"[八字择吉] 报告创建失败: {str(e)}")
raise CustomException(msg=f"保存八字择吉报告失败: {str(e)}")
# 启动后台任务执行AI测算
asyncio.create_task(
cls._process_bazi_zeji_calculation(
zeji_id=zeji_id,
data=data,
user_id=user_id,
)
)
return {"zeji_id": zeji_id, "status": 1}
@classmethod
async def _process_bazi_zeji_calculation(
cls,
zeji_id: int,
data: BaziZejiCalculateRequestSchema,
user_id: int,
) -> None:
"""后台任务执行八字择吉AI测算"""
log_prefix = f"[八字择吉-{zeji_id}]"
try:
# 更新状态为测算中
await cls._update_zeji_status(zeji_id, task_status=2)
log.info(f"{log_prefix} 开始AI测算")
# 构建AI输入文本
zeji_type_labels = {
"wedding": "婚嫁择吉",
"business": "开业择吉",
"move": "搬家择吉",
"travel": "出行择吉",
"investment": "投资择吉",
"surgery": "手术择吉",
"contract": "签约择吉",
"other": "其他择吉"
}
zeji_type_label = zeji_type_labels.get(data.zeji_type, data.zeji_type)
ai_input_text = f"""八字择吉测算请求:
姓名:{data.name}
性别:{'' if data.gender == 'male' else ''}
生辰:{data.birth_date}
出生地:{data.birth_place}
择吉类型:{zeji_type_label}
择吉目的:{data.zeji_purpose}
期望日期范围:{data.date_range_start}{data.date_range_end}
请根据以上信息进行八字择吉测算,分析个人八字特点,推荐最适宜的吉日良辰,并提供详细的择吉建议。"""
# 调用AI服务进行测算
ai_response = await cls._call_ai_service(ai_input_text)
log.info(f"ai_response is {ai_response}")
log.info(f"[DEBUG] 调用前 ai_response type: {type(ai_response)}, length: {len(ai_response) if ai_response else 'None'}")
if not ai_response:
raise Exception("AI服务返回空结果")
# 解析AI响应并构建结果数据
result_data = cls._parse_ai_response(ai_response, data, zeji_type_label)
log.info(f"result_data is {result_data}")
# 更新数据库记录
update_data = YifanBaziZejiUpdateSchema(
lucky_score=result_data.get('lucky_score'),
lucky_level=result_data.get('lucky_level'),
recommended_dates=json.dumps(result_data.get('recommended_dates'), ensure_ascii=False),
wuxing_analysis=json.dumps(result_data.get('wuxing_analysis'), ensure_ascii=False),
bazi_analysis=result_data.get('bazi_analysis'),
tiangang_analysis=result_data.get('tiangang_analysis'),
zeji_suggestions=result_data.get('zeji_suggestions'),
avoid_suggestions=result_data.get('avoid_suggestions'),
best_time_periods=json.dumps(result_data.get('best_time_periods'), ensure_ascii=False),
fengshui_tips=result_data.get('fengshui_tips'),
lucky_directions=json.dumps(result_data.get('lucky_directions'), ensure_ascii=False),
lucky_colors=json.dumps(result_data.get('lucky_colors'), ensure_ascii=False),
lucky_numbers=json.dumps(result_data.get('lucky_numbers'), ensure_ascii=False),
unlocked_content=json.dumps(result_data.get('unlocked_content'), ensure_ascii=False),
ai_source_data=ai_response,
task_status=5 # 测算成功
)
await cls._update_zeji_record(zeji_id, update_data)
log.info(f"{log_prefix} AI测算完成并保存成功")
except asyncio.TimeoutError:
log.error(f"{log_prefix} AI测算超时")
await cls._update_zeji_status(zeji_id, task_status=3, error_message="AI测算超时")
except Exception as e:
log.error(f"{log_prefix} AI测算失败: {str(e)}")
await cls._update_zeji_status(zeji_id, task_status=0, error_message=str(e)[:500])
@classmethod
async def _call_ai_service(cls, text: str) -> str:
"""
调用AI服务进行八字择吉测算
Args:
text: 输入文本
Returns:
AI响应结果
"""
from app.api.v1.module_application.ai.service import AIModelTestService
log.info(f"[八字择吉] 调用AI服务输入长度: {len(text)}")
try:
# 调用实际的AI服务使用bazi_zeji模型类型
ai_response = await AIModelTestService.test_naming(
model_type="bazi_zeji",
text=text
)
log.info(f"[八字择吉] AI服务响应成功响应长度: {len(ai_response)}")
return ai_response
except Exception as e:
log.error(f"[八字择吉] AI服务调用失败: {str(e)}")
# 如果AI服务调用失败返回模拟数据作为降级方案
log.warning("[八字择吉] 使用模拟数据作为降级方案")
mock_response = {
"lucky_score": 88,
"lucky_level": "中吉",
"recommended_dates": [
{
"date": "2024-03-15",
"lunar_date": "二月初六",
"weekday": "星期五",
"score": 92,
"level": "大吉",
"best_times": ["09:00-11:00", "13:00-15:00"],
"reason": "此日天干地支与您八字相合,五行流通顺畅,最宜开业庆典",
"yi": ["开市", "签约", "祈福"],
"ji": ["动土", "安葬"]
}
],
"wuxing_analysis": {
"personal_wuxing": "金旺木弱",
"suitable_elements": ["", ""],
"avoid_elements": ["", ""],
"balance_suggestion": "宜多接触水木属性事物,避免火土过旺"
},
"bazi_analysis": "您的八字显示金旺木弱,需要水来调和...",
"tiangang_analysis": "天干地支分析显示您适合在春季进行重要决策...",
"zeji_suggestions": "根据您的八字特点,建议选择水旺木盛的日子...",
"avoid_suggestions": "需要避开火土过旺的时间...",
"best_time_periods": [
{
"time": "09:00-11:00",
"name": "巳时",
"description": "此时辰阳气上升,最利开创事业"
}
],
"fengshui_tips": "建议在东南方位进行重要活动...",
"lucky_directions": ["东南", "正东"],
"lucky_colors": ["绿色", "蓝色", "黑色"],
"lucky_numbers": [3, 8, 13, 18],
"unlocked_content": {
"detailed_bazi": "详细八字解析...",
"yearly_fortune": "全年运势分析...",
"monthly_guide": "逐月择吉指南...",
"master_advice": "大师特别建议..."
}
}
return json.dumps(mock_response, ensure_ascii=False)
@classmethod
def _parse_ai_response(cls, ai_response: str, request_data: BaziZejiCalculateRequestSchema, zeji_type_label: str) -> Dict[str, Any]:
"""解析AI响应数据"""
try:
# 添加详细的调试信息
log.info(f"[DEBUG] ai_response type: {type(ai_response)}")
log.info(f"[DEBUG] ai_response length: {len(ai_response) if ai_response else 'None'}")
log.info(f"[DEBUG] ai_response repr: {repr(ai_response[:200] if ai_response else 'None')}")
if not ai_response:
raise ValueError("AI响应为空")
if not isinstance(ai_response, str):
log.warning(f"AI响应不是字符串类型尝试转换: {type(ai_response)}")
ai_response = str(ai_response)
# 去除可能的前后空白字符和特殊字符
ai_response = ai_response.strip()
# 尝试找到JSON开始和结束位置
start_idx = ai_response.find('{')
end_idx = ai_response.rfind('}')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
json_str = ai_response[start_idx:end_idx+1]
log.info(f"[DEBUG] 提取的JSON字符串: {json_str[:200]}...")
ai_data = json.loads(json_str)
else:
log.error(f"[DEBUG] 无法找到有效的JSON结构")
raise ValueError("AI响应中没有找到有效的JSON结构")
log.info(f"解析AI响应数据 ai_data is {ai_data}")
# 构建完整的响应数据
result_data = {
"lucky_score": ai_data.get("lucky_score", 0),
"lucky_level": ai_data.get("lucky_level", ""),
"recommended_dates": ai_data.get("recommended_dates", []),
"wuxing_analysis": ai_data.get("wuxing_analysis", {}),
"bazi_analysis": ai_data.get("bazi_analysis", ""),
"tiangang_analysis": ai_data.get("tiangang_analysis", ""),
"zeji_suggestions": ai_data.get("zeji_suggestions", ""),
"avoid_suggestions": ai_data.get("avoid_suggestions", ""),
"best_time_periods": ai_data.get("best_time_periods", []),
"fengshui_tips": ai_data.get("fengshui_tips", ""),
"lucky_directions": ai_data.get("lucky_directions", []),
"lucky_colors": ai_data.get("lucky_colors", []),
"lucky_numbers": ai_data.get("lucky_numbers", []),
"unlocked_content": ai_data.get("unlocked_content", {})
}
return result_data
except json.JSONDecodeError as e:
log.error(f"AI响应JSON解析失败: {str(e)}")
raise Exception(f"AI响应格式错误: {str(e)}")
@classmethod
async def _update_zeji_status(cls, zeji_id: int, task_status: int, error_message: str = None):
"""更新八字择吉测算状态"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanBaziZejiCRUD(temp_auth)
crud.session = session
update_data = YifanBaziZejiUpdateSchema(
task_status=task_status,
error_message=error_message
)
await crud.update_yifan_bazi_zeji_crud(zeji_id, update_data)
await session.commit() # 提交事务
log.info(f"[八字择吉] 状态更新成功ID: {zeji_id}, 状态: {task_status}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[八字择吉] 状态更新失败ID: {zeji_id}, 错误: {str(e)}")
raise
@classmethod
async def _update_zeji_record(cls, zeji_id: int, update_data: YifanBaziZejiUpdateSchema):
"""更新八字择吉测算记录"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanBaziZejiCRUD(temp_auth)
crud.session = session
await crud.update_yifan_bazi_zeji_crud(zeji_id, update_data)
await session.commit() # 提交事务
log.info(f"[八字择吉] 记录更新成功ID: {zeji_id}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[八字择吉] 记录更新失败ID: {zeji_id}, 错误: {str(e)}")
raise
@classmethod
async def get_bazi_zeji_result_service(cls, auth: AuthSchema, zeji_id: int) -> BaziZejiCalculateResponseSchema:
"""获取八字择吉测算结果"""
zeji_obj = await YifanBaziZejiCRUD(auth).get_yifan_bazi_zeji_crud(zeji_id)
if not zeji_obj:
raise CustomException(msg="八字择吉测算记录不存在")
if zeji_obj.task_status != 5:
raise CustomException(msg="八字择吉测算尚未完成")
# 构建响应数据
response_data = {
"zeji_id": zeji_obj.id,
"name": zeji_obj.name,
"zeji_type": zeji_obj.zeji_type,
"zeji_type_label": cls._get_zeji_type_label(zeji_obj.zeji_type),
"lucky_score": zeji_obj.lucky_score or 0,
"lucky_level": zeji_obj.lucky_level or "",
"recommended_dates": json.loads(zeji_obj.recommended_dates) if zeji_obj.recommended_dates else [],
"wuxing_analysis": json.loads(zeji_obj.wuxing_analysis) if zeji_obj.wuxing_analysis else {},
"bazi_analysis": zeji_obj.bazi_analysis or "",
"tiangang_analysis": zeji_obj.tiangang_analysis or "",
"zeji_suggestions": zeji_obj.zeji_suggestions or "",
"avoid_suggestions": zeji_obj.avoid_suggestions or "",
"best_time_periods": json.loads(zeji_obj.best_time_periods) if zeji_obj.best_time_periods else [],
"fengshui_tips": zeji_obj.fengshui_tips or "",
"lucky_directions": json.loads(zeji_obj.lucky_directions) if zeji_obj.lucky_directions else [],
"lucky_colors": json.loads(zeji_obj.lucky_colors) if zeji_obj.lucky_colors else [],
"lucky_numbers": json.loads(zeji_obj.lucky_numbers) if zeji_obj.lucky_numbers else [],
"unlocked": json.loads(zeji_obj.unlocked_content) if zeji_obj.unlocked_content and zeji_obj.is_unlocked else None,
"is_unlocked": bool(zeji_obj.is_unlocked),
"unlock_price": float(zeji_obj.unlock_price or 19.90)
}
return BaziZejiCalculateResponseSchema(**response_data)
@classmethod
def _get_zeji_type_label(cls, zeji_type: str) -> str:
"""获取择吉类型标签"""
labels = {
"wedding": "婚嫁择吉",
"business": "开业择吉",
"move": "搬家择吉",
"travel": "出行择吉",
"investment": "投资择吉",
"surgery": "手术择吉",
"contract": "签约择吉",
"other": "其他择吉"
}
return labels.get(zeji_type, zeji_type)
@classmethod
async def create_yifan_bazi_zeji_service(cls, auth: AuthSchema, data: YifanBaziZejiCreateSchema) -> dict:
"""创建八字择吉测算记录"""
result_dict = await YifanBaziZejiCRUD(auth).create_yifan_bazi_zeji_crud(data=data)
return YifanBaziZejiOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def get_yifan_bazi_zeji_service(cls, auth: AuthSchema, zeji_id: int) -> dict:
"""获取单个八字择吉测算记录"""
result_dict = await YifanBaziZejiCRUD(auth).get_yifan_bazi_zeji_crud(zeji_id=zeji_id)
if not result_dict:
raise CustomException(msg="八字择吉测算记录不存在")
return YifanBaziZejiOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def update_yifan_bazi_zeji_service(cls, auth: AuthSchema, zeji_id: int, data: YifanBaziZejiUpdateSchema) -> dict:
"""更新八字择吉测算记录"""
result_dict = await YifanBaziZejiCRUD(auth).update_yifan_bazi_zeji_crud(zeji_id=zeji_id, data=data)
return YifanBaziZejiOutSchema.model_validate(result_dict).model_dump()
@classmethod
async def delete_yifan_bazi_zeji_service(cls, auth: AuthSchema, zeji_id: int) -> bool:
"""删除八字择吉测算记录"""
return await YifanBaziZejiCRUD(auth).delete_yifan_bazi_zeji_crud(zeji_id=zeji_id)
@classmethod
async def list_yifan_bazi_zeji_service(cls, auth: AuthSchema, search: YifanBaziZejiQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanBaziZejiCRUD(auth).list_yifan_bazi_zeji_crud(search=search_dict, order_by=order_by)
return [YifanBaziZejiOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_bazi_zeji_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanBaziZejiQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询"""
search_dict = search.__dict__ if search else None
result_dict = await YifanBaziZejiCRUD(auth).page_yifan_bazi_zeji_crud(page_no=page_no, page_size=page_size, search=search_dict, order_by=order_by)
# 基础CRUD返回的是items键我们需要转换为data键以保持API一致性
if "items" in result_dict:
result_dict["data"] = result_dict.pop("items")
return result_dict

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from .controller import YifanCaiyunJiexiRouter
__all__ = ["YifanCaiyunJiexiRouter"]

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.dependencies import AuthPermission
from app.common.response import SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.logger import log
from app.api.v1.module_yifan.yifan_caiyun_jiexi.service import YifanCaiyunJiexiService
from app.api.v1.module_yifan.yifan_caiyun_jiexi.schema import (
YifanCaiyunJiexiCreateSchema, YifanCaiyunJiexiUpdateSchema, YifanCaiyunJiexiQueryParam,
CaiyunJiexiAnalyzeRequestSchema
)
YifanCaiyunJiexiRouter = APIRouter(prefix="/yifan_caiyun_jiexi", tags=["财运解析"])
@YifanCaiyunJiexiRouter.post("/analyze", summary="财运解析", description="根据方案ID生成财运解析创建任务并异步生成分析结果")
async def analyze_caiyun_controller(
data: CaiyunJiexiAnalyzeRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""财运解析接口 - 创建任务,后台异步生成分析结果"""
result_dict = await YifanCaiyunJiexiService.analyze_caiyun_service(auth=auth, data=data)
log.info(f"财运解析任务已创建: 方案ID={data.report_id}")
return SuccessResponse(data=result_dict, msg="财运解析任务已创建,正在分析中")
@YifanCaiyunJiexiRouter.get("/result/{jiexi_id}", summary="获取财运解析结果", description="根据ID获取财运解析结果")
async def get_caiyun_jiexi_result_controller(
jiexi_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取财运解析结果接口"""
result_dict = await YifanCaiyunJiexiService.get_caiyun_jiexi_result_service(auth=auth, jiexi_id=jiexi_id)
log.info(f"获取财运解析结果成功: ID={jiexi_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取财运解析结果成功")
@YifanCaiyunJiexiRouter.get("/result/by-report/{report_id}", summary="根据报告ID获取财运解析结果", description="根据报告ID获取财运解析结果")
async def get_caiyun_jiexi_result_by_report_controller(
report_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""根据报告ID获取财运解析结果接口"""
result_dict = await YifanCaiyunJiexiService.get_caiyun_jiexi_result_by_report_service(auth=auth, report_id=report_id)
log.info(f"根据报告ID获取财运解析结果成功: 报告ID={report_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取财运解析结果成功")
@YifanCaiyunJiexiRouter.get("/status/{jiexi_id}", summary="获取财运解析状态", description="根据ID获取财运解析任务状态")
async def get_caiyun_jiexi_status_controller(
jiexi_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取财运解析状态接口"""
result_dict = await YifanCaiyunJiexiService.get_caiyun_jiexi_status_service(auth=auth, jiexi_id=jiexi_id)
log.info(f"获取财运解析状态成功: ID={jiexi_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取财运解析状态成功")
@YifanCaiyunJiexiRouter.get("/status/by-report/{report_id}", summary="根据报告ID获取财运解析状态", description="根据报告ID获取财运解析任务状态")
async def get_caiyun_jiexi_status_by_report_controller(
report_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""根据报告ID获取财运解析状态接口"""
result_dict = await YifanCaiyunJiexiService.get_caiyun_jiexi_status_by_report_service(auth=auth, report_id=report_id)
log.info(f"根据报告ID获取财运解析状态成功: 报告ID={report_id}")
return SuccessResponse(data=result_dict.model_dump(), msg="获取财运解析状态成功")
@YifanCaiyunJiexiRouter.get("/list", summary="查询财运解析列表", description="查询财运解析列表")
async def get_yifan_caiyun_jiexi_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanCaiyunJiexiQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""查询财运解析列表接口(数据库分页)"""
result_dict = await YifanCaiyunJiexiService.page_yifan_caiyun_jiexi_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询财运解析列表成功")
return SuccessResponse(data=result_dict, msg="查询财运解析列表成功")
@YifanCaiyunJiexiRouter.get("/{jiexi_id}", summary="获取财运解析详情", description="根据ID获取财运解析详情")
async def get_yifan_caiyun_jiexi_controller(
jiexi_id: int,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""获取财运解析详情接口"""
result_dict = await YifanCaiyunJiexiService.get_yifan_caiyun_jiexi_service(auth=auth, jiexi_id=jiexi_id)
log.info("获取财运解析详情成功")
return SuccessResponse(data=result_dict, msg="获取财运解析详情成功")
@YifanCaiyunJiexiRouter.post("/create", summary="创建财运解析", description="创建财运解析记录")
async def create_yifan_caiyun_jiexi_controller(
data: YifanCaiyunJiexiCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_caiyun_jiexi:create"]))
) -> JSONResponse:
"""创建财运解析接口"""
result_dict = await YifanCaiyunJiexiService.create_yifan_caiyun_jiexi_service(auth=auth, data=data)
log.info("创建财运解析成功")
return SuccessResponse(data=result_dict, msg="创建财运解析成功")
@YifanCaiyunJiexiRouter.put("/{jiexi_id}", summary="更新财运解析", description="根据ID更新财运解析记录")
async def update_yifan_caiyun_jiexi_controller(
jiexi_id: int,
data: YifanCaiyunJiexiUpdateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_caiyun_jiexi:update"]))
) -> JSONResponse:
"""更新财运解析接口"""
result_dict = await YifanCaiyunJiexiService.update_yifan_caiyun_jiexi_service(auth=auth, jiexi_id=jiexi_id, data=data)
log.info("更新财运解析成功")
return SuccessResponse(data=result_dict, msg="更新财运解析成功")
@YifanCaiyunJiexiRouter.delete("/{jiexi_id}", summary="删除财运解析", description="根据ID删除财运解析记录")
async def delete_yifan_caiyun_jiexi_controller(
jiexi_id: int,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_caiyun_jiexi:delete"]))
) -> JSONResponse:
"""删除财运解析接口"""
result = await YifanCaiyunJiexiService.delete_yifan_caiyun_jiexi_service(auth=auth, jiexi_id=jiexi_id)
log.info("删除财运解析成功")
return SuccessResponse(data=result, msg="删除财运解析成功")

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
from typing import List, Dict, Any, Optional
from sqlalchemy import select, and_, or_, desc, asc
from sqlalchemy.orm import selectinload
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_yifan.yifan_caiyun_jiexi.model import YifanCaiyunJiexiModel
from app.api.v1.module_yifan.yifan_caiyun_jiexi.schema import (
YifanCaiyunJiexiCreateSchema, YifanCaiyunJiexiUpdateSchema, YifanCaiyunJiexiQueryParam
)
class YifanCaiyunJiexiCRUD(CRUDBase[YifanCaiyunJiexiModel, YifanCaiyunJiexiCreateSchema, YifanCaiyunJiexiUpdateSchema]):
"""财运解析CRUD操作"""
def __init__(self, auth: AuthSchema):
super().__init__(model=YifanCaiyunJiexiModel, auth=auth)
async def create_yifan_caiyun_jiexi_crud(self, data: YifanCaiyunJiexiCreateSchema) -> YifanCaiyunJiexiModel:
"""
创建财运解析记录
参数:
- data (YifanCaiyunJiexiCreateSchema): 创建数据
返回:
- YifanCaiyunJiexiModel: 模型实例
"""
return await self.create(data=data)
async def get_yifan_caiyun_jiexi_crud(self, jiexi_id: int) -> YifanCaiyunJiexiModel:
"""
获取财运解析记录
参数:
- jiexi_id (int): 财运解析ID
返回:
- YifanCaiyunJiexiModel: 模型实例
"""
return await self.get(id=jiexi_id)
async def get_by_report_id_crud(self, report_id: int) -> Optional[YifanCaiyunJiexiModel]:
"""
根据方案ID获取财运解析记录
参数:
- report_id (int): 方案ID
返回:
- YifanCaiyunJiexiModel: 模型实例或None
"""
stmt = select(self.model).where(
and_(
self.model.report_id == report_id,
self.model.is_deleted == 0
)
)
result = await self.auth.db.execute(stmt)
return result.scalar_one_or_none()
async def update_yifan_caiyun_jiexi_crud(self, jiexi_id: int, data: YifanCaiyunJiexiUpdateSchema) -> YifanCaiyunJiexiModel:
"""
更新财运解析记录
参数:
- jiexi_id (int): 财运解析ID
- data (YifanCaiyunJiexiUpdateSchema): 更新数据
返回:
- YifanCaiyunJiexiModel: 更新后的模型实例
"""
return await self.update(id=jiexi_id, data=data)
async def delete_yifan_caiyun_jiexi_crud(self, jiexi_id: int) -> bool:
"""
删除财运解析记录(软删除)
参数:
- jiexi_id (int): 财运解析ID
返回:
- bool: 删除是否成功
"""
await self.delete(ids=[jiexi_id])
return True
async def list_yifan_caiyun_jiexi_crud(self, search: YifanCaiyunJiexiQueryParam) -> List[YifanCaiyunJiexiModel]:
"""
查询财运解析列表
参数:
- search (YifanCaiyunJiexiQueryParam): 查询参数
返回:
- List[YifanCaiyunJiexiModel]: 模型实例列表
"""
return await self.list(search=search)
async def page_yifan_caiyun_jiexi_crud(
self,
page_no: int,
page_size: int,
search: YifanCaiyunJiexiQueryParam,
order_by: str = None
) -> Dict[str, Any]:
"""
分页查询财运解析列表
参数:
- page_no (int): 页码
- page_size (int): 每页数量
- search (YifanCaiyunJiexiQueryParam): 查询参数
- order_by (str): 排序字段
返回:
- Dict[str, Any]: 分页结果
"""
return await self.page(
page_no=page_no,
page_size=page_size,
search=search,
order_by=order_by
)
def _build_search_conditions(self, search: YifanCaiyunJiexiQueryParam) -> List:
"""
构建搜索条件
参数:
- search (YifanCaiyunJiexiQueryParam): 查询参数
返回:
- List: 查询条件列表
"""
conditions = [self.model.is_deleted == 0]
if search.user_id is not None:
conditions.append(self.model.user_id == search.user_id)
if search.report_id is not None:
conditions.append(self.model.report_id == search.report_id)
if search.report_type:
conditions.append(self.model.report_type == search.report_type)
if search.task_status is not None:
conditions.append(self.model.task_status == search.task_status)
if search.wealth_level:
conditions.append(self.model.wealth_level == search.wealth_level)
if search.is_unlocked is not None:
conditions.append(self.model.is_unlocked == search.is_unlocked)
if search.name:
conditions.append(self.model.name.like(f'%{search.name}%'))
return conditions

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import String, Integer, SmallInteger, DateTime, Text, JSON, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanCaiyunJiexiModel(ModelMixin, UserMixin):
"""
财运解析表
"""
__tablename__: str = 'yifan_caiyun_jiexi'
__table_args__: dict[str, str] = {'comment': '财运解析'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户ID')
report_id: Mapped[int] = mapped_column(Integer, nullable=False, comment='关联的方案ID(yifan_naming_reports表ID)')
report_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='方案类型(personal:个人起名 company:公司起名等)')
# 基础信息(从方案中获取)
name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='姓名/公司名')
gender: Mapped[str | None] = mapped_column(String(10), nullable=True, comment='性别(male男 female女公司为空)')
birth_date: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='生辰八字显示格式')
birth_date_api: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='生辰八字API格式')
birth_place: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='出生地')
# 财运解析结果
wealth_score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='财运总分(0-100)')
wealth_level: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='财运等级(大富 中富 小富 平财 欠财)')
wealth_trend: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='财运趋势(上升 稳定 下降 波动)')
# 命盘精批(JSON格式存储)
mingpan_jingpi: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='命盘精批详细内容JSON')
# 流年总运(JSON格式存储)
liunian_zongyun: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='流年总运分析JSON')
# 风水锦囊(JSON格式存储)
fengshui_jinnang: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='风水锦囊指导JSON')
# 月度祥批(JSON格式存储)
yuedo_xiangpi: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='月度祥批JSON(包含全年月份、干支、核心运势判词、吉凶信息和重点月令深度解析)')
# 每日运程(JSON格式存储)
meiri_yuncheng: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='每日运程JSON(包含今日运势、财神方位、喜神方位、时辰吉凶信息)')
# 解锁相关
is_unlocked: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否已解锁深度解析(0否 1是)')
unlock_price: Mapped[str | None] = mapped_column(Numeric(10, 2), nullable=True, comment='解锁价格')
unlocked_content: Mapped[str | None] = mapped_column(JSON, nullable=True, comment='解锁后的深度内容JSON')
# 任务状态
task_status: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='任务状态(1已创建 2解析中 5解析成功 3解析超时 0任务失败)')
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment='错误信息')
# AI相关
ai_source_data: Mapped[str | None] = mapped_column(Text, nullable=True, comment='AI原始响应数据')
ai_model_version: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='AI模型版本')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
from fastapi import Query
# ==================== 嵌套模型 ====================
# 注意原有的复杂嵌套Schema已简化为字符串类型以避免Pydantic验证错误
# 如需要复杂结构可在前端解析JSON字符串
# ==================== 请求模型 ====================
class CaiyunJiexiAnalyzeRequestSchema(BaseModel):
"""财运解析分析请求模型"""
report_id: int = Field(..., description='方案ID', example=12345)
# ==================== 响应模型 ====================
class CaiyunJiexiAnalyzeResponseSchema(BaseModel):
"""财运解析分析响应模型"""
jiexi_id: int = Field(..., description='财运解析ID')
report_id: int = Field(..., description='关联方案ID')
name: str = Field(..., description='姓名/公司名')
wealth_score: int = Field(..., description='财运总分', ge=0, le=100)
wealth_level: str = Field(..., description='财运等级')
wealth_trend: str = Field(..., description='财运趋势')
mingpan_jingpi: str = Field(..., description='命盘精批JSON字符串')
liunian_zongyun: str = Field(..., description='流年总运JSON字符串')
fengshui_jinnang: str = Field(..., description='风水锦囊JSON字符串')
yuedo_xiangpi: str = Field(..., description='月度祥批JSON字符串(包含全年月份、干支、核心运势判词、吉凶信息和重点月令深度解析)')
meiri_yuncheng: str = Field(..., description='每日运程JSON字符串(包含今日运势、财神方位、喜神方位、时辰吉凶信息)')
unlocked: Optional[str] = Field(None, description='解锁后的深度内容JSON字符串')
is_unlocked: bool = Field(..., description='是否已解锁深度解析')
unlock_price: float = Field(..., description='解锁价格(元)')
# ==================== CRUD模型 ====================
class YifanCaiyunJiexiCreateSchema(BaseModel):
"""财运解析创建模型"""
user_id: Optional[int] = Field(None, description='用户ID')
report_id: int = Field(..., description='关联的方案ID')
report_type: Optional[str] = Field(None, description='方案类型')
# 基础信息
name: Optional[str] = Field(None, description='姓名/公司名')
gender: Optional[str] = Field(None, description='性别')
birth_date: Optional[str] = Field(None, description='生辰八字显示格式')
birth_date_api: Optional[datetime] = Field(None, description='生辰八字API格式')
birth_place: Optional[str] = Field(None, description='出生地')
# 财运解析结果
wealth_score: Optional[int] = Field(None, description='财运总分')
wealth_level: Optional[str] = Field(None, description='财运等级')
wealth_trend: Optional[str] = Field(None, description='财运趋势')
# 五大模块JSON字段
mingpan_jingpi: Optional[str] = Field(None, description='命盘精批JSON')
liunian_zongyun: Optional[str] = Field(None, description='流年总运JSON')
fengshui_jinnang: Optional[str] = Field(None, description='风水锦囊JSON')
yuedo_xiangpi: Optional[str] = Field(None, description='月度祥批JSON')
meiri_yuncheng: Optional[str] = Field(None, description='每日运程JSON')
# 解锁相关
is_unlocked: Optional[int] = Field(0, description='是否已解锁深度解析')
unlock_price: Optional[float] = Field(19.90, description='解锁价格')
unlocked_content: Optional[str] = Field(None, description='解锁后的深度内容JSON')
# 任务状态
task_status: Optional[int] = Field(1, description='任务状态')
error_message: Optional[str] = Field(None, description='错误信息')
# AI相关
ai_source_data: Optional[str] = Field(None, description='AI原始响应数据')
ai_model_version: Optional[str] = Field(None, description='AI模型版本')
is_deleted: Optional[int] = Field(0, description='是否删除')
remark: Optional[str] = Field(None, description='备注')
class YifanCaiyunJiexiUpdateSchema(BaseModel):
"""财运解析更新模型"""
# 财运解析结果
wealth_score: Optional[int] = Field(None, description='财运总分')
wealth_level: Optional[str] = Field(None, description='财运等级')
wealth_trend: Optional[str] = Field(None, description='财运趋势')
# 五大模块JSON字段
mingpan_jingpi: Optional[str] = Field(None, description='命盘精批JSON')
liunian_zongyun: Optional[str] = Field(None, description='流年总运JSON')
fengshui_jinnang: Optional[str] = Field(None, description='风水锦囊JSON')
yuedo_xiangpi: Optional[str] = Field(None, description='月度祥批JSON')
meiri_yuncheng: Optional[str] = Field(None, description='每日运程JSON')
# 解锁相关
is_unlocked: Optional[int] = Field(None, description='是否已解锁深度解析')
unlock_price: Optional[float] = Field(None, description='解锁价格')
unlocked_content: Optional[str] = Field(None, description='解锁后的深度内容JSON')
# 任务状态
task_status: Optional[int] = Field(None, description='任务状态')
error_message: Optional[str] = Field(None, description='错误信息')
# AI相关
ai_source_data: Optional[str] = Field(None, description='AI原始响应数据')
ai_model_version: Optional[str] = Field(None, description='AI模型版本')
remark: Optional[str] = Field(None, description='备注')
class YifanCaiyunJiexiQueryParam:
"""财运解析查询参数"""
def __init__(
self,
user_id: Optional[int] = Query(None, description='用户ID'),
report_id: Optional[int] = Query(None, description='方案ID'),
report_type: Optional[str] = Query(None, description='方案类型'),
task_status: Optional[int] = Query(None, description='任务状态'),
wealth_level: Optional[str] = Query(None, description='财运等级'),
is_unlocked: Optional[int] = Query(None, description='是否已解锁'),
name: Optional[str] = Query(None, description='姓名/公司名(模糊查询)')
) -> None:
self.user_id = user_id
self.report_id = report_id
self.report_type = report_type
self.task_status = task_status
self.wealth_level = wealth_level
self.is_unlocked = is_unlocked
self.name = name
# ==================== 任务状态响应 ====================
class CaiyunJiexiTaskStatusSchema(BaseModel):
"""财运解析任务状态响应"""
jiexi_id: int = Field(..., description='财运解析ID')
status: int = Field(..., description='任务状态(1已创建 2解析中 5解析成功 3解析超时 0任务失败)')
message: str = Field(..., description='状态描述')
progress: Optional[int] = Field(None, description='进度百分比', ge=0, le=100)

View File

@@ -0,0 +1,769 @@
# -*- coding: utf-8 -*-
import json
import asyncio
import time
from typing import Dict, Any
from app.core.logger import log
from app.core.exceptions import CustomException
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_yifan.yifan_caiyun_jiexi.crud import YifanCaiyunJiexiCRUD
from app.api.v1.module_yifan.yifan_caiyun_jiexi.schema import (
YifanCaiyunJiexiCreateSchema, YifanCaiyunJiexiUpdateSchema, YifanCaiyunJiexiQueryParam,
CaiyunJiexiAnalyzeRequestSchema, CaiyunJiexiAnalyzeResponseSchema, CaiyunJiexiTaskStatusSchema
)
from app.api.v1.module_yifan.yifan_naming_reports.crud import YifanNamingReportsCRUD
class YifanCaiyunJiexiService:
"""财运解析业务服务"""
@classmethod
async def analyze_caiyun_service(cls, auth: AuthSchema, data: CaiyunJiexiAnalyzeRequestSchema) -> Dict[str, Any]:
"""财运解析服务(异步模式)"""
log.info(f"[财运解析] 服务开始,用户: {auth.user.id if auth.user else None}, 方案ID: {data.report_id}")
user_id = auth.user.id if auth.user else 0
# 获取方案详情
report_crud = YifanNamingReportsCRUD(auth)
report_obj = await report_crud.get_by_id_yifan_naming_reports_crud(data.report_id)
if not report_obj:
raise CustomException(msg="方案记录不存在")
# 检查是否已经生成过财运解析
jiexi_crud = YifanCaiyunJiexiCRUD(auth)
existing_jiexi = await jiexi_crud.get_by_report_id_crud(data.report_id)
if existing_jiexi:
if existing_jiexi.task_status == 5: # 已完成
return {"jiexi_id": existing_jiexi.id, "status": 5, "message": "财运解析已完成"}
elif existing_jiexi.task_status in [1, 2]: # 进行中
return {"jiexi_id": existing_jiexi.id, "status": existing_jiexi.task_status, "message": "财运解析进行中"}
else: # 失败,重新生成
await jiexi_crud.delete_yifan_caiyun_jiexi_crud(existing_jiexi.id)
# 构建完整姓名
full_name = ""
if report_obj.surname and report_obj.given_name:
full_name = f"{report_obj.surname}{report_obj.given_name}"
elif report_obj.surname:
full_name = report_obj.surname
elif report_obj.given_name:
full_name = report_obj.given_name
# 处理生日字段类型转换
birthday = getattr(report_obj, 'birthday', None)
birth_date_str = None
if birthday:
if hasattr(birthday, 'strftime'):
birth_date_str = birthday.strftime('%Y年%m月%d')
else:
birth_date_str = str(birthday)
# 创建财运解析记录
create_data = YifanCaiyunJiexiCreateSchema(
user_id=user_id,
report_id=data.report_id,
report_type=report_obj.service_type,
name=full_name,
gender=getattr(report_obj, 'gender', None),
birth_date=birth_date_str,
birth_date_api=birthday,
birth_place=getattr(report_obj, 'address', None),
task_status=1,
is_deleted=0
)
log.info(f"[DEBUG] 准备创建财运解析记录,数据: {create_data}")
try:
jiexi_obj = await jiexi_crud.create_yifan_caiyun_jiexi_crud(data=create_data)
jiexi_id = jiexi_obj.id
# 确保事务提交,让后台任务能够找到刚创建的记录
if hasattr(auth.db, 'commit'):
await auth.db.commit()
log.info(f"[财运解析] 记录创建成功ID: {jiexi_id}")
except Exception as e:
log.error(f"[财运解析] 记录创建失败: {str(e)}")
log.error(f"[DEBUG] 异常详情: {type(e).__name__}: {str(e)}")
import traceback
log.error(f"[DEBUG] 异常堆栈: {traceback.format_exc()}")
raise CustomException(msg=f"保存财运解析记录失败: {str(e)}")
# 启动后台任务执行AI解析
asyncio.create_task(
cls._process_caiyun_analysis(
jiexi_id=jiexi_id,
report_obj=report_obj,
user_id=user_id,
)
)
return {"jiexi_id": jiexi_id, "status": 1, "message": "财运解析任务已创建"}
@classmethod
async def _process_caiyun_analysis(
cls,
jiexi_id: int,
report_obj: Any,
user_id: int,
) -> None:
"""后台任务执行财运解析AI分析"""
import asyncio
# 等待一小段时间,确保主事务已经提交
await asyncio.sleep(0.1)
log_prefix = f"[财运解析-{jiexi_id}]"
try:
# 更新状态为解析中
await cls._update_jiexi_status(jiexi_id, task_status=2)
log.info(f"{log_prefix} 开始AI解析")
# 根据category判断是个人还是公司
is_company = report_obj.category in ['company', 'Company']
if is_company:
# 公司财运解析
company_name = getattr(report_obj, 'given_name', '未提供')
industry = getattr(report_obj, 'industry', '未提供')
founder_name = getattr(report_obj, 'father_name', '未提供')
founder_birthday = getattr(report_obj, 'father_birth_date', None)
founder_birth_display = '未提供'
if founder_birthday:
if hasattr(founder_birthday, 'strftime'):
founder_birth_display = founder_birthday.strftime('%Y年%m月%d')
else:
founder_birth_display = str(founder_birthday)
# 构建公司财运解析AI输入文本
ai_input_text = f"""企业财运解析请求:
公司名称:{company_name}
所属行业:{industry}
创始人姓名:{founder_name}
创始人生辰:{founder_birth_display}
公司地址:{getattr(report_obj, 'address', '未提供')}
方案类型:{report_obj.service_type}
请根据以上信息进行全面的企业财运解析,包括企业命盘精批、流年总运和风水锦囊等,返回详细的分析结果。"""
else:
# 个人财运解析
full_name = ""
if report_obj.surname and report_obj.given_name:
full_name = f"{report_obj.surname}{report_obj.given_name}"
elif report_obj.surname:
full_name = report_obj.surname
elif report_obj.given_name:
full_name = report_obj.given_name
# 处理生日显示格式
birthday = getattr(report_obj, 'birthday', None)
birth_display = '未提供'
if birthday:
if hasattr(birthday, 'strftime'):
birth_display = birthday.strftime('%Y年%m月%d')
else:
birth_display = str(birthday)
# 构建个人财运解析AI输入文本
ai_input_text = f"""个人财运解析请求:
姓名:{full_name}
性别:{getattr(report_obj, 'gender', '未知')}
生辰:{birth_display}
出生地:{getattr(report_obj, 'address', '未提供')}
方案类型:{report_obj.service_type}
请根据以上信息进行全面的个人财运解析,包括财运评分、财运等级、投资建议、风水指导等,返回详细的分析结果。"""
# 调用AI服务进行解析
ai_response = await cls._call_ai_service(ai_input_text, is_company=is_company)
if not ai_response:
raise Exception("AI服务返回空结果")
# 解析AI响应并构建结果数据
result_data = cls._parse_ai_response(ai_response, report_obj)
# 更新数据库记录
update_data = YifanCaiyunJiexiUpdateSchema(
wealth_score=result_data.get('wealth_score'),
wealth_level=result_data.get('wealth_level'),
wealth_trend=result_data.get('wealth_trend'),
mingpan_jingpi=json.dumps(result_data.get('mingpan_jingpi'), ensure_ascii=False),
liunian_zongyun=json.dumps(result_data.get('liunian_zongyun'), ensure_ascii=False),
fengshui_jinnang=json.dumps(result_data.get('fengshui_jinnang'), ensure_ascii=False),
yuedo_xiangpi=json.dumps(result_data.get('yuedo_xiangpi'), ensure_ascii=False),
meiri_yuncheng=json.dumps(result_data.get('meiri_yuncheng'), ensure_ascii=False),
unlocked_content=json.dumps(result_data.get('unlocked'), ensure_ascii=False),
ai_source_data=ai_response,
task_status=5 # 解析成功
)
await cls._update_jiexi_record(jiexi_id, update_data)
log.info(f"{log_prefix} AI解析完成并保存成功")
except asyncio.TimeoutError:
log.error(f"{log_prefix} AI解析超时")
await cls._update_jiexi_status(jiexi_id, task_status=3, error_message="AI解析超时")
except Exception as e:
log.error(f"{log_prefix} AI解析失败: {str(e)}")
await cls._update_jiexi_status(jiexi_id, task_status=0, error_message=str(e)[:500])
@classmethod
async def _call_ai_service(cls, text: str, is_company: bool = False, max_retries: int = 3) -> str:
"""
调用AI服务进行财运解析带重试机制
Args:
text: 输入文本
is_company: 是否为公司财运解析
max_retries: 最大重试次数默认3次
Returns:
AI响应结果
"""
from app.api.v1.module_application.ai.service import AIModelTestService
log.info(f"[财运解析] 调用AI服务类型: {'企业' if is_company else '个人'},输入长度: {len(text)}")
# 根据类型选择不同的模型类型
model_type = "caiyun_jiexi_qiye" if is_company else "caiyun_jiexi"
last_exception = None
for attempt in range(max_retries + 1): # +1 因为包含第一次尝试
try:
if attempt > 0:
# 重试前等待,使用指数退避策略
wait_time = min(2 ** attempt, 10) # 最大等待10秒
log.info(f"[财运解析] 第{attempt + 1}次尝试,等待{wait_time}秒后重试")
await asyncio.sleep(wait_time)
# 调用实际的AI服务根据类型使用不同的模型
ai_response = await AIModelTestService.test_naming(
model_type=model_type,
text=text
)
log.info(f"[财运解析] AI服务响应成功响应长度: {len(ai_response)}")
return ai_response
except Exception as e:
last_exception = e
error_msg = str(e).lower()
# 判断是否为可重试的错误
retryable_errors = [
'invalid_grant',
'bad_response_status_code',
'timeout',
'服务暂时不可用',
'connection',
'network',
'500',
'502',
'503',
'504'
]
is_retryable = any(err in error_msg for err in retryable_errors)
if attempt < max_retries and is_retryable:
log.warning(f"[财运解析] AI服务调用失败(第{attempt + 1}次尝试): {str(e)},将进行重试")
continue
else:
if attempt >= max_retries:
log.error(f"[财运解析] AI服务调用失败已达到最大重试次数({max_retries}),最后错误: {str(e)}")
else:
log.error(f"[财运解析] AI服务调用失败错误不可重试: {str(e)}")
break
# 所有重试都失败了,使用降级方案
log.warning("[财运解析] 使用模拟数据作为降级方案")
mock_response = {
"wealth_score": 85,
"wealth_level": "中富",
"wealth_trend": "上升",
"mingpan_jingpi": {
"mingzao": "甲子年 丙寅月 戊午日 壬子时",
"zhen_taiyang_shi": "1984年2月15日 23:45 (真太阳时)",
"nongli_shengchen": "甲子年正月十四日子时",
"qianshi_yinji": "前世为商贾之人,善于理财,今生承继财运基因",
"jinsheng_keti": "此生课题:学会平衡物质与精神,通过正当途径积累财富",
"bazi_paipan": {
"nian": {"gan": "", "zhi": ""},
"yue": {"gan": "", "zhi": ""},
"ri": {"gan": "", "zhi": ""},
"shi": {"gan": "", "zhi": ""}
},
"mingge_cengci": "中等命格,财运中上",
"guji_duanyu": "《滴天髓》云:戊土生于寅月,木旺土虚,得火生扶,财运可期",
"qimen_paipan": "开门落宫,财星当令,主财运亨通",
"qimen_geju": "天盘甲子戊加地盘甲戌己,为青龙返首,主财运回升",
"wuxing_nengliang": {"mu": 30, "huo": 25, "tu": 20, "jin": 15, "shui": 10},
"dashi_pizhu": "命主八字中财星透干,且得月令生扶,中年后财运大开",
"wuxing_kaiyun": "宜补金水,可佩戴金饰或水晶,增强财运",
"shishen_mangdian": "偏财心性,容易冲动投资,需控制投机心理",
"shiye_caiyun_dingshu": "命中注定通过稳健经营获得财富,不宜投机取巧",
"hunyin_qinggan": "财星为用神,配偶贤内助,有助财运提升",
"wuxing_jiankang": "土弱易患脾胃疾病,金弱易有呼吸系统问题",
"shensha_guiren": "命带天乙贵人,遇困有贵人相助;文昌入命,利文职财运"
},
"liunian_zongyun": {
"dayun_zoushi": "30-40岁走丙寅大运木火通明事业财运俱佳",
"taisui_jiangjun": "甲辰太岁,与命主年柱相合,主吉利亨通",
"dangnian_liunian": "今年财运上升,投资理财有所收获,但需防小人",
"liunian_shensha": "流年遇天德贵人,化险为夷;犯太阴煞,防女性小人",
"fenqunti_zhuanyun": {
"qingnian": "多学习理财知识,积累第一桶金",
"zhongnian": "把握事业机遇,适度扩大投资",
"laonian": "保守理财,注重资产保值"
},
"liunian_jixiong_fangwei": {
"ji": ["东南", "正南", "西南"],
"xiong": ["正北", "东北"]
},
"caifu_laiyuan": "主要来源于工作收入和稳健投资,偏财运一般",
"touzi_lingyu_zhiyin": {
"fangdichan": "吉利,可适度投资",
"gupiao": "需谨慎,不宜重仓",
"jijin": "适合定投,风险可控",
"huangjin": "中性,可少量配置"
},
"dangnian_jiugong_feixing": "八白财星飞临东南方,利于财运提升",
"liunian_huajie": "可在东南方摆放招财植物,佩戴黄水晶化解不利"
},
"fengshui_jinnang": {
"guiren_huaxiang": "贵人多为中年男性,从事金融或地产行业,性格稳重",
"waiju_shaji_huajie": {
"lu_chong": "门前直路冲射,可摆放屏风化解",
"jian_dao_sha": "两楼夹缝形成剪刀煞,宜挂八卦镜",
"fan_gong_sha": "对面建筑反光,可用窗帘遮挡"
},
"jiaju_caiwei": {
"ming_caiwei": "客厅进门对角线位置,宜摆放发财树",
"an_caiwei": "根据飞星,今年在西南方,可放聚宝盆"
},
"zhichang_gaosheng": "办公桌左侧摆放文昌塔,背后靠实墙,面向开阔",
"cuiwang_taohua": "卧室西南角摆放粉水晶,增强桃花运",
"jiaju_zhiwu": [
{"name": "发财树", "position": "客厅财位", "effect": "招财进宝"},
{"name": "金钱树", "position": "办公室", "effect": "事业财运"},
{"name": "富贵竹", "position": "书房", "effect": "文昌财运"}
],
"mengchong_fengshui": "养金鱼利财运数量宜1、6、8条忌养猫",
"shuzi_nengliang": {
"shouji_hao": "尾号带6、8、9的号码利财运",
"che_pai": "选择数字组合168、888、666",
"lou_ceng": "6、8、16、18层为吉"
},
"aiche_pingan": "车内可悬挂平安符,选择金色或红色装饰",
"xijin_qianbao": {
"yanse": "黄色、棕色、金色最佳",
"zhidi": "真皮材质,避免帆布",
"shiyong": "钱包内放红绳穿制的古钱币"
},
"xingyun_se_peidai": {
"xingyun_se": ["金色", "黄色", "棕色"],
"peidai_shipin": [
{"name": "黄水晶手链", "effect": "增强正财运"},
{"name": "貔貅吊坠", "effect": "招财辟邪"},
{"name": "金戒指", "effect": "稳固财运"}
]
}
},
"yuedo_xiangpi": {
"quannian_gaishu": {
"zongyun_pinggu": "全年财运稳中有升,上半年平稳,下半年转旺",
"caiyun_zhuti": "正财为主,偏财为辅,宜稳健经营",
"guanjian_yueling": ["三月", "七月", "十月"],
"zhuyao_jixiong": "整体吉利,需防五月小人,十二月破财"
},
"yueling_xiangxi": {
"zhengYue": {
"ganzhi": "甲寅",
"hexin_yunshi": "新春开运,财源广进",
"jixiong_dengji": "大吉",
"caiyun_zhishu": 85,
"shiye_zhishu": 78,
"jiankang_zhishu": 82,
"renmai_zhishu": 75,
"zhongdian_shixiang": ["制定年度财务计划", "拓展人脉关系", "投资理财布局"],
"jixiong_rizi": {
"daji_rizi": ["初三", "初八", "十五"],
"xiaoji_rizi": ["初五", "十二", "二十"],
"jiji_rizi": ["初七", "十九", "二十五"]
},
"shendu_jiexi": "正月财星当令,利于开展新业务和投资项目,宜把握机遇积极行动"
}
},
"kaiyun_zhidao": {
"quannian_kaiyun": "佩戴黄水晶,办公室摆放招财树,增强财运磁场",
"zhongdian_yueling_zhidao": {
"最旺月份": "七月金水相生,财运最旺,可加大投资力度",
"最弱月份": "五月木土相克,需谨慎理财,避免冲动消费"
},
"wuxing_tiaohe": "补金水,泄木火,平衡五行能量",
"fengshui_buju": "财位摆放聚宝盆,门口悬挂五帝钱"
}
},
"meiri_yuncheng": {
"jinri_gaishu": {
"riqi_ganzhi": "甲子日",
"wuxing_zhuti": "木旺水相",
"zongyun_pinggu": "今日运势中上,财运亨通,事业顺遂",
"jixiong_dengji": "中吉",
"zhuyao_yingxiang": "甲木得水生扶,生机勃勃,利于开拓进取",
"kaiyun_yanse": ["青色", "绿色"],
"jiji_yanse": ["红色", "紫色"]
},
"yunshi_xiangxi": {
"caiyun_zhishu": 78,
"caiyun_fenxi": "今日正财运佳,工作收入稳定,投资理财有小幅收益,适合签约合作",
"shiye_zhishu": 82,
"shiye_fenxi": "工作效率高,决策判断准确,与同事合作愉快,有升职加薪机会",
"jiankang_zhishu": 75,
"jiankang_fenxi": "精神状态良好,体力充沛,注意保护肝胆,少食辛辣",
"qinggan_zhishu": 70,
"qinggan_fenxi": "人际关系和谐,感情发展平稳,家庭氛围温馨"
},
"fangwei_zhidao": {
"caishen_fangwei": {
"fangxiang": "正东",
"jiaodu": "75度",
"shiyong_shijian": "上午9-11点最佳",
"juti_zhidao": "面向正东方工作或谈判,有利于财运提升"
},
"xishen_fangwei": {
"fangxiang": "东南",
"jiaodu": "135度",
"shiyong_shijian": "下午1-3点最佳",
"juti_zhidao": "东南方摆放鲜花或绿植,增强喜庆气场"
},
"wenchang_fangwei": {
"fangxiang": "正南",
"jiaodu": "180度",
"shiyong_shijian": "上午7-9点最佳",
"juti_zhidao": "正南方学习工作,有利于思维敏捷,决策正确"
},
"jiji_fangwei": ["西北", "正北"],
"jiji_zhidao": "避免在西北和正北方进行重要决策或签约"
},
"shichen_jixiong": {
"chen_shi": {
"shijian": "07:00-09:00",
"ganzhi": "戊辰时",
"jixiong": "大吉",
"zhishu": 88,
"shiyong_shixiang": ["工作开始", "学习进修", "商务会议"],
"jiji_shixiang": ["懒散拖延", "负面思考"],
"xiangxi_fenxi": "辰时龙抬头,精神饱满,工作效率最高,适合处理重要事务"
}
},
"jinri_zhidao": {
"zuijia_shijian": "辰时(07:00-09:00)和酉时(17:00-19:00)",
"zhuyao_jiji": "未时(13:00-15:00)需特别谨慎",
"kaiyun_jianyi": ["佩戴绿色饰品", "多接触大自然", "保持积极心态"],
"zhuyi_shixiang": ["避免冲动决策", "注意肝胆保养", "防范小人是非"],
"chuanyi_jianyi": {
"yanse_xuanze": ["绿色", "青色"],
"fengge_jianyi": "清新自然风格",
"peishi_zhidao": "佩戴木质或绿色宝石饰品"
},
"yinshi_jianyi": {
"shiwu_xuanze": ["绿叶蔬菜", "新鲜水果"],
"jiji_shiwu": ["辛辣食物", "油腻食品"],
"yinshi_shijian": "早餐7-9点午餐11-13点最佳"
}
},
"tebie_tixing": {
"zhongda_shixiang": "今日适合签署重要合同,但需在上午完成",
"touzi_licai": "可适度增加股票投资,但避免高风险产品",
"renmai_guanxi": "主动与他人沟通,有利于建立长期合作关系",
"jiankang_yangsheng": "多做户外运动,呼吸新鲜空气,有益身心健康"
}
},
"unlocked": {
"shendu_fenxi": "深度八字命盘解析,包含详细的大运流年分析...",
"touzi_shiji": "最佳投资时机为每年3、6、9月避开7、12月...",
"caifu_mimi": "通过特定的风水布局和开运方法提升财运...",
"weilai_yuce": "未来五年财运预测及重大投资建议..."
}
}
return json.dumps(mock_response, ensure_ascii=False)
@classmethod
def _parse_ai_response(cls, ai_response: str, report_obj: Any) -> Dict[str, Any]:
"""解析AI响应数据"""
try:
# 添加详细的调试信息
log.info(f"[DEBUG] ai_response type: {type(ai_response)}")
log.info(f"[DEBUG] ai_response length: {len(ai_response) if ai_response else 'None'}")
log.info(f"[DEBUG] ai_response repr: {repr(ai_response[:200] if ai_response else 'None')}")
if not ai_response:
raise ValueError("AI响应为空")
if not isinstance(ai_response, str):
log.warning(f"AI响应不是字符串类型尝试转换: {type(ai_response)}")
ai_response = str(ai_response)
# 去除可能的前后空白字符和特殊字符
ai_response = ai_response.strip()
# 尝试找到JSON开始和结束位置
start_idx = ai_response.find('{')
end_idx = ai_response.rfind('}')
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
json_str = ai_response[start_idx:end_idx+1]
log.info(f"[DEBUG] 提取的JSON字符串: {json_str[:200]}...")
ai_data = json.loads(json_str)
else:
log.error(f"[DEBUG] 无法找到有效的JSON结构")
raise ValueError("AI响应中没有找到有效的JSON结构")
# 构建完整的响应数据
result_data = {
"wealth_score": ai_data.get("wealth_score", 0),
"wealth_level": ai_data.get("wealth_level", "平财"),
"wealth_trend": ai_data.get("wealth_trend", "稳定"),
"mingpan_jingpi": ai_data.get("mingpan_jingpi", {}),
"liunian_zongyun": ai_data.get("liunian_zongyun", {}),
"fengshui_jinnang": ai_data.get("fengshui_jinnang", {}),
"yuedo_xiangpi": ai_data.get("yuedo_xiangpi", {}),
"meiri_yuncheng": ai_data.get("meiri_yuncheng", {}),
"unlocked": ai_data.get("unlocked", {}),
"is_unlocked": False,
"unlock_price": 19.90
}
return result_data
except json.JSONDecodeError as e:
log.error(f"AI响应JSON解析失败: {str(e)}")
raise Exception(f"AI响应格式错误: {str(e)}")
@classmethod
async def _update_jiexi_status(cls, jiexi_id: int, task_status: int, error_message: str = None):
"""更新财运解析状态"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanCaiyunJiexiCRUD(temp_auth)
# 先检查记录是否存在
existing_record = await crud.get_yifan_caiyun_jiexi_crud(jiexi_id)
if not existing_record:
log.error(f"[财运解析] 记录不存在ID: {jiexi_id}")
raise Exception(f"财运解析记录不存在ID: {jiexi_id}")
update_data = YifanCaiyunJiexiUpdateSchema(
task_status=task_status,
error_message=error_message
)
await crud.update_yifan_caiyun_jiexi_crud(jiexi_id, update_data)
await session.commit() # 提交事务
log.info(f"[财运解析] 状态更新成功ID: {jiexi_id}, 状态: {task_status}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[财运解析] 状态更新失败ID: {jiexi_id}, 错误: {str(e)}")
raise
@classmethod
async def _update_jiexi_record(cls, jiexi_id: int, update_data: YifanCaiyunJiexiUpdateSchema):
"""更新财运解析记录"""
from app.core.database import async_db_session
async with async_db_session() as session:
try:
# 创建一个临时的auth对象用于更新操作
temp_auth = AuthSchema(user=None, db=session)
crud = YifanCaiyunJiexiCRUD(temp_auth)
await crud.update_yifan_caiyun_jiexi_crud(jiexi_id, update_data)
await session.commit() # 提交事务
log.info(f"[财运解析] 记录更新成功ID: {jiexi_id}")
except Exception as e:
await session.rollback() # 回滚事务
log.error(f"[财运解析] 记录更新失败ID: {jiexi_id}, 错误: {str(e)}")
raise
@classmethod
async def get_caiyun_jiexi_result_service(cls, auth: AuthSchema, jiexi_id: int) -> CaiyunJiexiAnalyzeResponseSchema:
"""获取财运解析结果"""
jiexi_obj = await YifanCaiyunJiexiCRUD(auth).get_yifan_caiyun_jiexi_crud(jiexi_id)
if not jiexi_obj:
raise CustomException(msg="财运解析记录不存在")
if jiexi_obj.task_status != 5:
raise CustomException(msg="财运解析尚未完成")
# 构建响应数据
response_data = {
"jiexi_id": jiexi_obj.id,
"report_id": jiexi_obj.report_id,
"name": jiexi_obj.name or "",
"wealth_score": jiexi_obj.wealth_score or 0,
"wealth_level": jiexi_obj.wealth_level or "平财",
"wealth_trend": jiexi_obj.wealth_trend or "稳定",
"mingpan_jingpi": jiexi_obj.mingpan_jingpi or "{}",
"liunian_zongyun": jiexi_obj.liunian_zongyun or "{}",
"fengshui_jinnang": jiexi_obj.fengshui_jinnang or "{}",
"yuedo_xiangpi": jiexi_obj.yuedo_xiangpi or "{}",
"meiri_yuncheng": jiexi_obj.meiri_yuncheng or "{}",
"unlocked": jiexi_obj.unlocked_content if jiexi_obj.unlocked_content and jiexi_obj.is_unlocked else None,
"is_unlocked": bool(jiexi_obj.is_unlocked),
"unlock_price": float(jiexi_obj.unlock_price or 19.90)
}
return CaiyunJiexiAnalyzeResponseSchema(**response_data)
@classmethod
async def get_caiyun_jiexi_result_by_report_service(cls, auth: AuthSchema, report_id: int) -> CaiyunJiexiAnalyzeResponseSchema:
"""根据报告ID获取财运解析结果"""
jiexi_crud = YifanCaiyunJiexiCRUD(auth)
jiexi_obj = await jiexi_crud.get_by_report_id_crud(report_id)
if not jiexi_obj:
raise CustomException(msg="该报告暂无财运解析记录")
if jiexi_obj.task_status != 5:
# 返回任务状态信息而不是抛出异常
status_messages = {
1: "财运解析已创建,等待处理",
2: "财运解析正在进行中",
3: "财运解析超时",
0: "财运解析失败"
}
raise CustomException(msg=status_messages.get(jiexi_obj.task_status, "财运解析状态未知"))
# 构建响应数据(与原方法相同的逻辑)
response_data = {
"jiexi_id": jiexi_obj.id,
"report_id": jiexi_obj.report_id,
"name": jiexi_obj.name or "",
"wealth_score": jiexi_obj.wealth_score or 0,
"wealth_level": jiexi_obj.wealth_level or "平财",
"wealth_trend": jiexi_obj.wealth_trend or "稳定",
"mingpan_jingpi": jiexi_obj.mingpan_jingpi or "{}",
"liunian_zongyun": jiexi_obj.liunian_zongyun or "{}",
"fengshui_jinnang": jiexi_obj.fengshui_jinnang or "{}",
"yuedo_xiangpi": jiexi_obj.yuedo_xiangpi or "{}",
"meiri_yuncheng": jiexi_obj.meiri_yuncheng or "{}",
"unlocked": jiexi_obj.unlocked_content if jiexi_obj.unlocked_content and jiexi_obj.is_unlocked else None,
"is_unlocked": bool(jiexi_obj.is_unlocked),
"unlock_price": float(jiexi_obj.unlock_price or 19.90)
}
return CaiyunJiexiAnalyzeResponseSchema(**response_data)
@classmethod
async def get_caiyun_jiexi_status_service(cls, auth: AuthSchema, jiexi_id: int) -> CaiyunJiexiTaskStatusSchema:
"""获取财运解析任务状态"""
jiexi_obj = await YifanCaiyunJiexiCRUD(auth).get_yifan_caiyun_jiexi_crud(jiexi_id)
if not jiexi_obj:
raise CustomException(msg="财运解析记录不存在")
status_messages = {
1: "已创建,等待处理",
2: "正在解析中",
5: "解析完成",
3: "解析超时",
0: "解析失败"
}
progress_map = {1: 10, 2: 50, 5: 100, 3: 0, 0: 0}
return CaiyunJiexiTaskStatusSchema(
jiexi_id=jiexi_obj.id,
status=jiexi_obj.task_status or 1,
message=status_messages.get(jiexi_obj.task_status or 1, "未知状态"),
progress=progress_map.get(jiexi_obj.task_status or 1, 0)
)
@classmethod
async def get_caiyun_jiexi_status_by_report_service(cls, auth: AuthSchema, report_id: int) -> CaiyunJiexiTaskStatusSchema:
"""根据报告ID获取财运解析任务状态"""
jiexi_crud = YifanCaiyunJiexiCRUD(auth)
jiexi_obj = await jiexi_crud.get_by_report_id_crud(report_id)
if not jiexi_obj:
# 如果没有财运解析记录,返回未创建状态
return CaiyunJiexiTaskStatusSchema(
jiexi_id=0,
status=0,
message="该报告暂无财运解析记录",
progress=0
)
status_messages = {
1: "已创建,等待处理",
2: "正在解析中",
5: "解析完成",
3: "解析超时",
0: "解析失败"
}
progress_map = {1: 10, 2: 50, 5: 100, 3: 0, 0: 0}
return CaiyunJiexiTaskStatusSchema(
jiexi_id=jiexi_obj.id,
status=jiexi_obj.task_status or 1,
message=status_messages.get(jiexi_obj.task_status or 1, "未知状态"),
progress=progress_map.get(jiexi_obj.task_status or 1, 0)
)
# ==================== CRUD服务方法 ====================
@classmethod
async def create_yifan_caiyun_jiexi_service(cls, auth: AuthSchema, data: YifanCaiyunJiexiCreateSchema) -> Dict[str, Any]:
"""创建财运解析记录"""
result = await YifanCaiyunJiexiCRUD(auth).create_yifan_caiyun_jiexi_crud(data=data)
return {"id": result.id}
@classmethod
async def get_yifan_caiyun_jiexi_service(cls, auth: AuthSchema, jiexi_id: int) -> Dict[str, Any]:
"""获取财运解析详情"""
result = await YifanCaiyunJiexiCRUD(auth).get_yifan_caiyun_jiexi_crud(jiexi_id=jiexi_id)
return result.__dict__ if result else {}
@classmethod
async def update_yifan_caiyun_jiexi_service(cls, auth: AuthSchema, jiexi_id: int, data: YifanCaiyunJiexiUpdateSchema) -> Dict[str, Any]:
"""更新财运解析记录"""
result = await YifanCaiyunJiexiCRUD(auth).update_yifan_caiyun_jiexi_crud(jiexi_id=jiexi_id, data=data)
return {"id": result.id}
@classmethod
async def delete_yifan_caiyun_jiexi_service(cls, auth: AuthSchema, jiexi_id: int) -> Dict[str, Any]:
"""删除财运解析记录"""
result = await YifanCaiyunJiexiCRUD(auth).delete_yifan_caiyun_jiexi_crud(jiexi_id=jiexi_id)
return {"success": result}
@classmethod
async def page_yifan_caiyun_jiexi_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: YifanCaiyunJiexiQueryParam,
order_by: str = None
) -> Dict[str, Any]:
"""分页查询财运解析列表"""
return await YifanCaiyunJiexiCRUD(auth).page_yifan_caiyun_jiexi_crud(
page_no=page_no,
page_size=page_size,
search=search,
order_by=order_by
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from sqlalchemy.ext.asyncio import AsyncSession
from .service import YifanFaqService
from .schema import YifanFaqCreateSchema, YifanFaqUpdateSchema, YifanFaqQueryParam
YifanFaqRouter = APIRouter(prefix='/yifan_faq', tags=["常见问题模块"])
@YifanFaqRouter.get("/mini/grouped", summary="小程序-按分类获取常见问题", description="按分类分组返回常见问题列表,用于小程序展示")
async def get_faq_grouped_for_mini(
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""
小程序获取常见问题接口(按分类分组)
返回格式:
{
"data": [
{
"category": "general",
"category_name": "通用",
"items": [
{"id": 1, "question": "...", "answer": "..."},
...
]
},
...
]
}
"""
result = await YifanFaqService.get_faq_grouped_service(db=db)
return SuccessResponse(data=result, msg="获取成功")
@YifanFaqRouter.get("/detail/{id}", summary="获取常见问题详情", description="获取常见问题详情")
async def get_yifan_faq_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:query"]))
) -> JSONResponse:
"""获取常见问题详情接口"""
result_dict = await YifanFaqService.detail_yifan_faq_service(auth=auth, id=id)
log.info(f"获取常见问题详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取常见问题详情成功")
@YifanFaqRouter.get("/list", summary="查询常见问题列表", description="查询常见问题列表")
async def get_yifan_faq_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanFaqQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:query"]))
) -> JSONResponse:
"""查询常见问题列表接口(数据库分页)"""
result_dict = await YifanFaqService.page_yifan_faq_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询常见问题列表成功")
return SuccessResponse(data=result_dict, msg="查询常见问题列表成功")
@YifanFaqRouter.post("/create", summary="创建常见问题", description="创建常见问题")
async def create_yifan_faq_controller(
data: YifanFaqCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:create"]))
) -> JSONResponse:
"""创建常见问题接口"""
result_dict = await YifanFaqService.create_yifan_faq_service(auth=auth, data=data)
log.info("创建常见问题成功")
return SuccessResponse(data=result_dict, msg="创建常见问题成功")
@YifanFaqRouter.put("/update/{id}", summary="修改常见问题", description="修改常见问题")
async def update_yifan_faq_controller(
data: YifanFaqUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:update"]))
) -> JSONResponse:
"""修改常见问题接口"""
result_dict = await YifanFaqService.update_yifan_faq_service(auth=auth, id=id, data=data)
log.info("修改常见问题成功")
return SuccessResponse(data=result_dict, msg="修改常见问题成功")
@YifanFaqRouter.delete("/delete", summary="删除常见问题", description="删除常见问题")
async def delete_yifan_faq_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:delete"]))
) -> JSONResponse:
"""删除常见问题接口"""
await YifanFaqService.delete_yifan_faq_service(auth=auth, ids=ids)
log.info(f"删除常见问题成功: {ids}")
return SuccessResponse(msg="删除常见问题成功")
@YifanFaqRouter.patch("/available/setting", summary="批量修改常见问题状态", description="批量修改常见问题状态")
async def batch_set_available_yifan_faq_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:patch"]))
) -> JSONResponse:
"""批量修改常见问题状态接口"""
await YifanFaqService.set_available_yifan_faq_service(auth=auth, data=data)
log.info(f"批量修改常见问题状态成功: {data.ids}")
return SuccessResponse(msg="批量修改常见问题状态成功")
@YifanFaqRouter.post('/export', summary="导出常见问题", description="导出常见问题")
async def export_yifan_faq_list_controller(
search: YifanFaqQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:export"]))
) -> StreamingResponse:
"""导出常见问题接口"""
result_dict_list = await YifanFaqService.list_yifan_faq_service(search=search, auth=auth)
export_result = await YifanFaqService.batch_export_yifan_faq_service(obj_list=result_dict_list)
log.info('导出常见问题成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_faq.xlsx'
}
)
@YifanFaqRouter.post('/import', summary="导入常见问题", description="导入常见问题")
async def import_yifan_faq_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_faq:import"]))
) -> JSONResponse:
"""导入常见问题接口"""
batch_import_result = await YifanFaqService.batch_import_yifan_faq_service(file=file, auth=auth, update_support=True)
log.info("导入常见问题成功")
return SuccessResponse(data=batch_import_result, msg="导入常见问题成功")
@YifanFaqRouter.post('/download/template', summary="获取常见问题导入模板", description="获取常见问题导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_faq:download"]))])
async def export_yifan_faq_template_controller() -> StreamingResponse:
"""获取常见问题导入模板接口"""
import_template_result = await YifanFaqService.import_template_download_yifan_faq_service()
log.info('获取常见问题导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_faq_template.xlsx'}
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanFaqModel
from .schema import YifanFaqCreateSchema, YifanFaqUpdateSchema, YifanFaqOutSchema
class YifanFaqCRUD(CRUDBase[YifanFaqModel, YifanFaqCreateSchema, YifanFaqUpdateSchema]):
"""常见问题数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanFaqModel, auth=auth)
async def get_by_id_yifan_faq_crud(self, id: int, preload: list | None = None) -> YifanFaqModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanFaqModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_faq_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanFaqModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanFaqModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_faq_crud(self, data: YifanFaqCreateSchema) -> YifanFaqModel | None:
"""
创建
参数:
- data (YifanFaqCreateSchema): 创建模型
返回:
- YifanFaqModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_faq_crud(self, id: int, data: YifanFaqUpdateSchema) -> YifanFaqModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanFaqUpdateSchema): 更新模型
返回:
- YifanFaqModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_faq_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_faq_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_faq_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanFaqOutSchema,
preload=preload
)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from sqlalchemy import Integer, DateTime, SmallInteger, Text, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanFaqModel(ModelMixin, UserMixin):
"""
常见问题表
"""
__tablename__: str = 'yifan_faq'
__table_args__: dict[str, str] = {'comment': '常见问题'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
question: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='问题标题')
answer: Mapped[str | None] = mapped_column(Text, nullable=True, comment='答案内容')
category: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='分类(general:通用 payment:支付 account:账户 service:服务 other:其他)')
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='排序(数值越小越靠前)')
view_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='浏览次数')
is_hot: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否热门(0否 1是)')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanFaqCreateSchema(BaseModel):
"""
常见问题新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
question: str = Field(default=..., description='问题标题')
answer: str = Field(default=..., description='答案内容')
category: str = Field(default='general', description='分类(general:通用 payment:支付 account:账户 service:服务 other:其他)')
sort_order: int = Field(default=0, description='排序(数值越小越靠前)')
view_count: int = Field(default=0, description='浏览次数')
is_hot: int = Field(default=0, description='是否热门(0否 1是)')
remark: str | None = Field(default=None, description='备注')
class YifanFaqUpdateSchema(YifanFaqCreateSchema):
"""
常见问题更新模型
"""
...
class YifanFaqOutSchema(YifanFaqCreateSchema, BaseSchema, UserBySchema):
"""
常见问题响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanFaqQueryParam:
"""常见问题查询参数"""
def __init__(
self,
question: str | None = Query(None, description="问题标题"),
answer: str | None = Query(None, description="答案内容"),
category: str | None = Query(None, description="分类(general:通用 payment:支付 account:账户 service:服务 other:其他)"),
remark: str | None = Query(None, description="备注"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
sort_order: int | None = Query(None, description="排序(数值越小越靠前)"),
view_count: int | None = Query(None, description="浏览次数"),
is_hot: int | None = Query(None, description="是否热门(0否 1是)"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 模糊查询字段
self.question = ("like", question)
# 模糊查询字段
self.answer = ("like", answer)
# 模糊查询字段
self.category = ("like", category)
# 精确查询字段
self.sort_order = sort_order
# 精确查询字段
self.view_count = view_count
# 精确查询字段
self.is_hot = is_hot
# 模糊查询字段
self.remark = ("like", remark)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanFaqCreateSchema, YifanFaqUpdateSchema, YifanFaqOutSchema, YifanFaqQueryParam
from .crud import YifanFaqCRUD
from .model import YifanFaqModel
# 分类映射
CATEGORY_MAP = {
'general': '通用',
'payment': '支付',
'account': '账户',
'service': '服务',
'other': '其他',
}
# 分类排序
CATEGORY_ORDER = ['general', 'payment', 'account', 'service', 'other']
class YifanFaqService:
"""
常见问题服务层
"""
@classmethod
async def get_faq_grouped_service(cls, db: AsyncSession) -> list[dict]:
"""
按分类分组获取常见问题(小程序专用)
"""
# 查询所有启用的FAQ按分类和排序字段排序
stmt = (
select(YifanFaqModel)
.where(YifanFaqModel.status == '1')
.order_by(YifanFaqModel.category, YifanFaqModel.sort_order)
)
result = await db.execute(stmt)
faq_list = result.scalars().all()
# 按分类分组
grouped = {}
for faq in faq_list:
category = faq.category or 'other'
if category not in grouped:
grouped[category] = []
grouped[category].append({
'id': faq.id,
'question': faq.question,
'answer': faq.answer,
'is_hot': faq.is_hot,
})
# 按预定义顺序返回
result_list = []
for cat in CATEGORY_ORDER:
if cat in grouped:
result_list.append({
'category': cat,
'category_name': CATEGORY_MAP.get(cat, cat),
'items': grouped[cat]
})
# 添加未知分类
for cat, items in grouped.items():
if cat not in CATEGORY_ORDER:
result_list.append({
'category': cat,
'category_name': CATEGORY_MAP.get(cat, cat),
'items': items
})
return result_list
@classmethod
async def detail_yifan_faq_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanFaqCRUD(auth).get_by_id_yifan_faq_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanFaqOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_faq_service(cls, auth: AuthSchema, search: YifanFaqQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanFaqCRUD(auth).list_yifan_faq_crud(search=search_dict, order_by=order_by)
return [YifanFaqOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_faq_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanFaqQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanFaqCRUD(auth).page_yifan_faq_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_faq_service(cls, auth: AuthSchema, data: YifanFaqCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanFaqCRUD(auth).create_yifan_faq_crud(data=data)
return YifanFaqOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_faq_service(cls, auth: AuthSchema, id: int, data: YifanFaqUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanFaqCRUD(auth).get_by_id_yifan_faq_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanFaqCRUD(auth).update_yifan_faq_crud(id=id, data=data)
return YifanFaqOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_faq_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanFaqCRUD(auth).get_by_id_yifan_faq_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanFaqCRUD(auth).delete_yifan_faq_crud(ids=ids)
@classmethod
async def set_available_yifan_faq_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanFaqCRUD(auth).set_available_yifan_faq_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_faq_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'question': '问题标题',
'answer': '答案内容',
'category': '分类(general:通用 payment:支付 account:账户 service:服务 other:其他)',
'sort_order': '排序(数值越小越靠前)',
'view_count': '浏览次数',
'is_hot': '是否热门(0否 1是)',
'remark': '备注',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_faq_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'问题标题': 'question',
'答案内容': 'answer',
'分类(general:通用 payment:支付 account:账户 service:服务 other:其他)': 'category',
'排序(数值越小越靠前)': 'sort_order',
'浏览次数': 'view_count',
'是否热门(0否 1是)': 'is_hot',
'备注': 'remark',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"question": row['question'],
"answer": row['answer'],
"category": row['category'],
"sort_order": row['sort_order'],
"view_count": row['view_count'],
"is_hot": row['is_hot'],
"remark": row['remark'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanFaqCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanFaqCRUD(auth).create_yifan_faq_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_faq_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'问题标题',
'答案内容',
'分类(general:通用 payment:支付 account:账户 service:服务 other:其他)',
'排序(数值越小越靠前)',
'浏览次数',
'是否热门(0否 1是)',
'备注',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query, Request
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse, ErrorResponse
from app.core.dependencies import AuthPermission, db_getter, get_current_user
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from sqlalchemy.ext.asyncio import AsyncSession
from .service import YifanFeedbackService
from .schema import YifanFeedbackCreateSchema, YifanFeedbackUpdateSchema, YifanFeedbackQueryParam, MiniFeedbackSubmitSchema
YifanFeedbackRouter = APIRouter(prefix='/yifan_feedback', tags=["意见反馈模块"])
@YifanFeedbackRouter.post("/mini/submit", summary="小程序-提交意见反馈", description="小程序用户提交意见反馈")
async def submit_feedback_for_mini(
data: MiniFeedbackSubmitSchema,
auth: AuthSchema = Depends(get_current_user)
) -> JSONResponse:
"""
小程序提交意见反馈接口
需要登录自动获取当前用户ID
"""
user_id = auth.user.id
result = await YifanFeedbackService.submit_feedback_service(auth=auth, user_id=user_id, data=data)
log.info(f"用户 {user_id} 提交意见反馈成功")
return SuccessResponse(data=result, msg="提交成功,感谢您的反馈!")
@YifanFeedbackRouter.get("/detail/{id}", summary="获取意见反馈详情", description="获取意见反馈详情")
async def get_yifan_feedback_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:query"]))
) -> JSONResponse:
"""获取意见反馈详情接口"""
result_dict = await YifanFeedbackService.detail_yifan_feedback_service(auth=auth, id=id)
log.info(f"获取意见反馈详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取意见反馈详情成功")
@YifanFeedbackRouter.get("/list", summary="查询意见反馈列表", description="查询意见反馈列表")
async def get_yifan_feedback_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanFeedbackQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:query"]))
) -> JSONResponse:
"""查询意见反馈列表接口(数据库分页)"""
result_dict = await YifanFeedbackService.page_yifan_feedback_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询意见反馈列表成功")
return SuccessResponse(data=result_dict, msg="查询意见反馈列表成功")
@YifanFeedbackRouter.post("/create", summary="创建意见反馈", description="创建意见反馈")
async def create_yifan_feedback_controller(
data: YifanFeedbackCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:create"]))
) -> JSONResponse:
"""创建意见反馈接口"""
result_dict = await YifanFeedbackService.create_yifan_feedback_service(auth=auth, data=data)
log.info("创建意见反馈成功")
return SuccessResponse(data=result_dict, msg="创建意见反馈成功")
@YifanFeedbackRouter.put("/update/{id}", summary="修改意见反馈", description="修改意见反馈")
async def update_yifan_feedback_controller(
data: YifanFeedbackUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:update"]))
) -> JSONResponse:
"""修改意见反馈接口"""
result_dict = await YifanFeedbackService.update_yifan_feedback_service(auth=auth, id=id, data=data)
log.info("修改意见反馈成功")
return SuccessResponse(data=result_dict, msg="修改意见反馈成功")
@YifanFeedbackRouter.delete("/delete", summary="删除意见反馈", description="删除意见反馈")
async def delete_yifan_feedback_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:delete"]))
) -> JSONResponse:
"""删除意见反馈接口"""
await YifanFeedbackService.delete_yifan_feedback_service(auth=auth, ids=ids)
log.info(f"删除意见反馈成功: {ids}")
return SuccessResponse(msg="删除意见反馈成功")
@YifanFeedbackRouter.patch("/available/setting", summary="批量修改意见反馈状态", description="批量修改意见反馈状态")
async def batch_set_available_yifan_feedback_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:patch"]))
) -> JSONResponse:
"""批量修改意见反馈状态接口"""
await YifanFeedbackService.set_available_yifan_feedback_service(auth=auth, data=data)
log.info(f"批量修改意见反馈状态成功: {data.ids}")
return SuccessResponse(msg="批量修改意见反馈状态成功")
@YifanFeedbackRouter.post('/export', summary="导出意见反馈", description="导出意见反馈")
async def export_yifan_feedback_list_controller(
search: YifanFeedbackQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:export"]))
) -> StreamingResponse:
"""导出意见反馈接口"""
result_dict_list = await YifanFeedbackService.list_yifan_feedback_service(search=search, auth=auth)
export_result = await YifanFeedbackService.batch_export_yifan_feedback_service(obj_list=result_dict_list)
log.info('导出意见反馈成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_feedback.xlsx'
}
)
@YifanFeedbackRouter.post('/import', summary="导入意见反馈", description="导入意见反馈")
async def import_yifan_feedback_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_feedback:import"]))
) -> JSONResponse:
"""导入意见反馈接口"""
batch_import_result = await YifanFeedbackService.batch_import_yifan_feedback_service(file=file, auth=auth, update_support=True)
log.info("导入意见反馈成功")
return SuccessResponse(data=batch_import_result, msg="导入意见反馈成功")
@YifanFeedbackRouter.post('/download/template', summary="获取意见反馈导入模板", description="获取意见反馈导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_feedback:download"]))])
async def export_yifan_feedback_template_controller() -> StreamingResponse:
"""获取意见反馈导入模板接口"""
import_template_result = await YifanFeedbackService.import_template_download_yifan_feedback_service()
log.info('获取意见反馈导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_feedback_template.xlsx'}
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanFeedbackModel
from .schema import YifanFeedbackCreateSchema, YifanFeedbackUpdateSchema, YifanFeedbackOutSchema
class YifanFeedbackCRUD(CRUDBase[YifanFeedbackModel, YifanFeedbackCreateSchema, YifanFeedbackUpdateSchema]):
"""意见反馈数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanFeedbackModel, auth=auth)
async def get_by_id_yifan_feedback_crud(self, id: int, preload: list | None = None) -> YifanFeedbackModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanFeedbackModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_feedback_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanFeedbackModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanFeedbackModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_feedback_crud(self, data: YifanFeedbackCreateSchema) -> YifanFeedbackModel | None:
"""
创建
参数:
- data (YifanFeedbackCreateSchema): 创建模型
返回:
- YifanFeedbackModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_feedback_crud(self, id: int, data: YifanFeedbackUpdateSchema) -> YifanFeedbackModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanFeedbackUpdateSchema): 更新模型
返回:
- YifanFeedbackModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_feedback_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_feedback_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_feedback_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanFeedbackOutSchema,
preload=preload
)

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import Integer, DateTime, SmallInteger, Text, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanFeedbackModel(ModelMixin, UserMixin):
"""
意见反馈表
"""
__tablename__: str = 'yifan_feedback'
__table_args__: dict[str, str] = {'comment': '意见反馈'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户ID')
content: Mapped[str | None] = mapped_column(Text, nullable=True, comment='反馈内容')
images: Mapped[str | None] = mapped_column(String(1000), nullable=True, comment='图片URL(多个用逗号分隔)')
contact: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='联系方式')
feedback_type: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)')
handle_status: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='处理状态(0待处理 1处理中 2已处理 3已关闭)')
handle_result: Mapped[str | None] = mapped_column(Text, nullable=True, comment='处理结果')
handle_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='处理时间')
handler_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='处理人ID')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
import datetime
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class MiniFeedbackSubmitSchema(BaseModel):
"""
小程序提交意见反馈模型
"""
content: str = Field(..., min_length=1, max_length=2000, description='反馈内容')
images: str | None = Field(default=None, description='图片URL(多个用逗号分隔)')
contact: str | None = Field(default=None, max_length=100, description='联系方式')
feedback_type: str = Field(default='other', description='反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)')
class YifanFeedbackCreateSchema(BaseModel):
"""
意见反馈新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
user_id: int | None = Field(default=None, description='用户ID')
content: str = Field(default=..., description='反馈内容')
images: str | None = Field(default=None, description='图片URL(多个用逗号分隔)')
contact: str | None = Field(default=None, description='联系方式')
feedback_type: str = Field(default='other', description='反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)')
handle_status: int = Field(default=0, description='处理状态(0待处理 1处理中 2已处理 3已关闭)')
handle_result: str | None = Field(default=None, description='处理结果')
handle_time: DateTimeStr | None = Field(default=None, description='处理时间')
handler_id: int | None = Field(default=None, description='处理人ID')
remark: str | None = Field(default=None, description='备注')
class YifanFeedbackUpdateSchema(YifanFeedbackCreateSchema):
"""
意见反馈更新模型
"""
...
class YifanFeedbackOutSchema(YifanFeedbackCreateSchema, BaseSchema, UserBySchema):
"""
意见反馈响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanFeedbackQueryParam:
"""意见反馈查询参数"""
def __init__(
self,
content: str | None = Query(None, description="反馈内容"),
images: str | None = Query(None, description="图片URL(多个用逗号分隔)"),
contact: str | None = Query(None, description="联系方式"),
feedback_type: str | None = Query(None, description="反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)"),
handle_result: str | None = Query(None, description="处理结果"),
remark: str | None = Query(None, description="备注"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
user_id: int | None = Query(None, description="用户ID"),
handle_status: int | None = Query(None, description="处理状态(0待处理 1处理中 2已处理 3已关闭)"),
handle_time: datetime.datetime | None = Query(None, description="处理时间"),
handler_id: int | None = Query(None, description="处理人ID"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 精确查询字段
self.user_id = user_id
# 模糊查询字段
self.content = ("like", content)
# 模糊查询字段
self.images = ("like", images)
# 模糊查询字段
self.contact = ("like", contact)
# 模糊查询字段
self.feedback_type = ("like", feedback_type)
# 精确查询字段
self.handle_status = handle_status
# 模糊查询字段
self.handle_result = ("like", handle_result)
# 精确查询字段
self.handle_time = handle_time
# 精确查询字段
self.handler_id = handler_id
# 模糊查询字段
self.remark = ("like", remark)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanFeedbackCreateSchema, YifanFeedbackUpdateSchema, YifanFeedbackOutSchema, YifanFeedbackQueryParam, MiniFeedbackSubmitSchema
from .crud import YifanFeedbackCRUD
from .model import YifanFeedbackModel
class YifanFeedbackService:
"""
意见反馈服务层
"""
@classmethod
async def submit_feedback_service(cls, auth: AuthSchema, user_id: int, data: MiniFeedbackSubmitSchema) -> dict:
"""
小程序提交意见反馈
"""
# 构造创建数据
create_data = {
'user_id': user_id,
'content': data.content,
'images': data.images,
'contact': data.contact,
'feedback_type': data.feedback_type,
'handle_status': 0, # 待处理
'status': 1, # 启用
'is_deleted': 0,
}
obj = await YifanFeedbackCRUD(auth).create(data=create_data)
return {
'id': obj.id,
'feedback_type': obj.feedback_type,
'created_time': str(obj.created_time),
}
@classmethod
async def detail_yifan_feedback_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanFeedbackCRUD(auth).get_by_id_yifan_feedback_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanFeedbackOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_feedback_service(cls, auth: AuthSchema, search: YifanFeedbackQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanFeedbackCRUD(auth).list_yifan_feedback_crud(search=search_dict, order_by=order_by)
return [YifanFeedbackOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_feedback_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanFeedbackQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanFeedbackCRUD(auth).page_yifan_feedback_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_feedback_service(cls, auth: AuthSchema, data: YifanFeedbackCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanFeedbackCRUD(auth).create_yifan_feedback_crud(data=data)
return YifanFeedbackOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_feedback_service(cls, auth: AuthSchema, id: int, data: YifanFeedbackUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanFeedbackCRUD(auth).get_by_id_yifan_feedback_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanFeedbackCRUD(auth).update_yifan_feedback_crud(id=id, data=data)
return YifanFeedbackOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_feedback_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanFeedbackCRUD(auth).get_by_id_yifan_feedback_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanFeedbackCRUD(auth).delete_yifan_feedback_crud(ids=ids)
@classmethod
async def set_available_yifan_feedback_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanFeedbackCRUD(auth).set_available_yifan_feedback_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_feedback_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'user_id': '用户ID',
'content': '反馈内容',
'images': '图片URL(多个用逗号分隔)',
'contact': '联系方式',
'feedback_type': '反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)',
'handle_status': '处理状态(0待处理 1处理中 2已处理 3已关闭)',
'handle_result': '处理结果',
'handle_time': '处理时间',
'handler_id': '处理人ID',
'remark': '备注',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_feedback_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'用户ID': 'user_id',
'反馈内容': 'content',
'图片URL(多个用逗号分隔)': 'images',
'联系方式': 'contact',
'反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)': 'feedback_type',
'处理状态(0待处理 1处理中 2已处理 3已关闭)': 'handle_status',
'处理结果': 'handle_result',
'处理时间': 'handle_time',
'处理人ID': 'handler_id',
'备注': 'remark',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"user_id": row['user_id'],
"content": row['content'],
"images": row['images'],
"contact": row['contact'],
"feedback_type": row['feedback_type'],
"handle_status": row['handle_status'],
"handle_result": row['handle_result'],
"handle_time": row['handle_time'],
"handler_id": row['handler_id'],
"remark": row['remark'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanFeedbackCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanFeedbackCRUD(auth).create_yifan_feedback_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_feedback_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'用户ID',
'反馈内容',
'图片URL(多个用逗号分隔)',
'联系方式',
'反馈类型(suggestion:建议 bug:问题 complaint:投诉 other:其他)',
'处理状态(0待处理 1处理中 2已处理 3已关闭)',
'处理结果',
'处理时间',
'处理人ID',
'备注',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanNamingFavoritesService
from .schema import YifanNamingFavoritesCreateSchema, YifanNamingFavoritesUpdateSchema, YifanNamingFavoritesQueryParam, YifanNamingFavoriteSchema, YifanNamingUnfavoriteSchema, MyFavoritesQueryParam
YifanNamingFavoritesRouter = APIRouter(prefix='/yifan_naming_favorites', tags=["命名方案收藏模块"])
@YifanNamingFavoritesRouter.get("/my_favorites", summary="获取我的收藏列表", description="获取当前用户的收藏列表,支持按分类筛选")
async def get_my_favorites_controller(
page_no: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
search: MyFavoritesQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""
获取我的收藏列表接口
返回数据格式:
- total: 总数
- items: 收藏列表
- id: 收藏ID
- solution_id: 方案ID
- category: 分类(personal:个人名 company:商号)
- name: 名字
- pinyin: 拼音
- tags: 标签列表
- created_time: 收藏时间
"""
result = await YifanNamingFavoritesService.get_my_favorites_service(
auth=auth,
category=search.category,
page_no=page_no,
page_size=page_size
)
log.info(f"获取我的收藏列表成功, 用户ID: {auth.user.id}")
return SuccessResponse(data=result, msg="获取收藏列表成功")
@YifanNamingFavoritesRouter.get("/detail/{id}", summary="获取命名方案收藏详情", description="获取命名方案收藏详情")
async def get_yifan_naming_favorites_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:query"]))
) -> JSONResponse:
"""获取命名方案收藏详情接口"""
result_dict = await YifanNamingFavoritesService.detail_yifan_naming_favorites_service(auth=auth, id=id)
log.info(f"获取命名方案收藏详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取命名方案收藏详情成功")
@YifanNamingFavoritesRouter.get("/list", summary="查询命名方案收藏列表", description="查询命名方案收藏列表")
async def get_yifan_naming_favorites_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanNamingFavoritesQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:query"]))
) -> JSONResponse:
"""查询命名方案收藏列表接口(数据库分页)"""
result_dict = await YifanNamingFavoritesService.page_yifan_naming_favorites_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询命名方案收藏列表成功")
return SuccessResponse(data=result_dict, msg="查询命名方案收藏列表成功")
@YifanNamingFavoritesRouter.post("/create", summary="创建命名方案收藏", description="创建命名方案收藏")
async def create_yifan_naming_favorites_controller(
data: YifanNamingFavoritesCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:create"]))
) -> JSONResponse:
"""创建命名方案收藏接口"""
result_dict = await YifanNamingFavoritesService.create_yifan_naming_favorites_service(auth=auth, data=data)
log.info("创建命名方案收藏成功")
return SuccessResponse(data=result_dict, msg="创建命名方案收藏成功")
@YifanNamingFavoritesRouter.put("/update/{id}", summary="修改命名方案收藏", description="修改命名方案收藏")
async def update_yifan_naming_favorites_controller(
data: YifanNamingFavoritesUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:update"]))
) -> JSONResponse:
"""修改命名方案收藏接口"""
result_dict = await YifanNamingFavoritesService.update_yifan_naming_favorites_service(auth=auth, id=id, data=data)
log.info("修改命名方案收藏成功")
return SuccessResponse(data=result_dict, msg="修改命名方案收藏成功")
@YifanNamingFavoritesRouter.delete("/delete", summary="删除命名方案收藏", description="删除命名方案收藏")
async def delete_yifan_naming_favorites_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:delete"]))
) -> JSONResponse:
"""删除命名方案收藏接口"""
await YifanNamingFavoritesService.delete_yifan_naming_favorites_service(auth=auth, ids=ids)
log.info(f"删除命名方案收藏成功: {ids}")
return SuccessResponse(msg="删除命名方案收藏成功")
@YifanNamingFavoritesRouter.patch("/available/setting", summary="批量修改命名方案收藏状态", description="批量修改命名方案收藏状态")
async def batch_set_available_yifan_naming_favorites_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:patch"]))
) -> JSONResponse:
"""批量修改命名方案收藏状态接口"""
await YifanNamingFavoritesService.set_available_yifan_naming_favorites_service(auth=auth, data=data)
log.info(f"批量修改命名方案收藏状态成功: {data.ids}")
return SuccessResponse(msg="批量修改命名方案收藏状态成功")
@YifanNamingFavoritesRouter.post('/export', summary="导出命名方案收藏", description="导出命名方案收藏")
async def export_yifan_naming_favorites_list_controller(
search: YifanNamingFavoritesQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:export"]))
) -> StreamingResponse:
"""导出命名方案收藏接口"""
result_dict_list = await YifanNamingFavoritesService.list_yifan_naming_favorites_service(search=search, auth=auth)
export_result = await YifanNamingFavoritesService.batch_export_yifan_naming_favorites_service(obj_list=result_dict_list)
log.info('导出命名方案收藏成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_naming_favorites.xlsx'
}
)
@YifanNamingFavoritesRouter.post('/import', summary="导入命名方案收藏", description="导入命名方案收藏")
async def import_yifan_naming_favorites_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_favorites:import"]))
) -> JSONResponse:
"""导入命名方案收藏接口"""
batch_import_result = await YifanNamingFavoritesService.batch_import_yifan_naming_favorites_service(file=file, auth=auth, update_support=True)
log.info("导入命名方案收藏成功")
return SuccessResponse(data=batch_import_result, msg="导入命名方案收藏成功")
@YifanNamingFavoritesRouter.post('/download/template', summary="获取命名方案收藏导入模板", description="获取命名方案收藏导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_naming_favorites:download"]))])
async def export_yifan_naming_favorites_template_controller() -> StreamingResponse:
"""获取命名方案收藏导入模板接口"""
import_template_result = await YifanNamingFavoritesService.import_template_download_yifan_naming_favorites_service()
log.info('获取命名方案收藏导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_naming_favorites_template.xlsx'}
)
@YifanNamingFavoritesRouter.post("/favorite", summary="收藏命名方案", description="收藏命名方案")
async def add_favorite_controller(
data: YifanNamingFavoriteSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""收藏命名方案接口"""
result_dict = await YifanNamingFavoritesService.add_favorite_service(auth=auth, data=data)
log.info(f"收藏命名方案成功: solution_id={data.solution_id}")
return SuccessResponse(data=result_dict, msg="收藏成功")
@YifanNamingFavoritesRouter.post("/unfavorite", summary="取消收藏命名方案", description="取消收藏命名方案")
async def remove_favorite_controller(
data: YifanNamingUnfavoriteSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""取消收藏命名方案接口"""
await YifanNamingFavoritesService.remove_favorite_service(auth=auth, data=data)
log.info(f"取消收藏命名方案成功: solution_id={data.solution_id}")
return SuccessResponse(msg="取消收藏成功")

View File

@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanNamingFavoritesModel
from .schema import YifanNamingFavoritesCreateSchema, YifanNamingFavoritesUpdateSchema, YifanNamingFavoritesOutSchema
class YifanNamingFavoritesCRUD(CRUDBase[YifanNamingFavoritesModel, YifanNamingFavoritesCreateSchema, YifanNamingFavoritesUpdateSchema]):
"""命名方案收藏数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanNamingFavoritesModel, auth=auth)
async def get_by_id_yifan_naming_favorites_crud(self, id: int, preload: list | None = None) -> YifanNamingFavoritesModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanNamingFavoritesModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_naming_favorites_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanNamingFavoritesModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanNamingFavoritesModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_naming_favorites_crud(self, data: YifanNamingFavoritesCreateSchema) -> YifanNamingFavoritesModel | None:
"""
创建
参数:
- data (YifanNamingFavoritesCreateSchema): 创建模型
返回:
- YifanNamingFavoritesModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_naming_favorites_crud(self, id: int, data: YifanNamingFavoritesUpdateSchema) -> YifanNamingFavoritesModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanNamingFavoritesUpdateSchema): 更新模型
返回:
- YifanNamingFavoritesModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_naming_favorites_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_naming_favorites_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_naming_favorites_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanNamingFavoritesOutSchema,
preload=preload
)
async def get_by_condition_yifan_naming_favorites_crud(self, user_id: int, solution_id: int, category: str | None = None) -> YifanNamingFavoritesModel | None:
"""
根据条件查询收藏记录
参数:
- user_id (int): 用户ID
- solution_id (int): 方案ID
- category (str | None): 分类(可选)
返回:
- YifanNamingFavoritesModel | None: 模型实例或None
"""
search = {
'user_id': user_id,
'solution_id': solution_id,
'is_deleted': 0
}
if category:
search['category'] = category
result = await self.list(search=search)
return result[0] if result else None
async def get_my_favorites_crud(self, user_id: int, category: str | None = None, page_no: int = 1, page_size: int = 10) -> dict:
"""
获取我的收藏列表(关联方案信息)
参数:
- user_id (int): 用户ID
- category (str | None): 分类筛选
- page_no (int): 页码
- page_size (int): 每页数量
返回:
- dict: 分页数据
"""
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from app.api.v1.module_yifan.yifan_naming_solutions.model import YifanNamingSolutionsModel
# 构建查询条件
conditions = [
YifanNamingFavoritesModel.user_id == user_id,
YifanNamingFavoritesModel.is_deleted == 0
]
if category:
conditions.append(YifanNamingFavoritesModel.category == category)
# 查询总数
count_stmt = select(func.count()).select_from(YifanNamingFavoritesModel).where(*conditions)
total = await self.auth.db.scalar(count_stmt)
# 分页查询收藏记录,关联方案信息
offset = (page_no - 1) * page_size
stmt = (
select(
YifanNamingFavoritesModel.id,
YifanNamingFavoritesModel.solution_id,
YifanNamingFavoritesModel.category,
YifanNamingFavoritesModel.created_time,
YifanNamingSolutionsModel.name,
YifanNamingSolutionsModel.pinyin,
YifanNamingSolutionsModel.tags
)
.join(YifanNamingSolutionsModel, YifanNamingFavoritesModel.solution_id == YifanNamingSolutionsModel.id)
.where(*conditions)
.order_by(YifanNamingFavoritesModel.created_time.desc())
.offset(offset)
.limit(page_size)
)
result = await self.auth.db.execute(stmt)
rows = result.all()
items = []
for row in rows:
items.append({
'id': row.id,
'solution_id': row.solution_id,
'category': row.category,
'created_time': row.created_time.strftime('%Y-%m-%d') if row.created_time else '',
'name': row.name,
'pinyin': row.pinyin,
'tags': row.tags or []
})
return {
'total': total or 0,
'items': items
}

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from sqlalchemy import DateTime, Integer, String, SmallInteger, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanNamingFavoritesModel(ModelMixin, UserMixin):
"""
命名方案收藏表
"""
__tablename__: str = 'yifan_naming_favorites'
__table_args__: dict[str, str] = {'comment': '命名方案收藏'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户ID')
solution_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='方案ID(关联yifan_naming_solutions表)')
category: Mapped[str | None] = mapped_column(String(20), nullable=True, comment='分类(personal:个人 company:商号)')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from typing import Optional
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class MyFavoriteItemSchema(BaseModel):
"""
我的收藏列表项
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(description='收藏ID')
solution_id: int = Field(description='方案ID')
category: str = Field(description='分类(personal:个人名 company:商号)')
name: str = Field(description='名字')
pinyin: Optional[str] = Field(default=None, description='拼音')
tags: Optional[list] = Field(default=None, description='标签列表')
created_time: str = Field(description='收藏时间')
class MyFavoritesListSchema(BaseModel):
"""
我的收藏列表响应
"""
total: int = Field(description='总数')
items: list[MyFavoriteItemSchema] = Field(description='收藏列表')
class MyFavoritesQueryParam:
"""我的收藏查询参数"""
def __init__(
self,
category: str | None = Query(None, description="分类筛选(personal:个人名 company:商号,不传则查全部)"),
) -> None:
self.category = category
class YifanNamingFavoritesCreateSchema(BaseModel):
"""
命名方案收藏新增模型
"""
is_deleted: int = Field(default=..., description='是否删除(0否 1是)')
user_id: int = Field(default=..., description='用户ID')
solution_id: int = Field(default=..., description='方案ID(关联yifan_naming_solutions表)')
category: str = Field(default=..., description='分类(personal:个人 company:商号)')
remark: str = Field(default=..., description='备注')
description: str | None = Field(default=None, max_length=255, description='备注/描述')
class YifanNamingFavoriteSchema(BaseModel):
"""
收藏请求模型
"""
solution_id: int = Field(default=..., description='方案ID(关联yifan_naming_solutions表)')
category: str = Field(default=..., description='分类(personal:个人 company:商号)')
class YifanNamingUnfavoriteSchema(BaseModel):
"""
取消收藏请求模型
"""
solution_id: int = Field(default=..., description='方案ID(关联yifan_naming_solutions表)')
class YifanNamingFavoritesUpdateSchema(YifanNamingFavoritesCreateSchema):
"""
命名方案收藏更新模型
"""
...
class YifanNamingFavoritesOutSchema(YifanNamingFavoritesCreateSchema, BaseSchema, UserBySchema):
"""
命名方案收藏响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanNamingFavoritesQueryParam:
"""命名方案收藏查询参数"""
def __init__(
self,
category: str | None = Query(None, description="分类(personal:个人 company:商号)"),
remark: str | None = Query(None, description="备注"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
user_id: int | None = Query(None, description="用户ID"),
solution_id: int | None = Query(None, description="方案ID(关联yifan_naming_solutions表)"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.user_id = user_id
# 精确查询字段
self.solution_id = solution_id
# 模糊查询字段
self.category = ("like", category)
# 模糊查询字段
self.remark = ("like", remark)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanNamingFavoritesCreateSchema, YifanNamingFavoritesUpdateSchema, YifanNamingFavoritesOutSchema, YifanNamingFavoritesQueryParam, YifanNamingFavoriteSchema, YifanNamingUnfavoriteSchema
from .crud import YifanNamingFavoritesCRUD
class YifanNamingFavoritesService:
"""
命名方案收藏服务层
"""
@classmethod
async def detail_yifan_naming_favorites_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanNamingFavoritesCRUD(auth).get_by_id_yifan_naming_favorites_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanNamingFavoritesOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_naming_favorites_service(cls, auth: AuthSchema, search: YifanNamingFavoritesQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanNamingFavoritesCRUD(auth).list_yifan_naming_favorites_crud(search=search_dict, order_by=order_by)
return [YifanNamingFavoritesOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_naming_favorites_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanNamingFavoritesQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanNamingFavoritesCRUD(auth).page_yifan_naming_favorites_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_naming_favorites_service(cls, auth: AuthSchema, data: YifanNamingFavoritesCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanNamingFavoritesCRUD(auth).create_yifan_naming_favorites_crud(data=data)
return YifanNamingFavoritesOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_naming_favorites_service(cls, auth: AuthSchema, id: int, data: YifanNamingFavoritesUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanNamingFavoritesCRUD(auth).get_by_id_yifan_naming_favorites_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanNamingFavoritesCRUD(auth).update_yifan_naming_favorites_crud(id=id, data=data)
return YifanNamingFavoritesOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_naming_favorites_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanNamingFavoritesCRUD(auth).get_by_id_yifan_naming_favorites_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanNamingFavoritesCRUD(auth).delete_yifan_naming_favorites_crud(ids=ids)
@classmethod
async def set_available_yifan_naming_favorites_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanNamingFavoritesCRUD(auth).set_available_yifan_naming_favorites_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_naming_favorites_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'user_id': '用户ID',
'solution_id': '方案ID(关联yifan_naming_solutions表)',
'category': '分类(personal:个人 company:商号)',
'remark': '备注',
'uuid': 'UUID全局唯一标识',
'description': '备注/描述',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_naming_favorites_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'用户ID': 'user_id',
'方案ID(关联yifan_naming_solutions表)': 'solution_id',
'分类(personal:个人 company:商号)': 'category',
'备注': 'remark',
'UUID全局唯一标识': 'uuid',
'备注/描述': 'description',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"user_id": row['user_id'],
"solution_id": row['solution_id'],
"category": row['category'],
"remark": row['remark'],
"uuid": row['uuid'],
"description": row['description'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanNamingFavoritesCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanNamingFavoritesCRUD(auth).create_yifan_naming_favorites_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_naming_favorites_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'用户ID',
'方案ID(关联yifan_naming_solutions表)',
'分类(personal:个人 company:商号)',
'备注',
'UUID全局唯一标识',
'备注/描述',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)
@classmethod
async def add_favorite_service(cls, auth: AuthSchema, data: YifanNamingFavoriteSchema) -> dict:
"""添加收藏"""
# 检查是否已收藏
existing = await YifanNamingFavoritesCRUD(auth).get_by_condition_yifan_naming_favorites_crud(
user_id=auth.user.id,
solution_id=data.solution_id,
category=data.category
)
if existing:
raise CustomException(msg="该方案已收藏")
# 创建收藏记录
create_data = YifanNamingFavoritesCreateSchema(
is_deleted=0,
user_id=auth.user.id,
solution_id=data.solution_id,
category=data.category,
remark=""
)
obj = await YifanNamingFavoritesCRUD(auth).create_yifan_naming_favorites_crud(data=create_data)
return YifanNamingFavoritesOutSchema.model_validate(obj).model_dump()
@classmethod
async def remove_favorite_service(cls, auth: AuthSchema, data: YifanNamingUnfavoriteSchema) -> None:
"""取消收藏"""
# 查找收藏记录
existing = await YifanNamingFavoritesCRUD(auth).get_by_condition_yifan_naming_favorites_crud(
user_id=auth.user.id,
solution_id=data.solution_id
)
if not existing:
raise CustomException(msg="该方案未收藏")
# 删除收藏记录
await YifanNamingFavoritesCRUD(auth).delete_yifan_naming_favorites_crud(ids=[existing.id])
@classmethod
async def get_my_favorites_service(cls, auth: AuthSchema, category: str | None = None, page_no: int = 1, page_size: int = 10) -> dict:
"""
获取我的收藏列表
参数:
- auth: 认证信息
- category: 分类筛选(personal/company)
- page_no: 页码
- page_size: 每页数量
返回:
- dict: 包含total和items的分页数据
"""
return await YifanNamingFavoritesCRUD(auth).get_my_favorites_crud(
user_id=auth.user.id,
category=category,
page_no=page_no,
page_size=page_size
)

View File

@@ -0,0 +1,223 @@
# 公司起名接口使用说明
## 接口地址
`POST /api/v1/yifan/yifan_naming_reports/company-naming`
## 功能说明
该接口用于公司起名服务,会:
1. 保存创始人和公司的基本信息到 `yifan_naming_reports`
2. 调用 AI 模型enterprise_naming生成起名建议
3. 解析 AI 响应并保存到以下表:
- `yifan_naming_solutions` - 保存推荐的公司名称方案
- `yifan_solution_sections` - 保存各个板块的摘要数据
- `yifan_section_details` - 保存板块的详细内容
4. 返回报告ID和AI完整响应
## 数据流转
```
用户输入
yifan_naming_reports (报告表)
AI 处理
yifan_naming_solutions (方案表) - 多个推荐名称
yifan_solution_sections (板块表) - 每个方案的板块
yifan_section_details (详情表) - 每个板块的详细内容
```
## 请求参数
```json
{
"industry": "科技、互联网",
"address": "北京",
"founder_name": "张三",
"founder_birthday": "1990-01-01 08:00:00",
"founder_birth_time": "辰时",
"founder_gender": "male",
"core_members": "[{\"name\":\"李四\",\"birthday\":\"1992-05-15\"}]",
"vision": "成为行业领先的科技公司",
"preference": "亲和温暖",
"user_id": 123
}
```
### 参数说明
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| industry | string | 是 | 所属行业与主营业务 |
| address | string | 是 | 注册地 |
| founder_name | string | 是 | 创始人姓名 |
| founder_birthday | datetime | 是 | 创始人出生日期格式YYYY-MM-DD HH:mm:ss |
| founder_birth_time | string | 否 | 出生时辰 |
| founder_gender | string | 是 | 性别male:男 female:女) |
| core_members | string | 否 | 核心成员信息JSON格式字符串 |
| vision | string | 否 | 企业愿景/品牌调性 |
| preference | string | 否 | 您希望名字传递的感觉 |
| user_id | int | 否 | 用户ID |
## 响应示例
```json
{
"code": 200,
"msg": "公司起名完成",
"data": {
"report_id": 1001,
"ai_response": "根据您提供的信息,为您推荐以下公司名称:\n\n1. 【鼎新科技】\n寓意象征权威与稳固代表创新与发展...\n\n2. 【云启智联】\n寓意代表云计算与互联网寓意开启新篇章..."
}
}
```
## AI 输入格式
接口会将用户输入的信息格式化为以下文本,传递给 AI 模型:
```
创始人:张三
出生日期1990年01月01日
出生时辰:辰时
性别:男
所属行业与主营业务:科技、互联网
企业愿景/品牌调性:成为行业领先的科技公司
您希望名字传递的感觉:亲和温暖
注册地:北京
```
## 前端调用示例
```typescript
import YifanNamingAPI from "@/api/module_yifan/yifan_naming";
// 调用公司起名接口
const response = await YifanNamingAPI.companyNaming({
industry: "科技、互联网",
address: "北京",
founder_name: "张三",
founder_birthday: "1990-01-01 08:00:00",
founder_birth_time: "辰时",
founder_gender: "male",
vision: "成为行业领先的科技公司",
preference: "亲和温暖"
});
console.log("报告ID:", response.data.report_id);
console.log("AI响应:", response.data.ai_response);
```
## 注意事项
1. 该接口无需登录即可调用
2. 出生日期必须包含时间部分HH:mm:ss
3. AI 响应内容会完整保存在 `ai_source_data` 字段中
4. 返回的 `report_id` 可用于后续查询报告详情
## AI 响应解析
接口会尝试解析 AI 返回的内容,支持以下格式:
### 格式1JSON 格式
```json
{
"solutions": [
{
"name": "鼎新科技",
"pinyin": "ding xin ke ji",
"total_score": 95,
"star_rating": 5,
"wuxing": "火土",
"tags": ["创新", "稳重"],
"name_meaning": "鼎,象征权威与稳固...",
"poetry_source": "出自《诗经》...",
"sections": [
{
"section_type": "ditiantai",
"title": "地天泰",
"summary_data": {...},
"details": [...]
}
]
}
]
}
```
### 格式2文本格式
```
1. 【鼎新科技】
拼音ding xin ke ji
寓意:鼎,象征权威与稳固;新,代表创新与发展...
五行:火土
诗词出处:出自《诗经》...
2. 【云启智联】
拼音yun qi zhi lian
寓意:云,代表云计算与互联网...
```
如果 AI 返回的格式无法解析,系统会创建一个默认方案,将完整响应保存在 `name_meaning` 字段中。
## 板块类型说明
`section_type` 支持以下类型:
- `ditiantai` - 地天泰
- `kaiyun_jingnang` - 开运锦囊
- `shici` - 诗词出处
- `wuxing` - 五行分布
- `liuyao` - 六维格局
- `jiazu` - 家族起名
- `bihua` - 笔画数理
- `ziyi_shengxiao` - 字义生肖
- `zonghe_pingfen` - 综合评分
## 详情类型说明
`detail_type` 支持以下类型:
- `life_guide` - 生活开运指南
- `poetry_base` - 诗词底蕴
- `hexagram_meaning` - 卦象释义
- `depth_analysis` - 深度字义解读
- `zodiac_analysis` - 生肖喜忌分析
- `master_comment` - 大师总评
## 数据库表关系
```sql
-- 1对多关系
yifan_naming_reports (1) yifan_naming_solutions (N)
-- 1对多关系
yifan_naming_solutions (1) yifan_solution_sections (N)
-- 1对多关系
yifan_solution_sections (1) yifan_section_details (N)
```
## 查询示例
### 查询报告的所有方案
```python
solutions = await YifanNamingSolutionsCRUD(auth).get_list_crud(
search={'report_id': report_id}
)
```
### 查询方案的所有板块
```python
sections = await YifanSolutionSectionsCRUD(auth).get_list_crud(
search={'solution_id': solution_id}
)
```
### 查询板块的所有详情
```python
details = await YifanSectionDetailsCRUD(auth).get_list_crud(
search={'section_id': section_id}
)
```

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.notice.crud import NoticeCRUD
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanNamingReportsService
from .schema import YifanNamingReportsCreateSchema, YifanNamingReportsUpdateSchema, YifanNamingReportsQueryParam, FortuneTellingRequestSchema, PersonalScoringTrialRequestSchema, CompanyNamingRequestSchema, PersonalNamingRequestSchema, PersonalRenamingRequestSchema, CompanyRenamingRequestSchema, CompanyScoringRequestSchema
YifanNamingReportsRouter = APIRouter(prefix='/yifan_naming_reports', tags=["命名服务案例模块"])
@YifanNamingReportsRouter.get("/my_reports", summary="获取我的方案列表", description="获取当前用户的方案列表")
async def get_my_reports_controller(
page_no: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""
获取我的方案列表接口
返回数据格式:
- total: 总数
- items: 方案列表
- id: 报告ID
- title: 标题
- subtitle: 副标题/描述
- category: 分类(personal:个人 company:公司)
- service_type: 服务类型
- has_solutions: 是否已生成方案
- created_time: 创建时间
- relative_time: 相对时间
"""
result = await YifanNamingReportsService.get_my_reports_service(
auth=auth,
page_no=page_no,
page_size=page_size
)
log.info(f"获取我的方案列表成功, 用户ID: {auth.user.id}")
return SuccessResponse(data=result, msg="获取方案列表成功")
@YifanNamingReportsRouter.get("/{report_id}/solutions", summary="获取报告方案列表", description="根据报告ID获取方案卡片列表小程序展示")
async def get_report_solutions_controller(
report_id: int = Path(..., description="报告ID"),
auth: AuthSchema = Depends(AuthPermission([], check_data_scope=False))
) -> JSONResponse:
result = await YifanNamingReportsService.get_report_solutions_service(auth=auth, report_id=report_id)
log.info(f"获取报告方案列表成功, report_id: {report_id}")
return SuccessResponse(data=result, msg="获取报告方案列表成功")
@YifanNamingReportsRouter.get("/admin/{report_id}/solutions", summary="管理后台获取报告方案列表", description="管理后台根据报告ID获取方案列表")
async def admin_get_report_solutions_controller(
report_id: int = Path(..., description="报告ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:query"]))
) -> JSONResponse:
result = await YifanNamingReportsService.admin_get_report_solutions_service(auth=auth, report_id=report_id)
log.info(f"管理后台获取报告方案列表成功, report_id: {report_id}")
return SuccessResponse(data=result, msg="获取报告方案列表成功")
@YifanNamingReportsRouter.get("/detail/{id}", summary="获取命名服务案例详情", description="获取命名服务案例详情")
async def get_yifan_naming_reports_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:query"]))
) -> JSONResponse:
"""获取命名服务案例详情接口"""
result_dict = await YifanNamingReportsService.detail_yifan_naming_reports_service(auth=auth, id=id)
log.info(f"获取命名服务案例详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取命名服务案例详情成功")
@YifanNamingReportsRouter.get("/list", summary="查询命名服务案例列表", description="查询命名服务案例列表")
async def get_yifan_naming_reports_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanNamingReportsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:query"]))
) -> JSONResponse:
"""查询命名服务案例列表接口(数据库分页)"""
result_dict = await YifanNamingReportsService.page_yifan_naming_reports_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询命名服务案例列表成功")
return SuccessResponse(data=result_dict, msg="查询命名服务案例列表成功")
@YifanNamingReportsRouter.post("/create", summary="创建命名服务案例", description="创建命名服务案例")
async def create_yifan_naming_reports_controller(
data: YifanNamingReportsCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:create"]))
) -> JSONResponse:
"""创建命名服务案例接口"""
result_dict = await YifanNamingReportsService.create_yifan_naming_reports_service(auth=auth, data=data)
log.info("创建命名服务案例成功")
return SuccessResponse(data=result_dict, msg="创建命名服务案例成功")
@YifanNamingReportsRouter.put("/update/{id}", summary="修改命名服务案例", description="修改命名服务案例")
async def update_yifan_naming_reports_controller(
data: YifanNamingReportsUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:update"]))
) -> JSONResponse:
"""修改命名服务案例接口"""
result_dict = await YifanNamingReportsService.update_yifan_naming_reports_service(auth=auth, id=id, data=data)
log.info("修改命名服务案例成功")
return SuccessResponse(data=result_dict, msg="修改命名服务案例成功")
@YifanNamingReportsRouter.delete("/delete", summary="删除命名服务案例", description="删除命名服务案例")
async def delete_yifan_naming_reports_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:delete"]))
) -> JSONResponse:
"""删除命名服务案例接口"""
await YifanNamingReportsService.delete_yifan_naming_reports_service(auth=auth, ids=ids)
log.info(f"删除命名服务案例成功: {ids}")
return SuccessResponse(msg="删除命名服务案例成功")
@YifanNamingReportsRouter.patch("/available/setting", summary="批量修改命名服务案例状态", description="批量修改命名服务案例状态")
async def batch_set_available_yifan_naming_reports_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:patch"]))
) -> JSONResponse:
"""批量修改命名服务案例状态接口"""
await YifanNamingReportsService.set_available_yifan_naming_reports_service(auth=auth, data=data)
log.info(f"批量修改命名服务案例状态成功: {data.ids}")
return SuccessResponse(msg="批量修改命名服务案例状态成功")
@YifanNamingReportsRouter.post('/export', summary="导出命名服务案例", description="导出命名服务案例")
async def export_yifan_naming_reports_list_controller(
search: YifanNamingReportsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:export"]))
) -> StreamingResponse:
"""导出命名服务案例接口"""
result_dict_list = await YifanNamingReportsService.list_yifan_naming_reports_service(search=search, auth=auth)
export_result = await YifanNamingReportsService.batch_export_yifan_naming_reports_service(obj_list=result_dict_list)
log.info('导出命名服务案例成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_naming_reports.xlsx'
}
)
@YifanNamingReportsRouter.post('/import', summary="导入命名服务案例", description="导入命名服务案例")
async def import_yifan_naming_reports_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_reports:import"]))
) -> JSONResponse:
"""导入命名服务案例接口"""
batch_import_result = await YifanNamingReportsService.batch_import_yifan_naming_reports_service(file=file, auth=auth, update_support=True)
log.info("导入命名服务案例成功")
return SuccessResponse(data=batch_import_result, msg="导入命名服务案例成功")
@YifanNamingReportsRouter.post('/download/template', summary="获取命名服务案例导入模板", description="获取命名服务案例导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_naming_reports:download"]))])
async def export_yifan_naming_reports_template_controller() -> StreamingResponse:
"""获取命名服务案例导入模板接口"""
import_template_result = await YifanNamingReportsService.import_template_download_yifan_naming_reports_service()
log.info('获取命名服务案例导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_naming_reports_template.xlsx'}
)
@YifanNamingReportsRouter.post("/fortune-telling", summary="测名服务", description="八字测名,生成分析报告(无需登录)")
async def fortune_telling_controller(
data: FortuneTellingRequestSchema,
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""测名服务接口 - 保存基本信息并生成分析报告"""
auth = AuthSchema(db=db, check_data_scope=False)
result_dict = await YifanNamingReportsService.fortune_telling_service(auth=auth, data=data)
log.info(f"测名服务成功: {data.surname}{data.given_name}")
return SuccessResponse(data=result_dict, msg="测名分析完成")
@YifanNamingReportsRouter.post("/personal-scoring", summary="个人测名服务(收费版)", description="个人测名(收费版),异步生成测名报告")
async def personal_scoring_controller(
data: PersonalScoringTrialRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""个人测名(收费版)接口 - 创建任务后台异步生成AI测名报告"""
result_dict = await YifanNamingReportsService.personal_scoring_service(auth=auth, data=data)
log.info(f"个人测名(收费)任务已创建: {data.surname}{data.given_name}")
return SuccessResponse(data=result_dict, msg="测名任务已创建,正在测算中")
@YifanNamingReportsRouter.post("/personal-scoring-trial", summary="个人测名服务(免费版)", description="个人测名(免费版),每用户首次免费,之后每周一次")
async def personal_scoring_trial_controller(
data: PersonalScoringTrialRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""个人测名(免费版)接口"""
result_dict = await YifanNamingReportsService.personal_scoring_trial_service(auth=auth, data=data)
log.info(f"免费测名服务成功: {data.surname}{data.given_name}")
return SuccessResponse(data=result_dict, msg="免费测名分析完成")
@YifanNamingReportsRouter.post("/scoring-trial-share-callback", summary="分享回调-赠送免费测名次数", description="好友通过分享链接进入小程序后,调用此接口为分享者增加免费次数")
async def scoring_trial_share_callback_controller(
sharer_user_id: int = Body(..., embed=True, description="分享者用户ID"),
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""分享回调接口 - 为分享者增加本周免费测名次数"""
result = await YifanNamingReportsService.grant_share_trial_quota(sharer_user_id=sharer_user_id)
return SuccessResponse(data=result, msg="分享奖励已发放")
@YifanNamingReportsRouter.post("/company-scoring", summary="企业测名服务(收费版)", description="企业测名(收费版),异步生成测名报告")
async def company_scoring_controller(
data: CompanyScoringRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""企业测名(收费版)接口 - 创建任务后台异步生成AI测名报告"""
result_dict = await YifanNamingReportsService.company_scoring_service(auth=auth, data=data)
log.info(f"企业测名(收费)任务已创建: {data.company_name}")
return SuccessResponse(data=result_dict, msg="测名任务已创建,正在测算中")
@YifanNamingReportsRouter.post("/company-scoring-trial", summary="企业测名服务(免费版)", description="企业测名(免费版),每用户首次免费,之后每周一次")
async def company_scoring_trial_controller(
data: CompanyScoringRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""企业测名(免费版)接口"""
result_dict = await YifanNamingReportsService.company_scoring_trial_service(auth=auth, data=data)
log.info(f"企业免费测名服务成功: {data.company_name}")
return SuccessResponse(data=result_dict, msg="企业免费测名分析完成")
@YifanNamingReportsRouter.post("/company-naming", summary="公司起名服务", description="公司起名,创建任务并异步生成起名建议")
async def company_naming_controller(
data: CompanyNamingRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""公司起名服务接口 - 创建任务,后台异步生成起名建议"""
result_dict = await YifanNamingReportsService.company_naming_service(auth=auth, data=data)
founder_name = data.core_members[0].name if data.core_members else "未知"
log.info(f"公司起名任务已创建: {founder_name} - {data.industry}")
return SuccessResponse(data=result_dict, msg="起名任务已创建,正在测算中")
@YifanNamingReportsRouter.post("/personal-naming", summary="个人起名服务", description="个人起名,创建任务并异步生成起名建议")
async def personal_naming_controller(
data: PersonalNamingRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""个人起名服务接口 - 创建任务,后台异步生成起名建议"""
result_dict = await YifanNamingReportsService.personal_naming_service(auth=auth, data=data)
log.info(f"个人起名任务已创建: {data.last_name} - {data.style_label}")
return SuccessResponse(data=result_dict, msg="起名任务已创建,正在测算中")
@YifanNamingReportsRouter.post("/company-renaming", summary="企业改名服务", description="企业改名,创建任务并异步生成改名建议")
async def company_renaming_controller(
data: CompanyRenamingRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""企业改名服务接口 - 创建任务,后台异步生成改名建议"""
result_dict = await YifanNamingReportsService.company_renaming_service(auth=auth, data=data)
founder_name = data.core_members[0].name if data.core_members else "未知"
log.info(f"企业改名任务已创建: {founder_name} - {data.industry}")
return SuccessResponse(data=result_dict, msg="改名任务已创建,正在测算中")
@YifanNamingReportsRouter.post("/personal-renaming", summary="个人改名服务", description="个人改名,创建任务并异步生成改名建议")
async def personal_renaming_controller(
data: PersonalRenamingRequestSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
"""个人改名服务接口 - 创建任务,后台异步生成改名建议"""
result_dict = await YifanNamingReportsService.personal_renaming_service(auth=auth, data=data)
log.info(f"个人改名任务已创建: {data.last_name} - {data.original_name}")
return SuccessResponse(data=result_dict, msg="改名任务已创建,正在测算中")
@YifanNamingReportsRouter.get("/calendar", summary="获取当日黄历", description="获取当日黄历信息,包含农历、干支、宜忌等")
async def get_calendar_info_controller(
date: str | None = Query(None, description="日期(YYYY-MM-DD格式不传则为当天)")
) -> JSONResponse:
"""
获取黄历信息接口(无需登录)
返回数据格式:
- lunar_day: 农历日(如:十二)
- lunar_month: 农历月(如:腊月)
- lunar_year: 农历年(如:甲辰年)
- solar_date: 公历日期2024.01.22
- ganzhi_year: 干支年(如:甲辰)
- zodiac: 生肖(如:龙)
- yi: 宜(如:出行、开市)
- ji: 忌(如:动土、安葬)
- weekday: 星期
"""
result_dict = await YifanNamingReportsService.get_calendar_info_service(date=date)
log.info(f"获取黄历信息成功: {date or '当天'}")
return SuccessResponse(data=result_dict, msg="获取黄历信息成功")

View File

@@ -0,0 +1,324 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanNamingReportsModel
from .schema import YifanNamingReportsCreateSchema, YifanNamingReportsUpdateSchema, YifanNamingReportsOutSchema
class YifanNamingReportsCRUD(CRUDBase[YifanNamingReportsModel, YifanNamingReportsCreateSchema, YifanNamingReportsUpdateSchema]):
"""命名服务案例数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanNamingReportsModel, auth=auth)
async def get_by_id_yifan_naming_reports_crud(self, id: int, preload: list | None = None) -> YifanNamingReportsModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanNamingReportsModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_naming_reports_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanNamingReportsModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanNamingReportsModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_naming_reports_crud(self, data: YifanNamingReportsCreateSchema) -> YifanNamingReportsModel | None:
"""
创建
参数:
- data (YifanNamingReportsCreateSchema): 创建模型
返回:
- YifanNamingReportsModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_naming_reports_crud(self, id: int, data: YifanNamingReportsUpdateSchema) -> YifanNamingReportsModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanNamingReportsUpdateSchema): 更新模型
返回:
- YifanNamingReportsModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_naming_reports_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_naming_reports_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_naming_reports_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanNamingReportsOutSchema,
preload=preload
)
async def find_existing_report_crud(
self, surname: str, given_name: str, gender: str, birthday
) -> 'YifanNamingReportsModel | None':
"""按姓名+性别+生日查找已有的成功个人测名报告"""
from sqlalchemy import select
stmt = (
select(YifanNamingReportsModel)
.where(
YifanNamingReportsModel.surname == surname,
YifanNamingReportsModel.given_name == given_name,
YifanNamingReportsModel.gender == gender,
YifanNamingReportsModel.birthday == birthday,
YifanNamingReportsModel.status == 5,
YifanNamingReportsModel.is_deleted == 0,
YifanNamingReportsModel.category == "personal",
)
.order_by(YifanNamingReportsModel.created_time.desc())
.limit(1)
)
result = await self.auth.db.execute(stmt)
return result.scalar_one_or_none()
async def find_existing_company_report_crud(
self, company_name: str, industry: str, birthday
) -> 'YifanNamingReportsModel | None':
"""按公司名称+行业+创始人生日查找已有的成功企业测名报告"""
from sqlalchemy import select
stmt = (
select(YifanNamingReportsModel)
.where(
YifanNamingReportsModel.given_name == company_name,
YifanNamingReportsModel.industry == industry,
YifanNamingReportsModel.birthday == birthday,
YifanNamingReportsModel.status == 5,
YifanNamingReportsModel.is_deleted == 0,
YifanNamingReportsModel.category == "company",
YifanNamingReportsModel.service_type == "fortune_telling",
)
.order_by(YifanNamingReportsModel.created_time.desc())
.limit(1)
)
result = await self.auth.db.execute(stmt)
return result.scalar_one_or_none()
async def get_my_reports_crud(self, user_id: int, page_no: int = 1, page_size: int = 10) -> dict:
"""
获取我的方案列表
参数:
- user_id (int): 用户ID
- page_no (int): 页码
- page_size (int): 每页数量
返回:
- dict: 分页数据
"""
from sqlalchemy import select, func, exists
from app.api.v1.module_yifan.yifan_naming_solutions.model import YifanNamingSolutionsModel
# 构建查询条件
conditions = [
YifanNamingReportsModel.user_id == user_id,
YifanNamingReportsModel.is_deleted == 0
]
# 查询总数
count_stmt = select(func.count()).select_from(YifanNamingReportsModel).where(*conditions)
total = await self.auth.db.scalar(count_stmt)
# 分页查询
offset = (page_no - 1) * page_size
stmt = (
select(YifanNamingReportsModel)
.where(*conditions)
.order_by(YifanNamingReportsModel.created_time.desc())
.offset(offset)
.limit(page_size)
)
result = await self.auth.db.execute(stmt)
reports = result.scalars().all()
# 获取每个报告是否有方案
report_ids = [r.id for r in reports]
has_solutions_map = {}
if report_ids:
solutions_stmt = (
select(YifanNamingSolutionsModel.report_id)
.where(
YifanNamingSolutionsModel.report_id.in_(report_ids),
YifanNamingSolutionsModel.is_deleted == 0
)
.distinct()
)
solutions_result = await self.auth.db.execute(solutions_stmt)
has_solutions_map = {r for r in solutions_result.scalars().all()}
items = []
for report in reports:
# 构建标题和副标题
title, subtitle = self._build_report_display(report)
# 计算相对时间
relative_time = self._get_relative_time(report.created_time)
items.append({
'id': report.id,
'title': title,
'subtitle': subtitle,
'category': report.category or '',
'service_type': report.service_type or '',
'has_solutions': report.id in has_solutions_map,
'created_time': report.created_time.strftime('%Y-%m-%d %H:%M:%S') if report.created_time else '',
'relative_time': relative_time
})
return {
'total': total or 0,
'items': items
}
def _build_report_display(self, report) -> tuple[str, str]:
"""构建报告的标题和副标题"""
category = report.category or ''
service_type = report.service_type or ''
# 服务类型映射
service_type_map = {
'fortune_telling': '测名',
'naming': '起名',
'renaming': '改名'
}
service_name = service_type_map.get(service_type, '')
if category == 'personal':
# 个人:李氏宝宝起名 / 个人改名
surname = report.surname or ''
if service_type == 'naming':
title = f"{surname}氏宝宝起名" if surname else "宝宝起名"
# 副标题:偏好 · 生日
preference = report.preference or ''
birthday = report.birthday.strftime('%Y-%m-%d') + '' if report.birthday else ''
subtitle = ' · '.join(filter(None, [preference, birthday]))
elif service_type == 'renaming':
title = "个人改名"
# 副标题原名xxx
original_name = report.original_name or ''
subtitle = f"原名:{original_name}" if original_name else ''
else:
# 测名
full_name = f"{report.surname or ''}{report.given_name or ''}"
title = f"{full_name}测名" if full_name else "姓名测试"
subtitle = ''
else:
# 公司
if service_type == 'naming':
title = f"{report.industry or ''}起名" if report.industry else "公司起名"
# 副标题:行业 · 地址
industry = report.industry or ''
address = report.address or ''
subtitle = ' · '.join(filter(None, [industry, address]))
elif service_type == 'renaming':
title = "公司改名"
original_name = report.original_name or ''
subtitle = f"原名:{original_name}" if original_name else ''
else:
title = "公司测名"
subtitle = ''
return title, subtitle
def _get_relative_time(self, dt) -> str:
"""计算相对时间"""
import datetime
if not dt:
return ''
now = datetime.datetime.now()
diff = now - dt
seconds = diff.total_seconds()
if seconds < 60:
return '刚刚'
elif seconds < 3600:
return f'{int(seconds // 60)}分钟前'
elif seconds < 86400:
return f'{int(seconds // 3600)}小时前'
elif seconds < 604800:
days = int(seconds // 86400)
return f'{days}天前'
elif seconds < 2592000:
weeks = int(seconds // 604800)
return f'{weeks}周前'
elif seconds < 31536000:
months = int(seconds // 2592000)
return f'{months}个月前'
else:
years = int(seconds // 31536000)
return f'{years}年前'

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import Date, DateTime, String, Text, Integer, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanNamingReportsModel(ModelMixin, UserMixin):
"""
命名服务案例表
"""
__tablename__: str = 'yifan_naming_reports'
__table_args__: dict[str, str] = {'comment': '命名服务案例'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0:否 1:是)')
category: Mapped[str | None] = mapped_column(String(20), nullable=True, comment='分类(personal:个人 company:公司)')
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True, comment='服务类型(fortune_telling:测名 naming:起名 renaming:改名)')
surname: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='姓氏')
given_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='名字')
gender: Mapped[str | None] = mapped_column(String(10), nullable=True, comment='性别(male:男 female:女 unknown:未知)')
birthday: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='生日')
address: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='地址')
genealogy_character: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='家谱字辈')
father_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='父亲姓名')
father_birth_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True, comment='父亲出生日期')
mother_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='母亲姓名')
mother_birth_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True, comment='母亲出生日期')
preference: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='偏好(字典值:elegant_classical:典雅古风 modern_simple:现代简约等)')
original_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='原名(改名时使用)')
rename_reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment='改名诉求')
industry: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='行业(公司命名时使用)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='用户id')
core_members: Mapped[str | None] = mapped_column(String(1000), nullable=True, comment='公司核心成员')
ai_source_data: Mapped[str | None] = mapped_column(Text, nullable=True, comment='AI原始数据')
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='错误信息')

View File

@@ -0,0 +1,336 @@
# -*- coding: utf-8 -*-
import datetime
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanNamingReportsCreateSchema(BaseModel):
"""
命名服务案例新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0:否 1:是)')
status: int = Field(default=1, description='状态(1:已创建 2:测算中 5:测算成功 3:测算超时 0:任务失败)')
category: str = Field(default=..., description='分类(personal:个人 company:公司)')
service_type: str = Field(default=..., description='服务类型(fortune_telling:测名 naming:起名 renaming:改名)')
surname: str = Field(default=..., description='姓氏')
given_name: str = Field(default=..., description='名字')
gender: str = Field(default=..., description='性别(male:男 female:女 unknown:未知)')
birthday: datetime.datetime = Field(default=..., description='生日')
address: str = Field(default=..., description='地址')
genealogy_character: str = Field(default=..., description='家谱字辈')
father_name: str = Field(default=..., description='父亲姓名')
father_birth_date: datetime.date | None = Field(default=None, description='父亲出生日期')
mother_name: str = Field(default=..., description='母亲姓名')
mother_birth_date: datetime.date | None = Field(default=None, description='母亲出生日期')
preference: str = Field(default=..., description='偏好(字典值:elegant_classical:典雅古风 modern_simple:现代简约等)')
original_name: str = Field(default=..., description='原名(改名时使用)')
rename_reason: str = Field(default=..., description='改名诉求')
industry: str = Field(default=..., description='行业(公司命名时使用)')
user_id: int = Field(default=..., description='用户id')
core_members: str = Field(default=..., description='公司核心成员')
ai_source_data: str | None = Field(default=None, description='AI原始数据')
error_message: str | None = Field(default=None, description='错误信息')
class YifanNamingReportsUpdateSchema(YifanNamingReportsCreateSchema):
"""
命名服务案例更新模型
"""
...
class YifanNamingReportsOutSchema(YifanNamingReportsCreateSchema, BaseSchema, UserBySchema):
"""
命名服务案例响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanNamingReportsQueryParam:
"""命名服务案例查询参数"""
def __init__(
self,
category: str | None = Query(None, description="分类(personal:个人 company:公司)"),
service_type: str | None = Query(None, description="服务类型(fortune_telling:测名 naming:起名 renaming:改名)"),
surname: str | None = Query(None, description="姓氏"),
given_name: str | None = Query(None, description="名字"),
gender: str | None = Query(None, description="性别(male:男 female:女 unknown:未知)"),
address: str | None = Query(None, description="地址"),
genealogy_character: str | None = Query(None, description="家谱字辈"),
father_name: str | None = Query(None, description="父亲姓名"),
mother_name: str | None = Query(None, description="母亲姓名"),
preference: str | None = Query(None, description="偏好(字典值:elegant_classical:典雅古风 modern_simple:现代简约等)"),
original_name: str | None = Query(None, description="原名(改名时使用)"),
rename_reason: str | None = Query(None, description="改名诉求"),
industry: str | None = Query(None, description="行业(公司命名时使用)"),
core_members: str | None = Query(None, description="公司核心成员"),
ai_source_data: str | None = Query(None, description="AI原始数据"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0:否 1:是)"),
status: int | None = Query(None, description="状态(1:已创建 2:测算中 5:测算成功 3:测算超时 0:任务失败)"),
birthday: datetime.datetime | None = Query(None, description="生日"),
father_birth_date: datetime.date | None = Query(None, description="父亲出生日期"),
mother_birth_date: datetime.date | None = Query(None, description="母亲出生日期"),
user_id: int | None = Query(None, description="用户id"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 模糊查询字段
self.category = ("like", category)
# 模糊查询字段
self.service_type = ("like", service_type)
# 模糊查询字段
self.surname = ("like", surname)
# 模糊查询字段
self.given_name = ("like", given_name)
# 模糊查询字段
self.gender = ("like", gender)
# 精确查询字段
self.birthday = birthday
# 模糊查询字段
self.address = ("like", address)
# 模糊查询字段
self.genealogy_character = ("like", genealogy_character)
# 模糊查询字段
self.father_name = ("like", father_name)
# 精确查询字段
self.father_birth_date = father_birth_date
# 模糊查询字段
self.mother_name = ("like", mother_name)
# 精确查询字段
self.mother_birth_date = mother_birth_date
# 模糊查询字段
self.preference = ("like", preference)
# 模糊查询字段
self.original_name = ("like", original_name)
# 模糊查询字段
self.rename_reason = ("like", rename_reason)
# 模糊查询字段
self.industry = ("like", industry)
# 精确查询字段
self.user_id = user_id
# 模糊查询字段
self.core_members = ("like", core_members)
# 模糊查询字段
self.ai_source_data = ("like", ai_source_data)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
class FortuneTellingRequestSchema(BaseModel):
"""
测名请求模型(简化版)
"""
category: str = Field(default="personal", description='分类(personal:个人 company:公司)')
surname: str = Field(..., description='姓氏')
given_name: str = Field(..., description='名字')
gender: str = Field(default="unknown", description='性别(male:男 female:女 unknown:未知)')
birthday: datetime.datetime = Field(..., description='生日')
user_id: int | None = Field(default=None, description='用户ID可选')
class FortuneTellingResponseSchema(BaseModel):
"""
测名响应模型
"""
report_id: int = Field(..., description='报告ID')
full_name: str = Field(..., description='完整姓名')
total_score: int = Field(..., description='总分')
star_rating: int = Field(..., description='星级(1-5)')
# 六维格局
hexagon_chart: dict = Field(..., description='六维格局数据')
hexagon_comment: str = Field(..., description='大师批注')
# 周易卦象
zhouyi_gua: dict = Field(..., description='周易卦象')
# 开运与运程
lucky_colors: list[str] = Field(..., description='幸运色')
lucky_numbers: list[int] = Field(..., description='幸运数字')
life_motto: str = Field(..., description='人生格言')
# 单字详解
character_analysis: list[dict] = Field(..., description='单字详解')
# 笔画数理分析
stroke_analysis: dict = Field(..., description='笔画数理分析')
# 字义与生肖解析
meaning_analysis: dict = Field(..., description='字义与生肖解析')
zodiac_compatibility: dict = Field(..., description='生肖适配度')
class PersonalScoringResponseSchema(BaseModel):
"""
个人测名AI版响应模型
"""
report_id: int = Field(..., description='报告ID')
ai_response: str = Field(..., description='AI原始响应文本')
class PersonalScoringTrialRequestSchema(BaseModel):
"""免费版个人测名请求模型"""
surname: str = Field(..., description='姓氏')
given_name: str = Field(..., description='名字')
gender: str = Field(..., description='性别(male/female)')
birthday: datetime.datetime = Field(..., description='生日')
class CoreMemberSchema(BaseModel):
"""核心成员信息"""
name: str = Field(..., description='姓名')
birthday: datetime.datetime = Field(..., description='出生日期')
class CompanyScoringRequestSchema(BaseModel):
"""企业测名请求模型"""
company_name: str = Field(..., description='公司名称')
industry: str = Field(..., description='行业')
address: str = Field(..., description='注册地/城市')
target_audience: str = Field(..., description='目标受众')
members: list[CoreMemberSchema] = Field(..., min_length=1, description='成员列表至少1个')
class CompanyNamingRequestSchema(BaseModel):
"""
公司起名请求模型
"""
# 基本信息
industry: str = Field(..., description='所属行业与主营业务')
address: str = Field(..., description='所在城市/注册地')
# 核心成员信息(第一个为创始人)
core_members: list[CoreMemberSchema] = Field(..., description='核心成员列表,第一个为创始人')
# 企业愿景(可选)
vision: str | None = Field(None, description='企业愿景/品牌调性')
preference: str | None = Field(None, description='您希望名字传递的感觉')
class CompanyNamingSolutionItemSchema(BaseModel):
"""
公司起名方案列表项(简化版)
"""
id: int = Field(..., description='方案ID')
name: str = Field(..., description='公司名称')
pinyin: str = Field(default='', description='拼音')
wuxing: str = Field(default='', description='五行标签(如:五行属金)')
shuxiang: str = Field(default='', description='属相(如:龙)')
tags: list[str] = Field(default=[], description='行业标签(如:利金融/科技)')
name_meaning: str = Field(default='', description='名字寓意')
poetry_source: str = Field(default='', description='诗词出处')
total_score: int | None = Field(default=None, description='总分')
star_rating: int | None = Field(default=None, description='星级(1-5)')
is_recommended: int = Field(default=0, description='是否推荐(0否 1是)')
class CompanyNamingResponseSchema(BaseModel):
"""
公司起名响应模型(异步,立即返回)
"""
report_id: int = Field(..., description='报告ID')
status: int = Field(..., description='状态(1:已创建 2:测算中 5:测算成功 3:测算超时 0:任务失败)')
class PersonalNamingRequestSchema(BaseModel):
"""
个人起名请求模型
"""
last_name: str = Field(..., description='姓氏')
gender: str = Field(..., description='性别(male:男 female:女)')
birth_date: datetime.datetime = Field(..., description='生辰(格式: 1990-04-05 09:00:00)')
birth_place: str = Field(..., description='出生地')
style_label: str = Field(..., description='偏好风格标签(如: 典雅古风)')
father_name: str | None = Field(None, description='父亲姓名')
father_birth_date: datetime.datetime | None = Field(None, description='父亲出生日期')
mother_name: str | None = Field(None, description='母亲姓名')
mother_birth_date: datetime.datetime | None = Field(None, description='母亲出生日期')
family_book: str | None = Field(None, description='家谱字辈')
class PersonalRenamingRequestSchema(PersonalNamingRequestSchema):
"""
个人改名请求模型
"""
original_name: str = Field(..., description='曾用名/原名')
reason: str = Field(..., description='改名诉求')
class CompanyRenamingRequestSchema(CompanyNamingRequestSchema):
"""
企业改名请求模型
"""
original_name: str = Field(..., description='原公司名称')
reason: str = Field(..., description='改名诉求')
class MyReportItemSchema(BaseModel):
"""
我的方案列表项
"""
id: int = Field(description='报告ID')
title: str = Field(description='标题')
subtitle: str = Field(description='副标题/描述')
category: str = Field(description='分类(personal:个人 company:公司)')
service_type: str = Field(description='服务类型(fortune_telling:测名 naming:起名 renaming:改名)')
has_solutions: bool = Field(description='是否已生成方案')
created_time: str = Field(description='创建时间')
relative_time: str = Field(description='相对时间(如: 刚刚、1天前)')
class MyReportsListSchema(BaseModel):
"""
我的方案列表响应
"""
total: int = Field(description='总数')
items: list[MyReportItemSchema] = Field(description='方案列表')
class CalendarInfoSchema(BaseModel):
"""
黄历信息响应模型
"""
# 农历信息
lunar_day: str = Field(..., description='农历日(如:十二)')
lunar_month: str = Field(..., description='农历月(如:腊月)')
lunar_year: str = Field(..., description='农历年(如:甲辰年)')
# 公历信息
solar_date: str = Field(..., description='公历日期2024.01.22')
solar_year: int = Field(..., description='公历年')
solar_month: int = Field(..., description='公历月')
solar_day: int = Field(..., description='公历日')
# 干支
ganzhi_year: str = Field(..., description='干支年(如:甲辰)')
ganzhi_month: str = Field(..., description='干支月')
ganzhi_day: str = Field(..., description='干支日')
# 生肖
zodiac: str = Field(..., description='生肖(如:龙)')
# 宜忌
yi: list[str] = Field(..., description='宜(如:出行、开市)')
ji: list[str] = Field(..., description='忌(如:动土、安葬)')
# 星期
weekday: str = Field(..., description='星期(如:星期一)')

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanNamingSolutionsService
from .schema import YifanNamingSolutionsCreateSchema, YifanNamingSolutionsUpdateSchema, YifanNamingSolutionsQueryParam
YifanNamingSolutionsRouter = APIRouter(prefix='/yifan_naming_solutions', tags=["改名方案模块"])
@YifanNamingSolutionsRouter.get("/recommended", summary="获取推荐方案列表", description="获取佳名赏析列表is_recommended=1的方案")
async def get_recommended_solutions_controller(
page_no: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""
获取推荐方案列表接口(无需登录)
返回数据格式:
- total: 总数
- items: 方案列表
- id: 方案ID
- name: 名称
- pinyin: 拼音
- name_meaning: 名字寓意
- poetry_source: 诗词出处
"""
auth = AuthSchema(db=db, check_data_scope=False)
result = await YifanNamingSolutionsService.get_recommended_solutions_service(
auth=auth,
page_no=page_no,
page_size=page_size
)
log.info(f"获取推荐方案列表成功")
return SuccessResponse(data=result, msg="获取推荐方案列表成功")
@YifanNamingSolutionsRouter.get("/pdf/{id}", summary="下载方案PDF报告", description="根据方案ID生成并下载PDF报告")
async def download_solution_pdf_controller(
id: int = Path(..., description="方案ID"),
auth: AuthSchema = Depends(AuthPermission([]))
) -> StreamingResponse:
"""
下载方案PDF报告接口
小程序调用此接口获取PDF文件流然后通过wx.downloadFile下载
"""
pdf_bytes = await YifanNamingSolutionsService.generate_solution_pdf_service(
auth=auth,
solution_id=id
)
log.info(f"下载方案PDF成功, 方案ID: {id}")
return StreamResponse(
data=bytes2file_response(pdf_bytes),
media_type='application/pdf',
headers={
'Content-Disposition': f'attachment; filename=solution_{id}.pdf',
'Content-Type': 'application/pdf'
}
)
@YifanNamingSolutionsRouter.get("/full_detail/{id}", summary="获取方案完整详情", description="获取方案完整详情,包含所有板块和详情数据")
async def get_solution_full_detail_controller(
id: int = Path(..., description="方案ID"),
auth: AuthSchema = Depends(AuthPermission([], check_data_scope=False))
) -> JSONResponse:
"""
获取方案完整详情接口
返回数据格式:
- id: 方案ID
- report_id: 关联报告ID
- name: 姓名/公司名
- pinyin: 拼音
- total_score: 总分
- star_rating: 星级
- wuxing: 五行
- shuxiang: 属相
- tags: 标签列表
- name_meaning: 名字寓意
- poetry_source: 诗词出处
- is_recommended: 是否推荐
- is_favorited: 是否已收藏
- sections: 板块列表
- id: 板块ID
- section_type: 板块类型
- title: 板块标题
- summary_data: 摘要数据
- details: 详情列表
"""
result = await YifanNamingSolutionsService.get_solution_full_detail_service(
auth=auth,
solution_id=id
)
log.info(f"获取方案完整详情成功, 方案ID: {id}")
return SuccessResponse(data=result, msg="获取方案详情成功")
@YifanNamingSolutionsRouter.get("/detail/{id}", summary="获取改名方案详情", description="获取改名方案详情")
async def get_yifan_naming_solutions_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:query"], check_data_scope=False))
) -> JSONResponse:
"""获取改名方案详情接口"""
result_dict = await YifanNamingSolutionsService.detail_yifan_naming_solutions_service(auth=auth, id=id)
log.info(f"获取改名方案详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取改名方案详情成功")
@YifanNamingSolutionsRouter.get("/list", summary="查询改名方案列表", description="查询改名方案列表")
async def get_yifan_naming_solutions_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanNamingSolutionsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:query"], check_data_scope=False))
) -> JSONResponse:
"""查询改名方案列表接口(数据库分页)"""
log.info(f"=== 改名方案查询开始 ===")
log.info(f"用户信息: user_id={auth.user.id if auth.user else None}, username={auth.user.username if auth.user else None}")
log.info(f"数据权限检查: check_data_scope={auth.check_data_scope}")
log.info(f"查询参数: page_no={page.page_no}, page_size={page.page_size}")
log.info(f"搜索条件: {search.__dict__ if search else None}")
result_dict = await YifanNamingSolutionsService.page_yifan_naming_solutions_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info(f"=== 查询结果 ===")
log.info(f"总数: {result_dict.get('total')}")
log.info(f"返回数据量: {len(result_dict.get('items', []))}")
log.info(f"查询改名方案列表成功")
return SuccessResponse(data=result_dict, msg="查询改名方案列表成功")
@YifanNamingSolutionsRouter.post("/create", summary="创建改名方案", description="创建改名方案")
async def create_yifan_naming_solutions_controller(
data: YifanNamingSolutionsCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:create"]))
) -> JSONResponse:
"""创建改名方案接口"""
result_dict = await YifanNamingSolutionsService.create_yifan_naming_solutions_service(auth=auth, data=data)
log.info("创建改名方案成功")
return SuccessResponse(data=result_dict, msg="创建改名方案成功")
@YifanNamingSolutionsRouter.put("/update/{id}", summary="修改改名方案", description="修改改名方案")
async def update_yifan_naming_solutions_controller(
data: YifanNamingSolutionsUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:update"]))
) -> JSONResponse:
"""修改改名方案接口"""
result_dict = await YifanNamingSolutionsService.update_yifan_naming_solutions_service(auth=auth, id=id, data=data)
log.info("修改改名方案成功")
return SuccessResponse(data=result_dict, msg="修改改名方案成功")
@YifanNamingSolutionsRouter.delete("/delete", summary="删除改名方案", description="删除改名方案")
async def delete_yifan_naming_solutions_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:delete"]))
) -> JSONResponse:
"""删除改名方案接口"""
await YifanNamingSolutionsService.delete_yifan_naming_solutions_service(auth=auth, ids=ids)
log.info(f"删除改名方案成功: {ids}")
return SuccessResponse(msg="删除改名方案成功")
@YifanNamingSolutionsRouter.patch("/available/setting", summary="批量修改改名方案状态", description="批量修改改名方案状态")
async def batch_set_available_yifan_naming_solutions_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:patch"]))
) -> JSONResponse:
"""批量修改改名方案状态接口"""
await YifanNamingSolutionsService.set_available_yifan_naming_solutions_service(auth=auth, data=data)
log.info(f"批量修改改名方案状态成功: {data.ids}")
return SuccessResponse(msg="批量修改改名方案状态成功")
@YifanNamingSolutionsRouter.patch("/toggle-recommend/{id}", summary="切换推荐状态", description="切换方案的推荐状态")
async def toggle_recommend_controller(
id: int = Path(..., description="方案ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:patch"]))
) -> JSONResponse:
"""切换推荐状态接口"""
result = await YifanNamingSolutionsService.toggle_recommend_service(auth=auth, id=id)
log.info(f"切换推荐状态成功, 方案ID: {id}")
return SuccessResponse(data=result, msg="操作成功")
@YifanNamingSolutionsRouter.post('/export', summary="导出改名方案", description="导出改名方案")
async def export_yifan_naming_solutions_list_controller(
search: YifanNamingSolutionsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:export"]))
) -> StreamingResponse:
"""导出改名方案接口"""
result_dict_list = await YifanNamingSolutionsService.list_yifan_naming_solutions_service(search=search, auth=auth)
export_result = await YifanNamingSolutionsService.batch_export_yifan_naming_solutions_service(obj_list=result_dict_list)
log.info('导出改名方案成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_naming_solutions.xlsx'
}
)
@YifanNamingSolutionsRouter.post('/import', summary="导入改名方案", description="导入改名方案")
async def import_yifan_naming_solutions_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_naming_solutions:import"]))
) -> JSONResponse:
"""导入改名方案接口"""
batch_import_result = await YifanNamingSolutionsService.batch_import_yifan_naming_solutions_service(file=file, auth=auth, update_support=True)
log.info("导入改名方案成功")
return SuccessResponse(data=batch_import_result, msg="导入改名方案成功")
@YifanNamingSolutionsRouter.post('/download/template', summary="获取改名方案导入模板", description="获取改名方案导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_naming_solutions:download"]))])
async def export_yifan_naming_solutions_template_controller() -> StreamingResponse:
"""获取改名方案导入模板接口"""
import_template_result = await YifanNamingSolutionsService.import_template_download_yifan_naming_solutions_service()
log.info('获取改名方案导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_naming_solutions_template.xlsx'}
)

View File

@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
from sqlalchemy import select, func
from app.core.base_crud import CRUDBase
from .model import YifanNamingSolutionsModel
import json
import ast
class YifanNamingSolutionsCRUD(CRUDBase[YifanNamingSolutionsModel, None, None]):
"""改名方案 CRUD"""
def __init__(self, auth):
super().__init__(model=YifanNamingSolutionsModel, auth=auth)
async def get_by_id_yifan_naming_solutions_crud(self, id: int) -> YifanNamingSolutionsModel | None:
"""根据ID获取"""
return await self.get(id=id)
async def list_yifan_naming_solutions_crud(self, search: dict | None = None, order_by: list[dict] | None = None) -> list[YifanNamingSolutionsModel]:
"""列表查询"""
return await self.list(search=search, order_by=order_by)
async def page_yifan_naming_solutions_crud(self, offset: int, limit: int, search: dict | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询"""
from .schema import YifanNamingSolutionsOutSchema
return await self.page(offset=offset, limit=limit, search=search, order_by=order_by, out_schema=YifanNamingSolutionsOutSchema)
async def create_yifan_naming_solutions_crud(self, data) -> YifanNamingSolutionsModel:
"""创建"""
return await self.create(data=data)
async def update_yifan_naming_solutions_crud(self, id: int, data) -> YifanNamingSolutionsModel:
"""更新"""
return await self.update(id=id, data=data)
async def delete_yifan_naming_solutions_crud(self, ids: list[int]) -> None:
"""删除"""
await self.delete(ids=ids)
async def set_available_yifan_naming_solutions_crud(self, ids: list[int], status: int) -> None:
"""批量设置状态"""
await self.set(ids=ids, status=status)
async def list_by_report_id(self, report_id: int) -> list[YifanNamingSolutionsModel]:
"""
根据报告ID获取方案列表
参数:
- report_id (int): 报告ID
返回:
- list[YifanNamingSolutionsModel]: 方案列表
"""
stmt = (
select(YifanNamingSolutionsModel)
.where(
YifanNamingSolutionsModel.report_id == report_id,
YifanNamingSolutionsModel.is_deleted == 0
)
.order_by(YifanNamingSolutionsModel.sort_order)
)
result = await self.auth.db.execute(stmt)
return result.scalars().all()
async def get_solution_full_detail_crud(self, solution_id: int, user_id: int | None = None) -> dict | None:
"""
获取方案完整详情(包含板块和详情)
参数:
- solution_id (int): 方案ID
- user_id (int | None): 用户ID用于判断是否已收藏
返回:
- dict | None: 方案完整详情
"""
from sqlalchemy import select
from app.api.v1.module_yifan.yifan_solution_sections.model import YifanSolutionSectionsModel
from app.api.v1.module_yifan.yifan_section_details.model import YifanSectionDetailsModel
from app.api.v1.module_yifan.yifan_naming_favorites.model import YifanNamingFavoritesModel
# 1. 获取方案基本信息
solution = await self.get(id=solution_id)
if not solution or solution.is_deleted == 1:
return None
# 2. 查询是否已收藏
is_favorited = False
if user_id:
fav_stmt = select(YifanNamingFavoritesModel.id).where(
YifanNamingFavoritesModel.user_id == user_id,
YifanNamingFavoritesModel.solution_id == solution_id,
YifanNamingFavoritesModel.is_deleted == 0
)
fav_result = await self.auth.db.execute(fav_stmt)
is_favorited = fav_result.scalar() is not None
# 3. 获取所有板块
sections_stmt = (
select(YifanSolutionSectionsModel)
.where(
YifanSolutionSectionsModel.solution_id == solution_id,
YifanSolutionSectionsModel.is_deleted == 0
)
.order_by(YifanSolutionSectionsModel.sort_order)
)
sections_result = await self.auth.db.execute(sections_stmt)
sections = sections_result.scalars().all()
# 4. 获取所有板块的详情
section_ids = [s.id for s in sections]
details_map = {}
if section_ids:
details_stmt = (
select(YifanSectionDetailsModel)
.where(
YifanSectionDetailsModel.section_id.in_(section_ids),
YifanSectionDetailsModel.is_deleted == 0
)
.order_by(YifanSectionDetailsModel.sort_order)
)
details_result = await self.auth.db.execute(details_stmt)
details = details_result.scalars().all()
# 按 section_id 分组
for detail in details:
if detail.section_id not in details_map:
details_map[detail.section_id] = []
details_map[detail.section_id].append({
'id': detail.id,
'detail_type': detail.detail_type,
'title': detail.title,
'content': detail.content,
'sort_order': detail.sort_order
})
# 5. 组装板块数据
sections_data = []
for section in sections:
sections_data.append({
'id': section.id,
'section_type': section.section_type,
'title': section.title,
'summary_data': section.summary_data,
'sort_order': section.sort_order,
'details': details_map.get(section.id, [])
})
real_list = ast.literal_eval(solution.analysis_result)[0]
real_list['id'] = solution.id
real_list['report_id'] = solution.report_id
return json.dumps(real_list, ensure_ascii=False)
# 6. 组装返回数据
# return {
# 'id': solution.id,
# 'report_id': solution.report_id,
# 'name': solution.name,
# 'pinyin': solution.pinyin,
# 'total_score': solution.total_score,
# 'star_rating': solution.star_rating,
# 'wuxing': solution.wuxing,
# 'shuxiang': solution.shuxiang,
# 'tags': solution.tags or [],
# 'name_meaning': solution.name_meaning,
# 'poetry_source': solution.poetry_source,
# 'is_recommended': solution.is_recommended,
# 'is_favorited': is_favorited,
# 'sections': sections_data
# }
async def get_recommended_solutions_crud(self, page_no: int = 1, page_size: int = 10) -> dict:
"""
获取推荐方案列表(佳名赏析),按名字去重
参数:
- page_no: 页码
- page_size: 每页数量
返回:
- dict: 包含total和items的分页数据
"""
from sqlalchemy import distinct
# 查询去重后的总数
count_stmt = (
select(func.count(distinct(YifanNamingSolutionsModel.name)))
.where(
YifanNamingSolutionsModel.is_recommended == 1,
YifanNamingSolutionsModel.is_deleted == 0
)
)
count_result = await self.auth.db.execute(count_stmt)
total = count_result.scalar() or 0
# 使用子查询获取每个名字的最新记录ID
subquery = (
select(
YifanNamingSolutionsModel.name,
func.max(YifanNamingSolutionsModel.id).label('max_id')
)
.where(
YifanNamingSolutionsModel.is_recommended == 1,
YifanNamingSolutionsModel.is_deleted == 0
)
.group_by(YifanNamingSolutionsModel.name)
.subquery()
)
# 查询列表(按名字去重,取最新的记录)
offset = (page_no - 1) * page_size
stmt = (
select(YifanNamingSolutionsModel)
.join(subquery, YifanNamingSolutionsModel.id == subquery.c.max_id)
.order_by(YifanNamingSolutionsModel.id.desc())
.offset(offset)
.limit(page_size)
)
result = await self.auth.db.execute(stmt)
solutions = result.scalars().all()
# 组装返回数据
items = []
for sol in solutions:
items.append({
'id': sol.id,
'name': sol.name or '',
'pinyin': sol.pinyin or '',
'name_meaning': sol.name_meaning or '',
'poetry_source': sol.poetry_source or '',
'total_score': sol.total_score,
'star_rating': sol.star_rating,
'wuxing': sol.wuxing or '',
'shuxiang': sol.shuxiang or '',
'tags': sol.tags if isinstance(sol.tags, list) else []
})
return {
'total': total,
'page_no': page_no,
'page_size': page_size,
'items': items
}

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer, SmallInteger, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanNamingSolutionsModel(ModelMixin, UserMixin):
"""
改名方案表
"""
__tablename__: str = 'yifan_naming_solutions'
__table_args__: dict[str, str] = {'comment': '改名方案表'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0:否 1:是)')
report_id: Mapped[int] = mapped_column(Integer, nullable=False, comment='关联报告ID')
name: Mapped[str] = mapped_column(String(100), nullable=False, comment='姓名/公司名')
pinyin: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='拼音')
total_score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='总分')
star_rating: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='星级(1-5)')
wuxing: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='五行')
shuxiang: Mapped[str | None] = mapped_column(String(20), nullable=True, comment='属相')
tags: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment='标签')
name_meaning: Mapped[str | None] = mapped_column(Text, nullable=True, comment='名字寓意')
poetry_source: Mapped[str | None] = mapped_column(Text, nullable=True, comment='诗词出处')
is_recommended: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, default=0, comment='是否推荐(0:否 1:是)')
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0, comment='排序')
analysis_result: Mapped[str | None] = mapped_column(Text, nullable=True, comment='AI分析结果')

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanNamingSolutionsCreateSchema(BaseModel):
"""
改名方案新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
report_id: int = Field(default=..., description='关联报告ID')
name: str = Field(default=..., description='姓名')
pinyin: str | None = Field(default=None, description='拼音')
total_score: int | None = Field(default=None, description='总分')
star_rating: int | None = Field(default=None, description='星级(1-5)')
wuxing: str | None = Field(default=None, description='五行(如:火土)')
shuxiang: str | None = Field(default=None, description='属相(如:龙)')
tags: list | None = Field(default=None, description='标签')
name_meaning: str | None = Field(default=None, description='名字寓意')
poetry_source: str | None = Field(default=None, description='诗词出处')
is_recommended: int = Field(default=0, description='是否推荐(0否 1是)')
sort_order: int = Field(default=0, description='排序')
analysis_result: str | None = Field(default=None, description='AI分析结果')
class YifanNamingSolutionsUpdateSchema(YifanNamingSolutionsCreateSchema):
"""
改名方案更新模型
"""
...
class YifanNamingSolutionsOutSchema(YifanNamingSolutionsCreateSchema, BaseSchema, UserBySchema):
"""
改名方案响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanNamingSolutionsQueryParam:
"""改名方案查询参数"""
def __init__(
self,
name: str | None = Query(None, description="姓名"),
pinyin: str | None = Query(None, description="拼音"),
wuxing: str | None = Query(None, description="五行(如:火土)"),
shuxiang: str | None = Query(None, description="属相(如:龙)"),
name_meaning: str | None = Query(None, description="名字寓意"),
poetry_source: str | None = Query(None, description="诗词出处"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
report_id: int | None = Query(None, description="关联报告ID"),
total_score: int | None = Query(None, description="总分"),
star_rating: int | None = Query(None, description="星级(1-5)"),
tags: str | None = Query(None, description='标签(JSON字符串,如:["改运补禄","平安顺遂"])'),
is_recommended: int | None = Query(None, description="是否推荐(0否 1是)"),
sort_order: int | None = Query(None, description="排序"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 精确查询字段
self.report_id = report_id
# 模糊查询字段
self.name = ("like", name)
# 模糊查询字段
self.pinyin = ("like", pinyin)
# 精确查询字段
self.total_score = total_score
# 精确查询字段
self.star_rating = star_rating
# 模糊查询字段
self.wuxing = ("like", wuxing)
# 模糊查询字段
self.shuxiang = ("like", shuxiang)
# 精确查询字段
self.tags = tags
# 模糊查询字段
self.name_meaning = ("like", name_meaning)
# 模糊查询字段
self.poetry_source = ("like", poetry_source)
# 精确查询字段
self.is_recommended = is_recommended
# 精确查询字段
self.sort_order = sort_order
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
class SectionDetailSchema(BaseModel):
"""板块详情"""
id: int = Field(description='详情ID')
detail_type: str = Field(description='详情类型')
title: str | None = Field(default=None, description='详情标题')
content: dict | None = Field(default=None, description='详细内容')
sort_order: int | None = Field(default=0, description='排序')
class SolutionSectionSchema(BaseModel):
"""方案板块"""
id: int = Field(description='板块ID')
section_type: str = Field(description='板块类型')
title: str | None = Field(default=None, description='板块标题')
summary_data: dict | None = Field(default=None, description='摘要数据')
sort_order: int | None = Field(default=0, description='排序')
details: list[SectionDetailSchema] = Field(default=[], description='板块详情列表')
class SolutionFullDetailSchema(BaseModel):
"""方案完整详情"""
id: int = Field(description='方案ID')
report_id: int = Field(description='关联报告ID')
name: str = Field(description='姓名/公司名')
pinyin: str | None = Field(default=None, description='拼音')
total_score: int | None = Field(default=None, description='总分')
star_rating: int | None = Field(default=None, description='星级(1-5)')
wuxing: str | None = Field(default=None, description='五行')
shuxiang: str | None = Field(default=None, description='属相')
tags: list | None = Field(default=None, description='标签')
name_meaning: str | None = Field(default=None, description='名字寓意')
poetry_source: str | None = Field(default=None, description='诗词出处')
is_recommended: int | None = Field(default=0, description='是否推荐')
is_favorited: bool = Field(default=False, description='是否已收藏')
sections: list[SolutionSectionSchema] = Field(default=[], description='板块列表')

View File

@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
import io
import os
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.utils.pdf_util import PDFUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanNamingSolutionsCreateSchema, YifanNamingSolutionsUpdateSchema, YifanNamingSolutionsOutSchema, YifanNamingSolutionsQueryParam
from .crud import YifanNamingSolutionsCRUD
class YifanNamingSolutionsService:
"""
改名方案服务层
"""
@classmethod
async def detail_yifan_naming_solutions_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanNamingSolutionsCRUD(auth).get_by_id_yifan_naming_solutions_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanNamingSolutionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_naming_solutions_service(cls, auth: AuthSchema, search: YifanNamingSolutionsQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanNamingSolutionsCRUD(auth).list_yifan_naming_solutions_crud(search=search_dict, order_by=order_by)
return [YifanNamingSolutionsOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_naming_solutions_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanNamingSolutionsQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanNamingSolutionsCRUD(auth).page_yifan_naming_solutions_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def get_recommended_solutions_service(cls, auth: AuthSchema, page_no: int = 1, page_size: int = 10) -> dict:
"""
获取推荐方案列表(佳名赏析)
参数:
- auth: 认证信息
- page_no: 页码
- page_size: 每页数量
返回:
- dict: 包含total和items的分页数据
"""
return await YifanNamingSolutionsCRUD(auth).get_recommended_solutions_crud(
page_no=page_no,
page_size=page_size
)
@classmethod
async def toggle_recommend_service(cls, auth: AuthSchema, id: int) -> dict:
"""
切换推荐状态
参数:
- auth: 认证信息
- id: 方案ID
返回:
- dict: 包含新的推荐状态
"""
obj = await YifanNamingSolutionsCRUD(auth).get_by_id_yifan_naming_solutions_crud(id=id)
if not obj:
raise CustomException(msg="方案不存在")
# 切换状态
new_status = 0 if obj.is_recommended == 1 else 1
await YifanNamingSolutionsCRUD(auth).set(ids=[id], is_recommended=new_status)
return {
'id': id,
'is_recommended': new_status
}
@classmethod
async def create_yifan_naming_solutions_service(cls, auth: AuthSchema, data: YifanNamingSolutionsCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanNamingSolutionsCRUD(auth).create_yifan_naming_solutions_crud(data=data)
return YifanNamingSolutionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_naming_solutions_service(cls, auth: AuthSchema, id: int, data: YifanNamingSolutionsUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanNamingSolutionsCRUD(auth).get_by_id_yifan_naming_solutions_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanNamingSolutionsCRUD(auth).update_yifan_naming_solutions_crud(id=id, data=data)
return YifanNamingSolutionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_naming_solutions_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanNamingSolutionsCRUD(auth).get_by_id_yifan_naming_solutions_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanNamingSolutionsCRUD(auth).delete_yifan_naming_solutions_crud(ids=ids)
@classmethod
async def set_available_yifan_naming_solutions_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanNamingSolutionsCRUD(auth).set_available_yifan_naming_solutions_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_naming_solutions_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'report_id': '关联报告ID',
'name': '姓名',
'pinyin': '拼音',
'total_score': '总分',
'star_rating': '星级(1-5)',
'wuxing': '五行(如:火土)',
'shuxiang': '属相(如:龙)',
'tags': '标签(如:["改运补禄","平安顺遂"])',
'name_meaning': '名字寓意',
'poetry_source': '诗词出处',
'is_recommended': '是否推荐(0否 1是)',
'sort_order': '排序',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_naming_solutions_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'关联报告ID': 'report_id',
'姓名': 'name',
'拼音': 'pinyin',
'总分': 'total_score',
'星级(1-5)': 'star_rating',
'五行(如:火土)': 'wuxing',
'属相(如:龙)': 'shuxiang',
'标签(如:["改运补禄","平安顺遂"])': 'tags',
'名字寓意': 'name_meaning',
'诗词出处': 'poetry_source',
'是否推荐(0否 1是)': 'is_recommended',
'排序': 'sort_order',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"report_id": row['report_id'],
"name": row['name'],
"pinyin": row['pinyin'],
"total_score": row['total_score'],
"star_rating": row['star_rating'],
"wuxing": row['wuxing'],
"shuxiang": row['shuxiang'],
"tags": row['tags'],
"name_meaning": row['name_meaning'],
"poetry_source": row['poetry_source'],
"is_recommended": row['is_recommended'],
"sort_order": row['sort_order'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanNamingSolutionsCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanNamingSolutionsCRUD(auth).create_yifan_naming_solutions_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_naming_solutions_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'关联报告ID',
'姓名',
'拼音',
'总分',
'星级(1-5)',
'五行(如:火土)',
'属相(如:龙)',
'标签(如:["改运补禄","平安顺遂"])',
'名字寓意',
'诗词出处',
'是否推荐(0否 1是)',
'排序',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)
@classmethod
async def get_solution_full_detail_service(cls, auth: AuthSchema, solution_id: int) -> dict:
"""
获取方案完整详情(包含板块和详情)
参数:
- auth: 认证信息
- solution_id: 方案ID
返回:
- dict: 方案完整详情
"""
user_id = auth.user.id if auth.user else None
result = await YifanNamingSolutionsCRUD(auth).get_solution_full_detail_crud(
solution_id=solution_id,
user_id=user_id
)
if not result:
raise CustomException(msg="方案不存在")
return result
@classmethod
async def generate_solution_pdf_service(cls, auth: AuthSchema, solution_id: int) -> bytes:
"""
生成方案PDF报告
参数:
- auth: 认证信息
- solution_id: 方案ID
返回:
- bytes: PDF文件字节
"""
# 获取方案详情
user_id = auth.user.id if auth.user else None
solution = await YifanNamingSolutionsCRUD(auth).get_solution_full_detail_crud(
solution_id=solution_id,
user_id=user_id
)
if not solution:
raise CustomException(msg="方案不存在")
# PDF模板路径
template_path = os.path.join(
os.path.dirname(__file__),
'..', '..', '..', '..', '..',
'static', 'pdf', 'example.pdf'
)
if not os.path.exists(template_path):
raise CustomException(msg="PDF模板文件不存在")
# 准备填充数据
data = {
'name': solution.get('name', ''),
'pinyin': solution.get('pinyin', ''),
'total_score': str(solution.get('total_score', '')),
'star_rating': '' * solution.get('star_rating', 0),
'wuxing': solution.get('wuxing', ''),
'shuxiang': solution.get('shuxiang', ''),
'tags': ''.join(solution.get('tags', [])) if solution.get('tags') else '',
'name_meaning': solution.get('name_meaning', ''),
'poetry_source': solution.get('poetry_source', ''),
}
# 字段位置配置 (x, y, 字体大小, 颜色)
# 注意: 这些位置需要根据实际PDF模板调整
field_positions = {
'name': (50, 250, 24, '#333333'),
'pinyin': (50, 240, 12, '#666666'),
'total_score': (150, 220, 18, '#ff6600'),
'star_rating': (50, 200, 14, '#ffcc00'),
'wuxing': (50, 180, 12, '#333333'),
'shuxiang': (120, 180, 12, '#333333'),
'tags': (50, 160, 10, '#999999'),
'name_meaning': (50, 130, 11, '#333333'),
'poetry_source': (50, 100, 10, '#666666'),
}
# 生成PDF
pdf_bytes = PDFUtil.fill_pdf_template(
template_path=template_path,
data=data,
field_positions=field_positions
)
log.info(f"生成方案PDF成功, 方案ID: {solution_id}")
return pdf_bytes

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, Body, Path
from fastapi.responses import JSONResponse
from app.common.response import SuccessResponse
from app.core.dependencies import AuthPermission
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanPartnerApplyService
from .schema import (
YifanPartnerApplyCreateSchema,
YifanPartnerApplyUpdateSchema,
YifanPartnerApplyQueryParam,
YifanPartnerApplyAuditSchema,
)
YifanPartnerApplyRouter = APIRouter(prefix='/yifan_partner_apply', tags=["合伙人申请模块"])
@YifanPartnerApplyRouter.post("/apply", summary="提交合伙人申请", description="小程序-提交合伙人申请")
async def create_yifan_partner_apply_for_mini(
data: YifanPartnerApplyCreateSchema,
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.create_yifan_partner_apply_service(auth=auth, data=data)
log.info("提交合伙人申请成功")
return SuccessResponse(data=result_dict, msg="提交成功")
@YifanPartnerApplyRouter.get("/mini/my", summary="我的合伙人申请", description="小程序-获取当前用户的申请记录")
async def get_my_partner_apply_list_for_mini(
auth: AuthSchema = Depends(AuthPermission([]))
) -> JSONResponse:
result_list = await YifanPartnerApplyService.get_my_apply_list_service(auth=auth)
return SuccessResponse(data=result_list, msg="获取成功")
@YifanPartnerApplyRouter.get("/detail/{id}", summary="获取合伙人申请详情", description="获取合伙人申请详情")
async def get_yifan_partner_apply_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:query"]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.detail_yifan_partner_apply_service(auth=auth, id=id)
log.info(f"获取合伙人申请详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取合伙人申请详情成功")
@YifanPartnerApplyRouter.get("/list", summary="查询合伙人申请列表", description="查询合伙人申请列表")
async def get_yifan_partner_apply_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanPartnerApplyQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:query"]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.page_yifan_partner_apply_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询合伙人申请列表成功")
return SuccessResponse(data=result_dict, msg="查询成功")
@YifanPartnerApplyRouter.post("/create", summary="创建合伙人申请", description="后台-创建合伙人申请")
async def create_yifan_partner_apply_controller(
data: YifanPartnerApplyCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:create"]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.create_yifan_partner_apply_service(auth=auth, data=data)
log.info("创建合伙人申请成功")
return SuccessResponse(data=result_dict, msg="创建成功")
@YifanPartnerApplyRouter.put("/update/{id}", summary="修改合伙人申请", description="修改合伙人申请")
async def update_yifan_partner_apply_controller(
data: YifanPartnerApplyUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:update"]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.update_yifan_partner_apply_service(auth=auth, id=id, data=data)
log.info("修改合伙人申请成功")
return SuccessResponse(data=result_dict, msg="修改成功")
@YifanPartnerApplyRouter.delete("/delete", summary="删除合伙人申请", description="删除合伙人申请")
async def delete_yifan_partner_apply_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:delete"]))
) -> JSONResponse:
await YifanPartnerApplyService.delete_yifan_partner_apply_service(auth=auth, ids=ids)
log.info(f"删除合伙人申请成功: {ids}")
return SuccessResponse(msg="删除成功")
@YifanPartnerApplyRouter.patch("/available/setting", summary="批量修改合伙人申请状态", description="批量修改合伙人申请状态")
async def batch_set_available_yifan_partner_apply_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:patch"]))
) -> JSONResponse:
await YifanPartnerApplyService.set_available_yifan_partner_apply_service(auth=auth, data=data)
log.info(f"批量修改合伙人申请状态成功: {data.ids}")
return SuccessResponse(msg="操作成功")
@YifanPartnerApplyRouter.patch("/audit/{id}", summary="审核合伙人申请", description="审核合伙人申请")
async def audit_yifan_partner_apply_controller(
data: YifanPartnerApplyAuditSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_partner_apply:audit"]))
) -> JSONResponse:
result_dict = await YifanPartnerApplyService.audit_yifan_partner_apply_service(
auth=auth,
id=id,
audit_status=data.audit_status,
partner_role=data.partner_role,
)
return SuccessResponse(data=result_dict, msg="审核成功")

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import select, or_
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanPartnerApplyModel
from .schema import (
YifanPartnerApplyCreateSchema,
YifanPartnerApplyUpdateSchema,
YifanPartnerApplyOutSchema,
)
class YifanPartnerApplyCRUD(CRUDBase[YifanPartnerApplyModel, YifanPartnerApplyCreateSchema, YifanPartnerApplyUpdateSchema]):
"""合伙人申请数据层"""
def __init__(self, auth: AuthSchema) -> None:
super().__init__(model=YifanPartnerApplyModel, auth=auth)
async def get_by_id_yifan_partner_apply_crud(self, id: int, preload: list | None = None) -> YifanPartnerApplyModel | None:
return await self.get(id=id, preload=preload)
async def list_yifan_partner_apply_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanPartnerApplyModel]:
return await self.list(search=search, order_by=order_by, preload=preload)
async def page_yifan_partner_apply_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanPartnerApplyOutSchema,
preload=preload
)
async def create_yifan_partner_apply_crud(self, data: YifanPartnerApplyCreateSchema) -> YifanPartnerApplyModel | None:
return await self.create(data=data)
async def update_yifan_partner_apply_crud(self, id: int, data: YifanPartnerApplyUpdateSchema) -> YifanPartnerApplyModel | None:
return await self.update(id=id, data=data)
async def delete_yifan_partner_apply_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
async def set_available_yifan_partner_apply_crud(self, ids: list[int], status: str) -> None:
return await self.set(ids=ids, status=status)
async def set_audit_status_yifan_partner_apply_crud(self, id: int, audit_status: int) -> YifanPartnerApplyModel | None:
return await self.update(id=id, data={"audit_status": audit_status})
async def exists_duplicate_apply_crud(self, user_id: int | None, phone: str | None) -> bool:
conditions = []
if user_id is not None:
conditions.append(YifanPartnerApplyModel.user_id == user_id)
if phone:
conditions.append(YifanPartnerApplyModel.phone == phone)
if not conditions:
return False
stmt = (
select(YifanPartnerApplyModel.id)
.where(
YifanPartnerApplyModel.is_deleted == 0,
or_(*conditions),
)
.limit(1)
)
result = await self.auth.db.execute(stmt)
return result.scalar() is not None

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from sqlalchemy import Integer, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanPartnerApplyModel(ModelMixin, UserMixin):
"""合伙人申请表"""
__tablename__: str = 'yifan_partner_apply'
__table_args__: dict[str, str] = {'comment': '合伙人申请表'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, default=0, comment='是否删除(0:否 1:是)')
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True, comment='用户ID(关联微信用户)')
real_name: Mapped[str] = mapped_column(String(50), nullable=False, comment='真实姓名')
phone: Mapped[str] = mapped_column(String(20), nullable=False, index=True, comment='手机号码')
wechat_id: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='微信号(选填)')
audit_status: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, default=0, index=True, comment='审核状态(0:待审核 1:审核通过 2:审核拒绝)')

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanPartnerApplyCreateSchema(BaseModel):
"""合伙人申请新增模型"""
is_deleted: int = Field(default=0, description='是否删除(0:否 1:是)')
status: int = Field(default=0, description='状态(0:启用 1:禁用)')
user_id: int | None = Field(default=None, description='用户ID(关联微信用户)')
real_name: str = Field(default=..., description='真实姓名')
phone: str = Field(default=..., description='手机号码')
wechat_id: str | None = Field(default=None, description='微信号(选填)')
audit_status: int = Field(default=0, description='审核状态(0:待审核 1:审核通过 2:审核拒绝)')
class YifanPartnerApplyUpdateSchema(YifanPartnerApplyCreateSchema):
"""合伙人申请更新模型"""
...
class YifanPartnerApplyOutSchema(YifanPartnerApplyCreateSchema, BaseSchema, UserBySchema):
"""合伙人申请响应模型"""
model_config = ConfigDict(from_attributes=True)
class YifanPartnerApplyQueryParam:
"""合伙人申请查询参数"""
def __init__(
self,
user_id: int | None = Query(None, description="用户ID"),
real_name: str | None = Query(None, description="真实姓名"),
phone: str | None = Query(None, description="手机号码"),
wechat_id: str | None = Query(None, description="微信号"),
audit_status: int | None = Query(None, description="审核状态(0:待审核 1:审核通过 2:审核拒绝)"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0:否 1:是)"),
status: int | None = Query(None, description="状态(0:禁用 1:启用)"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
self.created_id = created_id
self.updated_id = updated_id
self.is_deleted = is_deleted
self.status = status
self.user_id = user_id
self.real_name = ("like", real_name)
self.phone = ("like", phone)
self.wechat_id = ("like", wechat_id)
self.audit_status = audit_status
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
class YifanPartnerApplyAuditSchema(BaseModel):
"""合伙人申请审核请求模型"""
audit_status: int = Field(default=..., description='审核状态(1:审核通过 2:审核拒绝)')
partner_role: str | None = Field(default=None, description='身份(推广大使/合伙人 或 ambassador/partner)')

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.user.crud import UserCRUD
from .crud import YifanPartnerApplyCRUD
from .schema import (
YifanPartnerApplyCreateSchema,
YifanPartnerApplyUpdateSchema,
YifanPartnerApplyOutSchema,
YifanPartnerApplyQueryParam,
)
class YifanPartnerApplyService:
"""合伙人申请服务层"""
@classmethod
async def detail_yifan_partner_apply_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await YifanPartnerApplyCRUD(auth).get_by_id_yifan_partner_apply_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanPartnerApplyOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_partner_apply_service(cls, auth: AuthSchema, search: YifanPartnerApplyQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
search_dict = search.__dict__ if search else None
obj_list = await YifanPartnerApplyCRUD(auth).list_yifan_partner_apply_crud(search=search_dict, order_by=order_by)
return [YifanPartnerApplyOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_partner_apply_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanPartnerApplyQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
return await YifanPartnerApplyCRUD(auth).page_yifan_partner_apply_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict,
)
@classmethod
async def create_yifan_partner_apply_service(cls, auth: AuthSchema, data: YifanPartnerApplyCreateSchema) -> dict:
create_data = data.model_dump()
if auth.user and not create_data.get("user_id"):
create_data["user_id"] = auth.user.id
create_data["audit_status"] = 0
# 已申请过(包含已审核/待审核)则禁止重复申请
auth_no_scope = AuthSchema(db=auth.db, user=auth.user, check_data_scope=False)
exists = await YifanPartnerApplyCRUD(auth_no_scope).exists_duplicate_apply_crud(
user_id=create_data.get("user_id"),
phone=create_data.get("phone"),
)
if exists:
raise CustomException(msg="已提交过合伙人申请,不能重复申请")
obj = await YifanPartnerApplyCRUD(auth).create_yifan_partner_apply_crud(
data=YifanPartnerApplyCreateSchema.model_validate(create_data)
)
return YifanPartnerApplyOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_partner_apply_service(cls, auth: AuthSchema, id: int, data: YifanPartnerApplyUpdateSchema) -> dict:
obj = await YifanPartnerApplyCRUD(auth).get_by_id_yifan_partner_apply_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
obj = await YifanPartnerApplyCRUD(auth).update_yifan_partner_apply_crud(id=id, data=data)
return YifanPartnerApplyOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_partner_apply_service(cls, auth: AuthSchema, ids: list[int]) -> None:
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanPartnerApplyCRUD(auth).get_by_id_yifan_partner_apply_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanPartnerApplyCRUD(auth).delete_yifan_partner_apply_crud(ids=ids)
@classmethod
async def set_available_yifan_partner_apply_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
await YifanPartnerApplyCRUD(auth).set_available_yifan_partner_apply_crud(ids=data.ids, status=data.status)
@classmethod
async def audit_yifan_partner_apply_service(
cls,
auth: AuthSchema,
id: int,
audit_status: int,
partner_role: str | None = None,
) -> dict:
if audit_status not in (1, 2):
raise CustomException(msg='审核状态不合法')
obj = await YifanPartnerApplyCRUD(auth).get_by_id_yifan_partner_apply_crud(id=id)
if not obj:
raise CustomException(msg='审核失败,该数据不存在')
if obj.audit_status != 0:
raise CustomException(msg='审核失败,该申请已审核')
if audit_status == 1:
if not partner_role:
raise CustomException(msg='审核失败,缺少身份信息')
# 兼容:允许前端传中文或英文,最终统一存英文
role_map = {
"推广大使": "ambassador",
"合伙人": "partner",
"ambassador": "ambassador",
"partner": "partner",
}
mapped_role = role_map.get(partner_role)
if not mapped_role:
raise CustomException(msg='审核失败,身份信息不合法')
if not obj.user_id:
raise CustomException(msg='审核失败,缺少用户信息')
auth_no_scope = AuthSchema(db=auth.db, user=auth.user, check_data_scope=False)
await UserCRUD(auth_no_scope).update(id=obj.user_id, data={"partner_role": mapped_role})
obj = await YifanPartnerApplyCRUD(auth).set_audit_status_yifan_partner_apply_crud(id=id, audit_status=audit_status)
log.info(f"合伙人申请审核完成 id={id} audit_status={audit_status}")
return YifanPartnerApplyOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_my_apply_list_service(cls, auth: AuthSchema) -> list[dict]:
if not auth.user:
raise CustomException(msg='未登录')
obj_list = await YifanPartnerApplyCRUD(auth).list_yifan_partner_apply_crud(
search={"user_id": auth.user.id},
order_by=[{"id": "desc"}]
)
return [YifanPartnerApplyOutSchema.model_validate(obj).model_dump() for obj in obj_list]

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission, db_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanPrivacyPolicyService
from .schema import YifanPrivacyPolicyCreateSchema, YifanPrivacyPolicyUpdateSchema, YifanPrivacyPolicyQueryParam
YifanPrivacyPolicyRouter = APIRouter(prefix='/yifan_privacy_policy', tags=["隐私政策模块"])
@YifanPrivacyPolicyRouter.get("/mini/current", summary="小程序-获取当前隐私政策", description="根据类型获取当前生效的隐私政策或用户协议")
async def get_current_policy_for_mini(
policy_type: str = Query(default="privacy", description="协议类型: privacy-隐私政策, terms-用户协议"),
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""
小程序获取当前隐私政策/用户协议接口
无需登录,返回当前生效版本的协议内容
"""
result = await YifanPrivacyPolicyService.get_current_policy_service(db=db, policy_type=policy_type)
return SuccessResponse(data=result, msg="获取成功")
@YifanPrivacyPolicyRouter.get("/detail/{id}", summary="获取隐私政策详情", description="获取隐私政策详情")
async def get_yifan_privacy_policy_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:query"]))
) -> JSONResponse:
"""获取隐私政策详情接口"""
result_dict = await YifanPrivacyPolicyService.detail_yifan_privacy_policy_service(auth=auth, id=id)
log.info(f"获取隐私政策详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取隐私政策详情成功")
@YifanPrivacyPolicyRouter.get("/list", summary="查询隐私政策列表", description="查询隐私政策列表")
async def get_yifan_privacy_policy_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanPrivacyPolicyQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:query"]))
) -> JSONResponse:
"""查询隐私政策列表接口(数据库分页)"""
result_dict = await YifanPrivacyPolicyService.page_yifan_privacy_policy_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询隐私政策列表成功")
return SuccessResponse(data=result_dict, msg="查询隐私政策列表成功")
@YifanPrivacyPolicyRouter.post("/create", summary="创建隐私政策", description="创建隐私政策")
async def create_yifan_privacy_policy_controller(
data: YifanPrivacyPolicyCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:create"]))
) -> JSONResponse:
"""创建隐私政策接口"""
result_dict = await YifanPrivacyPolicyService.create_yifan_privacy_policy_service(auth=auth, data=data)
log.info("创建隐私政策成功")
return SuccessResponse(data=result_dict, msg="创建隐私政策成功")
@YifanPrivacyPolicyRouter.put("/update/{id}", summary="修改隐私政策", description="修改隐私政策")
async def update_yifan_privacy_policy_controller(
data: YifanPrivacyPolicyUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:update"]))
) -> JSONResponse:
"""修改隐私政策接口"""
result_dict = await YifanPrivacyPolicyService.update_yifan_privacy_policy_service(auth=auth, id=id, data=data)
log.info("修改隐私政策成功")
return SuccessResponse(data=result_dict, msg="修改隐私政策成功")
@YifanPrivacyPolicyRouter.delete("/delete", summary="删除隐私政策", description="删除隐私政策")
async def delete_yifan_privacy_policy_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:delete"]))
) -> JSONResponse:
"""删除隐私政策接口"""
await YifanPrivacyPolicyService.delete_yifan_privacy_policy_service(auth=auth, ids=ids)
log.info(f"删除隐私政策成功: {ids}")
return SuccessResponse(msg="删除隐私政策成功")
@YifanPrivacyPolicyRouter.patch("/available/setting", summary="批量修改隐私政策状态", description="批量修改隐私政策状态")
async def batch_set_available_yifan_privacy_policy_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:patch"]))
) -> JSONResponse:
"""批量修改隐私政策状态接口"""
await YifanPrivacyPolicyService.set_available_yifan_privacy_policy_service(auth=auth, data=data)
log.info(f"批量修改隐私政策状态成功: {data.ids}")
return SuccessResponse(msg="批量修改隐私政策状态成功")
@YifanPrivacyPolicyRouter.post('/export', summary="导出隐私政策", description="导出隐私政策")
async def export_yifan_privacy_policy_list_controller(
search: YifanPrivacyPolicyQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:export"]))
) -> StreamingResponse:
"""导出隐私政策接口"""
result_dict_list = await YifanPrivacyPolicyService.list_yifan_privacy_policy_service(search=search, auth=auth)
export_result = await YifanPrivacyPolicyService.batch_export_yifan_privacy_policy_service(obj_list=result_dict_list)
log.info('导出隐私政策成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_privacy_policy.xlsx'
}
)
@YifanPrivacyPolicyRouter.post('/import', summary="导入隐私政策", description="导入隐私政策")
async def import_yifan_privacy_policy_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_privacy_policy:import"]))
) -> JSONResponse:
"""导入隐私政策接口"""
batch_import_result = await YifanPrivacyPolicyService.batch_import_yifan_privacy_policy_service(file=file, auth=auth, update_support=True)
log.info("导入隐私政策成功")
return SuccessResponse(data=batch_import_result, msg="导入隐私政策成功")
@YifanPrivacyPolicyRouter.post('/download/template', summary="获取隐私政策导入模板", description="获取隐私政策导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_privacy_policy:download"]))])
async def export_yifan_privacy_policy_template_controller() -> StreamingResponse:
"""获取隐私政策导入模板接口"""
import_template_result = await YifanPrivacyPolicyService.import_template_download_yifan_privacy_policy_service()
log.info('获取隐私政策导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_privacy_policy_template.xlsx'}
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import YifanPrivacyPolicyModel
from .schema import YifanPrivacyPolicyCreateSchema, YifanPrivacyPolicyUpdateSchema, YifanPrivacyPolicyOutSchema
class YifanPrivacyPolicyCRUD(CRUDBase[YifanPrivacyPolicyModel, YifanPrivacyPolicyCreateSchema, YifanPrivacyPolicyUpdateSchema]):
"""隐私政策数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=YifanPrivacyPolicyModel, auth=auth)
async def get_by_id_yifan_privacy_policy_crud(self, id: int, preload: list | None = None) -> YifanPrivacyPolicyModel | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- YifanPrivacyPolicyModel | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_yifan_privacy_policy_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[YifanPrivacyPolicyModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[YifanPrivacyPolicyModel]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_yifan_privacy_policy_crud(self, data: YifanPrivacyPolicyCreateSchema) -> YifanPrivacyPolicyModel | None:
"""
创建
参数:
- data (YifanPrivacyPolicyCreateSchema): 创建模型
返回:
- YifanPrivacyPolicyModel | None: 模型实例或None
"""
return await self.create(data=data)
async def update_yifan_privacy_policy_crud(self, id: int, data: YifanPrivacyPolicyUpdateSchema) -> YifanPrivacyPolicyModel | None:
"""
更新
参数:
- id (int): 对象ID
- data (YifanPrivacyPolicyUpdateSchema): 更新模型
返回:
- YifanPrivacyPolicyModel | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_yifan_privacy_policy_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_yifan_privacy_policy_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_yifan_privacy_policy_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=YifanPrivacyPolicyOutSchema,
preload=preload
)

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
import datetime
from sqlalchemy import Integer, DateTime, SmallInteger, Text, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanPrivacyPolicyModel(ModelMixin, UserMixin):
"""
隐私政策表
"""
__tablename__: str = 'yifan_privacy_policy'
__table_args__: dict[str, str] = {'comment': '隐私政策'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0否 1是)')
title: Mapped[str | None] = mapped_column(String(200), nullable=True, comment='标题')
content: Mapped[str | None] = mapped_column(Text, nullable=True, comment='内容(支持富文本)')
version: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='版本号')
policy_type: Mapped[str | None] = mapped_column(String(32), nullable=True, comment='协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)')
effective_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True, comment='生效时间')
is_current: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否当前版本(0否 1是)')
remark: Mapped[str | None] = mapped_column(String(255), nullable=True, comment='备注')

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
import datetime
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanPrivacyPolicyCreateSchema(BaseModel):
"""
隐私政策新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
title: str = Field(default=..., description='标题')
content: str = Field(default=..., description='内容(支持富文本)')
version: str = Field(default=..., description='版本号')
policy_type: str = Field(default='privacy', description='协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)')
effective_time: datetime.datetime | None = Field(default=None, description='生效时间')
is_current: int = Field(default=0, description='是否当前版本(0否 1是)')
remark: str | None = Field(default=None, description='备注')
class YifanPrivacyPolicyUpdateSchema(YifanPrivacyPolicyCreateSchema):
"""
隐私政策更新模型
"""
...
class YifanPrivacyPolicyOutSchema(YifanPrivacyPolicyCreateSchema, BaseSchema, UserBySchema):
"""
隐私政策响应模型
"""
model_config = ConfigDict(from_attributes=True)
# 重写 effective_time 字段,确保返回字符串格式
effective_time: DateTimeStr | None = Field(default=None, description='生效时间')
class YifanPrivacyPolicyQueryParam:
"""隐私政策查询参数"""
def __init__(
self,
title: str | None = Query(None, description="标题"),
version: str | None = Query(None, description="版本号"),
policy_type: str | None = Query(None, description="协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)"),
remark: str | None = Query(None, description="备注"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
content: str | None = Query(None, description="内容(支持富文本)"),
effective_time: datetime.datetime | None = Query(None, description="生效时间"),
is_current: int | None = Query(None, description="是否当前版本(0否 1是)"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 模糊查询字段
self.title = ("like", title)
# 精确查询字段
self.content = content
# 模糊查询字段
self.version = ("like", version)
# 模糊查询字段
self.policy_type = ("like", policy_type)
# 精确查询字段
self.effective_time = effective_time
# 精确查询字段
self.is_current = is_current
# 模糊查询字段
self.remark = ("like", remark)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanPrivacyPolicyCreateSchema, YifanPrivacyPolicyUpdateSchema, YifanPrivacyPolicyOutSchema, YifanPrivacyPolicyQueryParam
from .crud import YifanPrivacyPolicyCRUD
from .model import YifanPrivacyPolicyModel
class YifanPrivacyPolicyService:
"""
隐私政策服务层
"""
@classmethod
async def get_current_policy_service(cls, db: AsyncSession, policy_type: str) -> dict | None:
"""
获取当前生效的隐私政策/用户协议(小程序专用)
"""
stmt = (
select(YifanPrivacyPolicyModel)
.where(
YifanPrivacyPolicyModel.policy_type == policy_type,
YifanPrivacyPolicyModel.is_current == 1,
YifanPrivacyPolicyModel.status == '1'
)
.order_by(YifanPrivacyPolicyModel.effective_time.desc())
.limit(1)
)
result = await db.execute(stmt)
policy = result.scalar_one_or_none()
if not policy:
return None
return {
'id': policy.id,
'title': policy.title,
'content': policy.content,
'version': policy.version,
'policy_type': policy.policy_type,
'effective_time': str(policy.effective_time) if policy.effective_time else None,
}
@classmethod
async def detail_yifan_privacy_policy_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanPrivacyPolicyCRUD(auth).get_by_id_yifan_privacy_policy_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanPrivacyPolicyOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_privacy_policy_service(cls, auth: AuthSchema, search: YifanPrivacyPolicyQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanPrivacyPolicyCRUD(auth).list_yifan_privacy_policy_crud(search=search_dict, order_by=order_by)
return [YifanPrivacyPolicyOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_privacy_policy_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanPrivacyPolicyQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanPrivacyPolicyCRUD(auth).page_yifan_privacy_policy_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_privacy_policy_service(cls, auth: AuthSchema, data: YifanPrivacyPolicyCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanPrivacyPolicyCRUD(auth).create_yifan_privacy_policy_crud(data=data)
return YifanPrivacyPolicyOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_privacy_policy_service(cls, auth: AuthSchema, id: int, data: YifanPrivacyPolicyUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanPrivacyPolicyCRUD(auth).get_by_id_yifan_privacy_policy_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanPrivacyPolicyCRUD(auth).update_yifan_privacy_policy_crud(id=id, data=data)
return YifanPrivacyPolicyOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_privacy_policy_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanPrivacyPolicyCRUD(auth).get_by_id_yifan_privacy_policy_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanPrivacyPolicyCRUD(auth).delete_yifan_privacy_policy_crud(ids=ids)
@classmethod
async def set_available_yifan_privacy_policy_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanPrivacyPolicyCRUD(auth).set_available_yifan_privacy_policy_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_privacy_policy_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'title': '标题',
'content': '内容(支持富文本)',
'version': '版本号',
'policy_type': '协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)',
'effective_time': '生效时间',
'is_current': '是否当前版本(0否 1是)',
'remark': '备注',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_privacy_policy_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'标题': 'title',
'内容(支持富文本)': 'content',
'版本号': 'version',
'协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)': 'policy_type',
'生效时间': 'effective_time',
'是否当前版本(0否 1是)': 'is_current',
'备注': 'remark',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"title": row['title'],
"content": row['content'],
"version": row['version'],
"policy_type": row['policy_type'],
"effective_time": row['effective_time'],
"is_current": row['is_current'],
"remark": row['remark'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanPrivacyPolicyCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanPrivacyPolicyCRUD(auth).create_yifan_privacy_policy_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_privacy_policy_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'标题',
'内容(支持富文本)',
'版本号',
'协议类型(privacy:隐私政策 terms:用户协议 service:服务条款)',
'生效时间',
'是否当前版本(0否 1是)',
'备注',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanSectionDetailsService
from .schema import YifanSectionDetailsCreateSchema, YifanSectionDetailsUpdateSchema, YifanSectionDetailsQueryParam
YifanSectionDetailsRouter = APIRouter(prefix='/yifan_section_details', tags=["板块详情模块"])
@YifanSectionDetailsRouter.get("/detail/{id}", summary="获取板块详情详情", description="获取板块详情详情")
async def get_yifan_section_details_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:query"]))
) -> JSONResponse:
"""获取板块详情详情接口"""
result_dict = await YifanSectionDetailsService.detail_yifan_section_details_service(auth=auth, id=id)
log.info(f"获取板块详情详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取板块详情详情成功")
@YifanSectionDetailsRouter.get("/list", summary="查询板块详情列表", description="查询板块详情列表")
async def get_yifan_section_details_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanSectionDetailsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:query"]))
) -> JSONResponse:
"""查询板块详情列表接口(数据库分页)"""
result_dict = await YifanSectionDetailsService.page_yifan_section_details_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询板块详情列表成功")
return SuccessResponse(data=result_dict, msg="查询板块详情列表成功")
@YifanSectionDetailsRouter.post("/create", summary="创建板块详情", description="创建板块详情")
async def create_yifan_section_details_controller(
data: YifanSectionDetailsCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:create"]))
) -> JSONResponse:
"""创建板块详情接口"""
result_dict = await YifanSectionDetailsService.create_yifan_section_details_service(auth=auth, data=data)
log.info("创建板块详情成功")
return SuccessResponse(data=result_dict, msg="创建板块详情成功")
@YifanSectionDetailsRouter.put("/update/{id}", summary="修改板块详情", description="修改板块详情")
async def update_yifan_section_details_controller(
data: YifanSectionDetailsUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:update"]))
) -> JSONResponse:
"""修改板块详情接口"""
result_dict = await YifanSectionDetailsService.update_yifan_section_details_service(auth=auth, id=id, data=data)
log.info("修改板块详情成功")
return SuccessResponse(data=result_dict, msg="修改板块详情成功")
@YifanSectionDetailsRouter.delete("/delete", summary="删除板块详情", description="删除板块详情")
async def delete_yifan_section_details_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:delete"]))
) -> JSONResponse:
"""删除板块详情接口"""
await YifanSectionDetailsService.delete_yifan_section_details_service(auth=auth, ids=ids)
log.info(f"删除板块详情成功: {ids}")
return SuccessResponse(msg="删除板块详情成功")
@YifanSectionDetailsRouter.patch("/available/setting", summary="批量修改板块详情状态", description="批量修改板块详情状态")
async def batch_set_available_yifan_section_details_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:patch"]))
) -> JSONResponse:
"""批量修改板块详情状态接口"""
await YifanSectionDetailsService.set_available_yifan_section_details_service(auth=auth, data=data)
log.info(f"批量修改板块详情状态成功: {data.ids}")
return SuccessResponse(msg="批量修改板块详情状态成功")
@YifanSectionDetailsRouter.post('/export', summary="导出板块详情", description="导出板块详情")
async def export_yifan_section_details_list_controller(
search: YifanSectionDetailsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:export"]))
) -> StreamingResponse:
"""导出板块详情接口"""
result_dict_list = await YifanSectionDetailsService.list_yifan_section_details_service(search=search, auth=auth)
export_result = await YifanSectionDetailsService.batch_export_yifan_section_details_service(obj_list=result_dict_list)
log.info('导出板块详情成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_section_details.xlsx'
}
)
@YifanSectionDetailsRouter.post('/import', summary="导入板块详情", description="导入板块详情")
async def import_yifan_section_details_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_section_details:import"]))
) -> JSONResponse:
"""导入板块详情接口"""
batch_import_result = await YifanSectionDetailsService.batch_import_yifan_section_details_service(file=file, auth=auth, update_support=True)
log.info("导入板块详情成功")
return SuccessResponse(data=batch_import_result, msg="导入板块详情成功")
@YifanSectionDetailsRouter.post('/download/template', summary="获取板块详情导入模板", description="获取板块详情导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_section_details:download"]))])
async def export_yifan_section_details_template_controller() -> StreamingResponse:
"""获取板块详情导入模板接口"""
import_template_result = await YifanSectionDetailsService.import_template_download_yifan_section_details_service()
log.info('获取板块详情导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_section_details_template.xlsx'}
)

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from app.core.base_crud import CRUDBase
from .model import YifanSectionDetailsModel
class YifanSectionDetailsCRUD(CRUDBase[YifanSectionDetailsModel, None, None]):
"""板块详情 CRUD"""
def __init__(self, auth):
super().__init__(model=YifanSectionDetailsModel, auth=auth)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer, SmallInteger, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanSectionDetailsModel(ModelMixin, UserMixin):
"""
板块详情表
"""
__tablename__: str = 'yifan_section_details'
__table_args__: dict[str, str] = {'comment': '板块详情表'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0:否 1:是)')
section_id: Mapped[int] = mapped_column(Integer, nullable=False, comment='关联板块ID')
detail_type: Mapped[str] = mapped_column(String(50), nullable=False, comment='详情类型')
title: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='详情标题')
content: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment='详细内容')
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0, comment='排序')

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanSectionDetailsCreateSchema(BaseModel):
"""
板块详情新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
section_id: int = Field(default=..., description='关联板块ID')
detail_type: str = Field(default=..., description='详情类型(life_guide:生活开运指南 poetry_base:诗词底蕴 hexagram_meaning:卦象释义 depth_analysis:深度字义解读 zodiac_analysis:生肖喜忌分析 master_comment:大师总评)')
title: str = Field(default=..., description='详情标题')
content: dict = Field(default=..., description='详细内容')
sort_order: int = Field(default=..., description='排序')
class YifanSectionDetailsUpdateSchema(YifanSectionDetailsCreateSchema):
"""
板块详情更新模型
"""
...
class YifanSectionDetailsOutSchema(YifanSectionDetailsCreateSchema, BaseSchema, UserBySchema):
"""
板块详情响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanSectionDetailsQueryParam:
"""板块详情查询参数"""
def __init__(
self,
detail_type: str | None = Query(None, description="详情类型(life_guide:生活开运指南 poetry_base:诗词底蕴 hexagram_meaning:卦象释义 depth_analysis:深度字义解读 zodiac_analysis:生肖喜忌分析 master_comment:大师总评)"),
title: str | None = Query(None, description="详情标题"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
section_id: int | None = Query(None, description="关联板块ID"),
content: str | None = Query(None, description="详细内容(JSON字符串)"),
sort_order: int | None = Query(None, description="排序"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 精确查询字段
self.section_id = section_id
# 模糊查询字段
self.detail_type = ("like", detail_type)
# 模糊查询字段
self.title = ("like", title)
# 精确查询字段
self.content = content
# 精确查询字段
self.sort_order = sort_order
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanSectionDetailsCreateSchema, YifanSectionDetailsUpdateSchema, YifanSectionDetailsOutSchema, YifanSectionDetailsQueryParam
from .crud import YifanSectionDetailsCRUD
class YifanSectionDetailsService:
"""
板块详情服务层
"""
@classmethod
async def detail_yifan_section_details_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanSectionDetailsCRUD(auth).get_by_id_yifan_section_details_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanSectionDetailsOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_section_details_service(cls, auth: AuthSchema, search: YifanSectionDetailsQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanSectionDetailsCRUD(auth).list_yifan_section_details_crud(search=search_dict, order_by=order_by)
return [YifanSectionDetailsOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_section_details_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanSectionDetailsQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanSectionDetailsCRUD(auth).page_yifan_section_details_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_section_details_service(cls, auth: AuthSchema, data: YifanSectionDetailsCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanSectionDetailsCRUD(auth).create_yifan_section_details_crud(data=data)
return YifanSectionDetailsOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_section_details_service(cls, auth: AuthSchema, id: int, data: YifanSectionDetailsUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanSectionDetailsCRUD(auth).get_by_id_yifan_section_details_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanSectionDetailsCRUD(auth).update_yifan_section_details_crud(id=id, data=data)
return YifanSectionDetailsOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_section_details_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanSectionDetailsCRUD(auth).get_by_id_yifan_section_details_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanSectionDetailsCRUD(auth).delete_yifan_section_details_crud(ids=ids)
@classmethod
async def set_available_yifan_section_details_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanSectionDetailsCRUD(auth).set_available_yifan_section_details_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_section_details_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'section_id': '关联板块ID',
'detail_type': '详情类型(life_guide:生活开运指南 poetry_base:诗词底蕴 hexagram_meaning:卦象释义 depth_analysis:深度字义解读 zodiac_analysis:生肖喜忌分析 master_comment:大师总评)',
'title': '详情标题',
'content': '详细内容',
'sort_order': '排序',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_section_details_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'关联板块ID': 'section_id',
'详情类型(life_guide:生活开运指南 poetry_base:诗词底蕴 hexagram_meaning:卦象释义 depth_analysis:深度字义解读 zodiac_analysis:生肖喜忌分析 master_comment:大师总评)': 'detail_type',
'详情标题': 'title',
'详细内容': 'content',
'排序': 'sort_order',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"section_id": row['section_id'],
"detail_type": row['detail_type'],
"title": row['title'],
"content": row['content'],
"sort_order": row['sort_order'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanSectionDetailsCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanSectionDetailsCRUD(auth).create_yifan_section_details_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_section_details_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'关联板块ID',
'详情类型(life_guide:生活开运指南 poetry_base:诗词底蕴 hexagram_meaning:卦象释义 depth_analysis:深度字义解读 zodiac_analysis:生肖喜忌分析 master_comment:大师总评)',
'详情标题',
'详细内容',
'排序',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import YifanSolutionSectionsService
from .schema import YifanSolutionSectionsCreateSchema, YifanSolutionSectionsUpdateSchema, YifanSolutionSectionsQueryParam
YifanSolutionSectionsRouter = APIRouter(prefix='/yifan_solution_sections', tags=["方案板块模块"])
@YifanSolutionSectionsRouter.get("/detail/{id}", summary="获取方案板块详情", description="获取方案板块详情")
async def get_yifan_solution_sections_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:query"]))
) -> JSONResponse:
"""获取方案板块详情接口"""
result_dict = await YifanSolutionSectionsService.detail_yifan_solution_sections_service(auth=auth, id=id)
log.info(f"获取方案板块详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取方案板块详情成功")
@YifanSolutionSectionsRouter.get("/list", summary="查询方案板块列表", description="查询方案板块列表")
async def get_yifan_solution_sections_list_controller(
page: PaginationQueryParam = Depends(),
search: YifanSolutionSectionsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:query"]))
) -> JSONResponse:
"""查询方案板块列表接口(数据库分页)"""
result_dict = await YifanSolutionSectionsService.page_yifan_solution_sections_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询方案板块列表成功")
return SuccessResponse(data=result_dict, msg="查询方案板块列表成功")
@YifanSolutionSectionsRouter.post("/create", summary="创建方案板块", description="创建方案板块")
async def create_yifan_solution_sections_controller(
data: YifanSolutionSectionsCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:create"]))
) -> JSONResponse:
"""创建方案板块接口"""
result_dict = await YifanSolutionSectionsService.create_yifan_solution_sections_service(auth=auth, data=data)
log.info("创建方案板块成功")
return SuccessResponse(data=result_dict, msg="创建方案板块成功")
@YifanSolutionSectionsRouter.put("/update/{id}", summary="修改方案板块", description="修改方案板块")
async def update_yifan_solution_sections_controller(
data: YifanSolutionSectionsUpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:update"]))
) -> JSONResponse:
"""修改方案板块接口"""
result_dict = await YifanSolutionSectionsService.update_yifan_solution_sections_service(auth=auth, id=id, data=data)
log.info("修改方案板块成功")
return SuccessResponse(data=result_dict, msg="修改方案板块成功")
@YifanSolutionSectionsRouter.delete("/delete", summary="删除方案板块", description="删除方案板块")
async def delete_yifan_solution_sections_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:delete"]))
) -> JSONResponse:
"""删除方案板块接口"""
await YifanSolutionSectionsService.delete_yifan_solution_sections_service(auth=auth, ids=ids)
log.info(f"删除方案板块成功: {ids}")
return SuccessResponse(msg="删除方案板块成功")
@YifanSolutionSectionsRouter.patch("/available/setting", summary="批量修改方案板块状态", description="批量修改方案板块状态")
async def batch_set_available_yifan_solution_sections_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:patch"]))
) -> JSONResponse:
"""批量修改方案板块状态接口"""
await YifanSolutionSectionsService.set_available_yifan_solution_sections_service(auth=auth, data=data)
log.info(f"批量修改方案板块状态成功: {data.ids}")
return SuccessResponse(msg="批量修改方案板块状态成功")
@YifanSolutionSectionsRouter.post('/export', summary="导出方案板块", description="导出方案板块")
async def export_yifan_solution_sections_list_controller(
search: YifanSolutionSectionsQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:export"]))
) -> StreamingResponse:
"""导出方案板块接口"""
result_dict_list = await YifanSolutionSectionsService.list_yifan_solution_sections_service(search=search, auth=auth)
export_result = await YifanSolutionSectionsService.batch_export_yifan_solution_sections_service(obj_list=result_dict_list)
log.info('导出方案板块成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=yifan_solution_sections.xlsx'
}
)
@YifanSolutionSectionsRouter.post('/import', summary="导入方案板块", description="导入方案板块")
async def import_yifan_solution_sections_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_solution_sections:import"]))
) -> JSONResponse:
"""导入方案板块接口"""
batch_import_result = await YifanSolutionSectionsService.batch_import_yifan_solution_sections_service(file=file, auth=auth, update_support=True)
log.info("导入方案板块成功")
return SuccessResponse(data=batch_import_result, msg="导入方案板块成功")
@YifanSolutionSectionsRouter.post('/download/template', summary="获取方案板块导入模板", description="获取方案板块导入模板", dependencies=[Depends(AuthPermission(["module_yifan:yifan_solution_sections:download"]))])
async def export_yifan_solution_sections_template_controller() -> StreamingResponse:
"""获取方案板块导入模板接口"""
import_template_result = await YifanSolutionSectionsService.import_template_download_yifan_solution_sections_service()
log.info('获取方案板块导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=yifan_solution_sections_template.xlsx'}
)

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from app.core.base_crud import CRUDBase
from .model import YifanSolutionSectionsModel
class YifanSolutionSectionsCRUD(CRUDBase[YifanSolutionSectionsModel, None, None]):
"""方案板块 CRUD"""
def __init__(self, auth):
super().__init__(model=YifanSolutionSectionsModel, auth=auth)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer, SmallInteger, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class YifanSolutionSectionsModel(ModelMixin, UserMixin):
"""
方案板块表
"""
__tablename__: str = 'yifan_solution_sections'
__table_args__: dict[str, str] = {'comment': '方案板块表'}
__loader_options__: list[str] = ["created_by", "updated_by"]
is_deleted: Mapped[int | None] = mapped_column(SmallInteger, nullable=True, comment='是否删除(0:否 1:是)')
solution_id: Mapped[int] = mapped_column(Integer, nullable=False, comment='关联方案ID')
section_type: Mapped[str] = mapped_column(String(50), nullable=False, comment='板块类型')
title: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='板块标题')
summary_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment='摘要数据')
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0, comment='排序')

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class YifanSolutionSectionsCreateSchema(BaseModel):
"""
方案板块新增模型
"""
is_deleted: int = Field(default=0, description='是否删除(0否 1是)')
status: int = Field(default=1, description='状态(0禁用 1启用)')
solution_id: int = Field(default=..., description='关联方案ID')
section_type: str = Field(default=..., description='板块类型(ditiantai:地天泰 kaiyun_jingnang:开运锦囊 shici:诗词出处 wuxing:五行分布 liuyao:六维格局 jiazu:家族起名 bihua:笔画数理 ziyi_shengxiao:字义生肖 zonghe_pingfen:综合评分)')
title: str = Field(default=..., description='板块标题')
summary_data: dict = Field(default=..., description='摘要数据(首页卡片展示)')
sort_order: int = Field(default=..., description='排序')
class YifanSolutionSectionsUpdateSchema(YifanSolutionSectionsCreateSchema):
"""
方案板块更新模型
"""
...
class YifanSolutionSectionsOutSchema(YifanSolutionSectionsCreateSchema, BaseSchema, UserBySchema):
"""
方案板块响应模型
"""
model_config = ConfigDict(from_attributes=True)
class YifanSolutionSectionsQueryParam:
"""方案板块查询参数"""
def __init__(
self,
section_type: str | None = Query(None, description="板块类型(ditiantai:地天泰 kaiyun_jingnang:开运锦囊 shici:诗词出处 wuxing:五行分布 liuyao:六维格局 jiazu:家族起名 bihua:笔画数理 ziyi_shengxiao:字义生肖 zonghe_pingfen:综合评分)"),
title: str | None = Query(None, description="板块标题"),
created_id: int | None = Query(None, description="创建人ID"),
updated_id: int | None = Query(None, description="更新人ID"),
is_deleted: int | None = Query(None, description="是否删除(0否 1是)"),
status: int | None = Query(None, description="状态(0禁用 1启用)"),
solution_id: int | None = Query(None, description="关联方案ID"),
summary_data: str | None = Query(None, description="摘要数据(JSON字符串)"),
sort_order: int | None = Query(None, description="排序"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 精确查询字段
self.created_id = created_id
# 精确查询字段
self.updated_id = updated_id
# 精确查询字段
self.is_deleted = is_deleted
# 精确查询字段
self.status = status
# 精确查询字段
self.solution_id = solution_id
# 模糊查询字段
self.section_type = ("like", section_type)
# 模糊查询字段
self.title = ("like", title)
# 精确查询字段
self.summary_data = summary_data
# 精确查询字段
self.sort_order = sort_order
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import YifanSolutionSectionsCreateSchema, YifanSolutionSectionsUpdateSchema, YifanSolutionSectionsOutSchema, YifanSolutionSectionsQueryParam
from .crud import YifanSolutionSectionsCRUD
class YifanSolutionSectionsService:
"""
方案板块服务层
"""
@classmethod
async def detail_yifan_solution_sections_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await YifanSolutionSectionsCRUD(auth).get_by_id_yifan_solution_sections_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return YifanSolutionSectionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_yifan_solution_sections_service(cls, auth: AuthSchema, search: YifanSolutionSectionsQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await YifanSolutionSectionsCRUD(auth).list_yifan_solution_sections_crud(search=search_dict, order_by=order_by)
return [YifanSolutionSectionsOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_yifan_solution_sections_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: YifanSolutionSectionsQueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await YifanSolutionSectionsCRUD(auth).page_yifan_solution_sections_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_yifan_solution_sections_service(cls, auth: AuthSchema, data: YifanSolutionSectionsCreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
obj = await YifanSolutionSectionsCRUD(auth).create_yifan_solution_sections_crud(data=data)
return YifanSolutionSectionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_yifan_solution_sections_service(cls, auth: AuthSchema, id: int, data: YifanSolutionSectionsUpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await YifanSolutionSectionsCRUD(auth).get_by_id_yifan_solution_sections_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
obj = await YifanSolutionSectionsCRUD(auth).update_yifan_solution_sections_crud(id=id, data=data)
return YifanSolutionSectionsOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_yifan_solution_sections_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await YifanSolutionSectionsCRUD(auth).get_by_id_yifan_solution_sections_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await YifanSolutionSectionsCRUD(auth).delete_yifan_solution_sections_crud(ids=ids)
@classmethod
async def set_available_yifan_solution_sections_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await YifanSolutionSectionsCRUD(auth).set_available_yifan_solution_sections_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_yifan_solution_sections_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
'id': '主键ID',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建人ID',
'updated_id': '更新人ID',
'is_deleted': '是否删除(0否 1是)',
'status': '状态(0禁用 1启用)',
'solution_id': '关联方案ID',
'section_type': '板块类型(ditiantai:地天泰 kaiyun_jingnang:开运锦囊 shici:诗词出处 wuxing:五行分布 liuyao:六维格局 jiazu:家族起名 bihua:笔画数理 ziyi_shengxiao:字义生肖 zonghe_pingfen:综合评分)',
'title': '板块标题',
'summary_data': '摘要数据(首页卡片展示)',
'sort_order': '排序',
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_yifan_solution_sections_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
'主键ID': 'id',
'创建时间': 'created_time',
'更新时间': 'updated_time',
'创建人ID': 'created_id',
'更新人ID': 'updated_id',
'是否删除(0否 1是)': 'is_deleted',
'状态(0禁用 1启用)': 'status',
'关联方案ID': 'solution_id',
'板块类型(ditiantai:地天泰 kaiyun_jingnang:开运锦囊 shici:诗词出处 wuxing:五行分布 liuyao:六维格局 jiazu:家族起名 bihua:笔画数理 ziyi_shengxiao:字义生肖 zonghe_pingfen:综合评分)': 'section_type',
'板块标题': 'title',
'摘要数据(首页卡片展示)': 'summary_data',
'排序': 'sort_order',
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
"id": row['id'],
"created_time": row['created_time'],
"updated_time": row['updated_time'],
"created_id": row['created_id'],
"updated_id": row['updated_id'],
"is_deleted": row['is_deleted'],
"status": row['status'],
"solution_id": row['solution_id'],
"section_type": row['section_type'],
"title": row['title'],
"summary_data": row['summary_data'],
"sort_order": row['sort_order'],
}
# 使用CreateSchema做校验后入库
create_schema = YifanSolutionSectionsCreateSchema.model_validate(data)
# 检查唯一性约束
await YifanSolutionSectionsCRUD(auth).create_yifan_solution_sections_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_yifan_solution_sections_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
'主键ID',
'创建时间',
'更新时间',
'创建人ID',
'更新人ID',
'是否删除(0否 1是)',
'状态(0禁用 1启用)',
'关联方案ID',
'板块类型(ditiantai:地天泰 kaiyun_jingnang:开运锦囊 shici:诗词出处 wuxing:五行分布 liuyao:六维格局 jiazu:家族起名 bihua:笔画数理 ziyi_shengxiao:字义生肖 zonghe_pingfen:综合评分)',
'板块标题',
'摘要数据(首页卡片展示)',
'排序',
]
selector_header_list = []
option_list = []
# 添加下拉选项
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1,178 @@
# 手机号认证功能说明
## 概述
为微信小程序添加了手机号和密码的登录注册功能,支持短信验证码验证。
## 新增接口
### 1. 发送短信验证码
- **接口路径**: `POST /yifan_wx_auth/send-sms`
- **功能**: 发送手机短信验证码
- **请求参数**:
```json
{
"mobile": "13800138000",
"code_type": "register" // register, resetpwd, changepwd, changemobile, mobilelogin
}
```
- **响应**:
```json
{
"success": true,
"msg": "验证码发送成功"
}
```
### 2. 手机号密码注册
- **接口路径**: `POST /yifan_wx_auth/mobile-register`
- **功能**: 使用手机号和密码注册新用户
- **请求参数**:
```json
{
"mobile": "13800138000",
"password": "123456",
"repassword": "123456",
"verification_code": "123456",
"inviter_id": 1 // 可选邀请人ID
}
```
- **响应**:
```json
{
"success": true,
"msg": "注册成功",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 7200,
"user_info": {
"id": 1,
"username": "mobile_38000_abc1",
"name": "mobile_38000_abc1",
"avatar": null,
"mobile": "13800138000"
}
}
}
```
### 3. 手机号密码登录
- **接口路径**: `POST /yifan_wx_auth/mobile-login`
- **功能**: 使用手机号和密码登录
- **请求参数**:
```json
{
"mobile": "13800138000",
"password": "123456",
"inviter_id": 1 // 可选邀请人ID
}
```
- **响应**:
```json
{
"success": true,
"msg": "登录成功",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 7200,
"user_info": {
"id": 1,
"username": "mobile_38000_abc1",
"name": "用户昵称",
"avatar": null,
"mobile": "13800138000"
}
}
}
```
### 4. 忘记密码
- **接口路径**: `POST /yifan_wx_auth/forgot-password`
- **功能**: 通过手机号和短信验证码重置密码
- **请求参数**:
```json
{
"mobile": "13800138000",
"password": "newpassword123",
"repassword": "newpassword123",
"verification_code": "123456"
}
```
- **响应**:
```json
{
"success": true,
"msg": "密码重置成功"
}
```
## 阿里云短信配置
### 配置信息
- **AccessKey ID**: `LTAI5t7ox6LSot4bTXQiU39R`
- **AccessKey Secret**: `X4Z5K3ZSrZcXzcc5HgWZNmMUmTvK8N`
- **地域ID**: `cn-hangzhou`
- **国内短信签名**: `山东实城派网络科技`
- **国际短信签名**: `Shando Tech`
- **支持国际短信**: `true`
### 短信模板配置
- **注册验证码**: `SMS_486390015`
- **重置密码验证码**: `SMS_486445022`
- **修改密码验证码**: `SMS_486450016`
- **修改手机号验证码**: `SMS_487250049`
- **手机登录验证码**: `SMS_487410035`
### 国际短信模板
- **国际注册验证码**: `SMS_INTL_486390015`
- **国际重置密码验证码**: `SMS_INTL_486445022`
- **国际修改密码验证码**: `SMS_INTL_486450016`
- **国际修改手机号验证码**: `SMS_INTL_487250049`
- **国际手机登录验证码**: `SMS_INTL_487410035`
## 功能特性
### 1. 短信验证码
- **有效期**: 5分钟
- **发送频率限制**: 1分钟内只能发送一次
- **验证码长度**: 6位数字
- **支持国际短信**: 自动识别国际手机号
### 2. 安全特性
- 密码使用bcrypt加密存储
- 验证码使用后自动删除
- 支持发送频率限制
- 注册时检查手机号是否已存在
### 3. 用户管理
- 自动生成用户名格式: `mobile_{手机号后8位}_{随机4位}`
- 支持邀请人关系绑定
- 自动更新最后登录时间
- 创建注册通知
## 依赖包
需要安装以下Python包
```
alibabacloud-dysmsapi20170525==3.0.0 # 阿里云短信服务SDK
alibabacloud-tea-openapi==0.3.9 # 阿里云SDK基础依赖
alibabacloud-tea-util==0.3.13 # 阿里云SDK工具依赖
```
## 使用说明
1. **安装依赖**: 运行 `pip install -r requirements.txt` 安装新增的依赖包
2. **配置检查**: 确认阿里云短信服务配置信息正确
3. **测试接口**: 使用API文档测试新增的三个接口
4. **前端集成**: 前端可以调用这些接口实现手机号注册登录功能
## 注意事项
1. 短信验证码有5分钟有效期请及时使用
2. 发送验证码有频率限制1分钟内只能发送一次
3. 手机号格式必须是11位数字中国大陆
4. 密码长度要求6-20位字符
5. 国际手机号会自动使用国际短信模板和签名

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends, Request, UploadFile, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio.client import Redis
from app.common.response import SuccessResponse
from app.core.router_class import OperationLogRoute
from app.core.logger import log
from app.core.dependencies import db_getter, redis_getter, get_current_user
from app.core.exceptions import CustomException
from app.utils.upload_util import UploadUtil
from app.utils.oss_util import OSSUtil
from app.config.setting import settings
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.notice.crud import NoticeCRUD
from app.api.v1.module_system.notice.schema import NoticeOutSchema
from .service import YifanWxAuthService
from .qrcode_service import WxMiniQRCodeService
from .schema import (
WxMiniLoginSchema,
WxMiniLoginOutSchema,
WxMiniRegisterSchema,
SendSmsSchema,
MobileRegisterSchema,
MobileLoginSchema,
MobileLoginOutSchema,
ForgotPasswordSchema,
)
from .qrcode_schema import WxMiniQRCodeSchema, WxMiniQRCodeOutSchema
YifanWxAuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/yifan_wx_auth", tags=["易凡小程序认证"])
YifanNoticeRouter = APIRouter(route_class=OperationLogRoute, prefix="/notice", tags=["小程序消息"])
@YifanNoticeRouter.get("/mini/list", summary="小程序消息列表", description="小程序获取消息列表(无需后台权限)")
async def get_yifan_notice_mini_list_controller(
page_no: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=50, description="每页数量"),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
auth = AuthSchema(db=db, check_data_scope=False)
offset = (page_no - 1) * page_size
result = await NoticeCRUD(auth).page(
offset=offset,
limit=page_size,
order_by=[{"created_time": "desc"}],
search={"status": "0"},
out_schema=NoticeOutSchema,
)
log.info("小程序查询消息列表成功")
return SuccessResponse(data=result, msg="获取消息列表成功")
@YifanWxAuthRouter.post("/login", summary="微信小程序登录", description="微信小程序登录", response_model=WxMiniLoginOutSchema)
async def wx_mini_login_controller(
request: Request,
login_data: WxMiniLoginSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
微信小程序登录
参数:
- request (Request): FastAPI请求对象
- login_data (WxMiniLoginSchema): 小程序登录数据(code)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- WxMiniLoginOutSchema: 登录响应包含是否已注册、token等信息
"""
result = await YifanWxAuthService.wx_mini_login_service(
request=request,
redis=redis,
db=db,
login_data=login_data
)
if result.is_registered:
log.info(f"微信小程序用户登录成功")
return SuccessResponse(data=result.model_dump(), msg="登录成功")
else:
log.info(f"微信小程序用户未注册openid: {result.openid}")
# 用户未注册,返回 success=False前端可通过 success 或 is_registered 判断
return SuccessResponse(data=result.model_dump(), msg="用户未注册", success=False)
# path: /yifan_wx_auth/register
@YifanWxAuthRouter.post("/register", summary="微信小程序注册", description="微信小程序注册新用户", response_model=WxMiniLoginOutSchema)
async def wx_mini_register_controller(
request: Request,
register_data: WxMiniRegisterSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
微信小程序注册新用户
参数:
- request (Request): FastAPI请求对象
- register_data (WxMiniRegisterSchema): 注册数据(openid, name, avatar, mobile)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- WxMiniLoginOutSchema: 登录响应包含token等信息
"""
result = await YifanWxAuthService.wx_mini_register_service(
request=request,
redis=redis,
db=db,
register_data=register_data
)
log.info(f"微信小程序用户注册成功")
return SuccessResponse(data=result.model_dump(), msg="注册成功")
@YifanWxAuthRouter.post("/logout", summary="微信小程序退出登录", description="微信小程序退出登录", dependencies=[Depends(get_current_user)])
async def wx_mini_logout_controller(
request: Request,
redis: Redis = Depends(redis_getter),
) -> JSONResponse:
"""
微信小程序退出登录
参数:
- request (Request): FastAPI请求对象
- redis (Redis): Redis客户端对象
返回:
- JSONResponse: 退出登录响应
"""
await YifanWxAuthService.wx_mini_logout_service(
request=request,
redis=redis
)
log.info(f"微信小程序用户退出登录成功")
return SuccessResponse(msg="退出登录成功")
@YifanWxAuthRouter.post("/upload-image", summary="小程序上传图片", description="小程序上传图片到阿里云OSS")
async def wx_mini_upload_image_controller(
file: UploadFile,
request: Request,
) -> JSONResponse:
"""
上传图片到阿里云OSS
参数:
- file (UploadFile): 上传的图片文件
- request (Request): FastAPI请求对象
返回:
- JSONResponse: 包含文件信息的响应
"""
if not file or not file.filename:
raise CustomException(msg="请选择要上传的图片")
filename_lower = file.filename.lower()
allowed_image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
ext = "." + filename_lower.rsplit(".", 1)[-1] if "." in filename_lower else ""
if ext not in allowed_image_exts:
raise CustomException(msg="仅支持上传图片: jpg/jpeg/png/gif/webp")
if not (file.content_type or "").startswith("image/"):
raise CustomException(msg="仅支持上传图片")
# 使用OSS上传
oss_util = OSSUtil()
filename, oss_key, file_url = await oss_util.upload_file(file=file)
log.info(f"图片上传OSS成功: {filename}")
return SuccessResponse(
data={
"file_path": oss_key, # OSS对象键
"file_name": filename, # 生成的文件名
"origin_name": file.filename, # 原始文件名
"file_url": file_url, # OSS访问URL
"storage_type": "oss" # 存储类型标识
},
msg="上传成功",
)
@YifanWxAuthRouter.delete("/delete-image", summary="删除OSS图片", description="通过文件URL删除阿里云OSS中的图片")
async def delete_oss_image_controller(
file_url: str = Body(..., embed=True, description="要删除的文件URL"),
) -> JSONResponse:
"""
删除OSS图片
参数:
- file_url (str): 要删除的文件完整URL
返回:
- JSONResponse: 删除结果响应
"""
if not file_url:
raise CustomException(msg="文件URL不能为空")
# 验证URL格式并提取OSS Key
try:
# 从URL中提取OSS对象键
# 例如: https://your-domain.com/upload/2024/02/08/filename.jpg -> upload/2024/02/08/filename.jpg
if settings.OSS_DOMAIN not in file_url:
raise CustomException(msg="无效的OSS文件URL")
# 提取OSS Key (去掉域名部分)
oss_key = file_url.replace(f"{settings.OSS_DOMAIN}/", "")
if not oss_key:
raise CustomException(msg="无法从URL中提取文件路径")
# 使用OSS工具删除文件
oss_util = OSSUtil()
success = oss_util.delete_file(oss_key)
if success:
log.info(f"OSS图片删除成功: {oss_key}")
return SuccessResponse(
data={"deleted_file": oss_key},
msg="图片删除成功"
)
else:
raise CustomException(msg="图片删除失败")
except CustomException:
raise
except Exception as e:
log.error(f"删除OSS图片异常: {str(e)}")
raise CustomException(msg=f"删除图片失败: {str(e)}")
@YifanWxAuthRouter.post("/generate-qrcode", summary="生成分享二维码", description="生成包含用户ID的小程序分享二维码", response_model=WxMiniQRCodeOutSchema)
async def generate_share_qr_code_controller(
request: Request,
page: str = Query(default="pages/index/index", description="跳转页面路径,如: pages/index/index"),
scene: str = Query(default=None, description="额外场景值"),
width: int = Query(default=430, ge=280, le=1280, description="二维码宽度"),
auto_color: bool = Query(default=False, description="自动配置线条颜色"),
is_hyaline: bool = Query(default=False, description="是否需要透明底色"),
current_user = Depends(get_current_user),
) -> JSONResponse:
"""
生成微信小程序分享二维码
参数:
- request (Request): FastAPI请求对象
- page (str): 跳转页面路径,默认 pages/index/index
- scene (str): 额外场景值,可选
- width (int): 二维码宽度默认430px
- auto_color (bool): 自动配置线条颜色默认False
- is_hyaline (bool): 是否需要透明底色默认False
- current_user: 当前登录用户
返回:
- WxMiniQRCodeOutSchema: 二维码信息包含URL和场景值
"""
# 记录请求参数用于调试
log.info(f"生成二维码请求参数 - 用户ID: {current_user.user.id}, 页面路径: {page}, 场景值: {scene}")
qr_data = WxMiniQRCodeSchema(
page=page,
scene=scene,
width=width,
auto_color=auto_color,
line_color={"r": 0, "g": 0, "b": 0},
is_hyaline=is_hyaline
)
try:
result = await WxMiniQRCodeService.generate_qr_code(
user_id=current_user.user.id,
qr_data=qr_data,
base_url=str(request.base_url)
)
except CustomException as e:
log.error(f"生成二维码失败 - 用户ID: {current_user.user.id}, 页面路径: {page}, 错误: {str(e)}")
raise
log.info(f"用户 {current_user.user.id} 生成分享二维码成功")
return SuccessResponse(data=result.model_dump(), msg="二维码生成成功")
@YifanWxAuthRouter.post("/send-sms", summary="发送短信验证码", description="发送手机短信验证码")
async def send_sms_verification_code_controller(
sms_data: SendSmsSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
发送短信验证码
参数:
- sms_data (SendSmsSchema): 短信发送数据(手机号、验证码类型)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- JSONResponse: 发送结果响应
"""
await YifanWxAuthService.send_sms_verification_code_service(
redis=redis,
db=db,
sms_data=sms_data
)
log.info(f"短信验证码发送成功: {sms_data.mobile}")
return SuccessResponse(msg="验证码发送成功")
@YifanWxAuthRouter.post("/mobile-register", summary="手机号密码注册", description="使用手机号和密码注册新用户", response_model=MobileLoginOutSchema)
async def mobile_register_controller(
request: Request,
register_data: MobileRegisterSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
手机号密码注册新用户
参数:
- request (Request): FastAPI请求对象
- register_data (MobileRegisterSchema): 注册数据(手机号、密码、确认密码、验证码)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- MobileLoginOutSchema: 登录响应包含token等信息
"""
result = await YifanWxAuthService.mobile_register_service(
request=request,
redis=redis,
db=db,
register_data=register_data
)
log.info(f"手机号用户注册成功: {register_data.mobile}")
return SuccessResponse(data=result.model_dump(), msg="注册成功")
@YifanWxAuthRouter.post("/mobile-login", summary="手机号密码登录", description="使用手机号和密码登录", response_model=MobileLoginOutSchema)
async def mobile_login_controller(
request: Request,
login_data: MobileLoginSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
手机号密码登录
参数:
- request (Request): FastAPI请求对象
- login_data (MobileLoginSchema): 登录数据(手机号、密码)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- MobileLoginOutSchema: 登录响应包含token等信息
"""
result = await YifanWxAuthService.mobile_login_service(
request=request,
redis=redis,
db=db,
login_data=login_data
)
log.info(f"手机号用户登录成功: {login_data.mobile}")
return SuccessResponse(data=result.model_dump(), msg="登录成功")
@YifanWxAuthRouter.post("/forgot-password", summary="忘记密码", description="通过手机号和短信验证码重置密码")
async def forgot_password_controller(
forgot_data: ForgotPasswordSchema,
redis: Redis = Depends(redis_getter),
db: AsyncSession = Depends(db_getter),
) -> JSONResponse:
"""
忘记密码
参数:
- forgot_data (ForgotPasswordSchema): 忘记密码数据(手机号、新密码、确认密码、验证码)
- redis (Redis): Redis客户端对象
- db (AsyncSession): 数据库会话对象
返回:
- JSONResponse: 重置结果响应
"""
await YifanWxAuthService.forgot_password_service(
redis=redis,
db=db,
forgot_data=forgot_data
)
log.info(f"用户忘记密码重置成功: {forgot_data.mobile}")
return SuccessResponse(msg="密码重置成功")

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field
from typing import Optional
class WxMiniQRCodeSchema(BaseModel):
"""微信小程序二维码生成请求模型"""
page: str = Field(default="pages/index/index", description="跳转页面路径")
scene: Optional[str] = Field(default=None, description="场景值最大32个可见字符")
width: int = Field(default=430, ge=280, le=1280, description="二维码宽度默认430px")
auto_color: bool = Field(default=False, description="自动配置线条颜色")
line_color: dict = Field(default={"r": 0, "g": 0, "b": 0}, description="线条颜色RGB")
is_hyaline: bool = Field(default=False, description="是否需要透明底色")
class WxMiniQRCodeOutSchema(BaseModel):
"""微信小程序二维码生成响应模型"""
qr_code_url: str = Field(..., description="二维码图片URL")
qr_code_path: str = Field(..., description="二维码图片本地路径")
scene: str = Field(..., description="场景值")
page: str = Field(..., description="跳转页面")
expires_at: Optional[str] = Field(None, description="过期时间")

View File

@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
import httpx
import aiofiles
import re
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urljoin
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import log
from .qrcode_schema import WxMiniQRCodeSchema, WxMiniQRCodeOutSchema
class WxMiniQRCodeService:
"""微信小程序二维码服务"""
@staticmethod
async def get_access_token() -> str:
"""获取微信小程序access_token"""
url = "https://api.weixin.qq.com/cgi-bin/token"
params = {
"grant_type": "client_credential",
"appid": settings.WX_MINI_APP_ID,
"secret": settings.WX_MINI_APP_SECRET
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params)
data = response.json()
if "errcode" in data and data["errcode"] != 0:
log.error(f"获取access_token失败: {data}")
raise CustomException(msg=f"获取access_token失败: {data.get('errmsg', '未知错误')}")
return data["access_token"]
@staticmethod
def _validate_page_path(page: str) -> bool:
"""验证小程序页面路径格式"""
if not page:
return False
# 页面路径应该符合小程序页面路径格式,如: pages/index/index, pages/user/profile
# 不能包含特殊字符不能是UUID格式
page_pattern = r'^[a-zA-Z0-9_/]+$'
# 检查是否为UUID格式这种格式是错误的
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8}$'
if re.match(uuid_pattern, page):
log.error(f"页面路径不能是UUID格式: {page}")
return False
if not re.match(page_pattern, page):
log.error(f"页面路径格式不正确: {page}")
return False
# 页面路径通常以pages/开头
if not page.startswith('pages/'):
log.warning(f"页面路径建议以'pages/'开头: {page}")
return True
@classmethod
async def generate_qr_code(
cls,
user_id: int,
qr_data: WxMiniQRCodeSchema,
base_url: str
) -> WxMiniQRCodeOutSchema:
"""生成微信小程序二维码"""
# 验证页面路径格式
if not cls._validate_page_path(qr_data.page):
raise CustomException(msg=f"页面路径格式错误: {qr_data.page},应为类似 'pages/index/index' 的格式")
# 构建场景值包含用户ID
scene = f"uid={user_id}"
if qr_data.scene:
scene += f"&{qr_data.scene}"
# 检查场景值长度限制
if len(scene) > 32:
raise CustomException(msg="场景值超过32个字符限制")
# 获取access_token
access_token = await cls.get_access_token()
# 调用微信API生成二维码
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}"
payload = {
"scene": scene,
"page": qr_data.page,
"width": qr_data.width,
"auto_color": qr_data.auto_color,
"line_color": qr_data.line_color,
"is_hyaline": qr_data.is_hyaline
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
# 检查响应是否为图片
if response.headers.get("content-type", "").startswith("image/"):
# 保存二维码图片
qr_filename, qr_filepath, qr_url = await cls._save_qr_code_image(
response.content, user_id, base_url
)
return WxMiniQRCodeOutSchema(
qr_code_url=qr_url,
qr_code_path=str(qr_filepath),
scene=scene,
page=qr_data.page,
expires_at=None # 小程序码永久有效
)
else:
# 响应为错误信息
try:
error_data = response.json()
error_code = error_data.get('errcode', 'unknown')
error_msg = error_data.get('errmsg', '未知错误')
# 针对常见错误提供更友好的提示
if error_code == 41030:
error_msg = f"页面路径不存在或格式错误: {qr_data.page},请检查小程序是否已发布该页面"
elif error_code == 45009:
error_msg = f"接口调用超过限制,请稍后再试"
elif error_code == 40013:
error_msg = f"AppID无效请检查小程序配置"
log.error(f"生成小程序码失败 - 错误码: {error_code}, 错误信息: {error_msg}, 页面路径: {qr_data.page}")
raise CustomException(msg=f"生成小程序码失败: {error_msg}")
except Exception as e:
log.error(f"解析微信API错误响应失败: {e}, 原始响应: {response.text}")
raise CustomException(msg=f"生成小程序码失败: 微信API返回异常响应")
@staticmethod
async def _save_qr_code_image(
image_content: bytes,
user_id: int,
base_url: str
) -> tuple[str, Path, str]:
"""保存二维码图片到本地"""
# 创建二维码专用目录
qr_dir = settings.UPLOAD_FILE_PATH / "qrcode" / datetime.now().strftime("%Y/%m/%d")
qr_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = f"qr_uid_{user_id}_{timestamp}.jpg"
filepath = qr_dir / filename
# 保存图片
async with aiofiles.open(filepath, 'wb') as f:
await f.write(image_content)
# 生成访问URL
file_url = urljoin(base_url, filepath.as_posix())
log.info(f"二维码保存成功: {filepath}")
return filename, filepath, file_url
@classmethod
async def generate_share_qr_code(
cls,
user_id: int,
base_url: str,
page: str = "pages/index/index",
scene: str = None
) -> WxMiniQRCodeOutSchema:
"""生成分享二维码的便捷方法"""
qr_data = WxMiniQRCodeSchema(
page=page,
scene=scene,
width=430,
auto_color=False,
line_color={"r": 0, "g": 0, "b": 0},
is_hyaline=False
)
return await cls.generate_qr_code(user_id, qr_data, base_url)

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field, BaseModel
class WxMiniLoginSchema(BaseModel):
"""微信小程序登录请求模型"""
code: str = Field(..., min_length=1, description='微信小程序登录code')
inviter_id: int | None = Field(default=None, description='邀请人ID(可选)')
class WxMiniUserInfoSchema(BaseModel):
"""微信小程序用户信息模型"""
openid: str = Field(..., description='用户唯一标识')
unionid: str | None = Field(default=None, description='用户在开放平台的唯一标识符')
session_key: str = Field(..., description='会话密钥')
class WxMiniLoginOutSchema(BaseModel):
"""微信小程序登录响应模型"""
model_config = ConfigDict(from_attributes=True)
is_registered: bool = Field(..., description='是否已注册')
access_token: str | None = Field(default=None, description='访问token')
refresh_token: str | None = Field(default=None, description='刷新token')
token_type: str | None = Field(default=None, description='token类型')
expires_in: int | None = Field(default=None, description='过期时间(秒)')
openid: str = Field(..., description='微信OpenID')
user_info: dict | None = Field(default=None, description='用户信息')
class WxMiniRegisterSchema(BaseModel):
"""微信小程序注册请求模型"""
openid: str = Field(..., min_length=1, description='微信OpenID')
name: str = Field(..., min_length=1, max_length=32, description='昵称')
avatar: str | None = Field(default=None, description='头像URL')
mobile: str | None = Field(default=None, description='手机号')
inviter_id: int | None = Field(default=None, description='邀请人ID(可选)')
class WxMiniBindSchema(BaseModel):
"""微信小程序绑定已有账号请求模型"""
openid: str = Field(..., min_length=1, description='微信OpenID')
username: str = Field(..., min_length=1, description='用户名')
password: str = Field(..., min_length=1, description='密码')
class SendSmsSchema(BaseModel):
"""发送短信验证码请求模型"""
mobile: str = Field(..., min_length=11, max_length=11, description='手机号')
code_type: str = Field(default="register", description='验证码类型: register, resetpwd, changepwd, changemobile, mobilelogin')
class MobileRegisterSchema(BaseModel):
"""手机号密码注册请求模型"""
mobile: str = Field(..., min_length=11, max_length=11, description='手机号')
password: str = Field(..., min_length=6, max_length=20, description='密码')
repassword: str = Field(..., min_length=6, max_length=20, description='确认密码')
verification_code: str = Field(..., min_length=6, max_length=6, description='短信验证码')
inviter_id: int | None = Field(default=None, description='邀请人ID(可选)')
class MobileLoginSchema(BaseModel):
"""手机号密码登录请求模型"""
mobile: str = Field(..., min_length=11, max_length=11, description='手机号')
password: str = Field(..., min_length=1, description='密码')
inviter_id: int | None = Field(default=None, description='邀请人ID(可选)')
class MobileLoginOutSchema(BaseModel):
"""手机号登录响应模型"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description='访问token')
refresh_token: str = Field(..., description='刷新token')
token_type: str = Field(..., description='token类型')
expires_in: int = Field(..., description='过期时间(秒)')
user_info: dict = Field(..., description='用户信息')
class ForgotPasswordSchema(BaseModel):
"""忘记密码请求模型"""
mobile: str = Field(..., min_length=11, max_length=11, description='手机号')
password: str = Field(..., min_length=6, max_length=20, description='新密码')
repassword: str = Field(..., min_length=6, max_length=20, description='确认新密码')
verification_code: str = Field(..., min_length=6, max_length=6, description='短信验证码')

View File

@@ -0,0 +1,628 @@
# -*- coding: utf-8 -*-
import json
import uuid
import httpx
from fastapi import Request
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from user_agents import parse
from app.common.enums import RedisInitKeyConfig
from app.utils.common_util import get_random_character
from app.utils.ip_local_util import IpLocalUtil
from app.utils.hash_bcrpy_util import PwdUtil
from app.core.redis_crud import RedisCURD
from app.core.exceptions import CustomException
from app.core.logger import log
from app.config.setting import settings
from app.core.security import create_access_token
from app.api.v1.module_monitor.online.schema import OnlineOutSchema
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel
from app.api.v1.module_system.notice.crud import NoticeCRUD
from app.api.v1.module_system.auth.schema import JWTPayloadSchema, JWTOutSchema, AuthSchema
from .schema import (
WxMiniLoginSchema,
WxMiniUserInfoSchema,
WxMiniLoginOutSchema,
WxMiniRegisterSchema,
WxMiniBindSchema,
SendSmsSchema,
MobileRegisterSchema,
MobileLoginSchema,
MobileLoginOutSchema,
ForgotPasswordSchema
)
class YifanWxAuthService:
"""易凡小程序微信登录服务"""
@classmethod
async def create_token_service(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
"""创建访问令牌和刷新令牌"""
session_id = str(uuid.uuid4())
request.scope["session_id"] = session_id
user_agent = parse(request.headers.get("user-agent"))
x_forwarded_for = request.headers.get('X-Forwarded-For')
if x_forwarded_for:
request_ip = x_forwarded_for.split(',')[0].strip()
else:
request_ip = request.client.host if request.client else "127.0.0.1"
login_location = await IpLocalUtil.get_ip_location(request_ip)
request.scope["login_location"] = login_location
request.scope["user_username"] = user.username
access_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_expires = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
now = datetime.now()
session_info = OnlineOutSchema(
session_id=session_id,
user_id=user.id,
name=user.name,
user_name=user.username,
ipaddr=request_ip,
login_location=login_location,
os=user_agent.os.family,
browser=user_agent.browser.family,
login_time=user.last_login,
login_type=login_type
).model_dump_json()
access_token = create_access_token(payload=JWTPayloadSchema(
sub=session_info,
is_refresh=False,
exp=now + access_expires,
))
refresh_token = create_access_token(payload=JWTPayloadSchema(
sub=session_info,
is_refresh=True,
exp=now + refresh_expires,
))
await RedisCURD(redis).set(
key=f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}',
value=access_token,
expire=int(access_expires.total_seconds())
)
await RedisCURD(redis).set(
key=f'{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}',
value=refresh_token,
expire=int(refresh_expires.total_seconds())
)
return JWTOutSchema(
access_token=access_token,
refresh_token=refresh_token,
expires_in=int(access_expires.total_seconds()),
token_type=settings.TOKEN_TYPE
)
@classmethod
async def code2session(cls, code: str) -> WxMiniUserInfoSchema:
"""通过code获取微信用户信息"""
import httpx
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": settings.WX_MINI_APP_ID,
"secret": settings.WX_MINI_APP_SECRET,
"js_code": code,
"grant_type": "authorization_code"
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params)
data = response.json()
if "errcode" in data and data["errcode"] != 0:
log.error(f"微信登录失败: {data}")
raise CustomException(msg=f"微信登录失败: {data.get('errmsg', '未知错误')}")
return WxMiniUserInfoSchema(
openid=data["openid"],
unionid=data.get("unionid"),
session_key=data["session_key"]
)
@classmethod
async def wx_mini_login_service(
cls,
request: Request,
redis: Redis,
db: AsyncSession,
login_data: WxMiniLoginSchema
) -> WxMiniLoginOutSchema:
"""微信小程序登录"""
wx_info = await cls.code2session(login_data.code)
auth = AuthSchema(db=db)
user = await UserCRUD(auth).get_by_wx_openid_crud(wx_openid=wx_info.openid)
if not user:
return WxMiniLoginOutSchema(
is_registered=False,
openid=wx_info.openid
)
if not user.status:
raise CustomException(msg="用户已被停用")
if user.inviter_id is None and login_data.inviter_id is not None:
await UserCRUD(auth).update(id=user.id, data={"inviter_id": login_data.inviter_id})
user = await UserCRUD(auth).get_by_id_crud(id=user.id)
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
token = await cls.create_token_service(
request=request,
redis=redis,
user=user,
login_type="wx_mini"
)
log.info(f"微信小程序用户 {user.username} 登录成功")
return WxMiniLoginOutSchema(
is_registered=True,
access_token=token.access_token,
refresh_token=token.refresh_token,
token_type=token.token_type,
expires_in=token.expires_in,
openid=wx_info.openid,
user_info={
"id": user.id,
"username": user.username,
"name": user.name,
"avatar": user.avatar
}
)
@classmethod
async def wx_mini_register_service(
cls,
request: Request,
redis: Redis,
db: AsyncSession,
register_data: WxMiniRegisterSchema
) -> WxMiniLoginOutSchema:
"""微信小程序注册新用户"""
auth = AuthSchema(db=db)
# 根据微信小程序OpenID获取用户信息
existing_user = await UserCRUD(auth).get_by_wx_openid_crud(wx_openid=register_data.openid)
if existing_user:
raise CustomException(msg="该微信已绑定其他账号")
if register_data.mobile:
mobile_user = await UserCRUD(auth).get_by_mobile_crud(mobile=register_data.mobile)
if mobile_user:
raise CustomException(msg="该手机号已被注册")
username = f"wx_{register_data.openid[-8:]}_{get_random_character()[:4]}"
random_password = get_random_character()[:16]
password_hash = PwdUtil.set_password_hash(random_password)
# 直接用字典创建用户,避免 UserCreateSchema 中的 role_ids 等字段
user_data = {
"username": username,
"password": password_hash,
"name": register_data.name,
"avatar": register_data.avatar,
"mobile": register_data.mobile,
"inviter_id": register_data.inviter_id,
"status": "0"
}
new_user = await UserCRUD(auth).create(data=user_data)
await UserCRUD(auth).bind_wx_openid_crud(id=new_user.id, wx_openid=register_data.openid)
try:
await NoticeCRUD(auth).create_crud(
data={
"notice_title": f"{username}注册",
"notice_type": "1",
"notice_content": f"{username}注册了壹梵小程序",
"status": "0",
}
)
except Exception as e:
log.error(f"注册成功但写入通知失败: {e}")
user = await UserCRUD(auth).get_by_id_crud(id=new_user.id)
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
token = await cls.create_token_service(
request=request,
redis=redis,
user=user,
login_type="wx_mini"
)
log.info(f"微信小程序用户 {username} 注册成功")
return WxMiniLoginOutSchema(
is_registered=True,
access_token=token.access_token,
refresh_token=token.refresh_token,
token_type=token.token_type,
expires_in=token.expires_in,
openid=register_data.openid,
user_info={
"id": user.id,
"username": user.username,
"name": user.name,
"avatar": user.avatar
}
)
@classmethod
async def wx_mini_bind_service(
cls,
request: Request,
redis: Redis,
db: AsyncSession,
bind_data: WxMiniBindSchema
) -> WxMiniLoginOutSchema:
"""微信小程序绑定已有账号"""
auth = AuthSchema(db=db)
existing_user = await UserCRUD(auth).get_by_wx_openid_crud(wx_openid=bind_data.openid)
if existing_user:
raise CustomException(msg="该微信已绑定其他账号")
user = await UserCRUD(auth).get_by_username_crud(username=bind_data.username)
if not user:
raise CustomException(msg="用户不存在")
if not PwdUtil.verify_password(plain_password=bind_data.password, password_hash=user.password):
raise CustomException(msg="密码错误")
if not user.status:
raise CustomException(msg="用户已被停用")
if user.wx_openid:
raise CustomException(msg="该账号已绑定其他微信")
await UserCRUD(auth).bind_wx_openid_crud(id=user.id, wx_openid=bind_data.openid)
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
token = await cls.create_token_service(
request=request,
redis=redis,
user=user,
login_type="wx_mini"
)
log.info(f"微信小程序用户 {user.username} 绑定成功")
return WxMiniLoginOutSchema(
is_registered=True,
access_token=token.access_token,
refresh_token=token.refresh_token,
token_type=token.token_type,
expires_in=token.expires_in,
openid=bind_data.openid,
user_info={
"id": user.id,
"username": user.username,
"name": user.name,
"avatar": user.avatar
}
)
@classmethod
async def wx_mini_logout_service(
cls,
request: Request,
redis: Redis
) -> None:
"""微信小程序退出登录"""
session_id = request.state.session_id
# 删除Redis中的token
await RedisCURD(redis).delete(f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}')
await RedisCURD(redis).delete(f'{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}')
log.info(f"微信小程序用户退出登录session_id: {session_id}")
@classmethod
async def send_subscribe_message(
cls,
openid: str,
template_id: str,
data: dict,
page: str = ""
) -> bool:
"""
发送微信小程序订阅消息(公共方法)
参数:
- openid: 用户OpenID
- template_id: 模板ID
- data: 模板数据,如 {"thing1": {"value": "xxx"}, "time2": {"value": "xxx"}}
- page: 点击通知跳转的小程序页面路径
"""
if not openid:
log.warning("[订阅消息] openid为空跳过发送")
return False
try:
from .qrcode_service import WxMiniQRCodeService
access_token = await WxMiniQRCodeService.get_access_token()
url = f"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={access_token}"
payload = {
"touser": openid,
"template_id": template_id,
"data": data,
}
if page:
payload["page"] = page
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
result = response.json()
if result.get("errcode", 0) != 0:
log.warning(f"[订阅消息] 发送失败: {result}")
return False
log.info(f"[订阅消息] 发送成功: openid={openid}, template={template_id}")
return True
except Exception as e:
log.error(f"[订阅消息] 发送异常: {e}")
return False
@classmethod
async def send_sms_verification_code_service(
cls,
redis: Redis,
db: AsyncSession,
sms_data: SendSmsSchema
) -> bool:
"""发送短信验证码服务"""
from app.utils.sms_util import SMSUtil
# 检查手机号格式
if not sms_data.mobile.isdigit() or len(sms_data.mobile) != 11:
raise CustomException(msg="手机号格式不正确")
# 检查发送频率限制1分钟内只能发送一次
redis_key = f"sms_code_limit:{sms_data.mobile}"
if await RedisCURD(redis).get(redis_key):
raise CustomException(msg="发送过于频繁请1分钟后再试")
# 如果是注册类型,检查手机号是否已存在
# if sms_data.code_type == "register":
# auth = AuthSchema(db=db)
# existing_user = await UserCRUD(auth).get_by_mobile_crud(mobile=sms_data.mobile)
# if existing_user:
# raise CustomException(msg="该手机号已被注册")
# 发送短信验证码(异常会直接抛出)
sms_util = SMSUtil()
success, verification_code = await sms_util.send_verification_code(
mobile=sms_data.mobile,
code_type=sms_data.code_type
)
# 将验证码存储到Redis5分钟有效期
code_key = f"sms_code:{sms_data.mobile}:{sms_data.code_type}"
log.info(f"Redis key is {code_key}")
await RedisCURD(redis).set(
key=code_key,
value=verification_code,
expire=300 # 5分钟
)
# 设置发送频率限制1分钟
await RedisCURD(redis).set(
key=redis_key,
value="1",
expire=60 # 1分钟
)
log.info(f"短信验证码发送成功: {sms_data.mobile}, 类型: {sms_data.code_type}")
return True
@classmethod
async def mobile_register_service(
cls,
request: Request,
redis: Redis,
db: AsyncSession,
register_data: MobileRegisterSchema
) -> MobileLoginOutSchema:
"""手机号密码注册服务"""
auth = AuthSchema(db=db)
# 验证密码一致性
if register_data.password != register_data.repassword:
raise CustomException(msg="两次输入的密码不一致")
# 验证短信验证码
code_key = f"sms_code:{register_data.mobile}:register"
stored_code = await RedisCURD(redis).get(code_key)
if not stored_code or stored_code != register_data.verification_code:
raise CustomException(msg="验证码错误或已过期")
# 检查手机号是否已存在
existing_user = await UserCRUD(auth).get_by_mobile_crud(mobile=register_data.mobile)
if existing_user:
raise CustomException(msg="该手机号已被注册")
# 生成用户名
username = f"mobile_{register_data.mobile[-8:]}_{get_random_character()[:4]}"
password_hash = PwdUtil.set_password_hash(register_data.password)
# 创建用户
user_data = {
"username": username,
"password": password_hash,
"name": username, # 使用用户名作为昵称
"mobile": register_data.mobile,
"inviter_id": register_data.inviter_id,
"status": "0"
}
new_user = await UserCRUD(auth).create(data=user_data)
# 删除已使用的验证码
await RedisCURD(redis).delete(code_key)
# 创建注册通知
try:
await NoticeCRUD(auth).create_crud(
data={
"notice_title": f"{username}注册",
"notice_type": "1",
"notice_content": f"{username}通过手机号注册了壹梵小程序",
"status": "0",
}
)
except Exception as e:
log.error(f"注册成功但写入通知失败: {e}")
# 更新最后登录时间
user = await UserCRUD(auth).get_by_id_crud(id=new_user.id)
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
# 创建token
token = await cls.create_token_service(
request=request,
redis=redis,
user=user,
login_type="mobile"
)
log.info(f"手机号用户 {username} 注册成功")
return MobileLoginOutSchema(
access_token=token.access_token,
refresh_token=token.refresh_token,
token_type=token.token_type,
expires_in=token.expires_in,
user_info={
"id": user.id,
"username": user.username,
"name": user.name,
"avatar": user.avatar,
"mobile": user.mobile
}
)
@classmethod
async def mobile_login_service(
cls,
request: Request,
redis: Redis,
db: AsyncSession,
login_data: MobileLoginSchema
) -> MobileLoginOutSchema:
"""手机号密码登录服务"""
auth = AuthSchema(db=db)
# 根据手机号查找用户
user = await UserCRUD(auth).get_by_mobile_crud(mobile=login_data.mobile)
if not user:
raise CustomException(msg="手机号未注册")
# 验证密码
if not PwdUtil.verify_password(plain_password=login_data.password, password_hash=user.password):
raise CustomException(msg="密码错误")
# 检查用户状态
if not user.status:
raise CustomException(msg="用户已被停用")
# 更新邀请人信息(如果需要)
if user.inviter_id is None and login_data.inviter_id is not None:
await UserCRUD(auth).update(id=user.id, data={"inviter_id": login_data.inviter_id})
user = await UserCRUD(auth).get_by_id_crud(id=user.id)
# 更新最后登录时间
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
# 创建token
token = await cls.create_token_service(
request=request,
redis=redis,
user=user,
login_type="mobile"
)
log.info(f"手机号用户 {user.username} 登录成功")
return MobileLoginOutSchema(
access_token=token.access_token,
refresh_token=token.refresh_token,
token_type=token.token_type,
expires_in=token.expires_in,
user_info={
"id": user.id,
"username": user.username,
"name": user.name,
"avatar": user.avatar,
"mobile": user.mobile
}
)
@classmethod
async def forgot_password_service(
cls,
redis: Redis,
db: AsyncSession,
forgot_data: ForgotPasswordSchema
) -> bool:
"""忘记密码服务"""
auth = AuthSchema(db=db)
# 验证密码一致性
if forgot_data.password != forgot_data.repassword:
raise CustomException(msg="两次输入的密码不一致")
# 验证短信验证码
code_key = f"sms_code:{forgot_data.mobile}:register"
stored_code = await RedisCURD(redis).get(code_key)
if not stored_code or stored_code != forgot_data.verification_code:
raise CustomException(msg="验证码错误或已过期")
# 检查手机号对应的用户是否存在
user = await UserCRUD(auth).get_by_mobile_crud(mobile=forgot_data.mobile)
if not user:
raise CustomException(msg="该手机号未注册")
# 检查用户状态
if not user.status:
raise CustomException(msg="用户已被停用")
# 更新密码
password_hash = PwdUtil.set_password_hash(forgot_data.password)
await UserCRUD(auth).update(id=user.id, data={"password": password_hash})
# 删除已使用的验证码
await RedisCURD(redis).delete(code_key)
# 创建密码重置通知
try:
await NoticeCRUD(auth).create_crud(
data={
"notice_title": f"{user.username}重置密码",
"notice_type": "1",
"notice_content": f"{user.username}通过手机号重置了登录密码",
"status": "0",
}
)
except Exception as e:
log.error(f"密码重置成功但写入通知失败: {e}")
log.info(f"用户 {user.username} 重置密码成功")
return True

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import db_getter, get_current_user, AuthPermission
from app.common.response import SuccessResponse, ErrorResponse
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import CreateOrderSchema, QueryOrderSchema, OrderInfoSchema
from .service import WxPayService
# 创建一个独立的路由分发器,专门管理 /yifan_wx_pay 前缀下的所有微信支付相关接口,
# 同时给这些接口统一打上「微信支付」的标签,方便接口文档自动分类。
router = APIRouter(prefix="/yifan_wx_pay", tags=["微信支付"])
@router.post("/create-order", summary="创建支付订单", dependencies=[Depends(get_current_user)])
async def create_order(
request: Request,
data: CreateOrderSchema,
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""
创建微信支付订单JSAPI/小程序支付)
支持两种模式:
1. 传入code参数通过code获取openid并存储到用户表
2. 不传code参数使用用户表已存储的openid
返回小程序调起支付所需的全部参数
"""
# 从JWT中获取用户信息
user = request.state.user
user_id = user.id
openid = user.wx_openid
log.info(f"用户ID is {user_id}")
# 如果传入了code参数通过code获取openid
if data.code:
openid = await WxPayService.get_openid_by_code(
db=db,
user_id=user_id,
code=data.code
)
elif not openid:
return ErrorResponse(msg="用户未绑定微信请传入code参数进行授权")
# 小程序内部浏览器 支付类型为jsapi。外部浏览器 支付类型则为h5。
# 根据支付类型调用不同方法
if data.pay_type == "h5":
result = await WxPayService.create_h5_order(
db=db,
user_id=user_id,
openid=openid,
data=data
)
else:
result = await WxPayService.create_jsapi_order(
db=db,
user_id=user_id,
openid=openid,
data=data
)
return SuccessResponse(data=result.model_dump())
@router.get("/list", summary="获取我的支付订单列表", dependencies=[Depends(get_current_user)])
async def list_orders(
request: Request,
page_no: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""分页获取当前用户的微信支付订单列表"""
user = request.state.user
result = await WxPayService.list_user_orders(db=db, user_id=user.id, page_no=page_no, page_size=page_size)
return SuccessResponse(data=result.model_dump())
@router.get("/admin/list", summary="管理员:获取支付订单列表")
async def admin_list_orders(
page_no: int = Query(1, description="页码"),
page_size: int = Query(10, description="每页数量"),
order_no: str | None = Query(None, description="订单号"),
out_trade_no: str | None = Query(None, description="商户订单号"),
transaction_id: str | None = Query(None, description="微信支付订单号"),
user_id: int | None = Query(None, description="用户ID"),
user_name: str | None = Query(None, description="用户昵称"),
user_mobile: str | None = Query(None, description="用户手机号"),
trade_state: str | None = Query(None, description="支付状态"),
business_type: str | None = Query(None, description="业务类型"),
db: AsyncSession = Depends(db_getter),
auth: AuthSchema = Depends(AuthPermission(["module_yifan:yifan_wx_pay_order:query"])),
) -> JSONResponse:
"""分页获取全部微信支付订单列表(管理员)"""
result = await WxPayService.list_admin_orders(
db=db,
page_no=page_no,
page_size=page_size,
order_no=order_no,
out_trade_no=out_trade_no,
transaction_id=transaction_id,
user_id=user_id,
user_name=user_name,
user_mobile=user_mobile,
trade_state=trade_state,
business_type=business_type,
)
return SuccessResponse(data=result.model_dump())
@router.get("/query-order/{out_trade_no}", summary="查询订单状态", dependencies=[Depends(get_current_user)])
async def query_order(
out_trade_no: str,
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""查询订单支付状态"""
result = await WxPayService.query_order(db=db, out_trade_no=out_trade_no)
return SuccessResponse(data=result.model_dump())
@router.post("/close-order/{out_trade_no}", summary="关闭订单", dependencies=[Depends(get_current_user)])
async def close_order(
out_trade_no: str,
db: AsyncSession = Depends(db_getter)
) -> JSONResponse:
"""关闭未支付的订单"""
await WxPayService.close_order(db=db, out_trade_no=out_trade_no)
return SuccessResponse(msg="订单已关闭")
@router.post("/notify", summary="支付回调通知")
async def pay_notify(
request: Request,
db: AsyncSession = Depends(db_getter)
):
"""
微信支付回调通知接口
注意此接口不需要JWT认证由微信服务器调用
"""
try:
body = await request.json()
log.info(f"收到支付回调: {body}")
success = await WxPayService.handle_pay_notify(db=db, notify_data=body)
if success:
return {"code": "SUCCESS", "message": "成功"}
else:
return {"code": "FAIL", "message": "处理失败"}
except Exception as e:
log.error(f"支付回调处理异常: {e}")
return {"code": "FAIL", "message": str(e)}

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
from typing import Optional
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from .model import WxPayOrderModel
from .schema import CreateOrderSchema
class WxPayOrderCRUD:
"""微信支付订单CRUD"""
def __init__(self, db: AsyncSession):
self.db = db
async def create_order(
self,
out_trade_no: str,
user_id: int,
openid: str,
data: CreateOrderSchema,
total_amount_fen: int = None
) -> WxPayOrderModel:
"""创建订单"""
# 如果传入了分单位金额,使用分单位;否则使用元单位
amount = total_amount_fen if total_amount_fen is not None else data.total_amount
order = WxPayOrderModel(
order_no=out_trade_no,
out_trade_no=out_trade_no,
user_id=user_id,
openid=openid,
description=data.description,
total_amount=amount,
business_type=data.business_type,
business_id=data.business_id,
trade_state="NOTPAY"
)
self.db.add(order)
await self.db.flush()
await self.db.refresh(order)
return order
async def get_by_out_trade_no(self, out_trade_no: str) -> Optional[WxPayOrderModel]:
"""根据商户订单号查询"""
result = await self.db.execute(
select(WxPayOrderModel).where(WxPayOrderModel.out_trade_no == out_trade_no)
)
return result.scalar_one_or_none()
async def update_order_status(
self,
out_trade_no: str,
transaction_id: str,
trade_state: str,
trade_state_desc: str = None,
success_time: datetime = None
) -> Optional[WxPayOrderModel]:
"""更新订单状态"""
await self.db.execute(
update(WxPayOrderModel)
.where(WxPayOrderModel.out_trade_no == out_trade_no)
.values(
transaction_id=transaction_id,
trade_state=trade_state,
trade_state_desc=trade_state_desc,
success_time=success_time
)
)
await self.db.flush()
return await self.get_by_out_trade_no(out_trade_no)
async def get_user_orders(self, user_id: int, limit: int = 20) -> list[WxPayOrderModel]:
"""获取用户订单列表"""
result = await self.db.execute(
select(WxPayOrderModel)
.where(WxPayOrderModel.user_id == user_id)
.order_by(WxPayOrderModel.created_time.desc())
.limit(limit)
)
return result.scalars().all()
async def page_user_orders(self, user_id: int, offset: int, limit: int) -> tuple[int, list[WxPayOrderModel]]:
"""分页获取用户订单列表(返回 total, items"""
total_stmt = select(func.count()).select_from(WxPayOrderModel).where(WxPayOrderModel.user_id == user_id)
total_result = await self.db.execute(total_stmt)
total = int(total_result.scalar() or 0)
items_stmt = (
select(WxPayOrderModel)
.where(WxPayOrderModel.user_id == user_id)
.order_by(WxPayOrderModel.created_time.desc())
.offset(offset)
.limit(limit)
)
items_result = await self.db.execute(items_stmt)
return total, items_result.scalars().all()
async def page_orders(
self,
offset: int,
limit: int,
order_no: str | None = None,
out_trade_no: str | None = None,
transaction_id: str | None = None,
user_id: int | None = None,
user_ids: list[int] | None = None,
trade_state: str | None = None,
business_type: str | None = None,
) -> tuple[int, list[WxPayOrderModel]]:
"""管理员:分页获取订单列表(返回 total, items"""
where_clauses = []
if order_no:
where_clauses.append(WxPayOrderModel.order_no == order_no)
if out_trade_no:
where_clauses.append(WxPayOrderModel.out_trade_no == out_trade_no)
if transaction_id:
where_clauses.append(WxPayOrderModel.transaction_id == transaction_id)
if user_id is not None:
where_clauses.append(WxPayOrderModel.user_id == user_id)
if user_ids is not None:
if len(user_ids) == 0:
return 0, []
where_clauses.append(WxPayOrderModel.user_id.in_(user_ids))
if trade_state:
where_clauses.append(WxPayOrderModel.trade_state == trade_state)
if business_type:
where_clauses.append(WxPayOrderModel.business_type == business_type)
total_stmt = select(func.count()).select_from(WxPayOrderModel)
if where_clauses:
total_stmt = total_stmt.where(*where_clauses)
total_result = await self.db.execute(total_stmt)
total = int(total_result.scalar() or 0)
items_stmt = select(WxPayOrderModel)
if where_clauses:
items_stmt = items_stmt.where(*where_clauses)
items_stmt = (
items_stmt.order_by(WxPayOrderModel.created_time.desc())
.offset(offset)
.limit(limit)
)
items_result = await self.db.execute(items_stmt)
return total, items_result.scalars().all()

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Integer, DateTime, Numeric, Text, DECIMAL
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin
class WxPayOrderModel(ModelMixin):
"""微信支付订单表"""
__tablename__ = "yifan_wx_pay_order"
order_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True, comment="订单号")
out_trade_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True, comment="商户订单号")
transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="微信支付订单号")
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="用户ID")
openid: Mapped[str] = mapped_column(String(128), nullable=False, comment="用户openid")
# 订单信息
description: Mapped[str] = mapped_column(String(128), nullable=False, comment="商品描述")
total_amount: Mapped[Decimal] = mapped_column(DECIMAL(10, 2), nullable=False, comment="订单金额(元)")
pay_amount: Mapped[Decimal | None] = mapped_column(DECIMAL(10, 2), nullable=True, comment="实付金额(元)")
refund_amount: Mapped[Decimal | None] = mapped_column(DECIMAL(10, 2), nullable=True, comment="退款金额(元)")
# 业务关联
business_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务类型: naming_report等")
business_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="业务ID")
# 支付状态
trade_state: Mapped[str] = mapped_column(String(32), default="NOTPAY", comment="交易状态: NOTPAY/SUCCESS/CLOSED/REFUND")
trade_state_desc: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="交易状态描述")
# 时间
success_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="支付成功时间")

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
from decimal import Decimal
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
class CreateOrderSchema(BaseModel):
"""创建订单请求"""
description: str = Field(..., max_length=128, description="商品描述")
total_amount: Decimal = Field(..., gt=0, decimal_places=2, description="订单金额(元)")
business_type: str = Field(..., max_length=32, description="业务类型: company_naming-企业起名, company_renaming-企业改名, company_name_analysis-企业测名, personal_naming-个人起名, personal_renaming-个人改名, personal_name_analysis-个人测名")
business_id: Optional[int] = Field(None, description="业务ID")
code: Optional[str] = Field(None, description="微信授权code(可选用于获取openid)")
pay_type: str = Field(default="h5", description="支付类型: h5-H5支付, jsapi-小程序支付")
class CreateOrderOutSchema(BaseModel):
"""创建订单响应"""
out_trade_no: str = Field(..., description="商户订单号")
prepay_id: str = Field(..., description="预支付交易会话标识")
pay_type: str = Field(..., description="支付类型")
# H5支付参数
h5_url: Optional[str] = Field(None, description="H5支付链接")
# 小程序调起支付所需参数
appId: Optional[str] = Field(None, description="小程序appId")
timeStamp: Optional[str] = Field(None, description="时间戳")
nonceStr: Optional[str] = Field(None, description="随机字符串")
package: Optional[str] = Field(None, description="订单详情扩展字符串")
signType: Optional[str] = Field(None, description="签名方式")
paySign: Optional[str] = Field(None, description="签名")
class QueryOrderSchema(BaseModel):
"""查询订单请求"""
out_trade_no: str = Field(..., description="商户订单号")
class OrderInfoSchema(BaseModel):
"""订单信息"""
id: int
uuid: Optional[str] = None
status: Optional[str | int] = None
created_time: Optional[datetime] = None
updated_time: Optional[datetime] = None
order_no: Optional[str] = None
out_trade_no: str
transaction_id: Optional[str]
user_id: Optional[int] = None
user_name: Optional[str] = Field(default=None, description="用户昵称")
user_mobile: Optional[str] = Field(default=None, description="用户手机号")
inviter_name: Optional[str] = Field(default=None, description="邀请人昵称")
inviter_mobile: Optional[str] = Field(default=None, description="邀请人手机号")
openid: Optional[str] = None
description: str
total_amount: Decimal
pay_amount: Optional[Decimal] = None
refund_amount: Optional[Decimal] = None
business_type: str
business_id: Optional[int]
trade_state: str
trade_state_desc: Optional[str]
success_time: Optional[datetime]
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
class OrderListSchema(BaseModel):
total: int = Field(..., description="总数")
items: list[OrderInfoSchema] = Field(default=[], description="订单列表")
class WxPayNotifySchema(BaseModel):
"""微信支付回调通知"""
id: str
create_time: str
resource_type: str
event_type: str
summary: str
resource: Dict[str, Any]

View File

@@ -0,0 +1,628 @@
# -*- coding: utf-8 -*-
import json
import time
import uuid
import base64
import hashlib
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any
from datetime import datetime
from pathlib import Path
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import log
from .crud import WxPayOrderCRUD
from .schema import CreateOrderSchema, CreateOrderOutSchema, OrderInfoSchema, OrderListSchema
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.auth.schema import AuthSchema
class WxPayService:
"""微信支付服务"""
# 微信支付API地址
WX_PAY_BASE_URL = "https://api.mch.weixin.qq.com"
@classmethod
def _generate_out_trade_no(cls) -> str:
"""生成商户订单号"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
random_str = uuid.uuid4().hex[:8].upper()
return f"{timestamp}{random_str}"
@classmethod
def _generate_nonce_str(cls) -> str:
"""生成随机字符串"""
return uuid.uuid4().hex
@classmethod
async def get_openid_by_code(cls, db: AsyncSession, user_id: int, code: str) -> str:
"""通过code获取openid并存储到用户表"""
# 调用微信API获取openid
url = "https://api.weixin.qq.com/sns/oauth2/access_token"
params = {
"appid": settings.WX_MINI_APP_ID,
"secret": settings.WX_MINI_APP_SECRET,
"code": code,
"grant_type": "authorization_code"
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params)
data = response.json()
if "errcode" in data and data["errcode"] != 0:
log.error(f"微信授权失败: {data}")
raise CustomException(msg=f"微信授权失败: {data.get('errmsg', '未知错误')}")
openid = data.get("openid")
if not openid:
raise CustomException(msg="获取openid失败")
# 更新用户表的wx_openid字段
auth = AuthSchema(db=db)
await UserCRUD(auth).bind_wx_openid_crud(id=user_id, wx_openid=openid)
log.info(f"用户{user_id}获取并存储openid成功: {openid}")
return openid
@classmethod
def _get_private_key(cls):
"""获取商户私钥"""
key_path = Path(settings.WX_PAY_PRIVATE_KEY_PATH)
if not key_path.exists():
raise CustomException(msg="商户私钥文件不存在")
with open(key_path, "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
return private_key
@classmethod
def _sign(cls, message: str) -> str:
"""RSA签名"""
private_key = cls._get_private_key()
signature = private_key.sign(
message.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256()
)
return base64.b64encode(signature).decode("utf-8")
@classmethod
def _build_authorization(cls, method: str, url_path: str, body: str = "") -> str:
"""构建Authorization头"""
mchid = str(settings.WX_PAY_MCH_ID)
serial_no = str(settings.WX_PAY_CERT_SERIAL_NO)
if not mchid.isascii():
raise CustomException(msg="微信支付配置错误: WX_PAY_MCH_ID 必须为ASCII字符")
if not serial_no.isascii():
raise CustomException(msg="微信支付配置错误: WX_PAY_CERT_SERIAL_NO 必须为ASCII字符")
timestamp = str(int(time.time()))
nonce_str = cls._generate_nonce_str()
message = f"{method}\n{url_path}\n{timestamp}\n{nonce_str}\n{body}\n"
signature = cls._sign(message)
return (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{mchid}",'
f'nonce_str="{nonce_str}",'
f'signature="{signature}",'
f'timestamp="{timestamp}",'
f'serial_no="{serial_no}"'
)
@classmethod
async def create_jsapi_order(
cls,
db: AsyncSession,
user_id: int,
openid: str,
data: CreateOrderSchema
) -> CreateOrderOutSchema:
'''这是多行注释'''
"""创建JSAPI支付订单小程序支付"""
out_trade_no = cls._generate_out_trade_no()
# 调用微信支付API
url_path = "/v3/pay/transactions/jsapi"
log.info(f"支付价格为:{data.total_amount}")
# 微信支付金额单位是分,需要将元转换为分
total_amount_fen = int((data.total_amount * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
log.info(f"支付价格转换为分后为:{total_amount_fen}")
request_body = {
"appid": settings.WX_MINI_APP_ID,
"mchid": settings.WX_PAY_MCH_ID,
"description": data.description,
"out_trade_no": out_trade_no,
"notify_url": settings.WX_PAY_NOTIFY_URL,
"amount": {
"total": total_amount_fen,
"currency": "CNY"
},
"payer": {
"openid": openid
}
}
body_str = json.dumps(request_body, ensure_ascii=False)
authorization = cls._build_authorization("POST", url_path, body_str)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{cls.WX_PAY_BASE_URL}{url_path}",
content=body_str,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": authorization
}
)
if response.status_code != 200:
log.error(f"微信支付下单失败: {response.text}")
raise CustomException(msg=f"微信支付下单失败: {response.text}")
result = response.json()
prepay_id = result.get("prepay_id")
# 微信下单成功后再保存订单到数据库方案A失败不落库
crud = WxPayOrderCRUD(db)
await crud.create_order(
out_trade_no=out_trade_no,
user_id=user_id,
openid=openid,
data=data,
total_amount_fen=total_amount_fen
)
# 生成小程序调起支付的参数
timestamp = str(int(time.time()))
nonce_str = cls._generate_nonce_str()
package = f"prepay_id={prepay_id}"
# 签名
sign_message = f"{settings.WX_MINI_APP_ID}\n{timestamp}\n{nonce_str}\n{package}\n"
pay_sign = cls._sign(sign_message)
log.info(f"创建支付订单成功: {out_trade_no}")
return CreateOrderOutSchema(
out_trade_no=out_trade_no,
prepay_id=prepay_id,
pay_type="jsapi",
appId=settings.WX_MINI_APP_ID,
timeStamp=timestamp,
nonceStr=nonce_str,
package=package,
signType="RSA",
paySign=pay_sign
)
@classmethod
async def create_h5_order(
cls,
db: AsyncSession,
user_id: int,
openid: str,
data: CreateOrderSchema
) -> CreateOrderOutSchema:
"""创建H5支付订单"""
out_trade_no = cls._generate_out_trade_no()
# 调用微信支付API
url_path = "/v3/pay/transactions/h5"
# 微信支付金额单位是分,需要将元转换为分
total_amount_fen = int((data.total_amount * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
request_body = {
"appid": settings.WX_MINI_APP_ID,
"mchid": settings.WX_PAY_MCH_ID,
"description": data.description,
"out_trade_no": out_trade_no,
"notify_url": settings.WX_PAY_NOTIFY_URL,
"amount": {
"total": total_amount_fen,
"currency": "CNY"
},
"scene_info": {
"payer_client_ip": "127.0.0.1",
"h5_info": {
"type": "Wap"
}
}
}
body_str = json.dumps(request_body, ensure_ascii=False)
authorization = cls._build_authorization("POST", url_path, body_str)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{cls.WX_PAY_BASE_URL}{url_path}",
content=body_str,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": authorization
}
)
if response.status_code != 200:
log.error(f"微信H5支付下单失败: {response.text}")
raise CustomException(msg=f"微信H5支付下单失败: {response.text}")
result = response.json()
h5_url = result.get("h5_url")
# 微信下单成功后再保存订单到数据库
crud = WxPayOrderCRUD(db)
await crud.create_order(
out_trade_no=out_trade_no,
user_id=user_id,
openid=openid,
data=data,
total_amount_fen=total_amount_fen
)
log.info(f"创建H5支付订单成功: {out_trade_no}")
return CreateOrderOutSchema(
out_trade_no=out_trade_no,
prepay_id=result.get("prepay_id", ""),
pay_type="h5",
h5_url=h5_url
)
@classmethod
async def list_user_orders(cls, db: AsyncSession, user_id: int, page_no: int = 1, page_size: int = 10) -> OrderListSchema:
"""分页获取当前用户订单列表"""
if page_no < 1:
page_no = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
offset = (page_no - 1) * page_size
crud = WxPayOrderCRUD(db)
total, items = await crud.page_user_orders(user_id=user_id, offset=offset, limit=page_size)
# 补充用户昵称、邀请人昵称、邀请人手机号
try:
from sqlalchemy import select
from app.api.v1.module_system.user.model import UserModel
user_ids = {o.user_id for o in items if getattr(o, "user_id", None) is not None}
user_map: dict[int, UserModel] = {}
inviter_map: dict[int, UserModel] = {}
if user_ids:
users_result = await db.execute(select(UserModel).where(UserModel.id.in_(list(user_ids))))
users = users_result.scalars().all()
user_map = {u.id: u for u in users if u and u.id is not None}
inviter_ids = {u.inviter_id for u in users if getattr(u, "inviter_id", None)}
if inviter_ids:
inviters_result = await db.execute(select(UserModel).where(UserModel.id.in_(list(inviter_ids))))
inviters = inviters_result.scalars().all()
inviter_map = {u.id: u for u in inviters if u and u.id is not None}
except Exception as e:
log.error(f"[微信支付] 补充用户/邀请人信息失败: {e}")
user_map = {}
inviter_map = {}
enriched_items: list[OrderInfoSchema] = []
for o in items:
u = user_map.get(getattr(o, "user_id", None))
inviter = inviter_map.get(getattr(u, "inviter_id", None)) if u else None
enriched_items.append(
OrderInfoSchema(
id=o.id,
uuid=getattr(o, "uuid", None),
status=getattr(o, "status", None),
created_time=getattr(o, "created_time", None),
updated_time=getattr(o, "updated_time", None),
order_no=getattr(o, "order_no", None),
out_trade_no=o.out_trade_no,
transaction_id=o.transaction_id,
user_id=getattr(o, "user_id", None),
user_name=u.name if u else None,
user_mobile=u.mobile if u else None,
inviter_name=inviter.name if inviter else None,
inviter_mobile=inviter.mobile if inviter else None,
openid=getattr(o, "openid", None),
description=o.description,
total_amount=o.total_amount,
pay_amount=o.pay_amount,
refund_amount=o.refund_amount,
business_type=o.business_type,
business_id=o.business_id,
trade_state=o.trade_state,
trade_state_desc=o.trade_state_desc,
success_time=o.success_time,
)
)
return OrderListSchema(total=total, items=enriched_items)
@classmethod
async def list_admin_orders(
cls,
db: AsyncSession,
page_no: int = 1,
page_size: int = 10,
order_no: str | None = None,
out_trade_no: str | None = None,
transaction_id: str | None = None,
user_id: int | None = None,
user_name: str | None = None,
user_mobile: str | None = None,
trade_state: str | None = None,
business_type: str | None = None,
) -> OrderListSchema:
"""管理员:分页获取全部订单列表"""
if page_no < 1:
page_no = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
# 如果按用户昵称/手机号筛选,先解析出 user_ids
user_ids_filter: list[int] | None = None
if user_name or user_mobile:
try:
from sqlalchemy import select
from app.api.v1.module_system.user.model import UserModel
user_where = []
if user_name:
user_where.append(UserModel.name.like(f"%{user_name}%"))
if user_mobile:
user_where.append(UserModel.mobile.like(f"%{user_mobile}%"))
users_result = await db.execute(select(UserModel.id).where(*user_where))
user_ids = [int(x) for x in users_result.scalars().all()]
user_ids_filter = user_ids
except Exception as e:
log.error(f"[微信支付][admin] 解析用户筛选条件失败: {e}")
user_ids_filter = []
if not user_ids_filter:
return OrderListSchema(total=0, items=[])
offset = (page_no - 1) * page_size
crud = WxPayOrderCRUD(db)
total, items = await crud.page_orders(
offset=offset,
limit=page_size,
order_no=order_no,
out_trade_no=out_trade_no,
transaction_id=transaction_id,
user_id=user_id,
user_ids=user_ids_filter,
trade_state=trade_state,
business_type=business_type,
)
# 补充用户昵称、邀请人昵称、邀请人手机号
try:
from sqlalchemy import select
from app.api.v1.module_system.user.model import UserModel
user_ids = {o.user_id for o in items if getattr(o, "user_id", None) is not None}
user_map: dict[int, UserModel] = {}
inviter_map: dict[int, UserModel] = {}
if user_ids:
users_result = await db.execute(select(UserModel).where(UserModel.id.in_(list(user_ids))))
users = users_result.scalars().all()
user_map = {u.id: u for u in users if u and u.id is not None}
inviter_ids = {u.inviter_id for u in users if getattr(u, "inviter_id", None)}
if inviter_ids:
inviters_result = await db.execute(select(UserModel).where(UserModel.id.in_(list(inviter_ids))))
inviters = inviters_result.scalars().all()
inviter_map = {u.id: u for u in inviters if u and u.id is not None}
except Exception as e:
log.error(f"[微信支付][admin] 补充用户/邀请人信息失败: {e}")
user_map = {}
inviter_map = {}
enriched_items: list[OrderInfoSchema] = []
for o in items:
u = user_map.get(getattr(o, "user_id", None))
inviter = inviter_map.get(getattr(u, "inviter_id", None)) if u else None
enriched_items.append(
OrderInfoSchema(
id=o.id,
uuid=getattr(o, "uuid", None),
status=getattr(o, "status", None),
created_time=getattr(o, "created_time", None),
updated_time=getattr(o, "updated_time", None),
order_no=getattr(o, "order_no", None),
out_trade_no=o.out_trade_no,
transaction_id=o.transaction_id,
user_id=getattr(o, "user_id", None),
user_name=u.name if u else None,
user_mobile=u.mobile if u else None,
inviter_name=inviter.name if inviter else None,
inviter_mobile=inviter.mobile if inviter else None,
openid=getattr(o, "openid", None),
description=o.description,
total_amount=o.total_amount,
pay_amount=o.pay_amount,
refund_amount=o.refund_amount,
business_type=o.business_type,
business_id=o.business_id,
trade_state=o.trade_state,
trade_state_desc=o.trade_state_desc,
success_time=o.success_time,
)
)
return OrderListSchema(total=total, items=enriched_items)
@classmethod
async def query_order(cls, db: AsyncSession, out_trade_no: str) -> OrderInfoSchema:
"""查询订单状态"""
crud = WxPayOrderCRUD(db)
order = await crud.get_by_out_trade_no(out_trade_no)
if not order:
raise CustomException(msg="订单不存在")
# 如果订单未支付,同步查询微信支付状态
if order.trade_state == "NOTPAY":
url_path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={settings.WX_PAY_MCH_ID}"
authorization = cls._build_authorization("GET", url_path)
async with httpx.AsyncClient() as client:
response = await client.get(
f"{cls.WX_PAY_BASE_URL}{url_path}",
headers={
"Accept": "application/json",
"Authorization": authorization
}
)
if response.status_code == 200:
result = response.json()
trade_state = result.get("trade_state")
if trade_state != "NOTPAY":
success_time = None
if result.get("success_time"):
success_time = datetime.fromisoformat(result["success_time"].replace("Z", "+00:00"))
order = await crud.update_order_status(
out_trade_no=out_trade_no,
transaction_id=result.get("transaction_id", ""),
trade_state=trade_state,
trade_state_desc=result.get("trade_state_desc"),
success_time=success_time
)
return OrderInfoSchema.model_validate(order)
@classmethod
def decrypt_notify_resource(cls, resource: Dict[str, Any]) -> Dict[str, Any]:
"""解密回调通知数据"""
algorithm = resource.get("algorithm")
if algorithm != "AEAD_AES_256_GCM":
raise CustomException(msg=f"不支持的加密算法: {algorithm}")
nonce = resource.get("nonce")
ciphertext = resource.get("ciphertext")
associated_data = resource.get("associated_data", "")
key = settings.WX_PAY_API_V3_KEY.encode("utf-8")
nonce_bytes = nonce.encode("utf-8")
ciphertext_bytes = base64.b64decode(ciphertext)
associated_data_bytes = associated_data.encode("utf-8")
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes)
return json.loads(plaintext.decode("utf-8"))
@classmethod
async def handle_pay_notify(cls, db: AsyncSession, notify_data: Dict[str, Any]) -> bool:
"""处理支付回调通知"""
event_type = notify_data.get("event_type")
if event_type != "TRANSACTION.SUCCESS":
log.warning(f"收到非成功支付通知: {event_type}")
return True
# 解密数据
resource = notify_data.get("resource", {})
decrypted_data = cls.decrypt_notify_resource(resource)
out_trade_no = decrypted_data.get("out_trade_no")
transaction_id = decrypted_data.get("transaction_id")
trade_state = decrypted_data.get("trade_state")
success_time = None
if decrypted_data.get("success_time"):
success_time = datetime.fromisoformat(decrypted_data["success_time"].replace("Z", "+00:00"))
# 更新订单状态
crud = WxPayOrderCRUD(db)
order = await crud.get_by_out_trade_no(out_trade_no)
if not order:
log.error(f"回调订单不存在: {out_trade_no}")
return False
if order.trade_state == "SUCCESS":
log.info(f"订单已处理过: {out_trade_no}")
return True
await crud.update_order_status(
out_trade_no=out_trade_no,
transaction_id=transaction_id,
trade_state=trade_state,
trade_state_desc=decrypted_data.get("trade_state_desc"),
success_time=success_time
)
log.info(f"支付回调处理成功: {out_trade_no}")
# TODO: 这里可以添加业务处理逻辑,比如更新命名报告状态等
# business_type = order.business_type
# business_id = order.business_id
return True
@classmethod
async def close_order(cls, db: AsyncSession, out_trade_no: str) -> bool:
"""关闭订单"""
crud = WxPayOrderCRUD(db)
order = await crud.get_by_out_trade_no(out_trade_no)
if not order:
raise CustomException(msg="订单不存在")
if order.trade_state != "NOTPAY":
raise CustomException(msg="只能关闭未支付的订单")
url_path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}/close"
request_body = {"mchid": settings.WX_PAY_MCH_ID}
body_str = json.dumps(request_body)
authorization = cls._build_authorization("POST", url_path, body_str)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{cls.WX_PAY_BASE_URL}{url_path}",
content=body_str,
headers={
"Content-Type": "application/json",
"Authorization": authorization
}
)
if response.status_code != 204:
log.error(f"关闭订单失败: {response.text}")
raise CustomException(msg="关闭订单失败")
await crud.update_order_status(
out_trade_no=out_trade_no,
transaction_id="",
trade_state="CLOSED",
trade_state_desc="商户主动关闭"
)
log.info(f"订单已关闭: {out_trade_no}")
return True