200702d066
Co-authored-by: Cursor <cursoragent@cursor.com>
607 lines
22 KiB
Python
607 lines
22 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Annotated, Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
|
||
from ..config import get_settings
|
||
from ..env_store import (
|
||
binance_keys_configured,
|
||
live_ready,
|
||
mask_secret,
|
||
okx_keys_configured,
|
||
upsert_env_keys,
|
||
)
|
||
from ..exchange.runtime import (
|
||
load_runtime_settings,
|
||
normalize_exchange_name,
|
||
persist_exchange_choice,
|
||
reload_market_session,
|
||
)
|
||
from ..models.db import get_db
|
||
from ..sim.ledger import Ledger
|
||
from ..sim.matcher import Matcher
|
||
from .auth import require_user
|
||
|
||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||
|
||
KEYS = (
|
||
"fee_rate",
|
||
"exit_move_pct",
|
||
"exit_mode",
|
||
"net_profit_target",
|
||
"premium_exit_multiple",
|
||
"rest_seconds",
|
||
"live_order_interval_sec",
|
||
"skip_weekends",
|
||
"initial_equity",
|
||
"leverage",
|
||
"perp_margin_mode",
|
||
"min_option_hours",
|
||
"min_option_leverage",
|
||
"atm_open_offset_enabled",
|
||
"max_atm_open_offset",
|
||
"fixed_direction_enabled",
|
||
"fixed_perp_side",
|
||
"close_bid_mark_max_pct",
|
||
"perp_qty_eth",
|
||
"option_qty_eth",
|
||
"show_manual_trade_buttons",
|
||
"sizing_mode",
|
||
"risk_loss_mode",
|
||
"risk_loss_pct",
|
||
"risk_loss_usdt",
|
||
"risk_capital_source",
|
||
"risk_manual_capital_usdt",
|
||
"risk_perp_unit",
|
||
"risk_option_unit",
|
||
"risk_exit_unit",
|
||
)
|
||
|
||
|
||
class StrategySettingsBody(BaseModel):
|
||
fee_rate: float | None = Field(default=None, ge=0, le=0.05)
|
||
exit_move_pct: float | None = Field(default=None, ge=0.1, le=50)
|
||
exit_mode: str | None = Field(default=None, pattern="^(fixed_usdt|premium_multiple)$")
|
||
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
|
||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||
live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30)
|
||
skip_weekends: bool | None = None
|
||
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
|
||
leverage: float | None = Field(default=None, ge=1, le=125)
|
||
perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$")
|
||
min_option_hours: float | None = Field(default=None, ge=1, le=720)
|
||
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
|
||
atm_open_offset_enabled: bool | None = None
|
||
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
|
||
fixed_direction_enabled: bool | None = None
|
||
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
|
||
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
|
||
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||
show_manual_trade_buttons: bool | None = None
|
||
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
|
||
sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$")
|
||
risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$")
|
||
risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100)
|
||
risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||
risk_capital_source: str | None = Field(
|
||
default=None, pattern="^(trading_account|manual)$"
|
||
)
|
||
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
|
||
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||
risk_option_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||
|
||
|
||
def _as_bool(raw: str | None, default: bool) -> bool:
|
||
if raw is None or raw == "":
|
||
return default
|
||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||
|
||
|
||
def _risk_preview_safe() -> dict:
|
||
try:
|
||
from ..strategy.risk_sizing import preview_risk_sizing
|
||
|
||
return preview_risk_sizing()
|
||
except Exception as e:
|
||
return {"ok": False, "detail": f"预览失败: {e}", "risk_based": False}
|
||
|
||
|
||
def _read_settings() -> dict:
|
||
db = get_db()
|
||
s = get_settings()
|
||
rt = load_runtime_settings()
|
||
mode = str(db.get_setting("exit_mode", s.exit_mode) or s.exit_mode)
|
||
if mode not in ("fixed_usdt", "premium_multiple"):
|
||
mode = "fixed_usdt"
|
||
return {
|
||
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
|
||
"exit_move_pct": float(
|
||
db.get_setting("exit_move_pct", str(s.exit_move_pct)) or s.exit_move_pct
|
||
),
|
||
"exit_mode": mode,
|
||
"net_profit_target": float(
|
||
db.get_setting("net_profit_target", str(s.net_profit_target))
|
||
or s.net_profit_target
|
||
),
|
||
"premium_exit_multiple": float(
|
||
db.get_setting("premium_exit_multiple", str(s.premium_exit_multiple))
|
||
or s.premium_exit_multiple
|
||
),
|
||
"rest_seconds": int(
|
||
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||
),
|
||
"live_order_interval_sec": float(
|
||
db.get_setting(
|
||
"live_order_interval_sec", str(s.live_order_interval_sec)
|
||
)
|
||
or s.live_order_interval_sec
|
||
),
|
||
"skip_weekends": _as_bool(
|
||
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
|
||
),
|
||
"initial_equity": float(
|
||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||
),
|
||
"leverage": float(db.get_setting("leverage", str(s.leverage)) or s.leverage),
|
||
"perp_margin_mode": (
|
||
mm
|
||
if (
|
||
mm := str(
|
||
db.get_setting("perp_margin_mode", s.perp_margin_mode)
|
||
or s.perp_margin_mode
|
||
or "cross"
|
||
)
|
||
.strip()
|
||
.lower()
|
||
)
|
||
in ("cross", "isolated")
|
||
else "cross"
|
||
),
|
||
"min_option_hours": float(
|
||
db.get_setting("min_option_hours", str(s.min_option_hours))
|
||
or s.min_option_hours
|
||
),
|
||
"min_option_leverage": float(
|
||
db.get_setting("min_option_leverage", str(s.min_option_leverage))
|
||
or s.min_option_leverage
|
||
),
|
||
"atm_open_offset_enabled": _as_bool(
|
||
db.get_setting(
|
||
"atm_open_offset_enabled", str(s.atm_open_offset_enabled)
|
||
),
|
||
s.atm_open_offset_enabled,
|
||
),
|
||
"max_atm_open_offset": float(
|
||
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
|
||
or s.max_atm_open_offset
|
||
),
|
||
"fixed_direction_enabled": _as_bool(
|
||
db.get_setting(
|
||
"fixed_direction_enabled", str(s.fixed_direction_enabled)
|
||
),
|
||
s.fixed_direction_enabled,
|
||
),
|
||
"fixed_perp_side": (
|
||
side
|
||
if (
|
||
side := str(
|
||
db.get_setting("fixed_perp_side", s.fixed_perp_side)
|
||
or s.fixed_perp_side
|
||
)
|
||
.strip()
|
||
.lower()
|
||
)
|
||
in ("long", "short")
|
||
else "long"
|
||
),
|
||
"close_bid_mark_max_pct": float(
|
||
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
||
or s.close_bid_mark_max_pct
|
||
),
|
||
"perp_qty_eth": float(
|
||
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
||
),
|
||
"option_qty_eth": float(
|
||
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
|
||
),
|
||
"show_manual_trade_buttons": _as_bool(
|
||
db.get_setting("show_manual_trade_buttons", "0"), False
|
||
),
|
||
"sizing_mode": (
|
||
sm
|
||
if (
|
||
sm := str(db.get_setting("sizing_mode", "manual") or "manual")
|
||
.strip()
|
||
.lower()
|
||
)
|
||
in ("manual", "risk_based")
|
||
else "manual"
|
||
),
|
||
"risk_loss_mode": (
|
||
lm
|
||
if (
|
||
lm := str(db.get_setting("risk_loss_mode", "percent") or "percent")
|
||
.strip()
|
||
.lower()
|
||
)
|
||
in ("percent", "absolute")
|
||
else "percent"
|
||
),
|
||
"risk_loss_pct": float(db.get_setting("risk_loss_pct", "1") or 1),
|
||
"risk_loss_usdt": float(db.get_setting("risk_loss_usdt", "15") or 15),
|
||
"risk_capital_source": (
|
||
cs
|
||
if (
|
||
cs := str(
|
||
db.get_setting("risk_capital_source", "trading_account")
|
||
or "trading_account"
|
||
)
|
||
.strip()
|
||
.lower()
|
||
)
|
||
in ("trading_account", "manual")
|
||
else "trading_account"
|
||
),
|
||
"risk_manual_capital_usdt": float(
|
||
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
|
||
),
|
||
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
|
||
"risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2),
|
||
"risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
|
||
"risk_sizing_preview": _risk_preview_safe(),
|
||
"exchange": rt.exchange,
|
||
"perp_inst_id": rt.perp_inst_id,
|
||
"option_inst_family": rt.option_inst_family,
|
||
"index_inst_id": rt.index_inst_id,
|
||
"ledger": Ledger(db).snapshot(),
|
||
}
|
||
|
||
|
||
@router.get("/strategy")
|
||
async def get_strategy_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
return _read_settings()
|
||
|
||
|
||
@router.put("/strategy")
|
||
async def put_strategy_settings(
|
||
body: StrategySettingsBody,
|
||
_user: Annotated[str, Depends(require_user)],
|
||
) -> dict:
|
||
db = get_db()
|
||
s = get_settings()
|
||
data = body.model_dump(exclude_none=True)
|
||
equity_to_apply: float | None = None
|
||
switch_to: str | None = None
|
||
|
||
if "exchange" in data:
|
||
new_ex = normalize_exchange_name(str(data.pop("exchange")))
|
||
old_ex = normalize_exchange_name(
|
||
db.get_setting("exchange", s.exchange) or s.exchange
|
||
)
|
||
if new_ex != old_ex:
|
||
if Matcher(db).has_open_position():
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="有未平仓,无法切换交易所;请先平仓后再改",
|
||
)
|
||
switch_to = new_ex
|
||
|
||
if "initial_equity" in data:
|
||
new_eq = float(data["initial_equity"])
|
||
old_eq = float(
|
||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||
)
|
||
if abs(new_eq - old_eq) > 1e-9:
|
||
if Matcher(db).has_open_position():
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="有未平仓,无法重置模拟资金;请先平仓后再改",
|
||
)
|
||
equity_to_apply = new_eq
|
||
|
||
if "perp_margin_mode" in data:
|
||
new_mm = str(data["perp_margin_mode"]).strip().lower()
|
||
old_mm = str(
|
||
db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode
|
||
).strip().lower()
|
||
if new_mm != old_mm and Matcher(db).has_open_position():
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="有未平仓,无法切换永续保证金模式;请先平仓后再改",
|
||
)
|
||
|
||
# 持仓中禁止改动会影响本组成交/出场的参数(基线:运行中无人工开平仓,策略锁定本组成交)
|
||
if Matcher(db).has_open_position():
|
||
locked_keys = (
|
||
"net_profit_target",
|
||
"exit_mode",
|
||
"premium_exit_multiple",
|
||
"perp_qty_eth",
|
||
"option_qty_eth",
|
||
"leverage",
|
||
"sizing_mode",
|
||
"risk_perp_unit",
|
||
"risk_option_unit",
|
||
"risk_exit_unit",
|
||
"risk_loss_mode",
|
||
"risk_loss_pct",
|
||
"risk_loss_usdt",
|
||
"risk_capital_source",
|
||
"risk_manual_capital_usdt",
|
||
)
|
||
hit = [k for k in locked_keys if k in data]
|
||
if hit:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓",
|
||
)
|
||
|
||
# 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场
|
||
sizing_mode = str(
|
||
data.get(
|
||
"sizing_mode",
|
||
db.get_setting("sizing_mode", "manual") or "manual",
|
||
)
|
||
).strip().lower()
|
||
if sizing_mode == "risk_based":
|
||
data["exit_mode"] = "fixed_usdt"
|
||
data.pop("perp_qty_eth", None)
|
||
data.pop("option_qty_eth", None)
|
||
data.pop("net_profit_target", None)
|
||
loss_mode = str(
|
||
data.get(
|
||
"risk_loss_mode",
|
||
db.get_setting("risk_loss_mode", "percent") or "percent",
|
||
)
|
||
).strip().lower()
|
||
if loss_mode == "absolute":
|
||
loss_u = data.get("risk_loss_usdt")
|
||
if loss_u is None:
|
||
loss_u = float(db.get_setting("risk_loss_usdt", "0") or 0)
|
||
if float(loss_u) <= 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="以损定仓选用亏损值时,须填写 risk_loss_usdt > 0",
|
||
)
|
||
else:
|
||
src = str(
|
||
data.get(
|
||
"risk_capital_source",
|
||
db.get_setting("risk_capital_source", "trading_account")
|
||
or "trading_account",
|
||
)
|
||
).strip().lower()
|
||
if src == "manual":
|
||
cap = data.get("risk_manual_capital_usdt")
|
||
if cap is None:
|
||
cap = float(db.get_setting("risk_manual_capital_usdt", "0") or 0)
|
||
if float(cap) <= 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="以损定仓选用单独本金时,须填写 risk_manual_capital_usdt > 0",
|
||
)
|
||
pct = data.get("risk_loss_pct")
|
||
if pct is None:
|
||
pct = float(db.get_setting("risk_loss_pct", "0") or 0)
|
||
if float(pct) <= 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0",
|
||
)
|
||
|
||
for k, v in data.items():
|
||
if k in KEYS:
|
||
db.set_setting(k, str(v))
|
||
|
||
if equity_to_apply is not None:
|
||
Ledger(db).reset_equity(
|
||
equity_to_apply,
|
||
note=f"设置模拟资金={equity_to_apply:.2f}",
|
||
)
|
||
|
||
if switch_to is not None:
|
||
rt = persist_exchange_choice(switch_to)
|
||
try:
|
||
await reload_market_session(rt)
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
|
||
) from e
|
||
|
||
return _read_settings()
|
||
|
||
|
||
class RuntimeSettingsBody(BaseModel):
|
||
mode: Literal["SIM", "LIVE"] | None = None
|
||
confirm_live: bool | None = False
|
||
confirm_live_phrase: str | None = None
|
||
okx_api_key: str | None = None
|
||
okx_api_secret: str | None = None
|
||
okx_api_passphrase: str | None = None
|
||
binance_api_key: str | None = None
|
||
binance_api_secret: str | None = None
|
||
|
||
|
||
def _runtime_payload() -> dict:
|
||
s = get_settings()
|
||
rt = load_runtime_settings()
|
||
mode = "SIM" if s.is_sim else "LIVE"
|
||
ready, reason = live_ready(exchange=rt.exchange)
|
||
return {
|
||
"mode": mode,
|
||
"exchange": rt.exchange,
|
||
"okx_configured": okx_keys_configured(s),
|
||
"binance_configured": binance_keys_configured(s),
|
||
"okx_api_key_masked": mask_secret(s.okx_api_key),
|
||
"okx_api_secret_masked": mask_secret(s.okx_api_secret),
|
||
"okx_api_passphrase_masked": mask_secret(s.okx_api_passphrase),
|
||
"binance_api_key_masked": mask_secret(s.binance_api_key),
|
||
"binance_api_secret_masked": mask_secret(s.binance_api_secret),
|
||
"live_ready": bool(ready) if mode == "LIVE" else True,
|
||
"live_ready_reason": reason if mode == "LIVE" else "sim",
|
||
"sim": s.is_sim,
|
||
}
|
||
|
||
|
||
@router.get("/runtime")
|
||
async def get_runtime_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
return _runtime_payload()
|
||
|
||
|
||
@router.put("/runtime")
|
||
async def put_runtime_settings(
|
||
body: RuntimeSettingsBody,
|
||
_user: Annotated[str, Depends(require_user)],
|
||
) -> dict:
|
||
db = get_db()
|
||
s = get_settings()
|
||
cur_mode = "SIM" if s.is_sim else "LIVE"
|
||
new_mode = (body.mode or cur_mode).strip().upper()
|
||
if new_mode not in ("SIM", "LIVE"):
|
||
raise HTTPException(status_code=400, detail="mode 须为 SIM 或 LIVE")
|
||
|
||
if new_mode != cur_mode and Matcher(db).has_open_position():
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="有未平仓,无法切换 SIM/LIVE;请先平仓后再改",
|
||
)
|
||
|
||
if new_mode == "LIVE" and cur_mode != "LIVE":
|
||
if not body.confirm_live:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="切换到 LIVE 须二次确认(confirm_live=true)",
|
||
)
|
||
phrase = (body.confirm_live_phrase or "").strip()
|
||
if phrase != "LIVE":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE",
|
||
)
|
||
secret = (s.auth_secret or "").strip()
|
||
if not secret or secret == "change-me-eth-hedge-sim-secret":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="切到 LIVE 前请在 .env 设置非默认 AUTH_SECRET",
|
||
)
|
||
|
||
updates: dict[str, str] = {}
|
||
if body.okx_api_key is not None and body.okx_api_key.strip():
|
||
updates["OKX_API_KEY"] = body.okx_api_key.strip()
|
||
if body.okx_api_secret is not None and body.okx_api_secret.strip():
|
||
updates["OKX_API_SECRET"] = body.okx_api_secret.strip()
|
||
if body.okx_api_passphrase is not None and body.okx_api_passphrase.strip():
|
||
updates["OKX_API_PASSPHRASE"] = body.okx_api_passphrase.strip()
|
||
if body.binance_api_key is not None and body.binance_api_key.strip():
|
||
updates["BINANCE_API_KEY"] = body.binance_api_key.strip()
|
||
if body.binance_api_secret is not None and body.binance_api_secret.strip():
|
||
updates["BINANCE_API_SECRET"] = body.binance_api_secret.strip()
|
||
|
||
if new_mode != cur_mode:
|
||
updates["MODE"] = new_mode
|
||
|
||
if updates:
|
||
upsert_env_keys(updates)
|
||
|
||
s2 = get_settings()
|
||
if new_mode == "LIVE":
|
||
rt = load_runtime_settings()
|
||
if rt.exchange == "okx" and not okx_keys_configured(s2):
|
||
if cur_mode == "SIM":
|
||
upsert_env_keys({"MODE": "SIM"})
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="切到 LIVE 前请先配置完整 OKX API Key/Secret/Passphrase",
|
||
)
|
||
if rt.exchange == "binance" and not binance_keys_configured(s2):
|
||
if cur_mode == "SIM":
|
||
upsert_env_keys({"MODE": "SIM"})
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="切到 LIVE 前请先配置完整币安 API Key/Secret",
|
||
)
|
||
|
||
# 热切 LIVE:强制暂停策略(对齐冷启动护栏;运行中禁止人工开平仓基线)
|
||
if new_mode == "LIVE" and cur_mode != "LIVE":
|
||
db.execute(
|
||
"UPDATE strategy_state SET running=0, phase=?, last_error=? WHERE id=1",
|
||
("paused", "已切换 LIVE,策略已强制暂停;确认就绪后再启动"),
|
||
)
|
||
try:
|
||
from ..strategy import get_engine
|
||
|
||
# 同步停循环标志(pause 为 async,此处只写状态)
|
||
get_engine()._set_state(
|
||
running=0,
|
||
phase="paused",
|
||
last_error="已切换 LIVE,策略已强制暂停;确认就绪后再启动",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from ..strategy import get_engine
|
||
|
||
get_engine().refresh_executor()
|
||
except Exception:
|
||
pass
|
||
|
||
return _runtime_payload()
|
||
|
||
|
||
class NotifySettingsBody(BaseModel):
|
||
enabled: bool | None = None
|
||
webhook_url: str | None = None
|
||
|
||
|
||
def _notify_payload() -> dict:
|
||
from ..notify import wecom
|
||
from ..env_store import mask_secret
|
||
|
||
s = get_settings()
|
||
url = wecom.wecom_webhook_url()
|
||
return {
|
||
"enabled": wecom.wecom_enabled(),
|
||
"webhook_configured": bool(url),
|
||
"webhook_url_masked": mask_secret(url) if url else None,
|
||
"venue_label": wecom.venue_label(),
|
||
}
|
||
|
||
|
||
@router.get("/notify")
|
||
async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
return _notify_payload()
|
||
|
||
|
||
@router.put("/notify")
|
||
async def put_notify_settings(
|
||
body: NotifySettingsBody,
|
||
_user: Annotated[str, Depends(require_user)],
|
||
) -> dict:
|
||
updates: dict[str, str] = {}
|
||
if body.enabled is not None:
|
||
updates["WECOM_ENABLED"] = "1" if body.enabled else "0"
|
||
get_db().set_setting("wecom_enabled", "1" if body.enabled else "0")
|
||
if body.webhook_url is not None and body.webhook_url.strip():
|
||
updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip()
|
||
get_db().set_setting("wecom_webhook_url", body.webhook_url.strip())
|
||
if updates:
|
||
upsert_env_keys(updates)
|
||
return _notify_payload()
|
||
|
||
|
||
@router.post("/notify/test")
|
||
async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
from ..notify import wecom
|
||
|
||
ok, msg = wecom.notify_test()
|
||
if not ok:
|
||
raise HTTPException(status_code=400, detail=f"推送失败: {msg}")
|
||
return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}
|