《从零开发校园墙微信小程序》第 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 去撞。
小结
- 多图投稿:不改后端协议,前端 Canvas 拼长图;记得等画布尺寸生效;
- 后端用「关卡式」响应式写法:错误返回 JSON,通过返回
empty,switchIfEmpty串联; - 敏感词分级;命中必须记日志;
- 第三方检测服务挂了怎么办(fail-open / fail-close)要主动选择 ;
- 检查通过的值 = 实际使用的值 ;
- 提交人身份只取 token;
- 图片检测放在上传之前。
下一篇,单独拆开讲敏感词过滤的实现——一个看似简单、实则有坑的算法。