License改造:ops平台管理最大设备数、审批有效期、License SDK文档
- ops平台AppDetailView添加License授权管理卡片(内联编辑最大设备数) - 服务开通审批对话框LICENSE类型支持选择有效期 - ops API新增getAppLicense/updateMaxDevices方法 - 文档站新增Android License SDK集成文档 - setup.md添加sdk-license依赖和ProGuard规则 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
这个提交包含在:
父节点
c0f9bc1c47
当前提交
038d862838
@ -46,6 +46,7 @@ export default defineConfig({
|
||||
{ text: 'IM 接入', link: '/android/im' },
|
||||
{ text: '推送接入', link: '/android/push' },
|
||||
{ text: '版本管理', link: '/android/update' },
|
||||
{ text: '授权管理', link: '/android/license' },
|
||||
],
|
||||
'/ios/': [
|
||||
{ text: '概览', link: '/ios/' },
|
||||
|
||||
164
docs-site/docs/android/license.md
普通文件
164
docs-site/docs/android/license.md
普通文件
@ -0,0 +1,164 @@
|
||||
# Android 授权管理(License SDK)
|
||||
|
||||
**模块**:`com.xuqm:sdk-license` · **最低 Android 版本**:API 24 (Android 7.0)
|
||||
|
||||
License SDK 用于设备授权注册与验证,所有平台(Android / iOS / 鸿蒙)共用同一个 License Key。
|
||||
|
||||
---
|
||||
|
||||
## 快速接入
|
||||
|
||||
### 1. 添加依赖
|
||||
|
||||
```kotlin
|
||||
// app/build.gradle.kts
|
||||
dependencies {
|
||||
implementation("com.xuqm:sdk-license:0.4.2")
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 放置 License 文件
|
||||
|
||||
从租户平台下载 `.xuqmlicense` 加密文件,放入 app 的 assets 目录:
|
||||
|
||||
```
|
||||
app/src/main/assets/xuqm/license.xuqm
|
||||
```
|
||||
|
||||
SDK 会按以下顺序查找文件:
|
||||
1. `assets/xuqm/license.xuqm`
|
||||
2. `assets/xuqm/` 目录下第一个 `.xuqmlicense` 文件
|
||||
|
||||
### 3. 检查授权
|
||||
|
||||
SDK **无需手动初始化**,首次调用 `checkLicense()` 时自动读取并解密 License 文件。
|
||||
|
||||
```kotlin
|
||||
LicenseSDK.checkLicense { isValid ->
|
||||
if (isValid) {
|
||||
// 授权通过,允许使用
|
||||
} else {
|
||||
// 授权失败,提示用户
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 说明
|
||||
|
||||
### checkLicense
|
||||
|
||||
检查设备授权状态。首次调用时自动完成:读取 License 文件 → 解密 → 注册/验证设备 → 缓存结果。
|
||||
|
||||
```kotlin
|
||||
// 回调方式
|
||||
LicenseSDK.checkLicense { isValid ->
|
||||
// true: 授权通过 false: 授权失败
|
||||
}
|
||||
|
||||
// 协程方式
|
||||
lifecycleScope.launch {
|
||||
val isValid = LicenseSDK.checkLicense()
|
||||
}
|
||||
```
|
||||
|
||||
**内部逻辑**:
|
||||
1. 检查本地缓存(默认 10 分钟有效期)
|
||||
2. 缓存有效 → 直接返回 `true`
|
||||
3. 缓存过期或无缓存:
|
||||
- 有 Token → 调用验证接口
|
||||
- 无 Token 或验证失败 → 调用注册接口
|
||||
4. 网络异常且缓存曾成功 → 返回 `true`(离线模式)
|
||||
5. 网络异常且无缓存 → 返回 `false`
|
||||
|
||||
---
|
||||
|
||||
## 设备唯一码
|
||||
|
||||
SDK 自动选择设备唯一码,优先级如下:
|
||||
|
||||
| 优先级 | 来源 | 稳定性 |
|
||||
|--------|------|--------|
|
||||
| 1 | ANDROID_ID | 同一签名 + 设备 + 用户不变,重装不变 |
|
||||
| 2 | 持久化 UUID | 首次生成后持久化存储 |
|
||||
|
||||
**保持不变**的场景:App 重启、App 重装(同一签名)、系统升级
|
||||
|
||||
**会变化**的场景:恢复出厂设置、更换签名重新安装
|
||||
|
||||
---
|
||||
|
||||
## 离线模式
|
||||
|
||||
SDK 支持离线使用:
|
||||
- 首次激活需要网络连接
|
||||
- 激活成功后,缓存有效期内(默认 10 分钟)可离线使用
|
||||
- 网络异常时,若缓存曾经成功验证,继续返回授权通过
|
||||
|
||||
---
|
||||
|
||||
## 数据存储
|
||||
|
||||
| 数据 | 存储方式 | 说明 |
|
||||
|------|----------|------|
|
||||
| deviceId | EncryptedSharedPreferences | AES-256 加密 |
|
||||
| token | EncryptedSharedPreferences | AES-256 加密 |
|
||||
| 授权状态 | EncryptedSharedPreferences | AES-256 加密 |
|
||||
| fallback UUID | SharedPreferences | ANDROID_ID 不可用时使用 |
|
||||
|
||||
---
|
||||
|
||||
## AppKey 变更
|
||||
|
||||
当 License 文件中的 AppKey 与已存储的不一致时,SDK 会自动:
|
||||
1. 清除所有旧数据(Token、deviceId、状态)
|
||||
2. 使用新 AppKey 重新注册
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
```kotlin
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// 无需初始化代码,SDK 自动读取 License 文件
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
LicenseSDK.checkLicense { isValid ->
|
||||
runOnUiThread {
|
||||
if (isValid) {
|
||||
// 进入主界面
|
||||
} else {
|
||||
Toast.makeText(this, "授权验证失败", Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: checkLicense 返回 false 怎么办?**
|
||||
A: 检查以下几点:
|
||||
- License 文件是否放在 `assets/xuqm/` 目录
|
||||
- 文件名是否为 `license.xuqm` 或以 `.xuqmlicense` 结尾
|
||||
- 设备是否有网络连接(首次激活需要网络)
|
||||
- License 是否已过期或被管理员禁用
|
||||
|
||||
**Q: 不同平台可以用同一个 License 吗?**
|
||||
A: 可以。所有平台共用同一个 AppKey,设备数量统一计算。
|
||||
|
||||
**Q: 设备被禁用后如何恢复?**
|
||||
A: 由租户管理员在租户平台的授权管理页面重新激活该设备,App 端无需额外操作,下次 `checkLicense` 会自动恢复正常。
|
||||
@ -28,6 +28,7 @@ dependencyResolutionManagement {
|
||||
| sdk-im | `com.xuqm:sdk-im` | 单聊、群聊、消息收发、会话、好友、群组 |
|
||||
| sdk-push | `com.xuqm:sdk-push` | 自动检测厂商、设备 Token 注册(华为/小米/OPPO/vivo/荣耀/FCM)|
|
||||
| sdk-update | `com.xuqm:sdk-update` | App 更新检查、下载安装 |
|
||||
| sdk-license | `com.xuqm:sdk-license` | 设备授权注册与验证 |
|
||||
|
||||
在 `app/build.gradle.kts` 中按需引入:
|
||||
|
||||
@ -37,6 +38,7 @@ dependencies {
|
||||
implementation("com.xuqm:sdk-im:0.4.2")
|
||||
implementation("com.xuqm:sdk-push:0.4.2") // 按需
|
||||
implementation("com.xuqm:sdk-update:0.4.2") // 按需
|
||||
implementation("com.xuqm:sdk-license:0.4.2") // 按需
|
||||
}
|
||||
```
|
||||
|
||||
@ -69,6 +71,9 @@ dependencies {
|
||||
# XuqmSDK Update
|
||||
-keep class com.xuqm.sdk.update.** { *; }
|
||||
|
||||
# XuqmSDK License
|
||||
-keep class com.xuqm.sdk.license.** { *; }
|
||||
|
||||
# Gson(IM 消息序列化使用)
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
@ -82,3 +87,4 @@ dependencies {
|
||||
- [Android IM 接入 →](./im)
|
||||
- [Android Push 接入 →](./push)
|
||||
- [Android 版本更新 →](./update)
|
||||
- [Android 授权管理 →](./license)
|
||||
|
||||
@ -76,6 +76,14 @@ export interface ServiceRequestPage {
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface LicenseStatusInfo {
|
||||
exists: boolean
|
||||
active?: boolean
|
||||
maxDevices?: number
|
||||
registeredDevices?: number
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
export interface AppItem {
|
||||
id: string
|
||||
appKey: string
|
||||
@ -227,8 +235,8 @@ export const opsApi = {
|
||||
listServiceRequests: (status = '', page = 0, size = 20) =>
|
||||
client.get<{ data: ServiceRequestPage }>('/ops/service-requests', { params: { status, page, size } }),
|
||||
|
||||
approveRequest: (requestId: string, reviewNote = '') =>
|
||||
client.post<{ data: ServiceRequest }>(`/ops/service-requests/${requestId}/approve`, { reviewNote }),
|
||||
approveRequest: (requestId: string, reviewNote = '', expiresAt?: string) =>
|
||||
client.post<{ data: ServiceRequest }>(`/ops/service-requests/${requestId}/approve`, { reviewNote, expiresAt }),
|
||||
|
||||
rejectRequest: (requestId: string, reviewNote = '') =>
|
||||
client.post<{ data: ServiceRequest }>(`/ops/service-requests/${requestId}/reject`, { reviewNote }),
|
||||
@ -275,4 +283,10 @@ export const opsApi = {
|
||||
|
||||
sendPushTestOffline: (payload: { appKey: string; userId: string; title: string; body: string; payload?: string }) =>
|
||||
client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload),
|
||||
|
||||
getAppLicense: (appKey: string) =>
|
||||
client.get<{ data: LicenseStatusInfo }>(`/ops/apps/${appKey}/license`),
|
||||
|
||||
updateMaxDevices: (appKey: string, maxDevices: number) =>
|
||||
client.put(`/ops/apps/${appKey}/license/max-devices`, { maxDevices }),
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-card style="margin-bottom: 16px">
|
||||
<template #header>功能服务</template>
|
||||
<el-table :data="detail.services" border stripe>
|
||||
<el-table-column prop="platform" label="平台" width="120" />
|
||||
@ -49,20 +49,88 @@
|
||||
<el-table-column prop="config" label="配置" min-width="240" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="licenseInfo?.exists">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>License 授权管理</span>
|
||||
<el-tag :type="licenseInfo.active ? 'success' : 'danger'" size="small">
|
||||
{{ licenseInfo.active ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="已注册设备">{{ licenseInfo.registeredDevices ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">
|
||||
{{ licenseInfo.expiresAt ? fmt(licenseInfo.expiresAt) : '永久' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最大设备数">
|
||||
<div v-if="!editingMaxDevices" class="max-devices-display">
|
||||
<span>{{ licenseInfo.maxDevices ?? '-' }}</span>
|
||||
<el-button link type="primary" size="small" @click="startEditMaxDevices" style="margin-left:8px">
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else class="max-devices-edit">
|
||||
<el-input-number v-model="editMaxDevicesValue" :min="1" :max="999999" size="small" />
|
||||
<el-button type="primary" size="small" :loading="savingMaxDevices" @click="saveMaxDevices" style="margin-left:8px">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button size="small" @click="editingMaxDevices = false" style="margin-left:4px">取消</el-button>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { opsApi, type AppDetail } from '@/api/ops'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { opsApi, type AppDetail, type LicenseStatusInfo } from '@/api/ops'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref<AppDetail | null>(null)
|
||||
const licenseInfo = ref<LicenseStatusInfo | null>(null)
|
||||
const editingMaxDevices = ref(false)
|
||||
const editMaxDevicesValue = ref(1)
|
||||
const savingMaxDevices = ref(false)
|
||||
|
||||
async function loadDetail() {
|
||||
const res = await opsApi.getApp(route.params.appKey as string)
|
||||
const appKey = route.params.appKey as string
|
||||
const res = await opsApi.getApp(appKey)
|
||||
detail.value = res.data.data
|
||||
loadLicense(appKey)
|
||||
}
|
||||
|
||||
async function loadLicense(appKey: string) {
|
||||
try {
|
||||
const res = await opsApi.getAppLicense(appKey)
|
||||
licenseInfo.value = res.data.data
|
||||
} catch {
|
||||
licenseInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startEditMaxDevices() {
|
||||
editMaxDevicesValue.value = licenseInfo.value?.maxDevices ?? 1
|
||||
editingMaxDevices.value = true
|
||||
}
|
||||
|
||||
async function saveMaxDevices() {
|
||||
if (!detail.value) return
|
||||
savingMaxDevices.value = true
|
||||
try {
|
||||
await opsApi.updateMaxDevices(detail.value.app.appKey, editMaxDevicesValue.value)
|
||||
ElMessage.success('最大设备数已更新')
|
||||
editingMaxDevices.value = false
|
||||
loadLicense(detail.value.app.appKey)
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
savingMaxDevices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(value: string) {
|
||||
@ -84,4 +152,12 @@ onMounted(loadDetail)
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.max-devices-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.max-devices-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -64,6 +64,11 @@
|
||||
|
||||
<el-dialog v-model="showReview" :title="reviewAction === 'approve' ? '通过申请' : '拒绝申请'" width="420px">
|
||||
<el-form>
|
||||
<el-form-item v-if="reviewAction === 'approve' && currentRequest?.serviceType === 'LICENSE'" label="有效期">
|
||||
<el-date-picker v-model="expiresAt" type="datetime" placeholder="选择过期时间(可选,留空为永久)"
|
||||
style="width:100%" value-format="YYYY-MM-DDTHH:mm:ss" :disabled-date="(date: Date) => date.getTime() < Date.now() - 86400000" />
|
||||
<div class="el-text--info" style="font-size:12px;margin-top:4px">设置后不可修改,留空表示永久有效</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注">
|
||||
<el-input v-model="reviewNote" type="textarea" :rows="3" placeholder="可选备注" />
|
||||
</el-form-item>
|
||||
@ -92,6 +97,7 @@ const total = ref(0)
|
||||
const showReview = ref(false)
|
||||
const reviewAction = ref<'approve' | 'reject'>('approve')
|
||||
const reviewNote = ref('')
|
||||
const expiresAt = ref('')
|
||||
const currentRequest = ref<ServiceRequest | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
@ -112,6 +118,7 @@ function openReview(row: ServiceRequest, action: 'approve' | 'reject') {
|
||||
currentRequest.value = row
|
||||
reviewAction.value = action
|
||||
reviewNote.value = ''
|
||||
expiresAt.value = ''
|
||||
showReview.value = true
|
||||
}
|
||||
|
||||
@ -120,7 +127,8 @@ async function submitReview() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (reviewAction.value === 'approve') {
|
||||
await opsApi.approveRequest(currentRequest.value.id, reviewNote.value)
|
||||
const exp = currentRequest.value.serviceType === 'LICENSE' ? expiresAt.value || undefined : undefined
|
||||
await opsApi.approveRequest(currentRequest.value.id, reviewNote.value, exp)
|
||||
ElMessage.success('已通过申请,服务已开通')
|
||||
} else {
|
||||
await opsApi.rejectRequest(currentRequest.value.id, reviewNote.value)
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户