mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-03 04:45:09 +00:00
feat(license): 多授权选择登录 + 离线 license SN/MAC 绑定校验加固
- 在线授权登录时账号名下有多条可用授权则弹窗选择,选定后复用验证码确认签发(login/confirm) - 新增 Doo::licenseDecode / licenseBindingError;保存离线 license 与创建用户前校验付费档 SN/MAC 与本机匹配 - 诊断展示与校验门槛纳入不限人数(people==0)档
This commit is contained in:
parent
c9f5296b73
commit
9a3d9d3b9c
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱 + 验证码申请试用并签发
|
||||
*/
|
||||
|
||||
@ -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不匹配';
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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/mac(mac 兼容数组或逗号串)
|
||||
* @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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -2516,9 +2516,12 @@ AI任务分析
|
||||
请保持联网,系统会自动为你续期。
|
||||
重新登录授权
|
||||
匹配
|
||||
与本机不一致
|
||||
不匹配
|
||||
收起
|
||||
更多信息
|
||||
提示
|
||||
将释放当前设备占用的授权座位并回到登录,确定继续?
|
||||
已过期
|
||||
选择要使用的授权
|
||||
当前设备使用中
|
||||
确定授权
|
||||
|
||||
@ -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 发生变化,行为不同:
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 | |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user