feat(license): 多授权选择登录 + 离线 license SN/MAC 绑定校验加固

- 在线授权登录时账号名下有多条可用授权则弹窗选择,选定后复用验证码确认签发(login/confirm)
- 新增 Doo::licenseDecode / licenseBindingError;保存离线 license 与创建用户前校验付费档 SN/MAC 与本机匹配
- 诊断展示与校验门槛纳入不限人数(people==0)档
This commit is contained in:
kuaifan 2026-07-02 12:00:33 +00:00
parent c9f5296b73
commit 9a3d9d3b9c
9 changed files with 252 additions and 23 deletions

View File

@ -13,6 +13,7 @@ use Request;
* 动态路由routes/web.php
* api/license/email/send -> email__send()
* api/license/login -> login()
* api/license/login/confirm -> login__confirm()
* api/license/trial -> trial()
* api/license/status -> status()
* api/license/refresh -> refresh()
@ -49,6 +50,25 @@ class LicenseController extends AbstractController
return Base::retSuccess('授权成功', $data);
}
/**
* 多条可用授权时,用户选定后确认签发(复用验证码)
*/
public function login__confirm()
{
User::auth('admin');
$email = trim(Request::input('email'));
$code = trim(Request::input('code'));
$entitlementId = (int)Request::input('entitlement_id');
if ($email === '' || $code === '') {
return Base::retError('请输入邮箱和验证码');
}
if ($entitlementId <= 0) {
return Base::retError('请选择要使用的授权');
}
$data = OnlineLicense::loginConfirm($email, $code, $entitlementId);
return Base::retSuccess('授权成功', $data);
}
/**
* 邮箱 + 验证码申请试用并签发
*/

View File

@ -861,6 +861,14 @@ class SystemController extends AbstractController
$type = trim(Request::input('type'));
if ($type == 'save') {
$license = Request::input('license');
// 解密失败sn 为空)视为无效 license
$decoded = Doo::licenseDecode($license);
if ((string)($decoded['sn'] ?? '') === '') {
return Base::retError('LICENSE 格式错误');
}
if ($err = Doo::licenseBindingError($decoded)) {
return Base::retError($err);
}
Doo::licenseSave($license);
// 离线/在线互斥:保存离线 license 即退出在线模式(尽力释放座位+清在线标志,不删除刚写入的文件)
OnlineLicense::switchToOffline();
@ -875,8 +883,8 @@ class SystemController extends AbstractController
'user_count' => User::whereBot(0)->whereNull('disable_at')->count(),
'error' => []
];
if ($data['info']['people'] > 3) {
// 小于3人的License不检查
if ($data['info']['people'] == 0 || $data['info']['people'] > 3) {
// 付费档才检查 SN/MAC
if ($data['info']['sn'] != $data['doo_sn']) {
$data['error'][] = '终端SN与License不匹配';
}

View File

@ -394,6 +394,10 @@ class User extends AbstractModel
}
// 密码
self::passwordPolicy($password);
// license
if ($err = Doo::licenseBindingError(Doo::license())) {
throw new ApiException($err);
}
// 开始注册
$user = Doo::userCreate($email, $password);
if ($other) {

View File

@ -81,6 +81,39 @@ class Doo
self::load()->licenseSave($license);
}
/**
* 解析License取字段
* @param $license
* @return array
*/
public static function licenseDecode($license): array
{
return self::load()->licenseDecode($license);
}
/**
* 校验 license SN/MAC 是否与本机匹配。通过返回 null,否则返回错误文案。
* @param array $info license 信息,含 people/sn/macmac 兼容数组或逗号串)
* @return string|null
*/
public static function licenseBindingError(array $info): ?string
{
$people = (int)($info['people'] ?? 0);
if (!($people === 0 || $people > 3)) {
return null;
}
if ((string)($info['sn'] ?? '') !== self::dooSN()) {
return '终端SN与License不匹配';
}
$mac = $info['mac'] ?? [];
$licenseMacs = array_filter(array_map('trim', is_array($mac) ? $mac : explode(',', (string)$mac)));
$curMacs = self::macs();
if ($licenseMacs && $curMacs && !array_intersect($licenseMacs, $curMacs)) {
return '终端MAC与License不匹配';
}
return null;
}
/**
* 当前会员ID来自请求的token
* @return int

View File

@ -176,6 +176,8 @@ class OnlineLicense
/**
* 邮箱 + 验证码登录并签发。失败抛 ApiException。
* 本机有多条可用授权时appstore 返回 select_required + candidates
* 此处不签发、原样返回候选,由前端选定后走 loginConfirm()
*/
public static function login(string $email, string $code): array
{
@ -183,6 +185,35 @@ class OnlineLicense
if (!$r['ok']) {
throw new ApiException($r['message']);
}
$d = $r['data'];
if (($d['status'] ?? '') === 'select_required') {
return [
'select_required' => true,
'candidates' => $d['candidates'] ?? [],
];
}
$status = self::applyIssue($email, $d);
if (!in_array($status, ['issued', 'renewed'], true)) {
throw new ApiException(self::statusHint($status));
}
return self::status();
}
/**
* 多条可用授权时,用户选定 $entitlementId 后确认签发(复用同一验证码)。失败抛 ApiException。
*/
public static function loginConfirm(string $email, string $code, int $entitlementId): array
{
$payload = array_merge([
'email' => $email,
'code' => $code,
'entitlement_id' => $entitlementId,
'lang' => self::lang(),
], self::fingerprint());
$r = self::call('login/confirm', $payload);
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));

