· engineering· 约 27 分钟精读

二十与二十五:免费额度的六条写入路径

核心要点速览 · TL;DR 概要

TagWorks 把免费层的登记册卡在 25 条,SlingGuard 卡在 20 条。判定式只有一行,调用点只有两处,而能让条数增加的入口有六个。本文按源码逐条核对:额度读的是哪个数、哪两条路径根本不读它、以及为什么唯一的泄压阀是删除。

二十与二十五:免费额度的六条写入路径

工具间的长桌上摊着一台手机。电箱一件一件过,登记册上的条数往上走:18、19、20。检验员按下右上角的加号,屏幕中央升起一张付费墙。他把手机递过来问:能不能先关掉提示,把这 20 台之外的第 21 台也录进去。

这个问题在两款应用里有不同的答案,但同一个原因:那条「免费层能用多少台」的线,不是台账自身的属性,而是几个界面在写入之前临时问了一次的一句话。这句话问得准不准、谁问了、谁没问,决定了额度在现场实际是什么样子。本文把 TagWorks 与 SlingGuard 两端源码里所有会改变条数的入口逐条过一遍:判定式、调用点、计数口径、绕过它的两条路径,以及唯一那个能腾出位置的减法。

一、两个常量与一行判定式

额度在代码里的全部形态是两个常量加一个布尔函数。

项目TagWorksSlingGuard
常量声明Pro.freeAssetLimit = 25ProConfig.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:

A(n)=P ∨ (n<L)A(n)=P\ \lor\ (n\lt L)

关键在于 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:它是唯一一个”问句一样、填的数不一样”的入口,也是现场最容易碰壁的一个。

八条路径、四处判定、两处直写,画在一张图上是这样:

上限只有一个判定式,闸门只装在四道入口上四条读额度的路径 · 三种计数口径 · 三条不经判定直接改数写入路径判定读的是哪个数登记册工具栏加号现存条数扫码未命中转新增现存条数批量导入 · TagWorks现存 + 解析后新增批量导入 · SlingGuard现存 + 文件行数首启样例播种不经判定,直接赋值备份整册恢复不经判定,整册替换删除后回填反向:删一条腾一格上限线:25 与 20登记册数组记录与放行播种 3 台先占位容量随删除回落数据层没有上限参数额度是界面策略,不是台账自身的约束口径来源:ProAccess.swift · RegisterView.swift · CSVImportView.swift · RiggingStore.swift · BackupService.swift

四、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” 已经把法律边界划清了,问题只剩下:一个既不出现在纸上、也不受权益控制的字段,是否应该作为付费项被列出。

批量检验与批量导入同理。这两项在免费层完全可用,属于实现面宽于承诺面——对用户无害,对定价不利,同样属于口径不同步。

九、把额度变成不变量的五条线

  1. 判定下沉。把额度检查从按钮闭包移到写入函数:addAsset、addBatch、replaceSnapshot、seedSmallDemoData 共用一个带 Quota 参数的入口,超额返回错误而不是布尔。视图层的 canAddAsset 保留,但只用于把付费墙提前一步呈现,不再作为唯一约束。
  2. 明确问句。把 canAddAsset(currentCount:) 拆成 remaining() 与 canAccept(additional:) 两个方法,让”加 n 件”这种问法在名字里就写清楚,杜绝第四节里那次语义滑动。加数一律取解析产物,不取文件行数。
  3. 定义超额态。恢复、迁移、播种之后可能出现的 n 大于 L 是合法状态,需要一个明确行为:整册只读、允许检验与统计、禁止新增、在列表顶部显示”超出免费层 n 条”而不是不显示。SlingGuard 目前的表现是超额之后什么都不提示,只在加号上碰壁。
  4. 数据与演示分离。种子资产与种子记录带一个不占额度的示例标记,不进报告、不进统计、不进审计链,并提供一个”清空示例数据”的单次动作;CompanyProfile 的公司名、场地、检验员、资质编号默认空值,让报告页眉在用户录入之前诚实空白。
  5. 删除与腾位解耦。报废转归档、归档不占额度、删除需确认并要求先导出,三选一即可消除第七节那个反向激励;同时把”删除动作”本身记进链式锚点,让台账至少能自证被删过。
  6. 权益确定态参与判定。加号与导出路径在 checked 为假时禁用而不是放行到付费墙;测试钩子一律 #if DEBUG 包裹,发布物里不留解锁旁路。

十、能力边界

  • 额度约束新增,不约束承载。恢复与播种写入的条数不受任何比较控制。
  • 额度是产品策略,不是安全边界。它不防篡改、不防复制,判定全在本机、由本机权益状态驱动。
  • 条数只在写入瞬间被检查一次,数据层没有上限参数。
  • 免费层的删除无确认、无软删除、无审计痕迹,级联销毁检验记录与放行记录。
  • 报告与统计不区分样机与真机:三台播种设备与其合格记录参与通过率、到期与在册统计,页眉打印播种的公司与资质编号。
  • 权益判定异步,未定态被当作未购处理,恰好在上限的已购用户冷启动首次点击会看到付费墙。
  • 额度文案与常量不同源,SlingGuard 的额度在界面上不可见。
  • 所有计算与存储在本机完成,没有服务端复核,也没有跨设备的额度账本。

十一、结语

25 和 20 这两个数字,在源码里各只出现一次比较:一行 isPro || currentCount < limit。六个能改变条数的入口里,四个填了这个数,两个根本没问。填了的四个还分成三种口径——现存条数、解析后条数、文件行数——于是”能不能再加”这句话,在加号、扫码、两份 CSV 导入上得到四种不同的边界。

对端侧合规工具来说,额度的正确形状不是”界面挡一下”,而是”写入函数认一次”。前者只要有一个入口忘记调用就失效,后者即使新增第七个入口也仍然成立。把这个差别处理干净,不需要联网、不需要后端,只需要把判定从按钮挪进函数,把演示数据从真数据里标记出来,再把删除这个动作跟”腾位置”这件事分开。

参考资料

Share:
Back to Blog