一个人三天做完全栈 App:Flutter + NestJS 起手式
客户端、后端、登录体系、多环境、真机验证、双平台上架的完整骨架。含 Claude Code 自己截图验收的闭环,以及十几个不报错但会静默出错的坑。
方案亮点
一套代码两端
iOS 和 Android 共用同一份 Dart 代码,适配工作只剩下平台差异那几处。这是单人做全栈最大的一笔节省。
AI 能自己验收
Android 侧装机、启动、点击、截图、读日志全在命令行里,截图直接是 PNG 流,AI 能自己确认页面真的出来了,而不只是「构建成功」。
框架约束替代 review
模块边界、DTO 校验、鉴权守卫都是 NestJS 强制的。一个人写代码没有第二双眼睛时,这层约束很值钱。
类型从 schema 生成
Prisma schema 是唯一真源,数据库迁移和 TypeScript 类型都从它出,少一份要手动同步的东西。
三套环境一次配好
代码里不出现任何环境判断,行为全由配置决定,因此任一环境都能验证「只在生产才生效」的逻辑。
含双平台上架清单
账号周期、权限声明、四条会卡住提交的审核规则,都在文里,不用交了才发现缺东西。
- 适用场景
- 一个人或很小的团队从零做一个带账号体系的移动 App,需要同时交付 iOS、Android 和后端。不适合纯前端项目,也不适合已有成熟后端只补客户端的情况。
- 起步耗时
- 骨架一天,登录体系一天,多环境与真机验证一天
动手前要准备
- macOS + Xcode(iOS 构建的硬性要求,Windows 做不了)
- Flutter SDK、Node.js、Docker(本地跑 PostgreSQL)
- Google Cloud 项目,用于 OAuth 客户端
- 一台 Android 真机;iOS 真机需要 Apple 开发者账号(免费个人账号可临时签名)
技术栈
| 客户端 | Flutter | 一套 Dart 代码出 iOS 与 Android;一个人做全栈时,省掉的是一整个平台的工作量 |
| 后端 | NestJS | 模块化 + 依赖注入 + 装饰器,结构强约束;DTO 校验和守卫是这套架构的主要收益 |
| ORM | Prisma | schema 即真源,迁移和类型都从它生成 |
| 数据库 | PostgreSQL | 本地用 Docker 起,时间列一律 timestamptz |
| 鉴权 | JWT + Google 登录 | google-auth-library 验 idToken;userId 只从 token 来,永不从请求体来 |
| 两步验证 | TOTP(otplib) | 含恢复码;挑战 token 与会话 token 同密钥签,必须显式区分 |
| 邮件 | MailSender 抽象 | 供应商未定也能开工;生产环境选 console 实现直接拒绝启动 |
| 配置 | dart-define-from-file / .env.* | 三套环境,编译期注入;代码里不出现任何环境判断 |
| 内网穿透 | Cloudflare Tunnel | 真机连本机后端;两个默认参数必须改,否则连不通还不报错 |
| 开发 | Claude Code | 需求分析、架构设计、编码到装机截图验收全流程;关键是让它自己看到结果 |
这套模板解决什么问题
一个人要交付一个带账号体系的 App,意味着同时是客户端开发、后端开发、 运维和测试。三天做完的前提不是打字快,而是不在已知的坑上重复浪费时间。
这篇记录的就是那些坑。它们有个共同特征:绝大多数不会报错。 构建成功、接口返回 200、页面正常渲染,数据却是错的,或者包里装的是上一次的配置。
业务细节已剥离
原项目是给客户做的商业项目,这里只保留与业务无关的架构、配置和踩坑记录。 所有包名、密钥、域名都替换成了占位值。
为什么是这套组合
| 位置 | 选择 | 一个人做全栈时的理由 |
|---|---|---|
| 客户端 | Flutter | 一套代码两个平台。这是单人项目最大的一笔节省 |
| 后端 | NestJS | 模块边界、DTO 校验、守卫都是框架强制的。一个人写代码时,框架的约束替代了 code review |
| 数据库 | PostgreSQL + Prisma | schema 即真源,迁移和 TypeScript 类型都从它生成,少一份要手动同步的东西 |
NestJS 不是 Next.js
两个完全不同的框架,名字像但没有关系。NestJS 是 Node 后端框架(Angular 风格的 模块与依赖注入),Next.js 是 React 全栈框架。搜资料时别搜错。
仓库结构:三个并排的仓库,各带一份 CLAUDE.md
project/
├── api/ NestJS + Prisma + PostgreSQL → api/CLAUDE.md
├── app/ Flutter 客户端 → app/CLAUDE.md
└── docs/ 设计文档与待确认问题 → docs/CLAUDE.md
三个独立仓库并排 clone 成同一个父目录,文档里的 ../api/...、../app/... 按此解析。
为什么不用 monorepo:客户端和后端的构建工具链完全不重叠(Flutter/Gradle/Xcode vs npm/Prisma),放一起没有共享收益,反而让 CI 和权限都变复杂。
跨端不变量:两边各存一份,标记 双
有些规则横跨客户端和服务端,任何一边单独理解都不完整。比如幂等键的格式: 生成在客户端,去重后果由服务端承担。
做法是这些条目在两份 CLAUDE.md 里各写一份,标上 [双],改动时两边一起改。
看起来是冗余,实际是刻意的:单人项目里没有第二个人提醒你「对面也要改」, 而 AI 一次只读一个仓库的上下文。把跨端约束写进两边,是让它在任一侧都能看见。
三套环境
这是整套配置里最容易出错、也最值得一开始就做对的部分。
| 环境 | 用途 | App flavor | 后端配置 |
|---|---|---|---|
| dev | 开发者本机 | dev | .env.development |
| staging | 客户验收、内部测试 | staging | .env.staging |
| prod | 商店版本 | prod | .env.production |
构建参数写进文件,不要手敲
flutter build apk --flavor dev --dart-define-from-file=env/dev.json
flutter build ios --flavor staging --dart-define-from-file=env/staging.json
flutter build ios --flavor prod --dart-define-from-file=env/prod.json
env/*.json 含密钥,gitignore 掉;env/example.json 是提交的模板:
{
"_comment": "复制成 dev/staging/prod.json 再填。那三个含密钥,已 gitignore。",
"APP_ENV": "dev | staging | production",
"API_BASE_URL": "https://api.example.com",
"ALLOW_SERVER_OVERRIDE": "dev/staging 为 true,production 必须 false"
}
手敲 --dart-define 迟早会漏一个。漏了之后表现是某个入口不显示,
而不是报错——排查成本远高于写文件。
flavor 和配置文件配错时要当场拦下
--flavor prod 配 env/dev.json 是完全合法的命令,构建会成功。
所以客户端启动时要自己比对:编译进来的 APP_ENV 和 flavor 名不一致,
直接显示一个红色错误页,而不是照常进主界面。
代码里不出现环境判断
服务端只读 src/config/env.ts 暴露的配置,客户端只读 lib/config.dart 的编译期常量。
任何「只在生产才生效」的行为都由配置值决定,因此可以在任一环境验证。
写 if (env === 'production') 的那一刻,这段逻辑就再也没法在开发环境测了。
配置不合格就拒绝启动
生产环境缺少或过短的 JWT_SECRET、缺 OAuth 客户端 ID、邮件供应商还是 console——
服务直接退出并说明原因。
宁可起不来,也不要用源码里的默认密钥签发 token 却看起来一切正常。
配套的规矩是:服务里不允许直读 process.env,全部经过 config/env.ts。
打错的变量名要在启动时报错,而不是运行时静默降级——第三方登录曾经就是这样悄悄失效的。
包名策略:先共用,但结构留好
三套 flavor 一开始共用同一个包名。原因是 Google OAuth 客户端按 「包名 + 签名指纹」匹配,给每个环境不同包名意味着三套都要各自注册 OAuth 客户端, 而正式包名往往还没定,等于要改两次。
但结构要提前就位:android/app/build.gradle.kts 的 productFlavors 里每个 flavor
都显式写 applicationId,iOS 每个 flavor 有独立的 build configuration。
定下正式包名后只改这几行值。
改包名要同步的地方比想象中多
漏一个就有功能静默失效。至少包括:
- Android OAuth 客户端(包名 + SHA-1,release 签名的 SHA-1 要单独注册)
- iOS OAuth 客户端与
Info.plist里的 URL Scheme - 后端的 Apple/Google 客户端 ID 白名单
- 任何以包名作为 User-Agent 或标识的第三方 SDK
- 商店后台的应用记录
把这份清单写进 docs,改包名那天照着过一遍。
登录体系:一个邮箱一个账号
这是整个后端最需要想清楚的部分,而且返工代价极高——账号一旦分裂, 用户数据就分裂了。
身份是邮箱,登录方式挂在旁边
users 账号本体,唯一键是邮箱
auth_identities 登录方式:google / apple / password,都指向同一个 user
用 Google 登录之后,同一个邮箱再设密码,必须落在同一个账号。 两个账号意味着用户数据分裂。
每条写入邮箱的路径都必须先验证
这是防 pre-account takeover 的唯一依据:
- 密码注册 → 收邮箱验证码
- 社交登录 → 用 provider 返回的
email_verified
任何一条路径去掉验证,攻击者就能先用你的邮箱占坑,等你之后用真账号登录时被合并进去。
userId 只来自 token,永不来自请求体
req.userId 由守卫写入,DTO 里没有 userId 字段。
客户端能指定 userId,就等于任何人都能操作任何人的数据。这条没有例外。
两步验证的挑战 token 必须被守卫拒绝
开了两步验证后,密码登录第一步返回的不是会话 token,而是一个带 purpose
的挑战 token。它和会话 token 用同一把密钥签。
所以鉴权守卫必须显式拒绝任何带 purpose 的 token——不拒绝的话,
第一步拿到的 token 就能直接访问业务接口,等于两步验证不存在。
这类漏洞的特征是:功能测试全部通过(能登录、能验证、能拒绝错误验证码), 只有专门去试「拿第一步的 token 直接调接口」才会发现。
邮件:供应商没定也能先把功能做完
真实项目里,发信服务往往要等客户开账号、等 DNS 权限配 SPF/DKIM/DMARC—— 那是以天计的等待。但注册流程不能等。
解法是一个抽象类加一个占位实现:
export abstract class MailSender {
abstract sendVerificationCode(email: VerificationEmail): Promise<void>
/** 这个实现能不能真的送到收件箱 */
abstract get canDeliver(): boolean
}
/** 供应商配好之前的替身:把验证码打到日志,而不是发出去 */
@Injectable()
export class ConsoleMailSender extends MailSender {
get canDeliver() { return false }
async sendVerificationCode(email: VerificationEmail) {
this.logger.warn(
`[未配置邮件供应商] ${email.to} 的验证码(${email.purpose}):${email.code}`,
)
}
}
之所以可以这么抽象,是因为候选项之间是真的可替换:Resend、SES、Brevo、 Mailgun、Postmark 都只要三样东西——收件人、标题、正文。选定之后只改这一个文件。
占位实现必须吵闹
打到日志的验证码,等于任何能看日志的人都能用。所以生产环境选 console 实现时 直接拒绝启动,而不是「先降级跑着」。
同理,测试环境固定验证码 000000 也只能由配置开启,绝不能是代码里的
if (env !== 'production')。
真正慢的从来不是写代码,是发信域名的 DNS 记录生效——那需要域名权限,不是编程问题。 所以域名要在第一天就去申请。
Claude Code 怎么参与:让它自己看到结果
这是三天能做完的关键。不是「让 AI 写代码」,是让它自己完成验证闭环。
需求与架构也交给它,但结论要落盘
讨论出的结论写进 docs/,不要只留在会话里。需要客户拍板的问题单独记一份清单。
理由很实际:会话上下文会丢,而下一次它读的是仓库里的文件。 写进 docs 的结论才是它下次能看见的结论。
装机 → 操作 → 截图 → 读日志,全在命令行里
Android 侧这个闭环是完整的:
adb install -r build/app/outputs/flutter-apk/app-debug.apk
adb shell am force-stop com.example.myapp
adb shell am start -n com.example.myapp/.MainActivity
adb shell input tap 540 950 # 点击坐标
adb exec-out screencap -p # 截图,直接是 PNG 流
adb logcat -d -s MYAPP_TAG # 只看自己的日志标签
adb exec-out screencap -p 把 PNG 输出到 stdout,AI 可以直接读到这张图。
这意味着它能自己确认「登录页真的显示出来了」,而不是只确认「构建成功了」。
把这些命令加进 .claude/settings.local.json 的 allow 列表,
整个循环就不需要人工确认了:
{
"permissions": {
"allow": [
"Bash(flutter build *)",
"Bash(flutter analyze *)",
"Bash(flutter test *)",
"Bash(adb install *)",
"Bash(adb shell *)",
"Bash(adb exec-out *)",
"Bash(adb logcat *)",
"Bash(npx prisma *)",
"Bash(docker compose *)"
]
}
}
iOS 真机截不到图
iOS 18 上 idevicescreenshot 报 Invalid service,devicectl 没有 screenshot
子命令。iOS 真机的视觉确认只能靠人看屏幕。
模拟器可以(xcrun simctl io booted screenshot),但模拟器测不了健康数据、
推送这类依赖真机的能力。
这条直接影响排期:iOS 那部分的验收,人必须在场。
验证要查库,不能只看接口返回 200
一次真实的教训:客户端每个数据点多发了一个字段,服务端 DTO 未声明,
被 forbidNonWhitelisted 拦下报 400。修好之后才发现——此前那批「验证通过」的记录,
关键列全是 null。当时只验到了「没报错」。
所以涉及写入的功能,验收标准是查数据库确认数据真的落地,不是接口返回 200。
十个不报错的坑
一、Flutter 增量构建可能不识别 --dart-define 的变化
改了 API_BASE_URL 重新构建,脚本打印新地址、构建成功,包里仍是旧地址,
无任何警告。flutter clean 之后才正确。
同样适用于所有 dart-define 参数——prod 包可能悄悄继承 staging 的配置。
解法是构建脚本自己校验产物:
# 解包比对,确认包里真的含有这次传入的地址
found=$(unzip -p "$OUT" 'lib/*/libapp.so' | strings | grep -cF "$API_BASE_URL" || true)
if [[ "$found" -eq 0 ]]; then
echo "ERROR: 产物里没有 $API_BASE_URL,构建复用了旧产物。" >&2
echo "先跑 flutter clean 再构建。" >&2
rm -f "$OUT" # 删掉,避免有人拿去用
exit 1
fi
注意 grep -c 而不是 grep -q:-q 匹配到就早退,上游会收到 SIGPIPE,
配合 set -o pipefail 反而把「匹配成功」判成失败。
上架包通常不走这个脚本,手敲命令时要么自己验,要么先 flutter clean。
二、真机连本机后端:三个陷阱
- 用局域网 IP,
--dart-define=API_BASE_URL=http://192.168.x.x:3000 - 手机开全局代理会挡掉局域网请求,要在代理里开「绕过局域网」
- 不要把
adb reverse放进数据通路。无线 adb 频繁重连会清掉端口转发, 表现是 App 突然连不上,且看不出原因
需要外网访问(比如给客户看)时用 Cloudflare Tunnel,但有两个默认值必须改:
cloudflared tunnel --url http://localhost:3000 \
--edge-ip-version 4 \ # 默认选 IPv6 边缘,很多网络不通,表现为 Cloudflare 1033
--protocol http2 # 默认 QUIC 走 UDP 7844,常被拦
地址打印出来不等于隧道能用
两个参数没加时,隧道地址照样正常打印。判断标准只有一个:
curl 一下健康检查接口,看返回的是不是你的服务。
三、跨仓库传递的信息断了不会报错
隧道地址写在 ~/.myapp/tunnel-url,写的人是后端仓库的脚本,读的人是客户端仓库的
构建脚本——两个仓库没有共同的根。
这条链路断掉时:构建脚本读不到就退回局域网 IP 照常出包,包看着完全正常, 但出了这个 WiFi 就连不上。
凡是跨仓库、跨进程传递的约定,都要问一句:断了会怎样? 答案是「静默降级」的,就要加显式检查。
四、iOS 签名:免费账号能跑通,但有三个限制
免费个人 Apple ID 可以签名装到自己的真机上,够开发用。但:
- 证书 7 天过期,到期后 App 直接打不开,重新构建即可
- 部分 entitlement 签不了(比如 Sign in with Apple),要先注释掉, 拿到付费账号再恢复
- bundle ID 必须换才能上架,而换 bundle ID 又要重建 OAuth 客户端
装机不需要打开 Xcode 界面:
flutter build ios --release --flavor staging --dart-define-from-file=env/staging.json
xcrun devicectl device install app --device <UDID> "build/ios/iphoneos/Runner.app"
xcrun devicectl device process launch --device <UDID> com.example.myapp
但第一次必须打开 Xcode:配置 Team、勾选 Automatically manage signing、 让它生成描述文件。之后才能全命令行。
排错经验:报「No profiles for ... were found」时,先看日志里有没有
Unable to log in with account ... rejected——描述文件找不到通常只是
Xcode 账号登录失效的后果,不是 bundle ID 的问题。
另外确认 xcode-select -p 指向的是完整 Xcode 而不是 Command Line Tools:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
五、依赖版本要锁,而且要写清楚为什么
Flutter 生态的版本冲突常常是间接的:升级 A 会让 pub 悄悄把 B 降级。
所以锁版本时在 README 里写清理由,否则半年后没人知道能不能升:
# 往上过某个版本会用到当前 Xcode 没有的 API
device_info_plus: 12.1.0
# 更高版本要求 compileSdk 37,当前 AGP 不支持
permission_handler: ^12.0.0
还有一类坑是新版 SDK 的 API 和网上教程完全不同。比如 google_sign_in 7.x
改成了 GoogleSignIn.instance + initialize,搜到的绝大多数示例都是旧写法。
遇到「照着教程写却编译不过」,先看装的是哪个大版本。
六、OAuth 的「Web 客户端」和网页无关
Google OAuth 里那个「Web 应用」类型的客户端 ID,在移动端场景里的作用是
serverClientId——它决定 idToken 的 audience,服务端据此验签。
Android 客户端 ID 反而不出现在代码里,靠包名 + SHA-1 匹配。
⚠️ release 签名的 SHA-1 必须另外注册,否则 debug 包登录正常、release 包必失败。
七、全链路 UTC,时区只在查询时投影
存储层永远是 UTC 瞬间。「哪一天」是读取时的投影,不是写入时的决定。
- 服务入口第一行设
process.env.TZ = 'UTC',在任何模块加载之前 - 所有时间列用
timestamptz - 按天聚合时时区作为绑定参数传入,不写死
曾经的现象:App 显示的日汇总和系统健康数据对不上。数据没错, 一个是 UTC 日、一个是本地日。修法是给查询加时区参数,不是改存储。
八、幂等键要带上所有会区分来源的维度
上报类接口都需要幂等键。关键是:任何两条本该共存的记录,键必须不同。
一开始容易只用「设备 + 时间窗口」,然后发现同一设备上有多个数据来源, 两者撞键,后到的那条被静默丢弃——结果取决于谁先上报。
而且去重通常是静默的(比如 Prisma 的 skipDuplicates),键错了不报错,只少数据。
配套的一条:只上报已经结束的时间窗口。上报进行中的窗口会先写入一个残缺值, 之后完整数据因键相同被忽略,那部分数据永久丢失。
九、客户端和服务端的 DTO 要能互相验红
改了客户端的 toJson() 而忘了改服务端 DTO,是单人全栈最常见的失误。
做法:服务端 DTO 的测试样例照着客户端的 toJson() 输出写。
这样任何一边单独改动,测试直接变红。
两个仓库分开提交时这点更容易漏——DTO 相关的改动,在提交信息里点名对侧。
十、Prisma 在 Alpine 缺 OpenSSL
node:22-alpine 不带 OpenSSL,Prisma 会误判并加载 1.1 版引擎,启动即崩
(libssl.so.1.1: No such file)。Dockerfile 里 apk add openssl 即可。
只有打成镜像才会暴露,本机跑不出来。
上架:账号、权限、审核
这一节的内容不由你的代码决定,由平台决定。它们的共同特点是周期长、无法加速, 而且多数在写第一行代码之前就该启动。
一、两个平台账号
| Apple Developer Program | Google Play Console | |
|---|---|---|
| 费用 | US$99 / 年 | US$25 一次性 |
| 周期 | 个人 1-2 天;公司主体要 D-U-N-S 编号,可能两周以上 | 注册后需身份验证,数天 |
| 卡住什么 | iOS 分发、TestFlight、Apple 登录 | Android 分发、内购 |
D-U-N-S 那条最容易被低估,它是邓白氏给企业发的编号,申请本身就要几天到两周, 而且完全没有加速通道。公司主体上架 iOS,这是第一件要办的事。
要做内购的话,Google Play 还要单独开通商家账号(Play Console → 财务), 审核又是几天。
二、包名:上架前必须定死,之后永远不能改
模板生成的 com.example.* 前缀,Google Play 直接拒收——不是政策建议,
是上传时就被挡下。iOS 那边虽然能传,但同样必须换成自有域名的反写。
包名一旦上架就是终身的
换包名等于发布一个新 App:用户、评分、下载量全部归零。所以它必须在 第一次上传之前定下来,而不是「先上了再说」。
要同步改的位置见前面「包名策略」那节的清单,这里不重复。只强调其中最容易漏的一条: release 签名的 SHA-1 必须单独注册到 OAuth 客户端。debug 包登录一切正常, release 包必失败——而这个差异只有打了正式包才会暴露,很容易拖到提交当天才发现。
三、权限:每一条都要能解释清楚
原则只有一条:声明了就要用得上,用不上就删掉。 多余的敏感权限不会让功能更强, 只会让审核更难过。
Android 侧一个典型的移动 App 会用到:
| 权限 | 用途 | 审核成本 |
|---|---|---|
INTERNET | 联网 | 无 |
ACTIVITY_RECOGNITION | 读取活动/计步类数据 | 需要醒目披露 |
ACCESS_FINE_LOCATION / COARSE | 定位 | 需要醒目披露 |
FOREGROUND_SERVICE / _LOCATION | 锁屏后继续记录 | 需声明 service type |
WAKE_LOCK | 同上 | 无 |
| 健康数据类 | 读取第三方健康平台 | 要提交声明表走人工审核 |
iOS 侧对应的是 Info.plist 里的几条 *UsageDescription,以及
UIBackgroundModes。
几条从踩坑里换来的规矩:
不要为了省事加 ACCESS_BACKGROUND_LOCATION。 后台定位要单独填表、录演示视频、
走人工审核,周期长且容易被拒。绝大多数「锁屏后继续记录」的需求,用前台服务
(用户主动开始、有常驻通知)就能满足,不需要那个权限。真要加,先给排期算上两周。
Android 14+ 的前台服务必须声明 foregroundServiceType。 注意这个 <service>
可能根本不在你自己的 manifest 里,而是由插件的 manifest 合并进来的。所以要验
合并产物,不能只看自己写的那份:
# 任务名和产物路径都含 flavor,按你实际的 flavor 替换 stagingDebug
cd android && ./gradlew -q :app:processStagingDebugMainManifest
grep -oE 'android:name="android\.permission\.[A-Z_.a-z]*"' \
../build/app/intermediates/merged_manifest/stagingDebug/*/AndroidManifest.xml \
| sort -u
核对权限要按元素,不要按文本
直接 grep 权限名 会把注释里提到的名字也算进去,造成假阳性——
「明明删了却还在」和「明明没加却查到」都是这么来的。
按 android:name="..." 这个属性形态去匹配。
升级任何带 manifest 的插件之后要重新验一遍:那些声明不由你控制。
敏感权限要在系统弹窗之前先解释。 两家平台都要求:请求活动识别、位置这类权限前, 先用 App 自己的界面说明用途,再触发系统弹窗。直接弹系统框是拒审理由。
权限文案必须是用户界面的语言。 一个真实教训:四条 *UsageDescription 全写成了
英文,而 App 界面是中文。这段文字是用户决定给不给权限时唯一的依据,
也是苹果会专门看的地方。
iOS 上查不到健康类读权限有没有被授予。 相关 API 永远返回 null——苹果故意不透露, 否则能靠它探测用户是否有某类数据。所以不要写「检测到未授权就提示用户」这种逻辑, 它判断不出来。直接发起请求,由系统决定弹不弹。
⚠️ 还有一条 iOS 特有的:用户拒绝后再次请求不会再弹,苹果只问一次。 唯一补救是引导用户去系统设置手动开,所以相关页面要准备好「打开系统设置」的入口。
四、两家都会卡住你的几条规则
有第三方登录就必须提供 Apple 登录(苹果审核指南 4.8)。接了 Google 登录, Apple 登录就不是可选项。
entitlement 被注释时,按钮照样会渲染
免费个人开发者账号签不了 Sign in with Apple 这类能力,开发期只能把 entitlement 注释掉。但代码里的按钮不受影响,照常显示,点下去才失败—— 系统检查的是 entitlement,不是代码。
拿到付费账号后要做三件事,缺一不可:取消注释、在开发者后台为该 App ID 开启 这项能力、后端填上客户端 ID。只改 entitlement 文件不够,运行时会报 error 1000。
必须提供应用内注销账户(苹果 5.1.1(v))。有注册功能就必须能在 App 内删除账户, 给个客服邮箱不算。Play 的要求更进一步:除了应用内入口,还要提供一个网页链接, 让没装 App 的人也能发起删除,并填进 Play Console。
这条要留出开发时间,不是提交前一天能补的——数据怎么删(硬删还是匿名化、 哪些记录要保留)本身就是个产品决策。
数字内容只能走平台内购(苹果 3.1.1)。用外部支付收订阅费会被直接拒。
没接好的付费入口,必须在正式包里隐藏
上架包里放一个按不动的购买按钮,本身就是拒审理由。
要么把内购接完整,要么用编译期开关把整个入口挡在正式包外,没有第三条路。 顺带一提,Play 的内购必须先把包传到至少内部测试轨道才能测, 纯本地 debug 包测不了——这条经常打乱排期。
抽成也不一样:Play 订阅从第一天起 15%,苹果第一年 30%。
Target API 等级每年抬高。 Play 每年提高最低要求,不满足会在上传环节就被挡。 提交前查一下当年的数字。
五、提交材料
审核用的测试账号必须真的能登进去。 这里有个很容易翻车的组合:邮箱验证码在开发期 是打到日志的,如果上架前没接真实邮件供应商,审核员收不到验证码,登不进去就是直接拒。 要么把邮件服务接好,要么预先建好账号、把密码直接给审核员。
隐私政策要有网页版 URL。 App 内那一页替代不了它——没装 App 的人也要能打开。 涉及健康、位置这类数据时,没有隐私政策直接拒。
数据安全表必须和实际行为一致。 Google 会做自动比对,不一致会被下架。 如实申报采集了哪些类别、是否加密传输、用户能否请求删除。
后台定位、需要特殊解释的功能,建议附一段演示视频或在审核备注里写清楚。
六、上架前的构建检查
flutter clean # 别省这一步,理由见坑一
flutter build appbundle --release --flavor prod \
--dart-define-from-file=env/prod.json
逐项过:
- 包名已换,不含
com.example. - release 签名的 SHA-1 已注册到 OAuth 客户端
- 所有「仅测试可见」的入口在正式包里确实不出现(服务器切换、调试页、未接好的付费入口)
- 允许覆盖服务器地址的开关是 false——一个陌生人能改后端地址的包, 等于一个收集他人账号的工具
- 合并后的 manifest 里没有已经不用的权限
- 前台服务的
foregroundServiceType存在 - 应用显示名解析正确
最后一条有个坑:构建产物的文件名不代表用户看到的名字。
CFBundleDisplayName 如果被硬写,会盖掉按 flavor 配好的 PRODUCT_NAME——
结果是 .app 文件名对、桌面图标和系统权限弹窗却是旧名字。用命令验解析后的值:
plutil -extract CFBundleDisplayName raw "build/ios/iphoneos/Runner.app/Info.plist"
三天怎么排
前提是账号和凭据在第一天之前就去申请——它们不是开发工作,但会卡住开发。 平台账号见上一节;另外域名也要尽早,DNS 生效和发信域名验证要几小时到一天, 它卡住的是 HTTPS、邮件和正式包名。
排期本身:
- 第一天:骨架。三个仓库、Prisma schema、NestJS 模块、Flutter 页面结构、 Docker 起本地数据库、三套环境配置一次做对。
- 第二天:登录体系。Google 登录、邮箱验证码注册、两步验证、账号合并。 这天的产出全是「不能返工」的东西,值得慢。
- 第三天:真机与多环境验证。Android 装机跑通闭环、iOS 签名与装机、 构建产物校验、隧道联调。
第三天最容易超时,因为坑集中在这里——上面十条里的前四条全部落在这一天。
服务端上线检查
客户端那份在上一节,这里是后端。每条都写了预期结果,对不上就是有问题:
# 配置不合格时,服务必须拒绝启动,而不是用默认密钥照常跑
NODE_ENV=production JWT_SECRET=short node dist/main.js
# 预期:非零退出,并打印缺了什么
# 不带 token
curl -s -o /dev/null -w '%{http_code}\n' https://api.your-domain.com/api/me
# 预期:401
# 带一个伪造 token
curl -s -o /dev/null -w '%{http_code}\n' https://api.your-domain.com/api/me \
-H 'Authorization: Bearer not.a.real.token'
# 预期:401
# 两步验证第一步拿到的挑战 token,不能当会话 token 用
curl -s -o /dev/null -w '%{http_code}\n' https://api.your-domain.com/api/me \
-H "Authorization: Bearer $CHALLENGE_TOKEN"
# 预期:401。若返回 200,等于两步验证形同虚设
最后一条尤其重要:它是唯一一个功能测试全绿也发现不了的安全问题—— 登录能过、验证码能验、错误码能拒,全都正常,只有专门去试这一下才会暴露。