AnQiCMS v3.8.1 发布:商城并入主干、飞书钉钉接管后台、四个支付回调重写
这一版从 3.6.7 到 3.8.1 跨了三周,922 次提交,主线 116 条,落到更新日志里是 51 项。功能不少,但说白了,一半的力气花在同一件事上:把那些”接口回成功、实际上什么都没做”的地方找出来。
商城并进主干了
商城版此前是条独立分支,从 v3.5.8 分出去的。这次以 v3.6.7 为底座做整体移植,处理了 151 个冲突文件。
新增 24 个数据模型:SKU、购物车、愿望清单、优惠券、营销活动、批发规则、物流运费、询盘订单、商品同步、积分、汇率等。订单结算补上优惠券、运费、会员价、活动价、分销、发票和物流追踪,另外配了 15 套商城邮件模板和一套商城模板。
主干这段时间攒的东西一样没丢:AI 意图内核、Partial 语义、SQL 注入加固、支付账户体系都在。
内容站不受影响。shop_mode 默认是 0,商城前台 38 条路由在普通模式下统一返回 404,后台数据照样能管,只是菜单不显示。要开商城,去系统设置里切一下。
后台能接飞书、钉钉了
不是”有新留言给你发条通知”那种接入。管理员在飞书私聊,或者在授权的群里,直接跟 AI 对话干活:改内容、查订单、调设置。网站有新留言、新订单,也能按规则推到指定群或者管理员私聊。
两个平台都走长连接,飞书用 oapi-sdk,钉钉用 Stream 模式。不需要公网回调地址,也不用开放入站端口。内网部署的站点能不能用,基本就看这一条。
几个实现上的取舍:
· 收到消息先回一个表情。飞书贴 OnIt,钉钉发表情回复。等模型和工具跑完那段静默太久,先给个”我收到了”。
· 回复按 Markdown 渲染,钉钉的标题会跳过代码围栏再剥标记。
· 组织标识改成调平台接口自动取。那个值在平台后台根本没有展示入口,要求站长手填等于把接入门槛设在拿不到的地方。
权限只留一套。渠道能干什么,完全由站点的意图曝光和接口开放范围决定,渠道内部没有第二套授权,也没有审批状态机。第一版是带审批的,后来去掉了——同一件事在后台配一次就够了,两套规则只会互相打架。
一份源码,多个白标包
品牌收敛到一处:config/oem/{id}/brand.json,用 go:embed 编进二进制,构建期靠 -ldflags 注入。同一个仓库能出多个互不影响的安装包,不用给每个客户维护一个 fork。
品牌不从远端拉,这是刻意的。内网部署、或者官网正好不可达的时候,”拉不到就退回默认”等于把客户的付费后台做成竞品广告位。
标识拼错了会怎样?回落到中性品牌,展示名就是那个拼错的标识,绝不回落到官方品牌。整套改造里唯一”错了会很难看”的地方就在这儿。
验收也不能靠 grep 源码。品牌替换发生在运行出口,源文件里保留官方文案本来就是设计的一部分。所以加了个 make brand-check:抓运行中站点的真实响应,看首页、后台外壳、robots.txt、sitemap.xml,双向判定——不该出现的别出现,该出现的别缺。
· · ·
四个支付回调,原来全是坏的
这句不是修辞。改之前,微信、PayPal、Stripe 的异步回调全部静默失效,支付宝会把没付款的订单标成已付。
微信 验签函数的第一个参数被当成 appId 传了,实际要的是商户 apiKey;验签算法还写死 MD5,而通知报文不带 sign_type,下单用的又是 HMAC-SHA256。每一笔通知都验不过。
Stripe 拿 evt_ 开头的事件 ID 去查 payment_id,必然查不到,然后直接 return。Iris 默认回 200 空响应,Stripe 认为投递成功,不再重试。
PayPal 处理函数要求带 ?token= 参数。那是同步 return_url 上才有的,而注册的 webhook 地址不带参数——回调全被挡在门口。
支付宝 验签没问题,但完全不校验 trade_status。WAIT_BUYER_PAY 状态也会把订单标成已支付。
四条都按根因修了。顺手给记账加了道闸门:通知重试和模板轮询会并发进来,用 paid_time = 0 的条件更新做闸门,只有一条能真正落账。不加这个,财务流水会重复记。
另外新接了两个渠道。虎皮椒:签名按官方 V3 文档,回调要验签名、appid、status=OD、金额四项,退款只支持整单。NOWPayments:加密货币,发票模式,IPN 用 HMAC-SHA512 验签,只认 finished、confirmed、sending 三种状态——它没有退款 API,所以退款分支直接报错,而不是把订单标成已退款、实际一分没退。
几处安全修补
附件上传改白名单,默认拒绝。导火索是 public/uploads 里出现了 shell.php5 和 s2.phtml。原来的逻辑只拦 .php 和 .jsp,filetype 认不出的内容还会回落到用户文件名。水印和标题图的后缀也不再取文件名,改由 image.Decode 的结果决定。模板包导入顺手补了 Zip Slip。
找回密码的凭据换算法。原来是 Md5(email + password) 派生,空密码账号(Google、公众号、验证码注册)会退化成 Md5(email)——知道邮箱就能改密登录。
邮箱探测加限流。注册、GET/POST verify/email 三个端点都能探测某个邮箱是否已注册,原先各自没限流,轮换着用就绕过去了。现在共用一个预算:20 次 / 10 分钟 / IP。
/aigenerate 整组没有鉴权。7 个端点不带 token 就能读 AI 配置、触发批量生成。补上中间件后统一回 1001。
那些”点了没反应”的 bug
锚文本批量更新,3557 篇文章,失败 3557 篇,287 毫秒”跑完”。原因藏得比较深:CurrentSite 返回的是带请求 ctx 的副本,go currentSite.ReplaceAnchor(nil) 把它带进 goroutine,handler 一返回 ctx 立刻被 cancel,之后每篇都 context canceled,错误又被替换函数吞掉。加了 Website.Async() 摘掉请求 ctx,0 失败,3.065 秒跑完。
优惠券批量发码,循环里完全不看插入的错误。几十张全失败也照样返回成功,后台刷新列表是空的。改成逐张检查,整批发码走事务,要么全落库要么一张不留。
写端点更普遍:51 个在请求体为空时回”已保存”,24 个在 id 不存在时回成功。抽了个统一的入参护栏收口,空格载荷、缺目标、目标不存在一律拒绝并说明原因。
升级流程里的那个更阴。主程序被 rename 成 xxx.old 后进程还跑在旧 inode 上,Linux 会把 /proc/self/exe 也改成 xxx.old。升级完没重启又点一次升级,就把 xxx.old 备份成 xxx.old.old,真正的 xxx 一次都没换,界面提示成功。
还有一处判断:代码里有 5 个地方用 ModuleId == 2 认定”这是产品”。模块 id 是自增的,删了模型重建、或者迁移过站点,id 未必是 2。商品页照样能打开,价格区块、SKU 筛选、Product 结构化数据一起消失,没有任何报错。统计里的 module_id = 2 更直接,让”上新”图表恒为 0。现在这些地方都读 modules.kind。
· · ·
升级前,先看这四件事
- 数据库会删列,先备份
archives 和 archive_drafts 会删掉 29 个历史列,不可回滚。商品属性已经迁到 archive_products 侧表,自己写过插件直查这些列的必须一起改——WHERE product_type = ? 这类条件现在是 LEFT JOIN + IFNULL,不然会把”没有侧表行的普通商品”漏掉。
- 投稿权限默认是空的
用户组的”可投稿栏目”升级后为空,等于不能投稿。没有”空就是全部”的兜底,父子栏目也不继承。升级完去用户组里勾一遍,不然前台投稿会直接报权限错误。
- 附件后缀改白名单
.psd、.ai、.apk 这些以前能传的现在会被拒。有需要就补表,判定逻辑只有一个入口,不用改多处。
- 商城默认是关的
要用得在系统设置里把 shop_mode 切到 1 并保存。
完整的 51 条变更在仓库的 CHANGELOG.md 里,每条都写了为什么改、改之前是什么样。这一版动的地方多,升级前把备份做完再动手。
如果升级过程中遇到问题,把版本号、日志片段和复现步骤发到仓库 Issues,我们会跟到底。