《从零开发校园墙微信小程序》第 5 篇,也是重头戏。一条投稿看着只是「发一段文字 + 几张图」,但中间有五道关卡。我们从前端到后端完整走一遍。

全貌

[前端] 选图 → 多图拼成一张长图 → 上传
   │
[后端] 登录校验(@SaCheckLogin)
   ├─① 封禁校验         被封号 → 401
   ├─② 用户是否存在      没注册 → 402
   ├─③ 自建敏感词        一级拦截 / 二级替换,并记录命中
   ├─④ 微信文本安全检测   msgSecCheck v2
   ├─⑤ 一小时限流        太频繁 → 403
   ├─⑥ 微信图片安全检测   imgSecCheck(有图时)
   ▼
 以墙号身份发微博 → 镜像落库 → 返回结果
关于状态码:下面的 401 / 402 / 403 都是业务码 ,放在响应体 code 里,HTTP 状态码仍是 200。前端据此直接提示用户,不会 触发重新登录(见第 4 篇)。

前端:选图与「多图拼长图」

投稿页最多 9 张图:

const MAX_IMAGES = 9
const chooseImages = async () => {
  const remaining = MAX_IMAGES - formData.value.imageList.length;
  if (remaining <= 0) { triggerNotify("warning", `最多上传 ${MAX_IMAGES} 张图片`); return; }
  const res = await Taro.chooseImage({
    count: remaining,
    sizeType: ["original", "compressed"],
    sourceType: ["album", "camera"],
  });
  formData.value.imageList = formData.value.imageList.concat(res.tempFilePaths);
};

为什么要拼长图

后端接口一次只收一张 图(@RequestPart("file") FilePart file)。用户想发多张图怎么办?不改后端协议,而是在前端用 Canvas 把文字和多张图拼成一张竖向长图 ,再作为一张图上传。
核心逻辑分四步:
1. 读每张图的宽高 ,按固定画布宽度 750 等比算出每张图的高度:

const canvasW = 750;
const imgHs = infos.map((info) => Math.round((canvasW * info.height) / info.width));

2. 文字自动换行 。Canvas 不会自动换行,要自己用 measureText 一个字一个字量:

const paragraphs = trimmed.split('\n');
paragraphs.forEach((para) => {
  let line = '';
  for (const ch of para) {
    const test = line + ch;
    const w = measureCtx.measureText(test).width;
    if (w > contentW && line) { lines.push(line); line = ch; }
    else { line = test; }
  }
  lines.push(line);
});

3. 算总高度 :上边距 + 文字区 + 间隙 + 所有图高度 + 下边距,然后更新画布尺寸并等一小会儿 (await delay(150)),让小程序把新尺寸渲染生效。这一步容易漏——不等的话画出来是被裁切的。
4. 绘制并导出 :

ctx.draw(false, () => {
  Taro.canvasToTempFilePath({
    canvasId: 'stitchCanvas',
    x: 0, y: 0, width: canvasW, height: canvasH,
    destWidth: canvasW, destHeight: canvasH,
    success: (res) => resolve(res.tempFilePath),
    fail: (err) => reject(err),
  });
});

还有一个小优化:生成结果带缓存 ,内容没变就复用;用户一改文字或图片,用 watch(formData, ..., { deep: true }) 让缓存失效。

mode 参数

投稿接口有个 mode:0 表示正常带文字,1 表示文字已经并入图片 (拼长图时文字画进去了,不用再单独发)。

后端:五道关卡

主流程是响应式编排的:

public Mono<String> contributeAndSave(String access_token, String status,
                                      MultipartFile image, String openId, Integer mode) {
    return userBanService.isBannedReactive(openId)
        .flatMap(banned -> {
            if (banned) return Mono.just(errJson("你已被封号,详情请联系管理员", "401"));
            AtomicReference<String> textRef = new AtomicReference<>(status);
            return precheckForContribute(openId, status, textRef)      // 前置校验
                .switchIfEmpty(Mono.defer(() -> {
                    if (ObjectUtils.isEmpty(image)) {
                        return sendStatusTextReactive(access_token, textRef.get(), mode, openId);
                    }
                    return imageCheckBeforePublish(image, openId)      // 图片安全检测
                        .switchIfEmpty(Mono.defer(() ->
                            sendStatusWithImageReactive(access_token, textRef.get(), image, mode, openId)));
                }));
        });
}

读法:每一步要么返回「错误 JSON」(流程终止),要么返回 empty(通过,进入下一步) ,靠 switchIfEmpty 串联。这是一种很清晰的「关卡式」响应式写法。

