电商小程序模板合规开发:规则库、纠纷状态机与入驻审核
2026/9/17 12:56:27 网站建设 项目流程

简介:面向微信小程序电商平台开发者、运营方及法务合规人员的协议与规则模板,聚焦平台服务协议、交易规则、入驻经营者审核要求以及用户纠纷处理机制,可用于搭建小程序商城时快速起草合规文本、内部评审与风险排查。压缩包仅含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 class="doc"> <view wx:for="{{clauses}}" wx:key="code" id="clause-{{item.code}}" class="clause level-{{item.level}}"> <view class="clause-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) })) }); // 订单页带 ?anchor=3.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 class="agree-row"> <radio-group bindchange="onAgreeChange"> <label><radio value="1" checked="{{agreed}}" color="#07c160" />我已阅读并同意</label> </radio-group> <navigator url="/pages/agreement/detail?code=service_agreement">《服务协议》</navigator> <navigator url="/pages/agreement/detail?code=trade_rule&anchor=3.2.1">《交易规则》</navigator> </view> <button disabled="{{!agreed}}" bindtap="submitAgree">提交</button>
submitAgree() { 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_idagreement_codeversionagreed_at(服务端NOW())、sceneclient_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 这张表做成一个脚本,每次发版前跑一遍,比记住这些条目靠谱得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询