写在前面最近做了一个面向闲鱼卖家的微信小程序项目名叫鱼店助手。这篇是技术复盘按模块讲实现思路和踩过的坑AI 文案生成含接口防刷、重新生成不重样、结果的规则检测违禁词检测四个图片工具MD5 重置、文字水印、调色增强、加边框全部在本地 canvas 处理利润记账规则库和知识库几百篇文章的下发、搜索、配图、发布通道测一测题库格式校验、抽题、判分分享卡片、每日次数限制以及一些零碎的坑都是实际做的时候碰到的代码是从项目里摘出来简化过的希望对做小程序的朋友有用。一、整体架构能简单就简单微信小程序原生无框架 │ wx.request ▼ nginxHTTPS 反代后端端口只监听本机 ▼ Node.js 后端只用内置 http / fs / path / crypto零依赖pm2 守护 ├─ /api/generate 代理 AI 接口密钥只在服务器 ├─ /api/config/public 每日次数规则等公开配置 ├─ /api/articles 规则库 / 知识库目录不带正文 ├─ /api/article?id 单篇正文 ├─ /api/articles/search 正文搜索 ├─ /api/quizzes、/api/quiz 题库目录 / 一套题 ├─ /uploads/* 文章配图 └─ /api/admin/* 网页后台发文章、录题、改提示词、调次数 ▼ JSON 文件存储config.json / articles.json / quizzes.json几个取舍1.后端零依赖。没有 Express没有数据库node server.js就能跑。数据量就是几百篇文章、几套题JSON 文件完全够用。2.能放服务器的内容都放服务器。文章、题库、提示词、次数规则都从服务器下发改内容不用发版、不用等审核。3.用户数据不上服务器。生成记录、利润账本、测试成绩都只存在用户手机的本地缓存里。不用做登录、没有隐私负担代价是换手机会丢。JSON 文件写入要注意先写临时文件再 rename写到一半进程挂了也不会把原文件写坏。const tmp ARTICLES_PATH .tmp; fs.writeFileSync(tmp, JSON.stringify(list)); fs.renameSync(tmp, ARTICLES_PATH); // rename 是原子操作二、AI 文案生成用户粘贴一条旧文案或同行的文案选「标准 / 精炼」风格生成一条新的。1. 密钥只放服务器小程序包可以被解包任何密钥都不能写在前端。小程序只调自己的/api/generate服务器带着密钥去请求大模型。接口用 OpenAI 兼容格式后台填地址、密钥、模型名就能切换厂商。用阿里百炼的千问做非流式调用时要关掉深度思考否则可能报错或超时费用也更高const body Object.assign( { model: cfg.model, messages }, /aliyuncs\.com/i.test(cfg.baseUrl) ? { enable_thinking: false } : {} );2. 接口防刷前端限次挡不住脚本前端的每日次数存在用户手机上清缓存就归零有人也完全可以绕过小程序直接刷接口烧的是你的 AI 余额。所以服务器做了三道闸单 IP 每分钟 5 次、单 IP 每天 30 次、全站每天 500 次计数放内存重启清零。踩坑nginx 后面拿到的 IP 全是 127.0.0.1。请求都是 nginx 转过来的结果一个人输错 10 次后台密码所有人一起被锁。但也不能无脑相信X-Real-IP外部请求可以伪造它。正确做法是只有请求来自本机 nginx 时才读这个头function clientIp(req) { const peer req.socket.remoteAddress || ?; const isLocal peer 127.0.0.1 || peer ::1 || peer ::ffff:127.0.0.1; const real String(req.headers[x-real-ip] || ).trim(); return isLocal real ? real : peer; }nginx 那边配上proxy_set_header X-Real-IP $remote_addr;。3. 重新生成不重样同一条原文反复点「再生成一条」模型每次都会收敛到几乎一样的标题。原因是每次请求互相独立模型看不到上一次写了什么提示词里写「每次不要重复」不起作用。解决办法是服务器替模型记住按「IP 原文哈希」记最近出过的 5 个标题再次生成时把这些标题附在提示词后面要求换一个钩子。只存内存2 小时不用就忘。const key ip | crypto.createHash(sha1).update(input).digest(hex); const rec recentTitles.get(key); if (rec Date.now() - rec.at 2 * 3600 * 1000) { prompt \n这条原文之前已经出过这些标题\n rec.titles.map((t) - t).join(\n) \n这次标题必须换一个钩子……; }记得给这个 Map 设上限超过 5000 条直接清空防止被刷爆内存。4. 结果下面的「这条用到的设计」用规则检测不让 AI 自夸生成结果下面会列出几条「这条用到的设计」比如「标题带钩子」「带话题标签」。这部分不是让 AI 自己说它会吹而是本地用正则检测文案里真实存在的特征只说检测到的const RULES [ { test: (t, first) /【[^】]{1,12}】/.test(first), note: 标题带钩子一眼戳中买家顾虑 }, { test: (t) /#[^\s#]{1,20}/.test(t), note: 带话题标签更容易被搜到 }, // ……售后兜底、可验证细节、咨询引导 ]; function analyze(text) { const first text.split(\n).filter((l) l.trim())[0] || ; const notes RULES.filter((r) r.test(text, first)).map((r) r.note); if (text.split(\n).length 3) notes.push(分行短句排版列表页里更好读); const tail dict.scan(text).hits.length 0 ? [违禁词自检 0 命中] : []; return notes.slice(0, 4 - tail.length).concat(tail); // 最多 4 条 }5. 长文案折叠user-select会让 line-clamp 失效结果很长时默认折叠 12 行。用-webkit-line-clamp实现后发现完全不起作用排查下来是text user-select长按可选字导致的外面包一层 view 也不行。解法是折叠状态关掉user-select展开后再打开text class{{folded ? body clamp : body}} user-select{{!folded}}{{result}}/text生成记录存在本地缓存里可以复制、填回输入框、删除。三、违禁词检测长词优先重叠只留一个检测就是字符串匹配难点在重叠。比如词库里同时有「全网最低价」和「最低价」一段文字里两个都能匹配上都标红的话高亮区间会打架。处理规则找出所有命中长词优先和已保留区间重叠的丢掉再按位置排序切成「普通段 / 风险段」交给页面渲染每个风险词附上分类和原因。function scan(text) { const hay text.toLowerCase(); const found []; DICT.forEach((d) { const needle d.w.toLowerCase(); for (let i hay.indexOf(needle); i 0; i hay.indexOf(needle, i 1)) { found.push({ w: d.w, start: i, end: i needle.length }); } }); found.sort((a, b) (b.end - b.start) - (a.end - a.start) || a.start - b.start); const kept []; found.forEach((m) { if (!kept.some((k) m.start k.end k.start m.end)) kept.push(m); }); return kept.sort((a, b) a.start - b.start); }扫描结果按「普通段 / 风险段」切开后渲染高亮目前词库写在前端代码里加词要发版下一步计划改成服务器下发。四、图片工具全部在本地 canvas 处理四个图片工具都不上传图片在手机本地用 canvas 2d 处理完直接存相册没有服务器带宽成本也不碰用户图片隐私。1. MD5 重置文件尾追加 8 个随机字节需求是「画面不变但文件 MD5 要变」。如果用 canvas 重新编码画质会有损失。更干净的做法是直接在文件末尾追加几个随机字节JPG 和 PNG 的解码器都会忽略结尾多余的字节像素一个都不变但整个文件的 MD5 必然改变。const src new Uint8Array(await readFileP(path)); const out new Uint8Array(src.length 8); out.set(src); for (let k 0; k 8; k) out[src.length k] Math.floor(Math.random() * 256); await writeFileP(tmpPath, out.buffer); await saveToAlbumP(tmpPath);处理前后的 MD5 用纯 JS 实现的 md5 算出来给用户看可以直观对比。2. 文字水印在旋转后的坐标系里铺网格满屏平铺水印的做法把原点移到图片中心、旋转画布然后铺一个能盖满整条对角线的网格隔行错开半格更难被抹掉。字号按图片宽度的比例算大图小图看起来一样大。ctx.translate(W / 2, H / 2); ctx.rotate(angle * Math.PI / 180); const tw ctx.measureText(text).width; const gapX tw fontPx * (1 g * 0.8); // g 由「密集度」滑块决定 const gapY fontPx * (1.6 g * 0.9); const R Math.sqrt(W * W H * H) / 2 gapX; let row 0; for (let y -R; y R; y gapY, row) { const off (row % 2) * gapX / 2; // 隔行错开半格 for (let x -R; x R; x gapX) drawOne(x off, y); }导出最长边限制在 2560再大部分手机的 canvas 会内存吃紧。3. 调色增强查表不逐像素做浮点运算第一版就是线性拉饱和度效果很假被否了。现在参考修图软件「自动增强」的结构每张图先分析再调1. 读直方图定黑白场去灰2.灰世界白平衡纠正偏色3.gamma提亮、S 曲线加对比软对比不死白4.自然饱和度只补淡的颜色已经很艳的不再加防止溢出5. 最后用十字核做一次unsharp 锐化前三步每次改参数时折成3 张 256 长度的查表LUT逐像素只需要查表速度快很多。10 个模板白底、美食、质感等其实就是这几个参数的不同组合。性能上分两档预览用最长边 1000 的小图实时算点保存时才按原图最长边上限 2000全尺寸算一次。4. 加边框小程序 canvas 2d 没有 roundRect圆角边框要自己用arcTo画路径function roundRectPath(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); }边框宽度按图片短边的百分比算颜色除了预设色还支持 HSL 自定义。5. 保存到相册用wx.saveImageToPhotosAlbum。小程序后台的「用户隐私保护指引」里必须声明「相册」否则审核会被驳回线上也会被拦。四个工具的参数会记在本地下次打开自动带上。五、利润记账用户预设商品的售价、成本每天只填单量、推广费、运费自动算当天利润按月看账本、成本构成每个月第一次打开弹上月小结。1. 金额一律用「分」存整数0.1 0.2 ! 0.3这种问题一个月累加下来账就对不上。所以所有金额都用「分」的整数存和算只在显示时除以 100function toFen(v) { // 用户输入的「元」→ 分 const n Math.round(parseFloat(v) * 100); return isFinite(n) n 0 ? n : 0; } function fmt(fen) { // 分 → 12.50 const n Math.round(fen || 0); return (n 0 ? - : ) (Math.abs(n) / 100).toFixed(2); }2. 口径写死在一个纯函数里计算全部放在一个不依赖界面的纯函数calcDay里可以直接用 node 写断言测试销售额 Σ 销量 × 售价 Σ 定制单售价 手续费 销售额 − 退货金额× 费率 拿货成本 Σ销量 − 退货数量× 成本价 Σ 定制单成本 利润 销售额 − 退货金额 − 拿货成本 − 手续费 − 其他成本每天的记录里存一份当时的价格快照以后改了预设价格历史账不会跟着变。3. 存储和交互上的几个细节一个月一个缓存 key形如xyw_profit_m_2026-10避免单个 key 越存越大撞上 1MB 上限。输入框用「非受控」写法打字时只更新草稿和底部汇总不把值回填给输入框的value否则光标会乱跳数字键盘上尤其明显。月度小结只弹一次本地记一个「已弹过到哪个月」新月份第一次打开时检查上个月有没有记录、有没有弹过。六、规则库和知识库几百篇文章怎么下发「知识」里分两个板块规则库平台公开发布的规则、公告、协议、卖家问答四百多篇和知识库运营经验。两个板块的文章结构一样用cat字段区分板块内再按小分类筛选筛选芯片根据文章里实际出现的小分类自动生成。1. 目录和正文分开下发第一版很简单一个接口把所有文章连同正文一起返回。文章一多就出事500 篇文章连正文大约1.6MB每次打开小程序都要下一遍小程序本地缓存单个 key 上限 1MB存不下列表一次渲染几百条滑动会卡改成三个接口接口返回内容500 篇实测GET /api/articles只有 id、标题、板块、小分类、日期、修改时间约 66KBGET /api/article?id点开哪篇拉哪篇的正文单篇GET /api/articles/search?q服务器搜正文只返回命中的 id最多 50 条小程序这边目录缓存在本地打开时先用缓存垫着没网也能看后台再拉最新的。正文一篇一个缓存 key按目录里的修改时间判断要不要重拉最多保留最近看过的 40 篇。列表一次渲染 30 条滑到底再追加 30 条用路径形式的setData只传新增的部分。搜索两个板块一起搜分两步标题和小分类在本地立刻出结果正文搜索等用户停手 0.4 秒后再问服务器结果接在后面。2. 文章配图配图不打进小程序包主包上限 2MB存在服务器/uploads/后台上传前在浏览器里先压到最长边 1600、800KB 以内的 jpg再传。服务器靠文件头识别图片类型jpg / png / gif / webp不信扩展名文件名用随机 16 位十六进制。/uploads/只认这种随机文件名格式顺手堵掉目录穿越。踩坑wx.previewImage不认小程序包内的图片路径点图放大会一直转圈。解法是打开文章时先用FileSystemManager.copyFile把包内图片拷到wx.env.USER_DATA_PATH预览用拷出来的那份。3. 发文章不新开公网接口文章多了以后想在本地写好 Markdown 一条命令发上去。没有为此新开对外接口也不让本地脚本接触管理密码1. 本地脚本读 Markdown 稿子用 macOS 自带的sips把图片压好2.scp传到服务器临时目录3.ssh调服务器上的一个小脚本由它在服务器本机读管理密码、调 127.0.0.1 的后台接口完成发布4. 传过的图片按内容指纹记下来改文章重发时不重复传图。七、测一测题库格式校验、抽题、判分1. 题目就是一段纯文本服务器负责校验后台录题不想做复杂的表单就约定一种纯文本格式一题一段1. 题干 A. 选项一 B. 选项二 C. 选项三 答案: B 解析: 为什么选 B判断题不写选项答案写「对 / 错」。只有服务器认这个格式解析失败时要告诉录题的人第几题哪里不对比如「第 1 题的答案是 E但只有 4 个选项」「第 3 题的选项顺序不对」。这样录题的人不用懂任何技术。2. 抽题和判分题库比「每次抽几道」多时随机抽用 Fisher-Yates 洗牌后取前 N 道function draw(quiz) { const pool quiz.questions.slice(); for (let i pool.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [pool[i], pool[j]] [pool[j], pool[i]]; } const n quiz.count 0 quiz.count pool.length ? quiz.count : pool.length; return pool.slice(0, n); }这是自测不是考试所以答案和解析随题目一起下发本机判分不做防作弊。百分制四舍五入按分数分 4 个档位成绩只存本机记最好成绩。转发成绩时把分数带在分享路径参数里朋友点进来能看到「朋友在这套题上得了 N 分」。八、分享卡片onShareAppMessage 必须同步返回账单、测试成绩、文章都做了转发封面是现画的图账单显示利润成绩显示分数文章显示标题。canvas 2d 现画、canvasToTempFilePath 导出的分享卡坑在于onShareAppMessage必须同步返回没法在用户点分享时再异步画图。解决办法是提前画好在「存好一天的账 / 打开账本 / 出成绩」这些时机用页面里一个藏在屏幕外的canvas type2d把卡片画出来导出成临时文件存着点分享时直接返回路径没画出来就退回固定封面。function render(page, selector, painter) { return new Promise((resolve) { wx.createSelectorQuery().in(page).select(selector).fields({ node: true }).exec((res) { const canvas res res[0] res[0].node; if (!canvas) { resolve(null); return; } canvas.width 1000; canvas.height 800; painter(canvas.getContext(2d)); wx.canvasToTempFilePath({ canvas, fileType: jpg, quality: 0.92, success: (r) resolve(r.tempFilePath), fail: () resolve(null) }); }); }); } onShareAppMessage() { return { title, path, imageUrl: this._card || /assets/share/profit.png }; }文章标题卡要自己做折行按字宽折行、行数摊匀、超过 4 行加省略号标点不落行首。九、每日次数限制服务器给规则本地计数每个功能文案生成、四个图片工具、看文章都能在后台单独设「每天 N 次」或者不限规则从服务器拉1 分钟内不重复拉拉不到就用本地缓存的上一份计数记在本地按天清零。没有登录服务器认不出是谁所以这层只是「温和限制」清缓存就归零。真正的硬墙是第二节里服务器按 IP 的生成限流。两层配合前者管体验后者管成本。十、其他零碎的坑1.读剪贴板是隐私接口。做了「一键粘贴」隐私保护指引里必须加「剪贴板」和相册一样。iOS 读剪贴板时系统顶部会弹「粘贴自 xx」属于正常现象。2.点了按钮没反馈加载提示放在按钮下方一屏之外用户以为没点上。改成把加载状态直接做进按钮里转圈 分步文案完成后自动把结果卡滚到可视区。3.开发者工具自动化查样式时-webkit-box一律返回flow-root别拿它判断 line-clamp 是否生效直接看元素高度。4.bash -s EOF里跑 ssh第一条 ssh 会把剩下的脚本当输入吃掉后面的命令不执行。ssh 要加-n或者把脚本写成文件再跑。最后整个项目的思路就一句话能用简单方案解决的就不上复杂的。零依赖后端、JSON 文件存储、内容服务器下发、图片本地处理、用户数据只存本地一个人维护起来也不累。有问题欢迎评论区交流。