资讯类小程序的技术难点不在页面,在后台:内容怎么组织、上架前怎么审、用户订阅了之后怎么推。这三件事做不好,要么内容乱、要么审核过不了、要么推送被用户屏蔽。
本文基于微信小程序 + 云开发(TCB)/自建服务端两种形态,给出可落地的方案。示例以云开发为主,关键逻辑同时给出自建服务端的写法。
资讯类内容组织的第一件事是分类。常见的错误是做成无限级树——"新闻 → 本地 → 西宁 → 城西区 → 民生",看起来灵活,实际运营三个月后没人说得清内容该放哪。
实践下来两级最实用,最多三级:
CREATE TABLE channel (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(40) NOT NULL,
slug VARCHAR(40) NOT NULL COMMENT 'URL 友好标识',
sort INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
UNIQUE KEY uk_slug (slug),
KEY idx_parent (parent_id)
);比栏目结构更重要的是内容与栏目的关系。一篇文章可能同时属于"本地新闻"和"民生"两个栏目,所以要用关联表,而不是在文章表里存一个 channel_id:
CREATE TABLE article_channel (
article_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
is_primary TINYINT NOT NULL DEFAULT 0 COMMENT '主栏目,用于面包屑',
PRIMARY KEY (article_id, channel_id),
KEY idx_channel (channel_id)
);is_primary 这个字段解决一个具体问题:文章在多个栏目出现,但详情页面包屑只能显示一条路径。没有这个字段,前端要么显示多个路径(难看),要么随机取一个(不稳定)。
微信小程序对内容类目有明确要求,UGC(用户发布内容)必须有审核机制。实际项目里要做三道关。
第一道:机器初审。 接入内容安全接口,对标题、正文、图片分别检测。文字用 msgSecCheck,图片用 imgSecCheck。云开发里可以直接调:
// 云函数 checkContent
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const { content, images = [] } = event;
const results = { text: null, images: [] };
if (content && content.trim()) {
try {
const r = await cloud.openapi.security.msgSecCheck({
content: content.slice(0, 2500),
version: 2,
scene: 2, // 2 表示评论/文章
openid: event.openid,
});
results.text = r.result && r.result.suggest; // pass / review / risky
} catch (e) {
// 接口异常时按 review 处理,不放行也不拒绝
results.text = 'review';
}
}
for (const url of images.slice(0, 9)) {
try {
const r = await cloud.openapi.security.imgSecCheck({ media: { contentType: 'image/png', value: Buffer.from(await fetchBuffer(url)) } });
results.images.push(r.errCode === 0 ? 'pass' : 'review');
} catch (e) {
results.images.push('review');
}
}
return results;
};注意 suggest 的三个值:pass 直接放行、review 转人工、risky 直接拒绝。很多项目只处理了 pass 和 risky,把 review 当 pass 放过去,一旦出事就是大问题。
第二道:人工复审。 机器判为 review 的进人工队列,编辑在后台逐条看。这一关必须有,因为机器审核的误判率在具体业务语境下并不低——本地新闻里的地名、民族称谓、行业术语,都容易被误判。
第三道:发布后巡查。 内容上线不等于安全。要做两件事:定期对已发布内容抽样复查;提供用户举报入口,举报后自动下架并进人工队列。
CREATE TABLE content_audit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
target_type TINYINT NOT NULL COMMENT '1文章 2评论',
target_id BIGINT NOT NULL,
stage TINYINT NOT NULL COMMENT '1机器 2人工 3举报',
result TINYINT NOT NULL COMMENT '1通过 2拒绝 3待处理',
reason VARCHAR(200) NULL,
operator VARCHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_target (target_type, target_id)
);这张表的价值不只是审计留痕,它还是优化依据:某类内容反复被判 review,说明审核规则需要调整,或者内容生产方向需要修正。
小程序的消息推送只有一条路——订阅消息。它和公众号模板消息、APP 推送的最大区别是:必须用户主动授权,且一次授权只能发一条。
这决定了推送策略必须是"精准少量",而不是"批量轰炸"。
3.1 授权要在正确的时机要
不要在用户一进小程序就弹授权框,那样通过率极低。正确的时机是用户产生明确意图的那一刻:点了"订阅更新"按钮、收藏了某个栏目、报名了某场活动。
// 前端:用户点击"订阅XX栏目更新"时
async function subscribeChannel(channelId) {
const res = await wx.requestSubscribeMessage({
tmplIds: [TMPL_CHANNEL_UPDATE],
});
if (res[TMPL_CHANNEL_UPDATE] === 'accept') {
await wx.cloud.callFunction({
name: 'saveSubscribe',
data: { channelId, tmplId: TMPL_CHANNEL_UPDATE },
});
wx.showToast({ title: '已订阅' });
}
}3.2 一次授权一条,要攒配额
用户点一次授权,你只能给他发一条。所以服务端要记录"剩余可发次数":
CREATE TABLE subscribe_quota (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(64) NOT NULL,
tmpl_id VARCHAR(64) NOT NULL,
remain INT NOT NULL DEFAULT 0 COMMENT '剩余可发次数',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_openid_tmpl (openid, tmpl_id)
);每次用户授权 +1,每次发送 -1。remain <= 0 时不能发,发了会报错。这个机制逼着你把消息用在刀刃上——比如只在"用户关注的栏目有重大更新"时发,而不是每天推。
3.3 发送要幂等
async function sendOnce(openid, tmplId, page, data, bizKey) {
// bizKey 保证同一业务事件只发一次,比如 article:12345
const key = `msg:${openid}:${bizKey}`;
const ok = await redis.set(key, '1', 'NX', 'EX', 604800);
if (!ok) return { skipped: true };
const quota = await getQuota(openid, tmplId);
if (quota <= 0) return { skipped: true, reason: 'no_quota' };
try {
await cloud.openapi.subscribeMessage.send({
touser: openid, templateId: tmplId, page, data,
miniprogramState: 'formal',
});
await decQuota(openid, tmplId);
return { ok: true };
} catch (e) {
await redis.del(key); // 发送失败释放幂等键,允许重试
return { ok: false, err: e.errCode };
}
}miniprogramState 这个参数要注意:开发版、体验版、正式版要对应设置,否则测试时收不到消息会误判为代码问题。
资讯小程序的首页通常是信息流。数据量小时直接查表就行,数据量上来要考虑缓存。
方案一:直接查 + 缓存首页。 适合日更几十篇的场景。按栏目 + 分页查,结果缓存 60 秒:
async function getFeed(channelId, cursor, limit = 20) {
const cacheKey = `feed:${channelId}:${cursor || 0}:${limit}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const rows = await db.query(
`SELECT a.id, a.title, a.cover, a.summary, a.published_at
FROM article a
JOIN article_channel ac ON ac.article_id = a.id
WHERE ac.channel_id = ?
AND a.status = 1
AND a.published_at <= NOW()
AND (? IS NULL OR a.id < ?)
ORDER BY a.id DESC
LIMIT ?`,
[channelId, cursor, cursor, limit]
);
await redis.set(cacheKey, JSON.stringify(rows), 'EX', 60);
return rows;
}用 id < cursor 做游标分页,不用 OFFSET。深分页时 OFFSET 10000 会扫掉一万行,而游标分页始终只扫需要的部分。
方案二:预生成时间线。 适合百万级内容或者需要个性化推荐的场景。用定时任务把每个栏目的最新 N 条写进 Redis List,请求时直接 LRANGE。代价是内容更新有延迟,以及内存开销。
对本地资讯类项目,方案一足够。别为了"看起来像大厂"上方案二,多出来的运维成本换不来对应的收益。
分页游标的一个细节:如果用 id DESC 做游标,用户下拉时新发布的文章不会出现在后续分页里(因为 id 更大,不满足 id < cursor),这是符合预期的——避免下拉时列表跳动。但用户回到顶部刷新时,要用新的游标重新拉第一页,不能用旧的。
缓存预热:本地资讯的访问有很强的时段特征,早八点和晚八点是高峰。可以在这两个时间点前五分钟,用定时任务把主要栏目的第一页缓存提前生成好。这个动作只花几秒钟,但能让高峰期的首屏请求全部命中缓存,数据库压力下降明显。
一是内容类目资质。 资讯类小程序在微信侧需要相应的资质(比如互联网新闻信息服务许可),个人主体和小微企业主体不一定能过。这个要在开发前确认,不要做完了卡在审核。
二是审核接口的配额。 内容安全接口有调用频率限制,UGC 量大的时候要做队列和重试,不能同步等。
三是订阅消息的模板。 模板要在小程序后台申请,模板类目和内容要对得上,文案不能自己随便写。申请被拒通常是因为"模板与所选类目不符"。
四是历史内容的批量体检。 如果是从旧站迁移内容过来,上线前要跑一遍全文检测。老内容里可能有当年没问题、现在敏感的表述。
青海青帝信息科技有限公司在本地资讯类项目中,会把"资质确认"放在第一步——技术方案再漂亮,资质过不了就没有意义。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。