开工第一天,我没急着写接口,也没去调大模型API,而是先把脑图摊开,把“指尖魔镜”这个产品从用户点开App到最终生成一张可用美甲效果图的完整链路盘了三遍。这个项目核心做两件事,一是帮用户把手部照片和美甲款式合成到一起,实时看效果;二是用AI按用户偏好生成新的美甲款式。听起来技术难点在图像合成和生成模型上,但真正决定后续开发顺不顺的,其实是Day1就要落地的业务数据模型。因为款式要管理、要审核、要上下架;AI生图这种耗时操作又不能同步等结果,必须走异步任务调度。如果这两块地基没打好,后面每接一个模块都是在豆腐上盖房。
这篇文章写给正在做AI应用、尤其是涉及“异步推理任务 + 业务资源管理”的后端开发者,也写给那些刚起步、面对一张空荡荡的数据库还不知道从哪下手的同学。我会以指尖魔镜的Day1为例,把业务数据模型的拆分过程、核心表的字段设计、状态机与任务调度之间的咬合关系,以及我实际踩过的坑全部摊开讲。
1. 开工之前,先把“指尖魔镜”的业务闭环盘清楚
1.1 产品闭环的三种触发链路
我习惯在写任何字段之前,先把产品会出现的“业务流”全部列出来,不列清楚就建表,后面一定返工。指尖魔镜当前能想到的闭环有三条。
第一条是用户主动选择款式做效果预览。用户上传一张手部照片,系统调用手部关键点检测模型,识别指甲位置和朝向;用户再从款式库里挑选某个美甲样式,后台将款式图像经过透视变换贴合到对应指甲上,生成一张合成图返回给前端。这条链路涉及“用户照片、款式、合成任务、合成结果”。
第二条是AI生成新款。用户用自然语言描述想要的风格,比如“奶白色底胶、贝壳碎片、无名指跳色”,后台把这些条件组装成提示词,交给图像生成模型处理,得到初稿后再进入人工或模型质量筛选,合格后入库成为新款式。这条链路比第一条更重,因为模型推理时间不可控,少则几秒,多则半分钟,不可能让HTTP请求一直挂着等返回,所以必须拆成任务异步处理。
第三条是运营端维护款式内容。美甲款式的视觉信息非常依赖图片素材,一个成品款式往往包含缩略图、官方展示大图、试戴效果示例图、AI设计过程稿等。运营需要上传、编辑、提交审核、上下架。素材不是简单的一张图,每个文件都有存储路径、尺寸、格式、MD5这些技术属性。
看完这三条链路,数据模型的方向就很明显了,系统核心不是订单、不是支付,而是“款式”和“任务”两个域。款式域负责整个视觉资源库的管理;任务域负责把所有耗时且可异步化的操作统一调度起来。
1.2 建模范围的边界:什么数据今天不该碰
我给自己画了一条边界,今天绝对不碰的至少有三块:用户账号体系只做到最基本的user_id关联,不做会员积分和用户偏好画像;支付、订单、售后这些电商能力完全留给后续独立服务;款式库存这种概念也不存在,美甲款式本身就是数字内容,不存在售罄问题。
没有边界意识,很容易一步到位建出二十张表,看着很完整,实际上每张表都充满猜测。AI项目迭代速度极快,你猜的订单字段大概率上线第三周就会改。建模应该是“用最小的核心空间装下确定性的业务”,不确定的用任务流和冗余字段兜住,等它长出来再说。
1.3 两个核心域的职责定义
款式管理域和任务调度域,边界一定要切干净。
款式管理域的职责是回答三个问题:一个款式是什么;它有哪些可视资源;它当前处于什么可见状态。它是纯业务资源的“内容库”,不关心这个款式是通过人工上传还是AI生成来的,也不需要知道自己是否正被某个合成任务引用。
任务调度域的职责是回答另外三个问题:系统里有哪些需要异步执行的动作;这些动作当前跑到哪一步了;如果失败,下一次重试在什么时候。它是系统的“流水线”,不应该关心任务结果具体是美甲图还是人脸图,而是把任何带“异步执行”属性的动作统一抽象成任务。
我见过最糟的设计,是把任务字段直接插到业务表里,比如给style表加一个generate_status、generate_task_id,某天想做视频生成再塞一堆video_xxx字段,表结构最终膨胀成不可维护的怪物。正确的做法是让任务域保持独立,通过业务类型和业务ID把你的款式表、请求记录、结果数据关联起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型拆解:款式域与任务域的核心实体
2.1 style主表:一个款式到底存什么字段
第一版style主表我这样设计,字段刻意收敛,凡是可通过素材子表表达的都不放在主表里。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增,后续拆库再迁移为雪花生成 |
| style_code | varchar(32) | 款式的业务唯一编号,用于前端追踪 |
| name | varchar(128) | 款式名称 |
| description | varchar(1024) | 描述文案 |
| category_id | bigint | 分类ID,例如纯色、法式、猫眼、手绘 |
| cover_asset_id | bigint | 封面素材ID,冗余来源素材子表 |
| source_type | varchar(32) | 款式来源:运营上传、AI生成、合作方导入 |
| status | varchar(32) | 款式状态:草稿、待审核、已上架、已下架 |
| designer_id | bigint | 创建人/设计人ID,运营或AI标识 |
| deleted | tinyint | 逻辑删除标记 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 最后更新时间 |
| version | int | 乐观锁版本号 |
说说几个设计考虑。
style_code一定不能省。虽然id已经是唯一主键,但对外展示、埋点、客服排查问题的时候,用一个可读性好、不会泄露业务规模的业务编号,效率高很多。
cover_asset_id是冗余字段,来自素材子表。它单独存在是有原因的,列表页展示封面图是最频繁的查询,如果每次都要去素材表里查“封面是哪张”,一个款式十几张素材的情况下会产生大量无意义关联查询。冗余带来的数据一致性风险靠一条代码约定来控制——只有素材子表的is_primary被置为1时,才允许同步更新style表的cover_asset_id。
2.2 style_asset素材子表:为什么素材不能直接拼接在style里
我之前见过很多人的设计是给style表弄一个images字段,存JSON数组,把所有图片URL都塞进去。小项目确实能跑,但一旦涉及“某张图是不是封面”“某张图是什么版本”“某张图为何展示不出来”这些问题,JSON字段就成了一团浆糊。
style_asset表的设计如下。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| style_id | bigint | 关联款式ID |
| asset_type | varchar(32) | 素材类型:缩略图、展示大图、试戴效果图、AI初稿 |
| file_url | varchar(512) | 对象存储URL |
| file_md5 | varchar(64) | 文件MD5,用于去重 |
| width | int | 图片宽度 |
| height | int | 图片高度 |
| size | int | 文件大小 |
| sort_order | int | 排序权重 |
| is_primary | tinyint | 是否封面素材 |
| status | varchar(32) | 正常、禁用、上传中、处理失败 |
| created_at | datetime | 创建时间 |
素材表没有用style_id + asset_type做逻辑唯一键,因为同一款式可能有多张试戴图或者多轮AI初稿。真正保证不重复的是file_md5,同一张图无论被哪个入口上传,都应通过MD5去重,这样能避免运营重复上传导致大量存储垃圾。
2.3 task任务表:将调度从业务中抽出来的关键
任务表的建模难度比款式表高,因为它的消费者不是人,而是“任务执行器”。执行器可能是一台服务器上的进程,也可能是多个实例同时跑,所以表的每一列都要从“并发执行”的角度去设计。
我设计的核心结构如下。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_no | varchar(64) | 任务业务编号 |
| task_type | varchar(64) | 任务类型,如AI生成款式、款式合图、图片审核、素材转码 |
| biz_type | varchar(32) | 关联业务类型,如STYLE、USER_PHOTO、ORDER |
| biz_id | bigint | 关联业务主键 |
| biz_ext | json | 业务扩展信息,尽量只放低查询频率的数据 |
| status | varchar(32) | 任务状态 |
| priority | int | 优先级,数值越大越先执行 |
| attempt_count | int | 已尝试次数 |
| max_attempts | int | 最大尝试次数 |
| next_retry_time | datetime | 下次可执行时间 |
| scheduled_time | datetime | 计划执行时间 |
| started_at | datetime | 实际开始执行时间 |
| finished_at | datetime | 结束时间 |
| worker_id | varchar(128) | 谁在执行,用于超时排查 |
| last_error_code | varchar(64) | 最近一次错误码 |
| last_error_msg | varchar(1024) | 最近一次错误信息 |
| request_payload | json | 任务入参 |
| result_payload | json | 任务结果 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
| version | int | 乐观锁版本号 |
我故意把request_payload和result_payload设计成JSON。比如AI生成款式的任务入参是“风格关键词 + 底图URL + 参数设置”,这些字段跟AI模型相关,今天用提示词生图,明天可能改成图像加LoRA,结构变化很快。如果把所有入参都拆成正式字段,每调整一次模型都要改表结构。JSON就是为了兜住这种快速变化。
但JSON有个使用红线。凡是需要检索、分组、排序的信息,绝不能只放在JSON里。比如biz_type、biz_id、task_type、status这些字段必须单独成列,否则后台做一个“查某款式所有AI生成任务”的页面,SQL会把所有记录拖出来逻辑过滤,那是灾难。
2.4 任务的幂等设计
任务域最容易被忽略的是幂等。同一个款式,用户可能连点两次“生成AI变体”,如果代码没有幂等保护,就会向模型服务提交两个一模一样的任务,浪费钱不说,还会在款式库里出现两张重复图。
我的做法是给task表加一个业务幂等键,建议加入唯一索引:
- request_source:请求来源渠道
- request_source_id:来源批次号
- task_type:任务类型
一个请求批次对同一个业务对象,同一个任务类型只允许存在一条未结束的任务记录。这样创建任务的时候直接执行INSERT,如果触发唯一键冲突,就说明任务已经创建过了,直接返回已有任务编号即可。
3. 真正决定地基质量的是状态机设计,不是表结构
3.1 款式状态:从创作者上传到运营上架
光有字段没有状态约束,一张表很快就塞满了脏数据。我对状态机的要求是“显式、可控、可追溯”。
款式的状态分为五类:DRAFT草稿、PENDING_REVIEW待审核、REJECTED已驳回、PUBLISHED已上架、OFF_SHELF已下架。
允许的迁移路径是固定的。DRAFT可以提交为PENDING_REVIEW,PENDING_REVIEW可以被审核通过变成PUBLISHED,也可以被驳回退回DRAFT;PUBLISHED可以下架变成OFF_SHELF,OFF_SHELF可以再上架回到PUBLISHED,也可以被编辑后重新进入DRAFT。
为什么不让已经PUBLISHED的款式直接修改内容?因为线上内容和审核内容是两回事。如果运营在已上架款式上直接改图,图片完全没有审核,一旦出现违规内容就直接展示给用户了。所以我明确规定,对已上架的款式做内容修改,会创建一个新素材版本,同时款式状态回滚到DRAFT,等审核通过后再上架。
3.2 任务状态:排队、处理中、结束三元模型
任务状态机比款式复杂,因为涉及失败重试和超时处理。但复杂不意味着混乱,核心状态只有三个大阶段。
第一个阶段是等待态,用PENDING表示。任务刚入队,还没有任何执行器领取。第二个阶段是处理态,用PROCESSING表示。某个执行器已经查到了这条任务,正在跑。第三个阶段是结束态,根据结果分三种:SUCCESS成功、FAILED失败、CANCELED取消。
很多人会问,失败后重试是什么状态?其实重试不是一种状态,而是一条任务从FAILED重新流转回PENDING。这需要通过max_attempts和next_retry_time控制。只要attempt_count小于最大尝试次数,系统允许一条FAILED任务被重置为PENDING并安排新的重试时间;达到最大尝试次数后再失败,则置为DEAD,表示死信,等人为介入。
任务状态里还要有超时机制。PROCESSING状态如果无限期停留,通常意味着执行器挂了或模型服务卡死,所以我会加一个“心跳时间列”heartbeat_at。调度器扫描时发现一条PROCESSING任务的心跳时间超时超过阈值,会把它状态改回PENDING,同时把attempt_count加1,交给另一个执行器重新尝试。这个机制在AI推理任务里是保命的,很多大模型推理服务会因为运行环境内存不足等原因直接断连,没有超时回收,你的队列会越堵越长。
3.3 状态迁移用“条件更新”而不是代码读改写
我见过不少团队用“先查状态再update”来实现状态迁移,这会出大问题,因为两个并发的执行器可能同时读到同一状态,然后都认为“我拿到了任务”。
正确做法是把状态迁移收敛成一条条件UPDATE。比如执行器抢单的时候执行这样的SQL:
sql复制UPDATE task
SET status = 'PROCESSING',
worker_id = 'worker-001',
started_at = NOW(),
attempt_count = attempt_count + 1,
version = version + 1
WHERE id = 123
AND status = 'PENDING'
AND next_retry_time <= NOW()
AND version = 1;
执行后检查受影响行数,是1说明抢到任务,是0说明别人先改了状态,当前这条执行请求直接放弃。这比用分布式锁轻量太多,而且数据库的原子性本身就保证了并发安全。只有当单实例的数据库已经成为瓶颈,或任务量级大到一轮扫描好几万条时,才需要引入Redis分布式锁或任务调度中间件。
4. 调度策略怎么落到字段上:从Day1就为接入调度平台留好接口
4.1 任务类型不是一个备注字段,而是路由键
task_type字段在我的设计里不是给人看的备注,而是整个调度逻辑的核心路由键。不同的任务类型对应不同的执行器,不同类型需要不同的资源组和优先级。
指尖魔镜Day1确认的任务类型有这些:
| task_type | 含义 | 执行特点 | 优先级 |
|---|---|---|---|
| STYLE_GENERATION | AI生成款式 | 大模型推理,慢、贵、容易失败 | 中 |
| STYLE_RENDER | 款式合图 | 图像处理,快、CPU密集型 | 高 |
| IMAGE_TRANSCODE | 素材转码压缩 | 批量处理存储资源 | 低 |
| IMAGE_AUDIT | 图片内容审核 | 调用审核模型,异步回调 | 高 |
| EFFECT_PREVIEW | 用户试戴效果合成 | 实时性要求较高 | 高 |
任务执行器扫描时,会按照task_type查不同的队列配置。比如STYLE_GENERATION要走GPU机器组,EFFECT_PREVIEW要走CPU轻量服务,如果把两类任务塞进同一个循环里处理,会互相拖垮。
4.2 任务调度能力以字段形式沉淀
我在设计时坚持一个原则:初期所有调度能力都能用字段表达,不引入额外调度框架。因为Day1的业务体量根本用不着复杂的分布式调度中间件,但字段设计必须为后续接入 xxl-job 这类分布式任务调度平台留好抽象层。
我在task表里预留了这些调度相关字段:
- scheduled_time:计划执行时间。用来支撑用户“晚上9点再生成”这种定时诉求,也用于失败退避。
- next_retry_time:下一次可执行时间。失败重试时,采用指数退避策略,比如第一次失败后5分钟,第二次30分钟,第三次2小时,直到耗尽max_attempts。
- priority:优先级。运营审核任务应该优先于低质的批量转码任务,所以priority会参与扫描排序。
- worker_id:当前执行实例标识。
调度器扫描的时候,本质上只需要执行一条带排序的查询:
sql复制SELECT * FROM task
WHERE status = 'PENDING'
AND next_retry_time <= NOW()
AND scheduled_time <= NOW()
ORDER BY priority DESC, created_at ASC
LIMIT 10;
这10条记录拿到手后,执行器逐条执行上面提到的条件UPDATE抢单。这个模式简单、直接,能支撑单机几百TPS的任务创建,足够指尖魔镜初期使用。等将来任务量上去了、机器分成多个集群了,再把调度这块替换成外部调度平台。由于task表已经把task_type、next_retry_time这些路由条件全都落成了字段,接入外部调度平台时,只需要写一个JobHandler去调用“查询待执行任务”的同一套SQL即可,业务层代码完全不用改。
4.3 模型API请求不阻塞:异步任务如何反哺业务
Day1的地基设计要回答一个非常实际的问题:用户调用了AI生成款式接口,后端到底该怎么响应?
一个糟糕的同步方案是后端拿着用户HTTP请求等模型服务推理完毕再返回,在模型卡顿时用户连接直接超时,前端拿着一个未知结果,体验非常差。
异步方案下,后端接口收到请求后,立刻创建一条STYLE_GENERATION任务,状态为PENDING,然后返回一个task_no给前端。前端拿着task_no轮询“查询任务状态”的接口,任务还在排队就返回排队中,处理中就返回处理中,成功后带上结果数据里的款式ID或图片地址。如果失败,则返回失败原因和推荐动作,比如“当前模型队列繁忙,请稍后在生成记录里重试”。
任务状态查询接口本质上查的就是task表,最终效果是task表把耗时操作与用户请求解耦了。这个过程不需要消息队列,不需要第三方调度平台,靠一张表就完成了闭环。
4.4 不要把业务状态写进任务表
我见过的一些任务表里会出现类似style_audit_status、style_publish_status这样的字段,这是把业务数据混进任务域了。任务表只负责“跑没跑到终点”,业务表只负责“业务处于什么生命周期”。
正确的做法是执行器完成任务后,根据业务类型分别去更新对应的业务表。比如IMGAGE_AUDIT任务执行完,拿到审核结果后,要更新style的状态为PENDING_REVIEW或REJECTED,同时写入一条审核记录。任务表本身不存审核意见,审核意见属于业务域的数据,可以放到audit_log表里。
之所以要分这么清,是因为一个AI组件可能同时服务多个业务方。今天指尖魔镜用这套任务表生成美甲款式,明天可能有一个“虚拟试戴”的业务也来找你,把图片合成任务注册进来。如果任务表里混入业务字段,每接一个业务就要改一次任务表结构,最后变成一个谁都不敢动的千字段怪物。
这样把任务域和业务域切开后,新增业务只需要新增task_type,业务表那边保持自己的状态推进逻辑,两边通过biz_type和biz_id建立弱关联,谁都不依赖谁的表结构,天然适合多团队协作。
5. 索引设计、冗余与扩展性:Day1最容易被忽视的硬仗
5.1 用真实SQL反推索引,而不是把所有列都加索引
我建表时习惯做一步操作:先把未来最高频的SQL写出来,再根据SQL决定索引。不是“这列以后可能查”,就顺手加个索引,那样只会让表空间膨胀、写入变慢。
指尖魔镜款式管理后台最常执行的SQL大概是:
- 查询某个分类下已上架的款式列表,按创建时间倒序
- 查询某个设计人的所有款式
- 查询所有待审核的款式,按提交审核时间正序
- 根据风格标签查款式
- 执行器扫描所有PENDING状态的AI生成任务
前三类查询在style表对应的组合索引是(status, category_id, created_at),因为用户永远看不到未上架的状态,这个查询的频率最高。第四类需要引出一张标签关联表style_tag_relation,tag_id和style_id都建索引。第五类就是task表的(status, next_retry_time),这个索引至关重要。
我之前看到过有人给task表建了很多索引,但偏偏没建(status, next_retry_time),结果任务量一大,全表扫描让数据库CPU直接飘红。执行器扫描待处理任务的频率可能几秒就一次,这个索引决定了调度系统心脏供血是否通畅。
5.2 面向查询做适度冗余,但要管住一致性
我前面提到的style表冗余cover_asset_id,就是一种典型的面向查询的冗余。还有另一个值得冗余的字段是style表的category_name缓存,因为这个分类名称在列表页要展示,联表查分类表虽然也能做到,但每次都关联一个只有几十行记录的分类表,看起来很简单,可一旦查询量上来,这多出来的一次join会放大好几倍资源消耗。
冗余可以,但必须格式化管理。我在项目里定了一条规则,凡是冗余字段,都在代码注释里标注来源表和同步方式。比如cover_asset_id的来源是style_asset表,同步时机是素材表is_primary置位时。品控强一点的团队可以在数据库层加一个定期对账的脚本,扫描出不同步的数据并报警。Day1我还没写对账脚本,但在表设计文档里已经把这条规则写清楚了。
5.3 逻辑删除和唯一索引:一个隐蔽的地雷
逻辑删除字段deleted是很多系统逃不开的习惯,但如果你把status的业务唯一键和deleted放一起,会出现一个隐蔽问题。
举个例子,style表里有个category业务编码category_code,假若运营误创建了一个编码为D0001的分类,随后逻辑删除,然后又想新建一个编码同样是D0001的分类,数据库的唯一索引会被那条已删除记录挡住,因为你不能在一个唯一索引上同时存在两个D0001。
这种问题的解决方案要根据业务场景来选。对于核心资源,尤其是需要长期追踪的款式,不建议单一逻辑删除,可以设计一条archive归档表保存历史数据;对于可以清理的中间数据,物理删除更省心。我的选择是,style表这种资源数据永远不物理删除,用deleted字段标记,但在“需要唯一性”的业务场景中,会把旧的逻辑删除记录通过改名机制让出唯一键位置,把deleted + 版本号组合进唯一索引。Day1我先用最简单的方式处理:把风格编码这种强唯一的表,直接物理删除草稿态记录,保留线上版本记录。虽然稍显粗暴,但能避免唯一索引的连环坑。
5.4 时间字段用UTC还是本地时间
AI应用面向的用户可能分散在不同时区,任务调度的next_retry_time如果跟着服务器本地时区走,一迁移服务器任务就全部乱掉。我Day1就定下规矩,所有表的时间字段统一用datetime类型且写入UTC时间,数据库连接串显式配置时区为UTC,由应用层在展示前转换成本地时区。虽然多了一层转换,但任务扫描逻辑不用感知用户时区,这才是最重要的。
5.5 不要为了“以后可能用”预留空白字段
我刚入行时,有个特别不好的习惯,每张表都会预留reserved1、reserved2这种字符串字段,觉得以后加需求的时候不用改表结构。实践证明,这些预留字段几乎不会被使用,就算使用也因为没有清晰语义,最后变成不知道存什么的垃圾字段。
业务数据模型应该只服务于“当前业务能说清楚的需求”,暂时不确定的信息放进扩展JSON字段,等某一天某个扩展字段被频繁查询时再拆成独立列,这种“按需演进”的建模方式远好过“大而全”的预先堆砌。
6. Day1交付清单:模型定义完之后还有什么要收尾
6.1 一套可以复跑的建表脚本和种子数据
Day1的交付不只是表建出来就完了,我强烈建议把建表SQL文件、状态枚举定义、初始分类数据放到同一份资源目录下,并保证从空库执行到可运行状态不超过两步。
我整理的文件结构大概是这样的:
text复制db/
├── 01_schema.sql # 全量建表DDL
├── 02_seed_data.sql # 初始分类、系统配置
├── 03_enum_definition.sql # 状态机定义说明注记
└── changelog/
└── 20250101_day1_initial.sql # 带执行日期的增量日志
以后每次表结构变更都新增一个changelog文件,而不是修改旧的01_schema.sql去向别人解释你已经改过了。这个习惯能在项目跑到第30天时救你一命。
6.2 状态机的代码表达
表结构之外,枚举和状态迁移规则要写成可测试的代码,否则状态机只存在于数据库设计文档里,实际代码想怎么改就怎么改。
我会用最朴素的Java枚举表达状态机,每次迁移前调用canTransitFrom判断来源状态是否合法。等状态多了可以考虑引入状态机引擎,但Day1没必要。模型的核心价值是能把所有状态迁移约束放在一处,让所有代码路径都执行同一个校验逻辑。
6.3 用任务表反查业务表,检查数据一致性
收尾时我做了个小测试,专门验证AI任务写完款式后,两个域的数据是不是对得上。
我随机看了一条STYLE_GENERATION任务的记录,它的biz_type为STYLE,biz_id是10086。按这个ID去查style表,确实存在一条状态为PENDING_REVIEW的款式,款式封面来源也在style_asset表里。但如果任务状态是SUCCESS而style表不存在对应ID,说明执行器把结果丢了;如果style表存在但task表没有任务记录,说明数据是先落库后创建任务,一旦创建失败业务数据就会孤悬。这轮联动检查帮我避免了一个大坑——AI生成结果回写数据库时,必须走先创建任务、执行成功后创建业务记录、再更新任务结果的事务顺序,顺序反了会出大岔子。
6.4 Day1的实际心得和一个经验教训
如果让我再走一遍Day1,我可能依然会在一个地方绕弯路,就是对“款式是否上架”这种简单业务塞了太多状态。我最初设计了DRAFT、PENDING_REVIEW、REJECTED、PUBLISHED、OFF_SHELF之外,还想塞一个“预览中”状态,结果发现预览中无非就是一种仅供内部使用的特殊可见权限,用一个is_previewable布尔字段加在款式表上就解决了,完全不需要污染整个状态机。
这次让我学到一点,业务数据模型里的状态机应该跟着“业务身份”走,而不是跟着“功能开关”走。功能开关可以用布尔字段、位标记或字典配置实现,一旦强行塞进状态机,后续每个状态组合和迁移路径都会指数级变复杂。
指尖魔镜Day1的任务到这里就算收尾了。当前项目目录里躺着的是一套干净到能向同事逐条解释的建表脚本,styletable负责管理款式状态和素材,task表负责调度所有异步AI操作,两张表通过biz_id弱关联。后续接什么新能力,基本都是在这套骨架里加任务类型和状态迁移,而不是推翻重来。
