← 开发模板
移动应用

一个人三天做完全栈 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 校验和守卫是这套架构的主要收益
ORMPrismaschema 即真源,迁移和类型都从它生成
数据库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 + Prismaschema 即真源,迁移和 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 ProgramGoogle 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,等于两步验证形同虚设

最后一条尤其重要:它是唯一一个功能测试全绿也发现不了的安全问题—— 登录能过、验证码能验、错误码能拒,全都正常,只有专门去试这一下才会暴露。

0浏览量