"""系统配置 API""" import re from typing import Optional, List from fastapi import APIRouter, Body, Depends from sqlalchemy.orm import Session from pydantic import BaseModel, Field, ConfigDict from app.core.database import get_db from app.core.auth import get_current_user from app.core.response import success_response, error_response from app.core.utils import generate_id from app.models.models import BizSystemConfig from app.crud.base import CRUDBase, get_model_attr router = APIRouter(prefix="/api/web/admin/system-config", tags=["系统配置"]) def _resolve_payload_with_crypto_fallback(payload: dict, current_user: dict): """兼容明文/密文请求,返回业务 payload。失败时返回 error_response。""" req_dict = payload if isinstance(payload, dict) else {} if isinstance(req_dict, dict) and req_dict.get("enc") == 1 and req_dict.get("edata"): try: from app.core import api_crypto from app.core.config import settings ts = req_dict.get("ts") nonce = req_dict.get("nonce") edata = req_dict.get("edata") operator_user_id = current_user.get("user_id") if isinstance(current_user, dict) else None fixed_secret = getattr(settings, "API_ENCRYPT_FIXED_SECRET", "") or "" if not (fixed_secret and operator_user_id and ts is not None and nonce): return {}, error_response(400, "密文解析配置缺失") key = api_crypto.derive_key( fixed_secret=fixed_secret, user_id=str(operator_user_id), ts=int(ts), nonce=str(nonce), ) plain = api_crypto.decrypt_json(str(edata), key) data = plain.get("data", plain) if isinstance(plain, dict) else {} return (data if isinstance(data, dict) else {}), None except Exception as exc: return {}, error_response(400, f"密文解密失败: {exc}") data = req_dict.get("data", req_dict) if isinstance(req_dict, dict) else {} return (data if isinstance(data, dict) else {}), None # 运营端「系统设置」仅存以下键(其余配置走 biz_system_config 但不在该界面维护) _SETTING_KEY_META = { "auto_cancel_minutes": { "response_key": "autoCancelMinutes", "default": 30, "min": 5, "max": 1440, "config_type": "number", "config_name": "订单自动取消时间", "config_group": "order", "description": "超时未支付自动取消订单(分钟)", }, "default_region_expected_delivery_minutes": { "response_key": "defaultRegionExpectedDeliveryMinutes", "default": 600, "min": 1, "max": 10080, "config_type": "number", "config_name": "区域未配置时的默认预计配送时长", "config_group": "order", "description": "分配配送员时,若区域未设置预计配送(分钟),则配送截止时间=当前时间+本值(分钟)", }, "delivery_timeout_warn_lead_minutes": { "response_key": "deliveryTimeoutWarnLeadMinutes", "default": 30, "min": 1, "max": 1440, "config_type": "number", "config_name": "配送超时预警提前时间", "config_group": "order", "description": "距离配送截止时间不足该分钟数时触发预警提示", }, "auto_confirm_receive_days": { "response_key": "autoConfirmReceiveDays", "default": 7, "min": 1, "max": 365, "config_type": "number", "config_name": "自动确认收货期限", "config_group": "order", "description": "商品送达后,客户超过该天数未确认收货则系统自动确认(天)", }, "customer_service_phone": { "response_key": "servicePhone", "default": "", "max_len": 32, "config_type": "string", "config_name": "客服电话", "config_group": "contact", "description": "小程序一键拨号展示的客服电话", }, } # 运营端「系统设置」页涉及的配置键:首次读取/定时任务读取前若缺失则插入默认值,保证均以 biz_system_config 为准 _WEB_SETTINGS_KEYS = ( "auto_cancel_minutes", "default_region_expected_delivery_minutes", "delivery_timeout_warn_lead_minutes", "auto_confirm_receive_days", "customer_service_phone", ) def ensure_web_system_settings_rows(db: Session, *, commit: bool = True) -> None: """若缺失则插入默认行(与 _SETTING_KEY_META 一致),便于统一从数据库读取。""" seeded = False for config_key in _WEB_SETTINGS_KEYS: if system_config_crud.get_by_key(db, config_key): continue meta = _SETTING_KEY_META[config_key] if meta["config_type"] == "string": val = "" if meta.get("default") in (None, "") else str(meta["default"]) else: val = str(int(meta["default"])) system_config_crud.create_or_update( db, config_key, val, config_type=meta["config_type"], config_name=meta["config_name"], config_group=meta["config_group"], description=meta["description"], create_by="system", create_name="系统", commit=False, ) seeded = True if seeded and commit: db.commit() def _parse_int_setting(raw: Optional[str], default: int) -> int: if raw is None or str(raw).strip() == "": return default try: return int(float(raw)) except (TypeError, ValueError): return default class OrderSettingsUpdate(BaseModel): model_config = ConfigDict(populate_by_name=True) auto_cancel_minutes: Optional[int] = Field(None, alias="autoCancelMinutes", ge=5, le=1440) default_region_expected_delivery_minutes: Optional[int] = Field( None, alias="defaultRegionExpectedDeliveryMinutes", ge=1, le=10080 ) delivery_timeout_warn_lead_minutes: Optional[int] = Field( None, alias="deliveryTimeoutWarnLeadMinutes", ge=1, le=1440 ) auto_confirm_receive_days: Optional[int] = Field( None, alias="autoConfirmReceiveDays", ge=1, le=365 ) class ContactSettingsUpdate(BaseModel): model_config = ConfigDict(populate_by_name=True) service_phone: Optional[str] = Field(None, alias="servicePhone", max_length=32) class SystemSettingsUpdateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) order: Optional[OrderSettingsUpdate] = None contact: Optional[ContactSettingsUpdate] = None class SystemConfigCRUD(CRUDBase[BizSystemConfig]): """系统配置CRUD""" def __init__(self): super().__init__(BizSystemConfig) def get_by_key(self, db: Session, config_key: str) -> Optional[BizSystemConfig]: """根据配置键获取配置""" return db.query(self.model).filter( self.model.config_key == config_key, self.model.is_del == 0 ).first() def create_or_update( self, db: Session, config_key: str, config_value: str, config_type: str = "string", config_name: str = None, config_group: str = None, description: str = None, is_active: int = 1, create_by: str = None, create_name: str = None, update_by: str = None, commit: bool = True, ) -> BizSystemConfig: """创建或更新配置""" existing = db.query(self.model).filter(self.model.config_key == config_key).first() if existing: existing.config_value = config_value existing.config_type = config_type if config_name: existing.config_name = config_name if config_group: existing.config_group = config_group if description: existing.description = description existing.is_active = is_active if hasattr(existing, "is_del"): existing.is_del = 0 if update_by is not None: existing.update_by = update_by if commit: db.commit() db.refresh(existing) else: db.flush() return existing else: data = { "id": generate_id(), "config_key": config_key, "config_value": config_value, "config_type": config_type, "config_name": config_name, "config_group": config_group, "description": description, "is_active": is_active, "create_by": create_by, "create_name": create_name } obj = self.model() for key, value in data.items(): attr_name = get_model_attr(obj, key) if attr_name: setattr(obj, attr_name, value) if not obj.id: obj.id = generate_id() db.add(obj) if commit: db.commit() db.refresh(obj) else: db.flush() return obj def batch_update_status( self, db: Session, ids: List[str], is_active: int ) -> int: """批量更新状态""" count = db.query(self.model).filter( self.model.id.in_(ids) ).update({"is_active": is_active}, synchronize_session=False) db.commit() return count system_config_crud = SystemConfigCRUD() def _clamp_to_meta(value: int, meta: dict) -> int: return max(meta["min"], min(meta["max"], value)) def _read_setting_int(db: Session, config_key: str) -> int: meta = _SETTING_KEY_META[config_key] row = system_config_crud.get_by_key(db, config_key) if not row: return meta["default"] parsed = _parse_int_setting(row.config_value, meta["default"]) return _clamp_to_meta(parsed, meta) def _read_setting_str(db: Session, config_key: str) -> str: meta = _SETTING_KEY_META[config_key] default = meta.get("default", "") max_len = int(meta.get("max_len", 255)) row = system_config_crud.get_by_key(db, config_key) if not row or row.config_value is None: s = "" if default is None else str(default) return s.strip()[:max_len] return str(row.config_value).strip()[:max_len] def _normalize_service_phone(value: Optional[str]) -> str: if value is None: return "" s = str(value).strip() if len(s) > 32: raise ValueError("客服电话长度不能超过32个字符") if not s: return "" if not re.fullmatch(r"[\d\-\+\s\(\)]+", s): raise ValueError("客服电话仅支持数字及 - + ( ) 与空格") return s def get_auto_cancel_minutes_for_job(db: Session) -> int: """供定时任务读取:线上单未支付自动取消的分钟数。""" ensure_web_system_settings_rows(db, commit=True) return _read_setting_int(db, "auto_cancel_minutes") def get_auto_confirm_receive_days_for_job(db: Session) -> int: """供定时任务读取:送达后自动确认收货的天数。""" ensure_web_system_settings_rows(db, commit=True) return _read_setting_int(db, "auto_confirm_receive_days") def get_default_region_expected_delivery_minutes(db: Session) -> int: """区域未配置预计配送时长时,分配配送员弹窗使用的默认分钟数。""" ensure_web_system_settings_rows(db, commit=True) return _read_setting_int(db, "default_region_expected_delivery_minutes") def read_customer_service_phone_for_mini(db: Session) -> str: """小程序端展示的客服电话(已清洗长度)。""" ensure_web_system_settings_rows(db, commit=True) return _read_setting_str(db, "customer_service_phone") @router.get("/settings", response_model=dict) def get_system_settings( db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """运营端系统设置 — 数据来自 biz_system_config(配送时效预警请在「区域」中配置)""" ensure_web_system_settings_rows(db, commit=True) order = { "autoCancelMinutes": _read_setting_int(db, "auto_cancel_minutes"), "defaultRegionExpectedDeliveryMinutes": _read_setting_int( db, "default_region_expected_delivery_minutes" ), "deliveryTimeoutWarnLeadMinutes": _read_setting_int( db, "delivery_timeout_warn_lead_minutes" ), "autoConfirmReceiveDays": _read_setting_int(db, "auto_confirm_receive_days"), } contact = { "servicePhone": _read_setting_str(db, "customer_service_phone"), } return success_response({ "order": order, "contact": contact, }) def _parse_drm_from_payload_order(order_raw: Optional[dict]) -> Optional[int]: """从原始 JSON 的 order 中解析 defaultRegionExpectedDeliveryMinutes(兼容解析异常)。""" if not isinstance(order_raw, dict): return None raw_v = order_raw.get("defaultRegionExpectedDeliveryMinutes") if raw_v is None: return None try: return int(float(raw_v)) except (TypeError, ValueError): return None @router.put("/settings", response_model=dict) def update_system_settings( payload: dict = Body(...), db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """更新运营端系统设置(按模块部分更新);三条配置同一事务提交,避免只落库两条。""" payload, req_err = _resolve_payload_with_crypto_fallback(payload, current_user) if req_err is not None: return req_err try: body = SystemSettingsUpdateBody.model_validate(payload) except Exception as e: return error_response(400, f"参数无效: {str(e)}") if body.order is None and body.contact is None: return error_response(400, "请至少选择一项设置内容") uid = current_user.get("user_id") uname = current_user.get("real_name") or current_user.get("username") or "" order_raw = payload.get("order") if isinstance(payload.get("order"), dict) else None try: ensure_web_system_settings_rows(db, commit=False) updated = False if body.order is not None and body.order.auto_cancel_minutes is not None: k = "auto_cancel_minutes" meta = _SETTING_KEY_META[k] v = _clamp_to_meta(body.order.auto_cancel_minutes, meta) system_config_crud.create_or_update( db, k, str(v), config_type=meta["config_type"], config_name=meta["config_name"], config_group=meta["config_group"], description=meta["description"], create_by=uid, create_name=uname, update_by=uid, commit=False, ) updated = True if body.order is not None and body.order.auto_confirm_receive_days is not None: k_acr = "auto_confirm_receive_days" meta_acr = _SETTING_KEY_META[k_acr] v_acr = _clamp_to_meta(body.order.auto_confirm_receive_days, meta_acr) system_config_crud.create_or_update( db, k_acr, str(v_acr), config_type=meta_acr["config_type"], config_name=meta_acr["config_name"], config_group=meta_acr["config_group"], description=meta_acr["description"], create_by=uid, create_name=uname, update_by=uid, commit=False, ) updated = True if body.order is not None and body.order.delivery_timeout_warn_lead_minutes is not None: k_warn = "delivery_timeout_warn_lead_minutes" meta_warn = _SETTING_KEY_META[k_warn] v_warn = _clamp_to_meta(body.order.delivery_timeout_warn_lead_minutes, meta_warn) system_config_crud.create_or_update( db, k_warn, str(v_warn), config_type=meta_warn["config_type"], config_name=meta_warn["config_name"], config_group=meta_warn["config_group"], description=meta_warn["description"], create_by=uid, create_name=uname, update_by=uid, commit=False, ) updated = True if body.order is not None: k_drm = "default_region_expected_delivery_minutes" meta_drm = _SETTING_KEY_META[k_drm] drm = body.order.default_region_expected_delivery_minutes if drm is None: drm = _parse_drm_from_payload_order(order_raw) if drm is None: drm = _read_setting_int(db, k_drm) v_drm = _clamp_to_meta(int(drm), meta_drm) system_config_crud.create_or_update( db, k_drm, str(v_drm), config_type=meta_drm["config_type"], config_name=meta_drm["config_name"], config_group=meta_drm["config_group"], description=meta_drm["description"], create_by=uid, create_name=uname, update_by=uid, commit=False, ) updated = True if body.contact is not None and body.contact.service_phone is not None: try: phone_norm = _normalize_service_phone(body.contact.service_phone) except ValueError as e: return error_response(400, str(e)) k = "customer_service_phone" meta = _SETTING_KEY_META[k] system_config_crud.create_or_update( db, k, phone_norm, config_type=meta["config_type"], config_name=meta["config_name"], config_group=meta["config_group"], description=meta["description"], create_by=uid, create_name=uname, update_by=uid, commit=False, ) updated = True if not updated: return error_response(400, "没有可保存的字段") db.commit() return success_response({"message": "保存成功"}) except Exception as e: db.rollback() return error_response(500, f"保存失败: {str(e)}") class SystemConfigCreateRequest(BaseModel): """创建系统配置请求""" config_key: str = Field(..., description="配置键") config_value: str = Field(..., description="配置值") config_type: str = Field("string", description="配置类型: string, number, boolean, json") config_name: str = Field(..., description="配置名称") config_group: str = Field(None, description="配置分组") description: str = Field(None, description="描述") is_active: int = Field(1, description="是否启用") @router.post("/create", response_model=dict) def create_config( request: SystemConfigCreateRequest, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """创建系统配置""" try: # 检查配置键是否已存在 existing = system_config_crud.get_by_key(db, request.config_key) if existing: return error_response(400, "配置键已存在") config = system_config_crud.create( db=db, data={ "id": generate_id(), "config_key": request.config_key, "config_value": request.config_value, "config_type": request.config_type, "config_name": request.config_name, "config_group": request.config_group, "description": request.description, "is_active": request.is_active, "create_by": current_user.get("user_id"), "create_name": current_user.get("real_name") } ) return success_response({ "id": config.id, "message": "配置创建成功" }) except Exception as e: return error_response(500, f"创建配置失败: {str(e)}") @router.post("/update/{config_id}", response_model=dict) def update_config( config_id: str, request: SystemConfigCreateRequest, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """更新系统配置""" try: config = system_config_crud.get(db, config_id) if not config: return error_response(404, "配置不存在") if config.is_del == 1: return error_response(404, "配置已删除") # 如果修改了配置键,检查是否冲突 if request.config_key != config.config_key: existing = system_config_crud.get_by_key(db, request.config_key) if existing: return error_response(400, "配置键已存在") config.config_key = request.config_key config.config_value = request.config_value config.config_type = request.config_type config.config_name = request.config_name config.config_group = request.config_group config.description = request.description config.is_active = request.is_active db.commit() db.refresh(config) return success_response({"message": "配置更新成功"}) except Exception as e: return error_response(500, f"更新配置失败: {str(e)}") @router.get("/list", response_model=dict) def get_config_list( page: int = 1, page_size: int = 20, config_name: str = None, config_group: str = None, is_active: int = None, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """获取系统配置列表""" try: query = db.query(system_config_crud.model).filter(system_config_crud.model.is_del == 0) if config_name: query = query.filter(system_config_crud.model.config_name.like(f"%{config_name}%")) if config_group: query = query.filter(system_config_crud.model.config_group == config_group) if is_active is not None: query = query.filter(system_config_crud.model.is_active == is_active) total = query.count() items = query.order_by(system_config_crud.model.config_group, system_config_crud.model.create_at.desc()).offset((page - 1) * page_size).limit(page_size).all() return success_response({ "list": [_to_response(item) for item in items], "total": total, "page": page, "page_size": page_size }) except Exception as e: return error_response(500, f"获取配置列表失败: {str(e)}") @router.get("/detail/{config_id}", response_model=dict) def get_config_detail( config_id: str, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """获取配置详情""" try: config = system_config_crud.get(db, config_id) if not config: return error_response(404, "配置不存在") if config.is_del == 1: return error_response(404, "配置已删除") return success_response(_to_response(config)) except Exception as e: return error_response(500, f"获取配置详情失败: {str(e)}") @router.get("/get/{config_key}", response_model=dict) def get_config_by_key( config_key: str, db: Session = Depends(get_db) ): """根据配置键获取配置值""" try: config = system_config_crud.get_by_key(db, config_key) if not config or config.is_del == 1: return error_response(404, "配置不存在") return success_response({ "key": config.config_key, "value": config.config_value, "type": config.config_type }) except Exception as e: return error_response(500, f"获取配置失败: {str(e)}") @router.post("/batch-status", response_model=dict) def batch_update_status( body: dict, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """批量更新配置状态""" try: ids = body.get("ids", []) is_active = body.get("is_active") if not isinstance(ids, list) or len(ids) == 0: return error_response(400, "ids不能为空") if is_active is None: return error_response(400, "is_active不能为空") count = system_config_crud.batch_update_status(db, ids, is_active) return success_response({"count": count, "message": "批量更新成功"}) except Exception as e: return error_response(500, f"批量更新失败: {str(e)}") @router.delete("/delete/{config_id}", response_model=dict) def delete_config( config_id: str, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """删除配置""" try: config = system_config_crud.get(db, config_id) if not config: return error_response(404, "配置不存在") config.is_del = 1 db.commit() return success_response({"message": "删除成功"}) except Exception as e: return error_response(500, f"删除配置失败: {str(e)}") def _to_response(config: BizSystemConfig) -> dict: """转换为响应格式""" return { "id": config.id, "config_key": config.config_key, "config_value": config.config_value, "config_type": config.config_type, "config_name": config.config_name, "config_group": config.config_group, "description": config.description, "is_active": config.is_active, "create_at": str(config.create_at) if config.create_at else None, "update_at": str(config.update_at) if config.update_at else None }