第 7 章 · 04 触达链路细节:代码转换、名称查询与容错 本节摘要:本节讲飞书触达链路的「最后三件小事」——把前面三节的内容串成一条「从选股结果到飞书群」的完整链路。三个细节看似琐碎,每件都关系用户体验:① 雪球代码转换——A 股 6 开头是上海、4/8 开头是北京、其余是深圳,错一个前缀链接就点不进去;② 股票名称查询——选股结果是「6 位代码」对人不友好,用 baostock 二次查询出「贵州茅台」这样的中文名,飞书消息才看着舒服;③ HTTP 失败容错——飞书服务偶发不可达,仅记 ERROR 日志,不抛异常、不影响下个策略。本节用「链路图」把这三件事拼起来,再用一段可运行的代码完整复现 Sequoia-X 的飞书发送逻辑(Mock 飞书响应)。 内容来源:原项目飞书通知模块精读。
本节摘要:本节讲飞书触达链路的「最后三件小事」——把前面三节的内容串成一条「从选股结果到飞书群」的完整链路。三个细节看似琐碎,每件都关系用户体验:① 雪球代码转换——A 股 6 开头是上海、4/8 开头是北京、其余是深圳,错一个前缀链接就点不进去;② 股票名称查询——选股结果是「6 位代码」对人不友好,用 baostock 二次查询出「贵州茅台」这样的中文名,飞书消息才看着舒服;③ HTTP 失败容错——飞书服务偶发不可达,仅记 ERROR 日志,不抛异常、不影响下个策略。本节用「链路图」把这三件事拼起来,再用一段可运行的代码完整复现 Sequoia-X 的飞书发送逻辑(Mock 飞书响应)。
内容来源:原项目飞书通知模块精读。
💡 核心心法:好的「用户感知层」的代码,不只做「能跑」的事,还做「让用户感觉舒服」的细节——名字而不是代码、可点击而不是裸 URL、失败不打扰。
阅读完本节,你应当能够:
把 Sequoia-X 触达层的完整流程画成「从选股结果到飞书群」的链路:
5 个步骤 + 1 个错误兜底——整条链路就这么简单。
雪球是中国主流的股票社区,飞书卡片里的链接直接用雪球代码拼接:
https://xueqiu.com/S/<雪球代码>
A 股 6 位代码到雪球代码的转换规则:
| A 股代码开头 | 交易所 | 雪球代码前缀 | 示例 |
|---|---|---|---|
| 6 | 上海证券交易所 | SH | 600519 → SH600519 |
| 9 | 上海 B 股 | SH | 900901 → SH900901 |
| 0/3 | 深圳证券交易所 | SZ | 000001 → SZ000001 |
| 4/8 | 北京证券交易所 | BJ | 830799 → BJ830799 |
Sequoia-X 的实现:
@staticmethod def _to_xueqiu_code(code: str) -> str: """6/9 开头 → SH;4/8 开头 → BJ;其余 → SZ。""" if code.startswith("6"): return f"SH{code}" elif code.startswith(("4", "8")): return f"BJ{code}" return f"SZ{code}"
注意:Sequoia-X 把 9 开头也算到 SH——上海 B 股(沪 B)虽然代码以 9 开头但仍属上交所。
为什么需要「代码 → 名称」的查询?
| 没查名称 | 查了名称 |
|---|---|
飞书消息:[SH600519](https://xueqiu.com/S/SH600519) |
飞书消息:[贵州茅台](https://xueqiu.com/S/SH600519) |
| 用户看到「SH600519」——需要查代码表才知道是什么 | 用户看到「贵州茅台」——一眼就懂 |
「代码 vs 名称」是用户体验的核心分水岭。
Sequoia-X 用 baostock 查名称:
@staticmethod def _get_stock_names(symbols: list[str]) -> dict[str, str]: """通过 baostock 批量查询股票名称。""" import baostock as bs bs.login() mapping = {} for code in symbols: prefix = "sh" if code.startswith(("6", "9")) else "sz" rs = bs.query_stock_basic(code=f"{prefix}.{code}") while rs.next(): row = rs.get_row_data() mapping[code] = row[1] # 第 2 个字段是股票名称 bs.logout() return mapping
注意细节:
bs.login() / bs.logout() 包住——避免长连接;dict[str, str](代码 → 名称)——O(1) 查询。一个潜在问题:如果某只股票当天停牌或新上市,baostock 可能查不到——mapping.get(code, xq_code) 兜底用「雪球代码」作为名称。
这是一个**「用户能容忍」**的兜底——总比「没消息」好。
把代码转链接、嵌入到卡片 JSON 里。前面第 02 节已经讲过卡片结构,这里只补充链接生成的细节:
links: list[str] = [] for code in symbols: xq_code = self._to_xueqiu_code(code) name = names.get(code, xq_code) # 优先用中文名,缺则用雪球代码 links.append(f"[{name}](https://xueqiu.com/S/{xq_code})") symbol_text = " ".join(links)
关键:
name = names.get(code, xq_code):缺名称时用雪球代码——保证总有名字。" ".join(links):用空格分隔多个链接——飞书会渲染为可点击列表。Sequoia-X 用 requests.post 发送,重点是错误处理:
def send(self, symbols, strategy_name, webhook_key="default") -> None: url = self.settings.get_webhook_url(webhook_key) payload = self._build_card(symbols, strategy_name) try: resp = requests.post( url, data=json.dumps(payload), headers={"Content-Type": "application/json"}, timeout=10, ) resp_json = resp.json() # 飞书真正的成功标志是内部的 code == 0(**不是 HTTP 200**) if resp.status_code != 200 or resp_json.get("code") != 0: logger.error(f"飞书推送失败 [{webhook_key}] ...") else: logger.info(f"飞书推送成功 [{webhook_key}],共 {len(symbols)} 只") except requests.RequestException as exc: # 网络异常:仅记日志,**不抛异常**——不影响主流程 logger.error(f"飞书推送请求异常 [{webhook_key}]:{exc}")
三个关键点:
if resp.status_code != 200 or resp_json.get("code") != 0: # 失败
code == 0 才代表「飞书真的把消息发出去了」。两者必须同时为真才算成功。这是飞书 webhook 的双层成功判定——很多新手栽在这里。
timeout=10不设 timeout 可能永远卡住——网络偶发半开状态会让程序挂起。
10 秒是**「足够慢但不永久」**的经验值。
except requests.RequestException as exc: logger.error(...) # 只记日志 # 不 raise,不 return False——调用方继续执行
为什么不抛异常?
「失败隔离」是 Sequoia-X 整套系统的核心哲学——任何一层挂都不影响整体。
下面这段代码完整演示「从选股结果到飞书发送」的完整链路。它Mock 飞书的 HTTP 响应——不实际发送,只演示:
# feishu_send_demo.py # 可运行:完整飞书发送链路(Mock) import json import logging from unittest.mock import patch, MagicMock def to_xueqiu_code(code: str) -> str: if code.startswith("6"): return f"SH{code}" elif code.startswith(("4", "8")): return f"BJ{code}" return f"SZ{code}" def build_card(symbols: list[str], names: dict[str, str], strategy_name: str) -> dict: today = "2024-06-15" links = [f"[{names.get(s, to_xueqiu_code(s))}](https://xueqiu.com/S/{to_xueqiu_code(s)})" for s in symbols] return { "msg_type": "interactive", "card": { "header": { "title": {"tag": "plain_text", "content": f"📈 Sequoia-X 选股播报 | {strategy_name}"}, "template": "blue", }, "elements": [ {"tag": "div", "text": {"tag": "lark_md", "content": f"**日期:** {today}\n**策略:** {strategy_name}\n**选股数量:** {len(symbols)}"}}, {"tag": "hr"}, {"tag": "div", "text": {"tag": "lark_md", "content": "**选股列表:**\n" + " ".join(links)}}, ], }, } def mock_send(payload: dict, url: str, scenario: str = "success") -> dict: """Mock 飞书响应,模拟不同场景。""" if scenario == "success": return {"status_code": 200, "code": 0, "msg": "success"} elif scenario == "feishu_reject": return {"status_code": 200, "code": 99991663, "msg": "敏感词拦截"} elif scenario == "http_error": return {"status_code": 500, "code": -1, "msg": "server error"} return {} def send_with_logging(url: str, payload: dict, scenario: str) -> str: """完整发送流程:构造 → POST → 解析响应 → 记日志 → 不抛异常。""" logging.basicConfig(level=logging.INFO, format="%(name)s - %(message)s") log = logging.getLogger("feishu") resp = mock_send(payload, url, scenario) if resp.get("status_code") == 200 and resp.get("code") == 0: log.info(f"推送成功:{len(payload['card']['elements'])} 个块") return "success" else: log.error(f"推送失败 [{scenario}] status={resp.get('status_code')} code={resp.get('code')}") return "failed" # 不抛异常,调用方继续 if __name__ == "__main__": symbols = ["600519", "000001"] names = {"600519": "贵州茅台", "000001": "平安银行"} card = build_card(symbols, names, "海龟突破") print("=" * 60) print("演示 1:成功推送") print("=" * 60) print(send_with_logging("https://feishu/hook/turtle", card, "success")) print() print("=" * 60) print("演示 2:飞书敏感词拦截(HTTP 200 但 code != 0)") print("=" * 60) print(send_with_logging("https://feishu/hook/turtle", card, "feishu_reject")) print() print("=" * 60) print("演示 3:HTTP 500 错误") print("=" * 60) print(send_with_logging("https://feishu/hook/turtle", card, "http_error"))
预期输出:
============================================================ 演示 1:成功推送 ============================================================ feishu - 推送成功:3 个块 success ============================================================ 演示 2:飞书敏感词拦截(HTTP 200 但 code != 0) ============================================================ feishu - 推送失败 [feishu_reject] status=200 code=99991663 failed ============================================================ 演示 3:HTTP 500 错误 ============================================================ feishu - 推送失败 [http_error] status=500 code=-1 failed
注意:所有失败都「仅记日志、不抛异常」——主流程继续。
飞书 webhook 的双层成功标志是新手最容易踩的坑:
| 看到的现象 | 真相 |
|---|---|
| 「我 POST 成功了,HTTP 200」 | HTTP 200 ≠ 飞书推送成功 |
| 「为什么群收不到」 | 飞书内部 code 不是 0(被安全机制拦截) |
| 「所有代码看起来都对」 | 响应体的 code 才是真相 |
飞书常见错误码:
| 错误码 | 含义 | 修复 |
|---|---|---|
| 0 | 成功 | - |
| 99991663 | 敏感词拦截 | 在飞书机器人配置中加白名单词 |
| 99991664 | IP 不在白名单 | 飞书后台加 IP 白名单 |
| 99991668 | 签名错误 | 检查 token 完整性 |
Sequoia-X 的触达链路设计体现了三条工程哲学:
| 哲学 | 体现 |
|---|---|
| 用户感知层也要精细 | 名称查询、雪球链接、卡片设计 |
| 失败要隔离 | 仅记日志、不抛异常、不影响其他策略 |
| 够用就好 | 不用企业微信 SDK、不用异步 HTTP 库——requests 就够 |
「用户能感知」的代码(推送、卡片、日志)值得花时间打磨——这是用户对你的第一印象。
触达链路细节 ✅ ← 本节 │ ▼ 下一章(第 8 章 主流程编排与运行模式):双模式分发 + 策略注册与执行
code == 0——两者缺一不可。except 后只记日志,不抛异常——失败隔离的核心。timeout=10:避免网络半开状态永久挂起。配套教学脚本:
images/feishu_send_demo.py——Mock 飞书响应,完整链路演示。
至此,第 7 章事件驱动策略与消息推送四节全部完成。下一章,我们把所有零件串起来——讲主流程编排与双运行模式。