☰
SaaS套餐功能怎么真正控制住?菜单、接口与后台任务的分层校验
2026/10/17 2:41:20 网站建设 项目流程

给租户关闭“批量报表”后,页面上的运行按钮消失了,运维却发现后台仍有新报表文件生成。原因并不神秘:旧页面可以继续发请求,接口可以被直接调用,已经排队的任务也可能在几分钟后由 Worker 执行。隐藏入口只影响用户看见什么,不等于限制系统真正做什么。

上一篇已经把套餐版本、订阅、能力项和配额上限分开;本文接着回答“能力未开通时,哪些执行点必须挡住”。示例采用 Java 17、Spring Boot 风格服务层和 MySQL 8.0.16 及以上版本。公司、账号和报表数据均为虚构;代码展示通用控制边界,不包含实际产品的内部策略。

目录

  1. 固定一个绕过菜单的案例
  2. 菜单、接口、任务和产物各管哪一道门
  3. 任务模型:把租户、发起人和目标数据绑在一起
  4. 服务层与 Worker 如何共用授权判断
  5. 预期结果、自动测试和 SQL 验证
  6. 失效、故障与上线验收边界

一、固定一个绕过菜单的案例

沿用虚构租户甲公司t_a、乙公司t_b。甲公司的sub_101 → PRO v3开通了batch_report;乙公司的sub_201 → BASIC v1没有开通。u_07是甲公司的有效成员,有report:run动作权限;乙公司的u_09有本租户的report:read,又因旧角色配置保留了report:run,但仍不能运行新报表。甲公司的业务记录a_1001和乙公司的b_1001不能互相读取。

本篇看四次请求和一次延迟执行:

场景输入预期结果
正常提交u_07 / t_a / a_1001创建甲公司的待执行任务
旧页面直调u_09 / t_b / b_1001CAPABILITY_NOT_ACTIVE,任务表无新行
跨租户猜 IDu_07 / t_a / b_1001目标在当前租户不可见,不泄露乙公司详情
提交后能力撤销甲公司任务已排队,Worker 领取前权益失效Worker 不开始生成,任务标明阻塞原因
文件下载乙公司账号猜到甲公司的任务 ID不返回甲公司文件或任务详情

最后一项很容易漏:即使提交和计算都守住了,下载地址若只按文件 ID 查找,仍可能跨租户交付产物。本文不讨论报表的具体内容和算法,重点是同一条能力规则如何贯穿异步工作流。

图1:菜单消失并不能覆盖直调接口、延迟执行和旧产物下载。

二、菜单、接口、任务和产物各管哪一道门

这四层的输入不同,不能共享前端返回的一个enabled布尔值:

执行点必须核对的事实失败时的副作用
菜单投影当前租户的权益、成员动作权限隐藏入口;仅改善交互
提交接口可信租户身份、有效成员、动作权限、套餐能力、目标归属不创建任务、不扣用量
Worker 领取任务行绑定的租户和发起人、此刻仍可执行的权限与能力不运行计算,不创建产物
结果查询/下载当前请求者对任务和产物的访问资格、任务归属不返回文件路径、下载令牌或字节流

授权可以写成一个合取条件:允许提交 = 租户有效 ∧ 成员有效 ∧ report:run 已授予 ∧ batch_report 当前开通 ∧ 目标属于当前租户。这五项缺一不可;“套餐已开通”不意味着每个成员都有操作权,“管理员能操作”也不意味着套餐没开通时可以越过产品边界。

Worker 不是拿浏览器会话继续执行,它需要可信的任务上下文。消息里只放jobId,Worker 从数据库重读任务行,再按行内tenant_id查询目标数据;不能让消息体自带的tenant_id覆盖数据库事实。发起人的身份和权限也要按当前成员关系核对。本例选择领取前重新授权:被撤销的能力或动作使排队任务进入BLOCKED。至于已经开始运行的任务是否中止、降级后旧产物是否还能下载,属于下一篇的生效与历史数据政策,必须先明文确定,不能靠代码的偶然行为决定。

