查看:25 回复:4

www.ally.ren 全站企业级验收、故障修复与工程优化实录

2小时前发布 上海

本文记录一次针对 www.ally.ren 主站及 www.ally.ren/weixinbianji 微信 Markdown 编辑器的生产环境全链路验收与修复过程。验收范围覆盖 Nginx、TLS、安全响应头、Python/Node 服务、数据库、Redis、微信公众号回调、多图文章、图片上传、评论、点赞、分享、导出、定时发布、WebSocket 协作以及测试数据清理。

验收日期:2026 年 7 月 15 日

一、项目背景

本次验收对象包括两个相互关联的生产应用:

https://www.ally.ren/

博客主站;支持微信公众号发文;支持文章、作者、标签、搜索、评论、点赞、分享、导出和定时发布;主应用由 Flask、Gunicorn、PostgreSQL 和 Redis 提供服务。

https://www.ally.ren/weixinbianji/

微信 Markdown 编辑器;支持 Markdown 编辑、预览、OCR、语音输入、图片上传、版本历史、评论、导入、导出和多人协作;前端为静态单页应用;图片上传 API 由独立 Gunicorn 服务提供;多人协作由 Node.js WebSocket 服务提供。

此次工作的目标并不是简单确认“页面能打开”,而是按企业工程级应用标准,从以下维度进行严格验收:

线上可访问性;Nginx 配置正确性;HTTPS 与证书状态;安全响应头;敏感文件防护;服务运行状态;代码质量与依赖状态;数据库和缓存链路;用户操作完整性;微信公众号回调;多图文章全生命周期;异常输入与权限边界;备份、恢复和维护能力;测试数据清理与回归验证。二、系统架构概览2.1 请求入口

外部请求统一进入 Nginx:

本次确认的核心服务包括:

nginx;blog-ally.service;weixin-upload.service;weixin-ws.service;PostgreSQL;Redis。

主站、上传 API 和 WebSocket 服务均仅监听本机端口,由 Nginx 对外代理,避免应用端口直接暴露。

2.3 数据与文件目录

系统使用了不同目录隔离不同用途的文件:

应用代码目录;静态前端目录;临时上传目录;草稿媒体目录;私密文章媒体目录;公开文章媒体目录;错误页面目录;Nginx 缓存目录。

这种分层有利于执行 systemd 只读保护、最小写权限和私密媒体隔离。

三、初始验收结果3.1 线上访问

初始检查确认:

主站首页返回 HTTP 200;微信编辑器返回 HTTP 200;HTTP 正确跳转到 HTTPS;裸域正确跳转到 www 主域;/weixinbianji 正确跳转到 /weixinbianji/;页面引用的 89 个同域资源均能正常加载;主站、上传 API 和 WebSocket 监听状态正常。3.2 安全基线

系统已经具备多项安全能力:

HTTPS 强制跳转;HSTS;X-Content-Type-Options: nosniff;X-Frame-Options;Referrer-Policy;Permissions-Policy;Content Security Policy;API 限流;敏感文件拦截;CSP 报告接口;systemd 非 root 用户运行;NoNewPrivileges;ProtectSystem=strict;只读代码目录;可写目录白名单;定时备份;上传文件清理;Restic 维护;恢复演练定时器。3.3 依赖与证书TLS 证书有效;Nginx 配置语法检查通过;Node.js 生产依赖审计未发现已知漏洞;Python pip check 未发现依赖关系破损;项目安全测试套件最终为 11 项全部通过。

不过,进一步执行生产操作后,仍发现了多个“页面正常但业务链路实际不可用”的隐藏问题。

四、问题一:微信编辑器敏感路径回退到 SPA 首页4.1 故障现象

访问:

https://www.ally.ren/weixinbianji/.env

最初返回 HTTP 200。

响应内容并不是 .env 文件,而是编辑器的 index.html。因此没有发生真实密钥泄露,但从安全监控和企业规范看,敏感路径不应返回 200。

