电商小程序模板合规开发:规则库、纠纷状态机与入驻审核

电商小程序模板合规开发:规则库、纠纷状态机与入驻审核 简介面向微信小程序电商平台开发者、运营方及法务合规人员的协议与规则模板聚焦平台服务协议、交易规则、入驻经营者审核要求以及用户纠纷处理机制可用于搭建小程序商城时快速起草合规文本、内部评审与风险排查。压缩包仅含1个docx文档约18KB轻量易读便于直接编辑替换主体信息后落地使用。已有641人学习下载说明该主题受到互联网电商从业者关注。文档围绕《电子商务法》展开细化平台公开公平公正原则、信息记录与三年保存、个人信息查询更正删除注销、经营者身份核验登记、网络安全与数据保密、规则修订前公示、违规警示暂停终止及自营业务区分等义务交易规则部分还涵盖合同成立、格式条款无效情形、交付时间认定、快递物流查验与环保包装、电子支付对账和未授权支付责任划分适合作为合规自查与协议撰写参考。1. 电商小程序模板里最容易返工的从来不是页面接手一个电商小程序模板商品、订单、支付都跑通了上线前卡在三份 docx服务协议与交易规则、对用户处理纠纷的机制、对入驻经营者的审核要求。很多人第一反应是把它们当成法务给的文案粘进一个静态页面就交差然后连续踩三个坑规则改一个字就要重新发版商家投诉时找不到当时的条款版本用户申诉时拿不出他勾选同意的那条记录。这三份文档在工程上是三类数据规则库有版本、有生效时间、可锚点检索的条款树、流程工单状态机、时限参数、超时升级条件、准入规则资质字段校验、审核状态流转、留痕与复核。把它们按数据模型来做模板才谈得上可交付、可二次开发。下面按「文本建模 → 纠纷流程 → 入驻审核 → 上线自检」推进示例用原生小程序 Node.js/MySQL换 uniapp 打包微信小程序同样适用。2. 服务协议与交易规则的文本建模从 docx 到小程序规则库2.1 把 docx 拆成条款树条款编号就是稳定主键法务给的文档通常是「一级标题 二级标题 3.2.1 这种编号」。整段塞进富文本会直接废掉三个需求用户点「交易规则 4.1」跳不过去规则变更时 diff 不出到底改的是哪一条埋点拿不到用户看了哪一条。常见做法是先解析成条款树编号当主键。// 把 markdown 化的规则文档切成条款树条款编号作为稳定 id const fs require(fs); const DOC fs.readFileSync(./rules.md, utf8); function parseClauses(md) { const clauses []; let cur null; for (const line of md.split(\n)) { // 只认 ## 3.2.1 退款规则 这类编号标题 const m line.match(/^(#{2,4})\s(\d(?:\.\d)*)\s(.)$/); if (m) { if (cur) clauses.push(cur); cur { code: m[2], // 3.2.1跨版本不变做锚点和埋点 key level: m[1].length, // 2/3/4决定目录缩进 title: m[3].trim(), content: [] }; } else if (cur) { cur.content.push(line); } } if (cur) clauses.push(cur); return clauses.map(c ({ ...c, content: c.content.join(\n).trim() })); } const tree parseClauses(DOC); fs.writeFileSync(./clauses.json, JSON.stringify(tree, null, 2)); console.log(tree.length, 条); // 和法务给的目录数量对账少一条就是解析漏了code保持稳定用户收藏的锚点才不会因为改版失效level决定前端缩进层级最后打印条数是为了和 docx 目录人工对账——「第3条」「3、」这类混排编号是解析漏项的高发区。字段类型作用生成方式codestring锚点跳转、埋点、版本 diff 的主键文档编号levelint目录缩进与折叠层级#数量titlestring目录项与站内搜索标题文档标题contenttext条款正文剩余段落versionstring该条所属的规则版本发布时写入effective_atdatetime生效时间后台配置2.2 用 rich-text 渲染条款并支持锚点跳转rich-text只认name/attrs/children不认 class所以每条款外面要包一层view把 id 挂在这层上。!-- pages/agreement/detail.wxml -- scroll-view scroll-y scroll-into-view{{anchor}} scroll-with-animation classdoc view wx:for{{clauses}} wx:keycode idclause-{{item.code}} classclause level-{{item.level}} view classclause-title{{item.code}} {{item.title}}/view rich-text nodes{{item.nodes}}/rich-text /view /scroll-view// pages/agreement/detail.js Page({ data: { clauses: [], anchor: }, onLoad(query) { const clauses require(../../data/clauses.json); // 也可改为后端下发 this.setData({ clauses: clauses.map(c ({ ...c, nodes: this.md2nodes(c.content) })) }); // 订单页带 ?anchor3.2.1 跳进来直接定位到条款 if (query.anchor) this.setData({ anchor: clause-${query.anchor} }); }, md2nodes(md) { return md.split(/\n{2,}/).map(p ({ name: p, attrs: { style: margin:0 0 16px;line-height:1.7;color:#333 }, children: this.inline(p) })); }, // 只处理加粗与链接够覆盖规则文档的排版需求 inline(text) { const out []; const re /(\*\*[^*]\*\*)|(\[[^\]]\]\([^)]\))/g; let last 0, m; while ((m re.exec(text))) { if (m.index last) out.push({ type: text, text: text.slice(last, m.index) }); if (m[1]) out.push({ name: strong, children: [{ type: text, text: m[1].slice(2, -2) }] }); if (m[2]) { const [, label, href] m[2].match(/\[([^\]])\]\(([^)])\)/); out.push({ name: a, attrs: { href, style: color:#07c160 }, children: [{ type: text, text: label }] }); } last re.lastIndex; } if (last text.length) out.push({ type: text, text: text.slice(last) }); return out; } });两个参数值得盯住scroll-into-view只认子节点的 id挂到内层rich-text上是跳不动的nodes 里的行内样式单位建议用 px用 rpx 时在部分机型上会按节点自身宽度做二次换算排版忽宽忽窄。长表格别塞进 nodes拆成多个view渲染更稳。2.3 规则版本表与「强制重新同意」的判定规则一改就发版的根源是没有版本表。同一agreement_code允许多版本共存只有生效时间已到的那条对外。CREATE TABLE agreement_version ( id BIGINT PRIMARY KEY AUTO_INCREMENT, agreement_code VARCHAR(32) NOT NULL COMMENT service_agreement/trade_rule/merchant_agreement, version VARCHAR(16) NOT NULL COMMENT 语义化版本如 2.3.0, content MEDIUMTEXT NOT NULL COMMENT 条款树 JSON发布后不可修改, effective_at DATETIME NOT NULL COMMENT 生效时间, force_agree TINYINT(1) NOT NULL DEFAULT 0 COMMENT 1需用户重新勾选, published_by VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code_version (agreement_code, version), KEY idx_effective (agreement_code, effective_at) );// 返回当前生效版本并判断是否需要强制重新同意 async function getAgreement(code, userId) { const [cur] await db.query( SELECT version, content, force_agree FROM agreement_version WHERE agreement_code ? AND effective_at NOW() ORDER BY effective_at DESC LIMIT 1, [code]); if (!cur) throw new Error(no_effective_agreement); const [last] await db.query( SELECT version FROM user_agreement_log WHERE user_id ? AND agreement_code ? ORDER BY agreed_at DESC LIMIT 1, [userId, code]); return { version: cur.version, content: JSON.parse(cur.content), // 只有被显式标记为强制的新版本才弹重签错别字修订不该骚扰全量用户 needReAgree: cur.force_agree 1 (!last || last.version ! cur.version) }; }effective_at支持提前发布、到点生效配合缓存刷新任务即可历史版本永不物理删除用户申诉时要能还原「他当时看到的原文」。force_agree是业务开关别默认给 1。2.4 同意留痕那个单选框背后要存什么用户勾选同意只是 UI证据在服务端。view classagree-row radio-group bindchangeonAgreeChange labelradio value1 checked{{agreed}} color#07c160 /我已阅读并同意/label /radio-group navigator url/pages/agreement/detail?codeservice_agreement《服务协议》/navigator navigator url/pages/agreement/detail?codetrade_ruleanchor3.2.1《交易规则》/navigator /view button disabled{{!agreed}} bindtapsubmitAgree提交/buttonsubmitAgree() { wx.request({ url: ${API}/user/agreement/agree, method: POST, data: { items: [ { code: service_agreement, version: this.data.svcVersion }, { code: trade_rule, version: this.data.ruleVersion } ], scene: checkout // 注册/下单/入驻哪个场景触发的同意 }, success: () wx.setStorageSync(agreed_versions, this.data.svcVersion) }); }user_agreement_log至少要落user_id、agreement_code、version、agreed_at服务端NOW()、scene、client_ip服务端取和ua。时间是证据核心不要用客户端时间戳scene决定这次同意能不能覆盖别的场景——入驻商家签的是《商家服务协议》跟消费者协议不是同一份别混用一个 code。本地缓存只用来减少弹窗打扰判重一律以服务端为准。3. 纠纷处理机制工单状态机、时限参数与举证材料3.1 纠纷类型枚举与受理边界受理边界写不清楚工单系统会被「我要投诉」灌满。常见做法是把纠纷收敛成有限枚举每个枚举绑定受理条件和材料清单前端拿它渲染表单后端拿它做校验。type_code场景前置条件必交材料默认处理方refund_not_received已退款未到账存在退款成功流水退款单号截图平台logistics_delay发货超时超过约定发货时限订单号商家quality_issue质量问题签收 7 日内商品实拍图 ≥2 张商家false_description描述不符订单已完成对比图平台after_sale_refused售后被拒有商家拒绝记录沟通记录平台type_code一旦上线就不要改名改名等于历史工单集体失去分类新增类型只增不改。前置条件写在服务端前端那份只用来提示用户否则改个判据就要发版。3.2 状态机白名单流转加乐观更新// dispute-state.js只允许白名单内的流转 const TRANSITIONS { submitted: [accepted, rejected], accepted: [negotiating, platform_intervening, resolved], negotiating: [platform_intervening, resolved, closed], platform_intervening: [resolved, closed], resolved: [closed], rejected: [platform_intervening, closed], // 用户可申请平台复核 closed: [] }; async function transit(ticketId, event, remark, operator) { const [t] await db.query( SELECT status FROM dispute_ticket WHERE id ?, [ticketId]); if (!t) throw new Error(ticket_not_found); if (!TRANSITIONS[t.status].includes(event)) { throw new Error(illegal_transition: ${t.status} - ${event}); } // WHERE 带上原状态两个客服同时点「受理」只会成功一次 const [r] await db.query( UPDATE dispute_ticket SET status ?, updated_at NOW(), last_operator ? WHERE id ? AND status ?, [event, operator, ticketId, t.status]); if (r.affectedRows 0) throw new Error(concurrent_update); await db.query( INSERT INTO dispute_log(ticket_id, from_status, to_status, operator, remark, created_at) VALUES (?,?,?,?,?,NOW()), [ticketId, t.status, event, operator, remark || ]); }关键在UPDATE ... WHERE status 原状态affectedRows为 0 就提示「状态已变更」而不是把别人的操作覆盖掉。dispute_log每次流转写一行用户端的「处理进度」页面直接读它不用再养一张时间线表。超时升级做成幂等 SQL多实例跑也不会重复升级-- 商家 48 小时未响应自动转平台介入 UPDATE dispute_ticket SET status platform_intervening, escalate_reason merchant_timeout, escalated_at NOW() WHERE status accepted AND merchant_deadline NOW() AND escalated_at IS NULL; -- 只升级一次3.3 举证材料上传与本地草稿// 选图上传同时把表单草稿落到本地沙箱避免切后台丢内容 const fs wx.getFileSystemManager(); Page({ data: { images: [], ticketId: 0 }, chooseImages() { wx.chooseMedia({ count: 9 - this.data.images.length, // 单次最多 9 张 mediaType: [image], sizeType: [compressed], // 原图常见 3MB 以上先压再传 sourceType: [album, camera], success: async (res) { const uploaded []; for (const f of res.tempFiles) { const r await wx.uploadFile({ url: ${API}/dispute/evidence, filePath: f.tempFilePath, name: file, formData: { ticketId: this.data.ticketId, type: buyer_evidence } }); uploaded.push(JSON.parse(r.data).fileId); } this.setData({ images: this.data.images.concat(uploaded) }); // 沙箱路径随缓存清理消失只能当草稿 fs.writeFileSync(${wx.env.USER_DATA_PATH}/dispute_draft.json, JSON.stringify({ images: this.data.images }), utf8); } }); } });wx.env.USER_DATA_PATH是当前小程序的沙箱目录适合放这种续填草稿它随小程序卸载和缓存清理消失绝对不能拿来存举证材料——证据留在服务端对象存储库里只存 fileId。wx.uploadFile是并发请求9 张图直接 for 循环容易触发后端限流分批 3 张更稳。formData.type区分买方和商家举证后端按扩展名和 MIME 双重校验防止改名文件被别处直接渲染。3.4 时限参数表与催办通知参数默认值含义超时动作merchant_response_hours48商家首次响应升级平台介入merchant_evidence_hours48商家反举证采信用户主张buyer_evidence_hours72用户补充举证关闭并记录platform_handle_days3工作日平台处理催办并上报主管auto_close_days15双方均无动作自动关闭evidence_keep_years3证据留存归档冷存这些值不要写死在代码里按业务线配置覆盖生鲜和 3C 的响应时限本来就不一样。计算 deadline 用工作日别用自然日否则节假日前后的工单会集体判超时。// 每小时执行一次负责升级与催办多实例部署要加分布式锁 async function tick() { await db.query( UPDATE dispute_ticket SET status platform_intervening, escalate_reason merchant_timeout, escalated_at NOW() WHERE status accepted AND merchant_deadline NOW() AND escalated_at IS NULL); // 距截止不足 12 小时且催办未满 2 次推一条订阅消息 const [soon] await db.query( SELECT id, user_id FROM dispute_ticket WHERE status negotiating AND merchant_deadline BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 12 HOUR) AND remind_count 2); for (const t of soon) await sendSubscribeMsg(t.user_id, t.id); }订阅消息必须由用户主动授权不能自动弹催办链路要准备降级方案用户没授权时退回站内消息否则「升级了但没人知道」会变成常态投诉。4. 入驻经营者的审核要求资质字段、校验规则与审核流4.1 资质字段清单必填项与类目加挂项字段适用主体是否必填校验方式主体类型全部是枚举企业/个体工商户/个人营业执照名称企业、个体是与统一社会信用代码核验结果一致统一社会信用代码企业、个体是18 位校验码算法法定代表人姓名企业是与身份信息核验一致经营者身份证号个体、个人是MOD 11-2 校验经营类目全部是多选决定加挂资质类目资质证件按类目条件必填证件号 有效期结算账户全部是户名与主体一致客服联系方式全部是至少一个可接通主体类型决定了字段的组合必填规则把这张表做成配置驱动比在表单里写一堆wx:if好维护得多。4.2 统一社会信用代码与身份证号的本地预校验// 统一社会信用代码 18 位校验字符集 31 个不含 I O S V Z const CODE_CHARS 0123456789ABCDEFGHJKLMNPQRTUWXY; const WEIGHTS [1,3,9,27,19,26,16,17,20,29,25,13,8,24,10,30,28]; function checkUscc(code) { if (!/^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/.test(code)) return false; let sum 0; for (let i 0; i 17; i) { const idx CODE_CHARS.indexOf(code[i]); if (idx -1) return false; sum idx * WEIGHTS[i]; } const check (31 - (sum % 31)) % 31; // 余数为 0 时校验位取 0 return CODE_CHARS[check] code[17]; } // 身份证号校验ISO 7064 MOD 11-2 function checkIdCard(id) { if (!/^\d{17}[\dXx]$/.test(id)) return false; const w [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2]; const c 10X98765432; let sum 0; for (let i 0; i 17; i) sum Number(id[i]) * w[i]; return c[sum % 11] id[17].toUpperCase(); }这两个函数只解决「格式对不对」作用是让用户在输入框失焦时就发现手抖别等提交后走一轮核验再打回重填。真正的核验要靠主体信息核验服务或人工比对证件原件本地校验不能当审核结论。前端做一次、后端提交接口再做一次只留前端等于给抓包留门。营业执照名称、法人姓名这类文本还要做去空格、全角转半角、括号统一之后再比对否则「」和「(」能卡住一半的自动核验。4.3 审核状态机与结构化驳回原因码// 驳回必须带枚举内的原因码不接受自由文本 const REJECT_REASONS { R001: 营业执照名称与主体不一致, R002: 统一社会信用代码校验未通过, R003: 经营范围不含所申请经营类目, R004: 类目资质证件缺失或已过期, R005: 结算账户户名与主体不一致, R006: 法定代表人身份信息不匹配 }; async function audit(merchantId, action, reasonCode, operator, remark) { if (action reject !REJECT_REASONS[reasonCode]) { throw new Error(reason_code_required); } const next action approve ? approved : rejected; const [r] await db.query( UPDATE merchant_apply SET status ?, reason_code ?, audit_at NOW(), auditor ? WHERE id ? AND status IN (submitted,reviewing,supplement), // 状态守卫 [next, reasonCode || null, operator, merchantId]); if (r.affectedRows 0) throw new Error(status_changed); await db.query( INSERT INTO merchant_audit_log(merchant_id, action, reason_code, operator, remark, created_at) VALUES (?,?,?,?,?,NOW()), [merchantId, action, reasonCode || null, operator, remark || ]); }原因码是枚举用户端就能把 R004 翻成「请补充有效期内的类目资质证件」并直接给出补交通道运营也能按原因码出统计看哪个类目被拒最多。状态守卫写进WHERE通过和驳回同时点只会生效一个。驳回后允许补件重新提交复用同一张申请单不要新建否则同一主体在库里会有多条互相矛盾的记录。4.4 资质有效期管理与周期复核CREATE TABLE merchant_qualification ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_id BIGINT NOT NULL, qual_type VARCHAR(32) NOT NULL COMMENT business_license/food_permit/brand_auth, cert_no VARCHAR(64), file_id VARCHAR(64) NOT NULL COMMENT 对象存储文件 id不存带签名 URL, valid_from DATE, valid_to DATE COMMENT 长期有效填 9999-12-31便于统一比较, audit_status VARCHAR(16) NOT NULL DEFAULT pending, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_merchant_type_no (merchant_id, qual_type, cert_no), KEY idx_expire (valid_to, audit_status) );-- 到期前 30 天提醒补件 SELECT merchant_id, qual_type, valid_to FROM merchant_qualification WHERE audit_status approved AND valid_to BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY);valid_to用 9999-12-31 而不是 NULL比较逻辑就不用写两种分支唯一键用来挡同一张证件重复提交证件原件存对象存储、库里只留 fileId展示时后端签临时链接——把带签名的 URL 存库签名一过期后台就成片裂图。到期后先限制上新和提报活动不要直接关店在途订单还需要处理。5. 上线前的自检与几个具体技巧5.1 一份 JSON 同时产出 docx 和小程序页面链路固定成法务在 docx 上改 → 解析成 clauses.json → 提 PR → 生成新版本记录。反向导出用于给法务确认避免两边文案漂移。// 从 clauses.json 反向生成 docx供法务核对 const { Document, Packer, Paragraph, HeadingLevel } require(docx); const clauses require(./clauses.json); const fs require(fs); const children []; for (const c of clauses) { children.push(new Paragraph({ text: ${c.code} ${c.title}, heading: c.level 2 ? HeadingLevel.HEADING_1 : HeadingLevel.HEADING_2, spacing: { before: 240, after: 120 } })); children.push(new Paragraph({ text: c.content })); // 正文按条输出 } Packer.toBuffer(new Document({ sections: [{ children }] })) .then(b fs.writeFileSync(./rules_export.docx, b));CI 里再加一步对账把 clauses.json 的code集合与上一版比较少一条就报错。条款被删必须显式确认否则很容易在合并分支时把某条规则吃掉。5.2 真机与基础库差异的自查项rich-text 的 nodes 在 iOS 上对嵌套层级更敏感超过三层容易被截断条款正文别做深层嵌套。基础库版本从哪设置开发工具里切「详情 → 本地设置 → 调试基础库」真机走微信自带版本功能要在project.config.json的 libVersion 和后端最低基础库之间取交集再测。wx.uploadFile在弱网下的失败率明显高于wx.request举证上传要带重试和失败文件记录。订阅消息授权不能自动弹催办链路要为未授权用户预留站内消息兜底。scroll-into-view的锚点区分大小写条款号含大写字母时统一转小写再比对。5.3 提审前的对照清单检查项怎么看不通过的典型表现服务类目与经营内容一致小程序后台类目配置类目选错提审被打回协议入口首屏可达真机走注册和下单只藏在「我的-设置」深处同意记录可回溯查 user_agreement_log只存了本地缓存条款锚点能跳转订单页点「交易规则 3.2.1」跳过去停在文首举证上传有限制传 10MB 图和文档文件后端直接 413资质到期会提醒把一条 valid_to 改成昨天没有扫描任务无人发现测试账号可登录提审时填进审核备注审核员登不进去把 5.3 这张表做成一个脚本每次发版前跑一遍比记住这些条目靠谱得多。本文还有配套的精品资源点击获取