· engineering· 约 27 分钟精读
二十与二十五:免费额度的六条写入路径
TagWorks 把免费层的登记册卡在 25 条,SlingGuard 卡在 20 条。判定式只有一行,调用点只有两处,而能让条数增加的入口有六个。本文按源码逐条核对:额度读的是哪个数、哪两条路径根本不读它、以及为什么唯一的泄压阀是删除。
工具间的长桌上摊着一台手机。电箱一件一件过,登记册上的条数往上走:18、19、20。检验员按下右上角的加号,屏幕中央升起一张付费墙。他把手机递过来问:能不能先关掉提示,把这 20 台之外的第 21 台也录进去。
这个问题在两款应用里有不同的答案,但同一个原因:那条「免费层能用多少台」的线,不是台账自身的属性,而是几个界面在写入之前临时问了一次的一句话。这句话问得准不准、谁问了、谁没问,决定了额度在现场实际是什么样子。本文把 TagWorks 与 SlingGuard 两端源码里所有会改变条数的入口逐条过一遍:判定式、调用点、计数口径、绕过它的两条路径,以及唯一那个能腾出位置的减法。
一、两个常量与一行判定式
额度在代码里的全部形态是两个常量加一个布尔函数。
| 项目 | TagWorks | SlingGuard |
|---|---|---|
| 常量声明 | Pro.freeAssetLimit = 25 | ProConfig.freeAssetLimit = 20 |
| 声明位置 | Services/ProAccess.swift 第 8 行 | Services/ProAccess.swift 第 6 行 |
| 判定式 | isPro || currentCount < limit | 同一行结构 |
| 判定式位置 | 第 49 至 51 行 | 第 52 至 54 行 |
canAddAsset 调用点 | 2 处 | 2 处 |
pro.isPro 引用点 | 19 处 | 14 处 |
| 常量在视图层的引用 | 5 处 | 0 处 |
判定式写成数学形式只有一行。设现存条数为 n、上限为 L、权益为 P:
关键在于 currentCount 是个入参。函数自己不读台账,它只回答「如果现在是 n 条,还能不能再加一件」,语义是单件预判。而 pro.isPro 在两端合计被引用 33 次、canAddAsset 只有 4 个调用点:绝大多数权益判断是功能分级(水印、导出、备份),容量判断只占很小一片。整个工程的额度行为,取决于每个入口各自往里填了什么数。
二、闸门装在哪一层
把台账真正写进内存的函数列出来:RegisterStore 侧的 add、addBatch、replaceSnapshot、delete,RiggingStore 侧的 addAsset、updateAsset、deleteAssets,再加直接对四个 @Published 数组赋值的 BackupService.restoreBackup。逐个查签名与函数体,没有一个带额度参数,没有一个引用 freeAssetLimit。addBatch 的实现只有两行:追加、刷新落盘。addAsset 是一行:assets.insert(asset, at: 0)。
再看常量的引用方向:TagWorks 里 Pro.freeAssetLimit 被引用 6 次,其中 5 次在视图(列表顶部横幅、LIMIT REACHED 胶囊、付费墙两处文案、CSV 预检),1 次在判定式;SlingGuard 里被引用 1 次,就是判定式本身。
结论一句话:容量上限是界面约定,不是数据不变量。
这条判断的实际后果不是”能被绕过”这种道德问题,而是工程意义上的三条:任何新写入口都要”记得”重新问一次;问与不问在编译期没有任何机制保证;额度状态无法从数据本身推出来,只能从”当前界面是谁”推出来。第七个写入口上线的那天,它不带额度,不是因为有人决定不设限,而是因为没人想起要问。
三、六条写入路径
六条路径在两端合计八个入口(CSV 与恢复各按两端分列)。把它们全列出来:
| 入口 | 应用 | 是否过闸 | 填进判定式的数 | 实际边界 |
|---|---|---|---|---|
| 工具栏加号 | 两端 | 是 | 现存条数 | 25 / 20 可到达 |
| 扫码未命中转新增 | 仅 TagWorks | 是 | 现存条数 | 与加号同一边界 |
| CSV 批量导入 | TagWorks | 是 | 现存 + 解析后条数 | 恰好 25 放行 |
| CSV 批量导入 | SlingGuard | 是 | 现存 + 文件非空行数 | 恰好 20 拒绝 |
| 备份整册恢复 | TagWorks | 入口在 Pro 之后 | 不填数 | 免费层不可达 |
| 备份整册恢复 | SlingGuard | 否 | 不填数 | 无上限 |
| 首次启动播种 | SlingGuard | 否 | 不填数 | 先占 3 格 |
| 删除 | 两端 | 否(减法) | 不填数 | 唯一泄压阀 |
加号是标准答案:按钮的点击闭包里先 pro.canAddAsset(currentCount: store.assets.count),真则开表单,假则开付费墙。TagWorks 的扫码走同一条问句——扫码命中已有资产直接进详情,未命中才把标识填进新建表单,此时才问额度;因此”扫一台已登记的设备”永远不消耗额度。SlingGuard 的扫码只查找不写入,扫码路径与额度无关。
播种与恢复两条不过闸的路径,第四、五节分开讲。这里先说 CSV:它是唯一一个”问句一样、填的数不一样”的入口,也是现场最容易碰壁的一个。
八条路径、四处判定、两处直写,画在一张图上是这样:
四、CSV:同一句话的两种填法
两端的导入闸门写在不同层、读不同的数。
TagWorks 的 performImport() 先解析再判:targetCount = store.assets.count + res.assets.count,然后 if !pro.isPro && targetCount > Pro.freeAssetLimit。res.assets 是解析产物,无效行、缺名行、与既有标识重复的行已经被剔掉或改名,所以这里的加数是真正会落盘的新增条数。
SlingGuard 的 runImport() 先判再解析:把文本按换行切开、丢掉全空行,prospectiveCount = max(0, lines.count - 1),然后 if !pro.canAddAsset(currentCount: store.assets.count + prospectiveCount)。这里的加数是文件的非空数据行数,包含随后会被解析器跳过的行。
两种填法给出三种可观察的差异。
其一,比较方向。canAddAsset 是严格小于;TagWorks 的 CSV 用 > 拒绝。设现存 n、待新增 k:TagWorks 拒绝当且仅当 n+k 大于 25,因此 n+k 恰等于 25 放行;SlingGuard 拒绝当且仅当 n+k 不小于 20,因此 n+k 恰等于 20 被拒。同一句「还能不能再加」,在 TagWorks 里能加满到 25,在 SlingGuard 里加不满。
其二,加数虚高。SlingGuard 首启自动播种 3 台,装到 20 台的额度实际只剩 17 个空位。此时来一份 18 行的 CSV,其中 2 行的 Tag 编号已在册:解析器会跳过这 2 行,真正落 16 台,16+3=19,仍在额度内;但闸门看到的是 3+18=21,直接拒。要导入就必须先把 CSV 拆成两次。这类”按行数估”的保守偏差,在批量场景里表现为:文件越脏,越容易被误拒;越脏的文件本来越需要批量导入。
其三,行数不等于记录数。导入解析器是支持引号内换行的,也就是说一条记录可以在源文件里占两行。按换行统计的 prospectiveCount 会把这种记录数成两条。TagWorks 不受影响,因为它数的是解析产物。
写到这里,问题不是谁对谁错,而是判定式没有说明它回答的是哪种问句。canAddAsset(currentCount:) 的入参名是”当前条数”,SlingGuard 把”当前条数加待导入条数”填进去,函数照原样回答,语义就滑了一层:从”还能不能加一件”变成”加完这批还剩不剩位置”。一个入参名挡住了这个滑坡,仅此而已。
五、恢复:一处有闸,一处没有
备份恢复是全工程唯一能一次改变整册条数的操作,两端把它放在了不同的位置。
TagWorks 把闸门装在入口:设置页的恢复按钮写成 if pro.isPro { showImporter = true } else { showPaywall = true },导出备份同样 gated。文件选择器因此对免费层不可达。落盘函数 replaceSnapshot 不查额度——但因为入口挡住了,免费层不会走到”超额”这个状态。
SlingGuard 把闸门装在导出、没装在恢复:导出 CSV 与导出单文件备份都在 if pro.isPro 之后,恢复按钮的实现只有一行 showingFileImporter = true。restoreBackup(from:into:) 解码归档、过版本闸门(archive.version <= BackupArchive.currentVersion),然后把归档里的资产、记录、放行、公司档案四个数组整体赋给当前 store 并落盘。没有额度参数,没有条数比较,也没有恢复前的安全副本——这一点与 TagWorks 不同,TagWorks 在替换之前会先写一份 TagWorks-before-restore-*.json 留档。
结果是”超额态”在 SlingGuard 的免费层是可达的:一次恢复之后,登记册可以装着远超 20 条的资产与记录,此后加号每一次都弹付费墙(因为 n 早已不小于 20),而这两百多条数据全部可读、可检验、可参与统计、可出水印报告。
这一节要说的不是”额度守不住”,而是额度在这里已经换了定义:它约束的不再是承载量,而只是新增动作。 承载量由上一次恢复的归档决定。把额度当作容量来沟通(付费墙原文写的是 “Free tier is capped at 20 assets”),与实际行为(只挡新增)之间,存在一个需要在文档里挑明的口径差。
顺带一句:checked 这个发布属性在两端都写了、都只赋值不读。refresh() 是异步的,冷启动第一帧 isPro 为假。未触达上限的台账不受影响(n < L 已为真);恰好在上限的已购用户,在权益返回之前那一次点击会被弹一次付费墙。TagWorks 的 scenePhase 只做落盘 flush,SlingGuard 连这个回调都没有;权益复查只发生在启动那一次与商店交易更新回调。
六、播种:把三台样机写进法定台账
RiggingStore.init 的默认分支是:load(),如果 assets.isEmpty 就 seedSmallDemoData()。这个函数写入三台吊具——编号 SL-101 的 3 吨 4 米扁平吊带、CH-042 的 6.7 吨双腿 80 级链、SH-008 的 8.5 吨弓形卸扣——三条 thoroughExamination6Mo 类型的合格记录,检验员署名与资质编号,以及一个公司档案,然后 save()。TagWorks 的 RegisterStore.init 没有这一步:空就是空,种子只在启动参数下才写。
种子数据带来四条可核验的后果。
第一,没有标记。RiggingAsset、InspectionRecord、CompanyProfile 三个模型里查不到 isSample、isDemo、来源字段或任何等效标记。样机与真机在数据结构上完全同构,因此任何”排除样机”的查询都没有落点,任何”只统计真机”的报表都写不出来。
第二,进报告。ReportExporter.makeReport 页眉直接取 profile.companyName、profile.siteLocation、profile.inspectorName、profile.certificationNumber 四个字段,四个字段只判断是否为空串。新装设备只要没改过设置,导出的证据报告页眉印着 Atlas Lifting & Rigging Services、North Harbor Yard、J. Campbell (LEEA Inspector)、LEEA-CERT-8842。报告正文另有英文的 Important limitation 一段,明确写了应用不代替检验、不核验资质、不构成法定证书;但页眉的公司名与资质号并不因此变成空白。
第三,进统计。ReportExporter.summary 算的六项在册状态(在用、30 天内到期、已过期、隔离、报废、未检)与通过率,全部把这 3 台与那 3 条记录当真数据算。种子链 sling 的上次检验日期落在 195 天前,6 个月周期早已越线,因此一台刚装好、还没录过任何实物设备的手机,仪表板打开就写着 Total Inventory 3、In Service 2、Overdue 1,环形进度显示 67% 并亮橙色警告三角;而导出的证据报告首页写 Entered pass outcomes: 100%——这个数字来自三条从未发生过的检验。
第四,占额度且不释放。3 格先被占掉,20 的上限实际剩 17,而界面唯一的条数显示是筛选胶囊上的 All (3),没有任何一处写着 3/20。想腾回来只能删,删除会连带销毁记录(下一节)。更麻烦的是:把那 3 台删空之后,assets 又变回空集,下一次冷启动 load() 读到空,播种再来一遍。
对照 TagWorks 的取舍:首启空登记册,列表给 No assets yet,报告页眉在公司名为空时直接不打印那一行、检验员为空时打一个破折号。空数据在证据面上比演示数据诚实,代价是新装用户面对一个空屏。两种哲学没有绝对优劣,但对”证据文件生成器”来说,把演示公司写死成页眉默认值这一条,是可以直接改成空值加显式示例的。
还有一条实现细节:-uiTestSeedFull 在 TagWorks 播 25 台(注释写着 seed exactly 25 assets so the free-tier ceiling is at its edge)、在 SlingGuard 播 20 台,这些分支与默认播种路径一样没有包在 #if DEBUG 里;SlingGuard 的 -uiTestPro 包了,TagWorks 的没包。启动参数需要调试器注入,普通用户碰不到,但”往台账里灌 20 台样机”与”把权益置为已购”这两条旁路确实存在于发布产物之中。隔离它们只需要一个条件编译。
七、减法:唯一的泄压阀是删除,而删除销毁历史
额度按 assets 数组长度计。因此现场要腾位置,只有一个动作有效:删掉一条。
两端的删除都是级联的。TagWorks 的 delete(_:) 两行:从 assets 移除该条,再从 records 里移除所有 assetID 相同的记录。SlingGuard 的 deleteAssets(ids:) 三行:assets、records、releases 三处 removeAll。触发点是列表的行侧滑,闭包直接调用删除,没有确认弹窗、没有软删除、没有”退役”状态、没有可撤销窗口,也没有”删除前请先导出”的闸门。
于是额度与法定记录之间出现一个结构性的反向激励:为了让新设备进得来,最省事的办法是让旧设备连同它的检验历史一起出去。AS/NZS 3760 与 LOLER 1998 都要求检测结果与彻底检查记录保存一定期限并可出示;应用本身不阻止删除、也不留删除痕迹(删除不进审计链,链上只有新增),因此”台账曾经有 28 条、现在 25 条”这件事在端侧无法复原。
这里给额度定个性:它约束的是同时在册的设备数,不是历史规模。这个定义本身合理——免费层的边际成本确实只与在册条数有关;但一旦它与删除共享同一个手势,产品就必须在别处补上约束。可行的补法有三个方向:给报废与退役设备一个不占额度的归档态;在删除路径上加确认与导出前置;把额度改为按”当期在用”计,历史设备只读。三者都比现在的行侧滑复杂,但没有一个需要联网。
八、闸门外侧的三件事
第一件是可见性。免费用户能不能看见自己用掉了多少?TagWorks 给了两处:列表顶部一行 Free tier: n/25 assets used,触顶时右侧多一枚橙色 LIMIT REACHED 胶囊;付费墙在触顶时再补一行 Free limit reached: n/25 assets。但这两处都挂在 siteFilter == nil 的条件下——从仪表板点进某个站点时,额度横幅与加号一起不渲染。SlingGuard 一处都没有:常量在视图层零引用,屏幕上的条数只有胶囊里的 All (n),那个 n 是分面计数,不是额度分子;唯一出现”20”这个字的地方,是付费墙里一句英文描述。
第二件是文案与常量不同源。Free limit: 25 与 capped at 20 assets 都是硬编码字符串,不引用常量。改常量不会改文案,改文案不会改判定。这类”同一件事在两个地方各写一遍”的分叉,通常在下一次调额度时暴露。
第三件最实质:付费墙承诺的功能行与代码里的闸门不是一一对应。SlingGuard 的付费墙列了六行,逐行去找对应判定:
| 付费墙条目 | 代码里的判定 |
|---|---|
| 无限资产 | 存在,2 处 canAddAsset |
| 无水印证据报告 | 存在,watermarked = !pro.isPro |
| 档案签名图进报告 | 无判定;且 ReportExporter 全文未引用 signatureImageData |
| 批量定期检验 | 无判定,批量开关只判断登记册非空 |
| 批量 CSV 导入与二维码标签 | 导入只过额度闸;标签打印无判定 |
| CSV 导出与备份 | 存在,两处 if pro.isPro |
签名那一行需要说清楚:签名字段被捕获、被 base64 内嵌进登记册 JSON、被备份归档带走,但报告生成器里没有任何一处引用它——signatureImageData 在 ReportExporter.swift 中被引用 0 次。付费墙描述里”把档案里的签名图形放进本地报告呈现”这句承诺,在代码里既没有权益判断也没有实现路径。它的后半句 “it is not legal authentication” 已经把法律边界划清了,问题只剩下:一个既不出现在纸上、也不受权益控制的字段,是否应该作为付费项被列出。
批量检验与批量导入同理。这两项在免费层完全可用,属于实现面宽于承诺面——对用户无害,对定价不利,同样属于口径不同步。
九、把额度变成不变量的五条线
- 判定下沉。把额度检查从按钮闭包移到写入函数:
addAsset、addBatch、replaceSnapshot、seedSmallDemoData共用一个带Quota参数的入口,超额返回错误而不是布尔。视图层的canAddAsset保留,但只用于把付费墙提前一步呈现,不再作为唯一约束。 - 明确问句。把
canAddAsset(currentCount:)拆成remaining()与canAccept(additional:)两个方法,让”加 n 件”这种问法在名字里就写清楚,杜绝第四节里那次语义滑动。加数一律取解析产物,不取文件行数。 - 定义超额态。恢复、迁移、播种之后可能出现的 n 大于 L 是合法状态,需要一个明确行为:整册只读、允许检验与统计、禁止新增、在列表顶部显示”超出免费层 n 条”而不是不显示。SlingGuard 目前的表现是超额之后什么都不提示,只在加号上碰壁。
- 数据与演示分离。种子资产与种子记录带一个不占额度的示例标记,不进报告、不进统计、不进审计链,并提供一个”清空示例数据”的单次动作;
CompanyProfile的公司名、场地、检验员、资质编号默认空值,让报告页眉在用户录入之前诚实空白。 - 删除与腾位解耦。报废转归档、归档不占额度、删除需确认并要求先导出,三选一即可消除第七节那个反向激励;同时把”删除动作”本身记进链式锚点,让台账至少能自证被删过。
- 权益确定态参与判定。加号与导出路径在
checked为假时禁用而不是放行到付费墙;测试钩子一律#if DEBUG包裹,发布物里不留解锁旁路。
十、能力边界
- 额度约束新增,不约束承载。恢复与播种写入的条数不受任何比较控制。
- 额度是产品策略,不是安全边界。它不防篡改、不防复制,判定全在本机、由本机权益状态驱动。
- 条数只在写入瞬间被检查一次,数据层没有上限参数。
- 免费层的删除无确认、无软删除、无审计痕迹,级联销毁检验记录与放行记录。
- 报告与统计不区分样机与真机:三台播种设备与其合格记录参与通过率、到期与在册统计,页眉打印播种的公司与资质编号。
- 权益判定异步,未定态被当作未购处理,恰好在上限的已购用户冷启动首次点击会看到付费墙。
- 额度文案与常量不同源,SlingGuard 的额度在界面上不可见。
- 所有计算与存储在本机完成,没有服务端复核,也没有跨设备的额度账本。
十一、结语
25 和 20 这两个数字,在源码里各只出现一次比较:一行 isPro || currentCount < limit。六个能改变条数的入口里,四个填了这个数,两个根本没问。填了的四个还分成三种口径——现存条数、解析后条数、文件行数——于是”能不能再加”这句话,在加号、扫码、两份 CSV 导入上得到四种不同的边界。
对端侧合规工具来说,额度的正确形状不是”界面挡一下”,而是”写入函数认一次”。前者只要有一个入口忘记调用就失效,后者即使新增第七个入口也仍然成立。把这个差别处理干净,不需要联网、不需要后端,只需要把判定从按钮挪进函数,把演示数据从真数据里标记出来,再把删除这个动作跟”腾位置”这件事分开。
参考资料
- [1] Apple. StoreKit 2 — Transaction.currentEntitlements. https://developer.apple.com/documentation/storekit/transaction/currententitlements
- [2] Standards Australia / Standards New Zealand. AS/NZS 3760:2022 — In-service inspection and testing of electrical equipment. https://www.standards.org.au/standards-catalogue/standard/as-nzs-3760
- [3] UK legislation. The Lifting Operations and Lifting Equipment Regulations 1998, Regulation 9 (examination of lifting equipment) and Schedule 1 (reports). https://www.legislation.gov.uk/uksi/1998/2307/regulation/9
- [4] OSHA. 29 CFR 1910.184 — Slings. https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.184
- [5] IETF. RFC 4180 — Common Format and MIME Type for CSV Files. https://www.rfc-editor.org/rfc/rfc4180
- [6] Apple. SwiftUI fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:onCompletion:). https://developer.apple.com/documentation/swiftui/fileimporter