View File

@ -2516,9 +2516,12 @@ AI任务分析
请保持联网,系统会自动为你续期。
重新登录授权
匹配
与本机不一致
不匹配
收起
更多信息
提示
将释放当前设备占用的授权座位并回到登录,确定继续?
已过期
选择要使用的授权
当前设备使用中
确定授权

View File

@ -19,6 +19,9 @@ aliases:
- 在线授权冻结
- 在线授权被吊销
- 换机 deactivate
- 选择要使用的授权
- 一个账号多条授权
- 登录时选择授权
- SN 与 License 不匹配
- MAC 与 License 不匹配
- 终端SN与License不匹配
@ -60,6 +63,11 @@ DooTask 的 License 页提供两种授权方式,可在页面顶部 Tab 切换
- 终端把自身指纹doo_sn、网卡 MAC、版本上报授权中心签发一张租约 License 并落地
- 成功后页面显示账号、套餐、使用人数、授权有效期、SN/MAC与本机是否匹配、当前状态
#### 账号名下有多条可用授权时
- 若该账号名下有**两条及以上**本机可用的授权(座位空闲、或本机已在用的),登录时会弹出「选择要使用的授权」窗口,逐条列出套餐、使用人数、授权有效期,本机已在用的会标注「当前设备使用中」
- 选中一条后点击「确定授权」即对该条签发(复用刚才的验证码,无需重新发码)
- 只有一条可用时不弹窗,直接签发;没有可用授权则提示申请试用或购买;名下授权都被其它实例占用时提示需先在原实例释放(换机)
### 没有正式授权 → 申请试用
- 填好邮箱与验证码后点击「申请试用」即开通试用授权并签发
- 试用默认 14 天 / 不限人数(具体以 App Store 管理员配置为准,时长硬上限 60 天),每个账号仅能申请一次
@ -83,7 +91,7 @@ DooTask 的 License 页提供两种授权方式,可在页面顶部 Tab 切换
## SN / MAC 与本机匹配
在线授权签发的 License 内嵌了签发时的终端 SN 与网卡 MAC。正常情况下二者与本机一致「在线授权」Tab 保持极简,只显示账号、套餐、使用人数、有效期与状态(带颜色圆点),不常驻展示 SN/MAC 这类技术细节。
一旦 SN 或 MAC 与本机不一致,页面顶部会浮出告警卡,并展开「诊断详情」逐项列出「授权 SN / 当前 SN / 授权 MAC / 当前 MAC」及匹配标记✓/✕同一批告警也会出现在仪表盘顶部横幅。「离线授权」Tab手动粘贴场景则始终以列表展示 SN/MAC用行尾标签直陈匹配与否、不一致时整行标红IP/域名/创建时间等低频字段收纳在「更多信息」里。
一旦 SN 或 MAC 与本机不匹配,页面顶部会浮出告警卡,并展开「诊断详情」逐项列出「授权 SN / 当前 SN / 授权 MAC / 当前 MAC」及匹配标记✓/✕同一批告警也会出现在仪表盘顶部横幅。「离线授权」Tab手动粘贴场景则始终以列表展示 SN/MAC用行尾标签直陈匹配与否、不匹配时整行标红IP/域名/创建时间等低频字段收纳在「更多信息」里。
授权成功后若终端的 SN 或 MAC 发生变化,行为不同:

