feat(license): 在线授权登录与试用改为邮箱+验证码

配合 appstore 账号体系统一,在线授权去除 App Store 账号+密码:
- OnlineLicense 新增 emailSend,login/trial 改 (email, code),call payload
  去 account/password、加 email/code(fingerprint 续传)
- LicenseController 新增 email__send,login/trial 读 email/code
- license.vue 在线 Tab 改邮箱+发码+验证码(与试用复用状态防串台),
  离线 Tab 与互斥链路不动
- 同步 i18n 文案与 ai-kb license/online、api-map
This commit is contained in:
kuaifan 2026-06-23 01:21:11 +00:00
parent 7c6dfe8a25
commit 7c6b8ce6f4
7 changed files with 142 additions and 110 deletions

View File

@ -11,8 +11,8 @@ use Request;
* 在线授权客户端(与 SystemController::license 的离线粘贴并存)。
*
* 动态路由routes/web.php
* api/license/email/send -> email__send()
* api/license/login -> login()
* api/license/trial/send -> trial__send()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
@ -21,48 +21,46 @@ use Request;
class LicenseController extends AbstractController
{
/**
* 账号登录并签发在线授权
* 发送邮箱验证码(登录与试用共用)
*/
public function email__send()
{
User::auth('admin');
$email = trim(Request::input('email'));
if ($email === '') {
return Base::retError('请输入邮箱');
}
$masked = OnlineLicense::emailSend($email);
return Base::retSuccess('验证码已发送', ['email' => $masked]);
}
/**
* 邮箱 + 验证码登录并签发在线授权
*/
public function login()
{
User::auth('admin');
$account = trim(Request::input('account'));
$password = trim(Request::input('password'));
if ($account === '' || $password === '') {
return Base::retError('请输入账号和密码');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::login($account, $password);
$data = OnlineLicense::login($email, $code);
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'));
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
if ($account === '' || $password === '' || $code === '') {
return Base::retError('请输入账号、密码和验证码');
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
$data = OnlineLicense::trial($account, $password, $code);
$data = OnlineLicense::trial($email, $code);
return Base::retSuccess('试用已开通', $data);
}

View File

@ -154,28 +154,11 @@ class OnlineLicense
// ---- 对外动作 ----
/**
* 账号登录并签发。失败抛 ApiException
* 发送邮箱验证码(登录与试用共用),返回脱敏邮箱
*/
public static function login(string $account, string $password): array
public static function emailSend(string $email): string
{
$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
{
// 带上实例指纹sn/macs让 appstore 在发送验证码前即可做试用资格校验
$r = self::call('trial/send', array_merge(['account' => $account, 'password' => $password], self::fingerprint()));
$r = self::call('email/send', ['email' => $email]);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
@ -183,16 +166,32 @@ class OnlineLicense
}
/**
* 申请试用并签发
* 邮箱 + 验证码登录并签发。失败抛 ApiException
*/
public static function trial(string $account, string $password, string $code): array
public static function login(string $email, string $code): array
{
$payload = array_merge(['account' => $account, 'password' => $password, 'code' => $code], self::fingerprint());
$r = self::call('login', array_merge(['email' => $email, 'code' => $code], self::fingerprint()));
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($email, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 邮箱 + 验证码申请试用并签发。
*/
public static function trial(string $email, string $code): array
{
$payload = array_merge(['email' => $email, 'code' => $code], self::fingerprint());
$r = self::call('trial', $payload);
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$status = self::applyIssue($account, $r['data']);
$status = self::applyIssue($email, $r['data']);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}

View File

@ -1018,3 +1018,5 @@ AI 助手
在线授权即将到期,请保持联网续期
在线授权已过期,新增用户受限,请尽快续期
在线授权已失效,已回落到基础版
请输入邮箱
请输入邮箱和验证码

View File

@ -2501,3 +2501,6 @@ App Store账号
当前已绑定在线授权,绑定离线后将替换当前授权并释放在线座位,是否继续?
当前已绑定离线授权,绑定在线后将替换当前授权,是否继续?
请输入License
验证码已发送至(*)
(*)秒后重发
请输入邮箱和验证码

View File

@ -1,19 +1,20 @@
---
id: license.online.howto
title: 在线授权(账号登录 / 申请试用 / 自动续期)
title: 在线授权(邮箱验证码登录 / 申请试用 / 自动续期)
type: howto
feature: license
scope: super-admin
locale: zh
aliases:
- 在线授权
- 账号授权
- 邮箱授权
- 邮箱验证码授权
- 用 appstore 账号授权
- 申请试用
- 试用授权
- 在线 License
- 自动续期
- 授权账号登录
- 授权邮箱登录
- 在线授权到期
- 在线授权冻结
- 在线授权被吊销
@ -22,7 +23,7 @@ related_tools: []
related_pages: []
prerequisites:
- 需要进入「系统设置」→「License」页面仅管理员可见切到「在线授权」Tab
- 需要一个在 App Store 申请的账号(开发者账号),登录后可在 App Store 门户「我的授权」查看
- 需要一个在 App Store 注册的邮箱账号,登录后可在 App Store 门户「我的授权」查看
- 终端需能联网访问 App Store 授权中心
negative:
- 在线授权与离线授权互斥:同一时刻只有一张生效 License切到在线并登录后会接管 License 文件
@ -32,29 +33,31 @@ negative:
last_verified: v1.7.91
---
# 在线授权(账号登录 / 申请试用 / 自动续期)
# 在线授权(邮箱验证码登录 / 申请试用 / 自动续期)
DooTask 的 License 页提供两种授权方式,可在页面顶部 Tab 切换:
- **离线授权**:手动粘贴 License 原文(见 [[license.howto]]),无自动续期。
- **在线授权**:用 App Store 账号登录自助签发,终端自动定时续期(类似 JetBrains 账号激活)。
- **在线授权**:用 App Store 注册邮箱 + 邮箱验证码登录自助签发,终端自动定时续期(类似 JetBrains 账号激活)。
## 入口
桌面端:左上角头像 →「系统设置」→「License」→ 顶部「在线授权」Tab仅管理员可见
## 怎么用
### 已有账号 + 已有授权
1. 在「在线授权」Tab 输入 App Store 账号、密码
2. 点击「登录授权」
3. 终端把自身指纹doo_sn、网卡 MAC、版本上报授权中心签发一张租约 License 并落地
4. 成功后页面显示套餐、使用人数、租约到期、当前状态
登录授权与申请试用共用同一套「邮箱 + 验证码」输入,先发码再选择动作:
1. 在「在线授权」Tab 输入 App Store 注册邮箱
2. 点击「发送验证码」→ 系统向该邮箱发码(按钮进入 60 秒倒计时,发码成功后提示验证码已发送至脱敏邮箱)
3. 填入收到的验证码
### 申请试用(没有正式授权时)
1. 在「在线授权」Tab 输入账号、密码
2. 点击「申请试用」→ 系统向账号注册邮箱发送验证码
3. 填入验证码 → 点击「确定试用」即开通试用授权并签发
4. 试用默认 14 天 / 不限人数(具体以 App Store 管理员配置为准,时长硬上限 60 天),每个账号仅能申请一次
### 已有授权 → 登录授权
- 填好邮箱与验证码后点击「登录授权」
- 终端把自身指纹doo_sn、网卡 MAC、版本上报授权中心签发一张租约 License 并落地
- 成功后页面显示套餐、使用人数、租约到期、当前状态
### 没有正式授权 → 申请试用
- 填好邮箱与验证码后点击「申请试用」即开通试用授权并签发
- 试用默认 14 天 / 不限人数(具体以 App Store 管理员配置为准,时长硬上限 60 天),每个账号仅能申请一次
### 日常维护
- **自动续期**:终端每小时检查,租约将尽时自动向授权中心续期,无需人工干预(需保持联网)

View File

@ -21,7 +21,7 @@
</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>
<Button :loading="loadIng > 0" @click="offlineRebindCancel">{{$L('取消')}}</Button>
</div>
</template>
</template>
@ -103,7 +103,7 @@
</Form>
<div class="setting-footer">
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{$L('提交')}}</Button>
<Button :loading="loadIng > 0" @click="resetForm" style="margin-left: 8px">{{$L('重置')}}</Button>
<Button :loading="loadIng > 0" @click="resetForm">{{$L('重置')}}</Button>
</div>
</template>
</TabPane>
@ -125,21 +125,21 @@
</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 :label="$L('邮箱')">
<Input v-model="onlineForm.email"
:class="codeCountdown > 0 ? 'setting-send-input' : 'setting-input'"
search @on-search="emailSend"
:enter-button="codeCountdown > 0 ? $L('(*)秒后重发', codeCountdown) : $L('发送验证码')"
:placeholder="$L('请输入邮箱')"/>
</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 v-if="codeSent" :label="$L('邮箱验证码')">
<Input v-model="onlineForm.code" class="setting-input" :placeholder="$L('请输入验证码')"/>
<div class="online-tip">{{$L('验证码已发送至(*)', maskedEmail)}}</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>
<Button :loading="onlineIng > 0" type="success" @click="trialSubmit">{{$L('申请试用')}}</Button>
</div>
</template>
</TabPane>
@ -222,17 +222,21 @@ export default {
offlineRebindLicense: '',
onlineIng: 0,
onlineForm: {
account: '',
password: '',
email: '',
code: '',
},
trialStep: 0,
trialEmail: '',
codeSent: false, // +
maskedEmail: '', //
codeCountdown: 0, //
codeTimer: null,
}
},
mounted() {
this.onlineRefresh();
},
beforeDestroy() {
this.clearCodeTimer();
},
computed: {
...mapState(['userInfo', 'formOptions']),
@ -418,15 +422,33 @@ export default {
}
},
// 60s
emailSend() {
if (this.codeCountdown > 0) {
return;
}
if (!this.onlineForm.email) {
$A.messageError('请输入邮箱');
return;
}
this.onlineCall('license/email/send', {
email: this.onlineForm.email,
}).then(({data}) => {
this.codeSent = true;
this.maskedEmail = data?.email || '';
this.startCodeCountdown();
});
},
onlineLogin() {
if (!this.onlineForm.account || !this.onlineForm.password) {
$A.messageError('请输入账号和密码');
if (!this.onlineForm.email || !this.onlineForm.code) {
$A.messageError('请输入邮箱和验证码');
return;
}
this.confirmReplaceOffline(() => {
this.onlineCall('license/login', {
account: this.onlineForm.account,
password: this.onlineForm.password,
email: this.onlineForm.email,
code: this.onlineForm.code,
}, '授权成功').then(_ => {
this.resetOnlineForm();
this.systemSetting();
@ -434,29 +456,14 @@ export default {
});
},
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('请输入验证码');
if (!this.onlineForm.email || !this.onlineForm.code) {
$A.messageError('请输入邮箱和验证码');
return;
}
this.confirmReplaceOffline(() => {
this.onlineCall('license/trial', {
account: this.onlineForm.account,
password: this.onlineForm.password,
email: this.onlineForm.email,
code: this.onlineForm.code,
}, '试用已开通').then(_ => {
this.resetOnlineForm();
@ -476,10 +483,30 @@ export default {
});
},
startCodeCountdown() {
this.clearCodeTimer();
this.codeCountdown = 60;
this.codeTimer = setInterval(() => {
this.codeCountdown--;
if (this.codeCountdown <= 0) {
this.clearCodeTimer();
}
}, 1000);
},
clearCodeTimer() {
if (this.codeTimer) {
clearInterval(this.codeTimer);
this.codeTimer = null;
}
this.codeCountdown = 0;
},
resetOnlineForm() {
this.onlineForm = {account: '', password: '', code: ''};
this.trialStep = 0;
this.trialEmail = '';
this.onlineForm = {email: '', code: ''};
this.codeSent = false;
this.maskedEmail = '';
this.clearCodeTimer();
},
}
}

View File

@ -200,8 +200,8 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/license/email/send | email__send() | any | |
| 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 | |