Appearance
SaaS 计费模块
1. 模块目标
以 用户 + 席位(Seat) 为唯一计费维度:全功能开放,默认 2 席免费,增机增席按数量付费。不引入组织 / 团队层。
2. 核心规则
- 1 Seat = 1 Device
- 1 条席位许可证(license)= 1 个可用席位槽位,可有独立
expires_at - 免费席:
source=included,expires_at=NULL(永久),默认 2 条 - 邀请奖励席:
source=invitation,expires_at=NULL(永久),每成功邀请 1 人双方各 1 条(见 T0076) - 付费席:每次购买生成 N 条 license,同一订单共享
expires_at - 占设备:
billing_seat_allocations把machine_id绑定到某条license_id - 可用席位数(实时):
COUNT(license WHERE status=active AND 未过期),不再用单一users.seat_quantity存总数
2.1 为何不能用 seat_quantity 一个数字?
若 3 号、5 号、15 号各买 3 席、有效期 31 天,则应有 3 批共 9 条付费 license,到期日分别为 4/3、4/5、4/15。单一总数无法回答「哪一席何时到期」「到期只影响哪几台机器」。
2.2 到期与续费(业务口径)
| 事件 | 行为 |
|---|---|
| license 到期 | status → expired;若已绑设备 → 释放 allocation,该设备只读(不停机不切币) |
| 续费单条 license | expires_at += validity_days(如 31),status → active;若设备仍在且未改绑 → 自动恢复全功能 |
| 续费整单(purchase) | 该 purchase_id 下全部 license 统一延长 |
| 增购 | 新建 billing_seat_purchases + N 条 license,不改动旧 license 的到期日 |
| 新设备注册 | 找一条「active 且未过期且未绑机」的 license → 创建 allocation;若无可用 license,机器仍登记入库但 seat_status=released,只能查看不能执行控制动作 |
到期查看:查 billing_seat_licenses.expires_at(列表按到期日排序);控制台可展示「席位 #id / 绑定设备 / 到期日 / 来源订单」。
3. 门禁
设备可控 = allocation.status = 'active'
AND license.status = 'active'
AND (license.expires_at IS NULL OR license.expires_at > NOW())- 许可证有效且已绑机:全功能
- 许可证过期或已释放:该设备只读;其余未到期席位不受影响
- 无可用 license:新设备仍可登记为只读,用户可在控制台购买/获得席位后再绑定
4. 数据库落点
| 概念 | 表 / 字段 |
|---|---|
| 计费参数 | billing_plan(单价、seat_validity_days 默认 31) |
| 购买订单(一批) | billing_seat_purchases(purchased_at、expires_at、quantity) |
| 席位许可证(一条一席) | billing_seat_licenses(expires_at、purchase_id、source) |
| 占席事实 | billing_seat_allocations(license_id + machine_id) |
| 设备归属 | machines.user_id(不变) |
| 席位展示 | machines.seat_status(冗余) |
5. Schema Patch 执行顺序
docs/schema-patches/20260620-saas-user-seat-billing.sql(DDL)docs/schema-patches/20260620-saas-migrate-user-seats.sql(存量迁移,幂等)docs/schema-patches/20260616-saas-seat-allocation-gates.sql(修正机器默认席位状态、allocation 历史记录索引与存量状态同步)docs/schema-patches/20260617-saas-seat-billing-payments.sql(购买订单 pending 状态与 Antom 幂等键)docs/schema-patches/20260618-saas-seat-renewal.sql(续费订单类型与目标 license/purchase)
5.1 DDL:20260620-saas-user-seat-billing.sql
sql
-- 用户级 Seat 计费:license 带独立到期日,支持分批购买
SET NAMES utf8mb4;
ALTER TABLE `machines`
ADD COLUMN `seat_status` ENUM('active', 'released', 'expired') NOT NULL DEFAULT 'active' COMMENT '席位状态:active=占席中,released=已释放,expired=许可证到期' AFTER `status`,
ADD KEY `idx_machines_user_seat` (`user_id`, `seat_status`);
CREATE TABLE IF NOT EXISTS `billing_plan` (
`id` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '固定为 1,全局仅一行配置',
`included_seats` INT UNSIGNED NOT NULL DEFAULT 2 COMMENT '每用户免费席位数;注册时自动发放对应 license',
`price_per_extra_seat_minor` INT UNSIGNED NOT NULL COMMENT '每增购 1 席单价(最小货币单位,如 999=9.99 USD)',
`seat_validity_days` INT UNSIGNED NOT NULL DEFAULT 31 COMMENT '付费席单次购买有效天数;续费按此延长',
`currency` CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '计费货币 ISO 4217 代码',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '配置最近更新时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '计费参数单例表';
INSERT INTO `billing_plan` (`id`, `included_seats`, `price_per_extra_seat_minor`, `seat_validity_days`, `currency`)
VALUES (1, 2, 999, 31, 'USD')
ON DUPLICATE KEY UPDATE `id` = `id`;
-- 购买订单:一次支付买 N 席,共享 purchased_at / expires_at
CREATE TABLE IF NOT EXISTS `billing_seat_purchases` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '购买订单主键',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
`quantity` INT UNSIGNED NOT NULL COMMENT '本次购买席位数',
`amount_minor` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '实付金额(最小货币单位)',
`currency` CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '支付货币',
`purchased_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '购买时间',
`expires_at` DATETIME NOT NULL COMMENT '本订单下全部席位的统一到期时间',
`external_payment_id` VARCHAR(128) DEFAULT NULL COMMENT 'Stripe PaymentIntent / Invoice ID 等',
`status` ENUM('paid', 'refunded', 'void') NOT NULL DEFAULT 'paid' COMMENT '订单状态:paid=已支付,refunded=已退款,void=作废',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_billing_seat_purchases_user_expires` (`user_id`, `expires_at`),
CONSTRAINT `fk_billing_seat_purchases_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '席位购买订单;一批席位的统一到期时间';
-- 席位许可证:一条记录 = 一个席位的使用权
CREATE TABLE IF NOT EXISTS `billing_seat_licenses` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '席位许可证主键',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
`purchase_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联 billing_seat_purchases.id;免费席为空',
`source` ENUM('included', 'paid') NOT NULL DEFAULT 'paid' COMMENT '来源:included=赠送免费席,paid=付费购买',
`status` ENUM('active', 'expired', 'revoked') NOT NULL DEFAULT 'active' COMMENT '状态:active=有效,expired=已到期,revoked=作废/退款',
`purchased_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效开始时间',
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL 表示永久(仅 included 免费席)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_billing_seat_licenses_user_status_expires` (`user_id`, `status`, `expires_at`),
KEY `idx_billing_seat_licenses_purchase` (`purchase_id`),
CONSTRAINT `fk_billing_seat_licenses_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
CONSTRAINT `fk_billing_seat_licenses_purchase` FOREIGN KEY (`purchase_id`) REFERENCES `billing_seat_purchases` (`id`) ON DELETE SET NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '席位许可证;每条对应一个可占用的席位槽位';
CREATE TABLE IF NOT EXISTS `billing_seat_allocations` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '占席记录主键',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id;冗余便于查询',
`license_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 billing_seat_licenses.id;占用的席位许可证',
`machine_id` BIGINT UNSIGNED NOT NULL COMMENT '关联 machines.id;每台设备最多一条 active 占席',
`status` ENUM('active', 'released') NOT NULL DEFAULT 'active' COMMENT '占席状态:active=当前占席,released=已释放',
`allocated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '占席开始时间',
`released_at` DATETIME DEFAULT NULL COMMENT '释放时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_billing_seat_allocations_machine` (`machine_id`),
UNIQUE KEY `uk_billing_seat_allocations_license` (`license_id`),
KEY `idx_billing_seat_allocations_user_status` (`user_id`, `status`),
CONSTRAINT `fk_billing_seat_allocations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
CONSTRAINT `fk_billing_seat_licenses_alloc` FOREIGN KEY (`license_id`) REFERENCES `billing_seat_licenses` (`id`),
CONSTRAINT `fk_billing_seat_allocations_machine` FOREIGN KEY (`machine_id`) REFERENCES `machines` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '许可证与设备的占席关系;1 license 同时最多绑 1 台设备';后续修正:
20260616-saas-seat-allocation-gates.sql将machines.seat_status默认值调整为released,以支持“无席位仍可登记但只读”的接入闭环;同时将billing_seat_allocations.machine_id/license_id的全局唯一键改为按(machine_id,status)、(license_id,status)查询索引,避免 released 历史记录阻断换绑。
5.2 迁移:20260620-saas-migrate-user-seats.sql(仅有存量数据时执行)
sql
SET NAMES utf8mb4;
-- 1. 每用户发放 2 条永久免费 license(若尚无 included)
INSERT INTO `billing_seat_licenses` (`user_id`, `purchase_id`, `source`, `status`, `purchased_at`, `expires_at`)
SELECT `u`.`id`, NULL, 'included', 'active', `u`.`created_at`, NULL
FROM `users` AS `u`
CROSS JOIN (SELECT 1 AS `n` UNION SELECT 2) AS `nums`
LEFT JOIN `billing_seat_licenses` AS `l`
ON `l`.`user_id` = `u`.`id` AND `l`.`source` = 'included'
WHERE `l`.`id` IS NULL;
-- 2. 超出免费席的存量机器:补 paid license(永久兼容,expires_at 置远未来)
-- 或按业务决定补一条 purchase 记录;此处示例为每多 1 台补 1 条 31 天 license
-- 3. 为无占席记录的机器绑定最早可用 license → allocation5.3 示例:3/5/15 各买 3 席(31 天)
假设购买月为 3 月:
| 订单 | purchased_at | quantity | expires_at | license 条数 |
|---|---|---|---|---|
| #1 | 3/3 | 3 | 4/3 | 3 |
| #2 | 3/5 | 3 | 4/5 | 3 |
| #3 | 3/15 | 3 | 4/15 | 3 |
另加 2 条 source=included、expires_at=NULL。
4/3 到期:仅订单 #1 的 3 条 license → expired;仅绑在这 3 条上的设备只读,订单 #2/#3 的 6 台不受影响。
续费:用户对订单 #1 续费 → 该 3 条 expires_at 延至 5/3(+31 天),status=active;原机器自动恢复。
6. 注册发放席位(已实现)
注册成功时(AuthService::register):
- 校验
billing_seat_licenses表已存在 - 事务内创建
users行 - 按
billing_plan.included_seats插入 N 条source=included、expires_at=NULL的 license - 响应附带
seats_granted(通常为 2)
存量用户执行 20260620-saas-migrate-user-seats.sql 补发。
7. 席位状态、换绑与门禁(已实现)
- PHP API 提供
GET /api/billing/seats查询当前用户席位汇总、license 列表与可绑定机器列表。 - PHP API 提供
PUT /api/billing/seats/{licenseId}/machine,用户可将指定 license 绑定到指定机器,或传machine_id=null释放该 license。 - 换绑事务口径:license 与 machine 必须属于当前用户;license 必须 active 且未过期;目标机器若已占其它席位会先释放旧 allocation;原机器不删除、不停机、不切币,只变为只读。
- 设备注册链路会自动尝试绑定最早可用 license;没有可用 license 时机器仍入库,
seat_status=released,仅用于后续分配席位。 - 设备能力与上报存储门禁以 allocation + license 实时有效性为准,
machines.seat_status仅用于展示/快速筛选。未占席机器除注册入库、基础展示、席位绑定外,其它会触发设备能力的功能均不可用;PHP 控制台 API、device-control/api/v1/command-jobs、诊断采集与 Go scheduler execute/dry-run 均会拦截或跳过未占有效席位的机器。 - Agent 注册成功后若机器仍未占席,主控会拒绝保存后续机器事实与指标上报,包括 resource snapshot、heartbeat Redis 缓存、
machines心跳更新时间、metric_systems、metric_powers、metric_miners、machine_mining_runtime、SEL 与硬件监控诊断快照。 - 智能调度策略成员不会因席位释放自动删除;未占席机器在调度决策中输出
skip/no_active_seat,不生成真实执行动作。
7.1 device-control 席位缓存结构
为避免心跳、指标、诊断与 command-job 高频查询直接打到 MySQL,device-control 使用 Redis 缓存单机有效席位状态:
- Key:
{redis.key_prefix}:seat:machine:{machine_id}:active - Value:
1表示当前有有效席位;0表示当前无有效席位 - TTL:2 小时
- Miss 行为:同步查询 allocation + license 实时状态,并回填上述 key,TTL 重新设置为 2 小时
- 主动刷新:
POST /api/internal/seat-cache/refresh,按machine_ids立即查库并重写缓存 - 主动失效:
POST /api/internal/seat-cache/invalidate,按machine_ids删除缓存,下一次读取时重建
触发方式:
- Web 控制台绑定/释放/换绑席位:PHP API 事务提交后调用 device-control
refresh - Agent 注册后自动占席:device-control 内部直接写入
1或0缓存 - 席位过期、续费、增购或后台批量修正:对应任务应调用
refresh;若影响范围无法精确计算,可调用invalidate
8. 购买、履约与到期(已实现)
- 计费参数:
GET /api/billing/plan - 购买:
POST /api/billing/checkout→ Antom Hosted Checkout;开发态POST /api/billing/checkout/{id}/dev-confirm - 履约:
POST /api/billing/antom/notify验签后幂等发放 license,并自动绑定最早未占席设备 - 续费:
POST /api/billing/checkout/renew(license_id续单条,或purchase_id续整单);支付成功后expires_at = GREATEST(now, 原到期) + seat_validity_days,并尝试恢复占席 - 到期 job:
php apps/api/scripts/billing_seat_expiry_job.php - 配置:
apps/api/config/antom.php+antom.local.php
9. 边界
- 退款、对账由后续任务实现
- 不区分 Free / Pro 功能;不按套餐锁智能调度
- 团队、RBAC、SSO 不在本期范围
10. 字段备注规范
与 数据表-v0.0.1.sql 保持一致:
- 每个字段必须有
COMMENT '...' - ENUM 在备注中写明各取值含义
- 新表必须有表级
COMMENT = '...' - 金额类字段注明单位(如「最小货币单位」「ISO 4217」)
11. 邀请奖励席(T0076)
- 邀请关系与进度:
user_invitations;邀请码:user_invite_codes - API:
GET /api/invitations/summary、GET /api/invitations/records;注册可选invite_code;Google 登录POST /api/auth/google/login可选invite_code(仅首次注册新账号时绑关系) - 定时任务:
php apps/api/scripts/invitation_reward_job.php(建议每 10 分钟) - DDL:
docs/schema-patches/20260623-user-invitations.sql - 控制台:
/console/invitations
12. 定时任务(到期处理)
建议 cron(如每小时):
bash
php /path/to/apps/api/scripts/billing_seat_expiry_job.php逻辑:
SELECT id FROM billing_seat_licenses WHERE status='active' AND expires_at IS NOT NULL AND expires_at <= NOW()- 批量
UPDATE status='expired' - 释放对应
billing_seat_allocations并同步machines.seat_status - 调用 device-control
invalidateSeatCache
续费接口(应用层,已实现):
POST /api/billing/checkout/renew+ Antom 支付- 单条:
expires_at = GREATEST(NOW(), expires_at) + seat_validity_days,status = active - 整单:原
purchase_id下全部 paid license 统一延长 - 若 license 曾绑定设备且当前无占席,自动恢复绑定
退款接口(应用层,待后续任务):
13. 最近变更
- 2026-06-20:初版,用户直挂 Seat 极简计费模型
- 2026-06-20:DDL 补全全部字段与表级 COMMENT
- 2026-06-20:改为 license + purchase 模型,支持分批购买与独立到期日
- 2026-06-20:注册事务内发放 included 免费席位(
BillingSeatRepository) - 2026-06-16:补齐席位状态 API、指定 license 换绑、设备注册自动占席/只读、控制动作席位门禁与控制台席位管理入口
- 2026-06-23:T0076 邀请有礼:邀请码、72h 在线累计、双方 invitation 永久席、控制台邀请页