API 的判断应集中在受保护的服务入口,路由规则与方法级保护可以作为纵深防御;任何能创建任务的路径,包括管理接口、批量导入或内部调用,都应复用同一业务授权判断。Spring Security 提供请求级和方法级授权机制,但这些机制不会自动知道你的租户订阅、资源归属和异步任务规则,业务事实仍需由系统提供。

图2:入口筛选不是安全凭据;每个真正产生业务效果的点都要独立校验。

三、任务模型:把租户、发起人和目标数据绑在一起

只在任务上记source_id不够,Worker 和下载接口需要一眼知道“是谁、在哪个租户、对哪条记录发起了什么”。下面保留最小的演示表;生产环境的租户、成员、订阅和权限表沿用前文,不在这里复制。

CREATETABLEdemo_report_source(tenant_idVARCHAR(32)NOTNULL,source_idVARCHAR(32)NOTNULL,titleVARCHAR(120)NOTNULL,PRIMARYKEY(tenant_id,source_id))ENGINE=InnoDB;CREATETABLEdemo_report_job(job_idVARCHAR(36)PRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,source_idVARCHAR(32)NOTNULL,creator_idVARCHAR(32)NOTNULL,request_keyVARCHAR(64)NOTNULL,stateVARCHAR(16)NOTNULL,entitlement_refVARCHAR(64)NOTNULL,failure_codeVARCHAR(40)NULL,output_keyVARCHAR(160)NULL,created_atDATETIME(6)NOTNULL,started_atDATETIME(6)NULL,CONSTRAINTuq_report_requestUNIQUE(tenant_id,request_key),INDEXix_report_queue(state,created_at),INDEXix_report_tenant_job(tenant_id,job_id),CONSTRAINTfk_report_sourceFOREIGNKEY(tenant_id,source_id)REFERENCESdemo_report_source(tenant_id,source_id),CONSTRAINTck_report_stateCHECK(stateIN('PENDING','RUNNING','DONE','BLOCKED','RETRY_WAIT','FAILED')))ENGINE=InnoDB;

复合外键阻止“甲公司的任务行指向乙公司的源记录”。entitlement_ref只记录提交时用于解释的订阅号与修订号,例如sub_101:r1,不是可重复使用的授权票据。Worker 领取时读取当前权益,不因任务保存了旧引用就自动放行。output_key应是内部对象键,不是前端可直接使用的永久公开 URL;下载需先完成任务归属和访问资格校验,再签发短时访问或由服务端转发。

演示输入可初始化为:

INSERTINTOdemo_report_source(tenant_id,source_id,title)VALUES('t_a','a_1001','甲公司十月业务汇总'),('t_b','b_1001','乙公司十月业务汇总');-- 第一次合法提交后,仅出现一条 tenant_id=t_a、source_id=a_1001 的 PENDING 任务。

这里的唯一键解决客户端重复提交同一个request_key时的任务去重,但不解决报表次数配额的并发扣减;那需要独立的用量和预留模型。也不要把jobId的不可猜测性当作下载授权,即使使用 UUID,仍要按租户和访问者验证。

图3:任务行是 Worker 的可信事实来源;源数据和结果也必须带租户边界。

四、服务层与 Worker 如何共用授权判断

把检查散落在 Controller、队列消费者和下载接口里,最容易出现“其中一个入口漏了套餐判断”。以下核心代码用同一个ReportGate给提交端和 Worker 使用。ActorDirectory从服务端会话或数据库解析当前成员,不能直接信请求体中的角色、租户或动作列表。

