这是什么
一个完整做下来、能提交报名的招生小程序:活动有会期、道场、名额、截止时间,五门课业任选一门;用户用微信登录,填七项资料、传一张形象照,提交后进「我的报名」看审核状态,也能取消。
这只是一个练习作品。
一 · 四页与一条主路径
三个 tab 页加一个跳转页,没有第五条路。每页只做一件事,页面之间不互相兜底——一处取不到数据,就只在这一页显示失败态。
| 页面 | 职责 | 出问题会怎样 |
|---|---|---|
首页 index | 山门封面、活动主数据、名额与倒计时、山中四事、五门课业、四步流程 | 取不到活动就显示「山门暂时未开,请稍后再试」,不白屏 |
详情 detail | 缘起、七日日程、课业门类、报名须知、行前信息 | 同上;已报过的人底部按钮直接变「已报名 · 查看记录」 |
报名 enroll | 登录、七项表单校验、形象照上传、重复报名拦截 | 未登录先出登录卡;校验不过就停在原地,不提交半份数据 |
我的报名 records | 本人记录、状态、取消 | 没有记录走空态;取消只把状态置 canceled,不删行 |
主路径只有一条,从登录穿到入库:
wx.login(静默,不弹授权框)
↓
activities(slug = qingcheng-2026-autumn,公开只读)
↓
报名表单:姓名 / 手机 / 性别 / 年龄 / 城市 / 课业 / 自述
↓
形象照 → 云存储 users/<uid>/enrollments/…(库里只存对象路径)
↓
enrollments 插入(owner_id 由库默认值写,前端不传)
↓
activity_enrollment_count(id) 只回一个数字登录只在需要的地方发生:app.js 启动就试一次静默登录,失败也不拦人看首页——活动信息是公开只读的,没必要为了逛一逛先要授权。真正要身份的是报名与记录两页,那里才出「微信一键登录」。
表单校验放在提交前一次性做完,不在输入时逐字拦截:姓名 2–20 字、手机号须是 11 位且 1 开头、年龄 16–99、必须选一门课业、城市不能空。形象照是选填,5MB 上限。
二 · 数据与权限
两张表就够,一条聚合函数就够。核心判断是:前端不承担任何权限职责,它拿到什么全看数据库放什么。
| 对象 | 内容 | 访问规则 |
|---|---|---|
activities | 活动主数据:标题、副题、会期、道场、名额、截止时间、日程、课业、须知 | 公开只读,策略 activities_read,未登录也能读 |
enrollments | 报名行:申请人、手机、性别、年龄、城市、课业、自述、照片路径、状态 | 本人读写,四条 owner 策略;UNIQUE(activity_id, owner_id) 防重复报名 |
activity_enrollment_count(id) | 已报名人数,只返回一个整数 | SECURITY DEFINER,先 REVOKE PUBLIC 再只授给 anon / authenticated |
名额那个数字是唯一的例外:首页要显示「还剩几席」,但如果前端能直接 select * 报名表,这个数字就等于把所有人的手机号一起交出去。所以人数走函数——函数内部才碰表,出口只有一个整数。
RLS 是唯一防线,不是第二道:owner_id 由数据库默认值写入,前端连字段都不传;「我的报名」天然只回本人那几行。就算绕过小程序直接打 API,能读到的也只有自己那条。反过来说,策略写错就等于公开,这里没有中间地带。
| enrollments 字段 | 说明 |
|---|---|
| activity_id | 外键指向 activities |
| owner_id | 默认值 auth.uid(),前端不传 |
| applicant_name / phone | 核验身份用,手机号在「我的报名」里打码显示 |
| gender / age / region | 可空的画像字段,年龄不填也能提交 |
| track | 五门课业之一,决定分坛 |
| self_intro | 选填,长老看这一栏 |
| photo_path | 云存储对象路径,不是可访问链接 |
| status | pending / approved / rejected / canceled |
| created_at | 提交时间,列表按它倒序 |
照片同理:上传后库里只留路径,展示时再换一个限时签名地址。对象桶不是公开读的,路径拿到手也打不开。
三 · 图是怎么来的
山门图、四张「山中四事」底纹、五门课业符号,全部由本地 ComfyUI 的 Z-Image Turbo 出图,符号再经 BiRefNet 去背成 108×108 透明 PNG。没有一张是从素材站找的,也没有用代码画几何图形凑。
颜色只能靠正向词收:Z-Image 没有负向提示词。第一版山门图正向里写了 jade-green,出来是一片亮翡翠绿——想压成水墨青灰,只能把 muted slate blue-grey、charcoal ink、qinglu shanshan 这类词写进正向,靠「缺少负向」是压不住的。
小程序 image 组件没有 object-position:四张底纹原图是 2:1,卡片框是 1.25:1。Web 那边可以 cover + 居底把树留着,小程序里没法指定,只能提前把图裁成卡片比例,四张统一 1.25:1 后用 aspectFill 铺满,零裁切。先算比例再改样式,能省掉一轮重画。
符号这条链是三段:出图(768²、统一「古金单色线描、纯白底」)→ 去背(BiRefNet,2–4 秒一张)→ 归一化(取 alpha 边界框,居中贴到 86% 画布,再 LANCZOS 缩到 108×108)。最后一步不是审美调整,是让五门符号在列表里视觉大小一致。
四 · 发布与包体
源码(4 页 + api/utils + assets)
↓
云服务开通:PostgreSQL + 对象存储 + 微信登录
↓
建表建策略(串行执行)
↓
miniprogram_npm 预置 SDK
↓
上传 → 体验版 → 提审 → 发布| 环节 | 关键点 |
|---|---|
| 云数据库 | 建表必须串行:enrollments 带外键,和 activities 并发送会报 DATABASE_42P01 |
| SDK | 把 miniprogram_dist/ 里的 SDK 直接放进 miniprogram_npm/,等于手工完成一次「构建 npm」,不装开发者工具也能发布 |
| tabBar 图标 | Pillow 按 324px 设计稿画完降采样到 81px;坐标空间必须和画布尺寸一致,否则画出画布外、只剩一张空图 |
| 包体 | 470KB,其中图片 204KB;所有照片走云存储,不进包 |
失败会怎样
- 活动数据取不到:首页与详情各自显示失败文案并有「重新加载」,其余 tab 仍可用。
- 静默登录失败:只写一条日志,不影响浏览;报名和记录页会出登录按钮。
- 重复报名:唯一约束报
23505,前端翻译成「你已提交过本期报名」并刷新已有记录,不是一句数据库错误。 - 截止或满额:提交前先判断倒计时与剩余名额,不让用户填完整张表才被拒。
- 照片上传失败:提示可以直接跳过,
photo_path允许为空,不卡住报名。 - 时区:所有日期统一按东八区格式化,避免真机时区不同导致「截止日差一天」。
常见问题
为什么报名表不让前端传 owner_id?
一旦前端能指定归属,改个请求就能替别人报名、或者把记录写到别人名下。交给数据库默认值写 auth.uid(),前端连这个字段都不出现,越权入口从根上没有。
报名人数为什么要单独写个函数?
直接查 enrollments 的 count 需要给对方读全表的权限,等于公开所有人的手机号。函数用 SECURITY DEFINER 在库内部聚合,只返回整数;先 REVOKE PUBLIC 再显式授权,避免默认继承来的调用权。
底纹为什么不能像网页那样「居中偏下裁」?
小程序 image 组件只支持几种 mode,没有 object-position。aspectFill 一定居中裁,想要「留树梢」只能把图先裁成目标比例。这也是为什么四张底纹进包前都过了一遍裁切,而不是在样式里调。
真机没跑过,这算做完了吗?
静态这一层是做完了:全部 JS 过 node --check,JSON 可解析,页面与资源引用齐备,WXML 标签配对平衡。但编译、真机登录、报名写入回读这三件必须在小程序环境里才能验,本机跑不了。所以状态是「代码完结、待真机核验」,不是「已上线」。
包体为什么只有 470KB?
图片全部重新压过:四张底纹共 72KB,五门符号 4.9–13KB 一个,tabBar 图标是 81px 小图。用户上传的照片根本不进包——它走云存储,包里只有一条路径。
更新记录
- v1.0 — 四页 + 微信登录 + 云数据库与云存储打通,报名可提交、可查、可取消
- v0.9 — 五门课业符号接入页面,未登记的课业回落成菱形点,不破图
- v0.8 — 四事卡片铺上图,底纹统一预裁 1.25:1
- v0.5 — 课业由「御剑」改为「打坐」,与「剑道」不再重复