feat(license): 新增在线授权(App Store 账号自助签发 + 自动续期)

- OnlineLicense 模块:登录/试用/续期/释放/状态机,离线↔在线互斥(last-write-wins)
- LicenseController + 动态路由;容器内 supervisor 独立进程定时续期(不依赖 LARAVELS_TIMER)
- license.vue 双 Tab:在线授权 + 离线绑定二次确认,已绑定在线时离线页提示+按需绑定
- 进入授权页静默刷新;同步 ai-kb 在线授权知识库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-22 08:22:26 +00:00
parent 6a4f815d5a
commit 4ca7fc10d1
18 changed files with 1045 additions and 10 deletions

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Module\OnlineLicense;
use Illuminate\Console\Command;
/**
* 在线授权续期(容器内独立进程按小时调用,无需 LARAVELS_TIMER、不经过 HTTP 转发)。
*
* php 容器 supervisor 程序 [program:license] 循环调用:
* while true; do php artisan online-license:renew; sleep 3600; done
*/
class OnlineLicenseRenew extends Command
{
protected $signature = 'online-license:renew';
protected $description = '在线授权:本地状态机推进 + 租约将尽时自动续期';
public function handle(): int
{
if (!OnlineLicense::enabled()) {
return 0;
}
OnlineLicense::cron();
$status = OnlineLicense::status();
$this->info('online-license: ' . ($status['status'] ?? 'offline') . ' lease=' . ($status['lease_expired_at'] ?? '-'));
return 0;
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use Request;
/**
* 在线授权客户端(与 SystemController::license 的离线粘贴并存)。
*
* 动态路由routes/web.php
* api/license/login -> login()
* api/license/trial/send -> trial__send()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
* api/license/logout -> logout()
*/
class LicenseController extends AbstractController
{
/**
* 账号登录并签发在线授权
*/
public function login()
{
User::auth('admin');
$account = trim(Request::input('account'));
$password = trim(Request::input('password'));
if ($account === '' || $password === '') {
return Base::retError('请输入账号和密码');
}
$data = OnlineLicense::login($account, $password);
return Base::retSuccess('授权成功', $data);
}
/**
* 发送试用验证码
*/
public function trial__send()
{
User::auth('admin');
$account = trim(Request::input('account'));
$password = trim(Request::input('password'));
if ($account === '' || $password === '') {
return Base::retError('请输入账号和密码');
}
$email = OnlineLicense::trialSend($account, $password);
return Base::retSuccess('验证码已发送', ['email' => $email]);
}
/**
* 申请试用并签发
*/
public function trial()
{
User::auth('admin');
$account = trim(Request::input('account'));
$password = trim(Request::input('password'));
$code = trim(Request::input('code'));
if ($account === '' || $password === '' || $code === '') {
return Base::retError('请输入账号、密码和验证码');
}
$data = OnlineLicense::trial($account, $password, $code);
return Base::retSuccess('试用已开通', $data);
}
/**
* 当前在线授权状态
*/
public function status()
{
User::auth('admin');
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 进入授权页时的静默刷新:服务可达则更新授权数据,网络失败则不更新、不提示。
*/
public function refresh()
{
User::auth('admin');
OnlineLicense::refresh();
return Base::retSuccess('success', OnlineLicense::status());
}
/**
* 退出在线授权(释放座位 + 回落默认)
*/
public function logout()
{
User::auth('admin');
OnlineLicense::logout();
return Base::retSuccess('已退出在线授权');
}
}

View File

@ -13,6 +13,7 @@ use Carbon\Carbon;
use App\Module\Doo;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use App\Module\Timer;
use App\Models\Setting;
use LdapRecord\Container;
@ -857,6 +858,8 @@ class SystemController extends AbstractController
if ($type == 'save') {
$license = Request::input('license');
Doo::licenseSave($license);
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
OnlineLicense::switchToOffline();
}
//
$data = [
@ -892,6 +895,11 @@ class SystemController extends AbstractController
if ($data['info']['expired_at'] && strtotime($data['info']['expired_at']) <= Timer::time()) {
$data['error'][] = '终端License已过期';
}
// 在线授权:把状态机提醒并入 error[]dashboard 警告条与本页错误展示自动复用),并附在线状态
foreach (OnlineLicense::stageMessages() as $msg) {
$data['error'][] = $msg;
}
$data['online'] = OnlineLicense::status();
//
if ($type === 'error') {
$data = [

View File

@ -0,0 +1,442 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use Carbon\Carbon;
use Illuminate\Support\Facades\Crypt;
/**
* 在线授权客户端编排。
*
* 在线授权产出的仍是现有格式的离线 license blob只是「获取方式」变成用 appstore 账号登录
* 自助签发、并由本类定时续期。doo.so 本地校验、license 文件存储全部复用。绑定状态以单例
* 形式存于 settings name=onlineLicenseinstance_token Crypt 加密。
*
* 四级状态机(基于租约内嵌到期 lease_expired_at 与本地宽限,全程不依赖 appstore 可达):
* active 续期正常
* reminder 续期失败/租约剩余不足 warn_days仅管理员可见提醒
* frozen 租约已过期doo.so 既有行为:限制新增用户)
* revoked 冻结超过 grace_days appstore 明确吊销 回落默认 3 人版
*/
class OnlineLicense
{
const KEY = 'onlineLicense';
// ---- 配置 ----
protected static function appstoreUrl(): string
{
return rtrim((string)config('dootask.online_license_appstore_url'), '/');
}
protected static function renewWithinDays(): int
{
return (int)config('dootask.online_license_renew_within_days', 20);
}
protected static function warnDays(): int
{
return (int)config('dootask.online_license_warn_days', 7);
}
protected static function graceDays(): int
{
return (int)config('dootask.online_license_grace_days', 14);
}
// ---- 状态读写(单例 settings----
public static function get(): array
{
return Base::setting(self::KEY) ?: [];
}
protected static function set(array $patch): array
{
$next = array_merge(self::get(), $patch);
Base::setting(self::KEY, $next);
return $next;
}
public static function enabled(): bool
{
$s = self::get();
return !empty($s['enabled']) && ($s['mode'] ?? '') === 'online';
}
protected static function token(): string
{
$enc = self::get()['instance_token'] ?? '';
if (empty($enc)) {
return '';
}
try {
return Crypt::decryptString($enc);
} catch (\Throwable) {
return '';
}
}
protected static function fingerprint(): array
{
return [
'sn' => Doo::dooSN(),
'macs' => implode(',', Doo::macs()),
'url' => (string)config('app.url'),
'version' => Doo::dooVersion(),
];
}
// ---- appstore 调用 ----
/**
* appstore license 接口。返回 ['ok'=>bool, 'data'=>array, 'message'=>string]
* $bearer 非空时带实例令牌(续期/释放)。
*/
protected static function call(string $path, array $payload, string $bearer = ''): array
{
$url = self::appstoreUrl() . '/api/v1/license/' . ltrim($path, '/');
$headers = ['Content-Type' => 'application/json'];
if ($bearer !== '') {
$headers['Authorization'] = 'Bearer ' . $bearer;
}
$resp = Ihttp::ihttp_request($url, json_encode($payload, JSON_UNESCAPED_UNICODE), $headers, 15);
if (Base::isError($resp)) {
return ['ok' => false, 'data' => [], 'message' => $resp['msg'] ?: '无法连接授权服务'];
}
$body = Base::json2array($resp['data'] ?? '');
if (($body['code'] ?? 0) !== 200) {
return ['ok' => false, 'data' => [], 'message' => $body['message'] ?: '授权服务返回错误'];
}
return ['ok' => true, 'data' => $body['data'] ?? [], 'message' => ''];
}
/**
* 处理签发结果issued/renewed 则落地 license + 更新绑定状态;其它状态原样返回供上层决策。
*/
protected static function applyIssue(string $account, array $d): string
{
$status = $d['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
$blob = $d['license'] ?? '';
if ($blob === '') {
throw new ApiException('授权服务未返回 license');
}
Doo::licenseSave($blob); // 复用离线落地与 doo.so 校验
$snap = $d['snapshot'] ?? [];
$patch = [
'enabled' => true,
'mode' => 'online',
'account' => $account,
'plan' => $snap['plan'] ?? '',
'people' => $snap['people'] ?? 0,
'valid_until' => $snap['valid_until'] ?? null,
'lease_expired_at' => $snap['lease_expired_at'] ?? null,
'server_status' => $status,
'error_count' => 0,
'last_error' => '',
'frozen_since' => null,
'last_renewed_at' => Carbon::now()->toDateTimeString(),
];
if (!empty($d['instance_token'])) {
$patch['instance_token'] = Crypt::encryptString($d['instance_token']);
}
self::set($patch);
self::computeStage();
}
return $status;
}
// ---- 对外动作 ----
/**
* 账号登录并签发。失败抛 ApiException。
*/
public static function login(string $account, string $password): array
{
$r = self::call('login', array_merge(['account' => $account, 'password' => $password], self::fingerprint()));
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($account, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 发送试用验证码,返回脱敏邮箱。
*/
public static function trialSend(string $account, string $password): string
{
$r = self::call('trial/send', ['account' => $account, 'password' => $password]);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
return $r['data']['email'] ?? '';
}
/**
* 申请试用并签发。
*/
public static function trial(string $account, string $password, string $code): array
{
$payload = array_merge(['account' => $account, 'password' => $password, 'code' => $code], self::fingerprint());
$r = self::call('trial', $payload);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($account, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 续期(定时任务调用)。不抛异常:网络/服务错误只累加计数,最终由状态机本地降级。
*/
public static function renew(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token === '') {
return;
}
$r = self::call('renew', self::fingerprint(), $token);
if (!$r['ok']) {
$s = self::get();
self::set([
'error_count' => (int)($s['error_count'] ?? 0) + 1,
'last_error' => $r['message'],
]);
self::computeStage();
return;
}
$status = $r['data']['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
self::applyIssue(self::get()['account'] ?? '', $r['data']);
return;
}
// 服务侧明确状态revoked/suspended/no_entitlement不延长租约记录后交状态机
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
self::computeStage();
}
/**
* 是否到了该续期的时间(租约剩余不足 renew_within_days
*/
public static function dueForRenew(): bool
{
$lease = self::get()['lease_expired_at'] ?? null;
if (!$lease) {
return true;
}
return Carbon::parse($lease)->lte(Carbon::now()->addDays(self::renewWithinDays()));
}
/**
* 定时续期入口:由容器内独立进程的 artisan 命令online-license:renew按小时调用。
* 先本地状态机推进(断网也能降级 frozen→revoked再在租约将尽时续期。
*/
public static function cron(): void
{
if (!self::enabled()) {
return;
}
self::computeStage();
if (self::enabled() && self::dueForRenew()) {
self::renew();
}
}
/**
* 进入授权页时的静默刷新:服务可达则按服务结果更新(成功续签 / 反映吊销冻结),
* 网络失败则什么都不做、不提示、不降级(避免一次页面刷新失败就误报)。
*/
public static function refresh(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token === '') {
return;
}
try {
$r = self::call('renew', self::fingerprint(), $token);
if (!$r['ok']) {
return; // 刷新失败:不更新、不提示
}
$status = $r['data']['status'] ?? '';
if (in_array($status, ['issued', 'renewed'], true)) {
self::applyIssue(self::get()['account'] ?? '', $r['data']);
} elseif (in_array($status, ['revoked', 'suspended', 'no_entitlement'], true)) {
// 服务侧明确结果(非网络失败):如实反映
self::set(['server_status' => $status, 'last_error' => self::statusHint($status)]);
self::computeStage();
}
} catch (\Throwable) {
// 忽略,保持现状
}
}
/**
* 退出在线授权:释放座位 + 回落默认。
*/
public static function logout(): void
{
$token = self::token();
if ($token !== '') {
self::call('deactivate', [], $token);
}
self::fallbackToDefault();
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
}
/**
* 切换到离线授权(互斥):保存离线 license 后调用。
* 尽力释放在线座位 + 清在线标志,但「不」删除 license 文件(刚保存的离线 license 要保留)。
*/
public static function switchToOffline(): void
{
if (!self::enabled()) {
return;
}
$token = self::token();
if ($token !== '') {
self::call('deactivate', [], $token); // 尽力释放座位,失败忽略
}
Base::setting(self::KEY, ['enabled' => false, 'mode' => 'offline']);
}
// ---- 状态机 ----
/**
* 据租约到期 + 宽限重新计算 status并在 revoked 时执行降级。
*/
public static function computeStage(): string
{
$s = self::get();
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
return 'offline';
}
$now = Carbon::now();
$server = $s['server_status'] ?? '';
$lease = $s['lease_expired_at'] ?? null;
if ($server === 'revoked') {
return self::transitionRevoked();
}
if ($lease && Carbon::parse($lease)->lte($now)) {
// 租约已过期 → 冻结;超过宽限 → 吊销
$frozenSince = $s['frozen_since'] ?? null;
if (!$frozenSince) {
$frozenSince = $now->toDateTimeString();
self::set(['frozen_since' => $frozenSince]);
}
if (Carbon::parse($frozenSince)->addDays(self::graceDays())->lte($now)) {
return self::transitionRevoked();
}
self::set(['status' => 'frozen']);
return 'frozen';
}
// 租约有效
$remindByLease = $lease && Carbon::parse($lease)->lte($now->copy()->addDays(self::warnDays()));
$remindByError = (int)($s['error_count'] ?? 0) > 0 || $server === 'suspended' || $server === 'no_entitlement';
$status = ($remindByLease || $remindByError) ? 'reminder' : 'active';
self::set(['status' => $status, 'frozen_since' => null]);
return $status;
}
protected static function transitionRevoked(): string
{
self::fallbackToDefault();
self::set(['status' => 'revoked', 'enabled' => false]);
return 'revoked';
}
/**
* 删除在线 license 文件,让 dooso 回落默认 3 人版(触发既有超员禁用)。
*/
protected static function fallbackToDefault(): void
{
foreach (['LICENSE', 'license'] as $name) {
$path = config_path($name);
if (is_file($path)) {
@unlink($path);
}
}
}
// ---- 提醒文案(注入 system/license 的 error[],复用 dashboard 警告条与 license 页)----
public static function stageMessages(): array
{
if (!self::enabled() && (self::get()['status'] ?? '') !== 'revoked') {
return [];
}
$s = self::get();
$status = $s['status'] ?? self::computeStage();
$msgs = [];
switch ($status) {
case 'reminder':
if (($s['server_status'] ?? '') === 'suspended') {
$msgs[] = '在线授权已被冻结,请联系服务商';
} elseif ((int)($s['error_count'] ?? 0) > 0) {
$msgs[] = '在线授权续期失败,请检查网络';
} else {
$msgs[] = '在线授权即将到期,请保持联网续期';
}
break;
case 'frozen':
$msgs[] = '在线授权已过期,新增用户受限,请尽快续期';
break;
case 'revoked':
$msgs[] = '在线授权已失效,已回落到基础版';
break;
}
return $msgs;
}
protected static function statusHint(string $status): string
{
return match ($status) {
'no_entitlement' => '该账号暂无可用授权,请先申请试用或购买',
'revoked' => '该授权已被吊销',
'suspended' => '该授权已被冻结',
'seat_taken' => '该授权已在另一台实例使用,请先在原实例释放(换机)',
'entitlement_expired' => '该授权已到期',
default => '签发失败(' . $status . '',
};
}
/**
* 对外状态(前端在线 Tab / status 接口用,不含敏感 token
*/
public static function status(): array
{
$s = self::get();
if (($s['mode'] ?? '') !== 'online' || empty($s['enabled'])) {
return ['mode' => 'offline'];
}
return [
'mode' => 'online',
'account' => $s['account'] ?? '',
'plan' => $s['plan'] ?? '',
'people' => $s['people'] ?? 0,
'valid_until' => $s['valid_until'] ?? null,
'lease_expired_at' => $s['lease_expired_at'] ?? null,
'last_renewed_at' => $s['last_renewed_at'] ?? null,
'status' => $s['status'] ?? self::computeStage(),
'error_count' => (int)($s['error_count'] ?? 0),
'last_error' => $s['last_error'] ?? '',
];
}
}

View File

@ -32,4 +32,17 @@ return [
// 临时文件自动清理天数DeleteTmpTask
'auto_empty_temp_file' => env('AUTO_EMPTY_TEMP_FILE', 30),
// 在线授权appstore 授权中心地址OnlineLicense默认中央测试可指向 dev appstore
// [调试中] 临时指向本地 dev appstore发版前改回 'https://appstore.dootask.com'
'online_license_appstore_url' => env('ONLINE_LICENSE_APPSTORE_URL', 'https://appstore.dootask.com'),
// 在线授权租约剩余不足该天数时触发续期OnlineLicense
'online_license_renew_within_days' => env('ONLINE_LICENSE_RENEW_WITHIN_DAYS', 20),
// 在线授权租约剩余不足该天数时在提醒OnlineLicense
'online_license_warn_days' => env('ONLINE_LICENSE_WARN_DAYS', 7),
// 在线授权冻结租约过期后到吊销的宽限天数OnlineLicense
'online_license_grace_days' => env('ONLINE_LICENSE_GRACE_DAYS', 14),
];

View File

@ -198,6 +198,9 @@ return [
'jobs' => [
// Enable LaravelScheduleJob to run `php artisan schedule:run` every 1 minute, replace Linux Crontab
// Hhxsv5\LaravelS\Illuminate\LaravelScheduleJob::class,
// 在线授权续期改由容器内独立进程跑supervisor [program:license] + artisan online-license:renew
// 不再依赖 LARAVELS_TIMER见 docker/php/license.conf
],
// Max waiting time of reloading

View File

@ -11,6 +11,7 @@ services:
- shared_data:/usr/share/dootask
- ./docker/crontab/crontab.conf:/etc/supervisor/conf.d/crontab.conf
- ./docker/php/php.conf:/etc/supervisor/conf.d/php.conf
- ./docker/php/license.conf:/etc/supervisor/conf.d/license.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
- ./docker/logs/supervisor:/var/log/supervisor
- ./:/var/www

10
docker/php/license.conf Normal file
View File

@ -0,0 +1,10 @@
[program:license]
directory=/var/www
command=sh docker/php/license.sh
numprocs=1
autostart=true
autorestart=true
startretries=100
user=root
redirect_stderr=true
stdout_logfile=/var/log/supervisor/%(program_name)s.log

8
docker/php/license.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/sh
# 在线授权续期php 容器内的独立系统进程supervisor [program:license] 托管)。
# 每小时直接跑一次 artisan CLI——不依赖 LARAVELS_TIMER、不经过 HTTP 转发。
# artisan 失败(如启动时 DB 未就绪)不会中断本循环,下个周期自动重试。
while true; do
php /var/www/artisan online-license:renew
sleep 3600
done

View File

@ -999,3 +999,22 @@ AI 助手
反馈类型错误
会话不存在或无权限
无权限
请输入账号和密码
请输入账号、密码和验证码
授权成功
验证码已发送
试用已开通
已退出在线授权
该账号暂无可用授权,请先申请试用或购买
该授权已被吊销
该授权已被冻结
该授权已在另一台实例使用,请先在原实例释放(换机)
该授权已到期
授权服务未返回 license
无法连接授权服务
授权服务返回错误
在线授权已被冻结,请联系服务商
在线授权续期失败,请检查网络
在线授权即将到期,请保持联网续期
在线授权已过期,新增用户受限,请尽快续期
在线授权已失效,已回落到基础版

View File

@ -2469,3 +2469,35 @@ AI任务分析
操作引导启动失败
最多只能添加(*)个
该标签已存在
离线授权
在线授权
账号
套餐
租约到期
上次续期
当前状态
立即续期
授权有效期
退出在线授权
App Store账号
请输入App Store账号
验证码已发送至
登录授权
申请试用
确定试用
生效中
已冻结
已吊销
请输入账号和密码
授权成功
试用已开通
在线授权续期成功
已退出在线授权
确定退出在线授权?
已绑定在线授权
绑定离线 License
绑定离线授权
绑定在线授权
当前已绑定在线授权,绑定离线后将替换当前授权并释放在线座位,是否继续?
当前已绑定离线授权,绑定在线后将替换当前授权,是否继续?
请输入License

View File

@ -581,13 +581,14 @@ features:
scope: super-admin
batch: B4
priority: P1
chunk_count_est: 3
chunk_count_est: 4
owner: ~
status: drafted
chunks:
- license.concept
- license.expire.faq
- license.howto
- license.online.howto
- id: ldap
name: LDAP 集成

View File

@ -22,7 +22,8 @@ negative:
- License 失效不会立即锁死系统,但会持续在管理端报错
- 修改 License 接口save仅超级管理员能调用普通管理员只能看
- 不支持自行重置 SN重新部署会生成新 SN需要重新签发 License
last_verified: v1.7.90
- 若用的是在线授权error 数组还可能出现「在线授权即将到期/已过期/已失效」等提醒,处理见 [[license.online.howto]]
last_verified: v1.7.91
---
# License 过期或失效怎么办

View File

@ -23,13 +23,16 @@ negative:
- 不能在终端外部直接编辑 License 文件,必须走管理端 API
- 一份 License 仅对当前终端的 SN + MAC 有效,换机或换网卡需重新申请
- 不支持把 License 拆给多个独立部署共享
last_verified: v1.7.90
last_verified: v1.7.91
---
# 申请与录入 License
> 本文介绍**离线授权**(手动粘贴 License 原文。License 页现有「离线授权 / 在线授权」两个 Tab
> 用 App Store 账号登录自助签发并自动续期的方式见 [[license.online.howto]]。
## 入口
桌面端:左上角头像 →「系统设置」→「License」仅管理员可见
桌面端:左上角头像 →「系统设置」→「License」→「离线授权」Tab(仅管理员可见)。
对应后端:`POST api/system/license``type=save` 写入。
## 操作步骤

View File

@ -0,0 +1,83 @@
---
id: license.online.howto
title: 在线授权(账号登录 / 申请试用 / 自动续期)
type: howto
feature: license
scope: super-admin
locale: zh
aliases:
- 在线授权
- 账号授权
- 用 appstore 账号授权
- 申请试用
- 试用授权
- 在线 License
- 自动续期
- 授权账号登录
- 在线授权到期
- 在线授权冻结
- 在线授权被吊销
- 换机 deactivate
related_tools: []
related_pages: []
prerequisites:
- 需要进入「系统设置」→「License」页面仅管理员可见切到「在线授权」Tab
- 需要一个在 App Store 申请的账号(开发者账号),登录后可在 App Store 门户「我的授权」查看
- 终端需能联网访问 App Store 授权中心
negative:
- 在线授权与离线授权互斥:同一时刻只有一张生效 License切到在线并登录后会接管 License 文件
- 离线授权(粘贴 License 原文)完全不受影响,没有自动续期
- 一个账号同一时刻只占用一个实例座位,换机需先在原实例「退出在线授权」释放
- 试用每个账号仅一次,时长由 App Store 管理员配置且硬上限 60 天
last_verified: v1.7.91
---
# 在线授权(账号登录 / 申请试用 / 自动续期)
DooTask 的 License 页提供两种授权方式,可在页面顶部 Tab 切换:
- **离线授权**:手动粘贴 License 原文(见 [[license.howto]]),无自动续期。
- **在线授权**:用 App Store 账号登录自助签发,终端自动定时续期(类似 JetBrains 账号激活)。
## 入口
桌面端:左上角头像 →「系统设置」→「License」→ 顶部「在线授权」Tab仅管理员可见
## 怎么用
### 已有账号 + 已有授权
1. 在「在线授权」Tab 输入 App Store 账号、密码
2. 点击「登录授权」
3. 终端把自身指纹doo_sn、网卡 MAC、版本上报授权中心签发一张租约 License 并落地
4. 成功后页面显示套餐、使用人数、租约到期、当前状态
### 申请试用(没有正式授权时)
1. 在「在线授权」Tab 输入账号、密码
2. 点击「申请试用」→ 系统向账号注册邮箱发送验证码
3. 填入验证码 → 点击「确定试用」即开通试用授权并签发
4. 试用默认 14 天 / 不限人数(具体以 App Store 管理员配置为准,时长硬上限 60 天),每个账号仅能申请一次
### 日常维护
- **自动续期**:终端每小时检查,租约将尽时自动向授权中心续期,无需人工干预(需保持联网)
- **立即续期**:状态页「立即续期」按钮可手动触发一次
- **换机 / 退出**:「退出在线授权」会释放该账号在授权中心占用的座位,并把终端回落到基础版;换机时先在原实例退出,再到新实例登录
## 状态与提醒(断网/欠费时的分级降级)
在线授权按租约 + 本地宽限分四级提醒会显示在「License」页与仪表盘顶部横幅仅管理员可见
| 状态 | 含义 | 影响 |
| --- | --- | --- |
| 生效中 | 续期正常 | 无 |
| 即将到期 | 续期失败或租约临近 | 仅提醒 |
| 已冻结 | 租约已过期 | 限制新增用户(沿用离线过期的既有行为) |
| 已吊销 | 冻结超过宽限期或授权被收回 | 回落基础版(最多 3 人,超出的账号按既有规则禁用) |
只要在租约窗口内恢复联网并成功续期一次,即可回到「生效中」。
## 怎么自检
- 接口 `POST api/license/status` 返回当前在线授权状态mode/plan/people/lease_expired_at/status
- 提醒文案同样并入 `POST api/system/license``error` 数组,便于脚本巡检
## 不支持
- 普通管理员能看授权信息但不能登录/签发只有超级管理员id=1能操作
- 在线授权依赖联网;完全离线/内网隔离部署请用离线授权(粘贴原文)
- 在 App Store 内购买 License 为后续能力,当前在线授权面向已售出/试用账号

View File

@ -1,5 +1,31 @@
<template>
<div class="setting-item submit">
<div class="setting-item submit license-setting">
<Tabs v-model="mode">
<TabPane :label="$L('离线授权')" name="offline">
<template v-if="onlineActive">
<div class="license-box">
<ul class="online-info">
<li><em>{{$L('当前状态')}}:</em><span class="online-link" @click="mode = 'online'">{{$L('已绑定在线授权')}}</span></li>
<li><em>SN:</em><span>{{formData.doo_sn}}</span></li>
<li><em>MAC:</em><span>{{infoJoin(formData.macs)}}</span></li>
</ul>
</div>
<div v-if="!offlineRebindShow" class="setting-footer">
<Button type="primary" @click="offlineRebindShow = true">{{$L('绑定离线 License')}}</Button>
</div>
<template v-else>
<Form :model="formData" v-bind="formOptions" @submit.native.prevent>
<FormItem label="License">
<Input v-model="offlineRebindLicense" type="textarea" :autosize="{minRows: 2,maxRows: 5}" :placeholder="$L('请输入License...')" />
</FormItem>
</Form>
<div class="setting-footer">
<Button :loading="loadIng > 0" type="primary" @click="offlineRebindSubmit">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="offlineRebindCancel" style="margin-left: 8px">{{$L('取消')}}</Button>
</div>
</template>
</template>
<template v-else>
<Form ref="formData" :model="formData" v-bind="formOptions" @submit.native.prevent>
<FormItem label="License" prop="license">
<Input v-model="formData.license" type="textarea" :autosize="{minRows: 2,maxRows: 5}" :placeholder="$L('请输入License...')" />
@ -70,7 +96,7 @@
<em>MAC:</em>
<span>{{infoJoin(formData.macs)}}</span>
</li>
<li v-for="tip in formData.error" class="warning">{{tip}}</li>
<li v-for="(tip, ti) in formData.error" :key="ti" class="warning">{{tip}}</li>
</ul>
</div>
</FormItem>
@ -79,6 +105,45 @@
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="resetForm" style="margin-left: 8px">{{$L('重置')}}</Button>
</div>
</template>
</TabPane>
<TabPane :label="$L('在线授权')" name="online">
<div v-if="onlineActive" class="license-box">
<ul class="online-info">
<li><em>{{$L('账号')}}:</em><span>{{online.account}}</span></li>
<li><em>{{$L('套餐')}}:</em><span>{{online.plan || '-'}}</span></li>
<li><em>{{$L('使用人数')}}:</em><span>{{online.people || $L('无限制')}}</span></li>
<li><em>{{$L('授权有效期')}}:</em><span>{{online.valid_until ? fmt(online.valid_until) : $L('永久')}}</span></li>
<li>
<em>{{$L('当前状态')}}:</em>
<span :class="{warning: online.status !== 'active'}">{{stageText(online.status)}}</span>
</li>
</ul>
<div class="setting-footer">
<Button :loading="onlineIng > 0" @click="onlineLogout">{{$L('退出在线授权')}}</Button>
</div>
</div>
<template v-else>
<Form :model="onlineForm" v-bind="formOptions" @submit.native.prevent>
<FormItem :label="$L('App Store账号')">
<Input v-model="onlineForm.account" :placeholder="$L('请输入App Store账号')" />
</FormItem>
<FormItem :label="$L('密码')">
<Input v-model="onlineForm.password" type="password" :placeholder="$L('请输入密码')" />
</FormItem>
<FormItem v-if="trialStep === 1" :label="$L('邮箱验证码')">
<Input v-model="onlineForm.code" :placeholder="$L('请输入验证码')" />
<div class="online-tip">{{$L('验证码已发送至')}} {{trialEmail}}</div>
</FormItem>
</Form>
<div class="setting-footer">
<Button :loading="onlineIng > 0" type="primary" @click="onlineLogin">{{$L('登录授权')}}</Button>
<Button v-if="trialStep === 0" :loading="onlineIng > 0" @click="trialSend" style="margin-left: 8px">{{$L('申请试用')}}</Button>
<Button v-else :loading="onlineIng > 0" type="success" @click="trialSubmit" style="margin-left: 8px">{{$L('确定试用')}}</Button>
</div>
</template>
</TabPane>
</Tabs>
</div>
</template>
@ -86,6 +151,16 @@
.license-box {
padding-top: 6px;
> ul {
&.online-info {
padding-left: 24px;
.online-link {
cursor: pointer;
color: #2d8cf0;
&:hover {
text-decoration: underline;
}
}
}
> li {
list-style: none;
font-size: 14px;
@ -116,6 +191,12 @@
}
}
}
.online-tip {
font-size: 12px;
line-height: 20px;
margin-top: 4px;
opacity: 0.6;
}
</style>
<script>
import {mapState} from "vuex";
@ -131,15 +212,42 @@ export default {
macs: [],
doo_sn: '',
user_count: 0,
error: []
error: [],
online: {},
},
mode: 'offline',
tabInited: false,
offlineRebindShow: false,
offlineRebindLicense: '',
onlineIng: 0,
onlineForm: {
account: '',
password: '',
code: '',
},
trialStep: 0,
trialEmail: '',
}
},
mounted() {
this.systemSetting();
this.onlineRefresh();
},
computed: {
...mapState(['userInfo', 'formOptions']),
online() {
return this.formData.online || {};
},
onlineActive() {
return this.online.mode === 'online';
},
// 线 = license 线
offlineBound() {
return !this.onlineActive && !!String(this.formData.license || '').trim();
},
},
methods: {
submitForm() {
@ -154,6 +262,40 @@ export default {
this.formData = $A.cloneJSON(this.formData_bak);
},
offlineRebindCancel() {
this.offlineRebindShow = false;
this.offlineRebindLicense = '';
},
// 线线线 License
offlineRebindSubmit() {
if (!String(this.offlineRebindLicense).trim()) {
$A.messageError('请输入License');
return;
}
$A.modalConfirm({
title: '绑定离线授权',
content: '当前已绑定在线授权,绑定离线后将替换当前授权并释放在线座位,是否继续?',
onOk: () => {
this.loadIng++;
return this.$store.dispatch("call", {
url: 'system/license',
data: {type: 'save', license: this.offlineRebindLicense},
method: 'post',
}).then(({data}) => {
$A.messageSuccess('修改成功');
this.formData = data;
this.formData_bak = $A.cloneJSON(this.formData);
this.offlineRebindCancel();
}).catch(({msg}) => {
$A.modalError(msg); //
}).finally(_ => {
this.loadIng--;
});
}
});
},
systemSetting(save) {
this.loadIng++;
this.$store.dispatch("call", {
@ -168,6 +310,13 @@ export default {
}
this.formData = data;
this.formData_bak = $A.cloneJSON(this.formData);
// 线线 Tab
if (!this.tabInited) {
this.tabInited = true;
if (data.online && data.online.mode === 'online') {
this.mode = 'online';
}
}
}).catch(({msg}) => {
if (save) {
$A.modalError(msg);
@ -177,6 +326,18 @@ export default {
});
},
onlineRefresh() {
// 线
this.$store.dispatch("call", {
url: 'license/refresh',
method: 'post',
}).catch(() => {
//
}).finally(() => {
this.systemSetting();
});
},
infoJoin(val, def = null) {
if ($A.isArray(val)) {
val = val.join(",")
@ -195,7 +356,116 @@ export default {
val2 = [val2]
}
return val1.some(v => val2.includes(v))
}
},
fmt(dt) {
return dt ? $A.dayjs(dt).format('YYYY-MM-DD HH:mm') : '-';
},
stageText(status) {
return {
active: this.$L('生效中'),
reminder: this.$L('即将到期'),
frozen: this.$L('已冻结'),
revoked: this.$L('已吊销'),
}[status] || status || '-';
},
onlineCall(url, data, successMsg) {
this.onlineIng++;
return this.$store.dispatch("call", {
url,
data,
method: 'post',
}).then((res) => {
if (successMsg) {
$A.messageSuccess(successMsg);
}
return res;
}).catch(({msg}) => {
$A.modalError(msg);
return Promise.reject(msg);
}).finally(_ => {
this.onlineIng--;
});
},
// 线线
confirmReplaceOffline(onOk) {
if (this.offlineBound) {
$A.modalConfirm({
title: '绑定在线授权',
content: '当前已绑定离线授权,绑定在线后将替换当前授权,是否继续?',
onOk,
});
} else {
onOk();
}
},
onlineLogin() {
if (!this.onlineForm.account || !this.onlineForm.password) {
$A.messageError('请输入账号和密码');
return;
}
this.confirmReplaceOffline(() => {
this.onlineCall('license/login', {
account: this.onlineForm.account,
password: this.onlineForm.password,
}, '授权成功').then(_ => {
this.resetOnlineForm();
this.systemSetting();
});
});
},
trialSend() {
if (!this.onlineForm.account || !this.onlineForm.password) {
$A.messageError('请输入账号和密码');
return;
}
this.onlineCall('license/trial/send', {
account: this.onlineForm.account,
password: this.onlineForm.password,
}).then(({data}) => {
this.trialStep = 1;
this.trialEmail = data?.email || '';
});
},
trialSubmit() {
if (!this.onlineForm.code) {
$A.messageError('请输入验证码');
return;
}
this.confirmReplaceOffline(() => {
this.onlineCall('license/trial', {
account: this.onlineForm.account,
password: this.onlineForm.password,
code: this.onlineForm.code,
}, '试用已开通').then(_ => {
this.resetOnlineForm();
this.systemSetting();
});
});
},
onlineLogout() {
$A.modalConfirm({
content: '确定退出在线授权?',
onOk: () => {
this.onlineCall('license/logout', {}, '已退出在线授权').then(_ => {
this.systemSetting();
});
}
});
},
resetOnlineForm() {
this.onlineForm = {account: '', password: '', code: ''};
this.trialStep = 0;
this.trialEmail = '';
},
}
}
</script>

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:298
接口总数:304
## 路由规则
@ -196,6 +196,17 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| api/system/version | version() | get | 获取版本号 |
| api/system/prefetch | prefetch() | get | 预加载的资源 |
## licenseLicenseController
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/license/login | login() | any | |
| api/license/trial/send | trial__send() | any | |
| api/license/trial | trial() | any | |
| api/license/status | status() | any | |
| api/license/refresh | refresh() | any | |
| api/license/logout | logout() | any | |
## dialogDialogController
| URL | 方法名 | HTTP | 说明 |

View File

@ -9,6 +9,7 @@ use App\Http\Controllers\Api\DialogController;
use App\Http\Controllers\Api\PublicController;
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\SystemController;
use App\Http\Controllers\Api\LicenseController;
use App\Http\Controllers\Api\AssistantController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ComplaintController;
@ -39,6 +40,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
// 系统
Route::any('system/{method}', SystemController::class);
Route::any('system/{method}/{action}', SystemController::class);
// 在线授权
Route::any('license/{method}', LicenseController::class);
Route::any('license/{method}/{action}', LicenseController::class);
// 对话
Route::any('dialog/{method}', DialogController::class);
Route::any('dialog/{method}/{action}', DialogController::class);