① 封禁:用响应式查询

封禁表读多写少、逻辑简单,走 R2DBC 响应式查询(isBannedReactive)。被封直接返回 401。

②–⑤ 前置校验(阻塞,丢弹性线程池)

这部分用了 MyBatis(阻塞),所以整体包进 boundedElastic:

private Mono<String> precheckForContribute(String openId, String status, AtomicReference<String> textRef) {
    return Mono.fromCallable(() -> {
        // ② 用户存在校验
        User curUser = userService.selectUserByUserId(openId);
        if (curUser == null) return errJson("你还没有注册呢", "402");

        // ③ 自建敏感词:一级拦截、二级替换
        SensitiveWordFilter check = sensitiveWordService.check();
        Map<Integer, Integer> triggered = check.getTriggeredWordsAndLevels(text);
        if (!triggered.isEmpty()) {
            boolean blocked = false;
            for (var entry : triggered.entrySet()) {
                // 每个命中都记日志(词、内容、用户、时间)
                ...
                if (entry.getValue() == 1) blocked = true;
            }
            if (blocked) return errJson("你的内容被判断为违规,多次记录会被封号,请注意言行", "401");
            text = check.filterSensitiveWords(text);     // 二级:替换
        }

        // ④ 微信文本内容安全检测(fail-open)
        try {
            var tr = wxContentSecurityService.checkText(text, openId);
            if (!tr.pass()) return errJson("内容包含违规信息,无法发布", "401");
        } catch (IOException e) {
            log.error("文本内容检测调用微信失败,本次放行(FAIL-OPEN) ...");
        }

        // ⑤ 一小时限流
        if (!commentsService.canComment(openId)) return errJson("一小时内已经发送过,请稍后再试", "403");

        textRef.set(text);      // 把「过滤后」的文本交给下一步
        return null;            // 通过
    }).subscribeOn(Schedulers.boundedElastic());
}

这里有三个值得讲的设计:
(a) 命中就记日志,哪怕只是二级替换。 「多次记录会被封号」就靠这张日志表统计,运营能看到谁老是试探。
(b) 微信内容检测用 fail-open(失败即放行)。 微信接口偶尔会不可用,如果此时一律拦截,用户就完全发不了帖;所以选择「检测服务挂了就放行,但记 error 日志」。这是一个取舍 :这里宁可漏过,也不要让正常用户被基础设施故障连累。你的场景如果是金融、教育未成年人,可能应当 fail-close,要自己判断。
(c) 用**AtomicReference**把过滤后的文本传出来。 响应式流里 lambda 不能改外部普通变量,所以用一个引用容器带出去。

这里还有一个真实的 bug 教训 :旧代码里,文本明明被敏感词过滤了,但发微博时用的还是原始文本 ——过滤白做了。现在统一用 textRef.get()(过滤后)发布。「检查通过」和「实际使用的数据」必须是同一个值 ,检查完要把清洗后的结果传下去。

⑥ 图片安全检测

return imageCheckBeforePublish(image, openId)
        .switchIfEmpty(Mono.defer(() -> sendStatusWithImageReactive(...)));

用微信的 imgSecCheck,放在 MinIO 上传之前 。违规图片直接拒绝,不落库、不上传、不发微博:既不浪费存储,也因为在后端执行而无法被直连接口绕过 (前端检测永远可以被绕)。

发微博与镜像

通过全部关卡后,用 WebClient 调微博开放平台接口发文字或图文微博,成功后把这条微博镜像写入自己的**weibos_status**,并在 status_user 记录真实提交人 openid。

限流的几个现实问题

  • 一小时限流 是业务规则,存在后端,前端只负责展示提示。永远不要只在前端限流。
  • 微博接口自己也有频率限制和配额 。发帖、评论、回复撞限流时,要把失败登记到 (token, 读/写) 维度 ,下次别再拿同一个已耗尽的 token 去撞。

小结

  1. 多图投稿:不改后端协议,前端 Canvas 拼长图;记得等画布尺寸生效;
  2. 后端用「关卡式」响应式写法:错误返回 JSON,通过返回 empty,switchIfEmpty 串联;
  3. 敏感词分级;命中必须记日志;
  4. 第三方检测服务挂了怎么办(fail-open / fail-close)要主动选择 ;
  5. 检查通过的值 = 实际使用的值 ;
  6. 提交人身份只取 token;
  7. 图片检测放在上传之前。

下一篇,单独拆开讲敏感词过滤的实现——一个看似简单、实则有坑的算法。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