server.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. # -*- coding: utf-8 -*-
  2. """
  3. 门店检测工作台 —— 后端服务(Python 标准库,无需安装任何依赖)
  4. 启动:
  5. python backend/server.py # 默认监听 0.0.0.0:8787(本机 + 局域网可访问)
  6. python backend/server.py --local # 只允许本机访问(127.0.0.1)
  7. python backend/server.py --port 9000 --open
  8. 接口:
  9. GET /api/meta 周期列表 / 阈值 / 导入历史 / 统计
  10. GET /api/dashboard?period= 工作台全量数据(聚合指标 + 检测结果 + KPI + 分布)
  11. PUT /api/thresholds 保存自定义考核指标
  12. POST /api/thresholds/reset 恢复官方默认
  13. POST /api/import?filename=&period=&date= 导入 CSV(body 为原始字节)
  14. GET /api/export.csv?... 导出检测清单
  15. GET /api/history 导入批次历史
  16. 目录:
  17. frontend/ 静态页面
  18. data/ 数据库 app.db、inbox/(丢文件进来自动导入)、processed/
  19. """
  20. import json
  21. import mimetypes
  22. import os
  23. import shutil
  24. import socket
  25. import subprocess
  26. import sys
  27. import threading
  28. import time
  29. import traceback
  30. import urllib.parse
  31. import webbrowser
  32. from datetime import datetime, timedelta
  33. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  34. import auth
  35. import codesync
  36. import db
  37. import ingest
  38. import rules
  39. BASE = os.path.dirname(os.path.abspath(__file__))
  40. ROOT = os.path.dirname(BASE)
  41. FRONTEND = os.path.join(ROOT, "frontend")
  42. SYNC_SCRIPT = os.path.join(ROOT, "sync", "life_sync.mjs")
  43. SYNC_HOUR = 1 # 每天凌晨 1:00 自动同步近30天数据
  44. SYNC_CATCHUP_HOURS = 20 # 距上次成功超过这么久,启动时补跑一次
  45. # ----------------------------------------------------------------------------
  46. # 业务组装
  47. # ----------------------------------------------------------------------------
  48. def build_dashboard(period=None):
  49. T = db.get_thresholds()
  50. X = db.get_texts()
  51. period = period or db.latest_period()
  52. if not period:
  53. return {"empty": True, "thresholds": T, "defaults": rules.DEFAULT_THRESHOLDS,
  54. "texts": X, "text_defaults": rules.DEFAULT_TEXTS,
  55. "periods": [], "stores": [], "kpis": [], "dist": {}, "period": None}
  56. rows = db.aggregate_period(period)
  57. stores = []
  58. for m in rows:
  59. d = rules.diagnose(m, T, X)
  60. stores.append({
  61. "id": m["id"], "name": m["name"], "city": m["city"],
  62. "score": m["score"], "hx_rate": m["hx_rate"], "hx_amt": m["used_amount"],
  63. "hx_cnt": m["used_coupons"], "td": m["return_rate"] or 0,
  64. "ts": m["complaint_rate"] or 0, "cp": m["cp_rate"], "cp_src": m["cp_src"],
  65. "reply": m["reply_rate"], "visit": m["visit"], "videos": m["videos"],
  66. "lit": m.get("lit", 0),
  67. "deal": m.get("deal", 0), "deal_refund": m.get("deal_refund", 0),
  68. "intend_deal": m["intend_deal"], "days": m["days"],
  69. "rank": m.get("rank_note") or "",
  70. # v2 诊断结果(瘦身版:只传模板键+变量,文案由前端按当前模板渲染)
  71. "tier": d["tier"], "headline": d["headline"], "hd_key": d["hd_key"],
  72. "primary_key": (d["primary"] or {}).get("key"),
  73. "sev": d["sev"],
  74. "problems": [{"k": p["key"], "t": p["tier"], "l": p["layer"], "v": p["level"],
  75. "c": p["cat"], "p": p["tpl"], "a": p["atpl"], "x": p["vars"]}
  76. for p in d["problems"]],
  77. })
  78. stores.sort(key=lambda x: (-x["sev"], x["hx_amt"]))
  79. kpis = [
  80. {"k": "ALL", "label": "全部门店", "value": len(stores), "hot": 0},
  81. {"k": "R", "label": "超违规预警线(退单/投诉/差评)", "value": sum(
  82. 1 for s in stores if s["tier"] == "R"), "hot": 1},
  83. {"k": "SCORE", "label": "评分低于基准%s" % T["score_base"], "value": sum(
  84. 1 for s in stores if s["score"] is not None and s["score"] < T["score_base"]), "hot": 1},
  85. {"k": "HX", "label": "核销率低于基准%.0f%%" % (T["hx_rate_base"] * 100), "value": sum(
  86. 1 for s in stores if s["hx_rate"] is not None and s["hx_rate"] < T["hx_rate_base"]), "hot": 1},
  87. {"k": "ZERO", "label": "本期零核销", "value": sum(1 for s in stores if s["hx_cnt"] == 0), "hot": 1},
  88. ]
  89. cat_cnt = {}
  90. for s in stores:
  91. for c in {p["c"] for p in s["problems"]}:
  92. cat_cnt[c] = cat_cnt.get(c, 0) + 1
  93. amount_buckets = {"zero": 0, "lt1k": 0, "ltbase": 0, "ok": 0}
  94. for s in stores:
  95. a = s["hx_amt"] or 0
  96. if a == 0:
  97. amount_buckets["zero"] += 1
  98. elif a < 1000:
  99. amount_buckets["lt1k"] += 1
  100. elif a < T["hx_amt_base"]:
  101. amount_buckets["ltbase"] += 1
  102. else:
  103. amount_buckets["ok"] += 1
  104. city_cnt = {}
  105. for s in stores:
  106. city_cnt[s["city"] or "未知"] = city_cnt.get(s["city"] or "未知", 0) + 1
  107. return {
  108. "empty": False,
  109. "period": period,
  110. "periods": db.get_periods(),
  111. "thresholds": T,
  112. "defaults": rules.DEFAULT_THRESHOLDS,
  113. "texts": X,
  114. "text_defaults": rules.DEFAULT_TEXTS,
  115. "customized": T != codesync.get_factory(),
  116. "kpis": kpis,
  117. "stores": stores,
  118. "dist": {
  119. "cats": sorted(cat_cnt.items(), key=lambda x: x[1]),
  120. "amount": amount_buckets,
  121. "cities": sorted(city_cnt.items(), key=lambda x: -x[1])[:15],
  122. },
  123. "stats": db.stats(),
  124. }
  125. def build_export(query):
  126. """导出门店实际数据(纯指标,不含诊断结论/问题/整改动作)"""
  127. d = build_dashboard(query.get("period", [None])[0])
  128. rows = d["stores"]
  129. kpi = query.get("kpi", ["ALL"])[0]
  130. city = query.get("city", [""])[0]
  131. cat = query.get("cat", [""])[0]
  132. score_band = query.get("score", [""])[0]
  133. q = query.get("q", [""])[0].lower()
  134. if kpi == "R":
  135. rows = [s for s in rows if s["tier"] == "R"]
  136. elif kpi == "SCORE":
  137. rows = [s for s in rows if s["score"] is not None and s["score"] < d["thresholds"]["score_base"]]
  138. elif kpi == "HX":
  139. rows = [s for s in rows if s["hx_rate"] is not None and s["hx_rate"] < d["thresholds"]["hx_rate_base"]]
  140. elif kpi == "ZERO":
  141. rows = [s for s in rows if s["hx_cnt"] == 0]
  142. if city:
  143. rows = [s for s in rows if s["city"] == city]
  144. if cat:
  145. rows = [s for s in rows if cat in {p["c"] for p in (s.get("problems") or [])}]
  146. if score_band:
  147. # 评分档位:闭区间按【】语义含端点(4.3 同时落在 4.0-4.3 与 4.3-4.5)
  148. def in_band(v):
  149. if v is None:
  150. return False
  151. x = round(v, 1)
  152. if score_band == "lt40":
  153. return x < 4.0
  154. if score_band == "40_43":
  155. return 4.0 <= x <= 4.3
  156. if score_band == "43_45":
  157. return 4.3 <= x <= 4.5
  158. if score_band == "gte45":
  159. return x >= 4.5
  160. return True
  161. rows = [s for s in rows if in_band(s["score"])]
  162. if q:
  163. rows = [s for s in rows if q in s["name"].lower() or q in s["id"]]
  164. def pct(v, n=1):
  165. return "" if v is None else ("%." + str(n) + "f%%") % (v * 100)
  166. head = ["门店ID", "门店名称", "城市", "评分", "访问次数", "成交券数", "核销券数",
  167. "核销率", "核销金额", "待核销券(估算)", "商责退单率", "投诉率", "差评率",
  168. "评价回复率", "发布视频数", "门店点亮数", "上榜信息及排名"]
  169. lines = [",".join(head)]
  170. for s in rows:
  171. pend = max((s["deal"] or 0) - (s["hx_cnt"] or 0), 0)
  172. cells = [s["id"], s["name"], s["city"],
  173. "" if s["score"] is None else "%.1f" % s["score"],
  174. str(s["visit"]), str(s["deal"]), str(s["hx_cnt"]),
  175. pct(s["hx_rate"]), "%.0f" % (s["hx_amt"] or 0), str(pend),
  176. "%.2f%%" % (s["td"] * 100), "%.2f%%" % (s["ts"] * 100),
  177. "" if s["cp"] is None else "%.2f%%" % (s["cp"] * 100),
  178. "" if s["reply"] is None else "%.0f%%" % (s["reply"] * 100),
  179. str(s["videos"]), str(s.get("lit") or 0),
  180. s.get("rank") or ""]
  181. # 单元格内的换行压成空格,避免 Excel 里出现多行单元格
  182. lines.append(",".join(
  183. '"%s"' % str(c).replace("\r", " ").replace("\n", " ").replace('"', '""')
  184. for c in cells))
  185. return ("\ufeff" + "\r\n".join(lines)).encode("utf-8"), d["period"]
  186. # ----------------------------------------------------------------------------
  187. # 近30天数据每日自动同步(生意经)
  188. # 放在服务进程里跑:launchd 会被 macOS 的 ~/Documents 保护挡住,服务本身没有这个问题
  189. # ----------------------------------------------------------------------------
  190. SYNC_LOCK = threading.Lock()
  191. SYNC_STATE = {"running": False, "last_start": None, "last_end": None,
  192. "last_ok": None, "last_msg": "", "trigger": ""}
  193. def _ego_bin():
  194. """定位 ego-browser:PATH 里没有时按常见安装位置找"""
  195. import shutil
  196. for c in (os.environ.get("EGO_BIN"),
  197. os.path.expanduser("~/.local/bin/ego-browser"),
  198. "/usr/local/bin/ego-browser", "/opt/homebrew/bin/ego-browser"):
  199. if c and os.path.isfile(c) and os.access(c, os.X_OK):
  200. return c
  201. return shutil.which("ego-browser")
  202. def run_sync(trigger="manual"):
  203. """跑一次同步;同一时间只允许一个"""
  204. if not SYNC_LOCK.acquire(blocking=False):
  205. return {"ok": False, "error": "已有同步任务在进行中"}
  206. try:
  207. if not os.path.isfile(SYNC_SCRIPT):
  208. return {"ok": False, "error": "找不到同步脚本 %s" % SYNC_SCRIPT}
  209. ego = _ego_bin()
  210. if not ego:
  211. return {"ok": False, "error": "找不到 ego-browser"}
  212. SYNC_STATE.update({"running": True, "trigger": trigger,
  213. "last_start": datetime.now().isoformat(timespec="seconds"),
  214. "last_msg": ""})
  215. print("[sync] 开始(%s)" % trigger)
  216. code = ("globalThis.SYNC_ARGS = [];"
  217. "await import('file://' + encodeURI(%r));" % SYNC_SCRIPT)
  218. pr = subprocess.run([ego, "nodejs", "-e", code], cwd=ROOT,
  219. capture_output=True, text=True, timeout=1800)
  220. out = (pr.stdout or "") + (pr.stderr or "")
  221. ok = pr.returncode == 0
  222. tail = [l for l in out.strip().splitlines() if l.strip()][-3:]
  223. SYNC_STATE.update({"running": False, "last_ok": ok,
  224. "last_end": datetime.now().isoformat(timespec="seconds"),
  225. "last_msg": " | ".join(tail)[:500]})
  226. db.set_setting("last_sync", datetime.now().isoformat(timespec="seconds"))
  227. print("[sync] 结束 ok=%s" % ok)
  228. return {"ok": ok, "output": tail, "returncode": pr.returncode}
  229. except Exception as e:
  230. traceback.print_exc()
  231. SYNC_STATE.update({"running": False, "last_ok": False,
  232. "last_end": datetime.now().isoformat(timespec="seconds"),
  233. "last_msg": str(e)[:500]})
  234. return {"ok": False, "error": str(e)}
  235. finally:
  236. SYNC_LOCK.release()
  237. def _sync_thread(trigger):
  238. def _run():
  239. try:
  240. run_sync(trigger)
  241. except Exception:
  242. traceback.print_exc()
  243. threading.Thread(target=_run, daemon=True).start()
  244. def daily_sync_loop(log=print):
  245. """每天 SYNC_HOUR:00 触发;启动时若距上次成功太久则补跑"""
  246. # 启动补跑
  247. try:
  248. last = db.get_setting("last_sync")
  249. stale = True
  250. if last:
  251. try:
  252. stale = (datetime.now() - datetime.fromisoformat(last)).total_seconds() > SYNC_CATCHUP_HOURS * 3600
  253. except ValueError:
  254. stale = True
  255. if stale:
  256. time.sleep(90) # 等服务和收件箱 watcher 起来
  257. log("[sync] 距上次同步超过 %d 小时,启动补跑一次" % SYNC_CATCHUP_HOURS)
  258. _sync_thread("catchup")
  259. except Exception:
  260. traceback.print_exc()
  261. while True:
  262. try:
  263. now = datetime.now()
  264. nxt = now.replace(hour=SYNC_HOUR, minute=0, second=0, microsecond=0)
  265. if nxt <= now:
  266. nxt = nxt + timedelta(days=1)
  267. wait = (nxt - now).total_seconds()
  268. log("[sync] 下次自动同步:%s" % nxt.strftime("%Y-%m-%d %H:%M"))
  269. time.sleep(max(60, wait))
  270. _sync_thread("schedule")
  271. time.sleep(120) # 留出执行时间,避免同分钟重复触发
  272. except Exception:
  273. traceback.print_exc()
  274. time.sleep(300)
  275. # ----------------------------------------------------------------------------
  276. # 收件箱自动导入
  277. # ----------------------------------------------------------------------------
  278. def watch_inbox(interval=20, log=print):
  279. """data/inbox 里丢 CSV / Excel 就自动导入,处理完移到 data/processed"""
  280. while True:
  281. try:
  282. files = [f for f in os.listdir(db.INBOX)
  283. if f.lower().endswith((".csv", ".xlsx")) and not f.startswith("~$")]
  284. for f in files:
  285. path = os.path.join(db.INBOX, f)
  286. try:
  287. stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  288. dest = "%s_%s" % (stamp, f)
  289. r = ingest.import_file(path, file_path=dest)
  290. shutil.move(path, os.path.join(db.PROCESSED, dest))
  291. log("[inbox] %s → 导入 %d 行(%s)" % (f, r.get("rows_written", 0), r.get("period")))
  292. except Exception as e:
  293. log("[inbox] %s 导入失败: %s" % (f, e))
  294. shutil.move(path, os.path.join(db.PROCESSED, "失败_" + f))
  295. except Exception:
  296. traceback.print_exc()
  297. time.sleep(interval)
  298. # ----------------------------------------------------------------------------
  299. # HTTP
  300. # ----------------------------------------------------------------------------
  301. # ---------------------------------------------------------------- 权限
  302. # 路由 → 需要的权限点。没列出的 /api/* 一律只要登录(view)
  303. PERM_RULES = [
  304. ("POST", "/api/login", None), # 公开
  305. ("POST", "/api/logout", None),
  306. ("GET", "/api/auth/state", None),
  307. ("GET", "/api/me", "view"),
  308. ("GET", "/api/users", "users"),
  309. ("POST", "/api/users", "users"),
  310. ("PUT", "/api/users", "users"),
  311. ("DELETE", "/api/users", "users"),
  312. ("GET", "/api/export.file", "export"),
  313. ("GET", "/api/export.csv", "export"),
  314. ("GET", "/api/batch/", "export"),
  315. ("POST", "/api/import", "import"),
  316. ("POST", "/api/sync", "sync"),
  317. ("PUT", "/api/thresholds", "config"),
  318. ("POST", "/api/thresholds/reset", "config"),
  319. ("PUT", "/api/copytext", "config"),
  320. ("POST", "/api/copytext/reset", "config"),
  321. ]
  322. SESSION_DAYS = 30
  323. def required_perm(method, path):
  324. for m, pref, perm in PERM_RULES:
  325. if m == method and (path == pref or path.startswith(pref)):
  326. return perm, True
  327. return "view", False
  328. def ensure_admin():
  329. """首次运行创建初始管理员"""
  330. if db.user_count() > 0:
  331. return None
  332. db.create_user("admin", "admin123", "admin", "管理员")
  333. u = db.get_user_by_name("admin")
  334. db.set_password(u["id"], "admin123", must_change=1)
  335. return "admin123"
  336. class Handler(BaseHTTPRequestHandler):
  337. server_version = "StoreOps/1.0"
  338. def log_message(self, fmt, *args):
  339. sys.stdout.write(" %s %s\n" % (self.command, self.path))
  340. # ---- helpers ----
  341. def _send(self, code, body, ctype="application/json; charset=utf-8", extra=None):
  342. self.send_response(code)
  343. self.send_header("Content-Type", ctype)
  344. self.send_header("Content-Length", str(len(body)))
  345. self.send_header("Cache-Control", "no-store")
  346. for k, v in (extra or {}).items():
  347. self.send_header(k, v)
  348. self.end_headers()
  349. self.wfile.write(body)
  350. def _json(self, obj, code=200):
  351. self._send(code, json.dumps(obj, ensure_ascii=False, default=str).encode("utf-8"))
  352. def _err(self, msg, code=400):
  353. self._json({"ok": False, "error": msg}, code)
  354. def _cookies(self):
  355. out = {}
  356. for part in (self.headers.get("Cookie") or "").split(";"):
  357. if "=" in part:
  358. k, v = part.split("=", 1)
  359. out[k.strip()] = v.strip()
  360. return out
  361. def _auth(self):
  362. self.user = None
  363. self.sid = None
  364. sid = self._cookies().get("sid")
  365. if sid:
  366. h = auth.hash_token(sid)
  367. u, _ = db.get_session(h)
  368. if u:
  369. self.user, self.sid = u, h
  370. return self.user
  371. def _guard(self, method, path):
  372. """返回 True 表示已放行;否则已经写好响应,调用方直接 return"""
  373. need, matched = required_perm(method, path)
  374. if need is None:
  375. return True
  376. if not self.user:
  377. self._json({"ok": False, "error": "请先登录", "need_login": True}, 401)
  378. return False
  379. if not auth.can(self.user["role"], need):
  380. self._json({"ok": False, "error": "当前账号(%s)没有这个权限" %
  381. auth.role_label(self.user["role"]), "forbidden": need}, 403)
  382. return False
  383. return True
  384. def _body(self):
  385. n = int(self.headers.get("Content-Length") or 0)
  386. return self.rfile.read(n) if n else b""
  387. def do_DELETE(self):
  388. try:
  389. u = urllib.parse.urlparse(self.path)
  390. self._auth()
  391. if u.path.startswith("/api/") and not self._guard("DELETE", u.path):
  392. return
  393. if u.path.startswith("/api/users/"):
  394. uid = int(u.path.rsplit("/", 1)[1])
  395. return self._user_delete(uid)
  396. return self._err("没有这个接口", 404)
  397. except Exception as e:
  398. traceback.print_exc()
  399. self._err("服务端异常: %s" % e, 500)
  400. # ---- 账号相关 ----
  401. def _login(self):
  402. try:
  403. body = json.loads(self._body().decode("utf-8") or "{}")
  404. except (ValueError, UnicodeDecodeError):
  405. return self._err("请求格式不对")
  406. name = (body.get("username") or "").strip()
  407. pw = body.get("password") or ""
  408. u = db.get_user_by_name(name)
  409. if not u or not auth.verify_password(pw, u["pass_hash"], u["salt"]):
  410. time.sleep(0.4) # 拖慢暴力破解
  411. return self._err("账号或密码不对", 401)
  412. if not u["active"]:
  413. return self._err("该账号已被停用,请联系管理员", 403)
  414. token = auth.new_token()
  415. exp = (datetime.now() + timedelta(days=SESSION_DAYS)).isoformat(timespec="seconds")
  416. db.create_session(auth.hash_token(token), u["id"], exp,
  417. self.headers.get("X-Forwarded-For", "") or self.client_address[0])
  418. db.touch_login(u["id"])
  419. payload = json.dumps({"ok": True, "must_change": bool(u["must_change"]),
  420. "role": u["role"],
  421. "display_name": u["display_name"]}).encode()
  422. return self._send(200, payload, "application/json; charset=utf-8", {
  423. "Set-Cookie": "sid=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax"
  424. % (token, SESSION_DAYS * 86400)})
  425. def _user_create(self):
  426. try:
  427. b = json.loads(self._body().decode("utf-8") or "{}")
  428. except (ValueError, UnicodeDecodeError):
  429. return self._err("请求格式不对")
  430. name = (b.get("username") or "").strip()
  431. pw = b.get("password") or ""
  432. role = b.get("role") or "viewer"
  433. if not name:
  434. return self._err("用户名不能为空")
  435. if db.get_user_by_name(name):
  436. return self._err("这个用户名已存在", 409)
  437. if role not in auth.ROLES:
  438. return self._err("角色不对")
  439. ok, msg = auth.check_strength(pw)
  440. if not ok:
  441. return self._err(msg)
  442. uid = db.create_user(name, pw, role, (b.get("display_name") or name).strip())
  443. return self._json({"ok": True, "id": uid})
  444. def _me_update(self):
  445. """改自己的密码"""
  446. try:
  447. b = json.loads(self._body().decode("utf-8") or "{}")
  448. except (ValueError, UnicodeDecodeError):
  449. return self._err("请求格式不对")
  450. u = self.user
  451. new = b.get("password") or ""
  452. ok, msg = auth.check_strength(new)
  453. if not ok:
  454. return self._err(msg)
  455. if not u["must_change"]:
  456. old = b.get("old_password") or ""
  457. if not auth.verify_password(old, u["pass_hash"], u["salt"]):
  458. return self._err("原密码不对", 403)
  459. db.set_password(u["id"], new, must_change=0)
  460. return self._json({"ok": True})
  461. def _user_update(self, uid):
  462. try:
  463. b = json.loads(self._body().decode("utf-8") or "{}")
  464. except (ValueError, UnicodeDecodeError):
  465. return self._err("请求格式不对")
  466. u = db.get_user(uid)
  467. if not u:
  468. return self._err("账号不存在", 404)
  469. role = b.get("role")
  470. if role is not None and role not in auth.ROLES:
  471. return self._err("角色不对")
  472. # 不能把最后一个管理员降级
  473. if role and role != "admin" and u["role"] == "admin" and db.admin_count() <= 1:
  474. return self._err("至少要保留一个管理员")
  475. active = b.get("active")
  476. if active is not None and not active and u["username"] == "admin":
  477. return self._err("admin 是内置账号,不能停用")
  478. if active is not None and not active and u["role"] == "admin" and db.admin_count() <= 1:
  479. return self._err("至少要保留一个启用的管理员")
  480. if active is not None and not active and uid == self.user["id"]:
  481. return self._err("不能停用自己正在用的账号")
  482. db.update_user(uid, role=role, display_name=b.get("display_name"), active=active)
  483. pw = b.get("password")
  484. if pw:
  485. ok, msg = auth.check_strength(pw)
  486. if not ok:
  487. return self._err(msg)
  488. db.set_password(uid, pw, must_change=1 if b.get("force_change") else 0)
  489. db.delete_user_sessions(uid) # 改密码后强制重新登录
  490. return self._json({"ok": True})
  491. def _user_delete(self, uid):
  492. if self.user["id"] == uid:
  493. return self._err("不能删除自己正在用的账号")
  494. u = db.get_user(uid)
  495. if not u:
  496. return self._err("账号不存在", 404)
  497. if u["username"] == "admin":
  498. return self._err("admin 是内置账号,不能删除")
  499. if u["role"] == "admin" and db.admin_count() <= 1:
  500. return self._err("至少要保留一个管理员")
  501. db.delete_user(uid)
  502. return self._json({"ok": True})
  503. def _static(self, path):
  504. if path in ("/", ""):
  505. if not getattr(self, "user", None):
  506. path = "/login.html" # 没登录先看登录页
  507. else:
  508. path = "/index.html"
  509. fp = os.path.normpath(os.path.join(FRONTEND, path.lstrip("/")))
  510. if not fp.startswith(FRONTEND) or not os.path.isfile(fp):
  511. self._send(404, b"Not Found", "text/plain; charset=utf-8")
  512. return
  513. ctype = mimetypes.guess_type(fp)[0] or "application/octet-stream"
  514. with open(fp, "rb") as f:
  515. data = f.read()
  516. self._send(200, data, ctype)
  517. # ---- routes ----
  518. def do_GET(self):
  519. try:
  520. u = urllib.parse.urlparse(self.path)
  521. path, q = u.path, urllib.parse.parse_qs(u.query)
  522. self._auth()
  523. if path.startswith("/api/"):
  524. if not self._guard("GET", path):
  525. return
  526. if path == "/api/auth/state":
  527. n = db.user_count()
  528. admin = db.get_user_by_name("admin")
  529. return self._json({
  530. "ok": True, "needs_setup": n == 0,
  531. "default_admin": bool(admin and admin["must_change"]),
  532. "logged_in": bool(self.user),
  533. })
  534. if path == "/api/me":
  535. u2 = self.user
  536. return self._json({"ok": True, "user": {
  537. "id": u2["id"], "username": u2["username"], "role": u2["role"],
  538. "role_label": auth.role_label(u2["role"]),
  539. "display_name": u2["display_name"],
  540. "must_change": bool(u2["must_change"]),
  541. "perms": sorted(auth.GRANTS.get(u2["role"], set())),
  542. }})
  543. if path == "/api/users":
  544. return self._json({"ok": True, "users": db.list_users(),
  545. "roles": [{"key": k, **auth.ROLES[k]} for k in auth.ROLE_ORDER],
  546. "perms": auth.PERMS,
  547. "grants": {k: sorted(v) for k, v in auth.GRANTS.items()}})
  548. if path == "/api/meta":
  549. return self._json({
  550. "ok": True,
  551. "periods": db.get_periods(),
  552. "thresholds": db.get_thresholds(),
  553. "defaults": rules.DEFAULT_THRESHOLDS,
  554. "factory": codesync.get_factory(),
  555. "customized": db.get_thresholds() != codesync.get_factory(),
  556. "history": db.import_history(),
  557. "stats": db.stats(),
  558. "inbox": {"path": db.INBOX, "processed": db.PROCESSED},
  559. })
  560. if path == "/api/dashboard":
  561. return self._json(build_dashboard(q.get("period", [None])[0]))
  562. if path == "/api/sync/status":
  563. return self._json({"ok": True, "state": SYNC_STATE,
  564. "last_sync": db.get_setting("last_sync"),
  565. "schedule": "每天 %02d:00" % SYNC_HOUR,
  566. "script": SYNC_SCRIPT})
  567. if path == "/api/history":
  568. return self._json({"ok": True, "history": db.import_history(50)})
  569. if path == "/api/copytext":
  570. return self._json({"ok": True, "texts": db.get_texts(),
  571. "defaults": rules.DEFAULT_TEXTS})
  572. if path.startswith("/api/batch/") and path.endswith("/file"):
  573. try:
  574. bid = int(path.split("/")[3])
  575. except (ValueError, IndexError):
  576. return self._err("批次号不正确", 400)
  577. return self._send_batch_file(db.get_batch(bid))
  578. if path == "/api/export.file":
  579. return self._send_batch_file(db.latest_batch_with_file(q.get("period", [None])[0]))
  580. if path == "/api/export.csv":
  581. body, period = build_export(q)
  582. name = "门店数据_%s.csv" % (period or "全部")
  583. return self._send(200, body, "text/csv; charset=utf-8",
  584. {"Content-Disposition":
  585. "attachment; filename*=UTF-8''%s" % urllib.parse.quote(name)})
  586. return self._static(path)
  587. except Exception as e:
  588. traceback.print_exc()
  589. self._err("服务端异常: %s" % e, 500)
  590. def _send_batch_file(self, batch):
  591. """原样返回某次导入的原始文件"""
  592. if not batch or not batch.get("file_path"):
  593. return self._err("这个批次没有留存原始文件", 404)
  594. p = os.path.join(db.PROCESSED, batch["file_path"])
  595. if not os.path.isfile(p):
  596. return self._err("原始文件已不存在(可能被清理)", 404)
  597. import re as _re
  598. name = _re.sub(r"^\d{8}_\d{6}_", "", batch["file_path"])
  599. ctype = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  600. if name.lower().endswith(".xlsx") else "text/csv; charset=utf-8")
  601. with open(p, "rb") as fp:
  602. body = fp.read()
  603. return self._send(200, body, ctype,
  604. {"Content-Disposition":
  605. "attachment; filename*=UTF-8''%s" % urllib.parse.quote(name)})
  606. def do_PUT(self):
  607. try:
  608. u = urllib.parse.urlparse(self.path)
  609. self._auth()
  610. if u.path.startswith("/api/") and not self._guard("PUT", u.path):
  611. return
  612. if u.path == "/api/me":
  613. return self._me_update()
  614. if u.path.startswith("/api/users/"):
  615. return self._user_update(int(u.path.rsplit("/", 1)[1]))
  616. if u.path == "/api/thresholds":
  617. body = json.loads(self._body().decode("utf-8"))
  618. merged = db.get_thresholds()
  619. merged.update(body)
  620. errs = rules.validate(merged)
  621. if errs:
  622. return self._err(";".join(errs), 422)
  623. db.set_thresholds(merged)
  624. ok, msg = codesync.write_defaults(merged)
  625. return self._json({"ok": True, "thresholds": db.get_thresholds(),
  626. "code_synced": ok, "code_msg": msg,
  627. "factory": codesync.get_factory()})
  628. if u.path == "/api/copytext":
  629. body = json.loads(self._body().decode("utf-8"))
  630. if not isinstance(body, dict):
  631. return self._err("文案格式不正确", 422)
  632. merged = db.get_texts()
  633. for k, v in body.items():
  634. if k in rules.DEFAULT_TEXTS:
  635. merged[k] = str(v if v is not None else "")
  636. db.set_texts(merged)
  637. return self._json({"ok": True, "texts": db.get_texts()})
  638. self._err("未知接口", 404)
  639. except Exception as e:
  640. traceback.print_exc()
  641. self._err("保存失败: %s" % e, 500)
  642. def do_POST(self):
  643. try:
  644. u = urllib.parse.urlparse(self.path)
  645. q = urllib.parse.parse_qs(u.query)
  646. self._auth()
  647. if u.path.startswith("/api/") and not self._guard("POST", u.path):
  648. return
  649. if u.path == "/api/login":
  650. return self._login()
  651. if u.path == "/api/logout":
  652. if self.sid:
  653. db.delete_session(self.sid)
  654. return self._send(200, json.dumps({"ok": True}).encode(),
  655. "application/json; charset=utf-8",
  656. {"Set-Cookie": "sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"})
  657. if u.path == "/api/users":
  658. return self._user_create()
  659. if u.path == "/api/import":
  660. raw = self._body()
  661. if not raw:
  662. return self._err("文件内容为空")
  663. filename = q.get("filename", ["upload.csv"])[0]
  664. period = q.get("period", [None])[0] or None
  665. date = q.get("date", [None])[0] or None
  666. stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  667. dest = "%s_%s" % (stamp, filename)
  668. try:
  669. os.makedirs(db.PROCESSED, exist_ok=True)
  670. with open(os.path.join(db.PROCESSED, dest), "wb") as fp:
  671. fp.write(raw)
  672. except OSError:
  673. dest = None
  674. result = ingest.import_bytes(raw, filename, period=period, date=date, file_path=dest)
  675. result["ok"] = result.get("ok", False)
  676. return self._json(result, 200 if result["ok"] else 422)
  677. if u.path == "/api/sync":
  678. if SYNC_STATE.get("running"):
  679. return self._json({"ok": False, "error": "已有同步任务在进行中"}, 409)
  680. threading.Thread(target=run_sync, args=("manual",), daemon=True).start()
  681. return self._json({"ok": True, "msg": "已开始同步,约 3-5 分钟"})
  682. if u.path == "/api/thresholds/reset":
  683. fac = codesync.get_factory()
  684. db.set_thresholds(dict(fac))
  685. ok, msg = codesync.write_defaults(fac)
  686. return self._json({"ok": True, "thresholds": db.get_thresholds(),
  687. "code_synced": ok, "code_msg": msg,
  688. "factory": fac})
  689. if u.path == "/api/copytext/reset":
  690. return self._json({"ok": True, "texts": db.reset_texts()})
  691. self._err("未知接口", 404)
  692. except Exception as e:
  693. traceback.print_exc()
  694. self._err("导入失败: %s" % e, 500)
  695. def bonjour_name():
  696. """取 Bonjour 名(这台机器 hostname 被设成了 IP,不能直接用)"""
  697. try:
  698. out = subprocess.run(["scutil", "--get", "LocalHostName"],
  699. capture_output=True, text=True, timeout=3).stdout.strip()
  700. if out:
  701. return out
  702. except Exception:
  703. pass
  704. h = socket.gethostname().split(".")[0]
  705. return None if _re_is_ip(h) else h
  706. def _re_is_ip(v):
  707. import re as _r
  708. return bool(_r.match(r"^\d{1,3}(\.\d{1,3}){3}$", v or ""))
  709. def lan_ips():
  710. """列出本机可用的局域网 IPv4 地址(用于手机/其他电脑访问)"""
  711. ips = []
  712. try:
  713. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  714. s.connect(("8.8.8.8", 80))
  715. ips.append(s.getsockname()[0])
  716. s.close()
  717. except Exception:
  718. pass
  719. try:
  720. for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
  721. ip = info[4][0]
  722. if ip not in ips:
  723. ips.append(ip)
  724. except Exception:
  725. pass
  726. return [ip for ip in ips if not ip.startswith("127.")]
  727. def main():
  728. port = 8787
  729. args = sys.argv[1:]
  730. if "--port" in args:
  731. port = int(args[args.index("--port") + 1])
  732. lan = "--local" not in args # 默认开放给局域网,--local 只绑本机
  733. host = "0.0.0.0" if lan else "127.0.0.1"
  734. db.init_db()
  735. init_pw = ensure_admin()
  736. if init_pw:
  737. print(" [首次启动] 已创建管理员账号 admin / %s —— 登录后请立刻修改密码" % init_pw)
  738. threading.Thread(target=watch_inbox, args=(20, lambda m: print(m)), daemon=True).start()
  739. # 自动同步依赖 ego-browser(要能操作已登录生意经的浏览器)。
  740. # 服务器上没有它就别启这个线程,免得每天 01:00 报一次错。
  741. can_sync = bool(_ego_bin()) and os.path.isfile(SYNC_SCRIPT)
  742. if can_sync:
  743. threading.Thread(target=daily_sync_loop, args=(lambda m: print(m),), daemon=True).start()
  744. srv = ThreadingHTTPServer((host, port), Handler)
  745. url = "http://127.0.0.1:%d" % port
  746. print("=" * 62)
  747. print(" 门店检测工作台已启动")
  748. print(" 本机访问: %s" % url)
  749. if lan:
  750. ips = lan_ips()
  751. if ips:
  752. for ip in ips:
  753. print(" 局域网: http://%s:%d" % (ip, port))
  754. else:
  755. print(" 局域网: 未取到本机 IP,请在命令行执行 ipconfig 查看 IPv4 地址")
  756. hn = bonjour_name()
  757. if hn:
  758. print(" 更好记的写法: http://%s.local:%d (同一 WiFi 下的 Mac / iPhone 可用)" % (hn, port))
  759. print(" 打不开时依次检查:① 两台设备是否同一 WiFi ② 本机是否休眠 ③ 系统设置→网络→防火墙")
  760. else:
  761. print(" 已限制为仅本机访问(--local)")
  762. print(" 数据目录: %s" % db.DATA_DIR)
  763. print(" 收件箱: %s (CSV/Excel 丢进去自动导入)" % db.INBOX)
  764. if can_sync:
  765. print(" 数据同步: 每天 %02d:00 自动拉取近30天数据(启动时距上次超过 %d 小时会补跑)"
  766. % (SYNC_HOUR, SYNC_CATCHUP_HOURS))
  767. print(" 立即同步: 左侧栏【同步数据】,或 POST /api/sync")
  768. else:
  769. print(" 数据同步: 未启用(这台机器上没有 ego-browser)")
  770. print(" 请用【导入数据】上传从生意经导出的 Excel")
  771. print(" 停止服务: Ctrl + C")
  772. print("=" * 62)
  773. if "--open" in args:
  774. threading.Timer(1.0, lambda: webbrowser.open(url)).start()
  775. try:
  776. srv.serve_forever()
  777. except KeyboardInterrupt:
  778. print("\n已停止")
  779. srv.shutdown()
  780. if __name__ == "__main__":
  781. main()