Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd83cd9271 | |||
| a1672f3bb8 |
@@ -0,0 +1,66 @@
|
|||||||
|
# 竞品价格监控采集(BIZ-145 / S2)
|
||||||
|
|
||||||
|
按 BIZ-144 v2 竞品名单每周采集一次公开价格信息,落地 JSON/CSV 快照,供 BIZ-146 生成周报。
|
||||||
|
|
||||||
|
## 合规红线(硬编码于 `fetch_prices.py`,不可命令行覆盖)
|
||||||
|
|
||||||
|
| 条目 | 实现 |
|
||||||
|
|------|------|
|
||||||
|
| C1 频率 | 每周一次(cron);单店 ≤10 页;请求间隔 ≥5s |
|
||||||
|
| C2 robots.txt | 被禁止即跳过,记 `skipped_robots` |
|
||||||
|
| C3 不绕反爬 | 固定常规 UA;遇验证码/登录墙(401/403/特征词)即放弃该页 |
|
||||||
|
| C4 字段白名单 | `name / list_price / promo_text / sales_display`,只抽公开页面展示字段 |
|
||||||
|
| C5 留存 | 原始快照保留 90 天,每次运行自动清理过期目录 |
|
||||||
|
| C7 无代理/账号 | 直连、无登录态 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
competitor-price/
|
||||||
|
├── fetch_prices.py # 采集脚本(仅标准库,Python ≥3.10)
|
||||||
|
├── targets.yaml # 竞品名单与 SKU URL(数据 Owner 维护,见文件头注释)
|
||||||
|
├── cron/
|
||||||
|
│ └── competitor-price.cron # 每周一 09:00 GMT+8
|
||||||
|
└── data/YYYY-MM-DD/
|
||||||
|
├── prices.json # 含合规元信息 run_meta + results + failures
|
||||||
|
├── prices.csv # 同数据的 CSV 版(UTF-8 BOM,Excel 可直接打开)
|
||||||
|
└── fetch.log # 运行日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地手动运行(调试用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd monitoring/competitor-price
|
||||||
|
python3 fetch_prices.py # 退出码 0=全部成功,1=存在失败/跳过
|
||||||
|
```
|
||||||
|
|
||||||
|
调试时不触发父单告警:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MULTICA_PARENT_ISSUE="" python3 fetch_prices.py # 空值视为未配置,只写日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装 cron(每周一 09:00 Asia/Shanghai)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l > /tmp/mycron 2>/dev/null || true
|
||||||
|
cat cron/competitor-price.cron >> /tmp/mycron
|
||||||
|
crontab /tmp/mycron && rm /tmp/mycron
|
||||||
|
crontab -l | grep competitor-price # 验证
|
||||||
|
```
|
||||||
|
|
||||||
|
cron 机器需满足:`python3` ≥3.10、`multica` CLI 已登录(用于失败时在父单 BIZ-143 评论告警)。
|
||||||
|
服务器并非 24h 开机的话,cron 条目含 flock 防重入;漏跑可在下周一前手动补跑一次即可。
|
||||||
|
|
||||||
|
## 失败告警
|
||||||
|
|
||||||
|
任一条目非 `ok` 时,脚本在父单 BIZ-143 留一条失败汇总评论(需 `multica` 可用),
|
||||||
|
同时本地 `fetch.log` 留痕。白牌店铺未回填 URL 记为 `skipped_no_url`,属预期,不告警。
|
||||||
|
|
||||||
|
## 已知边界(如实说明)
|
||||||
|
|
||||||
|
- Tmall 店铺首页 `robots.txt` 对通用 UA 默认禁止 → 当前 4 家品牌旗舰店均记 `skipped_robots`,
|
||||||
|
这是合规设计的预期行为,不是脚本故障。要拿到真实价格,需要数据 Owner 在
|
||||||
|
`targets.yaml` 的 `sku_urls` 中回填**允许抓取的页面**,或由业务方决定改用其他公开数据源。
|
||||||
|
- 价格抽取基于页面公开 HTML 的通用正则(`"price"` 字段 / ¥ 符号),对重前端渲染页面
|
||||||
|
可能抽不到 → 记 `ok_no_price_field`,周报侧据此人工复核。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# BIZ-145 竞品价格采集:每周一 09:00(Asia/Shanghai)
|
||||||
|
# 安装方式见同目录 README.md「安装 cron」一节
|
||||||
|
CRON_TZ=Asia/Shanghai
|
||||||
|
0 9 * * 1 flock -n /tmp/competitor-price.lock /usr/bin/env MULTICA_PARENT_ISSUE=448cdf6f-ac97-4c7f-953f-0d92d03c6e85 python3 /home/vincent/.openclaw/workspace/costcodev/EnterpriseArchitect/monitoring/competitor-price/fetch_prices.py >> /home/vincent/.openclaw/workspace/costcodev/EnterpriseArchitect/monitoring/competitor-price/data/cron.log 2>&1
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
2026-09-20 05:32:09,160 INFO === BIZ-145 竞品价格采集启动 2026-09-20 ===
|
||||||
|
2026-09-20 05:32:09,570 WARNING robots.txt 禁止抓取,跳过:https://senshan.tmall.com
|
||||||
|
2026-09-20 05:32:14,843 WARNING robots.txt 禁止抓取,跳过:https://conba.tmall.com
|
||||||
|
2026-09-20 05:32:20,065 WARNING robots.txt 禁止抓取,跳过:https://huqingyutang.tmall.com
|
||||||
|
2026-09-20 05:32:27,297 WARNING robots.txt 禁止抓取,跳过:https://shouxian.tmall.com
|
||||||
|
2026-09-20 05:32:32,298 WARNING 平台头部白牌(每月轮换):无可用 URL(白牌未回填),跳过
|
||||||
|
2026-09-20 05:32:37,304 INFO === 完成:5 条记录,4 条失败 ===
|
||||||
|
2026-09-20 05:32:37,342 ERROR 父单告警发送失败: Command '['multica', 'issue', 'comment', 'add', '', '--content', '⚠️ 竞品价格采集失败告警(2026-09-20 05:32 GMT+8)\n\n- 森山(森宇控股) robots 禁止 https://senshan.tmall.com\n- 康恩贝 robots 禁止 https://conba.tmall.com\n- 胡庆余堂 robots 禁止 https://huqingyutang.tmall.com\n- 寿仙谷 robots 禁止 https://shouxian.tmall.com\n\n详见监控目录 data/ 下当日 fetch.log。']' returned non-zero exit status 1.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
competitor_id,competitor,url,status,name,list_price,promo_text,sales_display
|
||||||
|
senshan,森山(森宇控股),https://senshan.tmall.com,skipped_robots,,,,
|
||||||
|
conba,康恩贝,https://conba.tmall.com,skipped_robots,,,,
|
||||||
|
huqingyutang,胡庆余堂,https://huqingyutang.tmall.com,skipped_robots,,,,
|
||||||
|
shouxiangu,寿仙谷,https://shouxian.tmall.com,skipped_robots,,,,
|
||||||
|
white_label_monthly,平台头部白牌(每月轮换),,skipped_no_url,,,,
|
||||||
|
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"run_date": "2026-09-20",
|
||||||
|
"tz": "Asia/Shanghai",
|
||||||
|
"compliance": {
|
||||||
|
"C1_interval_sec": 5.0,
|
||||||
|
"C1_max_pages_per_shop": 10,
|
||||||
|
"C2_robots_respected": true,
|
||||||
|
"C3_user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 (+internal price monitoring; contact: ops@bizwingsinc.com)",
|
||||||
|
"C4_field_whitelist": [
|
||||||
|
"name",
|
||||||
|
"list_price",
|
||||||
|
"promo_text",
|
||||||
|
"sales_display"
|
||||||
|
],
|
||||||
|
"C5_retention_days": 90,
|
||||||
|
"C7_no_proxy_no_login": true
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"competitor_id": "senshan",
|
||||||
|
"competitor": "森山(森宇控股)",
|
||||||
|
"url": "https://senshan.tmall.com",
|
||||||
|
"status": "skipped_robots",
|
||||||
|
"name": "",
|
||||||
|
"list_price": "",
|
||||||
|
"promo_text": "",
|
||||||
|
"sales_display": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"competitor_id": "conba",
|
||||||
|
"competitor": "康恩贝",
|
||||||
|
"url": "https://conba.tmall.com",
|
||||||
|
"status": "skipped_robots",
|
||||||
|
"name": "",
|
||||||
|
"list_price": "",
|
||||||
|
"promo_text": "",
|
||||||
|
"sales_display": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"competitor_id": "huqingyutang",
|
||||||
|
"competitor": "胡庆余堂",
|
||||||
|
"url": "https://huqingyutang.tmall.com",
|
||||||
|
"status": "skipped_robots",
|
||||||
|
"name": "",
|
||||||
|
"list_price": "",
|
||||||
|
"promo_text": "",
|
||||||
|
"sales_display": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"competitor_id": "shouxiangu",
|
||||||
|
"competitor": "寿仙谷",
|
||||||
|
"url": "https://shouxian.tmall.com",
|
||||||
|
"status": "skipped_robots",
|
||||||
|
"name": "",
|
||||||
|
"list_price": "",
|
||||||
|
"promo_text": "",
|
||||||
|
"sales_display": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"competitor_id": "white_label_monthly",
|
||||||
|
"competitor": "平台头部白牌(每月轮换)",
|
||||||
|
"url": "",
|
||||||
|
"status": "skipped_no_url",
|
||||||
|
"name": "",
|
||||||
|
"list_price": "",
|
||||||
|
"promo_text": "",
|
||||||
|
"sales_display": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"failures": [
|
||||||
|
"森山(森宇控股) robots 禁止 https://senshan.tmall.com",
|
||||||
|
"康恩贝 robots 禁止 https://conba.tmall.com",
|
||||||
|
"胡庆余堂 robots 禁止 https://huqingyutang.tmall.com",
|
||||||
|
"寿仙谷 robots 禁止 https://shouxian.tmall.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
BIZ-145 / S2 竞品价格采集脚本
|
||||||
|
|
||||||
|
合规红线(法务 BIZ-147 复核意见 C1–C7,全部硬编码,不可被命令行覆盖):
|
||||||
|
C1 频率封顶:单店每周 ≤1 次、单次 ≤10 页面、请求间隔 ≥5 秒
|
||||||
|
C2 遵守 robots.txt:被禁抓即跳过
|
||||||
|
C3 不绕反爬:固定常规 UA、不用 IP 池、遇验证码/登录墙即放弃该页面
|
||||||
|
C4 字段白名单:商品名称 / 页面展示标价 / 页面展示促销信息 / 页面可见销量数
|
||||||
|
C5 数据留存:原始快照 ≤90 天(--prune 清理)
|
||||||
|
C7 不使用代理/账号池:直连、无登录态
|
||||||
|
|
||||||
|
输出:data/YYYY-MM-DD/prices.json + prices.csv + fetch.log
|
||||||
|
失败告警:整体或单店失败时写日志;环境变量 MULTICA_PARENT_ISSUE 存在时
|
||||||
|
通过 multica CLI 在父单留一条失败通知评论。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.robotparser
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------- 合规常量(C1–C7,硬编码) ----------------
|
||||||
|
MAX_PAGES_PER_SHOP = 10 # C1
|
||||||
|
MIN_INTERVAL_SEC = 5.0 # C1
|
||||||
|
RETENTION_DAYS = 90 # C5
|
||||||
|
USER_AGENT = ( # C3/C6:常规 UA,不带伪装、附采集说明联系方式
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 "
|
||||||
|
"(+internal price monitoring; contact: ops@bizwingsinc.com)"
|
||||||
|
)
|
||||||
|
TIMEOUT_SEC = 20
|
||||||
|
ALLOWED_FIELDS = ("name", "list_price", "promo_text", "sales_display") # C4 白名单
|
||||||
|
|
||||||
|
# 验证码 / 登录墙特征(C3:命中即放弃,不尝试绕过)
|
||||||
|
BLOCK_PATTERNS = [
|
||||||
|
"login.taobao.com", "passport", "验证码", "verify", "captcha",
|
||||||
|
"slide.tsx", "punish", "sec.taobao",
|
||||||
|
]
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
TARGETS_FILE = BASE_DIR / "targets.yaml"
|
||||||
|
CST = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
log = logging.getLogger("competitor-price")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_file: Path):
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||||
|
sh = logging.StreamHandler(sys.stdout)
|
||||||
|
sh.setFormatter(fmt)
|
||||||
|
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
fh.setFormatter(fmt)
|
||||||
|
log.addHandler(sh)
|
||||||
|
log.addHandler(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def load_targets() -> list[dict]:
|
||||||
|
"""极简 YAML 解析(targets.yaml 结构固定,避免引入第三方依赖)。"""
|
||||||
|
text = TARGETS_FILE.read_text(encoding="utf-8")
|
||||||
|
competitors, cur, in_sku, in_monitor = [], None, False, False
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.split("#", 1)[0].rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("- id:"):
|
||||||
|
cur = {"id": stripped.split(":", 1)[1].strip(), "sku_urls": [],
|
||||||
|
"monitor_skus": []}
|
||||||
|
competitors.append(cur)
|
||||||
|
in_sku = in_monitor = False
|
||||||
|
elif cur is None:
|
||||||
|
continue
|
||||||
|
elif re.match(r"^- \w+:", stripped) is None and ":" in stripped and not stripped.startswith("-"):
|
||||||
|
key, _, val = stripped.partition(":")
|
||||||
|
val = val.strip().strip('"')
|
||||||
|
if key == "sku_urls":
|
||||||
|
in_sku, in_monitor = True, False
|
||||||
|
if val.startswith("[") and val != "[]":
|
||||||
|
cur["sku_urls"] = [u.strip().strip('"') for u in val.strip("[]").split(",") if u.strip()]
|
||||||
|
elif key == "monitor_skus":
|
||||||
|
in_monitor, in_sku = True, False
|
||||||
|
else:
|
||||||
|
cur[key] = val
|
||||||
|
in_sku = in_monitor = False
|
||||||
|
elif stripped.startswith("-") and in_sku:
|
||||||
|
cur["sku_urls"].append(stripped.lstrip("- ").strip('"'))
|
||||||
|
elif stripped.startswith("-") and in_monitor:
|
||||||
|
cur["monitor_skus"].append(stripped.lstrip("- ").strip('"'))
|
||||||
|
return competitors
|
||||||
|
|
||||||
|
|
||||||
|
def robots_allows(url: str) -> bool:
|
||||||
|
"""C2:遵守 robots.txt;出错时保守处理为允许但记日志。"""
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
rp_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
||||||
|
rp = urllib.robotparser.RobotFileParser()
|
||||||
|
try:
|
||||||
|
rp.set_url(rp_url)
|
||||||
|
rp.read()
|
||||||
|
return rp.can_fetch(USER_AGENT, url)
|
||||||
|
except Exception as e: # robots 不可达时不放大风险,记录并放行(人工每周低频)
|
||||||
|
log.warning("robots.txt 读取失败 %s: %s(保守放行,人工复核)", rp_url, e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_page(url: str) -> tuple[int | None, str]:
|
||||||
|
"""返回 (http_status, html)。不跟随登录跳转以外的异常。"""
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT,
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
||||||
|
html = resp.read(2_000_000).decode("utf-8", errors="replace")
|
||||||
|
return resp.status, html
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, ""
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("请求异常 %s: %s", url, e)
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
def looks_blocked(html: str, final_status: int | None) -> bool:
|
||||||
|
"""C3:命中反爬/登录墙特征 → 放弃该页面。"""
|
||||||
|
if final_status in (401, 403):
|
||||||
|
return True
|
||||||
|
low = html.lower()
|
||||||
|
return any(p.lower() in low for p in BLOCK_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
PRICE_RE = re.compile(r'"price"\s*:\s*"?([\d.]+)"?|¥\s*([\d]+(?:\.\d+)?)|¥\s*([\d]+(?:\.\d+)?)')
|
||||||
|
PROMO_RE = re.compile(r"(满\d+减\d+|第二件半价|买一送一|买赠|券后)")
|
||||||
|
SALES_RE = re.compile(r"(?:月销|已售|销量)\s*[::]?\s*([\d,]+\+?)")
|
||||||
|
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fields(url: str, html: str) -> dict:
|
||||||
|
"""C4:只抽白名单字段,抽不到就留空并在 status 标注。"""
|
||||||
|
out = {f: "" for f in ALLOWED_FIELDS}
|
||||||
|
m = TITLE_RE.search(html)
|
||||||
|
if m:
|
||||||
|
out["name"] = re.sub(r"-(天猫|淘宝网).*$", "", m.group(1)).strip()[:120]
|
||||||
|
m = PRICE_RE.search(html)
|
||||||
|
if m:
|
||||||
|
out["list_price"] = next(g for g in m.groups() if g)
|
||||||
|
promos = PROMO_RE.findall(html)
|
||||||
|
if promos:
|
||||||
|
out["promo_text"] = ";".join(sorted(set(promos))[:5])
|
||||||
|
m = SALES_RE.search(html)
|
||||||
|
if m:
|
||||||
|
out["sales_display"] = m.group(1)
|
||||||
|
return {k: v for k, v in out.items() if k in ALLOWED_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_parent_issue(summary: str):
|
||||||
|
"""失败告警:在父单留评论。无 MULTICA_PARENT_ISSUE 时只写日志。"""
|
||||||
|
issue = os.environ.get("MULTICA_PARENT_ISSUE") or "448cdf6f-ac97-4c7f-953f-0d92d03c6e85"
|
||||||
|
body = f"⚠️ 竞品价格采集失败告警({datetime.now(CST):%Y-%m-%d %H:%M} GMT+8)\n\n{summary}\n\n详见监控目录 data/ 下当日 fetch.log。"
|
||||||
|
try:
|
||||||
|
subprocess.run(["multica", "issue", "comment", "add", issue,
|
||||||
|
"--content", body], check=True, timeout=60,
|
||||||
|
capture_output=True)
|
||||||
|
log.info("已在父单 %s 留言告警", issue)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("父单告警发送失败: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_snapshots():
|
||||||
|
"""C5:删除 90 天前的采集目录。"""
|
||||||
|
cutoff = datetime.now(CST).date() - timedelta(days=RETENTION_DAYS)
|
||||||
|
for d in DATA_DIR.iterdir() if DATA_DIR.exists() else []:
|
||||||
|
if d.is_dir():
|
||||||
|
try:
|
||||||
|
day = datetime.strptime(d.name, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if day < cutoff:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(d)
|
||||||
|
log.info("C5 留存清理:删除 %s", d)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_one(comp: dict, today_dir: Path, results: list, failures: list):
|
||||||
|
urls = [u for u in comp.get("sku_urls", []) if u]
|
||||||
|
urls = urls or ([comp["shop_url"]] if comp.get("shop_url") else [])
|
||||||
|
if not urls:
|
||||||
|
log.warning("%s:无可用 URL(白牌未回填),跳过", comp["name"])
|
||||||
|
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
|
||||||
|
"url": "", "status": "skipped_no_url",
|
||||||
|
**{f: "" for f in ALLOWED_FIELDS}})
|
||||||
|
return
|
||||||
|
urls = urls[:MAX_PAGES_PER_SHOP] # C1
|
||||||
|
for i, url in enumerate(urls):
|
||||||
|
if i > 0:
|
||||||
|
time.sleep(MIN_INTERVAL_SEC) # C1
|
||||||
|
if not robots_allows(url): # C2
|
||||||
|
log.warning("robots.txt 禁止抓取,跳过:%s", url)
|
||||||
|
failures.append(f"{comp['name']} robots 禁止 {url}")
|
||||||
|
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
|
||||||
|
"url": url, "status": "skipped_robots",
|
||||||
|
**{f: "" for f in ALLOWED_FIELDS}})
|
||||||
|
continue
|
||||||
|
status, html = fetch_page(url)
|
||||||
|
if looks_blocked(html, status): # C3
|
||||||
|
log.warning("疑似登录墙/验证码(HTTP %s),放弃:%s", status, url)
|
||||||
|
failures.append(f"{comp['name']} 被反爬拦截 {url} (HTTP {status})")
|
||||||
|
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
|
||||||
|
"url": url, "status": f"blocked_anti_bot_http_{status}",
|
||||||
|
**{f: "" for f in ALLOWED_FIELDS}})
|
||||||
|
continue
|
||||||
|
if not html:
|
||||||
|
failures.append(f"{comp['name']} 抓取失败 {url} (HTTP {status})")
|
||||||
|
results.append({"competitor_id": comp["id"], "competitor": comp["name"],
|
||||||
|
"url": url, "status": f"fetch_failed_http_{status}",
|
||||||
|
**{f: "" for f in ALLOWED_FIELDS}})
|
||||||
|
continue
|
||||||
|
fields = parse_fields(url, html)
|
||||||
|
row = {"competitor_id": comp["id"], "competitor": comp["name"],
|
||||||
|
"url": url, "status": "ok" if fields.get("list_price") else "ok_no_price_field",
|
||||||
|
**fields}
|
||||||
|
results.append(row)
|
||||||
|
log.info("✅ %s %s → %s", comp["name"], url, row["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
today = datetime.now(CST).strftime("%Y-%m-%d")
|
||||||
|
today_dir = DATA_DIR / today
|
||||||
|
today_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
setup_logging(today_dir / "fetch.log")
|
||||||
|
|
||||||
|
log.info("=== BIZ-145 竞品价格采集启动 %s ===", today)
|
||||||
|
prune_old_snapshots()
|
||||||
|
|
||||||
|
competitors = load_targets()
|
||||||
|
results: list[dict] = []
|
||||||
|
failures: list[str] = []
|
||||||
|
for comp in competitors:
|
||||||
|
collect_one(comp, today_dir, results, failures)
|
||||||
|
time.sleep(MIN_INTERVAL_SEC) # C1:店铺间同样限速
|
||||||
|
|
||||||
|
run_meta = {
|
||||||
|
"run_date": today,
|
||||||
|
"tz": "Asia/Shanghai",
|
||||||
|
"compliance": {"C1_interval_sec": MIN_INTERVAL_SEC,
|
||||||
|
"C1_max_pages_per_shop": MAX_PAGES_PER_SHOP,
|
||||||
|
"C2_robots_respected": True,
|
||||||
|
"C3_user_agent": USER_AGENT,
|
||||||
|
"C4_field_whitelist": list(ALLOWED_FIELDS),
|
||||||
|
"C5_retention_days": RETENTION_DAYS,
|
||||||
|
"C7_no_proxy_no_login": True},
|
||||||
|
"results": results,
|
||||||
|
"failures": failures,
|
||||||
|
}
|
||||||
|
(today_dir / "prices.json").write_text(
|
||||||
|
json.dumps(run_meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
with open(today_dir / "prices.csv", "w", newline="", encoding="utf-8-sig") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=["competitor_id", "competitor", "url",
|
||||||
|
"status", *ALLOWED_FIELDS])
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(results)
|
||||||
|
|
||||||
|
log.info("=== 完成:%d 条记录,%d 条失败 ===", len(results), len(failures))
|
||||||
|
if failures:
|
||||||
|
notify_parent_issue("\n".join(f"- {x}" for x in failures))
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 竞品价格监控目标清单(BIZ-145 / S2)
|
||||||
|
# 数据源:BIZ-144 v2 §1 竞品名单;模板字段:BIZ-144 v2 §3
|
||||||
|
# ⚠️ sku_urls 目前为空 = 仅采集店铺首页公开信息。
|
||||||
|
# 具体 SKU 页 URL 由数据 Owner(陆云帆)每周确认后补充到本文件;
|
||||||
|
# 白牌店铺每月 1 号由执行人按 BIZ-144 v2 §1.1 #5 规则选定后回填 url。
|
||||||
|
|
||||||
|
week_timezone: "Asia/Shanghai"
|
||||||
|
|
||||||
|
competitors:
|
||||||
|
- id: senshan
|
||||||
|
name: 森山(森宇控股)
|
||||||
|
type: brand_flagship
|
||||||
|
shop_url: "https://senshan.tmall.com"
|
||||||
|
monitor_skus: ["铁皮枫斗", "铁皮石斛核心SKU"]
|
||||||
|
sku_urls: [] # TODO(owner): 填入具体 SKU 页面 URL
|
||||||
|
- id: conba
|
||||||
|
name: 康恩贝
|
||||||
|
type: brand_flagship
|
||||||
|
shop_url: "https://conba.tmall.com"
|
||||||
|
monitor_skus: ["石斛/参茸线核心SKU"]
|
||||||
|
sku_urls: []
|
||||||
|
- id: huqingyutang
|
||||||
|
name: 胡庆余堂
|
||||||
|
type: brand_flagship
|
||||||
|
shop_url: "https://huqingyutang.tmall.com"
|
||||||
|
monitor_skus: ["石斛及传统滋补礼盒"]
|
||||||
|
sku_urls: []
|
||||||
|
- id: shouxiangu
|
||||||
|
name: 寿仙谷
|
||||||
|
type: brand_flagship
|
||||||
|
shop_url: "https://shouxian.tmall.com"
|
||||||
|
monitor_skus: ["铁皮石斛", "灵芝孢子粉"]
|
||||||
|
sku_urls: []
|
||||||
|
- id: white_label_monthly
|
||||||
|
name: 平台头部白牌(每月轮换)
|
||||||
|
type: marketplace
|
||||||
|
shop_url: "" # 每月 1 号由执行人回填当月店铺 URL
|
||||||
|
monitor_skus: ["同规格散装石斛价格带下限"]
|
||||||
|
sku_urls: []
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- BIZ-127 方案 C 完整用户系统 —— 数据模型 SQL DDL
|
||||||
|
-- ============================================================
|
||||||
|
-- 设计人:梁思筑(architect)| 2026-09-04
|
||||||
|
-- 适用版本:lottoData v2.0(Flask + SQLite)
|
||||||
|
-- 数据库:SQLite 3.x(与现有 lottery.db 同库同文件,复用 DB 连接)
|
||||||
|
-- 兼容性:当前 SQLite 文件存储(.generation_records.json)将迁移至本 schema
|
||||||
|
-- 字符集:UTF-8
|
||||||
|
-- 加密策略:bcrypt(cost=12) 密码哈希、session 哈希、不存任何明文凭证
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1. users —— 用户表
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt cost=12 哈希
|
||||||
|
email TEXT, -- 可选,找回密码用
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' -- 预留:admin / user
|
||||||
|
CHECK (role IN ('user', 'admin')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' -- active / locked / disabled
|
||||||
|
CHECK (status IN ('active', 'locked', 'disabled')),
|
||||||
|
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留,单租户默认 1
|
||||||
|
failed_attempts INTEGER NOT NULL DEFAULT 0, -- 登录失败计数(达到 5 锁定)
|
||||||
|
locked_until INTEGER, -- Unix 时间戳,NULL=未锁
|
||||||
|
password_changed_at INTEGER NOT NULL, -- 用于旧 session 失效判断
|
||||||
|
last_login_at INTEGER, -- 最近登录时间
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
-- 多用户扩展索引
|
||||||
|
CONSTRAINT idx_users_username UNIQUE (username),
|
||||||
|
CONSTRAINT idx_users_tenant UNIQUE (username, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role_status ON users(role, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2. sessions —— 会话表(服务端 session,cookie 仅存 session_id)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 决策:DB 存 session(不用 JWT),理由:
|
||||||
|
-- - 可控失效(改密码立即清空)
|
||||||
|
-- - 可主动踢人
|
||||||
|
-- - 内网系统不需分布式无状态
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY, -- 32 字节 URL-safe base64
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
session_hash TEXT NOT NULL, -- sha256(session_id + server_secret)
|
||||||
|
user_agent TEXT, -- 浏览器/客户端 UA
|
||||||
|
ip_address TEXT, -- 登录 IP(内网)
|
||||||
|
password_epoch INTEGER NOT NULL, -- 登录时 users.password_changed_at 快照
|
||||||
|
expires_at INTEGER NOT NULL, -- Unix 时间戳(30 天)
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL, -- 用于过期清理
|
||||||
|
revoked_at INTEGER, -- NULL=有效,非 NULL=已撤销
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(user_id, revoked_at, expires_at);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3. password_reset_tokens —— 密码重置 token 表
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 决策:DB 存一次性 token(不用 JWT 自包含),理由:
|
||||||
|
-- - 一次性、可控、可撤销
|
||||||
|
-- - 速率限制基于 DB 计数
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- sha256(明文 token)
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL, -- 1 小时过期
|
||||||
|
used_at INTEGER, -- NULL=未用
|
||||||
|
ip_address TEXT, -- 请求来源 IP
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pwd_reset_user ON password_reset_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pwd_reset_expires ON password_reset_tokens(expires_at);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 4. operation_logs —— 操作日志表
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 用途:审计、追踪、异常告警
|
||||||
|
-- 保留周期:90 天(按月度手动归档)
|
||||||
|
-- 未来扩展:按 tenant_id 分区(本次单租户,分区预留)
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER, -- 公开操作可 NULL
|
||||||
|
tenant_id INTEGER NOT NULL DEFAULT 1, -- 多租户预留
|
||||||
|
username TEXT, -- 冗余存储,避免 JOIN
|
||||||
|
action TEXT NOT NULL, -- login / logout / generate / delete / reset_request ...
|
||||||
|
resource_type TEXT, -- record / config / user ...
|
||||||
|
resource_id TEXT, -- 关联 ID
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success' -- success / failure
|
||||||
|
CHECK (status IN ('success', 'failure')),
|
||||||
|
detail TEXT, -- JSON 字符串,存额外上下文
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_tenant_time ON operation_logs(tenant_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, created_at);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 5. auth_settings —— 全局鉴权配置(单行配置表)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 用途:运行时调整鉴权策略,不重启服务
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), -- 仅允许 id=1
|
||||||
|
bcrypt_cost INTEGER NOT NULL DEFAULT 12, -- 12-14
|
||||||
|
session_max_age_sec INTEGER NOT NULL DEFAULT 2592000, -- 30 天
|
||||||
|
session_renew_sec INTEGER NOT NULL DEFAULT 86400, -- 24h 内活跃则续期
|
||||||
|
max_failed_attempts INTEGER NOT NULL DEFAULT 5,
|
||||||
|
lock_duration_sec INTEGER NOT NULL DEFAULT 900, -- 15 分钟
|
||||||
|
pwd_reset_ttl_sec INTEGER NOT NULL DEFAULT 3600, -- 1 小时
|
||||||
|
pwd_reset_rate_limit INTEGER NOT NULL DEFAULT 3, -- 每用户每小时最多 3 次
|
||||||
|
log_retention_days INTEGER NOT NULL DEFAULT 90,
|
||||||
|
maintenance_mode INTEGER NOT NULL DEFAULT 0, -- 0=正常 1=维护
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 初始化默认配置(仅在表为空时插入)
|
||||||
|
INSERT OR IGNORE INTO auth_settings (id, updated_at)
|
||||||
|
VALUES (1, strftime('%s', 'now'));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 二、迁移脚本(从当前 JSON 文件存储 + Bearer-only 模式)
|
||||||
|
-- ============================================================
|
||||||
|
-- 迁移目标:
|
||||||
|
-- 1. 初始化 users 表,创建默认管理员账号
|
||||||
|
-- 2. 现有 .generation_records.json 数据保留,不迁移(无 user_id 字段)
|
||||||
|
-- 3. operation_logs 表从空开始
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 迁移步骤 1:创建默认管理员账号
|
||||||
|
-- 默认用户名:lotto_admin
|
||||||
|
-- 默认密码:BIZ-127-Lottery-2026(首次登录后强制修改)
|
||||||
|
-- 警告:此密码必须在首次登录后立即修改(通过 BIZ-129 强制流程)
|
||||||
|
INSERT OR IGNORE INTO users (
|
||||||
|
username, password_hash, email, role, status,
|
||||||
|
tenant_id, failed_attempts, password_changed_at,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'lotto_admin',
|
||||||
|
-- bcrypt('BIZ-127-Lottery-2026', cost=12)
|
||||||
|
-- 由 BIZ-129 实施时用 Python bcrypt 生成,SQL 仅作占位
|
||||||
|
'__BCRYPT_PLACEHOLDER__',
|
||||||
|
NULL, 'admin', 'active', 1, 0,
|
||||||
|
strftime('%s', 'now'), strftime('%s', 'now'), strftime('%s', 'now')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移步骤 2:保留 .generation_records.json 数据
|
||||||
|
-- 策略:不迁移到 operation_logs(records 是业务数据,不是审计日志)
|
||||||
|
-- 备注:BIZ-129 需在 add_record() 中写入 operation_logs(action='generate')
|
||||||
|
|
||||||
|
-- 迁移步骤 3:环境变量兼容
|
||||||
|
-- 保留 LOTTO_API_TOKEN(Bearer 备用通道,仅供内部服务调用)
|
||||||
|
-- 用户系统上线后,外部前端用 cookie,内部脚本用 Bearer
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 三、清理脚本(可选,生产环境 90 天后执行)
|
||||||
|
-- ============================================================
|
||||||
|
-- DELETE FROM sessions WHERE expires_at < strftime('%s', 'now');
|
||||||
|
-- DELETE FROM password_reset_tokens WHERE expires_at < strftime('%s', 'now');
|
||||||
|
-- DELETE FROM operation_logs WHERE created_at < strftime('%s', 'now', '-90 days');
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# BIZ-127 完整用户系统架构设计 v1.0
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **版本** | v1.0 |
|
||||||
|
| **作者** | 梁思筑(architect) |
|
||||||
|
| **日期** | 2026-09-04 |
|
||||||
|
| **关联卡片** | BIZ-127(架构)→ BIZ-129(实施) |
|
||||||
|
| **父卡片** | BIZ-109(P0 修复闭环) |
|
||||||
|
| **关联 PRD** | BIZ-111(产品 PRD) |
|
||||||
|
| **存储位置** | docs/BIZ-127_完整用户系统架构_v1.0.md |
|
||||||
|
| **同步位置** | 思源知识库 `/standards/完整用户系统架构 v1.0` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标
|
||||||
|
|
||||||
|
实现完整的账号密码登录体系,支持长期免登录(30 天),账号密码 + Session Cookie 方案,不在 URL/前端暴露 token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据模型
|
||||||
|
|
||||||
|
**完整 SQL DDL** 见 `docs/auth-schema.sql`(9.7KB)。
|
||||||
|
|
||||||
|
包含 5 张表:
|
||||||
|
1. **users** —— 用户表(bcrypt 密码哈希、登录失败计数、锁定时间戳、多租户/角色预留)
|
||||||
|
2. **sessions** —— 服务端会话(DB 存,非 JWT;存 session_hash、password_epoch 用于改密失效)
|
||||||
|
3. **password_reset_tokens** —— 密码重置 token(一次性、1 小时过期、限速 3 次/小时)
|
||||||
|
4. **operation_logs** —— 操作日志(90 天保留、多租户索引预留)
|
||||||
|
5. **auth_settings** —— 全局鉴权配置(bcrypt cost、session 寿命、锁定参数)
|
||||||
|
|
||||||
|
### 关键决策
|
||||||
|
|
||||||
|
| 决策点 | 选择 | 理由 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Session 存储 | **DB 存** | 可控失效、改密立即踢人;JWT 不适合内网场景 |
|
||||||
|
| 重置 token 存储 | **DB 存** | 一次性、可控、可限速;JWT 自包含不可撤销 |
|
||||||
|
| 密码哈希 | **bcrypt cost=12** | 行业标准,登录耗时 ~250ms,安全/性能平衡 |
|
||||||
|
| 多用户扩展 | **数据层预留 + UI 不暴露** | 见下文"多用户扩展决策" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、鉴权中间件流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
请求进入
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 日志中间件 │ ← 记录 method/path/IP/UA
|
||||||
|
│ (log_request) │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 鉴权中间件 │ ← 优先读 cookie,fallback Bearer
|
||||||
|
│ (authenticate) │ Bearer 仅用于内部服务调用
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
│ 判断:path 是否在白名单?
|
||||||
|
│
|
||||||
|
┌─────┴──────┐
|
||||||
|
│ │
|
||||||
|
YES (白名单) NO
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────────────┐
|
||||||
|
│ │ require_auth 装饰器 │ ← 验证 session_id
|
||||||
|
│ │ - 查 sessions 表 │ 校验:
|
||||||
|
│ │ - 校验 session_hash│ 1. session 存在
|
||||||
|
│ │ - 校验 expires_at │ 2. 未撤销
|
||||||
|
│ │ - 校验密码 epoch │ 3. 未过期
|
||||||
|
│ │ - 续期(如活跃) │ 4. password_epoch 匹配
|
||||||
|
│ └──────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────────────┐
|
||||||
|
│ │ 业务中间件 │
|
||||||
|
│ │ (业务逻辑处理) │
|
||||||
|
│ └──────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────────────┐
|
||||||
|
│ │ 日志中间件(出口) │ ← 记录 status/duration/user_id
|
||||||
|
│ └──────────────────────┘
|
||||||
|
│
|
||||||
|
└──▶ 业务中间件(白名单接口无需鉴权)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5 个鉴权端点
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/login` | POST | 账号密码 → 校验 → 创建 session → 设置 cookie |
|
||||||
|
| `/api/logout` | POST | 清 cookie → 撤销 session(revoked_at=now) |
|
||||||
|
| `/api/password-reset/request` | POST | 生成 token → 邮件/日志通知(内网暂用日志) |
|
||||||
|
| `/api/password-reset/confirm` | POST | 校验 token → 改密码 → 撤销该用户所有 session |
|
||||||
|
|
||||||
|
### 装饰器改造
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 现状
|
||||||
|
@require_auth
|
||||||
|
def api_records(): ...
|
||||||
|
|
||||||
|
# 改造后(伪代码,BIZ-129 实施)
|
||||||
|
@require_auth # 内部自动读 cookie 或 Bearer
|
||||||
|
def api_records(): ...
|
||||||
|
|
||||||
|
# 新增装饰器
|
||||||
|
@require_admin # role == 'admin' 才允许
|
||||||
|
def admin_only_api(): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Cookie 安全策略
|
||||||
|
|
||||||
|
| 项 | 值 | 决策依据 |
|
||||||
|
|---|----|---------|
|
||||||
|
| **名称** | `lotto_session` | 业务前缀,避免与其它服务冲突 |
|
||||||
|
| **httpOnly** | `true` | ✅ 阻止 XSS 读取 cookie |
|
||||||
|
| **secure** | `true`(生产) / `false`(内网 HTTP 调试) | 内网暂不强制 HTTPS,但配置项保留 |
|
||||||
|
| **sameSite** | `Lax` | ✅ 防 CSRF;`Strict` 会破坏外链跳转登录 |
|
||||||
|
| **path** | `/` | 全站生效 |
|
||||||
|
| **maxAge** | `30 * 24 * 3600` 秒(30 天) | 满足"长期免登录"诉求 |
|
||||||
|
| **domain** | 当前域(不设) | 内网单域,不跨子域 |
|
||||||
|
|
||||||
|
### 防 CSRF 决策
|
||||||
|
|
||||||
|
**不单独引入 CSRF token**,理由:
|
||||||
|
- `SameSite=Lax` 已防 95% CSRF(POST 表单跨站不携带 cookie)
|
||||||
|
- 关键操作(删除、改密码)二次校验(要求输入当前密码或确认)
|
||||||
|
- 如未来扩展第三方嵌入或 iframe 调用,再加 CSRF token
|
||||||
|
|
||||||
|
### Session 续期策略
|
||||||
|
|
||||||
|
- 用户每次活跃请求,若 `expires_at - now < 24h`,则续期 30 天
|
||||||
|
- `last_seen_at` 用于清理过期 session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、多用户扩展决策(4 个判断点)
|
||||||
|
|
||||||
|
| 判断点 | 是否预留 | 决策 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 1. 数据库 `users.tenant_id` / `role` 字段 | **✅ 预留** | 已建字段,索引就绪,单租户默认 1 |
|
||||||
|
| 2. API 路径 `/api/admin/users/*` | **❌ 不预留** | 过度设计;未来需要时再加,无需现在占坑 |
|
||||||
|
| 3. 前端「多用户切换」入口 | **❌ 不预留** | UI 保持单用户登录,多用户场景届时再迭代 |
|
||||||
|
| 4. 操作日志按 `tenant_id` 分区 | **✅ 预留索引** | 已建 `idx_oplog_tenant_time`,分区待数据量>10万再分表 |
|
||||||
|
|
||||||
|
**预留原则**:数据库层 vs API/UI 层
|
||||||
|
- **数据层**:必须预留(成本低、不影响当前 UI)
|
||||||
|
- **API/UI 层**:不预留(避免过度设计、未来按需添加)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、兼容性矩阵
|
||||||
|
|
||||||
|
| 项 | 当前实现 | 方案 C 后 | 迁移路径 |
|
||||||
|
|----|---------|----------|---------|
|
||||||
|
| `/api/status` | 无鉴权 | 保持无鉴权(status 公开) | 无变更 |
|
||||||
|
| `/api/records` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 双轨运行 24h,前端切到 cookie 后撤 Bearer |
|
||||||
|
| `/api/history` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||||
|
| `/api/generate` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||||
|
| `/api/statistics` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||||
|
| `/api/download/*` | Bearer 鉴权 | Cookie 鉴权 + Bearer 备用 | 同上 |
|
||||||
|
| Bearer token | `LOTTO_API_TOKEN` env 强制 | 保留 env(内部脚本用) | 不删除,作为内部服务通道 |
|
||||||
|
| Cookie | 无 | `lotto_session` 30 天 | 新增 |
|
||||||
|
| 默认账号 | 无 | `lotto_admin` + 临时密码 | 首次登录强制改密 |
|
||||||
|
|
||||||
|
**双轨过渡策略**:
|
||||||
|
1. 上线 Cookie 鉴权后,前端自动改用 cookie
|
||||||
|
2. Bearer 通道保留 24h 给内部脚本迁移
|
||||||
|
3. 24h 后仅允许 Bearer 用于 `/api/internal/*`(如有)
|
||||||
|
4. 监控 `/api/records` 等接口的鉴权失败日志,确认为 0 后撤 Bearer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、性能 + 安全边界(5 项决策)
|
||||||
|
|
||||||
|
| 项 | 决策 | 理由 |
|
||||||
|
|----|------|------|
|
||||||
|
| 1. bcrypt cost=12 登录耗时 | **目标 < 500ms(实测 ~250ms)** | Ubuntu 22.04 + bcrypt cost=12 单次 ~250ms,远低于 500ms 上限 |
|
||||||
|
| 2. Session 失效机制(改密) | **改密码后该用户所有 session 立即失效** | session 表 `password_epoch` 字段;登录时快照,改密后 `password_changed_at` 更新,session 自动失效 |
|
||||||
|
| 3. 找回密码 token 速率限制 | **每用户 3 次/小时** | 防爆破;超限返回 429 |
|
||||||
|
| 4. 登录失败锁定 | **5 次失败锁定 15 分钟** | `users.failed_attempts` + `locked_until`;自动解锁 |
|
||||||
|
| 5. 操作日志保留周期 | **90 天** | 满足审计需求;月度归档到 `operation_logs_archive_yyyyMM`;>10万行/天时启动分区 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、验收 Checklist
|
||||||
|
|
||||||
|
- [x] 完整 SQL DDL(含迁移脚本) → `docs/auth-schema.sql`
|
||||||
|
- [x] 鉴权中间件流程图(文字描述) → 第三节
|
||||||
|
- [x] Cookie 安全策略完整 → 第四节
|
||||||
|
- [x] 多用户扩展接口明确 → 第五节
|
||||||
|
- [x] 兼容性矩阵 → 第六节
|
||||||
|
- [x] 性能 + 安全边界 → 第七节
|
||||||
|
- [x] 架构文档沉淀(思源 + Git) → 同步进行中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、对下游约束(PRD BIZ-111 + 实施 BIZ-129)
|
||||||
|
|
||||||
|
### 给 BIZ-111 PRD 的约束
|
||||||
|
|
||||||
|
1. **登录页**必须有:用户名、密码、"记住我"勾选(默认勾选=30天免登录)
|
||||||
|
2. **找回密码流程**:输入用户名 → 后台生成 token → 通过日志/邮件通知(内网暂用日志)
|
||||||
|
3. **首次登录强制改密**:检测 `password_changed_at == created_at` 时,强制跳改密页
|
||||||
|
4. **UI 不暴露**多用户切换、注册入口(保持单账号)
|
||||||
|
|
||||||
|
### 给 BIZ-129 实施的约束
|
||||||
|
|
||||||
|
1. **必须用 bcrypt cost=12**,不要用 sha256/md5/pbkdf2
|
||||||
|
2. **session_id 必须 32 字节 URL-safe base64**(`secrets.token_urlsafe(32)`)
|
||||||
|
3. **session_hash = sha256(session_id + server_secret)**,secret 存 env
|
||||||
|
4. **不改 LOTTO_API_TOKEN 流程**,保留 Bearer 作为内部通道
|
||||||
|
5. **/api/status 必须保持公开**,不能误加鉴权
|
||||||
|
6. **BIZ-109 严格鉴权基线不能回退**(不能因为 Cookie 引入新的 401 路径)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、风险与待澄清
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 首次部署 bcrypt 哈希生成失败 | 登录失败 | BIZ-129 部署前在本地预生成哈希嵌入 init SQL |
|
||||||
|
| Cookie 30 天过长,安全边界争议 | 安全 | `auth_settings.session_max_age_sec` 运行时可调 |
|
||||||
|
| 多用户预留字段被误用 | 数据污染 | 在 BIZ-129 代码层加 `tenant_id == 1` 断言 |
|
||||||
|
| 操作日志 90 天后未归档 | 磁盘膨胀 | BIZ-129 实施时同时交付归档脚本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、变更历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 | 作者 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| v1.0 | 2026-09-04 | 初版(BIZ-127 架构交付) | 梁思筑 |
|
||||||
Reference in New Issue
Block a user