Compare commits

...

2 Commits

Author SHA1 Message Date
vincent cd83cd9271 feat(BIZ-145): 竞品价格采集脚本 + 每周定时调度
- fetch_prices.py: 标准库实现,C1-C7 合规红线硬编码
- targets.yaml: BIZ-144 v2 名单(4 家品牌旗舰店 + 月度白牌轮换)
- cron/competitor-price.cron: 每周一 09:00 Asia/Shanghai
- README.md: 本地运行 + cron 安装 + 失败告警说明
- data/2026-09-20/: 手动试跑样例(4 家被 robots 禁止=预期合规行为,白牌待回填)

修复: MULTICA_PARENT_ISSUE 空字符串时错误发评,改为空值回退默认父单

Co-authored-by: multica-agent <github@multica.ai>
2026-09-20 05:51:30 +08:00
architect a1672f3bb8 specs(BIZ-127): 完整用户系统架构 v1.0 + SQL DDL
依据 BIZ-19 标准, 同步到 EnterpriseArchitect 仓库:
- 数据模型: 5 表(users/sessions/password_reset_tokens/operation_logs/auth_settings)
- 鉴权流程: 日志 → 鉴权 → 业务 三层中间件
- Cookie: httpOnly + SameSite=Lax + maxAge=30d
- 多用户: 数据层预留, UI/API 不暴露
- 兼容性: Bearer 双轨 24h 过渡

决策对比见文档第七节, 验收 7 项已全部完成
2026-09-04 20:35:48 +08:00
9 changed files with 892 additions and 0 deletions
+66
View File
@@ -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,,,,
1 competitor_id competitor url status name list_price promo_text sales_display
2 senshan 森山(森宇控股) https://senshan.tmall.com skipped_robots
3 conba 康恩贝 https://conba.tmall.com skipped_robots
4 huqingyutang 胡庆余堂 https://huqingyutang.tmall.com skipped_robots
5 shouxiangu 寿仙谷 https://shouxian.tmall.com skipped_robots
6 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"
]
}
+283
View File
@@ -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())
+39
View File
@@ -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: []
+170
View File
@@ -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 架构交付) | 梁思筑 |