View File

@ -5,7 +5,7 @@
<div class="setting-component-item">
<div class="setting-scroll">
<!-- 首次进入且无缓存骨架占位 + 加载中有缓存则直接渲染下方真实数据 -->
<div v-if="firstLoading" class="license-box">
<div v-if="firstLoading" class="license-box license-wrap">
<div class="online-refreshing"><i class="online-spin"></i>{{$L('加载中...')}}</div>
<ul class="online-info">
<li><em>{{$L('账号')}}:</em><span class="online-skeleton"></span></li>
@ -16,7 +16,7 @@
</ul>
</div>
<template v-else>
<div v-if="onlineActive" class="license-box">
<div v-if="onlineActive" class="license-box license-wrap">
<!-- 后台刷新中指示缓存秒开后仍刷新最新数据 -->
<div v-if="onlineRefreshing" class="online-refreshing"><i class="online-spin"></i>{{$L('刷新中')}}</div>
<!-- 异常告警卡仅提醒/冻结/设备不匹配时浮现正常态不显示避免噪音 -->
@ -80,7 +80,7 @@
<div class="setting-component-item">
<div class="setting-scroll">
<template v-if="onlineActive">
<div class="license-box">
<div class="license-box license-wrap">
<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>
@ -105,12 +105,12 @@
<li class="offline-row" :class="{bad: !snMatch}">
<em>SN:</em>
<span class="v">{{formData.info.sn}}</span>
<span class="offline-flag">{{snMatch ? $L('匹配') : $L('与本机不一致')}}</span>
<span class="offline-flag">{{snMatch ? $L('匹配') : $L('不匹配')}}</span>
</li>
<li class="offline-row" :class="{bad: !macMatch}">
<em>MAC:</em>
<span class="v">{{infoJoin(formData.info.mac)}}</span>
<span class="offline-flag">{{macMatch ? $L('匹配') : $L('与本机不一致')}}</span>
<span class="offline-flag">{{macMatch ? $L('匹配') : $L('不匹配')}}</span>
</li>
<li class="offline-row">
<em>{{$L('使用人数')}}:</em>
@ -162,6 +162,28 @@
</div>
</TabPane>
</Tabs>
<!-- 本机有多条可用授权时选择要使用哪一条 -->
<Modal v-model="candidateShow" :title="$L('选择要使用的授权')" :mask-closable="false">
<div class="online-candidates">
<RadioGroup v-model="candidateChoice" vertical>
<Radio v-for="c in candidateList" :key="c.entitlement_id" :label="c.entitlement_id" class="online-candidate">
<div class="online-candidate-main">
<span class="online-candidate-plan">{{c.plan || $L('授权')}}</span>
<span v-if="c.occupied_by_self" class="online-candidate-self">{{$L('当前设备使用中')}}</span>
</div>
<div class="online-candidate-sub">
<span>{{$L('使用人数')}}: {{c.people ? c.people : $L('无限制')}}</span>
<span>{{$L('授权有效期')}}: {{candidateDuration(c)}}</span>
</div>
</Radio>
</RadioGroup>
</div>
<div slot="footer" class="adaption">
<Button @click="candidateShow = false">{{$L('取消')}}</Button>
<Button type="primary" :loading="candidateSubmitting" @click="onlineLoginConfirm">{{$L('确定授权')}}</Button>
</div>
</Modal>
</div>
</template>
@ -192,8 +214,8 @@
> li {
list-style: none;
font-size: 14px;
line-height: 24px;
padding-bottom: 4px;
line-height: 22px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
@ -211,8 +233,8 @@
}
.online-refreshing {
position: absolute;
top: 6px;
right: 0;
top: 16px;
right: 16px;
display: flex;
align-items: center;
font-size: 12px;
@ -321,8 +343,6 @@
.offline-detail {
.offline-row {
/* 文字排版(字号/行高/label/gap沿用默认信息行只保留标红块所需的内边距与圆角 */
padding: 2px 0;
border-radius: 5px;
> .v { flex: 1; word-break: break-all; }
.offline-flag {
flex-shrink: 0;
@ -348,10 +368,54 @@
}
}
body.window-portrait {
.license-box {
.license-wrap {
padding-top: 16px;
}
}
/* 在线授权:多授权选择弹窗 */
.online-candidates {
.ivu-radio-group {
width: 100%;
}
.online-candidate {
display: flex;
width: 100%;
margin: 0 0 8px;
padding: 10px 12px;
border: 1px solid #dcdee2;
border-radius: 6px;
white-space: normal;
height: auto;
line-height: 24px;
align-items: center;
flex-wrap: wrap;
gap: 4px;
&.ivu-radio-wrapper-checked {
border-color: #84C56A;
background: #f2fff0;
}
.online-candidate-main {
display: flex;
align-items: center;
gap: 8px;
}
.online-candidate-plan {
font-size: 14px;
font-weight: 600;
}
.online-candidate-self {
font-size: 12px;
color: #2d8cf0;
}
.online-candidate-sub {
font-size: 12px;
color: #808695;
span + span {
margin-left: 8px;
}
}
}
}
</style>
<script>
import {mapState} from "vuex";
@ -387,6 +451,10 @@ export default {
codeTimer: null,
firstLoading: true, //
onlineRefreshing: false,// 线
candidateShow: false, //
candidateList: [], //
candidateChoice: 0, // entitlement_id
candidateSubmitting: false, //
}
},
mounted() {
@ -449,7 +517,7 @@ export default {
const info = this.formData.info || {};
return !info.mac || this.existIntersection(this.formData.macs, info.mac);
},
// 线license SN MAC /
// 线license SN MAC /
onlineMismatch() {
const info = this.formData.info || {};
return !!info.sn && (!this.snMatch || !this.macMatch);
@ -742,15 +810,26 @@ export default {
return;
}
this.onlineAction = 'login';
this.onlineCall('license/login', {
email: this.onlineForm.email,
code: this.onlineForm.code,
}, '授权成功').then(_ => {
this.$store.dispatch("call", {
url: 'license/login',
data: {
email: this.onlineForm.email,
code: this.onlineForm.code,
},
method: 'post',
}).then(({data}) => {
// confirm
if (data && data.select_required) {
this.showCandidateSelect(data.candidates || []);
return;
}
$A.messageSuccess(this.$L('授权成功'));
this.resetOnlineForm();
this.systemSetting();
}).catch(() => {
}).catch(({msg}) => {
//
//
$A.modalError(msg);
this.onlineForm.code = '';
this.clearCodeTimer();
}).finally(() => {
@ -758,6 +837,48 @@ export default {
});
},
// =
showCandidateSelect(list) {
this.candidateList = list;
this.candidateChoice = list.length ? list[0].entitlement_id : 0;
this.candidateShow = true;
},
// +
onlineLoginConfirm() {
if (!this.candidateChoice) {
$A.messageError('请选择要使用的授权');
return;
}
this.candidateSubmitting = true;
this.$store.dispatch("call", {
url: 'license/login/confirm',
data: {
email: this.onlineForm.email,
code: this.onlineForm.code,
entitlement_id: this.candidateChoice,
},
method: 'post',
}).then(_ => {
$A.messageSuccess(this.$L('授权成功'));
this.candidateShow = false;
this.resetOnlineForm();
this.systemSetting();
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(() => {
this.candidateSubmitting = false;
});
},
// /
candidateDuration(c) {
if (c.duration_type === 'fixed') {
return c.valid_until ? this.fmt(c.valid_until) : '-';
}
return this.$L('永久');
},
// 线emailSend
trialSubmit() {
if (!this.onlineForm.email || !this.onlineForm.code) {

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数311
接口总数312
## 路由规则
@ -202,6 +202,7 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| --- | --- | --- | --- |
| api/license/email/send | email__send() | any | |
| api/license/login | login() | any | |
| api/license/login/confirm | login__confirm() | any | |
| api/license/trial | trial() | any | |
| api/license/status | status() | any | |
| api/license/refresh | refresh() | any | |