514 lines
20 KiB
Python
514 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
智能荐股系统 - Flask 主应用
|
|
页面:仪表盘 / 股票池 / 荐股中心 / 财经新闻 / 机构动向 / 数据管理 / 个股详情
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from flask import Flask, jsonify, render_template, request
|
|
|
|
from config import (IS_MOCK, SERVICE_HOST, SERVICE_NAME, SERVICE_PORT,
|
|
CHROMA_NEWS_COLLECTION, CHROMA_PROFILE_COLLECTION)
|
|
from database import init_db, query, query_one, execute, table_count
|
|
from engine import scoring
|
|
from engine.indicators import compute_indicators
|
|
from rag import vector_store as vs
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
log = logging.getLogger("app")
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
# ===================================================================== 页面
|
|
@app.route("/")
|
|
def page_index():
|
|
return render_template("index.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/stocks")
|
|
def page_stocks():
|
|
return render_template("stocks.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/recommend")
|
|
def page_recommend():
|
|
return render_template("recommend.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/news")
|
|
def page_news():
|
|
return render_template("news.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/institutions")
|
|
def page_institutions():
|
|
return render_template("institutions.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/admin")
|
|
def page_admin():
|
|
return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/stock/<code>")
|
|
def page_stock(code):
|
|
return render_template("stock_detail.html", code=code,
|
|
service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
# ===================================================================== 公共
|
|
def _indicators(code):
|
|
rows = query("SELECT date,open,high,low,close,volume FROM stock_daily "
|
|
"WHERE code=? ORDER BY date ASC", (code,))
|
|
return compute_indicators(rows)
|
|
|
|
|
|
def _news_score(code):
|
|
n = query_one(
|
|
"SELECT AVG(sentiment) AS s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
|
|
"AND publish_date >= date('now','-7 day')",
|
|
(code, f"%,{code}", f"{code},%"))
|
|
return n["s"] if n and n["s"] is not None else 0.0
|
|
|
|
|
|
def _inst_score(code):
|
|
st = query_one(
|
|
"SELECT COUNT(*) AS c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') "
|
|
"AND rating_date >= date('now','-30 day')", (code,))
|
|
cnt = st["c"] if st else 0
|
|
return min(1.0, cnt / 4.0)
|
|
|
|
|
|
def _scored_stock(code):
|
|
stock = query_one("SELECT * FROM stocks WHERE code=?", (code,))
|
|
if not stock:
|
|
return None
|
|
ind = _indicators(code)
|
|
news_s = _news_score(code)
|
|
inst_s = _inst_score(code)
|
|
sc = scoring.score_stock(ind, news_s, inst_s)
|
|
# 机构正面评级数 / 增持数
|
|
up = query_one("SELECT COUNT(*) c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') "
|
|
"AND rating_date >= date('now','-30 day')", (code,))
|
|
hold_up = query_one("SELECT COUNT(*) c FROM fund_holdings WHERE stock_code=? AND change_pct>0 AND quarter="
|
|
"(SELECT MAX(quarter) FROM fund_holdings)", (code,))
|
|
reasons = scoring.build_reasons(ind, news_s, up["c"] if up else 0, hold_up["c"] if hold_up else 0)
|
|
return {
|
|
"stock": stock, "ind": ind, "score": sc,
|
|
"reasons": reasons,
|
|
"inst_up": up["c"] if up else 0,
|
|
"hold_up": hold_up["c"] if hold_up else 0,
|
|
"news_score": round(news_s, 2),
|
|
"inst_score": round(inst_s, 2),
|
|
}
|
|
|
|
|
|
def _fmt(row):
|
|
"""转 JSON 安全(bytes 等)"""
|
|
return row
|
|
|
|
|
|
# ===================================================================== API
|
|
@app.route("/api/health")
|
|
def api_health():
|
|
return jsonify({"status": "ok", "service": SERVICE_NAME,
|
|
"is_mock": IS_MOCK, "time": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
|
|
|
|
@app.route("/api/overview")
|
|
def api_overview():
|
|
"""仪表盘:指数 / 市场情绪 / 行业热度 / 今日荐股 / 自选 / 要闻"""
|
|
idx = query("SELECT * FROM market_index ORDER BY date DESC LIMIT 2")
|
|
latest = idx[0] if idx else {}
|
|
prev = idx[1] if len(idx) > 1 else latest
|
|
inds = []
|
|
for k, label in (("sh", "上证指数"), ("sz", "深证成指"), ("cy", "创业板指")):
|
|
cur, old = latest.get(k, 0), prev.get(k, 0) or 1
|
|
inds.append({"key": k, "label": label, "value": cur,
|
|
"chg": round((cur - old) / old * 100, 2)})
|
|
|
|
# 今日涨跌统计
|
|
stat = query_one(
|
|
"SELECT COUNT(*) total, SUM(CASE WHEN change_pct>0 THEN 1 ELSE 0 END) up,"
|
|
"SUM(CASE WHEN change_pct<0 THEN 1 ELSE 0 END) down,"
|
|
"SUM(CASE WHEN change_pct>=9.8 THEN 1 ELSE 0 END) limit_up,"
|
|
"SUM(CASE WHEN change_pct<=-9.8 THEN 1 ELSE 0 END) limit_down,"
|
|
"ROUND(SUM(amount)/10000,2) amount_yi "
|
|
"FROM stock_daily WHERE date=(SELECT MAX(date) FROM stock_daily)")
|
|
|
|
# 行业热度(今日平均涨跌幅)
|
|
heat = query(
|
|
"SELECT s.industry, ROUND(AVG(d.change_pct),2) chg, COUNT(*) cnt "
|
|
"FROM stock_daily d JOIN stocks s ON s.code=d.code "
|
|
"WHERE d.date=(SELECT MAX(date) FROM stock_daily) GROUP BY s.industry "
|
|
"ORDER BY chg DESC LIMIT 8")
|
|
|
|
# 今日荐股 Top5(重算)
|
|
rec = _top_recommend(5, "all")
|
|
|
|
# 自选
|
|
watch = []
|
|
for w in query("SELECT code FROM watchlist ORDER BY added_at DESC"):
|
|
d = _scored_stock(w["code"])
|
|
if d:
|
|
watch.append({"code": w["code"], "name": d["stock"]["name"],
|
|
"close": d["ind"].get("close"), "chg": d["ind"].get("change_pct"),
|
|
"rating": d["score"]["rating"], "score": d["score"]["total"]})
|
|
|
|
news = query("SELECT id,title,source,category,sentiment,publish_date,related_stocks "
|
|
"FROM news ORDER BY publish_date DESC, id DESC LIMIT 6")
|
|
|
|
return jsonify({
|
|
"indexes": inds, "stat": stat, "heat": heat,
|
|
"top": rec, "watchlist": watch, "news": news,
|
|
})
|
|
|
|
|
|
@app.route("/api/stocks")
|
|
def api_stocks():
|
|
keyword = request.args.get("keyword", "").strip()
|
|
industry = request.args.get("industry", "").strip()
|
|
board = request.args.get("board", "").strip()
|
|
sort = request.args.get("sort", "code")
|
|
order = request.args.get("order", "asc")
|
|
page = max(1, int(request.args.get("page", 1)))
|
|
per = min(100, int(request.args.get("per", 20)))
|
|
rated = request.args.get("rating", "").strip()
|
|
|
|
conds, args = [], []
|
|
if keyword:
|
|
conds.append("(s.name LIKE ? OR s.code LIKE ?)")
|
|
args += [f"%{keyword}%", f"%{keyword}%"]
|
|
if industry:
|
|
conds.append("s.industry=?")
|
|
args.append(industry)
|
|
if board:
|
|
conds.append("s.board=?")
|
|
args.append(board)
|
|
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
|
|
|
all_rows = query(
|
|
f"SELECT s.*, d.close, d.change_pct, d.volume, d.amount "
|
|
f"FROM stocks s LEFT JOIN stock_daily d ON d.code=s.code AND d.date=(SELECT MAX(date) FROM stock_daily) "
|
|
f"{where}", args)
|
|
out = []
|
|
for r in all_rows:
|
|
sc = _scored_stock(r["code"])
|
|
if not sc:
|
|
continue
|
|
if rated and sc["score"]["rating"] != rated:
|
|
continue
|
|
out.append({
|
|
"code": r["code"], "name": r["name"], "industry": r["industry"],
|
|
"board": r["board"], "close": r["close"], "change_pct": r["change_pct"],
|
|
"amount": r["amount"], "market_cap": r["market_cap"], "pe": r["pe"],
|
|
"score": sc["score"]["total"], "rating": sc["score"]["rating"],
|
|
"reasons": sc["reasons"], "vol_ratio": sc["ind"].get("vol_ratio"),
|
|
"chg_5d": sc["ind"].get("chg_5d"),
|
|
})
|
|
|
|
allowed = {"code", "name", "close", "change_pct", "score", "amount", "market_cap"}
|
|
if sort in allowed:
|
|
out.sort(key=lambda x: (x[sort] is None, x[sort]), reverse=(order == "desc"))
|
|
total = len(out)
|
|
start = (page - 1) * per
|
|
return jsonify({"total": total, "page": page, "per": per,
|
|
"items": out[start:start + per]})
|
|
|
|
|
|
@app.route("/api/stock/<code>")
|
|
def api_stock(code):
|
|
d = _scored_stock(code)
|
|
if not d:
|
|
return jsonify({"error": "not found"}), 404
|
|
stock = d["stock"]
|
|
stock["is_watch"] = bool(query_one("SELECT 1 FROM watchlist WHERE code=?", (code,)))
|
|
stock["week_change"] = d["ind"].get("chg_5d")
|
|
stock["month_change"] = d["ind"].get("chg_20d")
|
|
return jsonify({"stock": stock, "ind": {k: v for k, v in d["ind"].items() if k != "bars"},
|
|
"score": d["score"], "reasons": d["reasons"],
|
|
"news_score": d["news_score"], "inst_score": d["inst_score"],
|
|
"inst_up": d["inst_up"], "hold_up": d["hold_up"]})
|
|
|
|
|
|
@app.route("/api/stock/<code>/kline")
|
|
def api_kline(code):
|
|
days = min(250, int(request.args.get("days", 120)))
|
|
rows = query("SELECT date,open,high,low,close,volume FROM stock_daily "
|
|
"WHERE code=? ORDER BY date DESC LIMIT ?", (code, days))
|
|
rows.reverse()
|
|
ind = compute_indicators(rows)
|
|
bars = ind.get("bars", [])
|
|
# 附 MA 序列
|
|
def ma_series(n):
|
|
out = []
|
|
for i in range(len(bars)):
|
|
seg = bars[max(0, i + 1 - n):i + 1]
|
|
if len(seg) < n:
|
|
out.append(None)
|
|
else:
|
|
out.append(round(sum(b["close"] for b in seg) / n, 2))
|
|
return out
|
|
return jsonify({
|
|
"dates": [b["date"] for b in bars],
|
|
"kline": [[b["open"], b["close"], b["low"], b["high"]] for b in bars],
|
|
"volumes": [b["volume"] for b in bars],
|
|
"ma5": ma_series(5), "ma10": ma_series(10), "ma20": ma_series(20),
|
|
"ma60": ma_series(60),
|
|
})
|
|
|
|
|
|
@app.route("/api/stock/<code>/news")
|
|
def api_stock_news(code):
|
|
rows = query("SELECT id,title,source,category,sentiment,publish_date,related_stocks "
|
|
"FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
|
|
"ORDER BY publish_date DESC LIMIT 30",
|
|
(code, f"%,{code}", f"{code},%"))
|
|
return jsonify({"items": rows})
|
|
|
|
|
|
@app.route("/api/stock/<code>/institutions")
|
|
def api_stock_inst(code):
|
|
ratings = query(
|
|
"SELECT inst_name, rating, target_price, rating_date, prev_rating FROM inst_ratings "
|
|
"WHERE stock_code=? ORDER BY rating_date DESC", (code,))
|
|
holdings = query(
|
|
"SELECT inst_name, quarter, hold_shares, hold_value, change_pct FROM fund_holdings "
|
|
"WHERE stock_code=? ORDER BY quarter DESC, hold_value DESC", (code,))
|
|
return jsonify({"ratings": ratings, "holdings": holdings})
|
|
|
|
|
|
@app.route("/api/news")
|
|
def api_news():
|
|
keyword = request.args.get("keyword", "").strip()
|
|
category = request.args.get("category", "").strip()
|
|
code = request.args.get("code", "").strip()
|
|
page = max(1, int(request.args.get("page", 1)))
|
|
per = 15
|
|
conds, args = [], []
|
|
if keyword:
|
|
conds.append("(title LIKE ? OR content LIKE ?)")
|
|
args += [f"%{keyword}%", f"%{keyword}%"]
|
|
if category:
|
|
conds.append("category=?")
|
|
args.append(category)
|
|
if code:
|
|
conds.append("(related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?)")
|
|
args += [code, f"%,{code}", f"{code},%"]
|
|
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
|
total = query_one(f"SELECT COUNT(*) c FROM news {where}", args)["c"]
|
|
items = query(f"SELECT * FROM news {where} ORDER BY publish_date DESC, id DESC LIMIT ? OFFSET ?",
|
|
args + [per, (page - 1) * per])
|
|
cats = query("SELECT category, COUNT(*) c FROM news GROUP BY category ORDER BY c DESC")
|
|
return jsonify({"total": total, "page": page, "per": per, "items": items, "cats": cats})
|
|
|
|
|
|
@app.route("/api/news/<int:nid>")
|
|
def api_news_detail(nid):
|
|
n = query_one("SELECT * FROM news WHERE id=?", (nid,))
|
|
if not n:
|
|
return jsonify({"error": "not found"}), 404
|
|
# 关联股票信息
|
|
stocks = []
|
|
for code in (n["related_stocks"] or "").split(","):
|
|
if not code:
|
|
continue
|
|
s = query_one("SELECT code,name,industry FROM stocks WHERE code=?", (code,))
|
|
if s:
|
|
stocks.append(s)
|
|
return jsonify({"news": n, "stocks": stocks})
|
|
|
|
|
|
def _top_recommend(limit=10, rating_filter="all"):
|
|
"""全市场评分排序"""
|
|
rows = query("SELECT code FROM stocks")
|
|
items = []
|
|
for r in rows:
|
|
d = _scored_stock(r["code"])
|
|
if not d:
|
|
continue
|
|
if rating_filter != "all" and d["score"]["rating"] != rating_filter:
|
|
continue
|
|
items.append({
|
|
"code": r["code"], "name": d["stock"]["name"], "industry": d["stock"]["industry"],
|
|
"close": d["ind"].get("close"), "change_pct": d["ind"].get("change_pct"),
|
|
"score": d["score"], "reasons": d["reasons"], "chg_5d": d["ind"].get("chg_5d"),
|
|
"vol_ratio": d["ind"].get("vol_ratio"),
|
|
})
|
|
items.sort(key=lambda x: x["score"]["total"], reverse=True)
|
|
return items[:limit]
|
|
|
|
|
|
@app.route("/api/recommend")
|
|
def api_recommend():
|
|
rating = request.args.get("rating", "all")
|
|
limit = min(80, int(request.args.get("limit", 30)))
|
|
return jsonify({"items": _top_recommend(limit, rating)})
|
|
|
|
|
|
# ------------------------------------------------------------------ AI 分析
|
|
@app.route("/api/stock/<code>/analyze", methods=["POST"])
|
|
def api_analyze(code):
|
|
from engine import analyst
|
|
body = request.get_json(silent=True) or {}
|
|
focus = (body.get("focus") or "").strip()
|
|
res = analyst.submit_report(code, focus)
|
|
return jsonify(res)
|
|
|
|
|
|
@app.route("/api/stock/<code>/analyze/status")
|
|
def api_analyze_status(code):
|
|
from engine import analyst
|
|
st = analyst.report_status(code)
|
|
if not st:
|
|
cached = analyst.get_cached_report(code)
|
|
if cached:
|
|
return jsonify({"status": "done", "report": cached["report"],
|
|
"cached": True, "created_at": cached["created_at"]})
|
|
return jsonify({"status": "idle"})
|
|
return jsonify(st)
|
|
|
|
|
|
# ------------------------------------------------------------------ 自选
|
|
@app.route("/api/watchlist", methods=["GET"])
|
|
def api_watchlist():
|
|
items = []
|
|
for w in query("SELECT code, added_at FROM watchlist ORDER BY added_at DESC"):
|
|
d = _scored_stock(w["code"])
|
|
if d:
|
|
items.append({"code": w["code"], "name": d["stock"]["name"],
|
|
"industry": d["stock"]["industry"],
|
|
"close": d["ind"].get("close"), "change_pct": d["ind"].get("change_pct"),
|
|
"rating": d["score"]["rating"], "score": d["score"]["total"],
|
|
"added_at": w["added_at"]})
|
|
return jsonify({"items": items})
|
|
|
|
|
|
@app.route("/api/watchlist/<code>", methods=["POST"])
|
|
def api_watch_add(code):
|
|
execute("INSERT OR IGNORE INTO watchlist(code) VALUES(?)", (code,))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/watchlist/<code>", methods=["DELETE"])
|
|
def api_watch_del(code):
|
|
execute("DELETE FROM watchlist WHERE code=?", (code,))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ------------------------------------------------------------------ 机构
|
|
@app.route("/api/institutions")
|
|
def api_institutions():
|
|
typ = request.args.get("type", "").strip()
|
|
cond, args = "", []
|
|
if typ:
|
|
cond, args = "WHERE type=?", [typ]
|
|
items = query(f"SELECT * FROM institutions {cond} ORDER BY type, id", args)
|
|
return jsonify({"items": items})
|
|
|
|
|
|
@app.route("/api/institutions/<int:iid>")
|
|
def api_institution_detail(iid):
|
|
inst = query_one("SELECT * FROM institutions WHERE id=?", (iid,))
|
|
if not inst:
|
|
return jsonify({"error": "not found"}), 404
|
|
ratings = query(
|
|
"SELECT r.stock_code, s.name, r.rating, r.target_price, r.rating_date, r.prev_rating "
|
|
"FROM inst_ratings r JOIN stocks s ON s.code=r.stock_code WHERE r.inst_id=? "
|
|
"ORDER BY r.rating_date DESC LIMIT 20", (iid,))
|
|
holdings = query(
|
|
"SELECT h.stock_code, s.name, h.quarter, h.hold_value, h.change_pct "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code WHERE h.inst_id=? "
|
|
"ORDER BY h.quarter DESC, h.hold_value DESC LIMIT 20", (iid,))
|
|
return jsonify({"institution": inst, "ratings": ratings, "holdings": holdings})
|
|
|
|
|
|
@app.route("/api/ratings/upgrades")
|
|
def api_ratings_upgrades():
|
|
"""评级上调/下调榜"""
|
|
rows = query(
|
|
"SELECT r.stock_code, s.name, r.inst_name, r.rating, r.prev_rating, r.rating_date, r.target_price "
|
|
"FROM inst_ratings r JOIN stocks s ON s.code=r.stock_code "
|
|
"WHERE r.rating_date >= date('now','-45 day') "
|
|
"ORDER BY r.rating_date DESC, r.id DESC LIMIT 30")
|
|
return jsonify({"items": rows})
|
|
|
|
|
|
@app.route("/api/holdings/moves")
|
|
def api_holdings_moves():
|
|
q = query_one("SELECT MAX(quarter) q FROM fund_holdings")
|
|
latest = q["q"] if q else "2026Q2"
|
|
inc = query(
|
|
"SELECT h.stock_code, s.name, h.inst_name, h.hold_value, h.change_pct, h.quarter "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code "
|
|
"WHERE h.quarter=? AND h.change_pct>0 ORDER BY h.change_pct DESC LIMIT 10", (latest,))
|
|
dec = query(
|
|
"SELECT h.stock_code, s.name, h.inst_name, h.hold_value, h.change_pct, h.quarter "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code "
|
|
"WHERE h.quarter=? AND h.change_pct<0 ORDER BY h.change_pct ASC LIMIT 10", (latest,))
|
|
return jsonify({"quarter": latest, "increase": inc, "decrease": dec})
|
|
|
|
|
|
# ------------------------------------------------------------------ 数据管理
|
|
@app.route("/api/admin/stats")
|
|
def api_admin_stats():
|
|
tables = ("stocks", "stock_daily", "news", "institutions", "inst_ratings",
|
|
"fund_holdings", "watchlist", "analysis_cache", "market_index")
|
|
return jsonify({
|
|
"tables": {t: table_count(t) for t in tables},
|
|
"vector": {
|
|
"news": vs.collection_count(CHROMA_NEWS_COLLECTION),
|
|
"profiles": vs.collection_count(CHROMA_PROFILE_COLLECTION),
|
|
},
|
|
"is_mock": IS_MOCK,
|
|
"db": "stock_advisor.db",
|
|
})
|
|
|
|
|
|
@app.route("/api/admin/reseed", methods=["POST"])
|
|
def api_admin_reseed():
|
|
"""一键重灌数据(含向量重建),后台执行"""
|
|
import subprocess
|
|
import threading
|
|
|
|
def run():
|
|
py = "/home/hz1/miniconda3/envs/openclaw/bin/python3"
|
|
subprocess.run([py, os.path.join(os.path.dirname(os.path.abspath(__file__)), "seed_data.py")],
|
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
|
stdout=open(os.path.join(LOG_DIR, "reseed.log"), "w"),
|
|
stderr=subprocess.STDOUT)
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return jsonify({"ok": True, "msg": "重灌任务已启动,可在数据管理页刷新查看进度"})
|
|
|
|
|
|
@app.route("/api/admin/healthcheck")
|
|
def api_admin_healthcheck():
|
|
"""外部依赖连通性检查"""
|
|
import requests
|
|
from config import EMBEDDING_API_URL, CHROMA_HOST, CHROMA_PORT, LLM_BASE_URL
|
|
out = {}
|
|
try:
|
|
r = requests.get(f"http://{CHROMA_HOST}:{CHROMA_PORT}/api/v2/tenants/default_tenant/databases/default_database/collections", timeout=5)
|
|
out["chroma"] = "ok" if r.status_code == 200 else f"http {r.status_code}"
|
|
except Exception as e:
|
|
out["chroma"] = f"fail {e}"
|
|
try:
|
|
r = requests.post(EMBEDDING_API_URL, json={"model": "bge-large-zh-v1.5", "input": ["测试"]}, timeout=10)
|
|
out["embedding"] = "ok" if r.status_code == 200 else f"http {r.status_code}"
|
|
except Exception as e:
|
|
out["embedding"] = f"fail {e}"
|
|
out["llm"] = "配置已就绪(调用时校验)"
|
|
return jsonify(out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
init_db()
|
|
print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}")
|
|
app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True)
|