产品 · 2026

青城山招生小程序

四个页面走完一次招生报名:山门看日程与名额,填表、传形象照,提交入库,再回来查自己那一条。微信登录 + 云数据库 + 云存储,谁能看到哪一行由 RLS 决定,不由前端决定。

v1.0 自用 微信小程序 PostgreSQL

这是什么

一个完整做下来、能提交报名的招生小程序:活动有会期、道场、名额、截止时间,五门课业任选一门;用户用微信登录,填七项资料、传一张形象照,提交后进「我的报名」看审核状态,也能取消。

这只是一个练习作品。

小程序首页:山门封面、名额统计、山中四事、五门课业
首页:山门 + 名额与倒计时 + 山中四事 + 五门课业,一屏之内把「要不要去」讲完。

一 · 四页与一条主路径

三个 tab 页加一个跳转页,没有第五条路。每页只做一件事,页面之间不互相兜底——一处取不到数据,就只在这一页显示失败态。

页面职责出问题会怎样
首页 index山门封面、活动主数据、名额与倒计时、山中四事、五门课业、四步流程取不到活动就显示「山门暂时未开,请稍后再试」,不白屏
详情 detail缘起、七日日程、课业门类、报名须知、行前信息同上;已报过的人底部按钮直接变「已报名 · 查看记录」
报名 enroll登录、七项表单校验、形象照上传、重复报名拦截未登录先出登录卡;校验不过就停在原地,不提交半份数据
我的报名 records本人记录、状态、取消没有记录走空态;取消只把状态置 canceled,不删行

主路径只有一条,从登录穿到入库:

text
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云存储对象路径,不是可访问链接
statuspending / 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)。最后一步不是审美调整,是让五门符号在列表里视觉大小一致。

四 · 发布与包体

text
源码(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 — 课业由「御剑」改为「打坐」,与「剑道」不再重复