批量优化

This commit is contained in:
hero920103 committed 2026-10-08 15:30:37 +08:00
1 parent 438145cae7
commit c925b198d1
50 files changed
+5716 -291

No files matched your search

+27
View File
@@ -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
View File
@@ -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,
},
});
}
+166 -32
View File
@@ -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();
+428
View File
@@ -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", ""),
})
}
+3
View File
@@ -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"
}
+18 -7
View File
@@ -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
+109 -24
View File
@@ -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": "修改成功"}
+38 -2
View File
@@ -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)
}
+27 -16
View File
@@ -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)
}
}
+51
View File
@@ -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": "获取成功",
+188
View File
@@ -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()
}
+4
View File
@@ -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"` // 超时小时
+131 -32
View File
@@ -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()
}
+215
View File
@@ -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()
}
+15 -11
View File
@@ -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"}
+88 -1
View File
@@ -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';
+92
View File
@@ -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;
+37
View File
@@ -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;
+17
View File
@@ -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
}
+1
View File
@@ -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"`
+47
View File
@@ -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"
}
+4
View File
@@ -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),
+18
View File
@@ -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" }
+15
View File
@@ -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")
+6 -1
View File
@@ -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 注册租户端「审批流配置」路由。
+6
View File
@@ -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")
+4
View File
@@ -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")
}
+56 -1
View File
@@ -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
+2 -2
View File
@@ -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
+49 -8
View File
@@ -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)
}
+348
View File
@@ -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})
}
}
+700
View File
@@ -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,
})
}
+20
View File
@@ -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 按租户套餐包含的功能模块过滤菜单:
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
+198 -43
View File
@@ -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
}
+22
View File
@@ -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 == "" {
+190
View File
@@ -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
}
+436
View File
@@ -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>
+25
View File
@@ -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>
+571
View File
@@ -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, '&lt;') + '</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>
+40
View File
@@ -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",
});
}
+52
View File
@@ -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();
}
+424
View File
@@ -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>