4.2 根因

Nginx 使用了:

^~ 前缀 location 命中后,服务器级敏感文件正则不会继续参与匹配。不存在的 .env、.bak、.conf 等路径最终被 SPA fallback 到首页。

4.3 修复方案

在 /weixinbianji/ location 内增加子应用专用敏感路径拦截,覆盖:

隐藏文件;.env;.py、.pyc;.log;.git;.sql;.bak、.old、.backup、.orig;.tmp;.map;.zip、.tar、.7z、.rar;.sh;.yaml、.yml;.conf。

同时保留 OCR 所需的 Tesseract traineddata.gz 文件访问能力,避免因为过度拦截破坏 OCR。

4.4 验证结果

修复后:

OCR 训练数据仍返回 200。

五、问题二:生产目录遗留大量 Source Map5.1 故障现象

微信编辑器生产目录内存在约 250 个 .map 文件,主要来自第三方依赖。

虽然入口脚本的 map 请求已经被部分拦截,但生产目录中仍保留完整 source map,不符合最小发布原则。

5.2 风险

Source Map 可能暴露:

源码结构;模块名称;构建路径;调试信息;未压缩逻辑;第三方依赖内部实现。5.3 修复方案

采用“双保险”策略:

删除生产目录中的所有 .map 文件;保留 Nginx 的 .map 拒绝规则。

删除前生成完整文件清单并纳入备份,以便必要时恢复。

5.4 验证结果生产目录 .map 数量变为 0;HTTP 请求 .map 返回 404;业务 JS、CSS、OCR 训练数据均未受影响;前端静态资源完整性检查通过。

六、问题三:API 安全头重复且冲突

6.1 故障现象