recordActor(StringuserId,StringtenantId,booleanactive,Set<String>actions){}recordSource(StringtenantId,StringsourceId){}recordRights(StringsubscriptionId,longrevision,Set<String>features){Stringreference(){returnsubscriptionId+":r"+revision;}}recordReportJob(Stringid,StringtenantId,StringsourceId,StringcreatorId,Stringstate,StringoutputKey){}interfaceActorDirectory{ActorcurrentMember(StringuserId,StringtenantId);}interfaceSourceRepository{Optional<Source>find(StringtenantId,StringsourceId);}interfaceRightsService{Rightscurrent(StringtenantId,Instantat);}interfaceTenantState{booleanisActive(StringtenantId);}interfaceJobRepository{ReportJobinsertPending(StringtenantId,StringsourceId,StringcreatorId,StringrequestKey,StringentitlementRef);ReportJoblockPending(StringjobId);// 事务内 SELECT ... FOR UPDATEOptional<ReportJob>findDoneForTenant(StringtenantId,StringjobId);voidmarkRunning(StringjobId);voidmarkBlocked(StringjobId,Stringreason);voidmarkRetryWait(StringjobId,Stringreason);}finalclassDeniedextendsRuntimeException{Denied(Stringcode){super(code);}}finalclassRightsUnavailableextendsRuntimeException{}finalclassReportGate{privatefinalTenantStatetenants;privatefinalSourceRepositorysources;privatefinalRightsServicerights;ReportGate(TenantStatetenants,SourceRepositorysources,RightsServicerights){this.tenants=tenants;this.sources=sources;this.rights=rights;}Rightsrequire(Actoractor,StringsourceId,Instantat){if(!tenants.isActive(actor.tenantId())||!actor.active())thrownewDenied("IDENTITY_NOT_ACTIVE");// 用当前租户查目标,不先按裸 sourceId 查询再比较,避免泄露他租户数据。sources.find(actor.tenantId(),sourceId).orElseThrow(()->newDenied("SOURCE_NOT_FOUND"));if(!actor.actions().contains("report:run"))thrownewDenied("ACTION_DENIED");Rightsgrant=rights.current(actor.tenantId(),at);if(!grant.features().contains("batch_report"))thrownewDenied("CAPABILITY_NOT_ACTIVE");returngrant;}}finalclassReportService{privatefinalActorDirectoryactors;privatefinalReportGategate;privatefinalJobRepositoryjobs;ReportService(ActorDirectoryactors,ReportGategate,JobRepositoryjobs){this.actors=actors;this.gate=gate;this.jobs=jobs;}@TransactionalReportJobsubmit(StringauthenticatedUserId,StringtrustedTenantId,StringsourceId,StringrequestKey,Instantat){Actoractor=actors.currentMember(authenticatedUserId,trustedTenantId);Rightsgrant=gate.require(actor,sourceId,at);returnjobs.insertPending(actor.tenantId(),sourceId,actor.userId(),requestKey,grant.reference());}}

trustedTenantId要从已验证的当前租户上下文产生,并与有效成员关系核对;不能只把前端传来的租户 ID 换个变量名。RightsService.current如果查询失败,应返回“无法确认权益”的故障,而不是返回空集合并伪装成租户未订阅;业务执行仍不放行,但告警和重试方式不同。

Worker 只接收jobId,先原子领取,再做实际计算。下面的事务边界表示“一次只有一个 Worker 能把PENDING改成RUNNING”;真正耗时的报表生成在事务外运行。队列重复投递时,非PENDING任务不得再次开始。

@Transactionalbooleanclaim(StringjobId,Instantat){ReportJobjob=jobs.lockPending(jobId);// 未命中 PENDING 时返回 nullif(job==null)returnfalse;try{Actoractor=actors.currentMember(job.creatorId(),job.tenantId());gate.require(actor,job.sourceId(),at);jobs.markRunning(job.id());returntrue;}catch(Denieddenied){jobs.markBlocked(job.id(),denied.getMessage());returnfalse;}catch(RightsUnavailableunavailable){jobs.markRetryWait(job.id(),"RIGHTS_UNAVAILABLE");returnfalse;}}voidprocess(StringjobId){// 仅在 claim 成功后执行。读取源数据仍用任务行 tenant_id + source_id。// 产物先写入该租户的私有对象空间,再原子登记 DONE 与 output_key。}

演示代码省略了持久层适配、产物写入和完整的异常重试,但不能省略它们的约束:claim中只有明确的业务拒绝可写BLOCKED;权益服务暂时不可用写RETRY_WAIT并告警;若数据库本身不可写,事务应回滚,由队列重新投递,不能假装已经持久化了等待状态。生成中失败要记录FAILED或可重试状态,不能留下半成品供下载。

行锁和状态条件能防止两个 Worker 同时从PENDING领取,但不能解决 Worker 在RUNNING后崩溃。生产实现还需领取令牌、租约到期时间和恢复扫描;process只能由持有当前领取令牌的 Worker 调用,完成登记时也要带令牌做条件更新。若系统要求撤权即时阻断运行中的任务,还要有取消检查点或原子提交策略,下一篇讨论其生效边界。

下载端的判断与“还能不能新建报表”不同。下面只要求当前成员有report:read,并且任务属于当前租户且已经完成;到期后历史产物是否继续可读,需要产品明确规定,不能把创建能力batch_report直接照搬成下载权限。

interfacePrivateStore{StringshortLivedDownload(StringoutputKey);}finalclassReportDownload{privatefinalActorDirectoryactors;privatefinalJobRepositoryjobs;privatefinalPrivateStorestore;ReportDownload(ActorDirectoryactors,JobRepositoryjobs,PrivateStorestore){this.actors=actors;this.jobs=jobs;this.store=store;}Stringopen(StringuserId,StringtrustedTenantId,StringjobId){Actoractor=actors.currentMember(userId,trustedTenantId);if(!actor.active()||!actor.actions().contains("report:read"))thrownewDenied("ACTION_DENIED");ReportJobjob=jobs.findDoneForTenant(actor.tenantId(),jobId).orElseThrow(()->newDenied("JOB_NOT_FOUND"));returnstore.shortLivedDownload(job.outputKey());}}

示例默认本租户拥有report:read的成员可读取本租户完成的报表;若报表还有创建人、部门或数据范围限制,应在签发地址前继续核对。对象存储本身必须保持私有,不能让知道output_key的人绕过这个入口。

图4:提交与领取都校验当前权限和权益;生成和下载继续保持同一租户归属。

五、预期结果、自动测试和 SQL 验证

正常提交的响应可简化为jobId=j_101, state=PENDING, tenant=t_a。旧页面直调乙公司接口返回CAPABILITY_NOT_ACTIVE;跨租户目标返回SOURCE_NOT_FOUND;已排队的j_101若在 Worker 领取前失去能力,最终应为BLOCKED / CAPABILITY_NOT_ACTIVE / output_key=NULL。拒绝路径的关键不是响应文案,而是任务表和产物表没有不该有的新增记录。

下面用 JUnit 5 的行为测试表达最重要的反例;Fixture在测试库中建立第一节的两个租户、成员、源记录和权益,并提供任务/产物查询方法:

@TestvoidoldButtonCannotSubmitForTenantWithoutCapability(){Fixturef=Fixture.twoTenants();Deniede=assertThrows(Denied.class,()->f.service.submit("u_09","t_b","b_1001","req-1",f.now()));assertEquals("CAPABILITY_NOT_ACTIVE",e.getMessage());assertEquals(0,f.jobs.countByTenant("t_b"));}@TestvoidsourceFromOtherTenantIsNotVisible(){Fixturef=Fixture.twoTenants();Deniede=assertThrows(Denied.class,()->f.service.submit("u_07","t_a","b_1001","req-2",f.now()));assertEquals("SOURCE_NOT_FOUND",e.getMessage());assertEquals(0,f.jobs.countByTenant("t_a"));}@TestvoidqueuedJobStopsBeforeExecutionAfterCapabilityLoss(){Fixturef=Fixture.twoTenants();ReportJobjob=f.service.submit("u_07","t_a","a_1001","req-3",f.now());f.rights.disable("t_a","batch_report");assertFalse(f.worker.claim(job.id(),f.now().plusSeconds(60)));assertEquals("BLOCKED",f.jobs.get(job.id()).state());assertEquals("CAPABILITY_NOT_ACTIVE",f.jobs.failureCode(job.id()));assertEquals(0,f.outputs.countByJob(job.id()));}@TestvoidotherTenantCannotDownloadEvenWithAJobId(){Fixturef=Fixture.twoTenants();f.jobs.insertDoneForTest("j_200","t_a","a_1001","private/a/report.pdf");Deniede=assertThrows(Denied.class,()->f.download.open("u_09","t_b","j_200"));assertEquals("JOB_NOT_FOUND",e.getMessage());assertEquals(0,f.privateStore.issuedDownloads());}

Fixture是测试夹具,不是生产服务接口。集成测试还应覆盖:同一request_key的重复提交、两个 Worker 同时领取、成员撤权、租户停用、权益服务短暂不可用、跨租户下载和旧任务下载。测试“重复领取”时不只断言任务最终为DONE,还要断言真正的计算调用次数为 1。

在演示库里,针对上述场景可用 SQL 核对任务与源记录的归属:

SELECTjob_id,tenant_id,source_id,creator_id,state,entitlement_ref,failure_code,output_keyFROMdemo_report_jobWHEREjob_id='j_101';-- 权益失效后预期:t_a / a_1001 / BLOCKED /-- CAPABILITY_NOT_ACTIVE / output_key 为 NULL。SELECTjob_idFROMdemo_report_jobWHEREtenant_id='t_b'ANDrequest_key='req-1';-- 预期 0 行:旧页面直调没有创建任务。SELECTj.job_idFROMdemo_report_job jLEFTJOINdemo_report_source sONs.tenant_id=j.tenant_idANDs.source_id=j.source_idWHEREs.source_idISNULL;-- 预期 0 行:任务没有失去本租户的源记录。SELECTjob_idFROMdemo_report_jobWHEREstate='BLOCKED'ANDoutput_keyISNOTNULL;-- 预期 0 行:被阻塞的任务不能出现可下载产物。

最后两条只是数据库一致性检查,不能代替接口测试:下载端即使从正确的任务行拿到output_key,仍需验证当前访问者是否可查看这条任务。验收时建议同时查接口审计日志、任务状态和私有对象存储记录,避免“数据库没登记但文件已上传”的半成品遗漏。

图5:拒绝路径要核对是否产生任务、计算或可访问的产物,而不只看页面按钮。

六、失效、故障与上线验收边界

权限和权益都可能在任务排队期间变化,因此“提交成功”不等于“未来一定生成”。本例的政策是领取前重新检查;被撤销则阻塞,不自动换成另一个订阅或另一个发起人。如果业务要求已经接受的任务必须完成,就需要明确地保存承诺范围、冻结用量并处理撤销例外,不能一边声称“撤权立即生效”,一边让旧任务继续无条件运行。

需要把三种失败分清:业务拒绝(未开通、无权限、源记录不属于当前租户)、暂时无法判定(权益服务或数据库故障)、执行失败(授权通过但报表计算或对象存储失败)。第一种不产生新工作,第二种不放行但可告警重试,第三种保存任务历史并按可恢复策略处理。把它们统一变成“稍后再试”,既难以解释,也可能反复执行不该执行的任务。

本文没有实现每月 20 次报表任务的并发配额预留,也没有规定升级、到期后旧报表的下载权;后两篇分别处理这两个问题。此处可以确认的底线是:菜单是体验层,接口是提交边界,Worker 是延迟执行边界,下载是结果交付边界。四层都能拿出租户、成员、能力、目标和副作用的证据,套餐功能才算真正控制住。

参考资料:

  • OWASP Authorization Cheat Sheet:请求应在服务端验证授权,不能依赖界面入口。
  • Spring Security Method Security:方法级授权可保护服务入口,但业务租户与权益事实仍需自行提供。
  • AWS SaaS Lens:Preventing cross-tenant access:租户隔离应延伸到实际读取和处理资源的执行路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询