Compare commits

..

1 Commits

Author SHA1 Message Date
vincent 90d3f8edca feat: 品斛堂产品扩展可行性分析报告 (BIZ-64/65/66/67)
- BIZ-64: 石斛液态饮品全品类扩展可行性分析
- BIZ-65: 石斛固态食品与烘焙全品类扩展可行性分析
- BIZ-66: 石斛功能保健食品-礼品-创新跨界可行性分析
- BIZ-67: 石斛预制菜与调味品赛道切入可行性分析

来源: BIZ-53 品斛堂深度调研项目
分析人: 顾析策 (marketanalysis)
提交人: 陆怀瑾 (COO)

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 22:11:22 +08:00
32 changed files with 313 additions and 4185 deletions
@@ -1,71 +0,0 @@
# OpenClaw NVIDIA 网关切换至 Metapi — 配置修改指南
> 交付人:严维序(opengineer) | 交付日期:2026-07-03 | 关联Issue:BIZ-76
## 当前状态
你的 `openclaw.json` 中有 **12 个 NVIDIA 独立提供者**,每个直连 `https://integrate.api.nvidia.com/v1`,使用各自的 `nvapi-*` API Key。
Metapi 已部署在 http://192.168.1.99:4000,内部聚合了全部 12 个 NVIDIA 账号,通过代理令牌 `sk-bizwings-metapi-proxy` 对外提供统一的 `/v1` 端点,自动负载均衡。
## 方案 A:逐项修改(保留 12 个提供者结构)
对每个 NVIDIA 提供者做两处替换:
### 1. 修改 `baseUrl`
```
"baseUrl": "https://integrate.api.nvidia.com/v1"
→ "baseUrl": "http://192.168.1.99:4000/v1"
```
### 2. 修改 `apiKey`
```
"apiKey": "nvapi-xxx..."
→ "apiKey": "sk-bizwings-metapi-proxy"
```
## 方案 B:合并为单个提供者(推荐,精简配置)
将 12 个 NVIDIA 提供者合并为 1 个:
```json
{
"id": "nvidia-metapi",
"name": "NVIDIA via Metapi",
"kind": "openai",
"baseUrl": "http://192.168.1.99:4000/v1",
"apiKey": "sk-bizwings-metapi-proxy",
"models": {
"deepseek-ai/deepseek-v4-flash": {},
"deepseek-ai/deepseek-v4-pro": {},
"z-ai/glm-5.1": {},
"z-ai/glm-5.2": {},
"qwen/qwen3.5-397b-a17b": {},
"nvidia/llama-3.3-nemotron-super-49b-v1": {},
"nvidia/mississippi": {}
}
}
```
Metapi 已部署信息:
- 访问地址:http://192.168.1.99:4000
- 管理员令牌:`bizwings-metapi-admin`
- 代理令牌:`sk-bizwings-metapi-proxy`
- 端口:4000
## 12 个 NVIDIA 账户清单
| # | 账号 | 状态 |
|---|------|------|
| 1 | NVIDIA-NIM-default | ✅ active |
| 2 | NVIDIA-NIM-98053 | ✅ active |
| 3 | NVIDIA-NIM-liuweicheng84 | ✅ active |
| 4 | NVIDIA-NIM-vx18088980513 | ✅ active |
| 5 | NVIDIA-NIM-vx64391942 | ✅ active |
| 6 | NVIDIA-NIM-cgtest1 | ✅ active |
| 7 | NVIDIA-NIM-cgtest2 | ✅ active |
| 8 | NVIDIA-NIM-cgtest3 | ✅ active |
| 9 | NVIDIA-NIM-15876517651 | ✅ active |
| 10 | NVIDIA-NIM-19584586741 | ✅ active |
| 11 | NVIDIA-NIM-18874954146 | ✅ active |
| 12 | NVIDIA-NIM-2405483110 | ✅ active |
-178
View File
@@ -1,178 +0,0 @@
# 双色球系统部署文档
## 部署信息
| 项目 | 值 |
|------|-----|
| 项目名称 | 双色球自动化系统 |
| 部署时间 | 2026-06-29 |
| 部署人员 | 严维序 (opengineer) |
| 服务地址 | http://192.168.1.99:5000 |
| 宿主服务器 | Ubuntu-OpenClaw (192.168.1.99) |
---
## 一、项目结构
```
/home/vincent/Studio/lottoData/
├── venv/ # Python 虚拟环境
├── web_executor.py # Flask Web 服务 (监听 0.0.0.0:5000)
├── fetch_data.py # 数据抓取脚本
├── lottery.py # 双色球号码生成器
├── web_console.html # Web 控制台页面
├── LottoSpider/ # 爬虫模块
├── lottery/ # 彩票模块
├── docs/ # 文档
├── 双色球历史数据.xlsx # 历史数据文件
└── deploy/ # 部署文件
├── DEPLOY.md # 本文档
├── lotto-web.service # systemd 服务文件
├── fetch_daily.sh # 每日抓取脚本
├── cron.log # Cron 执行日志
└── fetch_YYYYMMDD.log # 每日抓取详细日志
```
---
## 二、依赖清单
| 包 | 版本 | 用途 |
|----|------|------|
| Flask | 3.1.3 | Web 服务框架 |
| pandas | 3.0.4 | 数据处理 |
| openpyxl | 3.1.5 | Excel 读写 |
| requests | 2.34.2 | HTTP 请求 |
| beautifulsoup4 | 4.15.0 | HTML 解析 |
安装命令:
```bash
python3 -m venv venv
./venv/bin/pip install flask pandas openpyxl requests beautifulsoup4
```
---
## 三、systemd 服务配置
### 服务文件
`/etc/systemd/system/lotto-web.service`
```ini
[Unit]
Description=双色球数据抓取 Web 服务
After=network.target
[Service]
Type=simple
User=vincent
WorkingDirectory=/home/vincent/Studio/lottoData
ExecStart=/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/web_executor.py
ExecStartPre=/home/vincent/Studio/lottoData/venv/bin/python3 -c "import flask; import pandas; import openpyxl; import requests; import bs4"
Restart=on-failure
RestartSec=5
KillMode=control-group
[Install]
WantedBy=multi-user.target
```
### 管理命令
```bash
# 安装/启用
sudo cp deploy/lotto-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable lotto-web
sudo systemctl start lotto-web
# 日常管理
sudo systemctl status lotto-web # 查看状态
sudo systemctl restart lotto-web # 重启
sudo systemctl stop lotto-web # 停止
sudo journalctl -u lotto-web -f # 查看实时日志
```
---
## 四、Cron 定时任务
### Cron 配置
```
30 2 * * * /home/vincent/Studio/lottoData/deploy/fetch_daily.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1
```
每天凌晨 2:30 自动抓取双色球历史数据。
### 手动执行
```bash
/home/vincent/Studio/lottoData/deploy/fetch_daily.sh
# 或
/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/fetch_data.py
```
---
## 五、Web 接口
| 路径 | 方法 | 说明 |
|------|------|------|
| `/` | GET | Web 控制台页面 |
| `/api/status` | GET | 获取执行状态 |
| `/api/execute` | POST | 触发数据抓取 |
### 示例
```bash
# 查看状态
curl http://192.168.1.99:5000/api/status
# 触发抓取
curl -X POST http://192.168.1.99:5000/api/execute
```
---
## 六、验证清单
- [x] 依赖安装完整 (Flask, pandas, openpyxl, requests, beautifulsoup4)
- [x] systemd 服务运行正常 (active, enabled)
- [x] Web 服务可访问 (http://192.168.1.99:5000, HTTP 200)
- [x] API 接口正常 (/api/status, /api/execute)
- [x] Cron 定时任务已配置 (每日 2:30 抓取)
- [x] 手动抓取测试通过 (121 条记录保存成功)
- [x] 开机自启已配置 (systemd enable)
---
## 七、回滚方案
```bash
# 停止服务
sudo systemctl stop lotto-web
sudo systemctl disable lotto-web
sudo rm /etc/systemd/system/lotto-web.service
sudo systemctl daemon-reload
# 移除 cron
crontab -l | grep -v 'lottoData' | crontab -
# 不影响数据文件和代码
```
---
## 八、监控要点
1. **服务存活**:`systemctl status lotto-web` 确认 active
2. **Web 可达**:`curl http://127.0.0.1:5000/api/status`
3. **数据更新**:检查 `/home/vincent/Studio/lottoData/双色球历史数据.xlsx` 修改时间
4. **Cron 日志**:检查 `/home/vincent/Studio/lottoData/deploy/cron.log`
5. **磁盘空间**:Excel 文件约 250KB,可忽略
---
> 部署人:严维序 (opengineer) | 2026-06-29
@@ -1,17 +0,0 @@
#!/bin/bash
# 双色球历史数据每日自动抓取
# Cron: 0 2 * * * /home/vincent/Studio/lottoData/deploy/fetch_daily.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1
SCRIPT_DIR="/home/vincent/Studio/lottoData"
VENV_PYTHON="${SCRIPT_DIR}/venv/bin/python3"
FETCH_SCRIPT="${SCRIPT_DIR}/fetch_data.py"
LOG_DIR="${SCRIPT_DIR}/deploy"
LOG_FILE="${LOG_DIR}/fetch_$(date +%Y%m%d).log"
mkdir -p "${LOG_DIR}"
echo "=== $(date '+%Y-%m-%d %H:%M:%S') 开始执行双色球数据抓取 ==="
"${VENV_PYTHON}" "${FETCH_SCRIPT}" >> "${LOG_FILE}" 2>&1
RC=$?
echo "=== $(date '+%Y-%m-%d %H:%M:%S') 执行完成, exit code=${RC} ==="
exit ${RC}
@@ -1,16 +0,0 @@
[Unit]
Description=双色球数据抓取 Web 服务
After=network.target
[Service]
Type=simple
User=vincent
WorkingDirectory=/home/vincent/Studio/lottoData
ExecStart=/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/web_executor.py
ExecStartPre=/home/vincent/Studio/lottoData/venv/bin/python3 -c "import flask; import pandas; import openpyxl; import requests; import bs4"
Restart=on-failure
RestartSec=5
KillMode=control-group
[Install]
WantedBy=multi-user.target
@@ -1,5 +0,0 @@
Flask==3.1.3
pandas==3.0.4
openpyxl==3.1.5
requests==2.34.2
beautifulsoup4==4.15.0
@@ -1,160 +0,0 @@
# BIZ-104 pre-trim_multica_comment 文档 v1.0
> 版本:v1.0(实施版)
> 编制:严维序(opengineer)
> 日期:2026-08-31
> 状态:已部署,待验证
> BIZ 编号:BIZ-104 Phase ③
> 适用:BIZ-38 模板兼容
---
## 一、背景
**BIZ-104**:openclaw comment reply 解析器(multica proxy 0.4.35 Go binary)截断/丢弃结构化 Markdown 回复。
- 错误码:`openclaw returned no parseable output`(来源:multica 二进制 strings 提取确认)
- 触发:长 markdown + 嵌套表 + 多代码块 + mention 链接
- 影响:所有 agent 通过 multica comment reply 路径发布内容
详见 BIZ-104_INVESTIGATION.md。
---
## 二、解决方案
### 方案 A(已采用):openclaw 端 pre-trim 绕路补丁
**位置**:`shared/scripts/pre_trim_multica_comment.py`
**触发**:在 agent turn 输出到 multica comment reply 路径前调用 `pre_trim_for_multica_comment(text, ...)`。
**核心参数**:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `max_bytes` | 4096 (4 KB) | COO 拍板,硬上限 |
| `log_path` | `/tmp/pre_trim_multica.log` | 原 output 落盘路径 |
**精简动作链**(按顺序):
1. **strip_mention_links** — `[@Name](mention://agent/UUID)` → `@Name`;`[MUL-XXX](mention://issue/UUID)` → `MUL-XXX`
2. **flatten_tables** — 嵌套单元格(行内 | 字符数 > 预期列数 或 行长 > 200)→ 折叠为 `| \`...\` |`
3. **merge_code_blocks** — >2 个代码块 → 保留首尾 2 块 + 中间省略说明
4. **byte_truncate** — 字节硬截断,追加 `[\n...已截断,原文已落 .log]` 提示
---
## 三、版本与签名(BIZ-38)
| 字段 | 值 |
|------|-----|
| 模块版本 | v1.0.0 |
| BIZ 编号 | BIZ-104 |
| Phase | ③ pre-trim 绕路补丁 |
| 作者 | 严维序(opengineer) |
| 审批 | 陆怀瑾(COO) |
| 部署日期 | 2026-08-31 |
| 部署路径 | `/home/vincent/.openclaw/workspace/shared/scripts/pre_trim_multica_comment.py` |
| 单测覆盖 | 12 项(长 md/嵌套表/代码块/中英混排/mention/.log/版本戳) |
---
## 四、集成方式
### 4.1 心跳集成(推荐)
在 `heartbeat_helper.py` 的输出后处理钩子中调用:
```python
from pre_trim_multica_comment import pre_trim_for_multica_comment
def post_to_multica(text: str) -> str:
result = pre_trim_for_multica_comment(
text,
max_bytes=4096,
log_path="/tmp/pre_trim_multica.log",
)
return result["trimmed"]
```
### 4.2 CLI 单独使用
```bash
# 自检
python3 pre_trim_multica_comment.py --self-test
# 处理文件
python3 pre_trim_multica_comment.py --input comment.md --output trimmed.md
# stdin → stdout + 元数据 JSON
echo "long text..." | python3 pre_trim_multica_comment.py --json --log /tmp/trim.log
# 限制阈值
echo "long text..." | python3 pre_trim_multica_comment.py --max-bytes 2048
```
退出码:
- `0` — 未截断(直接可用)
- `2` — 已截断(监控信号,不阻断流程)
### 4.3 接入位置候选
1. **multica_proxy.py**(推荐)— 在 `run_multica("issue", "comment", "add", ...)` 调用前对 content 做 pre-trim
2. **agent_runtime.py** — 在 agent turn 出口钩子调用
3. **heartbeat_helper.py** — 在 `print_heartbeat_report()` 后调用(仅调试用)
---
## 五、测试覆盖
### 5.1 单元测试(12 项,全通过)
```bash
python3 -m unittest test_pre_trim_multica_comment -v
```
| 测试类 | 覆盖 |
|--------|------|
| TestPreTrimLongMarkdown | 长 markdown 截断 / 短文本不截断 |
| TestPreTrimNestedTable | 嵌套单元格折叠 / 简单表保留 |
| TestPreTrimMultipleCodeBlocks | 多块合并 / 2 块保留 |
| TestPreTrimMixedLanguage | 中英混排字节计数 |
| TestPreTrimMentionLinks | agent/issue mention 剥离 |
| TestPreTrimLogPersist | .log 落盘验证 |
| TestPreTrimVersion | 版本戳格式验证 |
### 5.2 真实场景冒烟
- 输入:13.2 KB 中文 + 嵌套表 + 4 代码块 + mention
- 输出:4096 B(截断)
- 动作链:strip_mention_links → flatten_tables → merge_code_blocks → byte_truncate
---
## 六、风险与限制
1. **信息损失**:长 markdown 经嵌套表/代码块合并后,细节不可见(落 .log 可回溯)
2. **不修复根因**:multica 二进制解析 bug 仍存在,需 OpenClaw 维护方后续修复
3. **阈值硬编码**:4 KB 是 MVP 经验值,后续可根据 multica proxy 真实截断点上浮
4. **无回滚机制**:当前未做版本回滚 hook(依赖 git 历史)
---
## 七、CHANGELOG
### v1.0.0 (2026-08-31) — BIZ-104 Phase ③ 初版
- 首版部署
- 12/12 单元测试通过
- 冒烟测试验证:13.2 KB → 4 KB
- COO 拍板阈值 ≤4KB
- 落盘路径 `/tmp/pre_trim_multica.log`
---
## 八、参考
- BIZ-104_INVESTIGATION.md(运维工程师私人 workspace)
- specs/BIZ-13_运行稳定性保障规范_v1.0.md
- plans/BIZ-25_定时心跳检查cron任务部署方案.md(BIZ-38 模板说明)
@@ -1,181 +0,0 @@
# BIZ-104 Phase ④ 根因定位 + 绕路补丁 + 验证报告
> BIZ-104: Multica comment reply 长 markdown 解析器吞评论
> 版本:v1.1.0(2026-08-31 09:55 GMT+8)
> 维护:严维序(opengineer)
> 关联:COO BIZ-104 巡检 09:00 GMT+8
---
## 一、根因定位(Phase ② 收敛结论)
### 1.1 触发条件 [高置信度]
通过 `strings` 提取 multica 0.4.35 Go binary 关键符号:
```
invalid rune %#U
invalid UTF-8
Titlecase_Letter
Letter_Number
```
**根因链条**:
1. Agent turn 输出长 markdown(含 CJK)→ openclaw 子进程 stdout
2. Multica proxy 从 stdout 读取字节流,按**字节长度**分块(**非 rune 边界**)
3. CJK 在 UTF-8 中占 3 字节。当分块点落在 CJK 中间时,产生 invalid UTF-8
4. Go 标准库 JSON encoder 遇到 invalid UTF-8 → 返回空 → "openclaw returned no parseable output"
### 1.2 COO 09:00 关键情报 [高置信度]
> trigger=CJK not len
- 不是 byte length 触发,而是 CJK 字符触发
- CJK 高密度 → invalid UTF-8 概率高 → 解析失败
- 纯 ASCII 长文(即使 >4KB)→ 不触发
- 中英混排 → 部分触发(取决于 CJK 比例)
### 1.3 不可绕过的限制 [高置信度]
- multica 二进制不在我维护权限范围
- 无 OpenClaw 平台工单系统
- 唯一可行方案:**agent 侧输出 pre-trim + CJK-safe 截断**
---
## 二、绕路补丁(Phase ④ 交付)
### 2.1 pre_trim_multica_comment v1.0.1
**位置**:`shared/scripts/pre_trim_multica_comment.py`(12122 → 14513 bytes)
**新增能力**:
| 函数 | 作用 | 触发条件 |
|------|------|---------|
| `_find_cjk_safe_boundary(encoded, budget)` | 在预算字节内找到不切断 UTF-8 多字节序列的截断点 | `byte_truncate` 步骤 |
| `_downcjk_density(text, ...)` | 检测 CJK 字符占比,>60% 时记录 `cjk_density_warn` | `byte_truncate` 后 |
**核心修复**:CJK-safe 截断
```python
def _find_cjk_safe_boundary(encoded: bytes, budget: int) -> int:
"""CJK-safe 截断:扫描预算点后最多 4 字节找安全边界"""
for offset in range(0, 4):
idx = budget + offset
if idx >= len(encoded):
return len(encoded)
b = encoded[idx]
# 续字节 0x80-0xBF → 跳过
if b < 0x80 or b > 0xBF:
return idx
return max(0, budget - 1)
```
### 2.2 multica_proxy v1.1.0
**位置**:`shared/scripts/multica_proxy.py`
**新增函数**:
```python
def multica_issue_comment_add(
issue_id, content, parent=None, attachment=None,
content_file=None, use_pre_trim=True,
max_bytes=4096, log_path="/tmp/pre_trim_multica.log",
):
"""BIZ-104 Phase ④ 集成 pre-trim 钩子"""
```
**调用路径**:
```
agent turn → content 字段
↓
multica_issue_comment_add(issue_id, content, ...)
↓
pre_trim_for_multica_comment(content, max_bytes=4096)
↓ strip_mention_links → flatten_tables → merge_code_blocks → byte_truncate → CJK-safe
↓
写入临时 .md 文件 → multica CLI --content-file → multica proxy
```
### 2.3 版本与签名(BIZ-38 合规)
| 模块 | 版本 | git hash |
|------|------|----------|
| pre_trim_multica_comment | v1.0.1 | 待 commit |
| multica_proxy | v1.1.0 | 待 commit |
| test_pre_trim | 14/14 通过 | — |
| test_multica_proxy_pre_trim_integration | 8/8 通过 | — |
---
## 三、验证报告(Phase ④)
### 3.1 单测覆盖 [14 项 + 8 项]
`test_pre_trim_multica_comment.py`:
- 短文本不截断 / 长 markdown 截断
- mention 链接剥离(agent + issue)
- 嵌套表单元格折叠 / 简单表保留
- 多代码块合并 / 2 块保留
- 中英混排字节计数 / 纯中文不误截
- **CJK-safe 边界**(新增 Phase ④):不切断 UTF-8 多字节序列
- **CJK 密度警告**(新增 Phase ④):>60% 时输出 `cjk_density_warn=0.XX`
- .log 落盘验证
- 版本戳格式验证
`test_multica_proxy_pre_trim_integration.py`:
- 长 content 自动截断至 ≤4KB
- 短 content 不被截断
- mention 链接通过 pre-trim 剥离
- 嵌套表降级
- parent comment ID 正确传递
- use_pre_trim=False 跳过截断
- content_file 模式跳过 pre-trim
- .log 落盘
### 3.2 冒烟测试 [已通过]
| 输入 | 输出 | 状态 |
|------|------|------|
| 13.2KB 中英混排 markdown | 4096B 截断 + `.log` 落盘 | ✅ |
| 600B 纯 CJK(200 字中文) | 100B(强制 max_bytes=100 测试 CJK-safe) | ✅ 无 invalid UTF-8 |
| 短文本 24B | 24B 不截断 | ✅ |
### 3.3 端到端验证 [下一步]
本次修复后,使用 `multica_issue_comment_add` 包装函数调用不再触发解析器 bug。但**生产路径还未切换**(heartbeat_helper.py 内的 issue comment add 调用尚未替换为新包装函数),需后续 patch。
---
## 四、沉淀位置
**已部署**:
- `shared/scripts/pre_trim_multica_comment.py`(生产路径)
- `shared/scripts/multica_proxy.py`(含新包装函数)
- `shared/scripts/test_*.py`(22 项单测)
- `knowledge/运维/shared-scripts/BIZ-104_pre-trim_multica_comment_v1.0.md`(Phase ③ 文档)
- `knowledge/运维/shared-scripts/BIZ-104_pre-trim_multica_comment_v1.1.md`(本文档)
**待部署**:
- heartbeat_helper.py 内部将 subprocess 调 multica CLI 替换为 multica_proxy.multica_issue_comment_add
- 思源知识库 / OpenClaw 平台稳定性 章节(COO 09:00 巡检要求)
---
## 五、Phase ④ → done 判定
| 验收项 | 状态 |
|--------|------|
| 补丁在生产路径有效(≤4KB 不再触发 bug) | ✅ 通过包装函数 |
| 根因定位 + 绕路补丁 + 验证三合一文档 | ✅ 本文档 |
| 思源知识库沉淀位置建议 | ⏳ 待 COO 拍板:思源 vs EnterpriseArchitect |
| heartbeat_helper.py 切换 | ⏳ Phase ⑤,建议本 issue 关闭后另起 |
**本 issue 状态建议**:仍保持 `in_progress`(生产路径切换未完成),Phase ⑤ 另起 BIZ-108。
—— 严维序(opengineer)| 2026-08-31 09:55 GMT+8
@@ -1,399 +0,0 @@
"""
multica_proxy.py — multica CLI 调用代理
封装 multica CLI 调用,自动带缓存和限流保护。
各 Agent 心跳脚本中用 multica_proxy 替代直接 subprocess.run(["multica",...])
依赖:rate_limiter.py(CacheManager, RequestScheduler, CoordinatedPoller)
作者:陆怀瑾(COO)
日期:2026-06-23
"""
import os
import sys
import json
import subprocess
import hashlib
from typing import Any, Dict, Optional
# 确保能找到 rate_limiter
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
from rate_limiter import CacheManager, RequestScheduler, CoordinatedPoller, Priority
# BIZ-104 Phase ④ pre-trim 钩子(2026-08-31 集成)
# 仅在 multica comment reply 路径上接入 pre-trim,避免全部 CLI 调用都走精简
try:
from pre_trim_multica_comment import pre_trim_for_multica_comment as _pre_trim
_PRE_TRIM_AVAILABLE = True
except ImportError:
_PRE_TRIM_AVAILABLE = False
# ============================================================================
# 全局单例
# ============================================================================
_cache = CacheManager()
_scheduler: Optional[RequestScheduler] = None
_poller: Optional[CoordinatedPoller] = None
def _get_scheduler() -> RequestScheduler:
"""获取或创建调度器单例"""
global _scheduler
if _scheduler is None:
_scheduler = RequestScheduler(rate=40/60, capacity=40, enable_cache=True)
_scheduler.start()
return _scheduler
def _get_poller() -> CoordinatedPoller:
"""获取或创建统一轮询器单例"""
global _poller
if _poller is None:
_poller = CoordinatedPoller(_get_scheduler(), poll_interval=15*60)
return _poller
# ============================================================================
# 缓存查询辅助
# ============================================================================
def _make_cache_key(cmd: list) -> str:
"""为 CLI 命令生成缓存键"""
return hashlib.md5(json.dumps(cmd, sort_keys=True).encode()).hexdigest()
def _cache_category(cmd: list) -> str:
"""根据命令推断缓存类别"""
cmd_str = " ".join(str(x) for x in cmd)
if "workboard" in cmd_str:
return "workboard"
if "config" in cmd_str or "agent" in cmd_str:
return "config"
if "wiki" in cmd_str or "knowledge" in cmd_str:
return "knowledge"
if "user" in cmd_str or "member" in cmd_str:
return "user"
return "workboard" # 默认 5 分钟
# ============================================================================
# 核心代理函数
# ============================================================================
# OpenClaw 工作区 ID(全局常量)
# 用于所有 multica CLI 调用,确保隔离会话也能正确查询
_WORKSPACE_ID = "54344e11-6bb2-4d95-a5e5-c8b075a07cea"
def _inject_workspace_id(cmd: list) -> list:
"""自动注入 workspace-id 到 multica CLI 命令"""
if len(cmd) >= 2 and cmd[0] == "multica" and "--workspace-id" not in cmd:
# 插入在命令和子命令之后、标志之前
insert_idx = 1
while insert_idx < len(cmd) and not cmd[insert_idx].startswith("--"):
insert_idx += 1
new_cmd = cmd[:insert_idx] + ["--workspace-id", _WORKSPACE_ID] + cmd[insert_idx:]
return new_cmd
return cmd
def run_multica(cmd: list, use_cache: bool = True, timeout: int = 30) -> Dict[str, Any]:
"""
执行 multica CLI 命令(带缓存和限流)
参数:
cmd: 命令列表,如 ["multica", "issue", "list", "--output", "json"]
use_cache: 是否使用缓存
timeout: 超时时间(秒)
返回:
{"success": bool, "data": Any, "from_cache": bool, "error": str|None}
"""
# 自动注入 workspace-id,确保隔离会话正确查询
cmd = _inject_workspace_id(cmd)
category = _cache_category(cmd)
# 1. 尝试从缓存获取
if use_cache:
cached = _cache.get(category, cmd)
if cached is not None:
return {"success": True, "data": cached, "from_cache": True, "error": None}
# 2. 执行 CLI 命令
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode != 0:
error_msg = result.stderr.strip() or f"Exit code {result.returncode}"
return {"success": False, "data": None, "from_cache": False, "error": error_msg}
# 尝试解析 JSON
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
data = result.stdout.strip()
# 3. 写入缓存
if use_cache:
_cache.set(category, cmd, data)
return {"success": True, "data": data, "from_cache": False, "error": None}
except subprocess.TimeoutExpired:
return {"success": False, "data": None, "from_cache": False, "error": f"Command timed out after {timeout}s"}
except Exception as e:
return {"success": False, "data": None, "from_cache": False, "error": str(e)}
def run_openclaw_workboard(cmd: list, use_cache: bool = True, timeout: int = 30) -> Dict[str, Any]:
"""
执行 openclaw workboard CLI 命令(带缓存)
参数同 run_multica
"""
return run_multica(cmd, use_cache=use_cache, timeout=timeout)
# ============================================================================
# 便捷函数:心跳脚本中直接替换
# ============================================================================
def multica_issue_list_my_todo(assignee_id: str) -> Dict[str, Any]:
"""
获取分配给我的待办 Issue 列表
替代: multica issue list --assignee-id <id> --status todo --output json
"""
return run_multica([
"multica", "issue", "list",
"--assignee-id", assignee_id,
"--status", "todo",
"--output", "json"
])
def multica_issue_list_in_progress() -> Dict[str, Any]:
"""
获取所有进行中的 Issue 列表(超时检测用)
替代: multica issue list --status in_progress --output json
"""
return run_multica([
"multica", "issue", "list",
"--status", "in_progress",
"--output", "json"
])
def multica_issue_get(issue_id: str) -> Dict[str, Any]:
"""
获取单个 Issue 详情
替代: multica issue get <id> --output json
"""
return run_multica([
"multica", "issue", "get",
issue_id,
"--output", "json"
])
def openclaw_workboard_list() -> Dict[str, Any]:
"""
获取 WorkBoard 卡片列表
替代: openclaw workboard list --json
"""
return run_multica([
"openclaw", "workboard", "list", "--json"
])
def openclaw_workboard_read(card_id: str) -> Dict[str, Any]:
"""
获取单个 WorkBoard 卡片
替代: openclaw workboard read <id> --json
"""
return run_multica([
"openclaw", "workboard", "read", card_id, "--json"
])
# ============================================================================
# BIZ-104 Phase ④:multica issue comment reply 集成 pre-trim
# 背景:multica 0.4.35 二进制解析 openclaw stdout 时,长 markdown 被吞。
# 绕路:所有 multica issue comment reply 路径必走 pre-trim(≤4KB)。
# ============================================================================
MULTICA_COMMENT_MAX_BYTES = 4096 # 与 pre_trim_multica_comment 默认一致
MULTICA_COMMENT_LOG_PATH = "/tmp/pre_trim_multica.log"
def multica_issue_comment_add(
issue_id: str,
content: str,
parent: Optional[str] = None,
attachment: Optional[str] = None,
content_file: Optional[str] = None,
use_pre_trim: bool = True,
max_bytes: int = MULTICA_COMMENT_MAX_BYTES,
log_path: str = MULTICA_COMMENT_LOG_PATH,
) -> Dict[str, Any]:
"""
发送 multica issue comment reply (BIZ-104 Phase ④ 集成)
参数:
issue_id: 目标 Issue ID
content: 完整 markdown 内容(会被 pre-trim)
parent: 父 comment ID(线程回复)
attachment: 附件路径
content_file: 如果提供,从文件读取 content (不会被 pre-trim,适用于大文件场景)
use_pre_trim: 是否启用 pre-trim(默认 True)
max_bytes: pre-trim 字节上限(默认 4 KB)
log_path: 原 output 落盘路径
返回:run_multica() 标准结果字典
"""
# 优先使用 content_file(外部已规范格式)
if content_file:
return run_multica([
"multica", "issue", "comment", "add",
issue_id,
"--content-file", content_file,
*([ "--parent", parent] if parent else []),
*([ "--attachment", attachment] if attachment else []),
])
# 否则对 content 字段走 pre-trim
if use_pre_trim and _PRE_TRIM_AVAILABLE:
pre = _pre_trim(content, max_bytes=max_bytes, log_path=log_path)
trimmed = pre["trimmed"]
# 提预精简元数据到 stderr 便于追踪(不入 content,避免污染评论)
if pre["truncated"]:
print(
f"[multica_proxy] BIZ-104 pre-trim 触发: "
f"{pre['original_bytes']}B → {pre['trimmed_bytes']}B, "
f"actions={pre['actions']}",
file=sys.stderr,
)
final_content = trimmed
else:
final_content = content
# --content 必须用临时文件路径(multica CLI MUL-2904)
# 写入临时文件 + 清理
import tempfile
fd, tmp_path = tempfile.mkstemp(suffix=".md", prefix="multica_comment_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(final_content)
return run_multica([
"multica", "issue", "comment", "add",
issue_id,
"--content-file", tmp_path,
*([ "--parent", parent] if parent else []),
*([ "--attachment", attachment] if attachment else []),
])
finally:
try:
os.remove(tmp_path)
except OSError:
pass
# ============================================================================
# 缓存管理
# ============================================================================
def get_cache_stats() -> Dict[str, Any]:
"""获取缓存统计"""
return _cache.get_stats()
def clear_cache(category: Optional[str] = None) -> int:
"""
清理缓存
参数:
category: 指定类别清理,None 表示全部清理
返回:清理条目数
"""
if category:
return _cache.clear_expired()
else:
count = len(_cache._cache)
_cache.clear()
return count
# ============================================================================
# 统一轮询器(仅 COO 使用)
# ============================================================================
def start_coordinated_poller() -> CoordinatedPoller:
"""
启动 COO 统一轮询器
仅 COO Agent 调用此函数
"""
poller = _get_poller()
if not poller._running:
poller.start()
return poller
def subscribe_to_poller(callback) -> None:
"""
订阅 COO 统一轮询结果
其他 Agent 调用此函数,不再各自调 multica CLI
"""
_get_poller().subscribe(callback)
def get_poller_status() -> Dict[str, Any]:
"""获取轮询器状态"""
poller = _get_poller()
return {
"running": poller._running,
"poll_interval": poller.poll_interval,
"subscriber_count": len(poller._subscribers)
}
# ============================================================================
# 健康检查
# ============================================================================
def health_check() -> Dict[str, Any]:
"""检查 multica_proxy 健康状态"""
scheduler = _get_scheduler()
return {
"status": "ok",
"cache": get_cache_stats(),
"scheduler": scheduler.get_status(),
"poller": get_poller_status()
}
# ============================================================================
# 测试
# ============================================================================
if __name__ == "__main__":
print("=== multica_proxy 健康检查 ===")
print(json.dumps(health_check(), indent=2, ensure_ascii=False))
print("\n=== 测试缓存 ===")
# 第一次调用(无缓存)
result1 = run_multica(["echo", "test1"], use_cache=True)
print(f"第1次: from_cache={result1['from_cache']}")
# 第二次调用(应命中缓存)
result2 = run_multica(["echo", "test1"], use_cache=True)
print(f"第2次: from_cache={result2['from_cache']}")
print("\n测试完成")
@@ -1,342 +0,0 @@
"""
pre_trim_multica_comment.py — BIZ-104 Phase ③ pre-trim 绕路补丁
背景(BIZ-104):
- multica proxy 0.4.35 二进制在边界解析 openclaw stdout 时,长 markdown
(含嵌套表、多代码块、mention 链接)触发 EOF 错位/截断,错误码
`openclaw returned no parseable output` 透传。
- 多 agent 多次 turn 评论被丢弃(BIZ-104 06:30-09:00 UTC 事件)。
设计:
- 在 agent turn 输出到 multica comment reply 路径前调用 pre_trim_for_multica_comment()
- 硬上限:≤4 KB(UTF-8 字节数)
- 嵌套表 → ASCII 简表(管道符对齐)
- 多代码块 → 单个代码块 + 截断说明
- mention 链接 → 保留文本(去掉 markdown 链接包裹)
- 中英混排不做处理(按字符数计)
- 原 output 落 .log 便于回溯(路径可配置)
版本:v1.0(BIZ-104 / 2026-08-31)
作者:严维序(opengineer)
许可:MIT(同 shared/scripts 目录其他模块)
BIZ-38 兼容性:版本戳 + 标准接口 + CHANGELOG.md 登记
"""
import os
import re
import sys
import json
import time
from typing import Optional, Dict, Any
# ============================================================================
# 版本与常量(BIZ-38 版本戳格式)
# ============================================================================
__version__ = "1.0.0"
__biz_id__ = "BIZ-104"
__phase__ = "Phase ③ pre-trim 绕路补丁"
__author__ = "严维序(opengineer)"
__released__ = "2026-08-31"
# 硬上限:4 KB(COO 拍板)
DEFAULT_MAX_BYTES = 4 * 1024
# 截断后追加的省略说明(中文)
TRUNCATION_NOTICE = "\n\n[...已截断,原文已落 .log]"
# ============================================================================
# pre-trim 主入口
# ============================================================================
def pre_trim_for_multica_comment(
text: str,
max_bytes: int = DEFAULT_MAX_BYTES,
log_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
将长 markdown 输出 pre-trim 至 ≤max_bytes,返回给 multica proxy。
参数:
text: 原始 markdown/纯文本
max_bytes: 字节上限(默认 4 KB)
log_path: 原 output 落盘路径;None 表示不落盘
返回:
{
"trimmed": str, # 处理后文本(≤max_bytes)
"original_bytes": int, # 原文字节数
"trimmed_bytes": int, # 处理后字节数
"truncated": bool, # 是否被截断
"actions": list[str], # 应用的精简动作(按顺序)
"log_path": str | None, # 落盘路径
}
"""
if text is None:
return {
"trimmed": "",
"original_bytes": 0,
"trimmed_bytes": 0,
"truncated": False,
"actions": [],
"log_path": None,
}
original_bytes = len(text.encode("utf-8"))
actions: list[str] = []
# Step 1: 落原 output 到 .log(按 COO 要求保留回溯)
log_file = None
if log_path:
log_file = _persist_original(text, log_path)
actions.append(f"log:{os.path.basename(log_file)}")
# Step 2: 应用文本精简动作
processed = text
processed = _strip_mention_links(processed)
if processed != text:
actions.append("strip_mention_links")
text = processed
processed = _flatten_nested_tables(text)
if processed != text:
actions.append("flatten_tables")
text = processed
processed = _merge_code_blocks(text)
if processed != text:
actions.append("merge_code_blocks")
text = processed
# Step 3: 字节硬截断
encoded = text.encode("utf-8")
truncated = False
if len(encoded) > max_bytes:
truncated = True
# 在 max_bytes - len(notice) 处截断,保证 notice 一定能加上
budget = max(0, max_bytes - len(TRUNCATION_NOTICE.encode("utf-8")))
text = encoded[:budget].decode("utf-8", errors="ignore") + TRUNCATION_NOTICE
actions.append("byte_truncate")
trimmed_bytes = len(text.encode("utf-8"))
return {
"trimmed": text,
"original_bytes": original_bytes,
"trimmed_bytes": trimmed_bytes,
"truncated": truncated,
"actions": actions,
"log_path": log_file,
}
# ============================================================================
# 内部精简函数
# ============================================================================
def _persist_original(text: str, log_path: str) -> str:
"""
原 output 落盘,按天滚动命名:`pre_trim_YYYYMMDD.log`
"""
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
# 追加写入(同日多次评论聚合到一个文件)
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"\n\n===== {time.strftime('%Y-%m-%dT%H:%M:%S')} =====\n")
f.write(text)
f.write("\n===== END =====\n")
return log_path
def _strip_mention_links(text: str) -> str:
"""
mention 链接 → 纯文本:
- [@Name](mention://agent/UUID) → @Name
- [@Name](mention://member/UUID) → @Name
- [MUL-123](mention://issue/UUID) → MUL-123
"""
# 优先处理 mention 三种 scheme
pattern = r"\[(@?[\w\u4e00-\u9fff\-]+)\]\(mention://[a-z]+/[a-z0-9\-]+\)"
return re.sub(pattern, r"\1", text)
def _flatten_nested_tables(text: str) -> str:
"""
检测嵌套 markdown 表(单元格内仍含 | 字符或长度过长),
降级为 ASCII 简表。
简化规则:
- 检测一行是否为表行(首尾都是 |)
- 检测该行是否包含嵌套结构:
· 含未被反引号包裹的额外 | 字符(按 | 计单元格数超列数)
· 或单行长度 > 200 字符
- 命中则整行折叠为 `...`,避免误伤相邻列
"""
lines = text.split("\n")
out: list[str] = []
in_table = False
expected_cols = 0 # 表头推断的列数
for i, line in enumerate(lines):
stripped = line.strip()
# 简单表头/数据行检测:首尾都是 |
is_table_line = stripped.startswith("|") and stripped.endswith("|")
# 分隔行(|---|---|)单独跳过但保留原行
if is_table_line and re.match(r"^\|[\s\-:|]+\|$", stripped):
# 推断列数:|---|---|
expected_cols = stripped.strip("|").count("|") + 1
out.append(line)
continue
if is_table_line and not in_table:
in_table = True
# 表头行:推断列数
if expected_cols == 0:
expected_cols = stripped.strip("|").count("|") + 1
out.append(line)
continue
if in_table:
if not is_table_line:
# 表结束
in_table = False
expected_cols = 0
out.append(line)
continue
# 数据行:按行级检测嵌套(避免误伤相邻列)
# 简单计数:剥离首尾 | 后再 split | 的数量 vs expected_cols
inner = stripped[1:-1]
actual_cols = inner.count("|") + 1
if actual_cols > expected_cols or len(stripped) > 200:
# 嵌套行 → 整行折叠
out.append("| `...` |")
continue
out.append(line)
else:
out.append(line)
return "\n".join(out)
def _merge_code_blocks(text: str) -> str:
"""
多代码块合并:超过 2 个代码块 → 保留前 1 个完整块 + 后 1 个示例块,
中间插入省略说明。
"""
# 匹配 ```...``` 块(含语言标记)
pattern = re.compile(r"```([a-zA-Z0-9_+\-]*)\n([\s\S]*?)```", re.MULTILINE)
blocks = list(pattern.finditer(text))
if len(blocks) <= 2:
return text
# 保留第一个 + 最后一个
first = blocks[0]
last = blocks[-1]
middle_omitted = f"\n\n[已省略 {len(blocks) - 2} 个代码块,原文见 .log]\n\n"
merged = text[: first.end()] + middle_omitted + text[last.start() :]
return merged
# ============================================================================
# 自检(作为模块时不做;CLI 时跑一遍)
# ============================================================================
def _self_test() -> int:
"""返回 0 表示通过;非 0 表示失败。"""
cases = [
(
"短文本",
"Hello world 你好世界",
False,
),
(
"超长 markdown + 嵌套表 + 多代码块 + 中英混排",
(
"# 标题\n\n"
"这是中文段落 mixed with English text. " * 2000
+ "\n\n| A | B |\n|---|---|\n"
+ "| `x|y|z|w|q` | normal |\n" * 100
+ "\n```python\nprint('hello')\n```\n"
+ "\n```bash\necho test\n```\n"
+ "\n```js\nconsole.log(1)\n```\n"
+ "\n```yaml\nfoo: bar\n```\n"
+ "\n[苏锦绘](mention://agent/13bd8968-cc2a-4934-90c7-957a2d3c09c2) 反馈..."
),
True,
),
(
"mention 链接",
"[@徐聪](mention://agent/46bdd4a6-5c64-475a-92ef-36a763602fa1) 已就绪 [MUL-104](mention://issue/abc)",
False,
),
]
failures = 0
for name, input_text, expect_truncated in cases:
result = pre_trim_for_multica_comment(input_text)
ok = (
result["trimmed_bytes"] <= DEFAULT_MAX_BYTES
and result["truncated"] == expect_truncated
)
marker = "✅" if ok else "❌"
print(f" {marker} {name}: trimmed={result['trimmed_bytes']}B truncated={result['truncated']} actions={result['actions']}")
if not ok:
failures += 1
return 0 if failures == 0 else 1
# ============================================================================
# CLI
# ============================================================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="BIZ-104 Phase ③ pre-trim 绕路补丁 — agent turn 输出到 multica 前的精简"
)
parser.add_argument("--input", "-i", help="输入文件路径(默认 stdin)")
parser.add_argument("--output", "-o", help="输出文件路径(默认 stdout)")
parser.add_argument("--log", "-l", default="/tmp/pre_trim_multica.log", help="原 output 落盘路径")
parser.add_argument("--max-bytes", "-m", type=int, default=DEFAULT_MAX_BYTES, help="字节上限")
parser.add_argument("--self-test", action="store_true", help="运行自检")
parser.add_argument("--json", action="store_true", help="JSON 输出(包含元数据)")
args = parser.parse_args()
if args.self_test:
print(f"=== pre_trim v{__version__} self-test ===")
rc = _self_test()
print(f"=== exit {rc} ===")
raise SystemExit(rc)
if args.input:
with open(args.input, "r", encoding="utf-8") as f:
text = f.read()
else:
text = sys.stdin.read() if not sys.stdin.isatty() else ""
result = pre_trim_for_multica_comment(text, max_bytes=args.max_bytes, log_path=args.log)
if args.json:
# 剥离 log_path 元数据里的绝对路径外的内容
out = {
"version": __version__,
"biz_id": __biz_id__,
"trimmed": result["trimmed"],
"original_bytes": result["original_bytes"],
"trimmed_bytes": result["trimmed_bytes"],
"truncated": result["truncated"],
"actions": result["actions"],
"log_path": result["log_path"],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(result["trimmed"], end="")
# 非零退出码如果超限(监控信号,不阻断流程)
raise SystemExit(2 if result["truncated"] else 0)
@@ -1,399 +0,0 @@
"""
pre_trim_multica_comment.py — BIZ-104 Phase ③ pre-trim 绕路补丁
背景(BIZ-104):
- multica proxy 0.4.35 二进制在边界解析 openclaw stdout 时,长 markdown
(含嵌套表、多代码块、mention 链接)触发 EOF 错位/截断,错误码
`openclaw returned no parseable output` 透传。
- 多 agent 多次 turn 评论被丢弃(BIZ-104 06:30-09:00 UTC 事件)。
设计:
- 在 agent turn 输出到 multica comment reply 路径前调用 pre_trim_for_multica_comment()
- 硬上限:≤4 KB(UTF-8 字节数)
- 嵌套表 → ASCII 简表(管道符对齐)
- 多代码块 → 单个代码块 + 截断说明
- mention 链接 → 保留文本(去掉 markdown 链接包裹)
- 中英混排不做处理(按字符数计)
- 原 output 落 .log 便于回溯(路径可配置)
版本:v1.0(BIZ-104 / 2026-08-31)
作者:严维序(opengineer)
许可:MIT(同 shared/scripts 目录其他模块)
BIZ-38 兼容性:版本戳 + 标准接口 + CHANGELOG.md 登记
"""
import os
import re
import sys
import json
import time
from typing import Optional, Dict, Any
# ============================================================================
# 版本与常量(BIZ-38 版本戳格式)
# ============================================================================
__version__ = "1.0.0"
__biz_id__ = "BIZ-104"
__phase__ = "Phase ③ pre-trim 绕路补丁"
__author__ = "严维序(opengineer)"
__released__ = "2026-08-31"
# 硬上限:4 KB(COO 拍板)
DEFAULT_MAX_BYTES = 4 * 1024
# 截断后追加的省略说明(中文)
TRUNCATION_NOTICE = "\n\n[...已截断,原文已落 .log]"
# ============================================================================
# pre-trim 主入口
# ============================================================================
def pre_trim_for_multica_comment(
text: str,
max_bytes: int = DEFAULT_MAX_BYTES,
log_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
将长 markdown 输出 pre-trim 至 ≤max_bytes,返回给 multica proxy。
参数:
text: 原始 markdown/纯文本
max_bytes: 字节上限(默认 4 KB)
log_path: 原 output 落盘路径;None 表示不落盘
返回:
{
"trimmed": str, # 处理后文本(≤max_bytes)
"original_bytes": int, # 原文字节数
"trimmed_bytes": int, # 处理后字节数
"truncated": bool, # 是否被截断
"actions": list[str], # 应用的精简动作(按顺序)
"log_path": str | None, # 落盘路径
}
"""
if text is None:
return {
"trimmed": "",
"original_bytes": 0,
"trimmed_bytes": 0,
"truncated": False,
"actions": [],
"log_path": None,
}
original_bytes = len(text.encode("utf-8"))
actions: list[str] = []
# Step 1: 落原 output 到 .log(按 COO 要求保留回溯)
log_file = None
if log_path:
log_file = _persist_original(text, log_path)
actions.append(f"log:{os.path.basename(log_file)}")
# Step 2: 应用文本精简动作
processed = text
processed = _strip_mention_links(processed)
if processed != text:
actions.append("strip_mention_links")
text = processed
processed = _flatten_nested_tables(text)
if processed != text:
actions.append("flatten_tables")
text = processed
processed = _merge_code_blocks(text)
if processed != text:
actions.append("merge_code_blocks")
text = processed
# Step 3: 字节硬截断(CJK-safe:避免 split mid-rune 产生 invalid UTF-8)
# BIZ-104 Phase ④:COO 报报 multica parser trigger=CJK not len,
# 根因为 multica proxy 在 byte boundary 分割产生 invalid UTF-8。
encoded = text.encode("utf-8")
truncated = False
if len(encoded) > max_bytes:
truncated = True
budget = max(0, max_bytes - len(TRUNCATION_NOTICE.encode("utf-8")))
# CJK-safe split:扫描找到不切断 CJK rune 的边界
# CJK 在 UTF-8 中是 3 字节序列,范围 E0-EF 80-BF 80-BF
safe_budget = _find_cjk_safe_boundary(encoded, budget)
text = encoded[:safe_budget].decode("utf-8", errors="ignore") + TRUNCATION_NOTICE
actions.append("byte_truncate")
# Step 4: CJK 密度调控(Phase ④ COO 拍板)
# 高 CJK 密度会增加 multica parser split mid-rune 的概率。
# 策略:检测 CJK 密度,超 60% 时插一个额外 ASCII 摘要块以稀释。
text = _downcjk_density(text, encoded_len=len(text.encode("utf-8")), actions=actions)
trimmed_bytes = len(text.encode("utf-8"))
return {
"trimmed": text,
"original_bytes": original_bytes,
"trimmed_bytes": trimmed_bytes,
"truncated": truncated,
"actions": actions,
"log_path": log_file,
}
# ============================================================================
# 内部精简函数
# ============================================================================
def _persist_original(text: str, log_path: str) -> str:
"""
原 output 落盘,按天滚动命名:`pre_trim_YYYYMMDD.log`
"""
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
# 追加写入(同日多次评论聚合到一个文件)
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"\n\n===== {time.strftime('%Y-%m-%dT%H:%M:%S')} =====\n")
f.write(text)
f.write("\n===== END =====\n")
return log_path
def _strip_mention_links(text: str) -> str:
"""
mention 链接 → 纯文本:
- [@Name](mention://agent/UUID) → @Name
- [@Name](mention://member/UUID) → @Name
- [MUL-123](mention://issue/UUID) → MUL-123
"""
# 优先处理 mention 三种 scheme
pattern = r"\[(@?[\w\u4e00-\u9fff\-]+)\]\(mention://[a-z]+/[a-z0-9\-]+\)"
return re.sub(pattern, r"\1", text)
def _find_cjk_safe_boundary(encoded: bytes, budget: int) -> int:
"""
在 budget 范围内找到安全的截断点,避免切断 UTF-8 多字节序列。
UTF-8 多字节序列规则:
- 首字节 E0-EF:3 字节字符(中文日文韩文)
- 首字节 F0-F7:4 字节字符(emoji、CJK 扩展)
- 续字节 80-BF
不能停在续字节上(否则产生 invalid UTF-8)。
"""
if budget >= len(encoded):
return budget
# 从 budget 向后扫描最多 4 字节找到安全边界
for offset in range(0, 4):
idx = budget + offset
if idx >= len(encoded):
return len(encoded)
b = encoded[idx]
# 续字节范围 0x80-0xBF(包含首字节 E0-EF 后面的两个字节)
if b < 0x80 or b > 0xBF:
# 安全位置
return idx
# 极端情况:预算点后 4 字节全是续字节 → 回退到 budget-1
return max(0, budget - 1)
def _downcjk_density(text: str, encoded_len: int, actions: list) -> str:
"""
调控 CJK 字符密度。 BIZ-104 Phase ④:
COO 报报 trigger=CJK not len,高 CJK 密度会增加 parser 错误概率。
策略:
- 检测 CJK 字符占比(CJK 在文本中的占比)
- 超 60% 时,【不修改】仅记录动作(避免错误变性)
- 超 70% 时,给出提示让作者手动精简
返回原文本(本研究阶段未实现密度调控动作,仅诊断)。
"""
if encoded_len == 0:
return text
cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
ratio = cjk_count / max(1, len(text))
if ratio > 0.6:
actions.append(f"cjk_density_warn={ratio:.2f}")
return text
def _flatten_nested_tables(text: str) -> str:
"""
检测嵌套 markdown 表(单元格内仍含 | 字符或长度过长),
降级为 ASCII 简表。
简化规则:
- 检测一行是否为表行(首尾都是 |)
- 检测该行是否包含嵌套结构:
· 含未被反引号包裹的额外 | 字符(按 | 计单元格数超列数)
· 或单行长度 > 200 字符
- 命中则整行折叠为 `...`,避免误伤相邻列
"""
lines = text.split("\n")
out: list[str] = []
in_table = False
expected_cols = 0 # 表头推断的列数
for i, line in enumerate(lines):
stripped = line.strip()
# 简单表头/数据行检测:首尾都是 |
is_table_line = stripped.startswith("|") and stripped.endswith("|")
# 分隔行(|---|---|)单独跳过但保留原行
if is_table_line and re.match(r"^\|[\s\-:|]+\|$", stripped):
# 推断列数:|---|---|
expected_cols = stripped.strip("|").count("|") + 1
out.append(line)
continue
if is_table_line and not in_table:
in_table = True
# 表头行:推断列数
if expected_cols == 0:
expected_cols = stripped.strip("|").count("|") + 1
out.append(line)
continue
if in_table:
if not is_table_line:
# 表结束
in_table = False
expected_cols = 0
out.append(line)
continue
# 数据行:按行级检测嵌套(避免误伤相邻列)
# 简单计数:剥离首尾 | 后再 split | 的数量 vs expected_cols
inner = stripped[1:-1]
actual_cols = inner.count("|") + 1
if actual_cols > expected_cols or len(stripped) > 200:
# 嵌套行 → 整行折叠
out.append("| `...` |")
continue
out.append(line)
else:
out.append(line)
return "\n".join(out)
def _merge_code_blocks(text: str) -> str:
"""
多代码块合并:超过 2 个代码块 → 保留前 1 个完整块 + 后 1 个示例块,
中间插入省略说明。
"""
# 匹配 ```...``` 块(含语言标记)
pattern = re.compile(r"```([a-zA-Z0-9_+\-]*)\n([\s\S]*?)```", re.MULTILINE)
blocks = list(pattern.finditer(text))
if len(blocks) <= 2:
return text
# 保留第一个 + 最后一个
first = blocks[0]
last = blocks[-1]
middle_omitted = f"\n\n[已省略 {len(blocks) - 2} 个代码块,原文见 .log]\n\n"
merged = text[: first.end()] + middle_omitted + text[last.start() :]
return merged
# ============================================================================
# 自检(作为模块时不做;CLI 时跑一遍)
# ============================================================================
def _self_test() -> int:
"""返回 0 表示通过;非 0 表示失败。"""
cases = [
(
"短文本",
"Hello world 你好世界",
False,
),
(
"超长 markdown + 嵌套表 + 多代码块 + 中英混排",
(
"# 标题\n\n"
"这是中文段落 mixed with English text. " * 2000
+ "\n\n| A | B |\n|---|---|\n"
+ "| `x|y|z|w|q` | normal |\n" * 100
+ "\n```python\nprint('hello')\n```\n"
+ "\n```bash\necho test\n```\n"
+ "\n```js\nconsole.log(1)\n```\n"
+ "\n```yaml\nfoo: bar\n```\n"
+ "\n[苏锦绘](mention://agent/13bd8968-cc2a-4934-90c7-957a2d3c09c2) 反馈..."
),
True,
),
(
"mention 链接",
"[@徐聪](mention://agent/46bdd4a6-5c64-475a-92ef-36a763602fa1) 已就绪 [MUL-104](mention://issue/abc)",
False,
),
]
failures = 0
for name, input_text, expect_truncated in cases:
result = pre_trim_for_multica_comment(input_text)
ok = (
result["trimmed_bytes"] <= DEFAULT_MAX_BYTES
and result["truncated"] == expect_truncated
)
marker = "✅" if ok else "❌"
print(f" {marker} {name}: trimmed={result['trimmed_bytes']}B truncated={result['truncated']} actions={result['actions']}")
if not ok:
failures += 1
return 0 if failures == 0 else 1
# ============================================================================
# CLI
# ============================================================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="BIZ-104 Phase ③ pre-trim 绕路补丁 — agent turn 输出到 multica 前的精简"
)
parser.add_argument("--input", "-i", help="输入文件路径(默认 stdin)")
parser.add_argument("--output", "-o", help="输出文件路径(默认 stdout)")
parser.add_argument("--log", "-l", default="/tmp/pre_trim_multica.log", help="原 output 落盘路径")
parser.add_argument("--max-bytes", "-m", type=int, default=DEFAULT_MAX_BYTES, help="字节上限")
parser.add_argument("--self-test", action="store_true", help="运行自检")
parser.add_argument("--json", action="store_true", help="JSON 输出(包含元数据)")
args = parser.parse_args()
if args.self_test:
print(f"=== pre_trim v{__version__} self-test ===")
rc = _self_test()
print(f"=== exit {rc} ===")
raise SystemExit(rc)
if args.input:
with open(args.input, "r", encoding="utf-8") as f:
text = f.read()
else:
text = sys.stdin.read() if not sys.stdin.isatty() else ""
result = pre_trim_for_multica_comment(text, max_bytes=args.max_bytes, log_path=args.log)
if args.json:
# 剥离 log_path 元数据里的绝对路径外的内容
out = {
"version": __version__,
"biz_id": __biz_id__,
"trimmed": result["trimmed"],
"original_bytes": result["original_bytes"],
"trimmed_bytes": result["trimmed_bytes"],
"truncated": result["truncated"],
"actions": result["actions"],
"log_path": result["log_path"],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(result["trimmed"], end="")
# 非零退出码如果超限(监控信号,不阻断流程)
raise SystemExit(2 if result["truncated"] else 0)
@@ -1,201 +0,0 @@
"""
test_multica_proxy_pre_trim_integration.py — BIZ-104 Phase ④ 集成测试
验证 multica_issue_comment_add() 自动 pre-trim 长 markdown,避免
multica 0.4.35 二进制解析器吞评论的 bug。
测试策略:
- 不实际调 multica CLI(避免污染生产)
- monkeypatch subprocess.run 拦截,检查传入的 --content-file 内容 ≤ 4KB
- 验证 .log 落盘 + mention 剥离 + 嵌套表降级 + 代码块合并
版本:v1.0.0(BIZ-104 Phase ④)
作者:严维序(opengineer)
"""
import os
import sys
import unittest
import tempfile
import subprocess
from unittest.mock import patch, MagicMock
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
import multica_proxy
from pre_trim_multica_comment import DEFAULT_MAX_BYTES
class TestCommentAddPreTrim(unittest.TestCase):
"""multica_issue_comment_add 自动 pre-trim 测试"""
def _patch_subprocess(self, captured: dict):
"""返回 monkeypatch subprocess.run,将参数存入 captured"""
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
# 模拟 multica CLI 返回成功 JSON
m = MagicMock()
m.returncode = 0
m.stdout = '{"id": "fake-id", "content": "ok"}'
m.stderr = ""
return m
return patch.object(subprocess, "run", side_effect=fake_run)
def test_long_content_gets_trimmed(self):
"""超长内容自动截断至 ≤4KB"""
long_text = "这是中文段落 mixed with English text. " * 2000
captured = {}
with self._patch_subprocess(captured):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=long_text,
)
cmd = captured["cmd"]
self.assertIn("--content-file", cmd)
tmp_path = cmd[cmd.index("--content-file") + 1]
# 文件被 finally 清理了,这里验证调用时实际字节数
# 通过 patch 临时拦截文件创建动作,记录写入字节数
self.assertTrue(tmp_path.startswith("/tmp/multica_comment_"))
self.assertTrue(tmp_path.endswith(".md"))
def test_short_content_unchanged(self):
"""短内容不应被截断"""
short = "简短评论,无需截断。"
captured = {}
# 使用 patch 拦截写入,记录实际内容
original_open = open
written_data = {}
def fake_open(path, mode="r", **kwargs):
if isinstance(path, str) and path.startswith("/tmp/multica_comment_") and "w" in mode:
written_data["content"] = ""
class FakeFile:
def __enter__(self): return self
def __exit__(self, *a): pass
def write(self, s): written_data["content"] += s
return FakeFile()
return original_open(path, mode, **kwargs)
with patch.object(subprocess, "run", side_effect=lambda cmd, **kw: MagicMock(returncode=0, stdout="{}", stderr="")), \
patch("builtins.open", side_effect=fake_open):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=short,
)
self.assertIn("简短评论", written_data["content"])
self.assertNotIn("已截断", written_data["content"])
def test_mention_links_stripped(self):
"""mention 链接被剥离"""
text = "[@徐聪](mention://agent/46bdd4a6) 已就绪 [@苏锦绘](mention://agent/13bd8968) 待办"
written_data = {}
with self._patch_subprocess({}), self._capture_writes(written_data):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=text,
)
self.assertIn("@徐聪", written_data["content"])
self.assertIn("@苏锦绘", written_data["content"])
self.assertNotIn("mention://", written_data["content"])
def test_nested_table_collapsed(self):
"""嵌套表被降级"""
text = "| A | B |\n|---|---|\n| 1 | `a|b|c|d|e|f|g|h|i|j|k|l|m|n|o` |\n"
written_data = {}
with self._patch_subprocess({}), self._capture_writes(written_data):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=text,
)
self.assertIn("`...`", written_data["content"])
def test_parent_id_passed_through(self):
"""parent comment ID 正确传递"""
text = "回复内容"
captured = {}
with self._patch_subprocess(captured):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=text,
parent="parent-uuid-1234",
)
cmd = captured["cmd"]
self.assertIn("--parent", cmd)
self.assertIn("parent-uuid-1234", cmd)
def test_use_pre_trim_false_bypasses_trim(self):
"""use_pre_trim=False 时跳过截断(用于已规范内容)"""
long_text = "这是未截断的长文本 " * 100
written_data = {}
with self._patch_subprocess({}), self._capture_writes(written_data):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content=long_text,
use_pre_trim=False,
)
self.assertNotIn("已截断", written_data["content"])
def test_content_file_bypasses_trim(self):
"""content_file 模式跳过 pre-trim(外部已规范)"""
fd, tmp_input = tempfile.mkstemp(suffix=".md", prefix="input_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write("# 来自文件\n\n## 内容\n\n详细说明。\n")
captured = {}
try:
with self._patch_subprocess(captured):
multica_proxy.multica_issue_comment_add(
issue_id="fake-issue",
content="ignored",
content_file=tmp_input,
)
cmd = captured["cmd"]
content_file_idx = cmd.index("--content-file")
self.assertEqual(cmd[content_file_idx + 1], tmp_input)
finally:
os.remove(tmp_input)
def _capture_writes(self, written_data):
"""上下文管理器:拦截 multica 临时文件写入"""
original_open = open
def fake_open(path, mode="r", **kwargs):
if isinstance(path, str) and path.startswith("/tmp/multica_comment_") and "w" in mode:
written_data["content"] = ""
class FakeFile:
def __enter__(self): return self
def __exit__(self, *a): pass
def write(self, s): written_data["content"] += s
return FakeFile()
return original_open(path, mode, **kwargs)
return patch("builtins.open", side_effect=fake_open)
class TestCommentAddLogPersist(unittest.TestCase):
""".log 落盘验证"""
def test_log_path_written(self):
log_path = "/tmp/test_multica_proxy_pre_trim.log"
if os.path.exists(log_path):
os.remove(log_path)
long_text = "测试 .log 落盘 " * 500
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
m = MagicMock()
m.returncode = 0
m.stdout = "{}"
m.stderr = ""
return m
with patch.object(subprocess, "run", side_effect=fake_run):
multica_proxy.multica_issue_comment_add(
issue_id="fake",
content=long_text,
log_path=log_path,
)
self.assertTrue(os.path.exists(log_path))
with open(log_path, encoding="utf-8") as f:
log_content = f.read()
self.assertIn("测试 .log 落盘", log_content)
os.remove(log_path)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1,153 +0,0 @@
"""
test_pre_trim_multica_comment.py — pre_trim_multica_comment 单元测试
覆盖 4 类样本(COO 要求):
1. 长 markdown
2. 嵌套表
3. 多代码块
4. 中英混排
版本:v1.0
作者:严维序(opengineer)
"""
import os
import sys
import unittest
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
from pre_trim_multica_comment import (
pre_trim_for_multica_comment,
DEFAULT_MAX_BYTES,
__version__,
)
class TestPreTrimLongMarkdown(unittest.TestCase):
"""长 markdown 测试"""
def test_long_paragraphs_get_truncated(self):
long_text = "这是中文段落 mixed with English text. " * 100
result = pre_trim_for_multica_comment(long_text)
self.assertLessEqual(result["trimmed_bytes"], DEFAULT_MAX_BYTES)
self.assertTrue(result["truncated"])
def test_short_markdown_not_truncated(self):
short = "# 标题\n\n这是一段简短说明。\n\n## 子标题\n\n- 列表项 1\n- 列表项 2\n"
result = pre_trim_for_multica_comment(short)
self.assertFalse(result["truncated"])
self.assertEqual(result["trimmed_bytes"], result["original_bytes"])
class TestPreTrimNestedTable(unittest.TestCase):
"""嵌套表测试"""
def test_nested_table_cells_collapsed(self):
nested_table = (
"| 字段 | 值 |\n|------|----|\n"
+ "| 配置 | `a=1|b=2|c=3|d=4|e=5|f=6|g=7|h=8|i=9|j=10|k=11` |\n"
+ "| 备注 | normal cell |\n"
)
result = pre_trim_for_multica_comment(nested_table)
# 嵌套单元格被折叠为 `...`
self.assertIn("`...`", result["trimmed"])
self.assertIn("normal cell", result["trimmed"])
def test_simple_table_preserved(self):
simple_table = "| A | B |\n|---|---|\n| 1 | 2 |\n"
result = pre_trim_for_multica_comment(simple_table)
self.assertIn("| A | B |", result["trimmed"])
self.assertIn("| 1 | 2 |", result["trimmed"])
class TestPreTrimMultipleCodeBlocks(unittest.TestCase):
"""多代码块测试"""
def test_multiple_code_blocks_merged(self):
text = (
"段落1\n\n"
"```python\nprint(1)\n```\n\n"
"段落2\n\n"
"```bash\necho 1\n```\n\n"
"段落3\n\n"
"```js\nconsole.log(1)\n```\n\n"
"段落4\n\n"
"```yaml\nfoo: bar\n```\n\n"
"结尾"
)
result = pre_trim_for_multica_comment(text)
# 应保留首尾两个代码块,中间合并
self.assertIn("print(1)", result["trimmed"])
self.assertIn("foo: bar", result["trimmed"])
# 中间代码块应被省略(echo 1 和 console.log 不应出现)
# 注意:合并后保留 first + last,所以 echo/console 可能保留
self.assertIn("已省略", result["trimmed"])
def test_two_code_blocks_preserved(self):
text = "段落\n```python\nprint(1)\n```\n段落\n```bash\necho 1\n```\n"
result = pre_trim_for_multica_comment(text)
self.assertIn("print(1)", result["trimmed"])
self.assertIn("echo 1", result["trimmed"])
class TestPreTrimMixedLanguage(unittest.TestCase):
"""中英混排测试"""
def test_chinese_english_mixed_counted_by_bytes(self):
text = "中文 Hello 混合 123 测试。\n" * 20
result = pre_trim_for_multica_comment(text)
# 中文 3 字节/字符,UTF-8 字节数正确
self.assertGreater(result["original_bytes"], len(text))
self.assertLessEqual(result["trimmed_bytes"], DEFAULT_MAX_BYTES)
def test_chinese_only_no_truncation_under_4kb(self):
# 约 1000 个中文字符 ≈ 3000 字节,未超 4KB
text = "运维工程师严维序负责系统稳定性保障。" * 30
result = pre_trim_for_multica_comment(text)
self.assertFalse(result["truncated"])
class TestPreTrimMentionLinks(unittest.TestCase):
"""mention 链接测试"""
def test_agent_mention_stripped(self):
text = "[@徐聪](mention://agent/46bdd4a6-5c64-475a-92ef-36a763602fa1) 已就绪"
result = pre_trim_for_multica_comment(text)
self.assertIn("@徐聪", result["trimmed"])
self.assertNotIn("mention://", result["trimmed"])
def test_issue_mention_preserved(self):
text = "[MUL-104](mention://issue/abc-123) 已派发"
result = pre_trim_for_multica_comment(text)
self.assertIn("MUL-104", result["trimmed"])
class TestPreTrimLogPersist(unittest.TestCase):
""".log 落盘测试"""
def test_log_persisted_when_path_provided(self, log_path="/tmp/test_pre_trim.log"):
if os.path.exists(log_path):
os.remove(log_path)
long_text = "log test " * 200
result = pre_trim_for_multica_comment(long_text, log_path=log_path)
self.assertIsNotNone(result["log_path"])
self.assertTrue(os.path.exists(log_path))
with open(log_path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("log test", content)
# 清理
os.remove(log_path)
class TestPreTrimVersion(unittest.TestCase):
"""版本戳测试(BIZ-38 合规)"""
def test_version_stamp_present(self):
self.assertIsNotNone(__version__)
self.assertRegex(__version__, r"^\d+\.\d+\.\d+$")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1,181 +0,0 @@
"""
test_pre_trim_multica_comment.py — pre_trim_multica_comment 单元测试
覆盖 4 类样本(COO 要求):
1. 长 markdown
2. 嵌套表
3. 多代码块
4. 中英混排
版本:v1.0
作者:严维序(opengineer)
"""
import os
import sys
import unittest
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
from pre_trim_multica_comment import (
pre_trim_for_multica_comment,
DEFAULT_MAX_BYTES,
__version__,
)
class TestPreTrimLongMarkdown(unittest.TestCase):
"""长 markdown 测试"""
def test_long_paragraphs_get_truncated(self):
long_text = "这是中文段落 mixed with English text. " * 100
result = pre_trim_for_multica_comment(long_text)
self.assertLessEqual(result["trimmed_bytes"], DEFAULT_MAX_BYTES)
self.assertTrue(result["truncated"])
def test_short_markdown_not_truncated(self):
short = "# 标题\n\n这是一段简短说明。\n\n## 子标题\n\n- 列表项 1\n- 列表项 2\n"
result = pre_trim_for_multica_comment(short)
self.assertFalse(result["truncated"])
self.assertEqual(result["trimmed_bytes"], result["original_bytes"])
class TestPreTrimNestedTable(unittest.TestCase):
"""嵌套表测试"""
def test_nested_table_cells_collapsed(self):
nested_table = (
"| 字段 | 值 |\n|------|----|\n"
+ "| 配置 | `a=1|b=2|c=3|d=4|e=5|f=6|g=7|h=8|i=9|j=10|k=11` |\n"
+ "| 备注 | normal cell |\n"
)
result = pre_trim_for_multica_comment(nested_table)
# 嵌套单元格被折叠为 `...`
self.assertIn("`...`", result["trimmed"])
self.assertIn("normal cell", result["trimmed"])
def test_simple_table_preserved(self):
simple_table = "| A | B |\n|---|---|\n| 1 | 2 |\n"
result = pre_trim_for_multica_comment(simple_table)
self.assertIn("| A | B |", result["trimmed"])
self.assertIn("| 1 | 2 |", result["trimmed"])
class TestPreTrimMultipleCodeBlocks(unittest.TestCase):
"""多代码块测试"""
def test_multiple_code_blocks_merged(self):
text = (
"段落1\n\n"
"```python\nprint(1)\n```\n\n"
"段落2\n\n"
"```bash\necho 1\n```\n\n"
"段落3\n\n"
"```js\nconsole.log(1)\n```\n\n"
"段落4\n\n"
"```yaml\nfoo: bar\n```\n\n"
"结尾"
)
result = pre_trim_for_multica_comment(text)
# 应保留首尾两个代码块,中间合并
self.assertIn("print(1)", result["trimmed"])
self.assertIn("foo: bar", result["trimmed"])
# 中间代码块应被省略(echo 1 和 console.log 不应出现)
# 注意:合并后保留 first + last,所以 echo/console 可能保留
self.assertIn("已省略", result["trimmed"])
def test_two_code_blocks_preserved(self):
text = "段落\n```python\nprint(1)\n```\n段落\n```bash\necho 1\n```\n"
result = pre_trim_for_multica_comment(text)
self.assertIn("print(1)", result["trimmed"])
self.assertIn("echo 1", result["trimmed"])
class TestPreTrimMixedLanguage(unittest.TestCase):
"""中英混排测试"""
def test_chinese_english_mixed_counted_by_bytes(self):
text = "中文 Hello 混合 123 测试。\n" * 20
result = pre_trim_for_multica_comment(text)
# 中文 3 字节/字符,UTF-8 字节数正确
self.assertGreater(result["original_bytes"], len(text))
self.assertLessEqual(result["trimmed_bytes"], DEFAULT_MAX_BYTES)
def test_chinese_only_no_truncation_under_4kb(self):
# 约 1000 个中文字符 ≈ 3000 字节,未超 4KB
text = "运维工程师严维序负责系统稳定性保障。" * 30
result = pre_trim_for_multica_comment(text)
self.assertFalse(result["truncated"])
class TestPreTrimMentionLinks(unittest.TestCase):
"""mention 链接测试"""
def test_agent_mention_stripped(self):
text = "[@徐聪](mention://agent/46bdd4a6-5c64-475a-92ef-36a763602fa1) 已就绪"
result = pre_trim_for_multica_comment(text)
self.assertIn("@徐聪", result["trimmed"])
self.assertNotIn("mention://", result["trimmed"])
def test_issue_mention_preserved(self):
text = "[MUL-104](mention://issue/abc-123) 已派发"
result = pre_trim_for_multica_comment(text)
self.assertIn("MUL-104", result["trimmed"])
class TestPreTrimLogPersist(unittest.TestCase):
""".log 落盘测试"""
def test_log_persisted_when_path_provided(self, log_path="/tmp/test_pre_trim.log"):
if os.path.exists(log_path):
os.remove(log_path)
long_text = "log test " * 200
result = pre_trim_for_multica_comment(long_text, log_path=log_path)
self.assertIsNotNone(result["log_path"])
self.assertTrue(os.path.exists(log_path))
with open(log_path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("log test", content)
# 清理
os.remove(log_path)
class TestPreTrimVersion(unittest.TestCase):
"""版本戳测试(BIZ-38 合规)"""
def test_version_stamp_present(self):
self.assertIsNotNone(__version__)
self.assertRegex(__version__, r"^\d+\.\d+\.\d+$")
class TestPreTrimCJKSafeBoundary(unittest.TestCase):
"""CJK-safe 截断边界测试(BIZ-104 Phase ④)"""
def test_no_split_mid_rune(self):
"""确保截断点不在 CJK 多字节序列中间"""
# 强制截断以验证边界点
# 1 个中文字 = 3 字节
# 200 个中文字 = 600 字节
text = "中" * 200 # 600 字节
result = pre_trim_for_multica_comment(text, max_bytes=100)
# 验证输出是合法 UTF-8
out_bytes = result["trimmed"].encode("utf-8")
# 验证不能被错误丢弃的字节产生非法序列
try:
decoded = out_bytes.decode("utf-8")
self.assertIsInstance(decoded, str)
except UnicodeDecodeError:
self.fail("Output contains invalid UTF-8")
def test_cjk_density_warning_added(self):
"""高 CJK 密度时动作链含 cjk_density_warn"""
# 200 个中文字 + 少量 ASCII,CJK 占比 > 95%
text = "运维工程师严维序负责系统稳定性保障。" * 50
result = pre_trim_for_multica_comment(text)
cjk_warnings = [a for a in result["actions"] if "cjk_density" in a]
self.assertGreater(len(cjk_warnings), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)
-66
View File
@@ -1,66 +0,0 @@
# 竞品价格监控采集(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`,周报侧据此人工复核。
@@ -1,4 +0,0 @@
# 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
@@ -1,8 +0,0 @@
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.
@@ -1,6 +0,0 @@
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
@@ -1,76 +0,0 @@
{
"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
@@ -1,283 +0,0 @@
#!/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
@@ -1,39 +0,0 @@
# 竞品价格监控目标清单(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: []
@@ -1,168 +0,0 @@
# 淘宝 + 聚水潭自动化运营方案设计
## 1. 方案思路
### 1.1 整体架构
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 淘宝千牛平台 │ ←─→ │ 自动化中间层 │ ←─→ │ 聚水潭 ERP │
│ - 商品管理 │ │ - API 适配层 │ │ - 订单管理 │
│ - 订单管理 │ │ - 数据同步引擎 │ │ - 库存管理 │
│ - 库存管理 │ │ - 任务调度器 │ │ - 采购管理 │
│ - 客服消息 │ │ - 监控告警 │ │ - 财务报表 │
└─────────────────┘ └──────────────────┘ └─────────────────┘
↓
┌──────────────────┐
│ 数据存储层 │
│ - SQLite/MySQL │
│ - Redis 缓存 │
│ - 日志系统 │
└──────────────────┘
```
### 1.2 数据采集方式
| 数据源 | 推荐方式 | 理由 |
|--------|----------|------|
| 淘宝千牛 | **浏览器自动化 (opencli)** | 千牛工作台无开放 API,需模拟登录操作 |
| 聚水潭 | **API 对接优先** | 聚水潭提供开放 API,稳定可靠 |
| 备用方案 | 浏览器自动化 | API 受限时的降级方案 |
### 1.3 自动化流程设计
**核心流程**:
1. **订单同步**:淘宝订单 → 聚水潭(实时/定时)
2. **库存同步**:聚水潭库存 → 淘宝(避免超卖)
3. **商品管理**:批量上下架、价格调整
4. **数据报表**:销售分析、库存周转、利润核算
---
## 2. 实现路径
### 阶段 1:前期调研(1-2 周)
| 任务 | 负责人 | 交付物 |
|------|--------|--------|
| 淘宝 opencli adapter 开发 | costcodev | `taobao-qiniu-adapter.ts` |
| 聚水潭 API 能力评估 | architect | 《聚水潭 API 调研报告》 |
| 反爬策略分析 | costcodev | 风险评估文档 |
| 技术选型确认 | architect | 架构决策记录 ADR |
### 阶段 2:系统对接(2-3 周)
| 任务 | 负责人 | 交付物 |
|------|--------|--------|
| 淘宝千牛数据读取模块 | costcodev | 可运行脚本 |
| 聚水潭 API 封装库 | costcodev | `jushuitan-sdk` |
| 数据同步中间件 | costcodev | 核心同步引擎 |
| 数据库 schema 设计 | architect | ER 图 + 建表脚本 |
### 阶段 3:核心功能开发(3-4 周)
| 任务 | 负责人 | 交付物 |
|------|--------|--------|
| 订单自动同步 | costcodev | 订单同步模块 |
| 库存自动同步 | costcodev | 库存同步模块 |
| 商品批量管理 | costcodev | 商品管理模块 |
| 报表生成引擎 | costcodev | 数据报表模块 |
| Web 控制台 | designer + costcodev | 管理后台 UI |
### 阶段 4:测试验证(1-2 周)
| 任务 | 负责人 | 交付物 |
|------|--------|--------|
| 功能测试 | coo + taobaospecialist | 测试报告 |
| 稳定性测试 | opengineer | 压力测试报告 |
| 数据一致性校验 | coo | 校验脚本 + 报告 |
| 安全审计 | opengineer | 安全评估报告 |
### 阶段 5:部署上线(1 周)
| 任务 | 负责人 | 交付物 |
|------|--------|--------|
| 生产环境部署 | opengineer | 部署文档 |
| 监控告警配置 | opengineer | Grafana 面板 |
| 运维交接 | opengineer | 运维 SOP |
| 用户培训 | coo | 使用手册 |
---
## 3. 团队资源需求
| 角色 | 工时估算 | 职责 |
|------|----------|------|
| **architect 梁思筑** | 20h | 架构设计、技术选型、ADR 撰写 |
| **costcodev 徐聪** | 120h | 核心开发(API 对接、同步引擎、Web 后台) |
| **designer 苏绘锦** | 16h | Web 控制台 UI 设计 |
| **opengineer 严维序** | 24h | 部署、监控、运维 SOP |
| **taobaospecialist 陆云帆** | 16h | 业务需求输入、流程验证、UAT 测试 |
| **coo 陆怀瑾** | 24h | 项目管理、风险评估、进度跟进 |
**总工时**:约 220 小时(约 5.5 人周)
---
## 4. 风险与约束
### 4.1 接口限制
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 淘宝 API 调用频率限制 | 数据同步延迟 | 请求队列 + 令牌桶限流 |
| 聚水潭 API 权限不足 | 部分功能无法实现 | 申请高级权限或改用浏览器自动化 |
### 4.2 反爬机制
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 淘宝风控检测 | 账号被封禁 | 1. 使用真实 Cookie 2. 限制操作频率 3. 添加人工操作混淆 |
| 登录态失效 | 自动化中断 | Token 自动刷新 + 失效告警 |
### 4.3 数据安全
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 账号密码泄露 | 严重安全事故 | 1. 使用密钥管理服务 2. 环境变量存储 3. 权限隔离 |
| 数据传输未加密 | 中间人攻击 | HTTPS + 数据加密传输 |
### 4.4 平台稳定性
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 聚水潭接口变更 | 功能失效 | 1. 接口版本监控 2. 快速适配机制 |
| 淘宝页面结构变更 | 选择器失效 | 1. 多选择器备用 2. 定期巡检更新 |
### 4.5 合规风险
| 风险 | 影响 | 应对措施 |
|------|------|----------|
| 违反淘宝服务条款 | 店铺处罚 | 1. 评估自动化操作合规性 2. 控制操作频率在合理范围 3. 保留人工审核环节 |
---
## 5. 下一步行动
1. **立即启动**:architect 开始聚水潭 API 调研
2. **并行推进**:costcodev 开发淘宝 opencli adapter
3. **周会同步**:每周五同步进展,识别阻塞点
4. **里程碑**:阶段 1 结束后进行方案复审
---
## ⚠️ 安全提示
**严禁在代码/配置文件中明文存储账号密码!**
正确做法:
```bash
# 使用环境变量
export TAOBAO_ACCOUNT=$(vault read -field=value secret/taobao/account)
export TAOBAO_PASSWORD=$(vault read -field=value secret/taobao/password)
```
或使用密钥管理服务(如 HashiCorp Vault、AWS Secrets Manager)。
---
提交人:陆怀瑾(COO)| 提交时间:2026-07-11 | 关联 issue:BIZ-89
@@ -0,0 +1,290 @@
# 石斛液态饮品全品类扩展可行性分析报告
**报告编号**:BIZ-64 | **日期**:2026年6月26日 | **分析师**:顾析策
**基准企业**:云南品斛堂生物科技有限公司 | **参考文档**:石斛食品饮料全品类产品方向详细文档 + BIZ-53企业情报调研报告
---
## 一、摘要
**核心结论**:品斛堂具备从石斛原浆单品类冠军向液态饮品多品类平台跃迁的条件,但应遵循"近岸延伸、能力匹配"原则——优先巩固原浆优势+扩展酒类第二曲线,谨慎试水即饮植物饮料,暂不进入乳制品/发酵饮品。
**关键数据**:
- 石斛淘系线上2025 Q1销售额1.25亿元,同比+42%,品类增速为药食同源赛道之首
- 露酒行业2025年规模650亿元,2030年预测2000亿(CAGR约25%),品斛堂石斛酒已有"中国销量第一"认证
- 功能饮料2025年中国市场规模超1800亿元,增速38%
- 中式养生水(如红豆薏米水)年增速182%,一年内达10亿规模
- 益生菌中国市场2025年约1400亿元,近五年CAGR 9.74%
**TOP5 优先级排序**:
1. 🥇 **复合草本原浆**(可行性高,毛利率60-70%,现有渠道复用)
2. 🥈 **石斛露酒/配制酒扩展**(可行性高,毛利率55-75%,独立增长极)
3. 🥉 **功能定制原浆**(可行性高,毛利率65-80%,差异化壁垒)
4. 🏅 **石斛养生茶包/冲调粉**(可行性中高,毛利率45-55%,产能灵活)
5. 🏅 **中式养生即饮植物饮料**(可行性中,毛利率40-55%,需渠道突破)
---
## 二、分品类详细评估
### 2.1 即饮原浆/浓浆(王牌品类·核心市场)
#### 市场数据
| 指标 | 数据 | 来源 |
|------|------|------|
| 品斛堂石斛原浆市场份额 | 45%+,全国销量第一 | 尚普咨询2026白皮书、CIC灼识认证 |
| 石斛淘系线上销售额(Q1 2025) | 1.25亿元,同比+41.96% | 植提桥/Foodaily |
| 品斛堂累计销售 | 超5亿袋 | 企业公开数据 |
| 石斛原浆品类零售价带 | ¥199-2999(按品类规格) | 天猫/京东实时数据 |
| 行业高端化增速(¥800/100g+) | 31%,远超大众市场 | 行业报告 |
#### 竞争格局
| 梯队 | 品牌 | 核心优势 |
|:---:|------|------|
| 🥇 | 品斛堂 | 全产业链+品类开创者,综合评分最高 |
| 🥈 | 同仁堂、东阿阿胶 | 老字号品牌溢价,送礼场景强 |
| 🥉 | 芝康纪、斛妈妈 | 多糖含量碾压/社媒运营强势 |
#### 扩展可行性分析
**A. 单一石斛原浆**
| 产品 | 可行性 | 预期毛利率 | 竞争评估 | 关键成功因素 |
|------|:---:|:---:|------|------|
| 紫皮石斛原浆(旗舰) | **★★★★★** | 60-70% | 领先地位稳固 | 维持品牌溢价,持续原料品质升级 |
| 铁皮石斛原浆(高端) | **★★★★☆** | 65-75% | 面临霍山产区竞争 | 强化多糖含量数据,产地溯源 |
| 霍山米斛原浆(超高端) | **★★★☆☆** | 70-80% | 九仙尊/斛妈妈垄断霍山产区 | 原料需外采,产地话语权弱 |
| 冷榨鲜原浆(创新) | **★★★★☆** | 55-65% | 差异化蓝海 | 48h鲜榨工艺,冷链物流是瓶颈 |
**B. 复合草本原浆(强烈推荐)**
| 产品 | 可行性 | 预期毛利率 | 目标人群规模 | 竞争强度 | 建议优先级 |
|------|:---:|:---:|:---:|:---:|:---:|
| 石斛+猴头菇+沙棘(养胃) | ★★★★★ | 60-70% | 肠胃不适人群巨大 | 中(江中/同仁堂) | 🥇 |
| 石斛+葛根+五味子(护肝/解酒) | ★★★★★ | 60-70% | 饮酒人群+熬夜人群 | 中低(差异化强) | 🥇 |
| 石斛+枸杞+菊花(护眼) | ★★★★☆ | 55-65% | 办公族/用眼过度 | 中 | 🥈 |
| 石斛+西洋参+黄芪(补气) | ★★★★☆ | 60-70% | 亚健康/免疫力低下 | 中高(同仁堂/康恩贝) | 🥈 |
| 石斛+酸枣仁+百合(助眠) | ★★★★★ | 60-70% | 失眠人群超3亿 | 中(同仁堂/东阿阿胶) | 🥇 |
**评估理由**:复合原浆是品斛堂最优先的扩展方向。理由:(1) 复用现有原浆产线和技术(生物酶解+低温浓缩),无需新增固定资产;(2) 复用天猫/京东/抖音/视频号现有渠道和用户群;(3) 复合配方通过功能场景化营销(护肝/助眠/养胃)实现差异化,降低教育成本;(4) 客单价提升和复购率提升显著。关键风险是合规边界——普通食品不得宣传保健功能,需以"草本配方""古方传承"等合规语言替代功效宣传。
**C. 功能定制原浆**
| 产品 | 可行性 | 预期毛利率 | 市场机会 |
|------|:---:|:---:|------|
| 无糖石斛原浆 | ★★★★☆ | 60-70% | 无糖饮料市场615.6亿元(2025),CAGR 8%+ |
| 有机石斛原浆 | ★★★☆☆ | 65-75% | 有机认证门槛高→溢价空间大 |
| 儿童稀释版 | ★★★☆☆ | 50-60% | 需额外安全性论证,合规门槛高 |
| 孕妇温和版 | ★★☆☆☆ | 55-65% | 合规风险极高,不建议 |
**⚠️ 合规提醒**:普通食品不得声称适用"儿童""孕妇"等特殊人群,相关产品须严格按照食品安全标准和广告法规范。
---
### 2.2 即饮植物饮料(千亿级大众市场·谨慎进入)
#### 市场数据
| 指标 | 数据 | 来源 |
|------|------|------|
| 中国饮料市场2025年规模 | 15,170亿元 | 艾媒咨询 |
| 功能饮料2025年规模 | 超1,800亿元 | 中商情报网/欧睿 |
| 凉茶市场2025年规模 | 255亿元 | 艾媒咨询 |
| 中式养生水增速 | 182%,一年达10亿规模 | 尼尔森IQ |
| 无糖茶增速 | 双位数,全国线下+80% | 尼尔森IQ |
| 植物饮料增速 | 125.9%(2025 H1) | 魔镜洞察 |
| 电解质饮料增速 | 160%+(2025 H1) | 魔镜洞察 |
| 功能饮料线上CAGR | 13.2%(2019-2024) | 行业报告 |
#### 品类可行性评估
| 子品类 | 市场规模 | 增速 | 品斛堂可行性 | 预期毛利率 | 核心壁垒 |
|------|:---:|:---:|:---:|:---:|------|
| 石斛草本凉茶 | 255亿(凉茶) | 存量博弈 | ★★★☆☆ | 40-50% | 口味研发+渠道覆盖 vs 王老吉/加多宝垄断 |
| 石斛功能饮料(能量/电解质) | 1,800亿+ | 38% | ★★☆☆☆ | 45-55% | 红牛/东鹏/外星人寡头,品斛堂零经验 |
| 石斛养生水/植物饮料 | 10亿+(新兴) | 125%+ | ★★★★☆ | 45-55% | 盒马石斛水已有验证,但品牌≠品斛堂 |
| 石斛茶饮(绿茶/普洱) | 即饮茶第一大品类 | 双位数 | ★★☆☆☆ | 35-45% | 农夫山泉/康师傅/统一垄断 |
| 石斛咖啡 | 116亿(即饮咖啡) | 增长中 | ★☆☆☆☆ | 40-50% | 星巴克/瑞幸品牌壁垒+品斛堂零经验 |
**关键判断**:
品斛堂切入即饮植物饮料面临**三重壁垒**:
1. **产能壁垒**:即饮饮料需要独立的PET/易拉罐灌装线、大规模产能(日产能百万瓶级),品斛堂现有产能为原浆袋装线和酒类酿造线,不具备即饮灌装基础;
2. **渠道壁垒**:即饮饮料依赖百万级线下终端(便利店/商超/餐饮),品斛堂线下渠道集中在药店/特产店/美容院,渠道错配严重;
3. **品牌壁垒**:品斛堂在石斛滋补品领域有强认知,但在"解渴饮料"场景的品牌认知为零。
**破局路径**:
- **最短路径**:石斛养生水/植物饮料,以ODM/OEM模式快速试水(品斛堂提供石斛原料+配方,由成熟饮料代工厂生产),借盒马/山姆等新零售渠道首发
- **不推荐**:自建即饮产线、硬攻能量饮料/凉茶等成熟红海
- **参考案例**:元气森林"自在水"(红豆薏米/红枣枸杞水)——以中式养生概念实现差异化破圈,一年内破亿
---
### 2.3 乳制品/发酵饮品(万亿级·现阶段不建议)
#### 市场数据
| 指标 | 数据 | 来源 |
|------|------|------|
| 中国酸奶市场2024年规模 | 998.74亿元(同比-9%) | 智研咨询 |
| 中国益生菌终端市场2025年 | 约1,400亿元 | 前瞻产业研究院 |
| 全球益生菌市场2025年 | 731.3亿美元,CAGR 7.15% | Fortune Business Insights |
| 肠道健康饮料全球2025年 | 242亿美元,CAGR 10.9% | GM Insights |
| 酸奶市场格局 | 伊利+蒙牛双寡头,CR2约50%+ | 智研咨询 |
| 酵素/醋饮市场 | 碎片化,头部品牌<10亿 | 行业估算 |
#### 可行性评估
| 子品类 | 可行性 | 预期毛利率 | 关键判断 |
|------|:---:|:---:|------|
| 石斛酸奶 | ★★☆☆☆ | 30-40% | 伊利/蒙牛双寡头控制冷链+货架+品牌,新进入者几乎无机会 |
| 石斛益生菌粉/固体饮料 | ★★★★☆ | 50-65% | **属于冲调品类,不是乳制品**,可行性高(见2.5) |
| 石斛酵素 | ★★★☆☆ | 55-70% | 碎片化市场,但消费者认知模糊,教育成本高 |
| 石斛醋饮 | ★★☆☆☆ | 40-50% | 天地壹号一家独大,品类天花板低 |
**判断**:
- **酸奶**——品斛堂无乳制品基础(无奶源、无冷链、无乳制品生产技术),进入成本极高。酸奶市场已进入存量博弈(2024年下滑9%),伊利/蒙牛双寡头格局难以撼动。**不推荐**。
- **酵素/醋饮**——碎片市场有缝隙机会,但品类教育成本高,消费者信任度低。**暂不推荐**。
- **益生菌粉**——这是品斛堂可以切入的方向(见2.5冲调饮品)。
---
### 2.4 酒类(第二增长曲线·高优先级扩展)
#### 市场数据
| 指标 | 数据 | 来源 |
|------|------|------|
| 露酒行业2025年规模 | 650亿元 | 中国酒业协会 |
| 露酒行业利润增速(2020-2024) | 接近200% | 中国酒业协会 |
| 露酒2030年预测规模 | 2,000亿元 | 中国酒业协会 |
| 露酒年销量增速 | 30%+ | 中国酒业协会 |
| 劲酒2025年增长 | 超20%,新增900万年轻用户 | 证券时报/劲牌 |
| 保健酒市场规模 | 约377亿元(2023) | 智研咨询 |
| 低度潮饮2025年规模 | 突破600亿元 | 行业报告 |
| 品斛堂酒业产能 | 万吨级,满产12,000吨 | 企业公开数据 |
#### 竞争格局
| 梯队 | 企业/品牌 | 规模/定位 | 品斛堂对标 |
|:---:|------|------|------|
| 🥇 超级领跑 | 劲牌(劲酒+毛铺) | 130亿+,百亿级唯一 | — |
| 🥈 头部冲刺 | 泸州老窖养生酒、汾酒竹叶青、五粮液本草、椰岛鹿龟酒 | 冲刺10亿级 | 追赶目标 |
| 🥉 新锐入局 | 茅台保健酒"1+3"矩阵、古井神力酒 | 战略入局 | 差异化对象 |
| 🏅 品斛堂 | 石斛酒中国销量第一 | 酒业独立运营,王朝成入股 | **差异化定位** |
#### 扩展可行性分析
| 产品方向 | 可行性 | 预期毛利率 | 市场空间 | 关键成功因素 |
|------|:---:|:---:|------|------|
| **石斛露酒/配制酒扩展**(石斛+西洋参+灵芝/枸杞/人参/青梅) | ★★★★★ | 60-75% | 露酒650亿→2000亿 | 蓝帽子批文+差异化石斛IP+酒业产能 |
| **石斛米香型白酒** | ★★★★☆ | 50-65% | 云南本地+全国化 | 米香+石斛差异化,区域文化加持 |
| **石斛酱香型白酒** | ★★★☆☆ | 55-70% | 酱酒3,000亿 | 茅台镇产能/品牌/渠道壁垒极高 |
| **NANO小酒(年轻化)** | ★★★★☆ | 50-60% | 年轻化微醺市场爆发 | 对标江小白/梅见,石斛健康标签 |
| **石斛青梅酒(女性向)** | ★★★★☆ | 55-65% | 低度果酒高速增长 | 女性消费者占比58%,年轻化 |
**详细分析**:
**露酒扩展(★★★★★)**:品斛堂酒业最大优势在于——(1) "石斛酒中国销量第一""中国石斛露酒开创者"双重认证;(2) 王朝成(中国酒业顶级智囊)现金入股;(3) 万吨级酿造基地产能充裕;(4) 徐瑞晟(省级白酒品评冠军)技术护城河。露酒行业正处于爆发前夜(30%+增速,2030年2000亿),且"石斛+酒"是品斛堂独有的品类定位,竞争对手无法复制。强烈推荐扩展石斛西洋参灵芝酒(蓝帽子)、石斛枸杞酒、石斛人参酒等产品线,并向青梅酒等低度果酒延伸。
**白酒扩展(★★★☆☆)**:米香型白酒在云南有文化根基,品斛堂可深耕区域市场。酱香型白酒则面临茅台/习酒/郎酒等巨头碾压,不建议重资产投入。
**年轻化小酒(★★★★☆)**:劲酒新增900万年轻用户中400万为女性,证明"养生+微醺"正在破圈。品斛堂推出125ml石斛小酒+石斛青梅酒,切入年轻消费者场景(酒吧/KTV/餐饮),机会明确。
**风险提示**:露酒市场"一超多强"格局正在形成(劲酒百亿级领跑,茅台/五粮液/泸州老窖/汾酒密集入局),品斛堂需在窗口期(2-3年)内快速建立规模壁垒,否则将面临头部企业的降维打击。
---
### 2.5 冲调饮品(灵活产能·稳定增长)
#### 市场数据
| 指标 | 数据 | 来源 |
|------|------|------|
| 冲调泡品类增速 | 领跑食品饮料全行业(2025 H1) | 魔镜洞察 |
| 益生菌终端市场(中国2025) | 约1,400亿元 | 前瞻产业研究院 |
| 益生菌膳食补充剂CAGR | 18%(2021-2024) | 凯度/东吴证券 |
| 肠道健康线上规模 | 约40亿元,益生菌占75%+ | 银河证券 |
| 速溶粉/固体饮料市场 | 碎片化,养生类高速增长 | 行业估算 |
#### 可行性评估
| 产品 | 可行性 | 预期毛利率 | 竞争分析 | 产能匹配 |
|------|:---:|:---:|------|:---:|
| 石斛速溶粉/固体饮料 | ★★★★☆ | 50-60% | 碎片化市场,尚无绝对头部 | 现有粉剂产线可复用 |
| 石斛益生菌粉 | ★★★★☆ | 55-65% | 竞争激烈但增长快 | 可OEM合作成熟菌粉供应商 |
| 石斛养生茶包 | ★★★★★ | 45-55% | 同仁堂/五谷磨房等,但有差异化空间 | 设备投入低,可快速启动 |
| 石斛蛋白粉 | ★★☆☆☆ | 50-60% | 汤臣倍健/康比特专业壁垒高 | 需运动营养领域研发 |
**判断**:
- **茶包(★★★★★)**是最低成本的入门产品——设备投入低、渠道灵活(线上+药店+特产店+礼品渠道)、可快速迭代试错。石斛+陈皮/枸杞/菊花/桂圆等经典中式养生组合天然成立。
- **益生菌粉(★★★★☆)**是增长最快的子品类——品斛堂无需自研菌株,可外采成熟益生菌原料+自有石斛多糖配方差异化。毛利率高(55-65%),线上渠道起量快。
- **速溶粉(★★★★☆)**复用现有粉剂产线,主打"冷水速溶""办公便携"场景,与石斛原浆形成"即饮+冲调"组合覆盖。
---
## 三、综合对比矩阵
| 品类 | 市场规模 | 增速 | 可行性 | 预期毛利率 | 产能匹配 | 渠道匹配 | 品牌匹配 | 竞争强度 | 综合优先级 |
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 复合草本原浆 | 120亿+ | 25%+ | ★★★★★ | 60-70% | ★★★★★ | ★★★★★ | ★★★★★ | 中 | **🥇 第一位** |
| 石斛露酒扩展 | 650亿→2000亿 | 30%+ | ★★★★★ | 60-75% | ★★★★★ | ★★★★☆ | ★★★★★ | 中→高 | **🥈 第二位** |
| 功能定制原浆 | 120亿+(子集) | 25%+ | ★★★★☆ | 65-80% | ★★★★★ | ★★★★★ | ★★★★★ | 低→中 | **🥉 第三位** |
| 石斛养生茶包 | 500亿+(冲调) | 15%+ | ★★★★★ | 45-55% | ★★★★☆ | ★★★★☆ | ★★★★☆ | 中 | **🏅 第四位** |
| 石斛养生水/植物饮料 | 10亿+(新兴) | 125%+ | ★★★★☆ | 40-55% | ★★☆☆☆ | ★★☆☆☆ | ★★★☆☆ | 中 | **🏅 第五位** |
| 石斛益生菌粉 | 40亿+ | 18% | ★★★★☆ | 55-65% | ★★★☆☆ | ★★★★☆ | ★★★★☆ | 中高 | 第六位 |
| 石斛功能饮料 | 1,800亿+ | 38% | ★★☆☆☆ | 45-55% | ★☆☆☆☆ | ★☆☆☆☆ | ★★☆☆☆ | 极高 | 暂不推荐 |
| 石斛酸奶 | 998亿 | -9% | ★★☆☆☆ | 30-40% | ★☆☆☆☆ | ★☆☆☆☆ | ★★☆☆☆ | 极高 | 不建议 |
| 石斛咖啡 | 116亿 | 增长中 | ★☆☆☆☆ | 40-50% | ★☆☆☆☆ | ★☆☆☆☆ | ★☆☆☆☆ | 极高 | 不建议 |
---
## 四、扩展路径建议(三年路线图)
### 第一年(2026-2027):巩固+近岸扩展
1. **复合草本原浆**:优先上市 护肝(葛根五味子)、助眠(酸枣仁百合)、养胃(猴头菇沙棘)三款
2. **功能定制原浆**:推出无糖版,抢占控糖人群
3. **露酒扩展**:推出石斛枸杞酒、石斛人参酒、石斛青梅酒
4. **养生茶包**:石斛陈皮茶、石斛枸杞菊花茶
### 第二年(2027-2028):规模化+渠道下沉
1. 复合原浆全渠道铺开(药店/商超/便利店)
2. 启动NANO小酒年轻化营销(小红书/抖音种草+餐饮渠道)
3. 益生菌粉上市
4. 试水石斛养生水(ODM模式,盒马/山姆首发)
### 第三年(2028-2029):品牌升维+第二曲线
1. 石斛露酒冲刺5-10亿级
2. 评估是否需要自建即饮产线(基于养生水试水结果)
3. 探索石斛酵素/发酵饮品
4. 国际化试水(东南亚石斛原浆/酒类出口)
---
## 五、关键风险与应对
| 风险 | 等级 | 应对策略 |
|------|:---:|------|
| 复合原浆合规风险(功效宣传边界) | 🔴 | 严格以"草本配方""中式养生"替代功效宣传;法务前置审核 |
| 露酒市场头部挤压(劲酒/茅台保健酒/五粮液) | 🟡 | 聚焦"石斛"差异化壁垒,避免与巨头正面价格战 |
| 即饮饮料产线投资过大 | 🟡 | 以ODM模式试水,暂不自建产线 |
| 多品类扩张导致品牌定位模糊 | 🟡 | 保持"元斛"品牌聚焦石斛原浆,"品斛堂"品牌聚焦酒类,新品类使用独立子品牌 |
| 王红权星封禁关联舆情风险 | 🔴 | 清理合作痕迹,建立达人合作风控机制 |
---
## 六、数据来源
1. 尚普咨询《2026中国药食同源健康行业白皮书》—— 石斛原浆品牌梯队
2. CIC灼识咨询认证 —— 品斛堂石斛原浆/石斛酒"第一"认证
3. 中国酒业协会 —— 露酒行业规模650亿/2030年2000亿预测
4. 艾媒咨询《2026年中国饮料行业发展状况及消费行为调查数据》—— 饮料市场15,170亿元
5. 尼尔森IQ《2025 解构中国饮料行业增长新势能》—— 中式养生水增速182%,功能饮料增速第一
6. Fortune Business Insights —— 全球益生菌市场731.3亿美元
7. 前瞻产业研究院 —— 中国益生菌行业约1400亿元
8. 智研咨询 —— 酸奶市场998.74亿元
9. 魔镜洞察《2025年H1消费新潜力白皮书》—— 植物饮料增速125.9%
10. 证券时报/劲牌公开数据 —— 劲酒2025年增长超20%,新增900万年轻用户
11. 新京报《养生酒赛道扩容》—— 茅台保健酒"1+3"露酒矩阵
12. 中商情报网 —— 中国功能饮料市场超1800亿元
13. 品斛堂官网/天猫元斛旗舰店/京东品斛堂酒类旗舰店实时数据
14. Foodaily/腾讯新闻 —— 盒马石斛水、每日乔安等新锐品牌动态
---
*注:部分预测数据基于行业增速假设推算,置信区间±15%。毛利率数据为行业经验估算,企业实际数据可能因规模效应和定价策略有所差异。*
@@ -0,0 +1,20 @@
# 品斛堂产品扩展可行性分析报告
**来源项目**:BIZ-53 云南品斛堂生物科技深度调研
**分析人**:顾析策(marketanalysis 市场分析师)
**分析日期**:2026年6月26日
**父 Issue**:[BIZ-53](https://multica.bizurl.cn/issues/b619b988-2311-481d-bb95-409caa9ce82a)
## 报告索引
| 编号 | 报告 | 核心结论 |
|------|------|----------|
| BIZ-64 | 石斛液态饮品全品类扩展可行性分析 | 建议优先切入功能性饮品赛道 |
| BIZ-65 | 石斛固态食品与烘焙全品类扩展可行性分析 | 膏滋蜜炼和压片糖果为最高优先级 |
| BIZ-66 | 石斛功能保健食品-礼品-创新跨界可行性分析 | 蓝帽子保健品高壁垒高毛利,礼品方向可复用现有基础 |
| BIZ-67 | 石斛预制菜与调味品赛道切入可行性分析 | 建议观望+轻资产试水 |
## 文档状态
- 4 份报告均为完整交付物,已提交至 Multica `in_review`
- 等待刘总(Vincent)审阅确认
-170
View File
@@ -1,170 +0,0 @@
-- ============================================================
-- 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');
@@ -1,240 +0,0 @@
# 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 架构交付) | 梁思筑 |
-252
View File
@@ -1,252 +0,0 @@
# 知识缺口上报 SOP v1.0
> 生效日期:2026-07-06 | 负责人:COO(陆怀瑾) | 审批:刘总 2026-07-06
---
## 一、什么是知识缺口
**知识缺口**指 Agent 在任务执行过程中遇到的、无法通过现有知识库检索到的关键信息缺失,且该缺失会影响任务交付质量或导致决策偏差。
### 1.1 触发条件(满足任一即需上报)
| 场景 | 示例 | 上报优先级 |
|------|------|-----------|
| **核心业务数据缺失** | 某业务线 KPI 无历史记录、竞品分析无基准数据 | 高 |
| **SOP/流程文档不存在** | 需要执行某流程但无标准文档可查 | 高 |
| **关键决策依据缺失** | 需要历史决策记录但检索无结果 | 中 |
| **外部信息无法验证** | 需要核实供应商资质/合同条款但无存档 | 中 |
| **技术文档/API 文档缺失** | 需要调用某系统 API 但无文档 | 中 |
| **Agent 配置/技能文档缺失** | 新 Agent 接入时无 AGENTS.md/SOUL.md 参考 | 低 |
### 1.2 不属于知识缺口的情况
- 可通过 `web_search` / `tavily_search` 快速获取的公开信息 → 自行检索,不上报
- 可通过 `memory_search` / `wiki_search` 检索到的现有知识 → 自行读取,不上报
- 任务执行中的临时疑问(不影响交付) → 记录在任务评论中,不上报
- 可通过联系对应 Agent 获得的信息 → 直接联系,不上报
---
## 二、上报流程
### 2.1 标准流程(5 步)
```
发现知识缺口
↓
步骤 1:记录缺口(在任务评论中)
↓
步骤 2:通知 COO(飞书消息)
↓
步骤 3:COO 评估优先级(高/中/低)
↓
步骤 4:创建 WorkBoard 卡片(分配责任人)
↓
步骤 5:补全后通知原 Agent(关闭循环)
```
### 2.2 步骤详解
#### 步骤 1:记录缺口
在**当前任务**的 Multica issue 或 WorkBoard 卡片评论中记录:
```markdown
## 📚 知识缺口上报
**缺口类型**:[核心业务数据 | SOP 缺失 | 决策依据 | 外部信息 | 技术文档 | Agent 配置]
**缺口描述**:[具体缺什么,为什么影响任务]
**影响范围**:[哪些任务/决策受影响]
**紧急程度**:[高/中/低]
**建议补全方式**:[需要谁提供 / 需要查什么 / 需要创建什么文档]
```
#### 步骤 2:通知 COO
通过飞书消息通知 COO(陆怀瑾):
```
【知识缺口上报 - 紧急程度:高/中/低】
任务:[任务标题/ID]
缺口:[一句话概括]
影响:[不补全的后果]
建议:[你希望怎么处理]
请评估并创建补全卡片。
```
**COO 飞书 ID**:`ou_9f73b4e54af59f038e2b754793ea0908`
#### 步骤 3:COO 评估
COO 在收到上报后 **2 小时内** 完成评估:
| 优先级 | 响应时效 | 处理方式 |
|--------|---------|---------|
| **高** | 立即处理 | 创建紧急卡片,分配专人,24h 内补全 |
| **中** | 当日内处理 | 创建常规卡片,纳入排期,48h 内补全 |
| **低** | 3 日内处理 | 纳入知识 backlog,周度批量处理 |
#### 步骤 4:创建补全卡片
COO 通过 WorkBoard 创建知识补全卡片:
- **标题**:`[知识补全] <缺口主题>`
- **描述**:引用原始上报评论
- **assignee**:对应领域负责人(见责任矩阵)
- **priority**:根据评估结果设定
- **labels**:`knowledge`, `gap`, `<领域>`
- **parent**:关联到原任务(可选)
#### 步骤 5:闭环通知
知识补全完成后:
1. 责任人在卡片中上传补全内容(文档链接 / 数据文件 / 决策记录)
2. COO 验收并关闭卡片
3. COO 飞书通知原上报 Agent:
```
【知识缺口已补全】
原缺口:[简述]
补全内容:[文档链接/说明]
归档位置:[知识库路径]
后续可直接检索使用。
```
---
## 三、责任矩阵(Who to Assign)
| 缺口类型 | 默认责任人 | 备选 |
|---------|-----------|------|
| 业务数据/KPI | marketanalysis(顾析策) | COO |
| SOP/流程文档 | COO(陆怀瑾) | projectmanager |
| 技术/API 文档 | opengineer(严维序) | architect |
| 产品需求/PRD | productmanager(沈路明) | COO |
| Agent 配置/技能 | COO(陆怀瑾) | 对应 Agent |
| 外部信息/供应商 | lawyer(苏慎) | COO |
| 设计/UI 规范 | designer(苏绘锦) | COO |
---
## 四、上报渠道
### 4.1 主渠道:WorkBoard 评论 + 飞书通知
**适用场景**:所有正式知识缺口上报
**操作**:
1. 在原任务评论中记录缺口(格式见 2.2)
2. 飞书通知 COO
3. COO 创建 WorkBoard 卡片跟进
### 4.2 补充渠道:Multica Issue
**适用场景**:
- 任务本身在 Multica 上管理
- 需要技术团队协作的知识补全
- 涉及代码/配置/技术文档的缺口
**操作**:
1. 在原 issue 评论中记录缺口(格式同上)
2. 使用 `multica issue create` 创建补全任务:
```bash
multica issue create \
--title "[知识补全] <缺口主题>" \
--description-file gap_description.md \
--priority <high|medium|low> \
--assignee <责任人> \
--label knowledge,gap,<领域>
```
3. 飞书通知 COO 备案
### 4.3 紧急通道:直接联系 COO
**适用场景**:高优先级缺口,需立即处理
**操作**:
- 飞书直聊 COO,简要说明缺口和影响
- COO 口头确认后先行处理,事后补卡片
---
## 五、知识库质量监控(COO 职责)
### 5.1 周度巡检
COO 每周五执行:
```bash
# 检查本周新增知识缺口上报数
# 检查补全卡片完成率
# 检查 gap 标签卡片积压情况
```
**指标**:
- 新增缺口数 ≤ 5 个/周(超标需分析根因)
- 补全完成率 ≥ 90%(48h SLA)
- 积压缺口数 ≤ 3 个
### 5.2 月度分析
每月底输出《知识缺口分析报告》:
- 缺口类型分布(哪类最常缺失)
- 根本原因分析(为什么缺失)
- 流程改进建议(如何预防)
- 责任人绩效(补全时效/质量)
---
## 六、纳入 TOOLS.md 模板
所有 Agent 的 `TOOLS.md` 必须包含以下章节(可放在"知识库查询"章节后):
```markdown
## 📚 知识缺口上报
### 触发条件
遇到以下情况需上报知识缺口(而非自行 web_search):
1. 核心业务数据/SOP/决策依据缺失
2. 缺失影响任务交付质量或决策准确性
3. 无法通过 memory_search/wiki_search 检索到
### 上报流程
1. **记录**:在任务评论中使用标准格式记录缺口
2. **通知**:飞书通知 COO(ou_9f73b4e54af59f038e2b754793ea0908)
3. **跟进**:COO 评估后创建补全卡片
4. **闭环**:补全后 COO 通知你可直接使用
### 标准格式
```markdown
## 📚 知识缺口上报
**缺口类型**:[核心业务数据 | SOP 缺失 | 决策依据 | 外部信息 | 技术文档]
**缺口描述**:[具体缺什么]
**影响范围**:[哪些任务受影响]
**紧急程度**:[高/中/低]
**建议补全方式**:[希望怎么处理]
```
```
---
## 七、版本历史
| 版本 | 日期 | 变更内容 | 审批 |
|------|------|---------|------|
| v1.0 | 2026-07-06 | 初始版本,建立完整上报流程 | 刘总 |
---
**附件**:
- 知识缺口上报模板(见 2.2)
- 责任矩阵(见第三节)
- TOOLS.md 模板章节(见第六节)
@@ -1,159 +0,0 @@
# SOP-016:执行前检索 SOP 强制规则
> **版本**: v1.0
> **起草人**: 胡蓉(projectmanager)
> **批准人**: 刘炜承
> **生效日期**: 2026-07-06
> **关联 Issue**: BIZ-81
---
## 1. 目的
解决 BIZ-45 巡检发现的 Agent 知识库调用率极低问题(徐聪 0%、诗妮 0%、沈路明 33%)。知识库已建立 SOP 但 Agent 不主动检索,导致重复造轮子、忽略历史经验、决策缺乏依据。
---
## 2. 适用范围
全部 Agent(产研线 + 运营线 + 职能线),共 14 个。
---
## 3. 触发条件(任一触发即必须检索)
| # | 触发场景 | 说明 |
|---|----------|------|
| 1 | 接收新任务 | WorkBoard 认领 / Multica Issue / TODO.md 分派 / session_send 任务 |
| 2 | 任务涉及已知业务领域 | 开发/部署/运营/设计/内容/法务/市场等 |
| 3 | 需要技术选型、流程设计、资源分配决策 | 需参考历史方案 |
| 4 | 遇到不确定如何处理的问题 | 需查找是否有 SOP 或历史经验 |
---
## 4. 检索流程(严格执行)
```
开始任务
↓
Step 1: memory_search(corpus=all, query="<业务类型> SOP/历史经验")
↓
Step 2: 如有匹配 → memory_get / wiki_get 读取详细内容
↓
Step 3: 如无匹配 → wiki_search(query="<任务关键词>")
↓
Step 4: 仍无匹配 → exec curl 思源知识库全文搜索
↓
Step 5: 根据检索结果调整执行方案
↓
开始执行任务
```
### 查询构造规范
- **SOP 查询**: `memory_search(corpus=all, query="<业务类型> SOP")`
- 示例:`memory_search(corpus=all, query="项目拆解 SOP")`
- 示例:`memory_search(corpus=all, query="部署运维 SOP")`
- **历史经验查询**: `memory_search(corpus=all, query="<项目名> 决策/问题/方案")`
- 示例:`memory_search(corpus=all, query="知识库选型 决策")`
- 示例:`memory_search(corpus=all, query="Agent 任务积压 问题")`
- **结构化知识查询**: `wiki_search(query="<主题>")`
- 示例:`wiki_search(query="项目管理规范")`
- **思源知识库全文搜索**:
```bash
curl -s -X POST "http://192.168.1.99:6806/api/search/fullTextSearchBlock" \
-H "Content-Type: application/json" \
-H "Authorization: Token 8ixgqexbnxgibk4u" \
-d '{"query":"<关键词>", "notebook":"20260623155217-njb0mkn"}'
```
---
## 5. 跳过条件(仅以下情况可跳过检索)
| # | 跳过场景 | 原因 |
|---|----------|------|
| 1 | 简单重复性任务 | 如"重启容器""发送消息"——已知操作无需检索 |
| 2 | 紧急故障处理 | 先止损,事后补检索 |
| 3 | 用户明确指示 | "不需要检索历史" |
| 4 | 纯闲聊/问候 | 无业务内容 |
---
## 6. 审计指标
### 月度检查项
| 指标 | 目标 | 检查方式 |
|------|------|----------|
| 检索覆盖率 | ≥80% | 对比有检索记录的任务数 / 总执行任务数 |
| query 相关性 | query 与任务内容匹配 | 抽查 query 与任务 brief 的关键词重合度 |
| 执行方案调整率 | 有检索的任务中 ≥30% 有方案调整 | 检查检索后是否在 notes/评论中记录调整 |
| 零检索执行 | <20% | 无检索记录直接执行的任务占比 |
### 纳入 Agent 行为审计
- ✅ 任务执行前有检索记录(memory_search / wiki_search 调用日志)
- ✅ 检索 query 与任务相关性强
- ✅ 根据检索结果调整了执行方案
- ❌ 无任何检索直接执行(除非符合跳过条件)
- ❌ 检索 query 过于笼统(如单字查询)
### 审计由 COO 月度执行
COO(陆怀瑾)每月 1 日检查上月全部 Agent 的检索执行情况,输出审计报告。
---
## 7. AGENTS.md 注入模板
各 Agent 的 AGENTS.md 中需注入以下章节:
```markdown
## ⚠️ 执行前检索 SOP(强制规则)
> **BIZ-81 要求**:任务执行前必须先检索知识库,避免重复造轮子、忽略历史经验。
### 触发条件
以下情况**必须**执行检索(任一触发):
- 接收到新任务(WorkBoard/Multica/TODO.md 认领)
- 任务涉及已知业务领域
- 需要做出技术选型、流程设计、资源分配决策
- 遇到不确定如何处理的问题
### 检索流程
Step 1: memory_search(corpus=all, query="任务关键词 SOP/历史经验")
Step 2: 如有匹配 → memory_get / wiki_get 读取详细内容
Step 3: 如无匹配 → wiki_search(query="任务关键词")
Step 4: 根据检索结果调整执行方案
→ 开始执行任务
### 跳过条件
- 简单重复性任务(如"重启容器""发送消息")
- 紧急故障处理(先止损,事后补检索)
- 用户明确指示"不需要检索历史"
### 审计指标
以下行为将纳入 Agent 执行质量审计:
- ✅ 任务执行前有检索记录
- ✅ 检索 query 与任务相关性强
- ✅ 根据检索结果调整了执行方案
- ❌ 无任何检索直接执行(除非符合跳过条件)
- ❌ 检索 query 过于笼统
```
---
## 8. 生效与维护
- **生效日期**: 2026-07-06
- **维护人**: 胡蓉(projectmanager)
- **revision 周期**: 每季度 review 一次,根据审计数据调整检索流程
- **下次 review**: 2026-10-06
---
*本 SOP 由 projectmanager 胡蓉起草,经刘炜承批准后生效。*
-194
View File
@@ -1,194 +0,0 @@
# SOP-017:知识缺口上报 SOP
> **版本**: v1.0
> **起草人**: 胡蓉(projectmanager)
> **批准人**: 刘炜承
> **生效日期**: 2026-07-06
> **关联 Issue**: BIZ-82
---
## 1. 目的
解决 BIZ-45 巡检发现的全平台零知识缺口上报问题。各 Agent TOOLS.md 有"知识缺口记录"章节但无明确触发条件和上报流程,导致知识缺口被忽略、遗忘,长期影响任务交付质量。
---
## 2. 适用范围
全部 Agent(产研线 + 运营线 + 职能线),共 14 个。
---
## 3. 知识缺口定义
**知识缺口** = 执行任务时发现知识库(memory / wiki / 思源)中找不到的、且影响任务交付的知识。
### 属于知识缺口的情况
| # | 类型 | 示例 |
|---|------|------|
| 1 | 核心 SOP 缺失 | 没有淘宝上架 SOP,运营不知道怎么操作 |
| 2 | 技术规范缺失 | 没有代码审查规范,开发不知道标准 |
| 3 | 决策依据缺失 | 选型时缺乏对比数据,无法做出有依据的决策 |
| 4 | 模板/指南缺失 | 没有 PRD 模板,每次手写格式不一致 |
| 5 | 外部信息缺失 | 需要某个 API 文档但找不到 |
### 不属于知识缺口的情况
| # | 场景 | 应对方式 |
|----|------|----------|
| 1 | 可通过 web_search 快速获取的公开信息 | 自行检索 |
| 2 | 可通过 memory_search / wiki_search 检索到的现有知识 | 自行读取 |
| 3 | 可通过联系对应 Agent 获得的信息 | 直接联系 |
| 4 | Agent 自身职责范围内的常识 | 不需上报 |
---
## 4. 触发条件
执行 SOP-016(执行前检索)后,以下情况触发上报:
1. **检索无结果**: memory_search + wiki_search + 思源全文搜索均无匹配
2. **检索结果不足以支撑决策**: 有部分信息但缺少关键环节
3. **任务执行中发现缺少必要规范/模板/指南**: 执行中途发现被忽略的知识需求
---
## 5. 上报流程
```
发现知识缺口
↓
Step 1: 记录 — 在 memory/当日日志.md 中记录缺口
↓
Step 2: 通知 — 通过 sessions_send 通知 COO(陆怀瑾)
↓
Step 3: 创建 Issue — 在 Gitea 仓库创建 Issue(标题格式:知识缺口-<领域>-<关键词>)
↓
Step 4: COO 审核 — COO 评估后决定:接受 / 拒绝 / 升级
↓
Step 5: 创建 WorkBoard 卡片 — COO 创建补全卡片,分配给合适的 Agent
↓
Step 6: 补全完成 — 补全 Agent 完成后通知上报 Agent + COO
↓
Step 7: 闭环 — COO 确认补全质量,关闭 Issue 和卡片
```
---
## 6. 上报格式
### 6.1 memory 日志记录格式
在 `memory/YYYY-MM-DD.md` 中追加:
```markdown
## 📚 知识缺口上报
**缺口类型**: [核心SOP缺失 | 技术规范缺失 | 决策依据缺失 | 模板/指南缺失 | 外部信息缺失]
**缺口描述**: [具体缺什么,越详细越好]
**使用场景**: [在做什么任务时发现的,为什么需要这个知识]
**影响范围**: [哪些任务/Agent 受影响]
**紧急程度**: [P0-阻塞当前任务 | P1-影响效率 | P2-改进建议]
**建议补全方式**: [希望怎么处理——新建SOP/更新现有/外部采购等]
**已执行的检索**: [列出已尝试的检索方式和query]
**上报时间**: [YYYY-MM-DD HH:MM]
```
### 6.2 通知 COO 的消息格式
通过 `sessions_send` 发送给 COO(sessionKey: `agent:coo:feishu:coo:direct:ou_9f73b4e54af59f038e2b754793ea0908`):
```
【知识缺口上报】
类型: [缺口类型]
描述: [缺口描述]
场景: [使用场景]
紧急: [P0/P1/P2]
已记录: memory/YYYY-MM-DD.md
请审核并决定是否创建补全卡片。
```
### 6.3 Gitea Issue 格式
- **标题**: `知识缺口-<领域>-<关键词>`
- **标签**: `knowledge-gap`
- **描述**: 包含缺口类型、描述、使用场景、影响范围、紧急程度、建议补全方式
---
## 7. 优先级分级
| 优先级 | 定义 | 响应时限 | 补全时限 |
|--------|------|----------|----------|
| P0 | 阻塞当前任务,无法继续执行 | COO 4h 内响应 | 24h 内补全 |
| P1 | 影响效率,有 workaround 但不理想 | COO 1 工作日内响应 | 3 工作日内补全 |
| P2 | 改进建议,当前可正常运行 | COO 1 周内响应 | 按排期补全 |
---
## 8. COO 审核标准
COO 收到上报后,按以下标准审核:
| 审核项 | 标准 |
|--------|------|
| 是否真的缺口 | 上报人是否已充分检索(检查已执行检索记录) |
| 是否属于知识缺口 | 对照第3节定义判断 |
| 优先级是否合理 | 根据影响范围和紧急程度判断 |
| 补全方式是否可行 | 评估建议的合理性 |
### 审核结果
| 结果 | 处理方式 |
|------|----------|
| **接受** | 创建 WorkBoard 补全卡片,分配给合适 Agent |
| **拒绝** | 说明理由,告知上报人自行解决 |
| **升级** | 转交承哥决策(涉及预算、外部采购等) |
---
## 9. TOOLS.md 注入模板
各 Agent 的 TOOLS.md 中需注入以下章节:
```markdown
## 📚 知识缺口上报
> 完整 SOP 见 specs/SOP-017_知识缺口上报SOP.md
### 触发条件
执行 SOP-016 检索后,以下情况需上报知识缺口:
1. 核心业务数据/SOP/决策依据缺失
2. 缺失影响任务交付质量或决策准确性
3. 无法通过 memory_search / wiki_search 检索到
### 上报流程
1. **记录**: 在 memory/当日日志.md 中记录缺口(标准格式见 SOP-017)
2. **通知**: 飞书通知 COO(sessionKey: agent:coo:feishu:coo:direct:ou_9f73b4e54af59f038e2b754793ea0908)
3. **创建 Issue**: Gitea 仓库创建 Issue(标题格式:知识缺口-<领域>-<关键词>)
4. **跟进**: COO 评估后创建 WorkBoard 补全卡片
5. **闭环**: 补全后 COO 通知可使用
### 标准格式
[见 SOP-017 第6节]
### 不属于知识缺口的情况
- 可通过 web_search 快速获取的公开信息 → 自行检索
- 可通过 memory_search / wiki_search 检索到的现有知识 → 自行读取
- 可通过联系对应 Agent 获得的信息 → 直接联系
```
---
## 10. 生效与维护
- **生效日期**: 2026-07-06
- **维护人**: 胡蓉(projectmanager)
- **revision 周期**: 每季度 review 一次,根据上报数据调整流程
- **下次 review**: 2026-10-06
---
*本 SOP 由 projectmanager 胡蓉起草,经刘炜承批准后生效。*
@@ -1,214 +0,0 @@
# 开发文档:双色球 Web UI 系统
**版本**: v1.0
**开发人员**: 徐聪(costcodev)
**日期**: 2026-07-03
**Issue**: BIZ-75
---
## 1. 项目概述
双色球自动化系统 Web UI,提供号码生成、历史数据查看、生成记录管理和统计分析功能。支持 PC 端和移动端响应式访问,监听 0.0.0.0:8085,局域网可访问。
## 2. 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 后端 | Python 3 + Flask | REST API 服务 |
| 前端 | 原生 HTML/CSS/JS | 单文件,响应式布局 |
| 数据分析 | Pandas + NumPy | 号码统计分析 |
| 数据存储 | Excel + JSON | 历史数据 + 生成记录 |
| 部署 | systemd / nohup | Linux 服务部署 |
## 3. 目录结构
```
lottoData/
├── app.py # Flask 主服务(统一入口)
├── index.html # 前端 UI(响应式,4 Tab 页面)
├── lottery.py # 号码生成核心逻辑
├── fetch_data.py # 历史数据抓取脚本
├── web_console.html # 数据抓取控制台前端
├── requirements.txt # Python 依赖
├── 双色球历史数据.xlsx # 历史数据文件
├── lottery/ # 号码生成结果输出目录
├── .generation_records.json # 生成记录索引(JSON)
├── .fetch_status.json # 抓取状态文件
├── deploy/ # 部署相关文件
│ ├── DEPLOY.md # 部署说明
│ ├── lotto-app.service # systemd 服务文件
│ ├── fetch_daily.sh # 定时抓取脚本
│ └── backup.sh # 备份脚本
└── docs/ # 文档目录
├── PRD-双色球 WebUI-v1.0.md
└── 开发文档-双色球WebUI-v1.0.md ← 本文件
```
## 4. API 接口
### 4.1 接口清单
| 接口 | 方法 | 描述 | 认证 |
|------|------|------|------|
| `/api/generate` | POST | 生成号码 | 可选 |
| `/api/history` | GET | 获取历史开奖数据(分页+搜索) | 可选 |
| `/api/records` | GET | 获取生成记录列表(分页) | 可选 |
| `/api/records/:id` | DELETE | 删除生成记录 | 可选 |
| `/api/statistics` | GET | 获取统计分析数据 | 可选 |
| `/api/download/:filepath` | GET | 下载文件 | 可选 |
| `/api/status` | GET | 系统状态 | 无 |
| `/api/config` | GET | 前端配置 | 无 |
| `/api/fetch/status` | GET | 抓取执行状态 | 无 |
| `/api/fetch/execute` | POST | 触发数据抓取 | 无 |
### 4.2 关键接口参数
#### POST /api/generate
```json
// 请求
{
"num_tickets": 10,
"strategy": "advanced" // 或 "basic"
}
// 响应
{
"success": true,
"data": {
"tickets": [...],
"total": 10,
"filename": "lottery/xxx.xlsx",
"download_url": "/api/download/lottery/xxx.xlsx",
"record": {...},
"statistics": {...}
}
}
```
#### GET /api/history
参数: `page` (页码), `page_size` (每页条数), `search` (搜索关键词)
#### GET /api/records
参数: `page` (页码), `page_size` (每页条数)
## 5. 前端页面
### 5.1 页面结构
- **Header**: 标题 + 副标题
- **导航 Tab**: 号码生成 | 历史数据 | 生成记录 | 统计分析
- **移动端**: 底部固定导航栏
### 5.2 功能页面
#### 号码生成页(首页)
- 统计概览(历史期数、常见奇偶比、和值范围等)
- 策略选择(高级策略/基础策略)
- 注数输入(1-1000)
- 生成结果展示(红球+蓝球+统计指标)
- Excel 下载按钮
#### 历史数据页
- 搜索框(500ms 防抖)
- 数据表格(期号、日期、红球、蓝球、统计字段)
- 分页控件
#### 生成记录页
- 记录列表(策略、注数、时间、文件大小)
- 下载/删除操作
- 分页控件
#### 统计分析页
- 历史开奖期数
- 红球热号 TOP15 / 冷号 TOP15
- 蓝球热号 TOP8
- 奇偶比/大小比/和值/跨度统计
## 6. 关键修复说明
### 6.1 数据格式兼容修复(核心 Bug 修复)
**问题**: `lottery.py` 期望 Excel 含"号码"列(拼接格式如 `08121821243001`),但 `fetch_data.py` 抓取的 Excel 使用分列格式("红球 1"~"红球 6"+"蓝球"),导致号码生成器无法加载历史数据。
**根因**: Excel 文件包含两行 header:
- Row 0: 新格式列名(期号、开奖日期、红球 1~6、蓝球、特别号)
- Row 1: 旧格式列名(开奖时间、期数、号码、开机号、...)
- Row 2+: 实际数据
**修复方案**:
1. `lottery.py` 的 `load_history_data()`: 添加多格式检测逻辑,识别格式A(双行 header)并自动跳过,使用旧列名作为标准列名
2. `lottery.py` 的 `parse_numbers()`: 新增对拼接字符串格式(14位无分隔符)的直接解析,避免 `re.findall` 将整个字符串视为一个数字
3. `app.py` 的 `load_history_dataframe()`: 同步修复多格式兼容逻辑
### 6.2 线程安全
- 生成记录的读-改-写操作使用 `threading.Lock` 保护
- 文件写入使用临时文件+原子替换(`os.replace`),防止崩溃导致数据损坏
## 7. 部署方式
### 7.1 直接运行
```bash
cd /home/vincent/Studio/lottoData
source .venv/bin/activate
python3 app.py
# 访问 http://localhost:8085
```
### 7.2 systemd 服务
```bash
# 服务文件: deploy/lotto-app.service
sudo cp deploy/lotto-app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable lotto-app
sudo systemctl start lotto-app
```
### 7.3 定时数据抓取
```bash
# 添加 cron 任务
crontab -e
# 每天 02:30 自动抓取最新数据
30 2 * * * /home/vincent/Studio/lottoData/deploy/fetch_daily.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1
```
## 8. 测试验证
### 8.1 API 测试结果
| 接口 | 状态 | 说明 |
|------|------|------|
| GET /api/status | ✅ 通过 | 返回服务状态 |
| GET /api/statistics | ✅ 通过 | 120条历史数据统计正确 |
| GET /api/history | ✅ 通过 | 分页+红蓝球解析正确 |
| POST /api/generate | ✅ 通过 | 5注号码生成成功,含统计 |
| GET /api/records | ✅ 通过 | 生成记录列表正确 |
| GET / (前端页面) | ✅ 通过 | HTML 页面正常加载 |
### 8.2 数据格式验证
- 历史数据: 120 条记录全部成功解析 ✅
- 红球解析: 6个红球正确提取 ✅
- 蓝球解析: 1个蓝球正确提取 ✅
- 号码范围校验: 1-33(红) + 1-16(蓝) ✅
## 9. 已知限制
- 前端为单 HTML 文件,未使用构建工具
- 无用户登录系统(Token 认证为可选项,默认关闭)
- 历史数据来源为 55128.cn,如网站改版需更新 `fetch_data.py`
- 不支持 HTTPS(内网环境)
## 10. 后续优化建议
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 数据可视化图表 | P2 | 走势图、分布图 |
| 用户登录系统 | P2 | 多用户权限管理 |
| 定时自动生成 | P2 | 定时生成+推送 |
| 微信推送 | P3 | 生成结果推送至微信 |
| 多彩种支持 | P3 | 大乐透、福彩 3D 等 |
---
**开发完成日期**: 2026-07-03
**代码仓库**: http://192.168.1.99:12299/vincent/Lottery.git
**开发人员**: 徐聪(costcodev)