Skip to content

SaaS 计费模块

1. 模块目标

用户 + 席位(Seat) 为唯一计费维度:全功能开放,默认 2 席免费,增机增席按数量付费。不引入组织 / 团队层。

2. 核心规则

  • 1 Seat = 1 Device
  • 1 条席位许可证(license)= 1 个可用席位槽位,可有独立 expires_at
  • 免费席:source=includedexpires_at=NULL(永久),默认 2 条
  • 邀请奖励席:source=invitationexpires_at=NULL(永久),每成功邀请 1 人双方各 1 条(见 T0076)
  • 付费席:每次购买生成 N 条 license,同一订单共享 expires_at
  • 占设备billing_seat_allocationsmachine_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 到期statusexpired;若已绑设备 → 释放 allocation,该设备只读(不停机不切币)
续费单条 licenseexpires_at += validity_days(如 31),statusactive;若设备仍在且未改绑 → 自动恢复全功能
续费整单(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_purchasespurchased_atexpires_atquantity
席位许可证(一条一席)billing_seat_licensesexpires_atpurchase_idsource
占席事实billing_seat_allocationslicense_id + machine_id
设备归属machines.user_id(不变)
席位展示machines.seat_status(冗余)

5. Schema Patch 执行顺序

  1. docs/schema-patches/20260620-saas-user-seat-billing.sql(DDL)
  2. docs/schema-patches/20260620-saas-migrate-user-seats.sql(存量迁移,幂等)
  3. docs/schema-patches/20260616-saas-seat-allocation-gates.sql(修正机器默认席位状态、allocation 历史记录索引与存量状态同步)
  4. docs/schema-patches/20260617-saas-seat-billing-payments.sql(购买订单 pending 状态与 Antom 幂等键)
  5. 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.sqlmachines.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 → allocation

5.3 示例:3/5/15 各买 3 席(31 天)

假设购买月为 3 月:

订单purchased_atquantityexpires_atlicense 条数
#13/334/33
#23/534/53
#33/1534/153

另加 2 条 source=includedexpires_at=NULL

4/3 到期:仅订单 #1 的 3 条 license → expired;仅绑在这 3 条上的设备只读,订单 #2/#3 的 6 台不受影响。

续费:用户对订单 #1 续费 → 该 3 条 expires_at 延至 5/3(+31 天),status=active;原机器自动恢复。

6. 注册发放席位(已实现)

注册成功时(AuthService::register):

  1. 校验 billing_seat_licenses 表已存在
  2. 事务内创建 users
  3. billing_plan.included_seats 插入 N 条 source=includedexpires_at=NULL 的 license
  4. 响应附带 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_systemsmetric_powersmetric_minersmachine_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 内部直接写入 10 缓存
  • 席位过期、续费、增购或后台批量修正:对应任务应调用 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/renewlicense_id 续单条,或 purchase_id 续整单);支付成功后 expires_at = GREATEST(now, 原到期) + seat_validity_days,并尝试恢复占席
  • 到期 jobphp 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/summaryGET /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

逻辑:

  1. SELECT id FROM billing_seat_licenses WHERE status='active' AND expires_at IS NOT NULL AND expires_at <= NOW()
  2. 批量 UPDATE status='expired'
  3. 释放对应 billing_seat_allocations 并同步 machines.seat_status
  4. 调用 device-control invalidateSeatCache

续费接口(应用层,已实现):

  1. POST /api/billing/checkout/renew + Antom 支付
  2. 单条:expires_at = GREATEST(NOW(), expires_at) + seat_validity_daysstatus = active
  3. 整单:原 purchase_id 下全部 paid license 统一延长
  4. 若 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 永久席、控制台邀请页