批量优化
This commit is contained in:
1 parent
438145cae7
commit
c925b198d1
50 files changed
+5716
-291
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 获取「加入企业」申请列表(仅本租户,按 JWT tid 隔离)
|
||||
export function getTenantApplies(params) {
|
||||
return request({
|
||||
url: '/backend/tenantApplies',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 通过申请:申请人加入本企业(默认「租户用户」角色)
|
||||
export function approveTenantApply(id) {
|
||||
return request({
|
||||
url: `/backend/tenantApplies/approve/${id}`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
// 驳回申请:data = { reason }
|
||||
export function rejectTenantApply(id, data) {
|
||||
return request({
|
||||
url: `/backend/tenantApplies/reject/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
+14
-8
@@ -18,10 +18,13 @@ export function getTenantUsers(tenantId) {
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo(userId) {
|
||||
// params.by === 'id':按 yz_system_tenant_user 主键精确查询(用户管理页传列表行 id 时必须携带);
|
||||
// 缺省时后端兼容 identity_id / 老 uid(个人中心等场景)
|
||||
export function getUserInfo(userId, params) {
|
||||
return request({
|
||||
url: `/backend/getUserInfo/${userId}`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,12 +37,13 @@ export function addUser(data) {
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑用户信息
|
||||
export function editUser(userId, data) {
|
||||
// 编辑用户信息(params.by === 'id' 时后端按主键精确匹配)
|
||||
export function editUser(userId, data, params) {
|
||||
return request({
|
||||
url: `/backend/editUser/${userId}`,
|
||||
method: 'post',
|
||||
data,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,22 +79,24 @@ export function uploadAvatar(formData) {
|
||||
});
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function deleteUser(userId) {
|
||||
// 删除用户(params.by === 'id' 时后端按主键精确匹配)
|
||||
export function deleteUser(userId, params) {
|
||||
return request({
|
||||
url: `/backend/deleteUser/${userId}`,
|
||||
method: 'delete',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePassword(userId, data) {
|
||||
// 修改密码;byID=true 表示 userId 是用户管理列表行主键,后端按主键精确匹配
|
||||
export function changePassword(userId, data, byID = false) {
|
||||
return request({
|
||||
url: '/backend/changePassword',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: userId,
|
||||
password: data.newPassword
|
||||
password: data.newPassword,
|
||||
by: byID ? 'id' : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -144,29 +144,43 @@
|
||||
<span class="hint">判断节点:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。运行期按条件命中其中一条分支。</span>
|
||||
</el-form-item>
|
||||
<template v-if="nodeForm.nodeType === 5">
|
||||
<el-form-item label="通知类型">
|
||||
<el-radio-group v-model="nodeForm.approverType" @change="onApproverTypeChange">
|
||||
<el-radio v-for="t in approverTypes" :key="t.value" :value="t.value">{{ t.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentApproverMeta && currentApproverMeta.needsSelection" :label="currentApproverMeta.selectLabel" required>
|
||||
<el-select v-model="nodeForm.approverIds" multiple filterable style="width: 100%" :placeholder="'选择' + currentApproverMeta.selectLabel + '(可多选)'">
|
||||
<el-option v-for="opt in currentPool" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||||
<el-form-item label="通知类型" required>
|
||||
<el-select
|
||||
v-model="nodeForm.notifyTypes"
|
||||
multiple
|
||||
collapse-tags
|
||||
style="width: 100%"
|
||||
placeholder="选择通知类型(可多选)"
|
||||
@change="onNotifyTypesChange"
|
||||
>
|
||||
<el-option v-for="t in approverTypes" :key="t.value" :label="t.label" :value="t.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label=" ">
|
||||
<span class="hint">该类型运行期自动定位通知对象,无需指定</span>
|
||||
<el-form-item
|
||||
v-for="t in selectedNotifyMetas"
|
||||
:key="t.value"
|
||||
:label="t.selectLabel || t.label"
|
||||
required
|
||||
>
|
||||
<el-select v-model="nodeForm.notifyIdsMap[t.value]" multiple filterable style="width: 100%" :placeholder="'选择' + (t.selectLabel || t.label) + '(可多选)'">
|
||||
<el-option v-for="opt in poolOf(t.poolKey)" :key="opt.id" :label="opt.name" :value="opt.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!selectedNotifyMetas.length" label=" ">
|
||||
<span class="hint">所选类型运行期自动定位通知对象,无需指定</span>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<span class="hint">通知节点不阻塞流程,仅发送消息给指定人员,流程自动继续往下走。</span>
|
||||
<span class="hint">通知节点不阻塞流程,流程自动继续往下走。可同时选择多种类型(如「发起人本人」+「指定用户」)。</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
||||
<span style="flex: 1"></span>
|
||||
<el-button @click="nodeDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmNode">确定</el-button>
|
||||
<div class="node-dialog-footer">
|
||||
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
|
||||
<span class="footer-spacer"></span>
|
||||
<el-button @click="nodeDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmNode">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -249,7 +263,19 @@ function approverSummary(node: any) {
|
||||
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
|
||||
}
|
||||
if (node.nodeType === NODE_TYPE.DECISION) return '按分支条件路由'
|
||||
if (node.nodeType === NODE_TYPE.NOTIFY) return '发送通知给指定人员'
|
||||
if (node.nodeType === NODE_TYPE.NOTIFY) {
|
||||
const types: number[] = node.notifyTypes?.length ? node.notifyTypes : [node.approverType]
|
||||
const parts = types.map((t) => {
|
||||
const meta = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||
if (!meta) return '未知类型'
|
||||
if (!meta.needsSelection) return meta.label
|
||||
const pool = (props.pools as any)[meta.poolKey] || []
|
||||
const ids = (node.notifyIdsMap || {})[t] || []
|
||||
const names = ids.map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
|
||||
return `${meta.label}:${names.length ? names.join('、') : '未选'}`
|
||||
})
|
||||
return '通知 ' + parts.join(' + ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -279,7 +305,7 @@ function buildGraph(payload: any) {
|
||||
|
||||
function makeNodeData(n: any) {
|
||||
const nodeType = n.node_type || n.nodeType || NODE_TYPE.APPROVAL
|
||||
const data = {
|
||||
const data: any = {
|
||||
nodeType,
|
||||
cls: clsOf(nodeType),
|
||||
name: n.node_name || n.name || '',
|
||||
@@ -289,10 +315,44 @@ function makeNodeData(n: any) {
|
||||
allowReturn: n.allow_return ?? n.allowReturn ?? 0,
|
||||
timeoutHours: n.timeout_hours ?? n.timeoutHours ?? 48,
|
||||
}
|
||||
if (nodeType === NODE_TYPE.NOTIFY) {
|
||||
applyNotifyData(data, n)
|
||||
}
|
||||
data.summary = approverSummary(data)
|
||||
return data
|
||||
}
|
||||
|
||||
// 通知节点:解析多类型存储 notify_targets: ["1:52","5:0"];兼容旧单类型格式
|
||||
//(纯数字 approver_ids + approver_type),并统一归一化为 notifyTargets 字符串数组。
|
||||
function applyNotifyData(data: any, n: any) {
|
||||
const targets: string[] = n.notify_targets || n.notifyTargets || []
|
||||
if (targets.length) {
|
||||
const types: number[] = []
|
||||
const map: Record<number, number[]> = {}
|
||||
targets.forEach((s) => {
|
||||
const [tStr, idStr] = String(s).split(':')
|
||||
const t = Number(tStr)
|
||||
if (!t) return
|
||||
if (!map[t]) {
|
||||
map[t] = []
|
||||
types.push(t)
|
||||
}
|
||||
const id = Number(idStr)
|
||||
if (id > 0) map[t].push(id)
|
||||
})
|
||||
data.notifyTargets = targets.map(String)
|
||||
data.notifyTypes = types
|
||||
data.notifyIdsMap = map
|
||||
data.approverType = types[0] || data.approverType
|
||||
} else {
|
||||
const t = data.approverType
|
||||
const ids: number[] = [...(data.approverIds || [])]
|
||||
data.notifyTypes = [t]
|
||||
data.notifyIdsMap = ids.length ? { [t]: ids } : {}
|
||||
data.notifyTargets = ids.length ? ids.map((id: number) => `${t}:${id}`) : [`${t}:0`]
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 / 外部切换流程时重建
|
||||
function reload() {
|
||||
const { ns, es } = buildGraph(props.modelValue || {})
|
||||
@@ -378,6 +438,9 @@ const nodeForm = reactive<any>({
|
||||
multiMode: 1,
|
||||
allowReturn: 0,
|
||||
timeoutHours: 48,
|
||||
// 通知节点多类型:选中的类型 + 各类型对应的对象 ID
|
||||
notifyTypes: [] as number[],
|
||||
notifyIdsMap: {} as Record<number, number[]>,
|
||||
})
|
||||
|
||||
const nodeDialogTitle = computed(() => {
|
||||
@@ -395,11 +458,28 @@ const currentPool = computed(() => {
|
||||
return meta ? (props.pools as any)[meta.poolKey] || [] : []
|
||||
})
|
||||
|
||||
const poolOf = (poolKey: string) => (props.pools as any)[poolKey] || []
|
||||
|
||||
// 通知节点:已选类型中需要手动指定对象的元信息
|
||||
const selectedNotifyMetas = computed(() =>
|
||||
(props.approverTypes as any[]).filter(
|
||||
(t) => (nodeForm.notifyTypes || []).includes(t.value) && t.needsSelection
|
||||
)
|
||||
)
|
||||
|
||||
function onNotifyTypesChange(vals: number[]) {
|
||||
const keep = new Set<number>(vals)
|
||||
Object.keys(nodeForm.notifyIdsMap).forEach((k) => {
|
||||
if (!keep.has(Number(k))) delete nodeForm.notifyIdsMap[k]
|
||||
})
|
||||
}
|
||||
|
||||
function onNodeDblClick(evt: any) {
|
||||
const node = evt.node
|
||||
if (!node) return
|
||||
editingNodeId.value = node.id
|
||||
const d = node.data
|
||||
const isNotify = d.nodeType === NODE_TYPE.NOTIFY
|
||||
Object.assign(nodeForm, {
|
||||
name: d.name,
|
||||
nodeType: d.nodeType,
|
||||
@@ -408,6 +488,8 @@ function onNodeDblClick(evt: any) {
|
||||
multiMode: d.multiMode,
|
||||
allowReturn: d.allowReturn,
|
||||
timeoutHours: d.timeoutHours,
|
||||
notifyTypes: isNotify ? [...(d.notifyTypes || [d.approverType])] : [],
|
||||
notifyIdsMap: isNotify ? JSON.parse(JSON.stringify(d.notifyIdsMap || {})) : {},
|
||||
})
|
||||
nodeDialog.value = true
|
||||
}
|
||||
@@ -426,9 +508,44 @@ function confirmNode() {
|
||||
ElMessage.warning('请填写节点名称')
|
||||
return
|
||||
}
|
||||
// 通知节点:多类型 + 各类型对象,归一化为 "类型:ID" 列表
|
||||
if (nodeForm.nodeType === NODE_TYPE.NOTIFY) {
|
||||
if (!nodeForm.notifyTypes.length) {
|
||||
ElMessage.warning('请选择通知类型')
|
||||
return
|
||||
}
|
||||
for (const t of nodeForm.notifyTypes) {
|
||||
const m = (props.approverTypes as any[]).find((x) => x.value === t)
|
||||
if (m?.needsSelection && !(nodeForm.notifyIdsMap[t] || []).length) {
|
||||
ElMessage.warning(`请选择「${m.label}」的通知对象`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const targets: string[] = []
|
||||
nodeForm.notifyTypes.forEach((t: number) => {
|
||||
const ids = nodeForm.notifyIdsMap[t] || []
|
||||
if (ids.length) {
|
||||
ids.forEach((id: number) => targets.push(`${t}:${id}`))
|
||||
} else {
|
||||
targets.push(`${t}:0`)
|
||||
}
|
||||
})
|
||||
node.data = makeNodeData({
|
||||
node_type: NODE_TYPE.NOTIFY,
|
||||
node_name: nodeForm.name,
|
||||
notify_targets: targets,
|
||||
approver_type: nodeForm.notifyTypes[0],
|
||||
approver_ids: [],
|
||||
multi_mode: nodeForm.multiMode,
|
||||
allow_return: nodeForm.allowReturn,
|
||||
timeout_hours: nodeForm.timeoutHours,
|
||||
})
|
||||
nodeDialog.value = false
|
||||
return
|
||||
}
|
||||
const meta = currentApproverMeta.value
|
||||
if ((nodeForm.nodeType === NODE_TYPE.APPROVAL || nodeForm.nodeType === NODE_TYPE.NOTIFY) && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||
ElMessage.warning(nodeForm.nodeType === NODE_TYPE.NOTIFY ? '请选择通知对象' : '请选择审批人')
|
||||
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
|
||||
ElMessage.warning('请选择审批人')
|
||||
return
|
||||
}
|
||||
node.data = makeNodeData({
|
||||
@@ -481,19 +598,25 @@ function removeCurrentEdge() {
|
||||
|
||||
// ==================== 对外序列化 ====================
|
||||
function serialize() {
|
||||
const outNodes = nodes.value.map((n: any, i: number) => ({
|
||||
node_key: n.id,
|
||||
node_name: n.data.name,
|
||||
node_type: n.data.nodeType,
|
||||
approver_type: n.data.approverType,
|
||||
approver_ids: n.data.approverIds,
|
||||
multi_mode: n.data.multiMode,
|
||||
allow_return: n.data.allowReturn,
|
||||
timeout_hours: n.data.timeoutHours,
|
||||
sort: i + 1,
|
||||
pos_x: Math.round(n.position.x),
|
||||
pos_y: Math.round(n.position.y),
|
||||
}))
|
||||
const outNodes = nodes.value.map((n: any, i: number) => {
|
||||
const isNotify = n.data.nodeType === NODE_TYPE.NOTIFY
|
||||
return {
|
||||
node_key: n.id,
|
||||
node_name: n.data.name,
|
||||
node_type: n.data.nodeType,
|
||||
approver_type: isNotify
|
||||
? (n.data.notifyTypes?.length ? n.data.notifyTypes[0] : n.data.approverType)
|
||||
: n.data.approverType,
|
||||
approver_ids: isNotify ? [] : n.data.approverIds,
|
||||
notify_targets: isNotify ? n.data.notifyTargets || [] : [],
|
||||
multi_mode: n.data.multiMode,
|
||||
allow_return: n.data.allowReturn,
|
||||
timeout_hours: n.data.timeoutHours,
|
||||
sort: i + 1,
|
||||
pos_x: Math.round(n.position.x),
|
||||
pos_y: Math.round(n.position.y),
|
||||
}
|
||||
})
|
||||
const outEdges = edges.value.map((e: any) => ({
|
||||
from: e.source,
|
||||
to: e.target,
|
||||
@@ -841,4 +964,15 @@ defineExpose({ serialize, fitView })
|
||||
margin-bottom: 4px;
|
||||
color: #ffd04b;
|
||||
}
|
||||
|
||||
// 节点弹窗底部:删除靠左,取消/确定靠右
|
||||
.node-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>加入申请</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filterForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="待审核" value="0" />
|
||||
<el-option label="已通过" value="1" />
|
||||
<el-option label="已驳回" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 申请列表 -->
|
||||
<el-table :data="applies" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column label="申请人" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.identity_nickname || "-" }}</div>
|
||||
<div class="sub-text">{{ scope.row.identity_mobile || "" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="tenant_name"
|
||||
label="申请加入企业"
|
||||
min-width="160"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="联系方式" min-width="150" align="center">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.contact_person || "-" }}</div>
|
||||
<div class="sub-text">{{ scope.row.contact_phone || "" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small">
|
||||
{{ statusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="驳回原因" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.status === 2">{{
|
||||
scope.row.reject_reason || "-"
|
||||
}}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="reviewer_name"
|
||||
label="审核人"
|
||||
width="110"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.reviewer_name || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.review_time ? formatDateTime(row.review_time) : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="150"
|
||||
align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="scope">
|
||||
<template v-if="scope.row.status === 0">
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
link
|
||||
@click="handleApprove(scope.row)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleReject(scope.row)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else class="sub-text">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getTenantApplies,
|
||||
approveTenantApply,
|
||||
rejectTenantApply,
|
||||
} from "@/api/tenantApply";
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
|
||||
interface TenantApply {
|
||||
id: number;
|
||||
identity_id: number;
|
||||
tenant_name: string;
|
||||
identity_nickname: string;
|
||||
identity_mobile: string;
|
||||
contact_person: string;
|
||||
contact_phone: string;
|
||||
contact_email: string;
|
||||
status: number;
|
||||
reject_reason: string;
|
||||
reviewer_name: string;
|
||||
review_time: string | null;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const applies = ref<TenantApply[]>([]);
|
||||
|
||||
const filterForm = ref({
|
||||
status: "",
|
||||
});
|
||||
|
||||
const statusText = (status: number): string => {
|
||||
const map: Record<number, string> = {
|
||||
0: "待审核",
|
||||
1: "已通过",
|
||||
2: "已驳回",
|
||||
};
|
||||
return map[status] || "未知";
|
||||
};
|
||||
|
||||
const statusTagType = (status: number): string => {
|
||||
const map: Record<number, string> = {
|
||||
0: "warning",
|
||||
1: "success",
|
||||
2: "danger",
|
||||
};
|
||||
return map[status] || "info";
|
||||
};
|
||||
|
||||
// 获取申请列表
|
||||
const fetchApplies = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (filterForm.value.status !== "") {
|
||||
params.status = filterForm.value.status;
|
||||
}
|
||||
const res = await getTenantApplies(params);
|
||||
if (res.code === 200) {
|
||||
applies.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取申请列表失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "获取申请列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
filterForm.value = { status: "" };
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleSizeChange = () => {
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
// 通过申请:申请人加入本企业
|
||||
const handleApprove = async (row: TenantApply) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认通过「${row.identity_nickname || row.identity_mobile}」加入本企业的申请?通过后将默认分配「租户用户」角色。`,
|
||||
"通过申请",
|
||||
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await approveTenantApply(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已通过");
|
||||
fetchApplies();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 驳回申请(需填写原因)
|
||||
const handleReject = async (row: TenantApply) => {
|
||||
let reason = "";
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
"请输入驳回原因",
|
||||
`驳回「${row.identity_nickname || row.identity_mobile}」的申请`,
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
inputPlaceholder: "驳回原因(可选)",
|
||||
inputValidator: (v: string) => {
|
||||
if (v && v.length > 200) return "原因不能超过200字";
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
reason = value || "";
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rejectTenantApply(row.id, { reason });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已驳回");
|
||||
fetchApplies();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sub-text {
|
||||
color: #8a94ad;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -161,7 +161,8 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
// 调用接口修改密码
|
||||
const res = await changePassword(form.value.id, passwordForm.value);
|
||||
// 第三个参数 true:userId 是用户管理列表行主键,后端按主键精确匹配
|
||||
const res = await changePassword(form.value.id, passwordForm.value, true);
|
||||
|
||||
if (res.code === 200 || res.msg === '修改成功') {
|
||||
ElMessage.success("密码修改成功");
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { addUser, editUser, getUserInfo } from "@/api/user";
|
||||
import { getAllRoles } from "@/api/role";
|
||||
|
||||
@@ -161,11 +161,25 @@ const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await getAllRoles();
|
||||
roles.value = (res.data || []).filter((r: RoleOption) => r.status !== 0);
|
||||
applyDefaultRole();
|
||||
} catch (e) {
|
||||
roles.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 新增用户时,角色默认选中全局「租户用户」角色(tenant_id=0,平台统一维护)
|
||||
const applyDefaultRole = () => {
|
||||
if (!isAdd.value || form.value.group_id) {
|
||||
return;
|
||||
}
|
||||
const def = roles.value.find(
|
||||
(r) => r.name === "租户用户" && Number(r.tenant_id || 0) === 0
|
||||
);
|
||||
if (def) {
|
||||
form.value.group_id = def.id;
|
||||
}
|
||||
};
|
||||
|
||||
// 平台规定的默认角色(全局角色,tenant_id=0,平台端统一维护)
|
||||
const globalRoles = computed(() =>
|
||||
roles.value.filter((r) => Number(r.tenant_id || 0) === 0)
|
||||
@@ -273,7 +287,9 @@ const loadUserData = async (user: any) => {
|
||||
throw new Error("未提供有效的用户 ID");
|
||||
}
|
||||
|
||||
const res = await getUserInfo(userId);
|
||||
// 列表行的 id 是 yz_system_tenant_user 主键,必须 by=id 精确匹配,
|
||||
// 否则后端会先当 identity_id 换算老 uid,命中别人的记录
|
||||
const res = await getUserInfo(userId, { by: "id" });
|
||||
|
||||
const data = res.data || res;
|
||||
|
||||
@@ -362,8 +378,53 @@ const handleSubmit = async () => {
|
||||
group_id: form.value.group_id,
|
||||
};
|
||||
|
||||
await addUser(submitData);
|
||||
ElMessage.success("添加成功");
|
||||
const res: any = await addUser(submitData);
|
||||
|
||||
// 统一认证:账号在别的企业已存在(后端不覆盖),需验证原身份密码后绑定
|
||||
if (res?.code === 409 && res?.data?.need_verify) {
|
||||
const tenantNames = (res.data.tenants || [])
|
||||
.map((t: any) => t.tenant_name)
|
||||
.filter(Boolean)
|
||||
.join("、");
|
||||
const tip = tenantNames
|
||||
? `该账号已在「${tenantNames}」创建过账号。请输入该账号的登录密码验证身份,验证通过后直接绑定本企业(不会修改原密码)`
|
||||
: "该账号已在其他企业存在。请输入该账号的登录密码验证身份,验证通过后直接绑定本企业(不会修改原密码)";
|
||||
|
||||
// 密码错误时重新弹窗让用户重试,取消则停留在表单
|
||||
for (;;) {
|
||||
let verifyPassword = "";
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(tip, "身份验证", {
|
||||
confirmButtonText: "验证并绑定",
|
||||
cancelButtonText: "取消",
|
||||
inputType: "password",
|
||||
inputPlaceholder: "请输入该账号的登录密码",
|
||||
inputValidator: (v: string) =>
|
||||
v && v.trim() ? true : "密码不能为空",
|
||||
});
|
||||
verifyPassword = value;
|
||||
} catch {
|
||||
return; // 用户取消验证
|
||||
}
|
||||
|
||||
// 带 identity_id + verify_password 重新提交,后端验证通过只建绑定
|
||||
const retry: any = await addUser({
|
||||
...submitData,
|
||||
identity_id: res.data.identity_id,
|
||||
verify_password: verifyPassword,
|
||||
});
|
||||
|
||||
if (retry?.code === 200) {
|
||||
ElMessage.success("身份验证通过,已绑定本企业");
|
||||
break;
|
||||
}
|
||||
ElMessage.error(retry?.msg || "验证失败,请重新输入密码");
|
||||
}
|
||||
} else if (res?.code === 200) {
|
||||
ElMessage.success("添加成功");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑用户
|
||||
if (!form.value.id || form.value.id === 0) {
|
||||
@@ -388,7 +449,7 @@ const handleSubmit = async () => {
|
||||
submitData.password = form.value.password;
|
||||
}
|
||||
|
||||
await editUser(form.value.id, submitData);
|
||||
await editUser(form.value.id, submitData, { by: "id" });
|
||||
ElMessage.success("更新成功");
|
||||
}
|
||||
|
||||
@@ -423,6 +484,7 @@ defineExpose({
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
applyDefaultRole();
|
||||
},
|
||||
openEdit: (user: any) => {
|
||||
isAdd.value = false;
|
||||
@@ -451,7 +513,9 @@ defineExpose({
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
status: 1,
|
||||
group_id: undefined,
|
||||
};
|
||||
applyDefaultRole();
|
||||
}
|
||||
visible.value = true;
|
||||
// 清除表单验证
|
||||
|
||||
@@ -306,7 +306,7 @@ const handleDelete = async (user: User) => {
|
||||
type: "warning",
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
await deleteUser(user.id, { by: "id" });
|
||||
ElMessage.success("删除成功");
|
||||
fetchUsers();
|
||||
fetchQuotaInfo();
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/services"
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// AuthAccountController 统一认证中心自助账户:两步式注册 / 找回密码
|
||||
//
|
||||
// 注册两步:
|
||||
// 1. 核验身份:手机号(短信验证码)或 账号(服务号验证码),通过即创建身份并签发 register_token;
|
||||
// 2. 企业归属:绑定企业(join 申请) / 创建企业(资料+营业执照,平台审核) / 暂不加入。
|
||||
type AuthAccountController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *AuthAccountController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *AuthAccountController) readJSON(v interface{}) bool {
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
var err error
|
||||
body, err = io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loginBackURL 组装回登录页地址,透传 client_id/redirect 与提示参数
|
||||
func loginBackURL(clientID, redirect, notice, warn string) string {
|
||||
params := url.Values{}
|
||||
if clientID != "" {
|
||||
params.Set("client_id", clientID)
|
||||
}
|
||||
if redirect != "" {
|
||||
params.Set("redirect", redirect)
|
||||
}
|
||||
if notice != "" {
|
||||
params.Set("notice", notice)
|
||||
}
|
||||
if warn != "" {
|
||||
params.Set("warn", warn)
|
||||
}
|
||||
qs := params.Encode()
|
||||
target := "/auth/login"
|
||||
if qs != "" {
|
||||
target += "?" + qs
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// RegisterPage 注册页
|
||||
// GET /auth/register?client_id=xxx&redirect=<base64>
|
||||
func (c *AuthAccountController) RegisterPage() {
|
||||
c.Data["ClientID"] = strings.TrimSpace(c.GetString("client_id"))
|
||||
c.Data["Redirect"] = c.GetString("redirect")
|
||||
c.Data["Error"] = c.GetString("error")
|
||||
c.TplName = "auth/register.tpl"
|
||||
}
|
||||
|
||||
// ==================== 第一步:核验身份 ====================
|
||||
|
||||
// SendRegisterCode 发送手机号注册短信验证码
|
||||
// POST /auth/register/send-code { phone }
|
||||
func (c *AuthAccountController) SendRegisterCode() {
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
if err := services.SendIdentityCode("register", req.Phone); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "验证码已发送,请查收短信"})
|
||||
}
|
||||
|
||||
// WechatStart 账号注册:获取服务号带参二维码(扫码关注后服务号回复验证码)。
|
||||
// 未认证公众号无法生成带参二维码时回退:qr_url 为空,notice 引导用户
|
||||
// 在公众号内发送「注册」获取验证码。
|
||||
// GET /auth/register/wechat/start
|
||||
func (c *AuthAccountController) WechatStart() {
|
||||
scene, qrURL, notice, expireAt, err := wechatmp.StartRegisterVerify(wechatmp.DefaultVerifyTTL)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qr_url": qrURL,
|
||||
"notice": notice,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WechatStatus 账号注册:轮询扫码状态(展示「已扫码」)
|
||||
// GET /auth/register/wechat/status?scene=xxx
|
||||
func (c *AuthAccountController) WechatStatus() {
|
||||
scene := strings.TrimSpace(c.GetString("scene"))
|
||||
scanned, expireAt, err := wechatmp.GetRegisterVerifyStatus(scene)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"scanned": scanned,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterVerify 第一步核验:验证码通过即创建统一身份,返回 register_token
|
||||
// POST /auth/register/verify
|
||||
//
|
||||
// mode=phone { mode, phone, code, password, confirm }
|
||||
// mode=account { mode, account, scene, code, password, confirm }
|
||||
func (c *AuthAccountController) RegisterVerify() {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
Phone string `json:"phone"`
|
||||
Account string `json:"account"`
|
||||
Scene string `json:"scene"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
|
||||
openID := ""
|
||||
if req.Mode == "account" {
|
||||
// 核销服务号验证码,得到扫码人 openid(注册完成后绑定微信登录):
|
||||
// 有 scene 走二维码会话核销;无 scene(公众号发送「注册」)按验证码认领
|
||||
var err error
|
||||
if strings.TrimSpace(req.Scene) != "" {
|
||||
openID, err = wechatmp.ConfirmRegisterVerify(req.Scene, req.Code)
|
||||
} else {
|
||||
openID, err = wechatmp.ConfirmRegisterVerifyByCode(req.Code)
|
||||
}
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
identity, token, err := services.VerifyAndCreateIdentity(services.RegisterVerifyInput{
|
||||
Mode: req.Mode,
|
||||
Phone: req.Phone,
|
||||
Account: req.Account,
|
||||
Code: req.Code,
|
||||
Password: req.Password,
|
||||
Confirm: req.Confirm,
|
||||
OpenID: openID,
|
||||
})
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "验证通过",
|
||||
"data": map[string]interface{}{"register_token": token, "identity_id": identity.ID},
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 第二步:企业归属 ====================
|
||||
|
||||
// TenantSearch 搜索企业(绑定企业卡片)
|
||||
// GET /auth/register/tenant/search?keyword=xxx
|
||||
func (c *AuthAccountController) TenantSearch() {
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
if keyword == "" {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入企业名称或编码"})
|
||||
return
|
||||
}
|
||||
list, err := services.SearchTenantsForRegister(keyword)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "搜索失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"list": list}})
|
||||
}
|
||||
|
||||
// ApplyJoin 绑定企业:提交加入申请,等待企业管理员审核
|
||||
// POST /auth/register/apply-join { token, tid, client_id, redirect }
|
||||
func (c *AuthAccountController) ApplyJoin() {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
Tid uint64 `json:"tid"`
|
||||
ClientID string `json:"client_id"`
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
apply, err := services.CreateJoinApply(identityID, req.Tid)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "申请已提交",
|
||||
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered_warn",
|
||||
"注册成功!加入「"+apply.TenantName+"」的申请已提交,请等待企业管理员审核通过后登录"),
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyCreate 创建企业:提交企业资料(营业执照),等待平台审核
|
||||
// POST /auth/register/apply-create { token, tenant_name, credit_code, legal_person, license_file, contact_person, contact_phone, contact_email, address, client_id, redirect }
|
||||
func (c *AuthAccountController) ApplyCreate() {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
CreditCode string `json:"credit_code"`
|
||||
LegalPerson string `json:"legal_person"`
|
||||
LicenseFile string `json:"license_file"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
ClientID string `json:"client_id"`
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := services.CreateTenantApply(identityID, services.TenantCreateApplyInput{
|
||||
TenantName: req.TenantName,
|
||||
CreditCode: req.CreditCode,
|
||||
LegalPerson: req.LegalPerson,
|
||||
LicenseFile: req.LicenseFile,
|
||||
ContactPerson: req.ContactPerson,
|
||||
ContactPhone: req.ContactPhone,
|
||||
ContactEmail: req.ContactEmail,
|
||||
Address: req.Address,
|
||||
}); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "申请已提交",
|
||||
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered_warn",
|
||||
"注册成功!创建企业的申请已提交,请等待平台审核,审核结果将以短信通知"),
|
||||
})
|
||||
}
|
||||
|
||||
// FinishSkip 暂不加入企业,直接完成注册
|
||||
// POST /auth/register/finish { token, client_id, redirect }
|
||||
func (c *AuthAccountController) FinishSkip() {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"client_id"`
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "注册成功",
|
||||
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered", "注册成功!你当前未加入任何企业,可稍后在登录后申请加入"),
|
||||
})
|
||||
}
|
||||
|
||||
// UploadLicense 上传营业执照(注册页未登录,专用无鉴权端点)
|
||||
// POST /auth/register/upload-license multipart 字段 file;仅 pdf/jpg/png,≤5MB
|
||||
func (c *AuthAccountController) UploadLicense() {
|
||||
file, header, err := c.GetFile("file")
|
||||
if err != nil || file == nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 类型与大小限制
|
||||
name := strings.ToLower(header.Filename)
|
||||
ok := strings.HasSuffix(name, ".pdf") || strings.HasSuffix(name, ".jpg") ||
|
||||
strings.HasSuffix(name, ".jpeg") || strings.HasSuffix(name, ".png")
|
||||
if !ok {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅支持 PDF、JPG、PNG 格式"})
|
||||
return
|
||||
}
|
||||
if header.Size > 5*1024*1024 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "文件大小不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
storage, err := services.GetStorageService()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "存储服务不可用,请稍后重试"})
|
||||
return
|
||||
}
|
||||
staged, err := storage.Stage(file, header)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "文件读取失败,请重试"})
|
||||
return
|
||||
}
|
||||
result, err := storage.Commit(staged, services.UploadContext{Source: services.SourcePlatform, Tid: 0, Tuid: 0})
|
||||
if err != nil {
|
||||
_ = storage.Discard(staged)
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "上传失败,请重试"})
|
||||
return
|
||||
}
|
||||
// 文件登记(平台文件管理可见)
|
||||
_, _ = services.CreateFileRecord(services.FileUploadParams{
|
||||
Source: services.SourcePlatform,
|
||||
Scope: services.ScopeTenant,
|
||||
Tid: 0,
|
||||
Uid: 0,
|
||||
Name: header.Filename,
|
||||
Ext: staged.Ext,
|
||||
Cate: 0,
|
||||
Size: uint64(staged.Size),
|
||||
Src: result.URL,
|
||||
ObjectKey: result.Key,
|
||||
Storage: storage.Type(),
|
||||
MD5: staged.MD5,
|
||||
})
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{"url": result.URL, "name": header.Filename},
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 找回密码 ====================
|
||||
|
||||
// ForgotPage 找回密码页
|
||||
// GET /auth/forgot?client_id=xxx&redirect=<base64>
|
||||
func (c *AuthAccountController) ForgotPage() {
|
||||
c.Data["ClientID"] = strings.TrimSpace(c.GetString("client_id"))
|
||||
c.Data["Redirect"] = c.GetString("redirect")
|
||||
c.Data["Error"] = c.GetString("error")
|
||||
c.TplName = "auth/forgot.tpl"
|
||||
}
|
||||
|
||||
// SendResetCode 发送找回密码验证码
|
||||
// POST /auth/forgot/send-code { phone }
|
||||
func (c *AuthAccountController) SendResetCode() {
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
if err := services.SendIdentityCode("reset", req.Phone); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "验证码已发送,请查收短信"})
|
||||
}
|
||||
|
||||
// ResetSubmit 提交重置密码
|
||||
// POST /auth/forgot/reset { phone, code, password, confirm, client_id, redirect }
|
||||
func (c *AuthAccountController) ResetSubmit() {
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
Confirm string `json:"confirm"`
|
||||
ClientID string `json:"client_id"`
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ResetIdentityPasswordByMobile(req.Phone, req.Code, req.Password, req.Confirm); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "密码已重置",
|
||||
"redirect": loginBackURL(req.ClientID, req.Redirect, "reset", ""),
|
||||
})
|
||||
}
|
||||
@@ -69,6 +69,9 @@ func (c *AuthLoginController) LoginPage() {
|
||||
c.Data["Step"] = step
|
||||
c.Data["Error"] = errMsg
|
||||
c.Data["Tenants"] = tenants
|
||||
// 自助账户流程提示:registered=注册成功 / registered_warn=注册成功但加入企业失败 / reset=密码已重置
|
||||
c.Data["Notice"] = c.GetString("notice")
|
||||
c.Data["WarnMsg"] = c.GetString("warn")
|
||||
c.TplName = "auth/login.tpl"
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"server/services/wechatmini"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// 小程序登录失败原因(前端按此分支给出不同引导)
|
||||
const (
|
||||
MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序
|
||||
MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序
|
||||
MiniReasonIdentityNotFound = "identity_not_found" // 微信未匹配到云泽账号
|
||||
MiniReasonNoTenant = "no_tenant" // 账号未加入任何企业
|
||||
MiniReasonWechatAPI = "wechat_api_error" // 微信接口报错
|
||||
@@ -49,10 +49,19 @@ func (c *AuthWechatMiniController) fail(reason string, bizCode int, msg string)
|
||||
c.serveJSON(map[string]interface{}{"code": bizCode, "msg": msg, "reason": reason})
|
||||
}
|
||||
|
||||
// loadMiniConfig 按客户端传入的 appid 选择小程序配置(多应用支持),
|
||||
// 未传 appid 时回退第一条启用配置
|
||||
func loadMiniConfig(appID string) (*wechatmini.Config, error) {
|
||||
if appID = strings.TrimSpace(appID); appID != "" {
|
||||
return wechatmini.LoadEnabledAppByAppID(appID)
|
||||
}
|
||||
return wechatmini.LoadEnabledConfig()
|
||||
}
|
||||
|
||||
// Config 小程序登录是否可用
|
||||
// GET /auth/wechat/miniapp/config
|
||||
// GET /auth/wechat/miniapp/config?appid=xxx(多应用时可指定,缺省取第一条启用配置)
|
||||
func (c *AuthWechatMiniController) Config() {
|
||||
cfg, err := wechatmini.LoadEnabledConfig()
|
||||
cfg, err := loadMiniConfig(c.GetString("appid"))
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
@@ -72,6 +81,7 @@ func (c *AuthWechatMiniController) Login() {
|
||||
Code string `json:"code"`
|
||||
PhoneCode string `json:"phone_code"`
|
||||
ClientID string `json:"client_id"`
|
||||
AppID string `json:"appid"`
|
||||
}
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
@@ -92,7 +102,7 @@ func (c *AuthWechatMiniController) Login() {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := wechatmini.LoadEnabledConfig()
|
||||
cfg, err := loadMiniConfig(req.AppID)
|
||||
if err != nil {
|
||||
c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通,请使用账号密码登录")
|
||||
return
|
||||
@@ -193,7 +203,8 @@ func (c *AuthWechatMiniController) Bind() {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Code string `json:"code"`
|
||||
AppID string `json:"appid"`
|
||||
}
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
@@ -209,7 +220,7 @@ func (c *AuthWechatMiniController) Bind() {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := wechatmini.LoadEnabledConfig()
|
||||
cfg, err := loadMiniConfig(req.AppID)
|
||||
if err != nil {
|
||||
c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通")
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -56,11 +57,18 @@ type backendTenantUserPayload struct {
|
||||
IsDefault *int8 `json:"is_default"`
|
||||
Status *int8 `json:"status"`
|
||||
Remark *string `json:"remark"`
|
||||
// IdentityID + VerifyPassword:绑定已有认证身份时使用。
|
||||
// 归并键(手机/邮箱/账号)命中已有身份时,接口返回 409 要求验证;
|
||||
// 前端补上 identity_id 与该账号的登录密码(verify_password)重新提交,
|
||||
// 验证通过只建企业绑定,不新建身份、不覆盖原密码。
|
||||
IdentityID uint64 `json:"identity_id"`
|
||||
VerifyPassword string `json:"verify_password"`
|
||||
}
|
||||
|
||||
type backendChangePasswordPayload struct {
|
||||
ID uint64 `json:"id"`
|
||||
Password string `json:"password"`
|
||||
By string `json:"by"`
|
||||
}
|
||||
|
||||
func formatBackendBirth(birth *string) *string {
|
||||
@@ -187,7 +195,12 @@ func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUser
|
||||
// yz_system_tenant_user.uid 仍是老 uid(老表不在 uid 全量迁移范围内),
|
||||
// 因此必须先换算再按 uid 查;先按主键 id 查会把 identity_id 命中的是
|
||||
// 主键恰好相同的**别人**的记录(个人中心显示他人资料就是这么来的)。
|
||||
func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) {
|
||||
//
|
||||
// byID=true 表示调用方明确传入的是 yz_system_tenant_user 主键
|
||||
// (用户管理页的行数据,列表接口返回的 id 就是主键),
|
||||
// 此时只按主键精确查询,禁止 identity_id/uid 换算——否则主键值
|
||||
// 恰好等于别人老 uid 时会查到**别人**的记录(编辑李增炎却回显李志强)。
|
||||
func findBackendTenantUser(idOrUid uint64, jwtTid uint64, byID bool) (*models.SystemTenantUser, error) {
|
||||
if idOrUid == 0 {
|
||||
return nil, errors.New("用户ID不能为空")
|
||||
}
|
||||
@@ -201,6 +214,14 @@ func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantU
|
||||
return qs
|
||||
}
|
||||
|
||||
if byID {
|
||||
var row models.SystemTenantUser
|
||||
if err := base().Filter("id", idOrUid).One(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// 1) 当作 identity_id:换算成本租户老 uid 后匹配
|
||||
var row models.SystemTenantUser
|
||||
if uid := services.ResolveTenantUID(jwtTid, idOrUid); uid > 0 {
|
||||
@@ -323,14 +344,18 @@ func (c *BackendAdminUserController) GetTenantUsers() {
|
||||
}
|
||||
|
||||
// GetUserInfo 获取后台租户用户详情
|
||||
// GET /backend/getUserInfo/:id
|
||||
// GET /backend/getUserInfo/:id[?by=id]
|
||||
// by=id:前端明确传主键(用户管理页),只按主键查询;
|
||||
// 缺省时兼容 identity_id / 老 uid(个人中心等场景)。
|
||||
func (c *BackendAdminUserController) GetUserInfo() {
|
||||
jwtUid, jwtTid := c.getJWTUidTid()
|
||||
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, _ := strconv.ParseUint(idStr, 10, 64)
|
||||
|
||||
if id == 0 {
|
||||
byID := c.GetString("by") == "id"
|
||||
|
||||
if id == 0 && !byID {
|
||||
id = jwtUid
|
||||
}
|
||||
|
||||
@@ -340,7 +365,7 @@ func (c *BackendAdminUserController) GetUserInfo() {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := findBackendTenantUser(id, jwtTid)
|
||||
u, err := findBackendTenantUser(id, jwtTid, byID)
|
||||
if err != nil || u == nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "后台用户信息不存在"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -389,16 +414,75 @@ func (c *BackendAdminUserController) AddUser() {
|
||||
account := strings.TrimSpace(*p.Account)
|
||||
p.Account = &account
|
||||
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password))
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
p.Password = &hashed
|
||||
// ── 统一认证:一人一套账号密码,跨企业只建绑定 ─────────────────────────
|
||||
// 分支①:前端携带 identity_id(409 验证后的二次提交)——
|
||||
// 验证该身份的登录密码,通过后只建企业绑定,不新建身份、不覆盖原密码。
|
||||
if p.IdentityID > 0 {
|
||||
if strings.TrimSpace(p.VerifyPassword) == "" {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "请输入该账号在原企业的登录密码以验证身份"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
identity, err := authsvc.GetIdentityByID(p.IdentityID)
|
||||
if err != nil || identity == nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "认证身份不存在"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
if !authsvc.VerifyIdentityPassword(identity, strings.TrimSpace(p.VerifyPassword)) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "密码不正确,无法确认是同一账号"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
// uid 统一为 identity_id,与已全量迁移的业务表口径一致
|
||||
p.Uid = identity.ID
|
||||
// 老表镜像身份层现有密码哈希(登录仍以认证中心为准)
|
||||
if identity.PasswordHash != nil && *identity.PasswordHash != "" {
|
||||
h := *identity.PasswordHash
|
||||
p.Password = &h
|
||||
} else {
|
||||
// 身份层无密码(历史数据),仅镜像到老表,不影响统一认证
|
||||
hashed, hErr := passwordutil.Hash(strings.TrimSpace(*p.Password))
|
||||
if hErr != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": hErr.Error()}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
p.Password = &hashed
|
||||
}
|
||||
} else if p.Uid == 0 {
|
||||
// 分支②:归并键(手机 > 邮箱 > 账号)命中已有身份 → 不覆盖,
|
||||
// 返回 409 让前端提示「该账号已在哪些企业存在」,
|
||||
// 用户输入原企业登录密码验证本人后,携 identity_id 重新提交(走分支①)。
|
||||
if existed, hit := authsvc.LocateIdentityByKey(account, strOrEmpty(p.Phone), strOrEmpty(p.Email)); hit {
|
||||
hints := make([]map[string]interface{}, 0)
|
||||
if tenants, err := authsvc.ListTenantOptions(existed.ID); err == nil {
|
||||
for _, t := range tenants {
|
||||
hints = append(hints, map[string]interface{}{"tid": t.Tid, "tenant_name": t.TenantName})
|
||||
}
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 409,
|
||||
"msg": "该账号已在其他企业存在,请输入原企业登录密码验证身份后绑定本企业",
|
||||
"data": map[string]interface{}{
|
||||
"need_verify": true,
|
||||
"identity_id": existed.ID,
|
||||
"tenants": hints,
|
||||
},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
// 分支③:全新身份 —— password 必填,先建认证身份再拿 identity_id 当 uid
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password))
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
p.Password = &hashed
|
||||
|
||||
if p.Uid == 0 {
|
||||
// 统一走 uid:优先用认证中心 identity_id,与已全量迁移的业务表口径一致
|
||||
uid, err := ensureTenantUIDForNewUser(
|
||||
p.Tid,
|
||||
strOrEmpty(p.Account),
|
||||
@@ -478,10 +562,11 @@ func (c *BackendAdminUserController) AddUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库)
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库);
|
||||
// 身份若已存在,密码以身份层为准,不同步(避免覆盖)
|
||||
var created models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
|
||||
syncTenantUserToAuth(&created)
|
||||
syncTenantUserToAuth(&created, false)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
@@ -511,7 +596,7 @@ func (c *BackendAdminUserController) EditUser() {
|
||||
|
||||
_, jwtTid := c.getJWTUidTid()
|
||||
|
||||
row, err := findBackendTenantUser(id, jwtTid)
|
||||
row, err := findBackendTenantUser(id, jwtTid, c.GetString("by") == "id")
|
||||
if err != nil || row == nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -606,10 +691,10 @@ func (c *BackendAdminUserController) EditUser() {
|
||||
_ = services.SetDefaultTenant(row.Uid, row.Tid)
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心
|
||||
// 双写:同步到统一认证中心(编辑不改密码,不同步密码字段)
|
||||
var edited models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil {
|
||||
syncTenantUserToAuth(&edited)
|
||||
syncTenantUserToAuth(&edited, false)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||
@@ -620,8 +705,8 @@ func (c *BackendAdminUserController) EditUser() {
|
||||
// DELETE /backend/deleteUser/:id
|
||||
//
|
||||
// 用户管理里的账号属于租户层面的登录凭证(yz_system_tenant_user),与员工
|
||||
//(yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录
|
||||
//(yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。
|
||||
// (yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录
|
||||
// (yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。
|
||||
// 因此这里只解除登录绑定,不做任何级联操作——删除账号不会影响员工档案和通讯录记录。
|
||||
// 员工侧的离职/禁用走 deleteEmployee,档案侧的离职走 employeefile/delete。
|
||||
func (c *BackendAdminUserController) DeleteUser() {
|
||||
@@ -636,7 +721,7 @@ func (c *BackendAdminUserController) DeleteUser() {
|
||||
|
||||
_, jwtTid := c.getJWTUidTid()
|
||||
|
||||
row, err := findBackendTenantUser(id, jwtTid)
|
||||
row, err := findBackendTenantUser(id, jwtTid, c.GetString("by") == "id")
|
||||
if err != nil || row == nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -682,7 +767,7 @@ func (c *BackendAdminUserController) ChangePassword() {
|
||||
|
||||
_, jwtTid := c.getJWTUidTid()
|
||||
|
||||
row, err := findBackendTenantUser(p.ID, jwtTid)
|
||||
row, err := findBackendTenantUser(p.ID, jwtTid, p.By == "id")
|
||||
if err != nil || row == nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -708,10 +793,10 @@ func (c *BackendAdminUserController) ChangePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
// 双写:同步新密码到统一认证中心
|
||||
// 双写:同步新密码到统一认证中心(改密场景,显式允许更新身份层密码)
|
||||
var updated models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil {
|
||||
syncTenantUserToAuth(&updated)
|
||||
syncTenantUserToAuth(&updated, true)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"}
|
||||
|
||||
@@ -122,12 +122,23 @@ func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView {
|
||||
}
|
||||
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
approverIDs := parseIDSlice(n.ApproverIDs)
|
||||
// 通知节点多类型格式:"类型:ID" CSV,原样下发给前端回显
|
||||
var notifyTargets []string
|
||||
if n.NodeType == models.ApprovalNodeNotify && strings.Contains(n.ApproverIDs, ":") {
|
||||
for _, part := range strings.Split(n.ApproverIDs, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
notifyTargets = append(notifyTargets, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
view.Nodes = append(view.Nodes, approvalNodePayload{
|
||||
NodeKey: n.NodeKey,
|
||||
NodeName: n.NodeName,
|
||||
NodeType: n.NodeType,
|
||||
ApproverType: n.ApproverType,
|
||||
ApproverIDs: parseIDSlice(n.ApproverIDs),
|
||||
ApproverIDs: approverIDs,
|
||||
NotifyTargets: notifyTargets,
|
||||
MultiMode: n.MultiMode,
|
||||
AllowReturn: n.AllowReturn,
|
||||
TimeoutHours: n.TimeoutHours,
|
||||
@@ -273,6 +284,11 @@ func (c *BackendApprovalController) FlowSave() {
|
||||
if timeout <= 0 {
|
||||
timeout = 48
|
||||
}
|
||||
// 通知节点多类型:以 "类型:ID" CSV 落库到 approver_ids,引擎按类型分组解析
|
||||
approverCSV := joinIDs(nd.ApproverIDs)
|
||||
if nodeType == models.ApprovalNodeNotify && len(nd.NotifyTargets) > 0 {
|
||||
approverCSV = strings.Join(nd.NotifyTargets, ",")
|
||||
}
|
||||
node := models.TenantApprovalNode{
|
||||
FlowID: flowID,
|
||||
Tid: tid,
|
||||
@@ -280,7 +296,7 @@ func (c *BackendApprovalController) FlowSave() {
|
||||
NodeName: strings.TrimSpace(nd.NodeName),
|
||||
NodeType: nodeType,
|
||||
ApproverType: nd.ApproverType,
|
||||
ApproverIDs: joinIDs(nd.ApproverIDs),
|
||||
ApproverIDs: approverCSV,
|
||||
MultiMode: multi,
|
||||
AllowReturn: nd.AllowReturn,
|
||||
TimeoutHours: timeout,
|
||||
@@ -505,6 +521,26 @@ func validateTenantApprovalNodes(nodes []approvalNodePayload) error {
|
||||
if strings.TrimSpace(nd.NodeName) == "" {
|
||||
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
|
||||
}
|
||||
// 通知节点多类型格式:校验每条 "类型:ID",自动定位类型(5/6)无需对象
|
||||
if nd.NodeType == models.ApprovalNodeNotify && len(nd.NotifyTargets) > 0 {
|
||||
for _, tg := range nd.NotifyTargets {
|
||||
kv := strings.SplitN(strings.TrimSpace(tg), ":", 2)
|
||||
if len(kv) != 2 {
|
||||
return fmt.Errorf("节点「%s」通知对象格式非法", nd.NodeName)
|
||||
}
|
||||
t, err := strconv.Atoi(strings.TrimSpace(kv[0]))
|
||||
if err != nil || t < int(models.ApprovalApproverUser) || t > int(models.ApprovalApproverInitLeader) {
|
||||
return fmt.Errorf("节点「%s」通知类型非法", nd.NodeName)
|
||||
}
|
||||
if t != int(models.ApprovalApproverInitiator) && t != int(models.ApprovalApproverInitLeader) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(kv[1]), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return fmt.Errorf("节点「%s」未选择通知对象", nd.NodeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if nd.ApproverType < models.ApprovalApproverUser || nd.ApproverType > models.ApprovalApproverInitLeader {
|
||||
return fmt.Errorf("节点「%s」审批人类型非法", nd.NodeName)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
"strconv"
|
||||
"strings"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -98,22 +100,31 @@ func (c *BackendMenuController) GetBackendMenu() {
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
uid, _ := strconv.ParseUint(idStr, 10, 64)
|
||||
tid := uint64(0)
|
||||
groupID := uint64(0)
|
||||
if uid > 0 {
|
||||
var tu models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("id", uid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tu); e == nil {
|
||||
// URL 参数是认证中心 identity_id,不能直接当 yz_system_tenant_user 主键查:
|
||||
// 主键恰好与 identity_id 相同时会套用**别人**的角色权限——
|
||||
// 如 identity_id=1(李增炎)命中主键 1(李志强 tid=1、group_id=0),
|
||||
// 反而免过滤拿到全量菜单(越权);identity_id=2 则命中李增炎的受限角色,
|
||||
// 菜单被错误过滤(基础设置 404)。
|
||||
// 解析顺序:认证绑定表(identity_id+tid → group_id,与 /auth/userinfo 同源)
|
||||
// → 业务表身份解析链(identity_id 换算 → 主键 → 老 uid)。
|
||||
jwtTid := backendJWTTenantID(c.Ctx.Request.Header.Get("Authorization"))
|
||||
if bind, e := authsvc.GetTenantUser(uid, jwtTid); e == nil {
|
||||
tid = bind.Tid
|
||||
groupID = bind.GroupID
|
||||
} else if tu, e := findBackendTenantUser(uid, jwtTid, false); e == nil {
|
||||
tid = tu.Tid
|
||||
if tu.GroupID > 0 {
|
||||
var role models.AdminRole
|
||||
if re := models.Orm.QueryTable(new(models.AdminRole)).
|
||||
Filter("id", tu.GroupID).
|
||||
Filter("cid", 2).
|
||||
One(&role); re == nil {
|
||||
backendMenus = filterMenusByRights(backendMenus, role.Rights)
|
||||
}
|
||||
}
|
||||
groupID = tu.GroupID
|
||||
}
|
||||
}
|
||||
if groupID > 0 {
|
||||
var role models.AdminRole
|
||||
if re := models.Orm.QueryTable(new(models.AdminRole)).
|
||||
Filter("id", groupID).
|
||||
Filter("cid", 2).
|
||||
One(&role); re == nil {
|
||||
backendMenus = filterMenusByRights(backendMenus, role.Rights)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -48,6 +49,24 @@ func (c *BackendModulesController) jsonErr(httpStatus, bizCode int, msg string)
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// backendUserGroupID 解析当前登录者在当前租户的角色ID。
|
||||
// claims.UserID 是认证中心 identity_id,不能直接当 yz_system_tenant_user 主键用;
|
||||
// 优先按认证绑定表(identity_id+tid → group_id,与 /auth/userinfo 同源),
|
||||
// 查不到再走业务表身份解析链(identity_id 换算 → 主键 → 老 uid)。
|
||||
func (c *BackendModulesController) backendUserGroupID(claims *jwtutil.Claims, tid uint64) uint64 {
|
||||
uid := uint64(claims.UserID)
|
||||
if uid == 0 {
|
||||
return 0
|
||||
}
|
||||
if bind, e := authsvc.GetTenantUser(uid, tid); e == nil {
|
||||
return bind.GroupID
|
||||
}
|
||||
if tu, e := findBackendTenantUser(uid, tid, false); e == nil {
|
||||
return tu.GroupID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// moduleWithEnabled 带套餐开通标记的模块输出
|
||||
type moduleWithEnabled struct {
|
||||
models.SystemModules
|
||||
@@ -91,6 +110,38 @@ func (c *BackendModulesController) GetTenantList() {
|
||||
enhanced = append(enhanced, moduleWithEnabled{SystemModules: m, Enabled: enabled})
|
||||
}
|
||||
|
||||
// 角色权限过滤(与 /backend/menu/:id 同口径):
|
||||
// 未分配角色或角色 rights 为空 → 全部可见;
|
||||
// 否则仅保留「名下至少有一个被授权菜单」的模块,未授权模块直接不返回,
|
||||
// 避免首页出现可点击却进 404 的模块卡片。
|
||||
if groupID := c.backendUserGroupID(claims, tid); groupID > 0 {
|
||||
var role models.AdminRole
|
||||
if re := models.Orm.QueryTable(new(models.AdminRole)).
|
||||
Filter("id", groupID).
|
||||
Filter("cid", 2).
|
||||
One(&role); re == nil && role.Rights != nil && strings.TrimSpace(*role.Rights) != "" {
|
||||
if len(parseRightsToSet(*role.Rights)) > 0 {
|
||||
var allMenus []models.SystemMenu
|
||||
if _, me := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&allMenus); me == nil {
|
||||
allowedMenus := filterMenusByRights(allMenus, role.Rights)
|
||||
owningAny := services.ModuleCodesOwningMenus(rows, allMenus)
|
||||
owningAllowed := services.ModuleCodesOwningMenus(rows, allowedMenus)
|
||||
visible := make([]moduleWithEnabled, 0, len(enhanced))
|
||||
for _, item := range enhanced {
|
||||
code := services.NormalizeModuleCode(item.Code)
|
||||
// 模块名下有菜单但一个都没授权 → 隐藏;
|
||||
// 名下没有菜单(无法判定)→ 保持原行为不隐藏
|
||||
if owningAny[code] && !owningAllowed[code] {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, item)
|
||||
}
|
||||
enhanced = visible
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "获取成功",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendTenantApplyController 租户端「加入申请」审批
|
||||
// 数据按 JWT tid 隔离:只处理目标企业为本租户的 join 申请。
|
||||
type BackendTenantApplyController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendTenantApplyController) backendClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
}
|
||||
if claims.UserType != "backend" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *BackendTenantApplyController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendTenantApplyController) readJSON(v interface{}) bool {
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
var err error
|
||||
body, err = io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reviewerName 当前审核人姓名(租户用户表 name,兜底 JWT username)
|
||||
func (c *BackendTenantApplyController) reviewerName(claims *jwtutil.Claims) string {
|
||||
var row models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row); err == nil && row.Name != nil && strings.TrimSpace(*row.Name) != "" {
|
||||
return strings.TrimSpace(*row.Name)
|
||||
}
|
||||
return claims.Username
|
||||
}
|
||||
|
||||
// List GET /backend/tenantApplies?page=1&pageSize=20&status=
|
||||
func (c *BackendTenantApplyController) List() {
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
if tid == 0 {
|
||||
c.jsonErr(403, 403, "无租户信息")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
var status *int8
|
||||
if s := strings.TrimSpace(c.GetString("status")); s != "" {
|
||||
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
|
||||
tmp := int8(v)
|
||||
status = &tmp
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := services.ListTenantAppliesForTenant(tid, status, page, pageSize)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取申请列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 补充申请人展示信息(昵称 / 手机号)
|
||||
type applyDTO struct {
|
||||
models.AuthTenantApply
|
||||
IdentityNickname string `json:"identity_nickname"`
|
||||
IdentityMobile string `json:"identity_mobile"`
|
||||
}
|
||||
list := make([]applyDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
dto := applyDTO{AuthTenantApply: r}
|
||||
if identity, ierr := authsvc.GetIdentityByID(r.IdentityID); ierr == nil && identity != nil {
|
||||
if identity.Nickname != nil {
|
||||
dto.IdentityNickname = *identity.Nickname
|
||||
}
|
||||
if identity.Mobile != nil {
|
||||
dto.IdentityMobile = *identity.Mobile
|
||||
}
|
||||
}
|
||||
list = append(list, dto)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"list": list, "total": total},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Approve POST /backend/tenantApplies/approve/:id
|
||||
func (c *BackendTenantApplyController) Approve() {
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
applyID, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || applyID == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
if err := services.ApproveJoinApply(applyID, tid, uint64(claims.UserID), c.reviewerName(claims)); err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已通过,申请人已加入本企业"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Reject POST /backend/tenantApplies/reject/:id { reason }
|
||||
func (c *BackendTenantApplyController) Reject() {
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
applyID, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || applyID == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
if err := services.RejectJoinApply(applyID, tid, uint64(claims.UserID), c.reviewerName(claims), strings.TrimSpace(req.Reason)); err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已驳回"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -35,6 +35,10 @@ type approvalNodePayload struct {
|
||||
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4判断 5通知
|
||||
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
|
||||
ApproverIDs []uint64 `json:"approver_ids"`
|
||||
// NotifyTargets 通知节点多类型存储:"类型:ID" 列表(如 ["1:52","5:0"]),
|
||||
// 类型取值同 approver_type;自动定位类型(5/6)ID 占位 0。
|
||||
// 仅通知节点使用;空列表时按 approver_type + approver_ids 旧格式处理。
|
||||
NotifyTargets []string `json:"notify_targets"`
|
||||
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
|
||||
AllowReturn int8 `json:"allow_return"` // 是否允许退回
|
||||
TimeoutHours int `json:"timeout_hours"` // 超时小时
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -21,27 +23,27 @@ type PlatformTenantController struct {
|
||||
}
|
||||
|
||||
type tenantDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
Worktime string `json:"worktime"`
|
||||
Status int8 `json:"status"`
|
||||
ID uint64 `json:"id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
Worktime string `json:"worktime"`
|
||||
Status int8 `json:"status"`
|
||||
// 套餐与用户数配额
|
||||
PackageID uint64 `json:"package_id"`
|
||||
PackageName string `json:"package_name"`
|
||||
// PackageExpireTime 套餐计费到期时间(NULL-永久有效);DaysRemaining 剩余天数
|
||||
PackageExpireTime *time.Time `json:"package_expire_time"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
UserQuota int `json:"user_quota"`
|
||||
UserUsed int `json:"user_used"`
|
||||
Remark string `json:"remark"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
UpdateTime *time.Time `json:"update_time,omitempty"`
|
||||
DeleteTime *time.Time `json:"delete_time,omitempty"`
|
||||
UserQuota int `json:"user_quota"`
|
||||
UserUsed int `json:"user_used"`
|
||||
Remark string `json:"remark"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
UpdateTime *time.Time `json:"update_time,omitempty"`
|
||||
DeleteTime *time.Time `json:"delete_time,omitempty"`
|
||||
}
|
||||
|
||||
// tenantPackageNameMap 套餐ID -> 套餐名称
|
||||
@@ -94,23 +96,23 @@ func toTenantDTO(t models.SystemTenant) tenantDTO {
|
||||
}
|
||||
}
|
||||
return tenantDTO{
|
||||
ID: t.ID,
|
||||
TenantCode: t.TenantCode,
|
||||
TenantName: t.TenantName,
|
||||
ContactPerson: stringValue(t.ContactPerson),
|
||||
ContactPhone: stringValue(t.ContactPhone),
|
||||
ContactEmail: stringValue(t.ContactEmail),
|
||||
Address: stringValue(t.Address),
|
||||
Worktime: stringValue(t.Worktime),
|
||||
Status: t.Status,
|
||||
PackageID: t.PackageID,
|
||||
ID: t.ID,
|
||||
TenantCode: t.TenantCode,
|
||||
TenantName: t.TenantName,
|
||||
ContactPerson: stringValue(t.ContactPerson),
|
||||
ContactPhone: stringValue(t.ContactPhone),
|
||||
ContactEmail: stringValue(t.ContactEmail),
|
||||
Address: stringValue(t.Address),
|
||||
Worktime: stringValue(t.Worktime),
|
||||
Status: t.Status,
|
||||
PackageID: t.PackageID,
|
||||
PackageExpireTime: t.PackageExpireTime,
|
||||
DaysRemaining: daysRemaining,
|
||||
UserQuota: t.UserQuota,
|
||||
Remark: stringValue(t.Remark),
|
||||
CreateTime: &ct,
|
||||
UpdateTime: &ut,
|
||||
DeleteTime: t.DeleteTime,
|
||||
UserQuota: t.UserQuota,
|
||||
Remark: stringValue(t.Remark),
|
||||
CreateTime: &ct,
|
||||
UpdateTime: &ut,
|
||||
DeleteTime: t.DeleteTime,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +236,14 @@ type tenantPayload struct {
|
||||
PackageID *uint64 `json:"package_id"`
|
||||
UserQuota *int `json:"user_quota"`
|
||||
Remark string `json:"remark"`
|
||||
|
||||
// 指定租户管理员(可选):把已有认证身份绑定为该租户管理员。
|
||||
// AdminIdentityID 直接指定身份;也可只传 AdminAccount/AdminPhone/AdminEmail 按归并键定位。
|
||||
// 统一认证原则:不新建账号、不覆盖原密码,只建「身份-企业」绑定并授予租户管理员角色。
|
||||
AdminIdentityID uint64 `json:"admin_identity_id"`
|
||||
AdminAccount string `json:"admin_account"`
|
||||
AdminPhone string `json:"admin_phone"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
}
|
||||
|
||||
func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
|
||||
@@ -264,6 +274,14 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
|
||||
p.UserQuota = &v
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(c.GetString("admin_identity_id")); s != "" {
|
||||
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
||||
p.AdminIdentityID = v
|
||||
}
|
||||
}
|
||||
p.AdminAccount = strings.TrimSpace(c.GetString("admin_account"))
|
||||
p.AdminPhone = strings.TrimSpace(c.GetString("admin_phone"))
|
||||
p.AdminEmail = strings.TrimSpace(c.GetString("admin_email"))
|
||||
|
||||
// ĺŚćĺ
łéŽĺ掾为犺ďźĺ°čŻäť JSON body č§ŁćďźeditTenant éťčޤ JSONďź?
|
||||
if p.TenantName == "" && p.TenantCode == "" {
|
||||
@@ -275,6 +293,72 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// bindTenantAdminFromPayload 把已有认证身份绑定为新租户的管理员(不新建账号、不覆盖密码)。
|
||||
// 优先 AdminIdentityID;否则按 AdminAccount/AdminPhone/AdminEmail 归并键(手机>邮箱>账号)定位。
|
||||
// 返回 (是否绑定, 错误);未指定管理员时返回 (false, nil)。
|
||||
func bindTenantAdminFromPayload(tid uint64, p tenantPayload) (bool, error) {
|
||||
identityID := p.AdminIdentityID
|
||||
if identityID == 0 && (p.AdminAccount != "" || p.AdminPhone != "" || p.AdminEmail != "") {
|
||||
existed, hit := authsvc.LocateIdentityByKey(p.AdminAccount, p.AdminPhone, p.AdminEmail)
|
||||
if !hit {
|
||||
return false, errors.New("未找到该账号对应的认证身份")
|
||||
}
|
||||
identityID = existed.ID
|
||||
}
|
||||
if identityID == 0 {
|
||||
return false, nil // 未指定管理员,跳过
|
||||
}
|
||||
|
||||
identity, err := authsvc.GetIdentityByID(identityID)
|
||||
if err != nil || identity == nil {
|
||||
return false, errors.New("认证身份不存在")
|
||||
}
|
||||
|
||||
// 企业内账号:显式指定 > 身份手机号 > user{id};姓名取身份昵称
|
||||
account := p.AdminAccount
|
||||
if account == "" && identity.Mobile != nil {
|
||||
account = strings.TrimSpace(*identity.Mobile)
|
||||
}
|
||||
if account == "" {
|
||||
account = fmt.Sprintf("user%d", identity.ID)
|
||||
}
|
||||
name := "租户管理员"
|
||||
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||
name = strings.TrimSpace(*identity.Nickname)
|
||||
}
|
||||
|
||||
// 老表镜像身份层现有密码哈希(登录仍以认证中心为准,不在建租户时改密)
|
||||
var pwd *string
|
||||
if identity.PasswordHash != nil {
|
||||
h := *identity.PasswordHash
|
||||
pwd = &h
|
||||
}
|
||||
|
||||
// isDefault=0:不改变该用户当前的默认企业
|
||||
if _, err := services.BindTenantUser(tid, identity.ID, &account, &name, identity.Mobile, identity.Email, nil, nil, pwd, 0, 1, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 绑定全局「租户管理员」角色(拥有 backend 全权限)
|
||||
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", identity.ID).
|
||||
Update(map[string]interface{}{"group_id": adminRoleID})
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心(建 yz_auth_tenant_user 绑定)
|
||||
var row models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", identity.ID).
|
||||
One(&row); e == nil {
|
||||
syncTenantUserToAuth(&row, false)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateTenant ĺĺťşç§ćˇ
|
||||
// POST /platform/tenant/createTenant
|
||||
func (c *PlatformTenantController) CreateTenant() {
|
||||
@@ -369,10 +453,25 @@ func (c *PlatformTenantController) CreateTenant() {
|
||||
return
|
||||
}
|
||||
|
||||
// 指定已有身份为租户管理员(可选):只建绑定,不新建账号、不改密码。
|
||||
// 绑定失败不影响租户创建结果,仅返回警告信息。
|
||||
adminBound := false
|
||||
adminWarn := ""
|
||||
if bound, bErr := bindTenantAdminFromPayload(uint64(id), p); bErr != nil {
|
||||
adminWarn = bErr.Error()
|
||||
} else {
|
||||
adminBound = bound
|
||||
}
|
||||
|
||||
msg := "success"
|
||||
if adminWarn != "" {
|
||||
msg = "租户已创建,但指定管理员绑定失败: " + adminWarn
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"id": id},
|
||||
"msg": msg,
|
||||
"data": map[string]interface{}{"id": id, "admin_bound": adminBound},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// PlatformTenantApplyController 平台端「企业创建审核」
|
||||
// 审批注册用户提交的创建企业申请:通过 → 自动建租户并把申请人绑定为租户管理员。
|
||||
type PlatformTenantApplyController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformTenantApplyController) platformClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("token 格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token 无效")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *PlatformTenantApplyController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *PlatformTenantApplyController) readJSON(v interface{}) bool {
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
var err error
|
||||
body, err = io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reviewerName 平台审核人姓名
|
||||
func (c *PlatformTenantApplyController) reviewerName(claims *jwtutil.Claims) string {
|
||||
var admin models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).
|
||||
Filter("id", claims.UserID).
|
||||
One(&admin); err == nil && admin.Name != nil && strings.TrimSpace(*admin.Name) != "" {
|
||||
return strings.TrimSpace(*admin.Name)
|
||||
}
|
||||
return claims.Username
|
||||
}
|
||||
|
||||
// List GET /platform/tenantApply/list?page=1&pageSize=20&type=&status=
|
||||
func (c *PlatformTenantApplyController) List() {
|
||||
claims, err := c.platformClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
_ = claims
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
var applyType *int8
|
||||
if s := strings.TrimSpace(c.GetString("type")); s != "" {
|
||||
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
|
||||
tmp := int8(v)
|
||||
applyType = &tmp
|
||||
}
|
||||
}
|
||||
var status *int8
|
||||
if s := strings.TrimSpace(c.GetString("status")); s != "" {
|
||||
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
|
||||
tmp := int8(v)
|
||||
status = &tmp
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := services.ListTenantAppliesForPlatform(applyType, status, page, pageSize)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取申请列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
type applyDTO struct {
|
||||
models.AuthTenantApply
|
||||
IdentityNickname string `json:"identity_nickname"`
|
||||
IdentityMobile string `json:"identity_mobile"`
|
||||
}
|
||||
list := make([]applyDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
dto := applyDTO{AuthTenantApply: r}
|
||||
if identity, ierr := authsvc.GetIdentityByID(r.IdentityID); ierr == nil && identity != nil {
|
||||
if identity.Nickname != nil {
|
||||
dto.IdentityNickname = *identity.Nickname
|
||||
}
|
||||
if identity.Mobile != nil {
|
||||
dto.IdentityMobile = *identity.Mobile
|
||||
}
|
||||
}
|
||||
list = append(list, dto)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"list": list, "total": total},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Detail GET /platform/tenantApply/detail/:id
|
||||
func (c *PlatformTenantApplyController) Detail() {
|
||||
claims, err := c.platformClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
_ = claims
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
var apply models.AuthTenantApply
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&apply); err != nil {
|
||||
c.jsonErr(404, 404, "申请不存在")
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": apply}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Approve POST /platform/tenantApply/approve/:id
|
||||
func (c *PlatformTenantApplyController) Approve() {
|
||||
claims, err := c.platformClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
tid, err := services.ApproveCreateApply(id, uint64(claims.UserID), c.reviewerName(claims))
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
msg := "已通过,租户创建成功,申请人已绑定为该企业管理员"
|
||||
if tid > 0 {
|
||||
msg = fmt.Sprintf("已通过:租户已创建(ID:%d),申请人已绑定为企业管理员", tid)
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg, "data": map[string]interface{}{"tid": tid}}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Reject POST /platform/tenantApply/reject/:id { reason }
|
||||
func (c *PlatformTenantApplyController) Reject() {
|
||||
claims, err := c.platformClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
_ = claims
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
if err := services.RejectCreateApply(id, uint64(claims.UserID), c.reviewerName(claims), strings.TrimSpace(req.Reason)); err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已驳回"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -21,17 +21,20 @@ import (
|
||||
)
|
||||
|
||||
// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。
|
||||
// updatePassword:身份已存在时是否允许同步密码(仅改密场景传 true;
|
||||
// 新增/编辑/绑定场景传 false,避免把身份层密码覆盖回旧值)。
|
||||
// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。
|
||||
func syncTenantUserToAuth(row *models.SystemTenantUser) {
|
||||
func syncTenantUserToAuth(row *models.SystemTenantUser, updatePassword bool) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
in := authsvc.SyncTenantUserInput{
|
||||
Tid: row.Tid,
|
||||
GroupID: row.GroupID,
|
||||
OrgID: row.OrgID,
|
||||
Status: row.Status,
|
||||
IsDefault: row.IsDefault,
|
||||
Tid: row.Tid,
|
||||
GroupID: row.GroupID,
|
||||
OrgID: row.OrgID,
|
||||
Status: row.Status,
|
||||
IsDefault: row.IsDefault,
|
||||
UpdatePassword: updatePassword,
|
||||
}
|
||||
if row.Account != nil {
|
||||
in.Account = strings.TrimSpace(*row.Account)
|
||||
@@ -293,10 +296,11 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库)
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库);
|
||||
// 新建身份时密码已随身份创建写入;已存在身份不覆盖密码
|
||||
var created models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil {
|
||||
syncTenantUserToAuth(&created)
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
|
||||
syncTenantUserToAuth(&created, false)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
|
||||
@@ -376,10 +380,10 @@ func (c *PlatformTenantUserController) EditTenantUser() {
|
||||
_ = services.SetDefaultTenant(p.Uid, p.Tid)
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心
|
||||
// 双写:同步到统一认证中心(仅本次显式修改了密码时才同步密码字段)
|
||||
var updated models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&updated); err == nil {
|
||||
syncTenantUserToAuth(&updated)
|
||||
syncTenantUserToAuth(&updated, p.Password != nil)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services/wechatmp"
|
||||
"server/services/wechatmini"
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -129,3 +131,88 @@ func (c *PlatformWechatMiniController) SaveConfig() {
|
||||
}
|
||||
c.ok(nil, "保存成功")
|
||||
}
|
||||
|
||||
// ListApps GET /platform/wechatMini/apps 多小程序应用列表(AppSecret 不回显)
|
||||
func (c *PlatformWechatMiniController) ListApps() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
rows, err := wechatmini.ListApps()
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取应用列表失败")
|
||||
return
|
||||
}
|
||||
type appDTO struct {
|
||||
models.WechatMiniApp
|
||||
Configured bool `json:"configured"` // AppSecret 已配置
|
||||
}
|
||||
list := make([]appDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
dto := appDTO{WechatMiniApp: r, Configured: r.AppSecret != ""}
|
||||
list = append(list, dto)
|
||||
}
|
||||
c.ok(map[string]interface{}{"list": list}, "获取成功")
|
||||
}
|
||||
|
||||
// SaveApp POST /platform/wechatMini/app/save 新增/更新应用
|
||||
func (c *PlatformWechatMiniController) SaveApp() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Enabled int `json:"enabled"`
|
||||
AutoBind int `json:"auto_bind"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
c.jsonErr(400, 400, "应用名称不能为空")
|
||||
return
|
||||
}
|
||||
id, err := wechatmini.SaveApp(wechatmini.SaveAppInput{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
AppID: p.AppID,
|
||||
AppSecret: p.AppSecret,
|
||||
Enabled: p.Enabled == 1,
|
||||
AutoBind: p.AutoBind == 1,
|
||||
Remark: p.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{"id": id}, "保存成功")
|
||||
}
|
||||
|
||||
// DeleteApp POST /platform/wechatMini/app/delete/:id 软删应用并停用
|
||||
func (c *PlatformWechatMiniController) DeleteApp() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
if err := wechatmini.DeleteApp(id); err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "已删除")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
-- ============================================================
|
||||
-- 统一身份层增加「全局登录账号」列(账号注册方式)
|
||||
-- 手机号注册:mobile 即账号;账号注册:自定义账号名存本列,
|
||||
-- 登录查找链 FindIdentityByAccount 按账号 → 手机号 → 邮箱匹配。
|
||||
-- 唯一索引允许多个 NULL(手机号注册的身份不填账号列)。
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < alter_auth_identity_account.sql
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE yz_auth_identity
|
||||
ADD COLUMN account varchar(64) NULL DEFAULT NULL COMMENT '全局登录账号(账号注册;NULL 表示手机号注册身份)' AFTER union_id;
|
||||
|
||||
ALTER TABLE yz_auth_identity
|
||||
ADD UNIQUE KEY uk_auth_identity_account (account);
|
||||
|
||||
-- 结果校验
|
||||
SHOW COLUMNS FROM yz_auth_identity LIKE 'account';
|
||||
@@ -0,0 +1,92 @@
|
||||
-- ============================================================
|
||||
-- 注册企业绑定/创建申请(yz_auth_tenant_apply)+ 审核菜单
|
||||
-- type=1 加入企业:注册第二步「绑定企业」,企业管理员在 backend 审批
|
||||
-- type=2 创建企业:注册第二步「创建企业」,平台在 platform 审批,
|
||||
-- 通过后自动建租户并把申请人绑定为租户管理员(不可更改)
|
||||
-- 幂等脚本:重复执行不会产生重复数据。
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < create_auth_tenant_apply.sql
|
||||
-- ============================================================
|
||||
|
||||
-- ---------- 1. 申请表 ----------
|
||||
CREATE TABLE IF NOT EXISTS yz_auth_tenant_apply (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
identity_id bigint unsigned NOT NULL COMMENT '申请人统一身份ID(yz_auth_identity)',
|
||||
type tinyint NOT NULL DEFAULT 1 COMMENT '1=加入企业 2=创建企业',
|
||||
tid bigint unsigned NOT NULL DEFAULT 0 COMMENT '目标企业ID(type=1 有值;type=2 审核通过后回填)',
|
||||
tenant_name varchar(128) NOT NULL DEFAULT '' COMMENT '企业名称(申请时快照)',
|
||||
credit_code varchar(64) NOT NULL DEFAULT '' COMMENT '统一社会信用代码(type=2)',
|
||||
legal_person varchar(64) NOT NULL DEFAULT '' COMMENT '法定代表人姓名(type=2)',
|
||||
license_file varchar(500) NOT NULL DEFAULT '' COMMENT '营业执照文件URL,pdf/jpg/png(type=2)',
|
||||
contact_person varchar(64) NOT NULL DEFAULT '' COMMENT '联系人',
|
||||
contact_phone varchar(20) NOT NULL DEFAULT '' COMMENT '联系电话',
|
||||
contact_email varchar(128) NOT NULL DEFAULT '' COMMENT '电子邮箱',
|
||||
address varchar(255) NOT NULL DEFAULT '' COMMENT '公司地址',
|
||||
status tinyint NOT NULL DEFAULT 0 COMMENT '0=待审核 1=已通过 2=已驳回',
|
||||
reject_reason varchar(255) NOT NULL DEFAULT '' COMMENT '驳回原因',
|
||||
reviewer_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '审核人ID(backend=租户用户uid / platform=平台管理员id)',
|
||||
reviewer_name varchar(64) NOT NULL DEFAULT '' COMMENT '审核人姓名',
|
||||
review_time datetime NULL COMMENT '审核时间',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time datetime NULL COMMENT '更新时间',
|
||||
delete_time datetime NULL COMMENT '软删时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_apply_identity (identity_id),
|
||||
KEY idx_apply_tid (tid),
|
||||
KEY idx_apply_status (status, type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='注册时加入企业/创建企业申请';
|
||||
|
||||
-- ---------- 2. backend 菜单:基础设置 → 加入申请 ----------
|
||||
-- 注意:用户变量在 SELECT 列表赋值后于同语句 WHERE 中引用不可靠,改用派生表
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT bs.id, '加入申请', '/basicSettings/applies', 'basicSettings/applies/index.vue',
|
||||
'Bell', 90, 1, 1, '[2]', 2, 'system', '企业管理员审批注册用户提交的「绑定企业」申请'
|
||||
FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL LIMIT 1) bs
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/applies' AND delete_time IS NULL) x);
|
||||
|
||||
-- 找不到基础设置目录时退化为 backend 顶级菜单
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT 0, '加入申请', '/basicSettings/applies', 'basicSettings/applies/index.vue',
|
||||
'Bell', 90, 1, 1, '[2]', 2, 'system', '企业管理员审批注册用户提交的「绑定企业」申请'
|
||||
FROM DUAL
|
||||
WHERE (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL LIMIT 1) IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/applies' AND delete_time IS NULL) x);
|
||||
|
||||
-- ---------- 3. platform 菜单:企业创建审核(顶级) ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT 0, '企业创建审核', '/tenantApply', 'tenantApply/index.vue',
|
||||
'OfficeBuilding', 21, 1, 1, '[1]', 2, 'system', '平台审批注册用户提交的「创建企业」申请,通过后自动建租户'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/tenantApply' AND delete_time IS NULL) x);
|
||||
|
||||
-- ---------- 4. 把「加入申请」菜单加入全局「租户管理员」角色 rights ----------
|
||||
-- rights 兼容 JSON 数组与逗号分隔两种格式;rights 为空表示全权限,无需处理
|
||||
SET @apply_menu_id := (
|
||||
SELECT id FROM yz_system_menu WHERE path = '/basicSettings/applies' AND delete_time IS NULL LIMIT 1
|
||||
);
|
||||
|
||||
-- 逗号分隔格式:追加
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CONCAT(TRIM(TRAILING ',' FROM TRIM(BOTH ' ' FROM rights)), ',', @apply_menu_id)
|
||||
WHERE cid = 2 AND tenant_id = 0 AND name = '租户管理员' AND delete_time IS NULL
|
||||
AND @apply_menu_id IS NOT NULL
|
||||
AND rights IS NOT NULL AND TRIM(rights) <> '' AND TRIM(rights) NOT LIKE '[%'
|
||||
AND NOT FIND_IN_SET(@apply_menu_id, REPLACE(REPLACE(REPLACE(rights, '[', ''), ']', ''), ' ', ''));
|
||||
|
||||
-- JSON 数组格式:末尾插入
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CONCAT(SUBSTRING(TRIM(rights), 1, LENGTH(TRIM(rights)) - 1), ',', @apply_menu_id, ']')
|
||||
WHERE cid = 2 AND tenant_id = 0 AND name = '租户管理员' AND delete_time IS NULL
|
||||
AND @apply_menu_id IS NOT NULL
|
||||
AND TRIM(rights) LIKE '[%' AND TRIM(rights) LIKE '%]'
|
||||
AND NOT FIND_IN_SET(@apply_menu_id, REPLACE(REPLACE(REPLACE(rights, '[', ''), ']', ''), ' ', ''));
|
||||
|
||||
-- ---------- 结果校验 ----------
|
||||
SELECT id, pid, title, path, component_path, views, type
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path IN ('/basicSettings/applies', '/tenantApply');
|
||||
|
||||
SELECT id, cid, tenant_id, name, rights FROM yz_system_admin_role
|
||||
WHERE cid = 2 AND tenant_id = 0 AND name = '租户管理员' AND delete_time IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ============================================================
|
||||
-- 微信小程序多应用配置(yz_platform_wechat_mini_app)
|
||||
-- 支持配置多个小程序:认证端登录时按客户端传入 appid 精确匹配,
|
||||
-- 未传 appid 时回退第一条启用记录。
|
||||
-- AppSecret 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥)。
|
||||
-- 幂等脚本:重复执行不会产生重复数据。
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < create_wechat_mini_app.sql
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_app (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
name varchar(128) NOT NULL DEFAULT '' COMMENT '应用名称',
|
||||
app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
|
||||
app_secret varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES-256-GCM 加密存储)',
|
||||
enabled tinyint NOT NULL DEFAULT 0 COMMENT '1=启用 0=停用',
|
||||
auto_bind tinyint NOT NULL DEFAULT 1 COMMENT '1=手机号命中身份后自动绑定小程序',
|
||||
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time datetime NULL COMMENT '更新时间',
|
||||
delete_time datetime NULL COMMENT '软删时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_mini_app_appid (app_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信小程序多应用配置';
|
||||
|
||||
-- 迁移旧单行配置(yz_platform_wechat_mini_config)到新表;已存在同 appid 时跳过
|
||||
INSERT INTO yz_platform_wechat_mini_app (name, app_id, app_secret, enabled, auto_bind, remark)
|
||||
SELECT COALESCE(NULLIF(c.remark, ''), '默认小程序'), c.app_id, c.app_secret, c.enabled, c.auto_bind, c.remark
|
||||
FROM yz_platform_wechat_mini_config c
|
||||
WHERE c.app_id <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM (SELECT 1 FROM yz_platform_wechat_mini_app x WHERE x.app_id = c.app_id) t
|
||||
);
|
||||
|
||||
-- 结果校验
|
||||
SELECT id, name, app_id, enabled, auto_bind, remark
|
||||
FROM yz_platform_wechat_mini_app
|
||||
WHERE delete_time IS NULL;
|
||||
@@ -26,3 +26,20 @@ func GetTenantAdminRole() (uint64, error) {
|
||||
}
|
||||
return role.ID, nil
|
||||
}
|
||||
|
||||
// GetTenantUserRole 返回全局「租户用户」角色(cid=2, tenant_id=0)的 ID。
|
||||
// 自助注册通过企业编码加入企业时默认绑定此角色(受限权限)。
|
||||
func GetTenantUserRole() (uint64, error) {
|
||||
if Orm == nil {
|
||||
return 0, orm.ErrNoRows
|
||||
}
|
||||
var role AdminRole
|
||||
if err := Orm.QueryTable(new(AdminRole)).
|
||||
Filter("cid", 2).
|
||||
Filter("tenant_id", 0).
|
||||
Filter("name", "租户用户").
|
||||
One(&role); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return role.ID, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
type AuthIdentity struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
UnionID string `orm:"column(union_id);size(32)" json:"union_id"`
|
||||
Account *string `orm:"column(account);size(64);null" json:"account"` // 全局登录账号(账号注册;手机号注册身份为 NULL)
|
||||
Mobile *string `orm:"column(mobile);size(20);null" json:"mobile"`
|
||||
Email *string `orm:"column(email);size(128);null" json:"email"`
|
||||
PasswordAlgo string `orm:"column(password_algo);size(16);default(argon2id)" json:"password_algo"`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// 企业申请类型 / 状态
|
||||
const (
|
||||
TenantApplyTypeJoin = 1 // 加入企业(绑定企业,企业管理员审批)
|
||||
TenantApplyTypeCreate = 2 // 创建企业(平台审批)
|
||||
|
||||
TenantApplyStatusPending = 0 // 待审核
|
||||
TenantApplyStatusPassed = 1 // 已通过
|
||||
TenantApplyStatusReject = 2 // 已驳回
|
||||
)
|
||||
|
||||
// AuthTenantApply 注册企业绑定/创建申请:yz_auth_tenant_apply
|
||||
//
|
||||
// 统一认证注册第二步产生:
|
||||
// - type=join:绑定企业申请,由目标企业的管理员在 backend「加入申请」页审批,
|
||||
// 通过后建立身份-企业绑定(默认「租户用户」角色);
|
||||
// - type=create:创建企业申请(含营业执照),由平台在 platform「企业创建审核」页审批,
|
||||
// 通过后自动创建租户并把申请人绑定为租户管理员。
|
||||
type AuthTenantApply struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||
Type int8 `orm:"column(type);default(1)" json:"type"`
|
||||
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
|
||||
TenantName string `orm:"column(tenant_name);size(128);default('')" json:"tenant_name"`
|
||||
CreditCode string `orm:"column(credit_code);size(64);default('')" json:"credit_code"`
|
||||
LegalPerson string `orm:"column(legal_person);size(64);default('')" json:"legal_person"`
|
||||
LicenseFile string `orm:"column(license_file);size(500);default('')" json:"license_file"`
|
||||
ContactPerson string `orm:"column(contact_person);size(64);default('')" json:"contact_person"`
|
||||
ContactPhone string `orm:"column(contact_phone);size(20);default('')" json:"contact_phone"`
|
||||
ContactEmail string `orm:"column(contact_email);size(128);default('')" json:"contact_email"`
|
||||
Address string `orm:"column(address);size(255);default('')" json:"address"`
|
||||
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||
RejectReason string `orm:"column(reject_reason);size(255);default('')" json:"reject_reason"`
|
||||
ReviewerID uint64 `orm:"column(reviewer_id);default(0)" json:"reviewer_id"`
|
||||
ReviewerName string `orm:"column(reviewer_name);size(64);default('')" json:"reviewer_name"`
|
||||
ReviewTime *time.Time `orm:"column(review_time);type(datetime);null" json:"review_time"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *AuthTenantApply) TableName() string {
|
||||
return "yz_auth_tenant_apply"
|
||||
}
|
||||
@@ -213,6 +213,8 @@ func Init(_ string) {
|
||||
|
||||
// 微信小程序配置(docs/sql/create_wechat_mini_config.sql)
|
||||
new(WechatMiniConfig),
|
||||
// 微信小程序多应用配置(docs/sql/create_wechat_mini_app.sql)
|
||||
new(WechatMiniApp),
|
||||
|
||||
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
|
||||
new(AuthIdentity),
|
||||
@@ -225,6 +227,8 @@ func Init(_ string) {
|
||||
new(AuthTokenBlacklist),
|
||||
new(AuthTenantIdp),
|
||||
new(AuthTenantAuthConfig),
|
||||
// 注册企业绑定/创建申请(docs/sql/create_auth_tenant_apply.sql)
|
||||
new(AuthTenantApply),
|
||||
new(AuthLoginLog),
|
||||
new(AuthAppLoginTicket),
|
||||
|
||||
|
||||
@@ -22,3 +22,21 @@ type WechatMiniConfig struct {
|
||||
}
|
||||
|
||||
func (m *WechatMiniConfig) TableName() string { return "yz_platform_wechat_mini_config" }
|
||||
|
||||
// WechatMiniApp 微信小程序(多应用) yz_platform_wechat_mini_app
|
||||
// 支持配置多个小程序:认证端登录时按客户端传入的 appid 精确匹配
|
||||
// (未传 appid 时回退第一条启用记录),AppSecret 加密存储不回显。
|
||||
type WechatMiniApp struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Name string `orm:"column(name);size(128);default('')" json:"name"`
|
||||
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
|
||||
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
|
||||
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
|
||||
AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *WechatMiniApp) TableName() string { return "yz_platform_wechat_mini_app" }
|
||||
@@ -45,6 +45,21 @@ func Register() {
|
||||
beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession")
|
||||
beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig")
|
||||
|
||||
// ---- 自助账户:两步式注册 / 找回密码 ----
|
||||
// 第一步核验身份:手机号(短信验证码)或账号(服务号验证码);第二步企业归属
|
||||
beego.Router("/auth/register", &authctl.AuthAccountController{}, "get:RegisterPage")
|
||||
beego.Router("/auth/register/send-code", &authctl.AuthAccountController{}, "post:SendRegisterCode")
|
||||
beego.Router("/auth/register/verify", &authctl.AuthAccountController{}, "post:RegisterVerify")
|
||||
beego.Router("/auth/register/wechat/start", &authctl.AuthAccountController{}, "get:WechatStart")
|
||||
beego.Router("/auth/register/wechat/status", &authctl.AuthAccountController{}, "get:WechatStatus")
|
||||
beego.Router("/auth/register/tenant/search", &authctl.AuthAccountController{}, "get:TenantSearch")
|
||||
beego.Router("/auth/register/apply-join", &authctl.AuthAccountController{}, "post:ApplyJoin")
|
||||
beego.Router("/auth/register/apply-create", &authctl.AuthAccountController{}, "post:ApplyCreate")
|
||||
beego.Router("/auth/register/finish", &authctl.AuthAccountController{}, "post:FinishSkip")
|
||||
beego.Router("/auth/register/upload-license", &authctl.AuthAccountController{}, "post:UploadLicense")
|
||||
beego.Router("/auth/forgot", &authctl.AuthAccountController{}, "get:ForgotPage;post:ResetSubmit")
|
||||
beego.Router("/auth/forgot/send-code", &authctl.AuthAccountController{}, "post:SendResetCode")
|
||||
|
||||
// 微信扫码登录(未认证公众号:服务号二维码 + 登录码 + 公众号内确认链接)
|
||||
beego.Router("/auth/wechat/scan/start", &authctl.AuthLoginController{}, "post:WechatScanStart")
|
||||
beego.Router("/auth/wechat/scan/status", &authctl.AuthLoginController{}, "get:WechatScanStatus")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package backend
|
||||
package backend
|
||||
|
||||
import (
|
||||
"server/controllers"
|
||||
@@ -16,6 +16,11 @@ func Register() {
|
||||
RegisterWechatMpRoutes()
|
||||
RegisterDictRoutes()
|
||||
RegisterApprovalRoutes()
|
||||
|
||||
// 注册企业绑定申请审批(基础设置 → 加入申请)
|
||||
beego.Router("/backend/tenantApplies", &controllers.BackendTenantApplyController{}, "get:List")
|
||||
beego.Router("/backend/tenantApplies/approve/:id", &controllers.BackendTenantApplyController{}, "post:Approve")
|
||||
beego.Router("/backend/tenantApplies/reject/:id", &controllers.BackendTenantApplyController{}, "post:Reject")
|
||||
}
|
||||
|
||||
// RegisterApprovalRoutes 注册租户端「审批流配置」路由。
|
||||
|
||||
@@ -84,6 +84,12 @@ func Register() {
|
||||
beego.Router("/platform/tenant/deleteTenant/:id", &controllers.PlatformTenantController{}, "delete:DeleteTenant")
|
||||
beego.Router("/platform/tenant/findTenantCode", &controllers.PlatformTenantController{}, "get:FindTenantCode")
|
||||
|
||||
// 注册「创建企业」申请审核(通过后自动建租户并绑定申请人为管理员)
|
||||
beego.Router("/platform/tenantApply/list", &controllers.PlatformTenantApplyController{}, "get:List")
|
||||
beego.Router("/platform/tenantApply/detail/:id", &controllers.PlatformTenantApplyController{}, "get:Detail")
|
||||
beego.Router("/platform/tenantApply/approve/:id", &controllers.PlatformTenantApplyController{}, "post:Approve")
|
||||
beego.Router("/platform/tenantApply/reject/:id", &controllers.PlatformTenantApplyController{}, "post:Reject")
|
||||
|
||||
// 平台租户用户绑定相关
|
||||
beego.Router("/platform/getTenantUsers/:tid", &controllers.PlatformTenantUserController{}, "get:GetTenantUsersByTid")
|
||||
beego.Router("/platform/tenantUser/list", &controllers.PlatformTenantUserController{}, "get:GetTenantUserList")
|
||||
|
||||
@@ -41,4 +41,8 @@ func RegisterWechatMpRoutes() {
|
||||
|
||||
// 小程序配置(一键登录开关;登录端点在认证中心 /auth/wechat/miniapp/*)
|
||||
beego.Router("/platform/wechatMini/config", &controllers.PlatformWechatMiniController{}, "get:GetConfig;post:SaveConfig")
|
||||
// 小程序多应用管理(支持配置多个小程序)
|
||||
beego.Router("/platform/wechatMini/apps", &controllers.PlatformWechatMiniController{}, "get:ListApps")
|
||||
beego.Router("/platform/wechatMini/app/save", &controllers.PlatformWechatMiniController{}, "post:SaveApp")
|
||||
beego.Router("/platform/wechatMini/app/delete/:id", &controllers.PlatformWechatMiniController{}, "post:DeleteApp")
|
||||
}
|
||||
@@ -386,6 +386,61 @@ func resolveApprovers(scope int8, node Node, in SubmitInput) ([]Approver, error)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// resolveNotifyTargets 解析通知节点的通知对象(支持多类型)。
|
||||
// approver_ids 兼容两种存储格式:
|
||||
// 1. 旧格式:纯数字 CSV —— 单类型,配合 node.ApproverType(历史数据);
|
||||
// 2. 多选格式:"类型:ID" CSV(如 "1:52,5:0")—— 类型取值同 approver_type,
|
||||
// 自动定位类型(5发起人本人 / 6发起人主管)无需指定对象,ID 占位 0。
|
||||
// 各类型的解析结果按 ID 去重合并。
|
||||
func resolveNotifyTargets(scope int8, node Node, in SubmitInput) ([]Approver, error) {
|
||||
if !strings.Contains(node.ApproverIDs, ":") {
|
||||
return resolveApprovers(scope, node, in)
|
||||
}
|
||||
typeOrder := make([]int8, 0, 4)
|
||||
idsByType := make(map[int8][]uint64)
|
||||
for _, part := range strings.Split(node.ApproverIDs, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(part, ":", 2)
|
||||
t, err := strconv.Atoi(strings.TrimSpace(kv[0]))
|
||||
if err != nil || t < int(models.ApprovalApproverUser) || t > int(models.ApprovalApproverInitLeader) {
|
||||
continue
|
||||
}
|
||||
at := int8(t)
|
||||
if _, ok := idsByType[at]; !ok {
|
||||
typeOrder = append(typeOrder, at)
|
||||
}
|
||||
if len(kv) == 2 {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(kv[1]), 10, 64); err == nil && id > 0 {
|
||||
idsByType[at] = append(idsByType[at], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := make([]Approver, 0, 8)
|
||||
for _, t := range typeOrder {
|
||||
sub := Node{ApproverType: t, ApproverIDs: joinApproverIDs(idsByType[t])}
|
||||
approvers, err := resolveApprovers(scope, sub, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged = append(merged, approvers...)
|
||||
}
|
||||
return dedupeApprovers(merged), nil
|
||||
}
|
||||
|
||||
// joinApproverIDs 把 ID 列表拼成 CSV(与 parseIDs 互逆)
|
||||
func joinApproverIDs(ids []uint64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
parts = append(parts, strconv.FormatUint(id, 10))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// tenantUsers 按 uid 取租户用户
|
||||
func tenantUsers(tid uint64, uids []uint64) []Approver {
|
||||
if len(uids) == 0 {
|
||||
@@ -895,7 +950,7 @@ func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver)
|
||||
|
||||
// fireNotifyTasks 为通知节点创建已读通知任务(不阻塞流程)
|
||||
func fireNotifyTasks(instID uint64, in SubmitInput, notifyNode *Node) {
|
||||
recipients, _ := resolveApprovers(in.Scope, *notifyNode, in)
|
||||
recipients, _ := resolveNotifyTargets(in.Scope, *notifyNode, in)
|
||||
recipients = dedupeApprovers(recipients)
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
|
||||
@@ -36,7 +36,7 @@ type IdentityProfile struct {
|
||||
}
|
||||
|
||||
// FindIdentityByAccount 按登录账号查找身份。
|
||||
// 匹配顺序:手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||
// 匹配顺序:全局账号(账号注册)→ 手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||
func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||
account = strings.TrimSpace(account)
|
||||
if account == "" {
|
||||
@@ -44,7 +44,7 @@ func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||
|
||||
for _, field := range []string{"mobile", "email"} {
|
||||
for _, field := range []string{"account", "mobile", "email"} {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := base.Filter(field, account).One(m); err == nil {
|
||||
return m, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,16 +12,22 @@ import (
|
||||
|
||||
// SyncTenantUserInput 老表 → 认证中心同步入参
|
||||
type SyncTenantUserInput struct {
|
||||
Tid uint64
|
||||
Account string // 企业内账号
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
PasswordHash string // 已哈希的密码;为空表示不修改密码
|
||||
Tid uint64
|
||||
Account string // 企业内账号
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
// PasswordHash 已哈希的密码;为空表示不修改密码。
|
||||
// 注意:身份已存在时,只有 UpdatePassword=true 才会生效(改密场景),
|
||||
// 否则保持不动 —— 避免 B 企业建同名用户时把 A 企业的密码静默覆盖掉。
|
||||
PasswordHash string
|
||||
GroupID uint64
|
||||
OrgID uint64
|
||||
Status int8
|
||||
IsDefault int8
|
||||
|
||||
// UpdatePassword 身份已存在时是否允许更新密码(仅改密场景传 true)
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
// SyncTenantUser 把租户用户同步到统一认证中心(幂等,可重复调用)。
|
||||
@@ -69,7 +76,7 @@ func RemoveTenantUser(tid uint64, account, phone, email string) error {
|
||||
// findOrCreateIdentity 按归并键查找身份,不存在则创建
|
||||
func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error) {
|
||||
if m := findIdentityByKey(in.Account, in.Phone, in.Email); m != nil {
|
||||
// 补全手机/邮箱/昵称;密码仅在显式传入时更新
|
||||
// 补全手机/邮箱/昵称;密码仅在显式授权(改密场景)时更新
|
||||
update := map[string]interface{}{}
|
||||
if in.Phone != "" {
|
||||
update["mobile"] = in.Phone
|
||||
@@ -83,7 +90,7 @@ func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error)
|
||||
if in.Status > 0 {
|
||||
update["status"] = in.Status
|
||||
}
|
||||
if in.PasswordHash != "" {
|
||||
if in.UpdatePassword && in.PasswordHash != "" {
|
||||
update["password_hash"] = in.PasswordHash
|
||||
update["password_algo"] = passwordutil.AlgoOf(in.PasswordHash)
|
||||
}
|
||||
@@ -230,3 +237,37 @@ func upsertTenantBinding(identity *models.AuthIdentity, in SyncTenantUserInput)
|
||||
Update(update)
|
||||
return err
|
||||
}
|
||||
|
||||
// LocateIdentityByKey 按归并键(手机 > 邮箱 > 账号)查找已有身份。
|
||||
// 第二个返回值表示是否命中。供「新增租户用户」流程判断:
|
||||
// 命中则要求先验证该身份的登录密码,验证通过后只建企业绑定。
|
||||
func LocateIdentityByKey(account, phone, email string) (*models.AuthIdentity, bool) {
|
||||
m := findIdentityByKey(account, phone, email)
|
||||
return m, m != nil
|
||||
}
|
||||
|
||||
// GetIdentityByID 按主键读取认证身份
|
||||
func GetIdentityByID(id uint64) (*models.AuthIdentity, error) {
|
||||
var m models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", id).One(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// VerifyIdentityPassword 校验身份的登录密码(兼容 legacy 哈希算法)
|
||||
func VerifyIdentityPassword(identity *models.AuthIdentity, plain string) bool {
|
||||
if identity == nil || identity.PasswordHash == nil || *identity.PasswordHash == "" {
|
||||
return false
|
||||
}
|
||||
return passwordutil.Verify(*identity.PasswordHash, plain)
|
||||
}
|
||||
|
||||
// BindIdentityToTenant 把已有身份绑定到企业(只写绑定层,不改身份资料与密码)。
|
||||
// 供「验证密码后绑定到新企业」流程使用,绑定层的企业内账号/姓名/联系方式独立维护。
|
||||
func BindIdentityToTenant(identity *models.AuthIdentity, in SyncTenantUserInput) error {
|
||||
if identity == nil {
|
||||
return errors.New("身份不存在")
|
||||
}
|
||||
return upsertTenantBinding(identity, in)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 统一认证中心自助账户能力:注册、找回密码、验证码。
|
||||
//
|
||||
// 与旧版 password_reset.go(按「租户名+账号」定位老表)不同,这里直接操作
|
||||
// 统一身份层 yz_auth_identity:手机号即全局账号,一处改密全平台生效。
|
||||
|
||||
var mobileRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
|
||||
// identityCodeItem 短信验证码条目
|
||||
type identityCodeItem struct {
|
||||
Code string
|
||||
ExpiredAt time.Time
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
var identityCodeStore sync.Map // key: purpose|phone
|
||||
|
||||
func identityCodeKey(purpose, phone string) string {
|
||||
return strings.TrimSpace(purpose) + "|" + strings.TrimSpace(phone)
|
||||
}
|
||||
|
||||
// IsMobile 校验手机号格式
|
||||
func IsMobile(phone string) bool {
|
||||
return mobileRegexp.MatchString(strings.TrimSpace(phone))
|
||||
}
|
||||
|
||||
// findIdentityByMobile 按手机号查身份(含软删过滤)
|
||||
func findIdentityByMobile(phone string) *models.AuthIdentity {
|
||||
if !IsMobile(phone) {
|
||||
return nil
|
||||
}
|
||||
var m models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("mobile", strings.TrimSpace(phone)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
// SendIdentityCode 发送自助账户短信验证码。
|
||||
// - purpose=register:手机号必须未注册
|
||||
// - purpose=reset:手机号必须已注册
|
||||
//
|
||||
// 60 秒服务端冷却,防短信轰炸;验证码 5 分钟有效。
|
||||
func SendIdentityCode(purpose, phone string) error {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if !IsMobile(phone) {
|
||||
return errors.New("请输入正确的手机号")
|
||||
}
|
||||
if purpose != "register" && purpose != "reset" {
|
||||
return errors.New("参数错误")
|
||||
}
|
||||
|
||||
existed := findIdentityByMobile(phone)
|
||||
if purpose == "register" && existed != nil {
|
||||
return errors.New("该手机号已注册,请直接登录或找回密码")
|
||||
}
|
||||
if purpose == "reset" && existed == nil {
|
||||
// 不暴露「手机号是否存在」会牺牲可用性;注册页已明示手机号唯一,这里直接提示
|
||||
return errors.New("该手机号尚未注册")
|
||||
}
|
||||
|
||||
// 服务端 60 秒冷却
|
||||
if v, ok := identityCodeStore.Load(identityCodeKey(purpose, phone)); ok {
|
||||
if item, ok := v.(identityCodeItem); ok && time.Since(item.IssuedAt) < 60*time.Second {
|
||||
return errors.New("验证码发送过于频繁,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
// 短信网关配置校验前置,避免生成验证码后发送失败造成困惑
|
||||
if _, _, err := getDefaultSystemSMSConfig(); err != nil {
|
||||
return errors.New("短信服务未配置,请联系平台管理员")
|
||||
}
|
||||
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000)) //nolint:gosec // 验证码场景可接受
|
||||
content := "验证码:" + code + ",5分钟内有效。为保护账号安全,请勿泄露给他人。"
|
||||
if err := enqueueSMSTaskForLogin(0, phone, content, code); err != nil {
|
||||
return errors.New("短信发送失败,请稍后重试")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
identityCodeStore.Store(identityCodeKey(purpose, phone), identityCodeItem{
|
||||
Code: code,
|
||||
ExpiredAt: now.Add(5 * time.Minute),
|
||||
IssuedAt: now,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyIdentityCode 校验自助账户短信验证码,验证通过后立即销毁(一次性)
|
||||
func VerifyIdentityCode(purpose, phone, code string) error {
|
||||
phone = strings.TrimSpace(phone)
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return errors.New("请输入验证码")
|
||||
}
|
||||
key := identityCodeKey(purpose, phone)
|
||||
v, ok := identityCodeStore.Load(key)
|
||||
if !ok {
|
||||
return errors.New("请先获取验证码")
|
||||
}
|
||||
item, ok := v.(identityCodeItem)
|
||||
if !ok {
|
||||
return errors.New("验证码状态异常,请重新获取")
|
||||
}
|
||||
if time.Now().After(item.ExpiredAt) {
|
||||
identityCodeStore.Delete(key)
|
||||
return errors.New("验证码已过期,请重新获取")
|
||||
}
|
||||
if item.Code != code {
|
||||
return errors.New("验证码错误")
|
||||
}
|
||||
identityCodeStore.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterIdentityInput 自助注册入参
|
||||
type RegisterIdentityInput struct {
|
||||
Phone string
|
||||
Code string
|
||||
Password string
|
||||
Confirm string
|
||||
Nickname string
|
||||
TenantCode string // 选填:填了则注册后直接加入该企业(默认「租户用户」角色)
|
||||
}
|
||||
|
||||
// RegisterIdentityResult 注册结果
|
||||
type RegisterIdentityResult struct {
|
||||
IdentityID uint64
|
||||
JoinedTid uint64 // 成功加入的企业;0 表示未加入
|
||||
Warning string // 加入企业失败的提示(身份已创建成功)
|
||||
}
|
||||
|
||||
// RegisterIdentity 自助注册:创建统一身份,可选加入企业。
|
||||
//
|
||||
// 统一认证原则:手机号即全局账号,密码在身份层一份;加入企业只是建绑定,
|
||||
// 企业内角色默认「租户用户」,后续由企业管理员调整。
|
||||
func RegisterIdentity(in RegisterIdentityInput) (*RegisterIdentityResult, error) {
|
||||
phone := strings.TrimSpace(in.Phone)
|
||||
if !IsMobile(phone) {
|
||||
return nil, errors.New("请输入正确的手机号")
|
||||
}
|
||||
if err := VerifyIdentityCode("register", phone, in.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(in.Password)) < 6 {
|
||||
return nil, errors.New("密码长度不能少于6个字符")
|
||||
}
|
||||
if in.Password != in.Confirm {
|
||||
return nil, errors.New("两次输入的密码不一致")
|
||||
}
|
||||
|
||||
// 防并发重复注册
|
||||
if findIdentityByMobile(phone) != nil {
|
||||
return nil, errors.New("该手机号已注册,请直接登录")
|
||||
}
|
||||
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password))
|
||||
if err != nil {
|
||||
return nil, errors.New("密码处理失败")
|
||||
}
|
||||
|
||||
nickname := strings.TrimSpace(in.Nickname)
|
||||
if nickname == "" {
|
||||
nickname = "用户" + phone[len(phone)-4:]
|
||||
}
|
||||
|
||||
// 选填企业编码:先校验企业存在且启用、配额未满,失败直接拒绝(身份尚未创建)
|
||||
tenantCode := strings.TrimSpace(in.TenantCode)
|
||||
var tenant *models.SystemTenant
|
||||
if tenantCode != "" {
|
||||
t := new(models.SystemTenant)
|
||||
err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("tenant_code", tenantCode).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(t)
|
||||
if err != nil {
|
||||
return nil, errors.New("企业编码不存在,请确认后重试")
|
||||
}
|
||||
if check, err := CheckTenantUserQuota(t.ID, 0); err == nil && !check.Allowed {
|
||||
return nil, fmt.Errorf("该企业用户数已达上限(%d/%d),请联系企业管理员", check.Used, check.Quota)
|
||||
}
|
||||
tenant = t
|
||||
}
|
||||
|
||||
identity := &models.AuthIdentity{
|
||||
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
PasswordAlgo: passwordutil.AlgoOf(hashed),
|
||||
Status: models.AuthIdentityStatusEnabled,
|
||||
}
|
||||
identity.Mobile = &phone
|
||||
identity.Nickname = &nickname
|
||||
identity.PasswordHash = &hashed
|
||||
|
||||
id, err := models.Orm.Insert(identity)
|
||||
if err != nil {
|
||||
return nil, errors.New("注册失败,请稍后重试")
|
||||
}
|
||||
identity.ID = uint64(id)
|
||||
|
||||
result := &RegisterIdentityResult{IdentityID: identity.ID}
|
||||
|
||||
// 加入企业:建绑定(老表 + 认证中心),角色默认「租户用户」
|
||||
if tenant != nil {
|
||||
warn := joinTenantAfterRegister(identity, tenant, nickname, phone, hashed)
|
||||
if warn != "" {
|
||||
result.Warning = warn
|
||||
} else {
|
||||
result.JoinedTid = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// joinTenantAfterRegister 注册后加入企业;返回警告信息(空串表示成功)。
|
||||
// 身份已创建成功,加入失败仅降级为警告,不回滚注册。
|
||||
func joinTenantAfterRegister(identity *models.AuthIdentity, tenant *models.SystemTenant, nickname, phone, passwordHash string) string {
|
||||
// 企业内账号默认用手机号;若已被本企业其他身份占用则放弃自动加入
|
||||
var dup models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tenant.ID).
|
||||
Filter("account", phone).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&dup); err == nil && dup.Uid != identity.ID {
|
||||
return "注册成功,但企业内账号已被占用,请联系企业管理员邀请你加入"
|
||||
}
|
||||
|
||||
phoneStr := phone
|
||||
nameStr := nickname
|
||||
hashStr := passwordHash
|
||||
if _, err := BindTenantUser(
|
||||
tenant.ID, identity.ID,
|
||||
&phoneStr, &nameStr, &phoneStr, nil,
|
||||
nil, nil, &hashStr,
|
||||
1, 1, nil,
|
||||
); err != nil {
|
||||
return "注册成功,但加入企业失败,请联系企业管理员邀请你加入"
|
||||
}
|
||||
|
||||
// 默认「租户用户」角色;角色 ID 一并写入认证中心绑定
|
||||
groupID := uint64(0)
|
||||
if roleID, err := models.GetTenantUserRole(); err == nil && roleID > 0 {
|
||||
groupID = roleID
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tenant.ID).
|
||||
Filter("uid", identity.ID).
|
||||
Update(map[string]interface{}{"group_id": roleID})
|
||||
}
|
||||
|
||||
// 双写认证中心绑定
|
||||
if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
|
||||
Tid: tenant.ID,
|
||||
Account: phoneStr,
|
||||
Name: nameStr,
|
||||
Phone: phoneStr,
|
||||
PasswordHash: passwordHash,
|
||||
GroupID: groupID,
|
||||
Status: 1,
|
||||
IsDefault: 1,
|
||||
}); err != nil {
|
||||
return "注册成功,企业绑定已建立,但认证中心同步失败,请联系管理员"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResetIdentityPasswordByMobile 自助找回密码:验证码通过后重置身份层密码。
|
||||
// 密码属于身份层,重置后该用户在所有企业的登录密码同步更新。
|
||||
func ResetIdentityPasswordByMobile(phone, code, newPassword, confirm string) error {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if !IsMobile(phone) {
|
||||
return errors.New("请输入正确的手机号")
|
||||
}
|
||||
if err := VerifyIdentityCode("reset", phone, code); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings.TrimSpace(newPassword)) < 6 {
|
||||
return errors.New("密码长度不能少于6个字符")
|
||||
}
|
||||
if newPassword != confirm {
|
||||
return errors.New("两次输入的密码不一致")
|
||||
}
|
||||
|
||||
identity := findIdentityByMobile(phone)
|
||||
if identity == nil {
|
||||
return errors.New("该手机号尚未注册")
|
||||
}
|
||||
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(newPassword))
|
||||
if err != nil {
|
||||
return errors.New("密码处理失败")
|
||||
}
|
||||
|
||||
_, err = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", identity.ID).
|
||||
Update(map[string]interface{}{
|
||||
"password_hash": hashed,
|
||||
"password_algo": passwordutil.AlgoOf(hashed),
|
||||
"fail_count": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.New("密码重置失败,请稍后重试")
|
||||
}
|
||||
|
||||
// 老表镜像同步:把新密码写到该身份在各企业的 yz_system_tenant_user 行,
|
||||
// 保持「老表密码 = 身份层密码」的双写口径(登录以认证中心为准)
|
||||
mirrorPasswordToTenantRows(identity.ID, hashed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirrorPasswordToTenantRows 把身份层密码镜像到该身份所有企业绑定的老表行
|
||||
func mirrorPasswordToTenantRows(identityID uint64, passwordHash string) {
|
||||
var binds []models.AuthTenantUser
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&binds); err != nil {
|
||||
return
|
||||
}
|
||||
for _, b := range binds {
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", b.Tid).
|
||||
Filter("uid", identityID).
|
||||
Update(map[string]interface{}{"password": passwordHash})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 两步式自助注册(统一认证中心):
|
||||
// 第一步 核验身份:手机号(短信验证码)或 账号(服务号验证码),
|
||||
// 核验通过即创建统一身份 yz_auth_identity,并签发一次性
|
||||
// register_token(10 分钟有效),token 携带身份与微信 openid。
|
||||
// 第二步 企业归属(三选一,可跳过):
|
||||
// - 绑定企业:创建 join 申请 → 目标企业管理员在 backend 审批;
|
||||
// - 创建企业:提交企业资料(营业执照)→ 平台在 platform 审批,
|
||||
// 通过后自动建租户并把申请人绑定为租户管理员;
|
||||
// - 暂不加入:只完成身份注册。
|
||||
// =============================================================
|
||||
|
||||
// registerTokenTTL register_token 有效期
|
||||
const registerTokenTTL = 10 * time.Minute
|
||||
|
||||
// registerTokenItem 一次性注册令牌:身份已创建,等待第二步归属操作
|
||||
type registerTokenItem struct {
|
||||
IdentityID uint64
|
||||
OpenID string // 账号注册模式扫码人的微信 openid(用于注册后绑定微信登录)
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
|
||||
var registerTokenStore sync.Map // key: token
|
||||
|
||||
// IssueRegisterToken 身份创建成功后签发注册令牌
|
||||
func IssueRegisterToken(identityID uint64, openid string) string {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("rt%d%d", identityID, time.Now().UnixNano())
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
registerTokenStore.Store(token, registerTokenItem{
|
||||
IdentityID: identityID,
|
||||
OpenID: openid,
|
||||
ExpiredAt: time.Now().Add(registerTokenTTL),
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
// ConsumeRegisterToken 领取注册令牌(一次性,过期作废)
|
||||
func ConsumeRegisterToken(token string) (identityID uint64, openid string, err error) {
|
||||
identityID, openid, err = PeekRegisterToken(token)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
registerTokenStore.Delete(strings.TrimSpace(token))
|
||||
return identityID, openid, nil
|
||||
}
|
||||
|
||||
// PeekRegisterToken 查看注册令牌(不消费);第二步先 Peek 校验,
|
||||
// 业务成功后再 Consume,避免申请失败导致令牌作废无法重试。
|
||||
func PeekRegisterToken(token string) (identityID uint64, openid string, err error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
v, ok := registerTokenStore.Load(token)
|
||||
if !ok {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
item, ok := v.(registerTokenItem)
|
||||
if !ok {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
if time.Now().After(item.ExpiredAt) {
|
||||
registerTokenStore.Delete(token)
|
||||
return 0, "", errors.New("注册会话已过期,请重新注册")
|
||||
}
|
||||
return item.IdentityID, item.OpenID, nil
|
||||
}
|
||||
|
||||
// ---------------- 第一步:核验并创建身份 ----------------
|
||||
|
||||
// RegisterVerifyInput 第一步核验入参
|
||||
type RegisterVerifyInput struct {
|
||||
Mode string // phone / account
|
||||
Phone string // mode=phone:手机号
|
||||
Account string // mode=account:自定义账号
|
||||
Code string // 验证码(短信 或 服务号下发)
|
||||
Password string
|
||||
Confirm string
|
||||
OpenID string // mode=account:核销服务号验证码得到的 openid
|
||||
}
|
||||
|
||||
// validateAccountName 账号名规则:4-32 位,字母开头,字母/数字/下划线/中划线
|
||||
func validateAccountName(account string) error {
|
||||
if len(account) < 4 || len(account) > 32 {
|
||||
return errors.New("账号长度需为 4-32 个字符")
|
||||
}
|
||||
c := account[0]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
|
||||
return errors.New("账号需以字母开头")
|
||||
}
|
||||
for i := 0; i < len(account); i++ {
|
||||
c := account[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
|
||||
return errors.New("账号仅支持字母、数字、下划线和中划线")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findIdentityByAccountName 按全局账号列查身份
|
||||
func findIdentityByAccountName(account string) *models.AuthIdentity {
|
||||
var m models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("account", account).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
// VerifyAndCreateIdentity 第一步:校验验证码与密码,创建统一身份,返回身份与新令牌。
|
||||
// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid。
|
||||
func VerifyAndCreateIdentity(in RegisterVerifyInput) (*models.AuthIdentity, string, error) {
|
||||
in.Mode = strings.TrimSpace(in.Mode)
|
||||
if in.Mode != "phone" && in.Mode != "account" {
|
||||
return nil, "", errors.New("请选择注册方式")
|
||||
}
|
||||
if len(strings.TrimSpace(in.Password)) < 6 {
|
||||
return nil, "", errors.New("密码长度不能少于6个字符")
|
||||
}
|
||||
if in.Password != in.Confirm {
|
||||
return nil, "", errors.New("两次输入的密码不一致")
|
||||
}
|
||||
|
||||
identity := &models.AuthIdentity{
|
||||
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
}
|
||||
|
||||
switch in.Mode {
|
||||
case "phone":
|
||||
phone := strings.TrimSpace(in.Phone)
|
||||
if !IsMobile(phone) {
|
||||
return nil, "", errors.New("请输入正确的手机号")
|
||||
}
|
||||
if err := VerifyIdentityCode("register", phone, in.Code); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if findIdentityByMobile(phone) != nil {
|
||||
return nil, "", errors.New("该手机号已注册,请直接登录")
|
||||
}
|
||||
identity.Mobile = &phone
|
||||
nick := "用户" + phone[len(phone)-4:]
|
||||
identity.Nickname = &nick
|
||||
case "account":
|
||||
account := strings.TrimSpace(in.Account)
|
||||
if err := validateAccountName(account); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if findIdentityByAccountName(account) != nil {
|
||||
return nil, "", errors.New("该账号已被占用,请换一个")
|
||||
}
|
||||
identity.Account = &account
|
||||
nick := account
|
||||
identity.Nickname = &nick
|
||||
}
|
||||
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("密码处理失败")
|
||||
}
|
||||
identity.PasswordHash = &hashed
|
||||
identity.PasswordAlgo = passwordutil.AlgoOf(hashed)
|
||||
identity.Status = models.AuthIdentityStatusEnabled
|
||||
|
||||
id, err := models.Orm.Insert(identity)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("注册失败,请稍后重试")
|
||||
}
|
||||
identity.ID = uint64(id)
|
||||
|
||||
token := IssueRegisterToken(identity.ID, in.OpenID)
|
||||
return identity, token, nil
|
||||
}
|
||||
|
||||
// ---------------- 第二步:企业归属 ----------------
|
||||
|
||||
// TenantSearchItem 企业搜索结果
|
||||
type TenantSearchItem struct {
|
||||
Tid uint64 `json:"tid"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
}
|
||||
|
||||
// SearchTenantsForRegister 注册页搜索启用中的企业(按名称/编码模糊,最多 10 条)
|
||||
func SearchTenantsForRegister(keyword string) ([]TenantSearchItem, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return []TenantSearchItem{}, nil
|
||||
}
|
||||
var rows []models.SystemTenant
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("tenant_name__icontains", keyword).
|
||||
Limit(10).
|
||||
All(&rows, "ID", "TenantName", "TenantCode")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) < 10 {
|
||||
var byCode []models.SystemTenant
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("tenant_code__icontains", keyword).
|
||||
Limit(10).
|
||||
All(&byCode, "ID", "TenantName", "TenantCode")
|
||||
seen := map[uint64]bool{}
|
||||
for _, r := range rows {
|
||||
seen[r.ID] = true
|
||||
}
|
||||
for _, r := range byCode {
|
||||
if !seen[r.ID] {
|
||||
rows = append(rows, r)
|
||||
seen[r.ID] = true
|
||||
if len(rows) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]TenantSearchItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, TenantSearchItem{Tid: r.ID, TenantName: r.TenantName, TenantCode: r.TenantCode})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateJoinApply 「绑定企业」申请:等待目标企业管理员审批
|
||||
func CreateJoinApply(identityID, tid uint64) (*models.AuthTenantApply, error) {
|
||||
if identityID == 0 || tid == 0 {
|
||||
return nil, errors.New("参数错误")
|
||||
}
|
||||
tenant := new(models.SystemTenant)
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("id", tid).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(tenant); err != nil {
|
||||
return nil, errors.New("企业不存在或已停用")
|
||||
}
|
||||
|
||||
identity, err := authsvc.GetIdentityByID(identityID)
|
||||
if err != nil || identity == nil {
|
||||
return nil, errors.New("注册身份异常,请重新注册")
|
||||
}
|
||||
// 已绑定该企业则无需申请
|
||||
if _, berr := authsvc.GetTenantUser(identityID, tid); berr == nil {
|
||||
return nil, errors.New("你已是该企业的成员,无需重复申请")
|
||||
}
|
||||
// 同企业待审申请去重
|
||||
cnt, _ := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("tid", tid).
|
||||
Filter("type", models.TenantApplyTypeJoin).
|
||||
Filter("status", models.TenantApplyStatusPending).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if cnt > 0 {
|
||||
return nil, errors.New("已提交过该企业的加入申请,请等待管理员审核")
|
||||
}
|
||||
|
||||
apply := &models.AuthTenantApply{
|
||||
IdentityID: identityID,
|
||||
Type: models.TenantApplyTypeJoin,
|
||||
Tid: tid,
|
||||
TenantName: tenant.TenantName,
|
||||
Status: models.TenantApplyStatusPending,
|
||||
}
|
||||
apply.ContactPerson = identityNickname(identity)
|
||||
if identity.Mobile != nil {
|
||||
apply.ContactPhone = *identity.Mobile
|
||||
}
|
||||
if _, err := models.Orm.Insert(apply); err != nil {
|
||||
return nil, errors.New("提交申请失败,请稍后重试")
|
||||
}
|
||||
return apply, nil
|
||||
}
|
||||
|
||||
// TenantCreateApplyInput 「创建企业」申请入参
|
||||
type TenantCreateApplyInput struct {
|
||||
TenantName string `json:"tenant_name"`
|
||||
CreditCode string `json:"credit_code"`
|
||||
LegalPerson string `json:"legal_person"`
|
||||
LicenseFile string `json:"license_file"`
|
||||
ContactPerson string `json:"contact_person"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
var creditCodeRegexp = regexp.MustCompile(`^[0-9A-Z]{18}$`)
|
||||
|
||||
// CreateTenantApply 「创建企业」申请:等待平台审批
|
||||
func CreateTenantApply(identityID uint64, in TenantCreateApplyInput) (*models.AuthTenantApply, error) {
|
||||
in.TenantName = strings.TrimSpace(in.TenantName)
|
||||
in.CreditCode = strings.ToUpper(strings.TrimSpace(in.CreditCode))
|
||||
in.LegalPerson = strings.TrimSpace(in.LegalPerson)
|
||||
in.LicenseFile = strings.TrimSpace(in.LicenseFile)
|
||||
in.ContactPerson = strings.TrimSpace(in.ContactPerson)
|
||||
in.ContactPhone = strings.TrimSpace(in.ContactPhone)
|
||||
in.ContactEmail = strings.TrimSpace(in.ContactEmail)
|
||||
in.Address = strings.TrimSpace(in.Address)
|
||||
|
||||
if in.TenantName == "" {
|
||||
return nil, errors.New("请填写公司名称")
|
||||
}
|
||||
if len(in.TenantName) > 128 {
|
||||
return nil, errors.New("公司名称过长")
|
||||
}
|
||||
if !creditCodeRegexp.MatchString(in.CreditCode) {
|
||||
return nil, errors.New("请填写正确的统一社会信用代码(18位)")
|
||||
}
|
||||
if in.LegalPerson == "" {
|
||||
return nil, errors.New("请填写法定代表人姓名")
|
||||
}
|
||||
if in.LicenseFile == "" {
|
||||
return nil, errors.New("请上传营业执照电子档")
|
||||
}
|
||||
if in.ContactPerson == "" {
|
||||
return nil, errors.New("请填写联系人")
|
||||
}
|
||||
if !IsMobile(in.ContactPhone) {
|
||||
return nil, errors.New("请填写正确的联系电话")
|
||||
}
|
||||
if in.ContactEmail == "" || !strings.Contains(in.ContactEmail, "@") {
|
||||
return nil, errors.New("请填写正确的电子邮箱")
|
||||
}
|
||||
|
||||
identity, err := authsvc.GetIdentityByID(identityID)
|
||||
if err != nil || identity == nil {
|
||||
return nil, errors.New("注册身份异常,请重新注册")
|
||||
}
|
||||
// 同一身份同时只允许一条待审的创建企业申请
|
||||
cnt, _ := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("type", models.TenantApplyTypeCreate).
|
||||
Filter("status", models.TenantApplyStatusPending).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if cnt > 0 {
|
||||
return nil, errors.New("已提交过创建企业申请,请等待平台审核")
|
||||
}
|
||||
// 信用代码防重:已有启用租户使用同一信用代码则拒绝
|
||||
if cc := in.CreditCode; cc != "" {
|
||||
dup, _ := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("credit_code", cc).
|
||||
Filter("delete_time__isnull", true).
|
||||
Exclude("status", models.TenantApplyStatusReject).
|
||||
Count()
|
||||
if dup > 0 {
|
||||
return nil, errors.New("该统一社会信用代码已提交过申请")
|
||||
}
|
||||
}
|
||||
|
||||
apply := &models.AuthTenantApply{
|
||||
IdentityID: identityID,
|
||||
Type: models.TenantApplyTypeCreate,
|
||||
TenantName: in.TenantName,
|
||||
CreditCode: in.CreditCode,
|
||||
LegalPerson: in.LegalPerson,
|
||||
LicenseFile: in.LicenseFile,
|
||||
ContactPerson: in.ContactPerson,
|
||||
ContactPhone: in.ContactPhone,
|
||||
ContactEmail: in.ContactEmail,
|
||||
Address: in.Address,
|
||||
Status: models.TenantApplyStatusPending,
|
||||
}
|
||||
if _, err := models.Orm.Insert(apply); err != nil {
|
||||
return nil, errors.New("提交申请失败,请稍后重试")
|
||||
}
|
||||
return apply, nil
|
||||
}
|
||||
|
||||
// ---------------- 审批:backend(加入申请) ----------------
|
||||
|
||||
// ListTenantAppliesForTenant 企业管理员的「加入申请」列表(tid 隔离)
|
||||
func ListTenantAppliesForTenant(tid uint64, status *int8, page, pageSize int) ([]models.AuthTenantApply, int64, error) {
|
||||
qs := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("type", models.TenantApplyTypeJoin).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true)
|
||||
if status != nil {
|
||||
qs = qs.Filter("status", *status)
|
||||
}
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []models.AuthTenantApply
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// ApproveJoinApply 企业管理员通过加入申请:建绑定(默认「租户用户」角色)。
|
||||
// 绑定成功后才置申请为已通过;绑定失败则申请保持待审并返回错误。
|
||||
func ApproveJoinApply(applyID, tid, reviewerID uint64, reviewerName string) error {
|
||||
apply, err := getPendingApply(applyID, models.TenantApplyTypeJoin, tid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
identity, err := authsvc.GetIdentityByID(apply.IdentityID)
|
||||
if err != nil || identity == nil {
|
||||
return errors.New("申请人身份不存在")
|
||||
}
|
||||
// 配额校验
|
||||
if check, qerr := CheckTenantUserQuota(tid, 0); qerr == nil && !check.Allowed {
|
||||
return fmt.Errorf("企业用户数已达上限(%d/%d),请先扩容后再通过申请", check.Used, check.Quota)
|
||||
}
|
||||
|
||||
if err := bindIdentityToTenant(identity, tid, models.GetTenantUserRole); err != nil {
|
||||
return err
|
||||
}
|
||||
return finishApply(apply, models.TenantApplyStatusPassed, "", reviewerID, reviewerName)
|
||||
}
|
||||
|
||||
// RejectJoinApply 企业管理员驳回加入申请
|
||||
func RejectJoinApply(applyID, tid, reviewerID uint64, reviewerName, reason string) error {
|
||||
apply, err := getPendingApply(applyID, models.TenantApplyTypeJoin, tid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return finishApply(apply, models.TenantApplyStatusReject, reason, reviewerID, reviewerName)
|
||||
}
|
||||
|
||||
// ---------------- 审批:platform(创建企业申请) ----------------
|
||||
|
||||
// ListTenantAppliesForPlatform 平台「企业创建审核」列表
|
||||
func ListTenantAppliesForPlatform(applyType *int8, status *int8, page, pageSize int) ([]models.AuthTenantApply, int64, error) {
|
||||
qs := models.Orm.QueryTable(new(models.AuthTenantApply)).Filter("delete_time__isnull", true)
|
||||
if applyType != nil {
|
||||
qs = qs.Filter("type", *applyType)
|
||||
}
|
||||
if status != nil {
|
||||
qs = qs.Filter("status", *status)
|
||||
}
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []models.AuthTenantApply
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// ApproveCreateApply 平台通过创建企业申请:建租户(默认套餐配额),
|
||||
// 申请人绑定为租户管理员(全局「租户管理员」角色),并回填申请 tid。
|
||||
func ApproveCreateApply(applyID, reviewerID uint64, reviewerName string) (tid uint64, err error) {
|
||||
apply, err := getPendingApply(applyID, models.TenantApplyTypeCreate, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
identity, err := authsvc.GetIdentityByID(apply.IdentityID)
|
||||
if err != nil || identity == nil {
|
||||
return 0, errors.New("申请人身份不存在")
|
||||
}
|
||||
|
||||
// 建租户
|
||||
tenant := &models.SystemTenant{
|
||||
TenantName: apply.TenantName,
|
||||
ContactPerson: applyStrPtr(apply.ContactPerson),
|
||||
ContactPhone: applyStrPtr(apply.ContactPhone),
|
||||
ContactEmail: applyStrPtr(apply.ContactEmail),
|
||||
Address: applyStrPtr(apply.Address),
|
||||
Status: 1,
|
||||
Remark: applyStrPtr(fmt.Sprintf("注册创建;信用代码 %s;法定代表人 %s;营业执照 %s", apply.CreditCode, apply.LegalPerson, apply.LicenseFile)),
|
||||
}
|
||||
tenant.TenantCode, err = genUniqueTenantCode()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if pkg, perr := GetDefaultTenantPackage(); perr == nil && pkg != nil {
|
||||
tenant.PackageID = pkg.ID
|
||||
if pkg.UserQuota > 0 {
|
||||
tenant.UserQuota = pkg.UserQuota
|
||||
}
|
||||
}
|
||||
if tenant.UserQuota <= 0 {
|
||||
tenant.UserQuota = DefaultTenantUserQuota
|
||||
}
|
||||
newID, err := models.Orm.Insert(tenant)
|
||||
if err != nil {
|
||||
return 0, errors.New("创建租户失败:" + err.Error())
|
||||
}
|
||||
tid = uint64(newID)
|
||||
|
||||
// 申请人绑定为租户管理员(不新建账号、不覆盖密码)
|
||||
if err := bindIdentityToTenant(identity, tid, models.GetTenantAdminRole); err != nil {
|
||||
// 绑定失败:租户保留(平台可在租户管理里手动补绑),申请保持待审
|
||||
return tid, fmt.Errorf("租户已创建(编码 %s),但管理员绑定失败:%s", tenant.TenantCode, err.Error())
|
||||
}
|
||||
|
||||
// 申请已通过标记失败不影响结果(租户与绑定已生效)
|
||||
apply.Tid = tid
|
||||
if ferr := finishApply(apply, models.TenantApplyStatusPassed, "", reviewerID, reviewerName); ferr != nil {
|
||||
return tid, nil
|
||||
}
|
||||
return tid, nil
|
||||
}
|
||||
|
||||
// RejectCreateApply 平台驳回创建企业申请
|
||||
func RejectCreateApply(applyID, reviewerID uint64, reviewerName, reason string) error {
|
||||
apply, err := getPendingApply(applyID, models.TenantApplyTypeCreate, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return finishApply(apply, models.TenantApplyStatusReject, reason, reviewerID, reviewerName)
|
||||
}
|
||||
|
||||
// ---------------- 内部辅助 ----------------
|
||||
|
||||
// getPendingApply 取指定类型的待审申请;tid>0 时校验归属
|
||||
func getPendingApply(applyID uint64, applyType int8, tid uint64) (*models.AuthTenantApply, error) {
|
||||
apply := new(models.AuthTenantApply)
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("id", applyID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(apply); err != nil {
|
||||
return nil, errors.New("申请不存在")
|
||||
}
|
||||
if apply.Type != applyType {
|
||||
return nil, errors.New("申请类型不匹配")
|
||||
}
|
||||
if tid > 0 && apply.Tid != tid {
|
||||
return nil, errors.New("无权处理该申请")
|
||||
}
|
||||
if apply.Status != models.TenantApplyStatusPending {
|
||||
return nil, errors.New("该申请已处理,请刷新列表")
|
||||
}
|
||||
return apply, nil
|
||||
}
|
||||
|
||||
// finishApply CAS 置申请终态,防止并发重复审批
|
||||
func finishApply(apply *models.AuthTenantApply, status int8, reason string, reviewerID uint64, reviewerName string) error {
|
||||
now := time.Now()
|
||||
affected, err := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("id", apply.ID).
|
||||
Filter("status", models.TenantApplyStatusPending).
|
||||
Update(map[string]interface{}{
|
||||
"status": status,
|
||||
"reject_reason": reason,
|
||||
"reviewer_id": reviewerID,
|
||||
"reviewer_name": reviewerName,
|
||||
"review_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return errors.New("该申请已处理,请刷新列表")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindIdentityToTenant 把身份绑定为租户成员(老表 + 认证中心双写),角色由 rolePicker 决定。
|
||||
// 企业内账号:手机号 > user{id};姓名取昵称;密码镜像身份层现有哈希。
|
||||
func bindIdentityToTenant(identity *models.AuthIdentity, tid uint64, rolePicker func() (uint64, error)) error {
|
||||
account := ""
|
||||
if identity.Mobile != nil && strings.TrimSpace(*identity.Mobile) != "" {
|
||||
account = strings.TrimSpace(*identity.Mobile)
|
||||
}
|
||||
if account == "" {
|
||||
account = fmt.Sprintf("user%d", identity.ID)
|
||||
}
|
||||
// 企业内账号被其他身份占用时拒绝
|
||||
var dup models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("account", account).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&dup); err == nil && dup.Uid != identity.ID {
|
||||
return errors.New("企业内账号已被占用(" + account + "),请管理员调整后重试")
|
||||
}
|
||||
|
||||
name := identityNickname(identity)
|
||||
var pwd *string
|
||||
if identity.PasswordHash != nil {
|
||||
h := *identity.PasswordHash
|
||||
pwd = &h
|
||||
}
|
||||
if _, err := BindTenantUser(
|
||||
tid, identity.ID,
|
||||
&account, &name, identity.Mobile, identity.Email,
|
||||
nil, nil, pwd,
|
||||
0, 1, nil,
|
||||
); err != nil {
|
||||
return errors.New("建立企业绑定失败:" + err.Error())
|
||||
}
|
||||
|
||||
groupID := uint64(0)
|
||||
if roleID, rerr := rolePicker(); rerr == nil && roleID > 0 {
|
||||
groupID = roleID
|
||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", identity.ID).
|
||||
Update(map[string]interface{}{"group_id": roleID})
|
||||
}
|
||||
|
||||
if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
|
||||
Tid: tid,
|
||||
Account: account,
|
||||
Name: name,
|
||||
Phone: applyStrVal(identity.Mobile),
|
||||
Email: applyStrVal(identity.Email),
|
||||
PasswordHash: applyStrVal(identity.PasswordHash),
|
||||
GroupID: groupID,
|
||||
Status: 1,
|
||||
IsDefault: 0,
|
||||
}); err != nil {
|
||||
return errors.New("认证中心同步失败:" + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyStrVal 取指针字符串值(nil 返回空串)
|
||||
func applyStrVal(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// applyStrPtr 字符串转指针
|
||||
func applyStrPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// identityNickname 身份昵称(空则用「用户{id}」兜底)
|
||||
func identityNickname(identity *models.AuthIdentity) string {
|
||||
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||
return strings.TrimSpace(*identity.Nickname)
|
||||
}
|
||||
return fmt.Sprintf("用户%d", identity.ID)
|
||||
}
|
||||
|
||||
// genUniqueTenantCode 生成唯一租户编码(T + 时间戳 + 随机)
|
||||
func genUniqueTenantCode() (string, error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
code := fmt.Sprintf("T%d%03d", time.Now().Unix(), time.Now().UnixNano()%1000)
|
||||
cnt, err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("tenant_code", code).Count()
|
||||
if err != nil {
|
||||
return "", errors.New("生成租户编码失败")
|
||||
}
|
||||
if cnt == 0 {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("生成租户编码失败,请稍后重试")
|
||||
}
|
||||
|
||||
// BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(账号注册模式)。
|
||||
// openid 已绑定其他身份时不抢占,静默跳过。
|
||||
func BindRegisterWechat(identityID uint64, openid string) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" || identityID == 0 {
|
||||
return
|
||||
}
|
||||
var row models.AuthIdentityThird
|
||||
err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("provider", models.IdPWechat).
|
||||
Filter("open_id", openid).
|
||||
One(&row)
|
||||
if err == nil {
|
||||
return // 已绑定(无论绑给谁),不抢占
|
||||
}
|
||||
if err != orm.ErrNoRows {
|
||||
return
|
||||
}
|
||||
_, _ = models.Orm.Insert(&models.AuthIdentityThird{
|
||||
IdentityID: identityID,
|
||||
Provider: models.IdPWechat,
|
||||
OpenID: openid,
|
||||
})
|
||||
}
|
||||
@@ -551,6 +551,26 @@ func ownerModuleCode(sortedModules []models.SystemModules, path string) (string,
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ModuleCodesOwningMenus 返回这些菜单路径归属的模块编码集合(按模块 path 前缀匹配)。
|
||||
// 供角色权限过滤使用:模块名下有菜单但一个都不在授权集合时,模块应整体隐藏。
|
||||
func ModuleCodesOwningMenus(allModules []models.SystemModules, menus []models.SystemMenu) map[string]bool {
|
||||
codes := map[string]bool{}
|
||||
if len(allModules) == 0 || len(menus) == 0 {
|
||||
return codes
|
||||
}
|
||||
sorted := sortModulesByPathDesc(allModules)
|
||||
for _, m := range menus {
|
||||
path := ""
|
||||
if m.Path != nil {
|
||||
path = *m.Path
|
||||
}
|
||||
if code, ok := ownerModuleCode(sorted, path); ok {
|
||||
codes[NormalizeModuleCode(code)] = true
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// FilterMenusByTenantModules 按租户套餐包含的功能模块过滤菜单:
|
||||
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
|
||||
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
//
|
||||
// 与 services/wechatmp(公众号)分开:小程序走 sns/jscode2session 与
|
||||
// wxa/business/getuserphonenumber,两者的 AppID / AppSecret 互不相通。
|
||||
//
|
||||
// 配置存储:多应用表 yz_platform_wechat_mini_app(docs/sql/create_wechat_mini_app.sql),
|
||||
// 旧单行表 yz_platform_wechat_mini_config 仅作为未迁移部署的回退数据源。
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +21,7 @@ import (
|
||||
// Config 小程序运行配置(敏感字段已解密)
|
||||
type Config struct {
|
||||
ID uint64
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 明文
|
||||
Enabled bool
|
||||
@@ -27,18 +32,11 @@ type Config struct {
|
||||
// ErrNotConfigured 未配置或未启用
|
||||
var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
|
||||
|
||||
// LoadConfig 读取小程序配置(单行,敏感字段解密);未配置返回 ErrNotConfigured
|
||||
func LoadConfig() (*Config, error) {
|
||||
var row models.WechatMiniConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// appFromRow 解密行数据为运行配置
|
||||
func appFromRow(row models.WechatMiniApp) (*Config, error) {
|
||||
cfg := &Config{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Enabled: row.Enabled == 1,
|
||||
AutoBind: row.AutoBind != 0,
|
||||
@@ -54,6 +52,44 @@ func LoadConfig() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadConfig 读取小程序配置(多应用表第一条,敏感字段解密)。
|
||||
// 新表无数据时回退旧单行表(未执行迁移 SQL 的存量部署);均未配置返回 ErrNotConfigured
|
||||
func LoadConfig() (*Config, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
if err == nil && len(rows) > 0 {
|
||||
return appFromRow(rows[0])
|
||||
}
|
||||
|
||||
// 旧单行表回退
|
||||
var legacy models.WechatMiniConfig
|
||||
err = models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&legacy)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{
|
||||
ID: legacy.ID,
|
||||
AppID: strings.TrimSpace(legacy.AppID),
|
||||
Enabled: legacy.Enabled == 1,
|
||||
AutoBind: legacy.AutoBind != 0,
|
||||
Remark: legacy.Remark,
|
||||
}
|
||||
if legacy.AppSecret != "" {
|
||||
plain, derr := wechatmp.DecryptSecret(legacy.AppSecret)
|
||||
if derr != nil {
|
||||
return nil, derr
|
||||
}
|
||||
cfg.AppSecret = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadEnabledConfig 读取并校验启用状态
|
||||
func LoadEnabledConfig() (*Config, error) {
|
||||
cfg, err := LoadConfig()
|
||||
@@ -66,61 +102,180 @@ func LoadEnabledConfig() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveInput 保存配置入参(AppSecret 支持掩码表示不修改)
|
||||
type SaveInput struct {
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
// LoadEnabledAppByAppID 按小程序 AppID 精确读取启用配置(多小程序场景,
|
||||
// 认证端按客户端传入的 appid 选择对应应用)
|
||||
func LoadEnabledAppByAppID(appID string) (*Config, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := appFromRow(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.Enabled || cfg.AppSecret == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存小程序配置(不存在则创建)
|
||||
func SaveConfig(in SaveInput) error {
|
||||
if strings.TrimSpace(in.AppID) == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
// ListApps 应用列表(软删除外,AppSecret 不回显)
|
||||
func ListApps() ([]models.WechatMiniApp, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
var row models.WechatMiniConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
|
||||
// SaveAppInput 保存应用入参(AppSecret 支持掩码表示不修改)
|
||||
type SaveAppInput struct {
|
||||
ID uint64
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// SaveApp 新增/更新小程序应用(AppID 全局唯一,排除自身)
|
||||
func SaveApp(in SaveAppInput) (uint64, error) {
|
||||
appID := strings.TrimSpace(in.AppID)
|
||||
if appID == "" {
|
||||
return 0, errors.New("AppID 不能为空")
|
||||
}
|
||||
now := time.Now()
|
||||
isNew := false
|
||||
if err != nil {
|
||||
if err != orm.ErrNoRows {
|
||||
return err
|
||||
|
||||
var row models.WechatMiniApp
|
||||
if in.ID > 0 {
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("id", in.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return 0, errors.New("应用不存在或已删除")
|
||||
}
|
||||
isNew = true
|
||||
row = models.WechatMiniConfig{}
|
||||
} else {
|
||||
row = models.WechatMiniApp{CreateTime: now}
|
||||
}
|
||||
|
||||
// AppID 唯一性校验(排除自身)
|
||||
dupQ := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true)
|
||||
if in.ID > 0 {
|
||||
dupQ = dupQ.Filter("id__ne", in.ID)
|
||||
}
|
||||
var dup models.WechatMiniApp
|
||||
if err := dupQ.One(&dup); err == nil {
|
||||
name := strings.TrimSpace(dup.Name)
|
||||
if name == "" {
|
||||
name = dup.AppID
|
||||
}
|
||||
return 0, fmt.Errorf("AppID 已被应用「%s」占用", name)
|
||||
}
|
||||
|
||||
row.Name = strings.TrimSpace(in.Name)
|
||||
row.AppID = appID
|
||||
secret := strings.TrimSpace(in.AppSecret)
|
||||
if secret != "" && !wechatmp.IsMasked(secret) {
|
||||
enc, eerr := wechatmp.EncryptSecret(secret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
return 0, eerr
|
||||
}
|
||||
row.AppSecret = enc
|
||||
}
|
||||
|
||||
row.AppID = strings.TrimSpace(in.AppID)
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
row.Enabled = 0
|
||||
if in.Enabled {
|
||||
row.Enabled = 1
|
||||
} else {
|
||||
row.Enabled = 0
|
||||
}
|
||||
row.AutoBind = 0
|
||||
if in.AutoBind {
|
||||
row.AutoBind = 1
|
||||
} else {
|
||||
row.AutoBind = 0
|
||||
}
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
row.UpdateTime = &now
|
||||
|
||||
if isNew {
|
||||
row.CreateTime = now
|
||||
_, err = models.Orm.Insert(&row)
|
||||
return err
|
||||
if row.ID == 0 {
|
||||
id, err := models.Orm.Insert(&row)
|
||||
return uint64(id), err
|
||||
}
|
||||
_, err = models.Orm.Update(&row)
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
// DeleteApp 删除应用(软删并停用)
|
||||
func DeleteApp(id uint64) error {
|
||||
if id == 0 {
|
||||
return errors.New("无效ID")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{
|
||||
"delete_time": now,
|
||||
"update_time": now,
|
||||
"enabled": 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveInput 旧单行配置保存入参(AppSecret 支持掩码表示不修改)——兼容旧端点,
|
||||
// 现写入新多应用表的第一行(无行时插入)
|
||||
type SaveInput struct {
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置(兼容旧单行端点:写入新表第一行,不存在则创建)
|
||||
func SaveConfig(in SaveInput) error {
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
One(&row)
|
||||
|
||||
if err == orm.ErrNoRows {
|
||||
_, serr := SaveApp(SaveAppInput{
|
||||
Name: "默认小程序",
|
||||
AppID: in.AppID,
|
||||
AppSecret: in.AppSecret,
|
||||
Enabled: in.Enabled,
|
||||
AutoBind: in.AutoBind,
|
||||
Remark: in.Remark,
|
||||
})
|
||||
return serr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, serr := SaveApp(SaveAppInput{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: in.AppID,
|
||||
AppSecret: in.AppSecret,
|
||||
Enabled: in.Enabled,
|
||||
AutoBind: in.AutoBind,
|
||||
Remark: in.Remark,
|
||||
})
|
||||
return serr
|
||||
}
|
||||
@@ -438,6 +438,23 @@ func handleTextIntent(openid, content string) string {
|
||||
if isLoginKeyword(text) {
|
||||
return DirectLoginReply(openid)
|
||||
}
|
||||
// 「注册」:注册页账号注册方式取码(无二维码兜底路径)
|
||||
if isRegisterKeyword(text) {
|
||||
row, err := LatestPendingRegisterVerifyByOpenID(openid)
|
||||
if err != nil || row == nil {
|
||||
row, err = CreateRegisterOpenIDVerify(openid, 0)
|
||||
if err != nil {
|
||||
return "系统繁忙,请稍后重新发送。"
|
||||
}
|
||||
}
|
||||
minutes := 1
|
||||
if row.ExpireAt != nil {
|
||||
if m := int(time.Until(*row.ExpireAt).Minutes()); m > 1 {
|
||||
minutes = m
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("您的注册验证码是:%s\n请回到注册页面输入完成验证(%d 分钟内有效)。", row.Code, minutes)
|
||||
}
|
||||
if !isBindKeyword(text) {
|
||||
return textBindGuide()
|
||||
}
|
||||
@@ -463,6 +480,11 @@ func handleTextIntent(openid, content string) string {
|
||||
return fmt.Sprintf("您的验证码是:%s\n请在系统页面「绑定微信」处输入完成绑定(%d 分钟内有效)。%s", row.Code, minutes, tip)
|
||||
}
|
||||
|
||||
// isRegisterKeyword 判断文本是否为「注册」意图(注册页账号注册取码)
|
||||
func isRegisterKeyword(text string) bool {
|
||||
return text == "注册" || strings.Contains(text, "注册")
|
||||
}
|
||||
|
||||
// isBindKeyword 判断文本是否为「获取验证码 / 绑定」类意图
|
||||
func isBindKeyword(text string) bool {
|
||||
if text == "" {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 注册「账号注册」验证码:网页发起 → 展示带参二维码(scene)→
|
||||
// 用户微信扫码关注 → 服务号被动回复 6 位验证码 → 网页填码核销。
|
||||
// 复用 yz_platform_wechat_mp_verify_code 会话表(bind_type="register"),
|
||||
// 扫码事件回复走通用 handleScanScene,无需新增事件处理。
|
||||
// =============================================================
|
||||
|
||||
// WechatBindTypeRegister 注册验证码会话类型(yz_platform_wechat_mp_verify_code.bind_type)
|
||||
const WechatBindTypeRegister = "register"
|
||||
|
||||
// StartRegisterVerify 创建注册验证码会话并生成带参二维码。
|
||||
// 带参二维码生成失败(未认证公众号 48001 等)时回退:qrURL 为空、
|
||||
// notice 返回引导(用户在公众号内发送「注册」获取验证码),scene 仍有效可轮询。
|
||||
func StartRegisterVerify(ttl time.Duration) (scene, qrURL, notice string, expireAt time.Time, err error) {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultVerifyTTL
|
||||
}
|
||||
cfg, err := LoadEnabledConfig()
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
scene = randomScene()
|
||||
code := randomCode6()
|
||||
now := time.Now()
|
||||
expireAt = now.Add(ttl)
|
||||
|
||||
row := &models.WechatMpVerifyCode{
|
||||
Scene: scene,
|
||||
Code: code,
|
||||
Status: models.WechatVerifyStatusWaiting,
|
||||
BindType: WechatBindTypeRegister,
|
||||
ExpireAt: &expireAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err = models.Orm.Insert(row); err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120)
|
||||
if err != nil {
|
||||
// 不吞真实错误:与扫码登录 StartScanLogin 一致,把微信返回的
|
||||
// errcode 与排查建议(IP 白名单 / 48001 未认证等)透出,便于定位;
|
||||
// 同时保留「公众号内发送注册」兜底路径,会话仍有效可轮询核销。
|
||||
notice := "请在公众号内直接发送「注册」获取验证码"
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
notice = "生成带参二维码失败(" + apiErr.Error() + ")。" + notice
|
||||
if hint := apiErr.Explain(); hint != "" {
|
||||
notice += ":" + hint
|
||||
}
|
||||
} else {
|
||||
notice = "生成带参二维码失败:" + err.Error() + "。" + notice
|
||||
}
|
||||
return scene, "", notice, expireAt, nil
|
||||
}
|
||||
return scene, qrURL, "", expireAt, nil
|
||||
}
|
||||
|
||||
// GetRegisterVerifyStatus 查询注册会话扫码状态(网页轮询展示「已扫码」)
|
||||
func GetRegisterVerifyStatus(scene string) (scanned bool, expireAt time.Time, err error) {
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return false, time.Time{}, err
|
||||
}
|
||||
if row.ExpireAt != nil {
|
||||
expireAt = *row.ExpireAt
|
||||
}
|
||||
if row.BindType != WechatBindTypeRegister {
|
||||
return false, expireAt, ErrSceneInvalid
|
||||
}
|
||||
return row.Status == models.WechatVerifyStatusScanned, expireAt, nil
|
||||
}
|
||||
|
||||
// ConfirmRegisterVerify 核销注册验证码:校验会话归属与状态,返回扫码人 openid。
|
||||
// 要求会话已被扫码(openid 已写入)、验证码匹配,核销后一次性失效。
|
||||
func ConfirmRegisterVerify(scene, code string) (openid string, err error) {
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row.BindType != WechatBindTypeRegister {
|
||||
return "", ErrSceneInvalid
|
||||
}
|
||||
switch row.Status {
|
||||
case models.WechatVerifyStatusWaiting:
|
||||
return "", ErrSceneNotScanned
|
||||
case models.WechatVerifyStatusUsed:
|
||||
return "", ErrCodeUsed
|
||||
case models.WechatVerifyStatusExpired:
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
if strings.TrimSpace(code) != row.Code {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
if _, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now}); uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
return row.OpenID, nil
|
||||
}
|
||||
|
||||
// CreateRegisterOpenIDVerify 公众号内发送「注册」时创建的注册会话:
|
||||
// openid 已知(消息发送者),网页端按验证码核销(无带参二维码的兜底路径)。
|
||||
func CreateRegisterOpenIDVerify(openid string, ttl time.Duration) (*models.WechatMpVerifyCode, error) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" {
|
||||
return nil, errors.New("openid 为空")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultVerifyTTL
|
||||
}
|
||||
now := time.Now()
|
||||
expireAt := now.Add(ttl)
|
||||
row := &models.WechatMpVerifyCode{
|
||||
Scene: randomScene(),
|
||||
Code: randomCode6(),
|
||||
OpenID: openid,
|
||||
Status: models.WechatVerifyStatusScanned, // 微信号已知,等同「已扫码待核销」
|
||||
BindType: WechatBindTypeRegister,
|
||||
ExpireAt: &expireAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// LatestPendingRegisterVerifyByOpenID 该微信号最近一条未核销的注册会话(重发验证码时复用)
|
||||
func LatestPendingRegisterVerifyByOpenID(openid string) (*models.WechatMpVerifyCode, error) {
|
||||
var row models.WechatMpVerifyCode
|
||||
err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("openid", openid).
|
||||
Filter("bind_type", WechatBindTypeRegister).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Filter("expire_at__gt", time.Now()).
|
||||
OrderBy("-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ConfirmRegisterVerifyByCode 无二维码兜底:按验证码认领「公众号发起」的注册会话。
|
||||
// 只认领 bind_type=register、openid 非空、已扫码待核销的最近会话,CAS 核销防止重复使用。
|
||||
func ConfirmRegisterVerifyByCode(code string) (string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
var row models.WechatMpVerifyCode
|
||||
err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("code", code).
|
||||
Filter("bind_type", WechatBindTypeRegister).
|
||||
Filter("openid__ne", "").
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Filter("expire_at__gt", time.Now()).
|
||||
OrderBy("-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
affected, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now})
|
||||
if uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
if affected == 0 {
|
||||
return "", ErrCodeUsed
|
||||
}
|
||||
return row.OpenID, nil
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>统一认证中心 - 找回密码</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3973ff;
|
||||
--primary-dark: #2560a9;
|
||||
--text: #1f2b48;
|
||||
--text-sub: #5b6780;
|
||||
--muted: #8a94ad;
|
||||
--border: #e3e9f5;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(120deg, #eef4ff 0%, #f7fbff 55%, #eaf3ff 100%);
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 32px 20px;
|
||||
position: relative; overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.glow { position: fixed; border-radius: 50%; filter: blur(60px); pointer-events: none; z-index: 0; }
|
||||
.glow-1 { width: 380px; height: 380px; top: -120px; left: -100px; background: rgba(82, 168, 255, 0.28); }
|
||||
.glow-2 { width: 320px; height: 320px; bottom: -120px; right: -80px; background: rgba(57, 115, 255, 0.20); }
|
||||
.auth-card {
|
||||
position: relative; z-index: 1;
|
||||
display: flex;
|
||||
width: 100%; max-width: 940px; min-height: 560px;
|
||||
background: #fff; border-radius: 22px;
|
||||
box-shadow: 0 18px 50px 0 rgba(37, 96, 169, 0.14), 0 2px 6px 0 rgba(31, 43, 72, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.auth-side {
|
||||
width: 340px; flex: 0 0 340px;
|
||||
padding: 44px 34px 28px;
|
||||
background: linear-gradient(160deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
display: flex; flex-direction: column;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.auth-side::before {
|
||||
content: ''; position: absolute; width: 260px; height: 260px;
|
||||
top: -90px; right: -110px; border-radius: 50%; background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
.auth-side::after {
|
||||
content: ''; position: absolute; width: 190px; height: 190px;
|
||||
bottom: -70px; left: -60px; border-radius: 50%; background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.brand-logo {
|
||||
width: 40px; height: 40px; border-radius: 11px;
|
||||
background: rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.30);
|
||||
display: flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||
}
|
||||
.brand-logo svg { width: 22px; height: 22px; }
|
||||
.brand-name { font-size: 16px; font-weight: 600; letter-spacing: .5px; }
|
||||
.side-title { margin-top: 44px; font-size: 26px; font-weight: 600; line-height: 1.45; position: relative; z-index: 1; }
|
||||
.side-desc {
|
||||
margin-top: 12px; font-size: 13.5px; line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.76); position: relative; z-index: 1;
|
||||
}
|
||||
.side-illus { margin: auto 0; padding: 22px 0; position: relative; z-index: 1; }
|
||||
.side-illus svg { width: 100%; height: auto; display: block; }
|
||||
.side-foot {
|
||||
position: relative; z-index: 1;
|
||||
padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font-size: 12px; color: rgba(255, 255, 255, 0.62);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.side-foot svg { width: 13px; height: 13px; }
|
||||
.auth-main {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 52px 56px 40px;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
.panel-head { margin-bottom: 26px; }
|
||||
.panel-title { font-size: 26px; font-weight: 600; letter-spacing: .5px; }
|
||||
.panel-sub { margin-top: 9px; font-size: 13.5px; color: var(--text-sub); line-height: 1.7; }
|
||||
.error {
|
||||
display: none; align-items: flex-start; gap: 9px;
|
||||
background: #fff2f0; border: 1px solid #ffd4cf; color: #cf1322;
|
||||
padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.6;
|
||||
margin-bottom: 18px; animation: shake .32s ease;
|
||||
}
|
||||
.error.show { display: flex; }
|
||||
.error svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 2px; }
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
.field { margin-bottom: 16px; }
|
||||
.field-label { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-sub); margin-bottom: 8px; font-weight: 500; }
|
||||
.input-wrap { position: relative; }
|
||||
.input-icon {
|
||||
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||
width: 18px; height: 18px; color: #a7b1c7; pointer-events: none; transition: color .2s;
|
||||
}
|
||||
.input-wrap input {
|
||||
width: 100%; height: 46px;
|
||||
padding: 0 44px 0 42px;
|
||||
font-size: 14px; color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: #fbfcff; outline: none;
|
||||
transition: border-color .2s, box-shadow .2s, background .2s;
|
||||
}
|
||||
.input-wrap input::placeholder { color: #adb6c9; }
|
||||
.input-wrap input:focus {
|
||||
border-color: var(--primary); background: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(57, 115, 255, 0.12);
|
||||
}
|
||||
.eye-btn {
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
|
||||
color: #a7b1c7; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: color .2s, background .2s;
|
||||
}
|
||||
.eye-btn:hover { color: var(--primary); background: #f0f5ff; }
|
||||
.eye-btn svg { width: 18px; height: 18px; }
|
||||
.code-btn {
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
height: 32px; padding: 0 14px;
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
background: var(--primary); color: #fff; font-size: 12.5px;
|
||||
transition: opacity .2s, background .2s;
|
||||
}
|
||||
.code-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.code-btn:disabled { background: #c3cdf0; cursor: not-allowed; }
|
||||
.link { color: var(--primary); text-decoration: none; cursor: pointer; transition: color .2s; }
|
||||
.link:hover { color: var(--primary-dark); text-decoration: underline; }
|
||||
.btn {
|
||||
width: 100%; height: 46px; border: none; border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
|
||||
color: #fff; font-size: 15.5px; font-weight: 500; letter-spacing: 2px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: 0 8px 20px rgba(57, 115, 255, 0.28);
|
||||
transition: transform .15s, box-shadow .2s, opacity .2s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(57, 115, 255, 0.34); }
|
||||
.btn:disabled { opacity: .72; cursor: not-allowed; box-shadow: none; }
|
||||
.spinner {
|
||||
display: none; width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.45); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin .7s linear infinite;
|
||||
}
|
||||
.btn.is-loading .spinner { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.back { text-align: center; margin-top: 22px; font-size: 13px; color: var(--text-sub); }
|
||||
.foot-tip {
|
||||
margin-top: 24px; padding-top: 16px; border-top: 1px dashed var(--border);
|
||||
font-size: 12px; color: var(--muted);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.foot-tip svg { width: 13px; height: 13px; flex: 0 0 auto; }
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 20px 14px; align-items: flex-start; }
|
||||
.auth-card { max-width: 520px; min-height: 0; flex-direction: column; }
|
||||
.auth-side { width: 100%; flex: none; padding: 30px 26px 24px; }
|
||||
.side-title { margin-top: 26px; font-size: 21px; }
|
||||
.side-illus { display: none; }
|
||||
.auth-main { padding: 34px 26px 30px; }
|
||||
.panel-title { font-size: 22px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<aside class="auth-side">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
<path d="M9.2 12.2l2 2 3.6-4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-name">云泽统一认证中心</span>
|
||||
</div>
|
||||
|
||||
<div class="side-title">找回登录密码</div>
|
||||
<div class="side-desc">
|
||||
通过注册手机号验证身份后即可重置密码;<br>
|
||||
统一认证下密码只有一份,<br>
|
||||
重置后您在所有企业的登录密码同步更新。
|
||||
</div>
|
||||
|
||||
<div class="side-illus">
|
||||
<svg viewBox="0 0 260 170" fill="none">
|
||||
<ellipse cx="130" cy="150" rx="98" ry="11" fill="rgba(255,255,255,.13)"/>
|
||||
<rect x="76" y="30" width="108" height="76" rx="12" fill="rgba(255,255,255,.14)" stroke="rgba(255,255,255,.4)" stroke-width="1.6"/>
|
||||
<path d="M104 62v-8a26 26 0 0152 0v8" stroke="rgba(255,255,255,.7)" stroke-width="4.5" stroke-linecap="round"/>
|
||||
<rect x="98" y="62" width="64" height="34" rx="8" fill="rgba(255,255,255,.3)"/>
|
||||
<circle cx="130" cy="79" r="5" fill="rgba(255,255,255,.85)"/>
|
||||
<path d="M130 82v7" stroke="rgba(255,255,255,.85)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||
<path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<span>© 2026 Yunzer · 连接已加密</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="auth-main">
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title">找回密码</h1>
|
||||
<p class="panel-sub">请输入注册手机号,通过短信验证后设置新密码</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>
|
||||
</svg>
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<form id="resetForm" onsubmit="return submitReset(event)">
|
||||
<div class="field">
|
||||
<label class="field-label" for="phone">注册手机号</label>
|
||||
<div class="input-wrap">
|
||||
<input id="phone" type="tel" placeholder="请输入注册时使用的手机号" maxlength="11" autofocus>
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="7" y="2" width="10" height="20" rx="2.5"/><path d="M11 18.5h2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="code">短信验证码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="code" type="text" placeholder="请输入验证码" maxlength="6" style="padding-right:110px">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2.5"/>
|
||||
</svg>
|
||||
<button class="code-btn" type="button" id="codeBtn" onclick="sendCode()">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="password">新密码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="password" type="password" placeholder="至少 6 位" autocomplete="new-password">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<button class="eye-btn" type="button" onclick="togglePassword('password', this)" title="显示密码" aria-label="显示密码">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="confirm">确认新密码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="confirm" type="password" placeholder="请再次输入新密码" autocomplete="new-password">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<button class="eye-btn" type="button" onclick="togglePassword('confirm', this)" title="显示密码" aria-label="显示密码">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="submitBtn" type="submit">
|
||||
<span class="spinner"></span>
|
||||
<span id="btnLabel">重置密码</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="back">想起密码了?<a class="link" id="backLogin" href="/auth/login">返回登录</a></div>
|
||||
|
||||
<div class="foot-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
</svg>
|
||||
<span>如手机号已停用,请联系企业管理员协助处理</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CLIENT_ID = "{{.ClientID}}";
|
||||
var REDIRECT = "{{.Redirect}}";
|
||||
|
||||
(function initBackLink() {
|
||||
var params = new URLSearchParams();
|
||||
if (CLIENT_ID) params.set('client_id', CLIENT_ID);
|
||||
if (REDIRECT) params.set('redirect', REDIRECT);
|
||||
var qs = params.toString();
|
||||
document.getElementById('backLogin').href = '/auth/login' + (qs ? '?' + qs : '');
|
||||
})();
|
||||
|
||||
function showError(msg) {
|
||||
var box = document.getElementById('error');
|
||||
document.getElementById('errorText').textContent = msg;
|
||||
box.classList.remove('show');
|
||||
void box.offsetWidth;
|
||||
box.classList.add('show');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
var box = document.getElementById('error');
|
||||
if (box) box.classList.remove('show');
|
||||
}
|
||||
|
||||
function togglePassword(id, btn) {
|
||||
var input = document.getElementById(id);
|
||||
var show = input.type === 'password';
|
||||
input.type = show ? 'text' : 'password';
|
||||
btn.title = show ? '隐藏密码' : '显示密码';
|
||||
}
|
||||
|
||||
var codeTimer = null;
|
||||
function setCodeBtnCountdown(sec) {
|
||||
var btn = document.getElementById('codeBtn');
|
||||
if (codeTimer) { clearInterval(codeTimer); codeTimer = null; }
|
||||
btn.disabled = true;
|
||||
var left = sec;
|
||||
btn.textContent = left + 's 后重发';
|
||||
codeTimer = setInterval(function () {
|
||||
left--;
|
||||
if (left <= 0) {
|
||||
clearInterval(codeTimer); codeTimer = null;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重新获取';
|
||||
} else {
|
||||
btn.textContent = left + 's 后重发';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function sendCode() {
|
||||
hideError();
|
||||
var phone = document.getElementById('phone').value.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) { showError('请输入正确的手机号'); return; }
|
||||
|
||||
var btn = document.getElementById('codeBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
|
||||
fetch('/auth/forgot/send-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: phone })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code !== 200) {
|
||||
showError(res.msg || '验证码发送失败');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重新获取';
|
||||
return;
|
||||
}
|
||||
setCodeBtnCountdown(60);
|
||||
})
|
||||
.catch(function () {
|
||||
showError('网络异常,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重新获取';
|
||||
});
|
||||
}
|
||||
|
||||
function submitReset(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
var phone = document.getElementById('phone').value.trim();
|
||||
var code = document.getElementById('code').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
var confirm = document.getElementById('confirm').value;
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) { showError('请输入正确的手机号'); return false; }
|
||||
if (!code) { showError('请输入短信验证码'); return false; }
|
||||
if (password.length < 6) { showError('密码长度不能少于 6 位'); return false; }
|
||||
if (password !== confirm) { showError('两次输入的密码不一致'); return false; }
|
||||
|
||||
var btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.classList.add('is-loading');
|
||||
document.getElementById('btnLabel').textContent = '提交中...';
|
||||
|
||||
fetch('/auth/forgot/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: phone,
|
||||
code: code,
|
||||
password: password,
|
||||
confirm: confirm,
|
||||
client_id: CLIENT_ID,
|
||||
redirect: REDIRECT
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code !== 200) {
|
||||
showError(res.msg || '密码重置失败');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('is-loading');
|
||||
document.getElementById('btnLabel').textContent = '重置密码';
|
||||
return;
|
||||
}
|
||||
window.location.href = res.redirect || ('/auth/login?client_id=' + encodeURIComponent(CLIENT_ID));
|
||||
})
|
||||
.catch(function () {
|
||||
showError('网络异常,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('is-loading');
|
||||
document.getElementById('btnLabel').textContent = '重置密码';
|
||||
});
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -164,6 +164,17 @@
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
background: #f0fff4; border: 1px solid #b7ebc2; color: #1f7a37;
|
||||
padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.notice-box.warn {
|
||||
background: #fffbe6; border-color: #ffe89e; color: #a96a00;
|
||||
}
|
||||
.notice-box svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 2px; }
|
||||
|
||||
.field { margin-bottom: 18px; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
@@ -455,6 +466,17 @@
|
||||
<p class="panel-sub">请输入您的账号与密码以继续</p>
|
||||
</div>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="notice-box{{if eq .Notice "registered_warn"}} warn{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{{if eq .Notice "registered_warn"}}<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>{{else}}<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/><path d="M9.2 12.2l2 2 3.6-4"/>{{end}}
|
||||
</svg>
|
||||
<span>
|
||||
{{if eq .Notice "reset"}}密码已重置,请使用新密码登录{{else if eq .Notice "registered_warn"}}{{.WarnMsg}},可先登录{{else}}注册成功,请使用新账号登录{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="error" id="error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>
|
||||
@@ -493,6 +515,7 @@
|
||||
|
||||
<div class="row-extra">
|
||||
<label class="remember"><input type="checkbox" id="remember"> 记住账号</label>
|
||||
<a class="link" href="/auth/forgot?client_id={{urlquery .ClientID}}&redirect={{urlquery .Redirect}}">忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="submitBtn" type="submit">
|
||||
@@ -501,6 +524,8 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="back" style="margin-top:16px">还没有账号?<a class="link" href="/auth/register?client_id={{urlquery .ClientID}}&redirect={{urlquery .Redirect}}">立即注册</a></div>
|
||||
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>统一认证中心 - 注册</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3973ff;
|
||||
--primary-dark: #2560a9;
|
||||
--text: #1f2b48;
|
||||
--text-sub: #5b6780;
|
||||
--muted: #8a94ad;
|
||||
--border: #e3e9f5;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(120deg, #eef4ff 0%, #f7fbff 55%, #eaf3ff 100%);
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 32px 20px;
|
||||
position: relative; overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.glow { position: fixed; border-radius: 50%; filter: blur(60px); pointer-events: none; z-index: 0; }
|
||||
.glow-1 { width: 380px; height: 380px; top: -120px; left: -100px; background: rgba(82, 168, 255, 0.28); }
|
||||
.glow-2 { width: 320px; height: 320px; bottom: -120px; right: -80px; background: rgba(57, 115, 255, 0.20); }
|
||||
.auth-card {
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; max-width: 860px;
|
||||
background: #fff; border-radius: 22px;
|
||||
box-shadow: 0 18px 50px 0 rgba(37, 96, 169, 0.14), 0 2px 6px 0 rgba(31, 43, 72, 0.04);
|
||||
padding: 40px 48px 32px;
|
||||
}
|
||||
.brand { text-align: center; margin-bottom: 22px; }
|
||||
.brand h1 { font-size: 22px; font-weight: 700; letter-spacing: 1px; }
|
||||
.brand p { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
||||
|
||||
/* 步骤条 */
|
||||
.steps { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 28px; }
|
||||
.step { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; }
|
||||
.step .dot {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #eef2fb; color: var(--muted); font-size: 13px; font-weight: 600;
|
||||
transition: all .25s;
|
||||
}
|
||||
.step.active { color: var(--text); font-weight: 600; }
|
||||
.step.active .dot { background: var(--primary); color: #fff; }
|
||||
.step.done .dot { background: #34b96f; color: #fff; }
|
||||
.step-line { width: 70px; height: 2px; background: var(--border); border-radius: 2px; }
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; animation: fade .25s ease; }
|
||||
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* 第一步:方式切换 */
|
||||
.mode-tabs { display: flex; background: #f2f5fb; border-radius: 12px; padding: 5px; margin-bottom: 22px; }
|
||||
.mode-tab {
|
||||
flex: 1; text-align: center; padding: 10px 0; border-radius: 9px;
|
||||
font-size: 14px; color: var(--text-sub); cursor: pointer; user-select: none;
|
||||
transition: all .2s;
|
||||
}
|
||||
.mode-tab.on { background: #fff; color: var(--primary); font-weight: 600; box-shadow: 0 2px 8px rgba(31,43,72,.08); }
|
||||
.mode-pane { display: none; }
|
||||
.mode-pane.on { display: block; }
|
||||
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; color: var(--text-sub); margin-bottom: 6px; }
|
||||
.field label .req { color: #e5484d; margin-left: 2px; }
|
||||
.input-row { display: flex; gap: 10px; }
|
||||
.ipt {
|
||||
flex: 1; width: 100%; height: 44px; padding: 0 14px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 14px; color: var(--text); outline: none; background: #fbfcff;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.ipt:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(57,115,255,.12); background: #fff; }
|
||||
.btn {
|
||||
height: 44px; padding: 0 20px; border: none; border-radius: 10px;
|
||||
font-size: 14px; cursor: pointer; transition: all .2s;
|
||||
background: #eef2fb; color: var(--text-sub);
|
||||
}
|
||||
.btn:hover { background: #e2e9f8; }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-primary {
|
||||
width: 100%; height: 46px; margin-top: 6px;
|
||||
background: linear-gradient(135deg, #3973ff, #5a8bff);
|
||||
color: #fff; font-size: 15px; font-weight: 600;
|
||||
box-shadow: 0 8px 20px -6px rgba(57,115,255,.5);
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.05); }
|
||||
.btn-ghost { background: #fff; border: 1px solid var(--border); color: var(--text-sub); }
|
||||
.btn-ghost:hover { border-color: var(--primary); color: var(--primary); background: #fff; }
|
||||
.code-btn { min-width: 118px; }
|
||||
.hint { font-size: 12px; color: var(--muted); margin-top: 4px; line-height: 1.6; }
|
||||
|
||||
/* 账号注册二维码区 */
|
||||
.wechat-box { display: none; text-align: center; margin-bottom: 16px; padding: 16px; border: 1px dashed var(--border); border-radius: 12px; background: #fbfcff; }
|
||||
.wechat-box.show { display: block; }
|
||||
.wechat-box img { width: 168px; height: 168px; object-fit: contain; }
|
||||
.wechat-box .scan-tip { font-size: 13px; color: var(--text-sub); margin-top: 8px; }
|
||||
.wechat-box .scan-tip.ok { color: #34b96f; font-weight: 600; }
|
||||
.wechat-box .mp-notice { font-size: 12px; color: #d08700; margin-top: 6px; }
|
||||
|
||||
/* 第二步:两个卡片 */
|
||||
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@media (max-width: 760px) { .cards { grid-template-columns: 1fr; } .auth-card { padding: 28px 20px; } }
|
||||
.choice-card {
|
||||
border: 1px solid var(--border); border-radius: 14px; padding: 20px;
|
||||
background: #fbfcff; transition: border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.choice-card:hover { border-color: rgba(57,115,255,.45); box-shadow: 0 8px 24px -10px rgba(57,115,255,.25); }
|
||||
.choice-card h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.choice-card .desc { font-size: 12px; color: var(--muted); margin-bottom: 14px; line-height: 1.6; }
|
||||
.choice-card .field { margin-bottom: 12px; }
|
||||
.search-list { max-height: 168px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; margin-top: 8px; background: #fff; }
|
||||
.search-item { padding: 10px 12px; font-size: 13px; cursor: pointer; border-bottom: 1px solid #f0f3fa; display: flex; justify-content: space-between; gap: 8px; }
|
||||
.search-item:last-child { border-bottom: none; }
|
||||
.search-item:hover { background: #f4f8ff; }
|
||||
.search-item.sel { background: #ecf2ff; color: var(--primary); font-weight: 600; }
|
||||
.search-item .code { color: var(--muted); font-size: 12px; }
|
||||
.search-empty { padding: 12px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.file-row { display: flex; align-items: center; gap: 10px; }
|
||||
.file-name { font-size: 12px; color: var(--text-sub); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-name.ok { color: #34b96f; }
|
||||
.card-submit { width: 100%; margin-top: 4px; }
|
||||
|
||||
.skip-row { text-align: center; margin-top: 20px; }
|
||||
.skip-link { font-size: 13px; color: var(--muted); cursor: pointer; background: none; border: none; text-decoration: underline; }
|
||||
.skip-link:hover { color: var(--primary); }
|
||||
|
||||
.msg { display: none; margin-bottom: 16px; padding: 10px 14px; border-radius: 10px; font-size: 13px; line-height: 1.6; }
|
||||
.msg.show { display: block; }
|
||||
.msg-err { background: #fdf0f0; color: #d54941; border: 1px solid #f5d4d2; }
|
||||
.msg-ok { background: #eefaf2; color: #2e9e5b; border: 1px solid #cdeeda; }
|
||||
|
||||
.foot { text-align: center; margin-top: 22px; font-size: 13px; color: var(--muted); }
|
||||
.foot a { color: var(--primary); text-decoration: none; }
|
||||
.foot a:hover { text-decoration: underline; }
|
||||
|
||||
/* 成功浮层 */
|
||||
.done-mask { display: none; position: fixed; inset: 0; background: rgba(23,32,54,.45); z-index: 50; align-items: center; justify-content: center; }
|
||||
.done-mask.show { display: flex; }
|
||||
.done-box { width: 380px; max-width: 92vw; background: #fff; border-radius: 18px; padding: 32px 28px; text-align: center; }
|
||||
.done-box .icon { width: 56px; height: 56px; border-radius: 50%; background: #eefaf2; color: #34b96f; font-size: 28px; display: flex; align-items: center; justify-content: center; margin: 0 auto 14px; }
|
||||
.done-box h3 { font-size: 17px; margin-bottom: 8px; }
|
||||
.done-box p { font-size: 13px; color: var(--text-sub); line-height: 1.7; margin-bottom: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
|
||||
<div class="auth-card" id="app"
|
||||
data-client-id="{{.ClientID}}"
|
||||
data-redirect="{{.Redirect}}">
|
||||
<div class="brand">
|
||||
<h1>创建账号</h1>
|
||||
<p>注册统一认证账号,一个账号密码通行所有企业</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step active" id="stepDot1"><span class="dot">1</span><span>核验身份</span></div>
|
||||
<div class="step-line"></div>
|
||||
<div class="step" id="stepDot2"><span class="dot">2</span><span>企业归属</span></div>
|
||||
</div>
|
||||
|
||||
<div class="msg msg-err" id="globalMsg"></div>
|
||||
|
||||
<!-- ================= 第一步:核验身份 ================= -->
|
||||
<div class="panel active" id="panelStep1">
|
||||
<div class="mode-tabs">
|
||||
<div class="mode-tab on" data-mode="phone">手机号注册</div>
|
||||
<div class="mode-tab" data-mode="account">账号注册</div>
|
||||
</div>
|
||||
|
||||
<!-- 手机号注册 -->
|
||||
<div class="mode-pane on" id="panePhone">
|
||||
<div class="field">
|
||||
<label>手机号<span class="req">*</span></label>
|
||||
<input class="ipt" id="phone" type="tel" maxlength="11" placeholder="请输入手机号(作为登录账号)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>短信验证码<span class="req">*</span></label>
|
||||
<div class="input-row">
|
||||
<input class="ipt" id="phoneCode" maxlength="6" placeholder="请输入短信验证码">
|
||||
<button class="btn code-btn" id="sendPhoneCode" type="button">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>设置密码<span class="req">*</span></label>
|
||||
<input class="ipt" id="phonePwd" type="password" placeholder="密码长度不少于6位">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认密码<span class="req">*</span></label>
|
||||
<input class="ipt" id="phonePwd2" type="password" placeholder="请再次输入密码">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnVerifyPhone" type="button">验证并下一步</button>
|
||||
</div>
|
||||
|
||||
<!-- 账号注册 -->
|
||||
<div class="mode-pane" id="paneAccount">
|
||||
<div class="field">
|
||||
<label>账号名<span class="req">*</span></label>
|
||||
<input class="ipt" id="account" maxlength="32" placeholder="4-32位,字母开头,可含数字、下划线、中划线">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>设置密码<span class="req">*</span></label>
|
||||
<input class="ipt" id="accountPwd" type="password" placeholder="密码长度不少于6位">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认密码<span class="req">*</span></label>
|
||||
<input class="ipt" id="accountPwd2" type="password" placeholder="请再次输入密码">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>微信身份核验<span class="req">*</span></label>
|
||||
<div class="wechat-box" id="wechatBox">
|
||||
<img id="wechatQr" alt="公众号二维码" style="display:none">
|
||||
<div class="scan-tip" id="scanTip">正在获取二维码…</div>
|
||||
<div class="mp-notice" id="mpNotice"></div>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<input class="ipt" id="accountCode" maxlength="6" placeholder="公众号下发的 6 位验证码">
|
||||
<button class="btn code-btn" id="btnQr" type="button">获取二维码</button>
|
||||
</div>
|
||||
<div class="hint">用微信扫码关注公众号后,验证码将通过公众号自动发送;无法扫码时可在公众号内直接发送「注册」获取验证码。</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnVerifyAccount" type="button">验证并下一步</button>
|
||||
</div>
|
||||
|
||||
<div class="foot">已有账号?<a href="/auth/login" id="toLogin">直接登录</a> · <a href="/auth/forgot" id="toForgot">忘记密码</a></div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 第二步:企业归属 ================= -->
|
||||
<div class="panel" id="panelStep2">
|
||||
<div class="cards">
|
||||
<!-- 卡片一:绑定企业 -->
|
||||
<div class="choice-card">
|
||||
<h3>🏢 绑定企业</h3>
|
||||
<div class="desc">你的账号已由企业创建/企业管理员已开通企业,搜索并申请加入,由企业管理员审核。</div>
|
||||
<div class="field">
|
||||
<label>企业名称 / 编码<span class="req">*</span></label>
|
||||
<div class="input-row">
|
||||
<input class="ipt" id="tenantKeyword" placeholder="输入企业名称或编码搜索">
|
||||
<button class="btn" id="btnSearchTenant" type="button">搜索</button>
|
||||
</div>
|
||||
<div class="search-list" id="tenantList" style="display:none"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary card-submit" id="btnJoin" type="button" disabled>确定加入</button>
|
||||
</div>
|
||||
|
||||
<!-- 卡片二:创建企业 -->
|
||||
<div class="choice-card">
|
||||
<h3>🏗️ 创建企业</h3>
|
||||
<div class="desc">没有现成企业?填写企业资料创建新企业,由平台审核,通过后你将成为企业管理员。</div>
|
||||
<div class="field"><label>公司名称<span class="req">*</span></label><input class="ipt" id="cName" maxlength="128" placeholder="营业执照上的公司全称"></div>
|
||||
<div class="field"><label>统一社会信用代码<span class="req">*</span></label><input class="ipt" id="cCredit" maxlength="18" placeholder="18 位统一社会信用代码"></div>
|
||||
<div class="field"><label>法定代表人姓名<span class="req">*</span></label><input class="ipt" id="cLegal" maxlength="64" placeholder="法定代表人姓名"></div>
|
||||
<div class="field">
|
||||
<label>营业执照电子档<span class="req">*</span>(PDF / JPG / PNG,≤5MB)</label>
|
||||
<div class="file-row">
|
||||
<button class="btn btn-ghost" id="btnPickFile" type="button">选择文件</button>
|
||||
<span class="file-name" id="licenseName">未选择文件</span>
|
||||
</div>
|
||||
<input type="file" id="licenseFile" accept=".pdf,.jpg,.jpeg,.png" style="display:none">
|
||||
</div>
|
||||
<div class="field"><label>联系人<span class="req">*</span></label><input class="ipt" id="cContact" maxlength="64" placeholder="联系人姓名"></div>
|
||||
<div class="field"><label>联系电话<span class="req">*</span></label><input class="ipt" id="cPhone" maxlength="11" placeholder="联系电话"></div>
|
||||
<div class="field"><label>电子邮箱<span class="req">*</span></label><input class="ipt" id="cEmail" maxlength="128" placeholder="电子邮箱"></div>
|
||||
<div class="field"><label>公司地址<span class="req">*</span></label><input class="ipt" id="cAddress" maxlength="255" placeholder="公司地址"></div>
|
||||
<button class="btn btn-primary card-submit" id="btnCreate" type="button">确定创建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skip-row">
|
||||
<button class="skip-link" id="btnSkip" type="button">暂不加入企业,直接完成注册 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功浮层 -->
|
||||
<div class="done-mask" id="doneMask">
|
||||
<div class="done-box">
|
||||
<div class="icon">✓</div>
|
||||
<h3 id="doneTitle">注册成功</h3>
|
||||
<p id="doneText"></p>
|
||||
<button class="btn btn-primary" id="btnGoLogin" type="button" style="width:100%">前往登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var app = document.getElementById('app');
|
||||
var CLIENT_ID = app.getAttribute('data-client-id') || '';
|
||||
var REDIRECT = app.getAttribute('data-redirect') || '';
|
||||
|
||||
var state = { mode: 'phone', token: '', scene: '', qrTimer: null, selectedTid: 0 };
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function showMsg(text, ok) {
|
||||
var el = $('globalMsg');
|
||||
el.className = 'msg show ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.textContent = text;
|
||||
if (!ok) { clearTimeout(showMsg._t); showMsg._t = setTimeout(function(){ el.className = 'msg'; }, 5000); }
|
||||
}
|
||||
function post(url, data, cb) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).then(function (r) { return r.json(); }).then(function (res) { cb(res); })
|
||||
.catch(function () { cb({ code: 500, msg: '网络异常,请稍后重试' }); });
|
||||
}
|
||||
function buildLoginURL(redirect) {
|
||||
var qs = [];
|
||||
if (CLIENT_ID) qs.push('client_id=' + encodeURIComponent(CLIENT_ID));
|
||||
if (REDIRECT) qs.push('redirect=' + encodeURIComponent(REDIRECT));
|
||||
var warn = '';
|
||||
try { var u = new URL(redirect || '/auth/login', location.origin); warn = u.searchParams.get('warn') || ''; } catch (e) {}
|
||||
if (warn) qs.push('warn=' + encodeURIComponent(warn));
|
||||
return (qs.length ? '/auth/login?' + qs.join('&') : '/auth/login');
|
||||
}
|
||||
function goStep(n) {
|
||||
$('panelStep1').classList.toggle('active', n === 1);
|
||||
$('panelStep2').classList.toggle('active', n === 2);
|
||||
$('stepDot1').className = 'step ' + (n === 1 ? 'active' : 'done');
|
||||
$('stepDot1').querySelector('.dot').textContent = n === 1 ? '1' : '✓';
|
||||
$('stepDot2').className = 'step ' + (n === 2 ? 'active' : '');
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
function done(title, text, redirect) {
|
||||
$('doneTitle').textContent = title;
|
||||
$('doneText').textContent = text;
|
||||
$('btnGoLogin').onclick = function () { location.href = redirect || buildLoginURL(''); };
|
||||
$('doneMask').classList.add('show');
|
||||
}
|
||||
|
||||
// ---------- 第一步:方式切换 ----------
|
||||
document.querySelectorAll('.mode-tab').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
document.querySelectorAll('.mode-tab').forEach(function (t) { t.classList.remove('on'); });
|
||||
tab.classList.add('on');
|
||||
state.mode = tab.getAttribute('data-mode');
|
||||
$('panePhone').classList.toggle('on', state.mode === 'phone');
|
||||
$('paneAccount').classList.toggle('on', state.mode === 'account');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 手机号注册 ----------
|
||||
var sendBtn = $('sendPhoneCode'), sendTimer = null;
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var phone = $('phone').value.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) { showMsg('请输入正确的手机号'); return; }
|
||||
sendBtn.disabled = true;
|
||||
post('/auth/register/send-code', { phone: phone }, function (res) {
|
||||
if (res.code !== 200) {
|
||||
showMsg(res.msg || '验证码发送失败');
|
||||
sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
showMsg('验证码已发送,请查收短信', true);
|
||||
var sec = 60;
|
||||
sendBtn.textContent = sec + 's 后重发';
|
||||
sendTimer = setInterval(function () {
|
||||
sec--;
|
||||
if (sec <= 0) { clearInterval(sendTimer); sendBtn.textContent = '获取验证码'; sendBtn.disabled = false; }
|
||||
else { sendBtn.textContent = sec + 's 后重发'; }
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
$('btnVerifyPhone').addEventListener('click', function () {
|
||||
var phone = $('phone').value.trim(), code = $('phoneCode').value.trim();
|
||||
var pwd = $('phonePwd').value, pwd2 = $('phonePwd2').value;
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) { showMsg('请输入正确的手机号'); return; }
|
||||
if (!code) { showMsg('请输入短信验证码'); return; }
|
||||
if (pwd.length < 6) { showMsg('密码长度不能少于6位'); return; }
|
||||
if (pwd !== pwd2) { showMsg('两次输入的密码不一致'); return; }
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/verify', { mode: 'phone', phone: phone, code: code, password: pwd, confirm: pwd2 }, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '验证失败'); return; }
|
||||
state.token = res.data.register_token;
|
||||
showMsg('身份核验通过', true);
|
||||
goStep(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 账号注册 ----------
|
||||
$('btnQr').addEventListener('click', function () {
|
||||
var btn = this; btn.disabled = true;
|
||||
$('wechatBox').classList.add('show');
|
||||
$('wechatQr').style.display = 'none';
|
||||
$('scanTip').textContent = '正在获取二维码…';
|
||||
$('scanTip').className = 'scan-tip';
|
||||
$('mpNotice').textContent = '';
|
||||
fetch('/auth/register/wechat/start').then(function (r) { return r.json(); }).then(function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { $('scanTip').textContent = res.msg || '获取二维码失败'; return; }
|
||||
state.scene = res.data.scene || '';
|
||||
if (res.data.qr_url) {
|
||||
$('wechatQr').src = res.data.qr_url;
|
||||
$('wechatQr').style.display = '';
|
||||
$('scanTip').textContent = '请用微信扫码并关注公众号';
|
||||
} else {
|
||||
$('scanTip').textContent = '无法展示二维码:请在公众号内直接发送「注册」获取验证码';
|
||||
}
|
||||
if (res.data.notice) { $('mpNotice').textContent = res.data.notice; }
|
||||
startQrPolling();
|
||||
}).catch(function () { btn.disabled = false; $('scanTip').textContent = '网络异常,请重试'; });
|
||||
});
|
||||
|
||||
function startQrPolling() {
|
||||
if (state.qrTimer) clearInterval(state.qrTimer);
|
||||
if (!state.scene) return;
|
||||
state.qrTimer = setInterval(function () {
|
||||
fetch('/auth/register/wechat/status?scene=' + encodeURIComponent(state.scene))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code === 200 && res.data && res.data.scanned) {
|
||||
$('scanTip').textContent = '已扫码,验证码已发送到公众号,请填入下方输入框';
|
||||
$('scanTip').className = 'scan-tip ok';
|
||||
clearInterval(state.qrTimer);
|
||||
}
|
||||
}).catch(function () {});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
$('btnVerifyAccount').addEventListener('click', function () {
|
||||
var account = $('account').value.trim(), code = $('accountCode').value.trim();
|
||||
var pwd = $('accountPwd').value, pwd2 = $('accountPwd2').value;
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]{3,31}$/.test(account)) { showMsg('账号需 4-32 位,字母开头,仅含字母/数字/下划线/中划线'); return; }
|
||||
if (pwd.length < 6) { showMsg('密码长度不能少于6位'); return; }
|
||||
if (pwd !== pwd2) { showMsg('两次输入的密码不一致'); return; }
|
||||
if (!code) { showMsg('请输入公众号下发的验证码'); return; }
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/verify', { mode: 'account', account: account, scene: state.scene, code: code, password: pwd, confirm: pwd2 }, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '验证失败'); return; }
|
||||
state.token = res.data.register_token;
|
||||
if (state.qrTimer) clearInterval(state.qrTimer);
|
||||
showMsg('身份核验通过', true);
|
||||
goStep(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 第二步:绑定企业 ----------
|
||||
$('btnSearchTenant').addEventListener('click', function () {
|
||||
var kw = $('tenantKeyword').value.trim();
|
||||
if (!kw) { showMsg('请输入企业名称或编码'); return; }
|
||||
var list = $('tenantList');
|
||||
list.style.display = '';
|
||||
list.innerHTML = '<div class="search-empty">搜索中…</div>';
|
||||
state.selectedTid = 0;
|
||||
$('btnJoin').disabled = true;
|
||||
fetch('/auth/register/tenant/search?keyword=' + encodeURIComponent(kw))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code !== 200 || !res.data || !res.data.list || !res.data.list.length) {
|
||||
list.innerHTML = '<div class="search-empty">未找到匹配企业,请确认名称或编码</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
res.data.list.forEach(function (t) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'search-item';
|
||||
item.innerHTML = '<span>' + t.tenant_name.replace(/</g, '<') + '</span><span class="code">' + t.tenant_code + '</span>';
|
||||
item.addEventListener('click', function () {
|
||||
list.querySelectorAll('.search-item').forEach(function (x) { x.classList.remove('sel'); });
|
||||
item.classList.add('sel');
|
||||
state.selectedTid = t.tid;
|
||||
$('btnJoin').disabled = false;
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
}).catch(function () { list.innerHTML = '<div class="search-empty">搜索失败,请重试</div>'; });
|
||||
});
|
||||
|
||||
$('btnJoin').addEventListener('click', function () {
|
||||
if (!state.selectedTid) { showMsg('请先选择企业'); return; }
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/apply-join', { token: state.token, tid: state.selectedTid }, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; }
|
||||
done('申请已提交', '加入企业的申请已提交,请等待企业管理员在后台审核,审核通过后即可登录。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 第二步:创建企业 ----------
|
||||
$('btnPickFile').addEventListener('click', function () { $('licenseFile').click(); });
|
||||
$('licenseFile').addEventListener('change', function () {
|
||||
var f = this.files && this.files[0];
|
||||
if (!f) return;
|
||||
if (!/\.(pdf|jpg|jpeg|png)$/i.test(f.name)) { showMsg('仅支持 PDF、JPG、PNG 格式'); this.value = ''; return; }
|
||||
if (f.size > 5 * 1024 * 1024) { showMsg('文件大小不能超过 5MB'); this.value = ''; return; }
|
||||
var nameEl = $('licenseName');
|
||||
nameEl.textContent = '上传中:' + f.name;
|
||||
nameEl.className = 'file-name';
|
||||
var fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fetch('/auth/register/upload-license', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code !== 200) { nameEl.textContent = '未选择文件'; showMsg(res.msg || '上传失败'); return; }
|
||||
nameEl.textContent = '已上传:' + res.data.name;
|
||||
nameEl.className = 'file-name ok';
|
||||
nameEl.setAttribute('data-url', res.data.url);
|
||||
}).catch(function () { nameEl.textContent = '未选择文件'; showMsg('上传失败,请重试'); });
|
||||
});
|
||||
|
||||
$('btnCreate').addEventListener('click', function () {
|
||||
var form = {
|
||||
tenant_name: $('cName').value.trim(),
|
||||
credit_code: $('cCredit').value.trim(),
|
||||
legal_person: $('cLegal').value.trim(),
|
||||
license_file: $('licenseName').getAttribute('data-url') || '',
|
||||
contact_person: $('cContact').value.trim(),
|
||||
contact_phone: $('cPhone').value.trim(),
|
||||
contact_email: $('cEmail').value.trim(),
|
||||
address: $('cAddress').value.trim()
|
||||
};
|
||||
if (!form.tenant_name) { showMsg('请填写公司名称'); return; }
|
||||
if (!/^[0-9A-Z]{18}$/.test(form.credit_code.toUpperCase())) { showMsg('请填写 18 位统一社会信用代码'); return; }
|
||||
form.credit_code = form.credit_code.toUpperCase();
|
||||
if (!form.legal_person) { showMsg('请填写法定代表人姓名'); return; }
|
||||
if (!form.license_file) { showMsg('请上传营业执照电子档'); return; }
|
||||
if (!form.contact_person) { showMsg('请填写联系人'); return; }
|
||||
if (!/^1[3-9]\d{9}$/.test(form.contact_phone)) { showMsg('请填写正确的联系电话'); return; }
|
||||
if (!form.contact_email || form.contact_email.indexOf('@') < 0) { showMsg('请填写正确的电子邮箱'); return; }
|
||||
if (!form.address) { showMsg('请填写公司地址'); return; }
|
||||
|
||||
if (!confirm('提交后,当前注册账号将成为「' + form.tenant_name + '」的管理员账号,该绑定不可更改。确定提交并等待平台审核吗?')) return;
|
||||
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/apply-create', form, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; }
|
||||
done('申请已提交', '创建企业的申请已提交,请等待平台审核;审核通过后当前账号将成为企业管理员账号。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 暂不加入 ----------
|
||||
$('btnSkip').addEventListener('click', function () {
|
||||
if (!confirm('暂不加入企业将直接完成注册,之后可在登录后申请加入。确定吗?')) return;
|
||||
post('/auth/register/finish', { token: state.token }, function (res) {
|
||||
if (res.code !== 200) { showMsg(res.msg || '操作失败'); return; }
|
||||
done('注册成功', '账号已创建。你当前未加入任何企业,可稍后申请加入。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
|
||||
// 回登录页时透传 client_id/redirect
|
||||
(function keepOAuthParams() {
|
||||
if (!CLIENT_ID && !REDIRECT) return;
|
||||
var suffix = (CLIENT_ID ? 'client_id=' + encodeURIComponent(CLIENT_ID) : '') +
|
||||
(REDIRECT ? (CLIENT_ID ? '&' : '') + 'redirect=' + encodeURIComponent(REDIRECT) : '');
|
||||
['toLogin', 'toForgot'].forEach(function (id) {
|
||||
var a = $(id);
|
||||
if (a) a.href = a.getAttribute('href') + (a.getAttribute('href').indexOf('?') >= 0 ? '&' : '?') + suffix;
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* 小程序多应用管理
|
||||
*/
|
||||
|
||||
/** 获取小程序应用列表(AppSecret 不回显,返回 configured 标记) */
|
||||
export function listMiniApps() {
|
||||
return request({
|
||||
url: "/platform/wechatMini/apps",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/更新小程序应用
|
||||
* @param {Object} data
|
||||
* @param {number} [data.id] 更新时携带;新增不传
|
||||
* @param {string} data.name 应用名称
|
||||
* @param {string} data.app_id 小程序 AppID
|
||||
* @param {string} [data.app_secret] 掩码/空串 = 不修改原值
|
||||
* @param {number} data.enabled 1=启用 0=停用
|
||||
* @param {number} data.auto_bind 1=手机号命中身份后自动绑定
|
||||
* @param {string} [data.remark] 备注
|
||||
*/
|
||||
export function saveMiniApp(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/app/save",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除应用(软删并停用) */
|
||||
export function deleteMiniApp(id) {
|
||||
return request({
|
||||
url: `/platform/wechatMini/app/delete/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* 获取企业申请列表(platform 审核用)
|
||||
* @param {Object} params
|
||||
* @param {number} params.page 页码
|
||||
* @param {number} params.pageSize 每页数量
|
||||
* @param {number} params.type 类型筛选:1=加入企业 2=创建企业
|
||||
* @param {number} params.status 状态筛选:0=待审核 1=已通过 2=已驳回
|
||||
*/
|
||||
export function getTenantApplies(params) {
|
||||
return request({
|
||||
url: "/platform/tenantApply/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申请详情
|
||||
* @param {number|string} id 申请ID
|
||||
*/
|
||||
export function getTenantApplyDetail(id) {
|
||||
return request({
|
||||
url: `/platform/tenantApply/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过创建企业申请:自动创建租户并把申请人绑定为企业管理员
|
||||
* @param {number|string} id 申请ID
|
||||
*/
|
||||
export function approveTenantApply(id) {
|
||||
return request({
|
||||
url: `/platform/tenantApply/approve/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回申请
|
||||
* @param {number|string} id 申请ID
|
||||
* @param {{reason: string}} data 驳回原因
|
||||
*/
|
||||
export function rejectTenantApply(id, data) {
|
||||
return request({
|
||||
url: `/platform/tenantApply/reject/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,10 @@
|
||||
controls-position="right" />
|
||||
<div class="form-tip">默认 20 人;达到上限后需增购用户数才能继续开账号</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!formData.id" label="指定管理员" prop="admin_identifier">
|
||||
<el-input v-model="formData.admin_identifier" placeholder="选填:已有用户的账号或手机号" clearable />
|
||||
<div class="form-tip">填已有用户,创建后自动绑定为该租户的管理员(不新建账号、不修改其原密码);留空则租户暂无管理员</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
@@ -109,7 +113,8 @@ const initialData = {
|
||||
address: '',
|
||||
status: 1,
|
||||
package_id: undefined as number | undefined,
|
||||
user_quota: 20
|
||||
user_quota: 20,
|
||||
admin_identifier: '' as string
|
||||
};
|
||||
|
||||
const formData = reactive({ ...initialData });
|
||||
@@ -184,12 +189,30 @@ const submitForm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发起真正的保存请求
|
||||
const saveApi = formData.id ? editTenant(formData.id, formData) : createTenant(formData);
|
||||
// 3. 发起真正的保存请求(创建时把「指定管理员」拆成后端归并键字段)
|
||||
let payload: any = { ...formData };
|
||||
if (!formData.id) {
|
||||
const identifier = String(formData.admin_identifier || '').trim();
|
||||
delete payload.admin_identifier;
|
||||
if (identifier) {
|
||||
if (/^1[3-9]\d{9}$/.test(identifier)) {
|
||||
payload.admin_phone = identifier;
|
||||
} else {
|
||||
payload.admin_account = identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
const saveApi = formData.id ? editTenant(formData.id, formData) : createTenant(payload);
|
||||
const saveRes = await saveApi;
|
||||
|
||||
if (saveRes.code === 200) {
|
||||
ElMessage.success('保存成功');
|
||||
if (saveRes.data && saveRes.data.admin_bound) {
|
||||
ElMessage.success('保存成功,已绑定指定用户为租户管理员');
|
||||
} else if (saveRes.msg && saveRes.msg !== 'success') {
|
||||
ElMessage.warning(saveRes.msg);
|
||||
} else {
|
||||
ElMessage.success('保存成功');
|
||||
}
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="miniapp-settings">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>小程序应用</span>
|
||||
<el-button type="primary" size="small" @click="openAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增小程序
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="tip-alert"
|
||||
title="支持配置多个小程序。小程序端登录时按其 AppID 匹配对应配置;未传 AppID 时使用列表中第一条启用记录。"
|
||||
/>
|
||||
|
||||
<el-table :data="apps" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="应用名称"
|
||||
min-width="140"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.name || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="app_id"
|
||||
label="AppID"
|
||||
min-width="180"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="AppSecret" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.row.configured ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.row.configured ? "已配置" : "未配置" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="scope.row.enabled === 1"
|
||||
@change="(val) => toggleEnabled(scope.row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机号自动绑定" width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="scope.row.auto_bind === 1"
|
||||
@change="(val) => toggleAutoBind(scope.row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remark"
|
||||
label="备注"
|
||||
min-width="140"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="scope">
|
||||
{{ scope.row.remark || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="create_time"
|
||||
label="创建时间"
|
||||
width="170"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" link @click="openEdit(scope.row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="form.id ? '编辑小程序' : '新增小程序'"
|
||||
width="520px"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="应用名称" prop="name">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
placeholder="如:商城小程序"
|
||||
maxlength="64"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="AppID" prop="app_id">
|
||||
<el-input v-model="form.app_id" placeholder="wx 开头的小程序 AppID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppSecret" prop="app_secret">
|
||||
<el-input
|
||||
v-model="form.app_secret"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="
|
||||
form.id && secretConfigured
|
||||
? '已配置,留空 / 掩码表示不修改'
|
||||
: '小程序 AppSecret'
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">启用后小程序端才可使用微信一键登录</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号自动绑定">
|
||||
<el-switch v-model="form.auto_bind" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">手机号命中已有身份后自动完成绑定</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { listMiniApps, saveMiniApp, deleteMiniApp } from "@/api/miniApp";
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const apps = ref([]);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref(null);
|
||||
const secretConfigured = ref(false);
|
||||
|
||||
const emptyForm = () => ({
|
||||
id: 0,
|
||||
name: "",
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
enabled: 0,
|
||||
auto_bind: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
|
||||
app_id: [
|
||||
{ required: true, message: "请输入小程序 AppID", trigger: "blur" },
|
||||
{ pattern: /^wx[0-9a-zA-Z]{10,}$/, message: "AppID 一般以 wx 开头", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
const fetchApps = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listMiniApps();
|
||||
if (res.code === 200) {
|
||||
apps.value = res.data.list || [];
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取应用列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "获取应用列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
form.value = emptyForm();
|
||||
secretConfigured.value = false;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
form.value = {
|
||||
id: row.id,
|
||||
name: row.name || "",
|
||||
app_id: row.app_id || "",
|
||||
app_secret: "",
|
||||
enabled: row.enabled,
|
||||
auto_bind: row.auto_bind,
|
||||
remark: row.remark || "",
|
||||
};
|
||||
secretConfigured.value = !!row.configured;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await saveMiniApp({ ...form.value });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "保存成功");
|
||||
dialogVisible.value = false;
|
||||
fetchApps();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 快捷开关:直接以当前值保存
|
||||
const toggleEnabled = async (row, val) => {
|
||||
await quickToggle(row, "enabled", val);
|
||||
};
|
||||
|
||||
const toggleAutoBind = async (row, val) => {
|
||||
await quickToggle(row, "auto_bind", val);
|
||||
};
|
||||
|
||||
const quickToggle = async (row, field, val) => {
|
||||
try {
|
||||
const res = await saveMiniApp({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
app_id: row.app_id,
|
||||
app_secret: "",
|
||||
enabled: field === "enabled" ? (val ? 1 : 0) : row.enabled,
|
||||
auto_bind: field === "auto_bind" ? (val ? 1 : 0) : row.auto_bind,
|
||||
remark: row.remark || "",
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已更新");
|
||||
fetchApps();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "更新失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除小程序「${row.name || row.app_id}」?删除后该小程序端将无法使用微信一键登录。`,
|
||||
"删除应用",
|
||||
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await deleteMiniApp(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已删除");
|
||||
fetchApps();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchApps();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.miniapp-settings {
|
||||
.settings-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #8a94ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -410,44 +410,6 @@
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 小程序一键登录配置 -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>微信小程序(一键登录)配置</span>
|
||||
<el-tag v-if="Number(miniCfg.enabled) === 1 && miniCfg.configured" type="success" size="small">已启用</el-tag>
|
||||
<el-tag v-else type="info" size="small" class="ml8">未启用:小程序端仅账号密码登录</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="miniCfg.enabled" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip-inline">开启后 uniapp 小程序端登录页展示「微信手机号一键登录」主入口</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="AppID" required>
|
||||
<el-input v-model="miniCfg.app_id" placeholder="小程序后台「开发管理 → 开发设置」中的 AppID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppSecret">
|
||||
<el-input v-model="miniCfg.app_secret" type="password" show-password placeholder="已保存时显示掩码,保持不变则无需修改" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="自动绑定">
|
||||
<el-switch v-model="miniCfg.auto_bind" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip-inline">开启后未通过手机号验证的 openid 也会自动绑定账号;关闭时仅在手机号验证通过后建立绑定</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="miniCfg.remark" placeholder="仅后台可见" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="miniSaving" @click="saveMiniConfig">保存配置</el-button>
|
||||
<div class="form-tip">
|
||||
① 此处 AppID 必须与 uniapp manifest.json「mp-weixin.appid」一致,否则 code 换 openid 会报 40029;<br />
|
||||
② 「手机号一键登录」需小程序为已认证企业主体,并在小程序后台开通「手机号快速验证」能力;<br />
|
||||
③ 保存并启用后,小程序端登录页自动切换为微信认证登录优先、账号密码其次。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 绑定我的微信 -->
|
||||
<!-- <el-card shadow="never" class="settings-card bind-card">
|
||||
<template #header>
|
||||
@@ -626,6 +588,19 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 小程序配置(支持多个小程序应用) -->
|
||||
<el-tab-pane name="miniapp">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Cellphone /></el-icon>
|
||||
<span>小程序配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<miniAppSettings />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
@@ -661,8 +636,6 @@ import { formatTime } from "@/utils/datetime";
|
||||
import {
|
||||
getWechatMpConfig,
|
||||
saveWechatMpConfig,
|
||||
getWechatMiniConfig,
|
||||
saveWechatMiniConfig,
|
||||
testWechatMpConnection,
|
||||
publishWechatMpMenu,
|
||||
uploadWechatFollowQrcode,
|
||||
@@ -678,6 +651,7 @@ import {
|
||||
pushWechatMpMessage,
|
||||
pushWechatMpTest
|
||||
} from "@/api/wechatMp";
|
||||
import miniAppSettings from "./miniAppSettings.vue";
|
||||
|
||||
const STORAGE_KEY = "notification_settings_draft";
|
||||
const activeSubTab = ref("email");
|
||||
@@ -1089,56 +1063,6 @@ const saveWechatConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ==================== 微信小程序(一键登录) ==================== */
|
||||
|
||||
const miniCfg = reactive({
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
enabled: 0,
|
||||
auto_bind: 1,
|
||||
remark: "",
|
||||
configured: false,
|
||||
});
|
||||
const miniSaving = ref(false);
|
||||
|
||||
const loadMiniConfig = async () => {
|
||||
try {
|
||||
const res = await getWechatMiniConfig();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(miniCfg, res.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载小程序配置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMiniConfig = async () => {
|
||||
if (!miniCfg.app_id) {
|
||||
ElMessage.warning("请先填写 AppID");
|
||||
return;
|
||||
}
|
||||
miniSaving.value = true;
|
||||
try {
|
||||
const res = await saveWechatMiniConfig({
|
||||
app_id: miniCfg.app_id,
|
||||
app_secret: miniCfg.app_secret,
|
||||
enabled: Number(miniCfg.enabled) === 1 ? 1 : 0,
|
||||
auto_bind: Number(miniCfg.auto_bind) === 1 ? 1 : 0,
|
||||
remark: miniCfg.remark,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("小程序配置保存成功");
|
||||
await loadMiniConfig();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
miniSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 关注二维码上传/删除(存 data URL,供租户端「绑定微信」页展示扫码)
|
||||
const followQrcodeUploading = ref(false);
|
||||
const handleFollowQrcodeUpload = async ({ file }) => {
|
||||
@@ -1434,7 +1358,6 @@ onMounted(() => {
|
||||
loadSiteReminderConfig();
|
||||
loadBarkConfig();
|
||||
loadWechatConfig();
|
||||
loadMiniConfig();
|
||||
loadWechatBinding();
|
||||
});
|
||||
|
||||
@@ -1442,7 +1365,6 @@ onMounted(() => {
|
||||
watch(activeSubTab, (tab) => {
|
||||
if (tab === "wechat") {
|
||||
loadWechatConfig();
|
||||
loadMiniConfig();
|
||||
loadWechatBinding();
|
||||
loadFollowers();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<div class="tenant-apply">
|
||||
<div class="header-bar">
|
||||
<h2>企业创建审核</h2>
|
||||
<div>
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<el-form-item label="申请类型">
|
||||
<el-select
|
||||
v-model="filterForm.type"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="创建企业" :value="2" />
|
||||
<el-option label="加入企业" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filterForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已驳回" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 申请列表 -->
|
||||
<el-table :data="applies" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column label="申请类型" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.row.type === 2 ? 'primary' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.row.type === 2 ? "创建企业" : "加入企业" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请人" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.identity_nickname || "-" }}</div>
|
||||
<div class="sub-text">{{ scope.row.identity_mobile || "" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="tenant_name"
|
||||
label="企业名称"
|
||||
min-width="170"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="credit_code"
|
||||
label="统一社会信用代码"
|
||||
width="190"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="legal_person"
|
||||
label="法定代表人"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="联系人" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.contact_person || "-" }}</div>
|
||||
<div class="sub-text">{{ scope.row.contact_phone || "" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small">
|
||||
{{ statusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" link @click="handleDetail(scope.row)">
|
||||
详情
|
||||
</el-button>
|
||||
<template v-if="scope.row.type === 2 && scope.row.status === 0">
|
||||
<el-button size="small" type="success" link @click="handleApprove(scope.row)">
|
||||
通过
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleReject(scope.row)">
|
||||
驳回
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="申请详情" width="640px">
|
||||
<el-descriptions v-if="current" :column="2" border>
|
||||
<el-descriptions-item label="申请类型" :span="2">
|
||||
{{ current.type === 2 ? "创建企业" : "加入企业" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">
|
||||
{{ current.identity_nickname || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人手机号">
|
||||
{{ current.identity_mobile || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="企业名称" :span="2">
|
||||
{{ current.tenant_name || "-" }}
|
||||
</el-descriptions-item>
|
||||
<template v-if="current.type === 2">
|
||||
<el-descriptions-item label="统一社会信用代码">
|
||||
{{ current.credit_code || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="法定代表人">
|
||||
{{ current.legal_person || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="营业执照" :span="2">
|
||||
<template v-if="current.license_file">
|
||||
<el-image
|
||||
v-if="isImage(current.license_file)"
|
||||
:src="current.license_file"
|
||||
:preview-src-list="[current.license_file]"
|
||||
fit="cover"
|
||||
style="width: 120px; height: 90px; border-radius: 4px"
|
||||
/>
|
||||
<el-link
|
||||
v-else
|
||||
type="primary"
|
||||
:href="current.license_file"
|
||||
target="_blank"
|
||||
>
|
||||
查看(PDF)
|
||||
</el-link>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="联系人">
|
||||
{{ current.contact_person || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">
|
||||
{{ current.contact_phone || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="电子邮箱">
|
||||
{{ current.contact_email || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="公司地址">
|
||||
{{ current.address || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
{{ statusText(current.status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="申请时间">
|
||||
{{ formatDateTime(current.create_time) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核人">
|
||||
{{ current.reviewer_name || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核时间">
|
||||
{{ current.review_time ? formatDateTime(current.review_time) : "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="current.status === 2"
|
||||
label="驳回原因"
|
||||
:span="2"
|
||||
>
|
||||
{{ current.reject_reason || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<template v-if="current && current.type === 2 && current.status === 0">
|
||||
<el-button type="success" @click="handleApprove(current); detailVisible = false">
|
||||
通过
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleReject(current); detailVisible = false">
|
||||
驳回
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getTenantApplies,
|
||||
getTenantApplyDetail,
|
||||
approveTenantApply,
|
||||
rejectTenantApply,
|
||||
} from "@/api/tenantApply";
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const applies = ref([]);
|
||||
const detailVisible = ref(false);
|
||||
const current = ref(null);
|
||||
|
||||
const filterForm = ref({
|
||||
type: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const statusText = (status) => {
|
||||
const map = { 0: "待审核", 1: "已通过", 2: "已驳回" };
|
||||
return map[status] || "未知";
|
||||
};
|
||||
|
||||
const statusTagType = (status) => {
|
||||
const map = { 0: "warning", 1: "success", 2: "danger" };
|
||||
return map[status] || "info";
|
||||
};
|
||||
|
||||
const isImage = (url) => /\.(png|jpe?g|gif|webp|bmp)(\?.*)?$/i.test(url || "");
|
||||
|
||||
// 获取申请列表
|
||||
const fetchApplies = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (filterForm.value.type !== "") params.type = filterForm.value.type;
|
||||
if (filterForm.value.status !== "") params.status = filterForm.value.status;
|
||||
const res = await getTenantApplies(params);
|
||||
if (res.code === 200) {
|
||||
applies.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取申请列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "获取申请列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
filterForm.value = { type: "", status: "" };
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handleSizeChange = () => {
|
||||
page.value = 1;
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchApplies();
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleDetail = async (row) => {
|
||||
current.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
try {
|
||||
const res = await getTenantApplyDetail(row.id);
|
||||
if (res.code === 200 && res.data) {
|
||||
current.value = { ...current.value, ...res.data };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取申请详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 通过申请:创建租户并绑定申请人为企业管理员
|
||||
const handleApprove = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认通过「${row.tenant_name}」的创建企业申请?通过后将自动创建租户,申请人(${row.identity_nickname || row.identity_mobile})会成为该企业的管理员账号,不可更改。`,
|
||||
"通过申请",
|
||||
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await approveTenantApply(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已通过");
|
||||
fetchApplies();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 驳回申请(需填写原因)
|
||||
const handleReject = async (row) => {
|
||||
let reason = "";
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt("请输入驳回原因", `驳回「${row.tenant_name}」的申请`, {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
inputPlaceholder: "驳回原因(可选)",
|
||||
inputValidator: (v) => {
|
||||
if (v && v.length > 200) return "原因不能超过200字";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
reason = value || "";
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rejectTenantApply(row.id, { reason });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已驳回");
|
||||
fetchApplies();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tenant-apply {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sub-text {
|
||||
color: #8a94ad;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user