From 4ca7fc10d1cd6914230ec384805e7805080f6a76 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 22 Jun 2026 08:22:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(license):=20=E6=96=B0=E5=A2=9E=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E6=8E=88=E6=9D=83=EF=BC=88App=20Store=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=87=AA=E5=8A=A9=E7=AD=BE=E5=8F=91=20+=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BB=AD=E6=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnlineLicense 模块:登录/试用/续期/释放/状态机,离线↔在线互斥(last-write-wins) - LicenseController + 动态路由;容器内 supervisor 独立进程定时续期(不依赖 LARAVELS_TIMER) - license.vue 双 Tab:在线授权 + 离线绑定二次确认,已绑定在线时离线页提示+按需绑定 - 进入授权页静默刷新;同步 ai-kb 在线授权知识库 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Console/Commands/OnlineLicenseRenew.php | 29 ++ .../Controllers/Api/LicenseController.php | 97 ++++ app/Http/Controllers/Api/SystemController.php | 8 + app/Module/OnlineLicense.php | 442 ++++++++++++++++++ config/dootask.php | 13 + config/laravels.php | 3 + docker-compose.yml | 1 + docker/php/license.conf | 10 + docker/php/license.sh | 8 + language/original-api.txt | 19 + language/original-web.txt | 32 ++ resources/ai-kb/_meta/feature-map.yaml | 3 +- resources/ai-kb/zh/faq/license/expire.md | 3 +- resources/ai-kb/zh/howto/license/apply.md | 7 +- resources/ai-kb/zh/howto/license/online.md | 83 ++++ .../js/pages/manage/setting/license.vue | 280 ++++++++++- routes/api-map.md | 13 +- routes/web.php | 4 + 18 files changed, 1045 insertions(+), 10 deletions(-) create mode 100644 app/Console/Commands/OnlineLicenseRenew.php create mode 100644 app/Http/Controllers/Api/LicenseController.php create mode 100644 app/Module/OnlineLicense.php create mode 100644 docker/php/license.conf create mode 100644 docker/php/license.sh create mode 100644 resources/ai-kb/zh/howto/license/online.md diff --git a/app/Console/Commands/OnlineLicenseRenew.php b/app/Console/Commands/OnlineLicenseRenew.php new file mode 100644 index 000000000..83abb550b --- /dev/null +++ b/app/Console/Commands/OnlineLicenseRenew.php @@ -0,0 +1,29 @@ +info('online-license: ' . ($status['status'] ?? 'offline') . ' lease=' . ($status['lease_expired_at'] ?? '-')); + return 0; + } +} diff --git a/app/Http/Controllers/Api/LicenseController.php b/app/Http/Controllers/Api/LicenseController.php new file mode 100644 index 000000000..3c87748a4 --- /dev/null +++ b/app/Http/Controllers/Api/LicenseController.php @@ -0,0 +1,97 @@ + 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('已退出在线授权'); + } +} diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index d4b900f26..8e369e90b 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -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 = [ diff --git a/app/Module/OnlineLicense.php b/app/Module/OnlineLicense.php new file mode 100644 index 000000000..b81da239e --- /dev/null +++ b/app/Module/OnlineLicense.php @@ -0,0 +1,442 @@ + 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'] ?? '', + ]; + } +} diff --git a/config/dootask.php b/config/dootask.php index f5ff4f0f9..ee556ee05 100644 --- a/config/dootask.php +++ b/config/dootask.php @@ -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), + ]; diff --git a/config/laravels.php b/config/laravels.php index 51602f2f6..a238405af 100644 --- a/config/laravels.php +++ b/config/laravels.php @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 785e262c9..4809b49f6 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/php/license.conf b/docker/php/license.conf new file mode 100644 index 000000000..c73cbd04b --- /dev/null +++ b/docker/php/license.conf @@ -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 diff --git a/docker/php/license.sh b/docker/php/license.sh new file mode 100644 index 000000000..5257371bf --- /dev/null +++ b/docker/php/license.sh @@ -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 diff --git a/language/original-api.txt b/language/original-api.txt index 6b98ddcfe..4adf5fb1b 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -999,3 +999,22 @@ AI 助手 反馈类型错误 会话不存在或无权限 无权限 +请输入账号和密码 +请输入账号、密码和验证码 +授权成功 +验证码已发送 +试用已开通 +已退出在线授权 +该账号暂无可用授权,请先申请试用或购买 +该授权已被吊销 +该授权已被冻结 +该授权已在另一台实例使用,请先在原实例释放(换机) +该授权已到期 +授权服务未返回 license +无法连接授权服务 +授权服务返回错误 +在线授权已被冻结,请联系服务商 +在线授权续期失败,请检查网络 +在线授权即将到期,请保持联网续期 +在线授权已过期,新增用户受限,请尽快续期 +在线授权已失效,已回落到基础版 diff --git a/language/original-web.txt b/language/original-web.txt index 6820838c2..fe92cde26 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2469,3 +2469,35 @@ AI任务分析 操作引导启动失败 最多只能添加(*)个 该标签已存在 +离线授权 +在线授权 +账号 +套餐 +租约到期 +上次续期 +当前状态 +立即续期 +授权有效期 +退出在线授权 +App Store账号 +请输入App Store账号 +验证码已发送至 +登录授权 +申请试用 +确定试用 +生效中 +已冻结 +已吊销 +请输入账号和密码 +授权成功 +试用已开通 +在线授权续期成功 +已退出在线授权 +确定退出在线授权? +已绑定在线授权 +绑定离线 License +绑定离线授权 +绑定在线授权 +当前已绑定在线授权,绑定离线后将替换当前授权并释放在线座位,是否继续? +当前已绑定离线授权,绑定在线后将替换当前授权,是否继续? +请输入License diff --git a/resources/ai-kb/_meta/feature-map.yaml b/resources/ai-kb/_meta/feature-map.yaml index e75f8ae72..92b3f6a79 100644 --- a/resources/ai-kb/_meta/feature-map.yaml +++ b/resources/ai-kb/_meta/feature-map.yaml @@ -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 集成 diff --git a/resources/ai-kb/zh/faq/license/expire.md b/resources/ai-kb/zh/faq/license/expire.md index 8a75effb7..42545ff00 100644 --- a/resources/ai-kb/zh/faq/license/expire.md +++ b/resources/ai-kb/zh/faq/license/expire.md @@ -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 过期或失效怎么办 diff --git a/resources/ai-kb/zh/howto/license/apply.md b/resources/ai-kb/zh/howto/license/apply.md index 665d61e70..9460f76fe 100644 --- a/resources/ai-kb/zh/howto/license/apply.md +++ b/resources/ai-kb/zh/howto/license/apply.md @@ -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` 写入。 ## 操作步骤 diff --git a/resources/ai-kb/zh/howto/license/online.md b/resources/ai-kb/zh/howto/license/online.md new file mode 100644 index 000000000..373e323be --- /dev/null +++ b/resources/ai-kb/zh/howto/license/online.md @@ -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 为后续能力,当前在线授权面向已售出/试用账号 diff --git a/resources/assets/js/pages/manage/setting/license.vue b/resources/assets/js/pages/manage/setting/license.vue index 1a313d4f8..ba331da0c 100644 --- a/resources/assets/js/pages/manage/setting/license.vue +++ b/resources/assets/js/pages/manage/setting/license.vue @@ -1,5 +1,31 @@ @@ -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; +} diff --git a/routes/api-map.md b/routes/api-map.md index 288afcbff..b66aabea4 100644 --- a/routes/api-map.md +++ b/routes/api-map.md @@ -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 | 预加载的资源 | +## license(LicenseController) + +| 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 | | + ## dialog(DialogController) | URL | 方法名 | HTTP | 说明 | diff --git a/routes/web.php b/routes/web.php index 012c65197..38ac159a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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);