/weixinbianji/api/* 同时由 Flask 和 Nginx添加安全头,导致响应中出现重复甚至冲突值,例如:

同时还出现重复 CSP、重复 nosniff 和重复 Permissions-Policy。

6.2 风险

不同浏览器或客户端对重复头的处理方式可能不同,容易造成:

安全策略不一致;调试困难;自动化扫描误报;上游与网关配置漂移。6.3 修复方案

在 Nginx API location 中隐藏上游 Flask 的重复安全头:

然后由 Nginx 统一输出编辑器安全策略,并为 API 添加:

Cache-Control: no-store

6.4 验证结果

关键安全头均只出现一次,无 DENY 与 SAMEORIGIN 冲突。

七、问题四:上传 API 每次扫描整个上传目录7.1 原实现

上传前通过遍历上传目录计算总占用:

for entry in os.scandir(UPLOAD_FOLDER):     total += entry.stat().st_size

虽然注释声称使用 os.statvfs(),实际仍是 O(n) 扫描。

7.2 风险

随着上传文件增加,每次上传请求都扫描全部文件,可能造成:

上传延迟增长;磁盘 I/O 放大;并发时性能下降;注释与实现不一致。7.3 修复方案

改为通过 os.statvfs() 获取文件系统可用空间:

st = os.statvfs(UPLOAD_FOLDER) available = st.f_bavail * st.f_frsize

上传前要求:

可用空间 >= 当前文件大小 + 安全余量

这样检查复杂度变为 O(1)。

7.4 配置失效保护

上传服务原来在 FLASK_SECRET_KEY 缺失时为每个 worker 生成临时密钥。多 worker 环境下,upload-token 和上传请求如果落到不同 worker,可能出现间歇性 Session/CSRF 校验失败。

现已改为:

FLASK_SECRET_KEY 未配置或长度不足 32 字符时,服务拒绝启动

避免以不稳定状态继续运行。

八、问题五:上传清理脚本存在 O(n²) 性能问题

8.1 原实现

清理脚本每删除一个文件,就重新扫描整个目录统计容量:

扫描全部文件 
删除一个文件 
再次扫描全部文件 
再删除一个文件 
……

大量文件时会形成近似 O(n²) 开销。

8.2 修复方案

改为:

单次扫描生成文件清单;按修改时间排序;一次计算总容量;删除文件时直接从内存中的 used 值减去文件大小;达到 quota 后停止。

同时保留原有:

4 小时保留时间;500MB 容量上限;systemd timer 周期执行。

九、问题六:微信编辑器实际图片上传返回 500

9.1 故障现象

虽然 /weixinbianji/api/upload-token 正常,但真正上传图片时返回:

{
"code":"PROCESS_ERROR",
"error":"上传处理失败"
}

这说明过去的健康检查只检查 token,没有覆盖真实文件写入。

9.2 根因

上传目录表面权限为:

www:www-data 0750

而上传 API 以 www-data 用户运行。组权限只有读取和执行,没有写权限。

进一步检查发现目录还有 ACL:

user:www-data:r-x

即使修改普通 mode,ACL 仍然覆盖为只读。

9.3 修复方案

目录权限统一调整为:

owner: www 
group: www-data 
mode: 2770

2 表示 setgid,新文件和子目录会继承 www-data 组。

当前 ACL 和默认 ACL 均调整为:

user::rwx 
group::rwx 
mask::rwx 
other::---

定时清理脚本也同步维护该权限,防止下一次任务执行后恢复成错误状态。

9.4 验证结果

真实上传 1×1 PNG:

HTTP 200 
success: true 
type: image/png

上传后的图片 URL 返回:

HTTP 200 
Content-Type: image/png

十、问题七:微信公众号多图模式实际无法发布

10.1 表面功能

代码和帮助文案已经声明支持:

发送“图”“发图”或“配图”进入多图模式;最多 9 张;总大小最多 60MB;发送文字作为配文发布;发送“完成”无配文发布;发送“取消”清理草稿。

但是生产端到端测试发现,多张图片能够保存为草稿,发布时却返回:

发布失败,图片仍安全保留并将在超时后清理

10.2 根因

create_post() 的事务顺序错误:

先更新 media_assets.post_id;后插入 posts 文章行。

由于 media_assets.post_id 有外键约束,数据库拒绝指向一个尚不存在的文章 ID:

ForeignKeyViolation: 
media_assets.post_id references a post that does not yet exist

10.3 修复方案

调整为同一事务内:

先插入文章基础行;再迁移草稿媒体;更新媒体关联;生成图片 Markdown;更新文章正文、摘要和 has_image;任何一步失败则数据库事务和文件移动一起回滚。

10.4 验证结果

真实创建两张图的测试文章:

两张图片均成功迁移;media_assets 中顺序为 1、2;文章 has_image=true;文章正文包含两张图片;文章详情页包含两个图片 URL;两张图片均返回 HTTP 200。

多图模式由“代码看起来支持”变为“生产真实可用”。

十一、问题八:多图文章可见性切换与路径膨胀11.1 原问题

图片在公开和私密目录之间迁移时,文件名会重复增加序号前缀:

1-file.png 
1-1-file.png 
1-1-1-file.png

多次切换后路径会持续变长。

同时,原迁移逻辑只处理 public/private,没有完整覆盖 pending。

11.2 修复方案

使用稳定路径格式:

文章ID/序号-媒体ID.扩展名

可见性存储规则统一为:

public          → 公开媒体目录 
private/pending → 私有媒体目录

迁移时根据旧存储和目标存储判断是否需要移动,而不是只判断两个 visibility 字符串。

11.3 验证结果

执行:

public → private → pending → public

结果:

文件存储类型正确;正文 URL 同步更新;路径保持稳定;私密文章匿名访问返回 404;再次公开后图片恢复可访问。

十二、问题九:修改带图文章会丢失图片

12.1 原实现

“改 文章ID 新内容”会直接使用新正文覆盖原文章内容。

对于多图文章,这意味着:

图片 Markdown 可能丢失;has_image 可能与正文不一致;如果内容审核导致 public 转 private,媒体文件可能仍留在公开目录。

12.2 修复方案

将带图文章修改改为单事务操作:

锁定文章;执行内容审核;根据新可见性迁移媒体;重新读取媒体 URL;使用新文字正文加现有图片 Markdown 重建内容;更新摘要、标签、has_image;更新作者公开文章计数;失败时回滚数据库和文件迁移。

12.3 验证结果修改后新文字正常显示;两张原图片仍在;图片顺序不变;可见性变化时媒体同步迁移;文章计数正确。

十三、问题十:删除带图文章遗留物理文件

13.1 原实现

删除文章时会删除:

评论;点赞;分享;浏览计数;文章数据库行。

由于外键级联,media_assets 数据库记录也会消失,但物理图片文件没有被删除,形成孤儿文件。

13.2 修复方案

删除前读取文章托管媒体文件清单;数据库事务成功后再删除物理文件和空目录。

执行顺序:

读取媒体路径     
↓ 
删除数据库文章及关联数据     
↓ 
数据库提交成功     
↓ 
删除物理媒体文件     
↓ 
清理空目录     
↓ 
刷新缓存

物理清理失败只记录日志,不再让已经成功的业务删除返回 HTTP 500。

13.3 验证结果

通过微信公众号“删 文章ID”真实验证:

微信返回“已删除”;文章数据库记录不存在;媒体数据库记录不存在;公开图片文件不存在;文章目录不存在;文章 URL 返回 404。

十四、问题十一:微信网址卡片消息没有响应

14.1 故障现象

普通文本网址能够被正文 URL 检测逻辑处理,但通过微信“分享网页卡片”发送时可能没有正确反馈。

14.2 根因

微信分享卡片使用:

后端消息处理器虽然支持 msg_type == “link”,但 XML 解析层没有解析 link,回调分发也只允许 text/image。

14.3 修复方案

在微信和企业微信 XML 解析层增加:

Title;Description;Url;MsgId。

同时把 link 加入回调允许类型。

14.4 验证结果

模拟真实签名的微信 link 消息:

回调返回 HTTP 200;返回标准 XML;网页抓取成功;生成链接文章;回复包含文章 URL。

测试文章随后转私密并清理。

十五、问题十二:微信公众号 XML 响应类型不标准

15.1 原响应

后端虽然返回 XML 字符串,但 Flask 默认响应类型为:

text/html; charset=utf-8

部分微信环境对被动回复类型较为敏感,可能导致服务器明明返回内容,但用户端不展示。

15.2 修复方案

统一通过 _reply_xml() 包装返回值:

Content-Type: application/xml; charset=utf-815.3 验证结果

发送“帮助”模拟回调:

HTTP 200;application/xml; charset=utf-8;返回完整帮助菜单;包含多图操作说明。

十六、问题十三:微信消息失败后重试会被去重忽略

16.1 原逻辑

微信消息到达后,系统先把 MsgId 插入 processed_messages,然后执行具体业务。

如果业务执行过程中发生异常:

MsgId 已被标记为处理;微信平台会使用同一 MsgId 重试;重试请求被判断为重复消息;真实业务永远不会再次执行。

16.2 修复方案

回调业务处理增加异常保护:

处理成功 → 保留去重标记 处理失败 → 删除去重标记并记录异常

这样微信平台重试时仍有机会成功。

16.3 企业微信去重补充

企业微信 XML 解析原来没有提取 MsgId,因此重试可能重复发文。现已为 text/image/link 增加 MsgId 解析。

16.4 验证结果

同一个 MsgId 连续发送两次“帮助”:

第一次返回完整帮助;第二次被正确去重,不重复执行。

十七、问题十四:未配置企业微信时允许明文 fallback

17.1 配置状态

服务器当前没有配置企业微信所需的:

WECOM_TOKEN;WECOM_ENCODING_AES_KEY;WECOM_CORP_ID。

17.2 原风险

解密器在缺少 AES 密钥时直接返回原始 POST XML,等于允许未验证的明文消息进入业务处理。

如果 /ww 暴露在公网,攻击者可能伪造企业微信消息。

17.3 修复方案

改为 fail-closed:

缺少任一企业微信安全配置 → 不处理回调

GET 验证和 POST 回调均明确失败,不再使用明文兼容模式。

17.4 验证结果

在未配置企业微信时:

POST /ww → 403

微信公众号 /wx 不受影响。

如果以后需要启用企业微信,必须先完整配置 token、AES key 和 CorpID。

十八、文章详情缓存与私密切换

18.1 故障现象

真实发布一篇文章后将其转为 private,立即访问文章 URL 时曾短暂返回旧公开页面。

18.2 根因

Nginx 对动态页面启用了 60 秒公共缓存。文章可见性已经在数据库中变更,但旧公开 HTML 仍可能在页面缓存中存活。

18.3 修复方案

为 /post/ 增加独立代理规则:

不进入 Nginx 公共页面缓存 Cache-Control: private, no-store18.4 验证结果

文章转为 private 后:

数据库 visibility 为 private;外部访问立即返回 404;页面不再短暂公开;不存在静态文章文件绕过动态权限判断。

十九、真实操作级验收范围

本次测试并非只执行 curl 状态检查,而是使用受控测试数据真实操作生产链路。

19.1 文章与媒体

已验证:

纯文字发文;单图上传;两图文章发布;图片顺序;图片访问;文章修改;带图文章修改;私密/公开/待审核切换;文章删除;数据库记录清理;物理图片清理。19.2 评论与互动

已验证:

浏览计数;根评论;嵌套回复;评论树;用户名 HTML/XSS 清洗;评论正文 HTML/XSS 清洗;点赞;取消点赞;分享;重复分享去重;文章主人隐藏评论;文章主人删除回复。19.3 登录与权限

已验证完整一次性微信登录流程:

生成一次性 token;GET 登录确认页;同一会话 CSRF;POST 确认;token 原子消费;创建登录会话;识别管理员角色;logout;logout 后会话清空。

同时验证:

无 Origin 写请求返回 403;无 CSRF 写请求返回 403;无效登录 token 返回 403;无效导出 token 返回 403;私密文章匿名访问返回 404;匿名导出返回 401;非法上传类型返回 400。19.4 导出

已验证:

作者 Markdown 导出;Content-Disposition;微信命令导出指定文章;4 小时下载 token;token 下载;导出内容完整;匿名用户权限拒绝。19.5 微信公众号命令

通过带真实签名的 XML 回调验证:

帮助;普通文字发文;普通网址;link 卡片;我的;修改文章;私密文章;私密文章列表;公开文章;定时发布;定时列表;取消定时;导出指定文章;多图发布;删除文章;MsgId 去重。19.6 微信编辑器

已验证:

页面与资源;Markdown 核心脚本语法;Manifest;Favicon;upload-token;实际图片上传;非法文件拒绝;CSRF;Origin;OCR 训练数据;语音 token;协作 token;非法房间号;WebSocket 握手;Notion、飞书、语雀导入提示;敏感路径拒绝;安全头唯一性。

二十、健康检查体系升级

原健康检查只检查:

页面;静态资源;upload-token;speech-token;collab-token;WebSocket。

因此无法发现“token 正常但磁盘不可写”的问题。

现已加入真实上传测试:

创建独立 Cookie 会话;获取 upload-token;生成 1×1 PNG;真实上传;校验返回 success=true;校验图片 URL 返回 HTTP 200;退出时自动删除测试图片。

这样健康检查可以覆盖:

Session;CSRF;Origin;Gunicorn 多 worker;上传目录权限;ACL;文件写入;Nginx /uploads/ 映射;MIME 类型。

二十一、测试数据清理

生产操作验收完成后,清理了:

专用测试微信作者;验收测试文章;评论和回复;点赞记录;分享记录;定时任务;通知记录;媒体数据库记录;公开媒体文件;私有媒体文件;编辑器临时上传文件;本地临时 Cookie、XML、PNG 和响应文件。

最终数据库复查:

test_posts = [] test_authors = []

没有已知测试文章或测试作者残留。

二十二、最终测试结果

22.1 自动安全测试

项目现有安全测试套件运行结果:

11 passed

测试使用受保护的 blog_test 数据库,不写入生产数据库。

22.2 服务状态

最终均为 active:

nginx blog-ally.service weixin-upload.service weixin-ws.service

22.3 核心线上状态

主站首页                    200 
操作说明                    200 
搜索页面                    200 
RSS                         200 
Sitemap                     200 
健康就绪检查                200 
微信编辑器                  200 
微信编辑器 API              200 
微信公众号帮助回调          200 
+ XML 未配置企业微信回调          403

22.4 语法与配置

全部通过:

Python py_compile;Bash bash -n;nginx -t;Node.js 前端脚本语法检查;编辑器静态检查;编辑器完整健康检查;WebSocket 握手;连续服务重启。

二十三、工程经验总结

23.1 HTTP 200 不等于功能正常

页面能打开、token 能返回,并不能证明图片真的可以写入磁盘。本次 /weixinbianji/api/upload 的故障只有真实上传才会暴露。

企业级健康检查必须覆盖关键写链路。

23.2 多图功能必须验证完整事务

多图草稿保存正常,不代表最终发布正常。数据库外键、文件迁移、正文生成和缓存刷新必须作为一个完整事务验证。

23.3 文件权限不仅是 chmod

Linux 文件访问还可能受到 ACL、默认 ACL、setgid、systemd sandbox 和服务用户影响。

排查时应同时检查:

stat namei -l getfacl systemctl show runuser -u 服务用户

23.4 数据库事务不能自动回滚文件系统

数据库可以 rollback,但 os.replace() 不会自动回滚。因此必须显式记录文件移动,并在异常时反向恢复。

23.5 去重标记应和业务成功绑定

消息去重记录如果过早提交,会把失败请求永久标记为成功。正确策略是:

成功后保留;失败时撤销;允许平台重试。

23.6 安全配置应 fail-closed

以下配置缺失时不应“尽量运行”:

Session 密钥;企业微信 AES 密钥;企业微信 token;CorpID;管理员鉴权密钥。

宁可拒绝启动或拒绝请求,也不应使用不安全的兼容 fallback。

23.7 删除业务数据必须处理物理文件

数据库外键级联只能删除数据库行,无法自动删除磁盘文件。带媒体系统必须建立明确的媒体生命周期:

草稿 → 发布 → 私密/公开迁移 → 修改 → 删除 → 物理清理

23.8 缓存必须服从权限变化

公开文章转私密属于权限变化,不能接受缓存延迟。涉及权限的详情页应绕过公共页面缓存,或具备精确主动失效机制。

二十四、结语

这次验收从最初的“站点能够访问”继续深入到了“所有关键操作能否在真实生产链路中正确完成”。

最终不仅确认了主站和微信编辑器的正常运行,还通过真实操作发现并修复了多个单纯代码阅读或状态探测难以发现的问题:

编辑器上传目录 ACL 导致实际上传失败;微信多图发布事务顺序错误;图片公开/私密迁移路径膨胀;修改带图文章丢失图片;删除文章遗留物理文件;微信 link 类型未解析;XML Content-Type 不规范;消息失败后去重阻止重试;未配置企业微信时存在明文 fallback;文章权限变化受到公共缓存影响。

完成修复后,系统已经通过页面、API、数据库、文件系统、微信公众号、编辑器、WebSocket、权限、安全和清理等多层回归测试。

对于生产系统而言,真正可靠的验收标准不应是“没有看到错误”,而应是:

对关键用户行为执行可重复、可观测、可清理的真实端到端测试,并确保失败时能够安全回滚、准确告警和可靠重试。

打赏
点赞
收藏
分享