Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377429995c | ||
|
|
f6744d3f91 | ||
|
|
692d71696a | ||
|
|
ec9252bd3d | ||
|
|
dd291868fb | ||
|
|
b38056f1d0 | ||
|
|
34b9ea1169 | ||
|
|
88f4176e60 | ||
|
|
a3bd1e8a95 |
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 1 完成报告:角色权限与范围基线
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
- 新增 `docs/v12/V1.2-角色权限与前端入口矩阵.md`。
|
||||||
|
- 冻结 14 个正式角色的数据范围、工作台入口、允许操作和禁止操作。
|
||||||
|
- 明确普通红娘为 SELF、红娘组长为 TEAM、平台/超级管理员为 ALL。
|
||||||
|
- 明确五类工作台业务卡片的查看及审核、待办分配、确认、完成权限,同时保留红娘不得代用户 Like/Pass、不得查看身份证原始佐证和咨询敏感备注等边界。
|
||||||
|
- 说明 V070/V071 是历史收敛迁移,不能覆盖 2026-10-25 最新裁决,后续通过幂等迁移恢复正式最小权限。
|
||||||
|
- 修改 `docs/governance/decision-register.md`,登记 V070/V071 冲突裁决。
|
||||||
|
- 新增 `backend/src/test/java/com/danyang/match/platform/deployment/PermissionBaselineContractTest.java`,锁定角色、范围、操作、敏感权限和历史迁移裁决。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=PermissionBaselineContractTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## 证据边界与风险
|
||||||
|
|
||||||
|
- 本任务未修改 V070/V071 历史迁移,也未直接修改数据库;活跃 PostgreSQL 角色权限/版本查询留给活栈验收任务。
|
||||||
|
- 矩阵锁定的是产品权限与数据范围契约,实际恢复权限需要后续 Task 5 的幂等迁移并通过干净数据库和活栈验证。
|
||||||
|
- 未运行全量 Maven、前端构建或 E2E;这些属于后续任务门禁。
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Task 2 完成报告
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 工作台六项指标先按各自 `detailPermission` 判断;无权限仅返回 `available=false` 与权限标识,不执行统计查询或返回真实值。
|
||||||
|
- 授权指标继续返回真实统计值,空数据保持 `0`。
|
||||||
|
- `matchmaker_leader` 未绑定组织时返回 `CROSS_ORGANIZATION_DENIED`。
|
||||||
|
- 团队范围使用严格 `organization_id = ?`,不再把 NULL 组织视为同一团队。
|
||||||
|
- 统计统一排除没有已发布资料 revision 的用户。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=MatchmakerWorkbenchAuthorizationTest,MatchmakerWorkbenchScopeTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:3 个测试通过,0 失败,0 错误。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
|
||||||
@@ -156,7 +156,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
import { summarizeMatchmakerServices } from '@/services/dashboard'
|
|
||||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -218,6 +217,7 @@ const navigation: NavigationItem[] = [
|
|||||||
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
|
||||||
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
|
||||||
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
|
||||||
|
{ label: '红娘团队管理', path: '/matchmaker-team', permissions: ['matchmaker.role.manage'], icon: Connection },
|
||||||
]
|
]
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -234,12 +234,10 @@ watch(() => route.fullPath, async () => {
|
|||||||
const perms = authStore.permissions
|
const perms = authStore.permissions
|
||||||
if (authStore.token === 'ui-preview-only') return
|
if (authStore.token === 'ui-preview-only') return
|
||||||
try {
|
try {
|
||||||
if (perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')) {
|
if (perms.includes('workbench.view')) {
|
||||||
const response: any = await get('/operator/matchmaker/management/services')
|
const response: any = await get('/operator/workbench/dashboard')
|
||||||
if (response.success && Array.isArray(response.data)) todayStats.value = summarizeMatchmakerServices(response.data)
|
const value = response.data?.metrics?.matchmakerTodos?.value
|
||||||
} else if (perms.includes('matchmaker.view')) {
|
if (response.success && typeof value === 'number') todayStats.value = { total: value, pending: value }
|
||||||
const response: any = await get('/operator/matchmaker/dashboard')
|
|
||||||
if (response.success) todayStats.value = { total: response.data.todayCount, pending: response.data.pendingCount + response.data.inProgressCount }
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
todayStats.value = null
|
todayStats.value = null
|
||||||
@@ -256,6 +254,13 @@ const permissions = computed(() => authStore.permissions || [])
|
|||||||
const currentRoleName = computed(() => {
|
const currentRoleName = computed(() => {
|
||||||
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
|
||||||
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
|
||||||
|
const role = operator.value?.roles?.[0]
|
||||||
|
const roleNames: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员', platform_admin: '平台管理员', matchmaker_leader: '红娘组长', matchmaker: '公益红娘',
|
||||||
|
reviewer: '资料审核人员', counselor: '公益咨询人员', counseling_manager: '咨询管理员', service_station_operator: '服务站运营人员',
|
||||||
|
content_editor: '内容编辑员', risk_operator: '风控运营人员',
|
||||||
|
}
|
||||||
|
if (role && roleNames[role]) return roleNames[role]
|
||||||
const perms = permissions.value
|
const perms = permissions.value
|
||||||
if (perms.includes('permission.manage')) return '平台管理员'
|
if (perms.includes('permission.manage')) return '平台管理员'
|
||||||
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
|
||||||
@@ -264,10 +269,15 @@ const currentRoleName = computed(() => {
|
|||||||
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
if (perms.includes('counseling.service.view')) return '公益咨询人员'
|
||||||
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
if (perms.includes('service_appointment.view')) return '服务站运营人员'
|
||||||
if (perms.includes('review.view')) return '资料审核人员'
|
if (perms.includes('review.view')) return '资料审核人员'
|
||||||
|
if (perms.includes('matchmaker.team.assign')) return '红娘组长'
|
||||||
if (perms.includes('matchmaker.view')) return '公益红娘'
|
if (perms.includes('matchmaker.view')) return '公益红娘'
|
||||||
return '运营人员'
|
return '运营人员'
|
||||||
})
|
})
|
||||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
const visibleNavigation = computed(() => navigation.filter((item) => {
|
||||||
|
const adminRole = operator.value?.roles?.some((role) => ['super_admin', 'platform_admin'].includes(role))
|
||||||
|
if ((item.path === '/referral' || item.path === '/matchmaker') && (permissions.value.includes('permission.manage') || adminRole)) return false
|
||||||
|
return item.permissions.every(hasPerm)
|
||||||
|
}))
|
||||||
|
|
||||||
function hasPerm(permission: string) {
|
function hasPerm(permission: string) {
|
||||||
return permissions.value.includes(permission)
|
return permissions.value.includes(permission)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="header-row">
|
||||||
|
<span>红娘团队管理</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-select v-model="status" clearable placeholder="全部状态" style="width:120px" @change="load">
|
||||||
|
<el-option label="正常" value="normal" /><el-option label="停用" value="suspended" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="keyword" clearable placeholder="姓名或用户名" style="width:180px" @keyup.enter="load" />
|
||||||
|
<el-button type="primary" @click="openCreate">新增红娘</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-alert v-if="error" type="error" :closable="false" :title="error" />
|
||||||
|
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无红娘团队成员">
|
||||||
|
<el-table-column prop="username" label="用户名" width="150" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="120" />
|
||||||
|
<el-table-column label="角色" width="130"><template #default="{ row }">{{ roleName(row.roles?.[0]) }}</template></el-table-column>
|
||||||
|
<el-table-column label="数据范围" width="110"><template #default="{ row }">{{ scopeName(row.scope) }}</template></el-table-column>
|
||||||
|
<el-table-column label="所属组织" min-width="180"><template #default="{ row }">{{ orgMap[row.organizationId] || '未绑定' }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'normal' ? 'success' : 'danger'">{{ row.status === 'normal' ? '正常' : '停用' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="300" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
|
||||||
|
<el-button size="small" :type="row.status === 'normal' ? 'warning' : 'success'" @click="toggleStatus(row)">{{ row.status === 'normal' ? '停用' : '启用' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-if="total" v-model:current-page="page" style="margin-top:16px;text-align:right" layout="total, prev, pager, next" :page-size="pageSize" :total="total" @current-change="load" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editing ? '编辑红娘团队成员' : '新增红娘团队成员'" width="520px">
|
||||||
|
<el-form label-width="92px">
|
||||||
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="角色"><el-radio-group v-model="form.roleCode"><el-radio value="matchmaker">普通红娘</el-radio><el-radio value="matchmaker_leader">红娘组长</el-radio></el-radio-group></el-form-item>
|
||||||
|
<el-form-item label="所属组织" required><el-select v-model="form.organizationId" clearable style="width:100%"><el-option v-for="org in organizations" :key="org.id" :label="org.name" :value="org.id" /></el-select></el-form-item>
|
||||||
|
<el-form-item v-if="!editing" label="初始密码"><el-input v-model="form.password" type="password" show-password /></el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" :title="`数据范围:${scopeName(form.roleCode === 'matchmaker_leader' ? 'TEAM' : 'SELF')}`" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const organizations = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const keyword = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editing = ref<any | null>(null)
|
||||||
|
const form = ref({ name: '', roleCode: 'matchmaker', organizationId: '', password: '' })
|
||||||
|
const orgMap = computed(() => Object.fromEntries(organizations.value.map((org) => [org.id, org.name])))
|
||||||
|
|
||||||
|
function roleName(role: string) { return role === 'matchmaker_leader' ? '红娘组长' : '普通红娘' }
|
||||||
|
function scopeName(scope: string) { return scope === 'TEAM' ? '团队范围' : scope === 'ALL' ? '全部范围' : '本人范围' }
|
||||||
|
async function load() {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const res: any = await get('/operator/matchmaker-team/members', { page: page.value - 1, pageSize, keyword: keyword.value || undefined, status: status.value || undefined })
|
||||||
|
items.value = res.data?.items || []; total.value = res.data?.total || 0
|
||||||
|
} catch (e: any) { error.value = e?.response?.data?.message || '红娘团队数据加载失败' } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function loadOrganizations() {
|
||||||
|
const res: any = await get('/operator/operators/organizations')
|
||||||
|
organizations.value = res.data || []
|
||||||
|
}
|
||||||
|
function openCreate() { editing.value = null; form.value = { name: '', roleCode: 'matchmaker', organizationId: '', password: '' }; dialogVisible.value = true }
|
||||||
|
function openEdit(row: any) { editing.value = row; form.value = { name: row.name, roleCode: row.roles?.[0] || 'matchmaker', organizationId: row.organizationId || '', password: '' }; dialogVisible.value = true }
|
||||||
|
async function save() {
|
||||||
|
if (!form.value.name.trim()) return ElMessage.warning('请填写红娘姓名')
|
||||||
|
if (form.value.roleCode === 'matchmaker_leader' && !form.value.organizationId) return ElMessage.warning('红娘组长必须选择所属组织')
|
||||||
|
if (!editing.value && form.value.password.length < 8) return ElMessage.warning('初始密码至少8位')
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: any = { name: form.value.name, roleCode: form.value.roleCode, organizationId: form.value.organizationId || null }
|
||||||
|
if (!editing.value) Object.assign(payload, { password: form.value.password })
|
||||||
|
const res: any = editing.value ? await put(`/operator/matchmaker-team/members/${editing.value.id}`, payload) : await post('/operator/matchmaker-team/members', payload)
|
||||||
|
if (!res.success) throw new Error(res.message || '保存失败')
|
||||||
|
dialogVisible.value = false; ElMessage.success('保存成功'); await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.response?.data?.message || e.message || '保存失败') } finally { saving.value = false }
|
||||||
|
}
|
||||||
|
async function toggleStatus(row: any) {
|
||||||
|
await put(`/operator/matchmaker-team/members/${row.id}/${row.status === 'normal' ? 'disable' : 'enable'}`)
|
||||||
|
ElMessage.success('状态已更新'); await load()
|
||||||
|
}
|
||||||
|
async function resetPassword(row: any) {
|
||||||
|
const value = await ElMessageBox.prompt('请输入新密码(至少8位)', '重置密码', { inputType: 'password', inputValidator: (v) => v.length >= 8 || '密码至少8位' })
|
||||||
|
await put(`/operator/matchmaker-team/members/${row.id}/reset-password`, { newPassword: value.value })
|
||||||
|
ElMessage.success('密码已重置')
|
||||||
|
}
|
||||||
|
onMounted(async () => { await loadOrganizations(); await load() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-row,.header-actions{display:flex;align-items:center}.header-row{justify-content:space-between}.header-actions{gap:8px}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,14 @@
|
|||||||
<el-table-column prop="username" label="用户名" width="140" />
|
<el-table-column prop="username" label="用户名" width="140" />
|
||||||
<el-table-column prop="displayId" label="人员编号" width="150" />
|
<el-table-column prop="displayId" label="人员编号" width="150" />
|
||||||
<el-table-column prop="name" label="姓名" width="120" />
|
<el-table-column prop="name" label="姓名" width="120" />
|
||||||
|
<el-table-column label="角色" min-width="190">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag v-for="role in row.roles || []" :key="role" size="small" style="margin-right:4px">{{ roleName(role) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据范围" width="110">
|
||||||
|
<template #default="{row}">{{ scopeName(row.scope) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -26,8 +34,9 @@
|
|||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200">
|
<el-table-column label="操作" width="270">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="openEdit(row)">角色权限</el-button>
|
||||||
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
|
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
|
||||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
||||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
||||||
@@ -41,15 +50,23 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增对话框 -->
|
<!-- 新增对话框 -->
|
||||||
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
|
<el-dialog v-model="formVisible" title="新增工作人员" width="560px">
|
||||||
<el-form :model="form" label-width="100px">
|
<el-form :model="form" label-width="100px">
|
||||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
<el-form-item label="所属机构">
|
<el-form-item label="所属机构">
|
||||||
<el-select v-model="form.organizationId" style="width:100%">
|
<el-select v-model="form.organizationId" clearable style="width:100%">
|
||||||
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-checkbox-group v-model="form.roleCodes">
|
||||||
|
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert v-if="selectedPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||||
|
{{ selectedPermissions.join('、') }}
|
||||||
|
</el-alert>
|
||||||
<el-form-item label="密码">
|
<el-form-item label="密码">
|
||||||
<el-input v-model="form.password" type="password" show-password />
|
<el-input v-model="form.password" type="password" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -59,12 +76,35 @@
|
|||||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="editVisible" title="调整角色与数据范围" width="560px">
|
||||||
|
<el-form :model="editForm" label-width="100px">
|
||||||
|
<el-form-item label="账号">{{ editForm.name }}({{ editForm.username }})</el-form-item>
|
||||||
|
<el-form-item label="所属机构">
|
||||||
|
<el-select v-model="editForm.organizationId" clearable style="width:100%">
|
||||||
|
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-checkbox-group v-model="editForm.roleCodes">
|
||||||
|
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert v-if="editPermissions.length" type="info" :closable="false" title="将继承以下权限">
|
||||||
|
{{ editPermissions.join('、') }}
|
||||||
|
</el-alert>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getAccessToken } from '@/utils/token'
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get, post, put } from '@/api/client'
|
import { get, post, put } from '@/api/client'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -74,7 +114,10 @@ const page = ref(0)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const form = ref({ username: '', name: '', organizationId: '', password: '' })
|
const form = ref({ username: '', name: '', organizationId: '', password: '', roleCodes: [] as string[] })
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editForm = ref({ id: '', username: '', name: '', organizationId: '', roleCodes: [] as string[] })
|
||||||
|
const roleTemplates = ref<any[]>([])
|
||||||
const orgList = ref<any[]>([])
|
const orgList = ref<any[]>([])
|
||||||
const orgMap = ref<Record<string, string>>({})
|
const orgMap = ref<Record<string, string>>({})
|
||||||
|
|
||||||
@@ -83,12 +126,20 @@ async function loadData() {
|
|||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||||
tableData.value = res.data?.list || []
|
tableData.value = res.data?.items || res.data?.list || []
|
||||||
total.value = res.data?.total || 0
|
total.value = res.data?.total || 0
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
const res: any = await get('/operator/operators/roles', {}, { Authorization: `Bearer ${token}` })
|
||||||
|
roleTemplates.value = res.data || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOrgs() {
|
async function loadOrgs() {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
@@ -99,7 +150,7 @@ async function loadOrgs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
form.value = { username: '', name: '', organizationId: '', password: '' }
|
form.value = { username: '', name: '', organizationId: '', password: '', roleCodes: ['matchmaker'] }
|
||||||
formVisible.value = true
|
formVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +159,8 @@ async function save() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
|
if (!form.value.roleCodes.length) return
|
||||||
|
if (form.value.roleCodes.includes('matchmaker_leader') && !form.value.organizationId) return
|
||||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||||
formVisible.value = false
|
formVisible.value = false
|
||||||
loadData()
|
loadData()
|
||||||
@@ -115,6 +168,29 @@ async function save() {
|
|||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(row: any) {
|
||||||
|
editForm.value = { id: row.id, username: row.username, name: row.name, organizationId: row.organizationId || '', roleCodes: [...(row.roles || [])] }
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editForm.value.roleCodes.length) return
|
||||||
|
if (editForm.value.roleCodes.includes('matchmaker_leader') && !editForm.value.organizationId) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const token = getAccessToken()
|
||||||
|
await put(`/operator/operators/${editForm.value.id}/roles`, { roleCodes: editForm.value.roleCodes, organizationId: editForm.value.organizationId || null }, { Authorization: `Bearer ${token}` })
|
||||||
|
editVisible.value = false
|
||||||
|
await loadData()
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleName(code: string) { return roleTemplates.value.find(r => r.code === code)?.name || code }
|
||||||
|
function scopeName(scope: string) { return scope === 'ALL' ? '全平台' : scope === 'TEAM' ? '本组织' : '本人' }
|
||||||
|
const selectedPermissions = computed(() => [...new Set(form.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||||
|
const editPermissions = computed(() => [...new Set(editForm.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
|
||||||
|
|
||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
@@ -136,7 +212,7 @@ async function resetPwd(id: string) {
|
|||||||
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
function handlePageChange(p: number) { page.value = p - 1; loadData() }
|
||||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||||
|
|
||||||
onMounted(() => { loadData(); loadOrgs() })
|
onMounted(() => { loadData(); loadOrgs(); loadRoles() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<el-table-column prop="contactName" label="联系人" width="120" />
|
<el-table-column prop="contactName" label="联系人" width="120" />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'已停用' }}</el-tag>
|
<el-tag :type="row.status==='active'?'success':'danger'">{{ row.status==='active'?'正常':'已停用' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
|
<el-button size="small" :type="row.status==='active'?'warning':'success'"
|
||||||
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
|
@click="toggleStatus(row)">{{ row.status==='active'?'停用':'启用' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -40,10 +40,11 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="机构类型">
|
<el-form-item label="机构类型">
|
||||||
<el-select v-model="form.type" style="width:100%">
|
<el-select v-model="form.type" style="width:100%">
|
||||||
<el-option label="妇联" value="fulian" />
|
<el-option label="妇联" value="women_federation" />
|
||||||
<el-option label="工会" value="gonghui" />
|
<el-option label="工会" value="trade_union" />
|
||||||
<el-option label="公益服务中心" value="public_service" />
|
<el-option label="公益服务中心" value="charity_center" />
|
||||||
<el-option label="志愿者团队" value="volunteer" />
|
<el-option label="志愿者团队" value="volunteer_team" />
|
||||||
|
<el-option label="民政合作单位" value="civil_affairs_partner" />
|
||||||
<el-option label="其他合作机构" value="other" />
|
<el-option label="其他合作机构" value="other" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -72,8 +73,8 @@ const form = ref({ name: '', type: '', contactName: '' })
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
const ORG_TYPE_TEXT: Record<string, string> = {
|
const ORG_TYPE_TEXT: Record<string, string> = {
|
||||||
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
|
women_federation: '妇联', trade_union: '工会', charity_center: '公益服务中心',
|
||||||
volunteer: '志愿者团队', other: '其他合作机构'
|
volunteer_team: '志愿者团队', civil_affairs_partner: '民政合作单位', other: '其他合作机构'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -88,7 +89,7 @@ async function loadData() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editId.value = ''
|
editId.value = ''
|
||||||
form.value = { name: '', type: 'fulian', contactName: '' }
|
form.value = { name: '', type: 'women_federation', contactName: '' }
|
||||||
formVisible.value = true
|
formVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ async function save() {
|
|||||||
async function toggleStatus(row: any) {
|
async function toggleStatus(row: any) {
|
||||||
try {
|
try {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
const action = row.status === 'active' ? 'disable' : 'enable'
|
||||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container page-shell">
|
||||||
|
<header class="page-heading"><div><h1>后台建档</h1><p>建立草稿档案,手机号核验和资料审核仍需独立完成。</p></div></header>
|
||||||
|
<section class="surface-card form-card">
|
||||||
|
<el-form label-position="top" @submit.prevent="submit">
|
||||||
|
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||||
|
<el-form-item label="所属组织"><el-input v-model="form.organization" /></el-form-item>
|
||||||
|
<el-form-item label="组织联系电话"><el-input v-model="form.organizationPhone" /></el-form-item>
|
||||||
|
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="submit">创建草稿</el-button>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { post } from '@/api/client'
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ name: '', organization: '', organizationPhone: '', phone: '' })
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try { await post('/operator/users', form); ElMessage.success('草稿档案已创建'); Object.assign(form, { name: '', organization: '', organizationPhone: '', phone: '' }) }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>.page-container{padding:28px}.form-card{max-width:620px;padding:24px}</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container page-shell">
|
||||||
|
<header class="page-heading"><div><h1>CSV 批量建档</h1><p>仅支持 UTF-8 CSV;预览不会写入用户表。</p></div></header>
|
||||||
|
<section class="surface-card form-card">
|
||||||
|
<input type="file" accept=".csv,text/csv" @change="onFile" />
|
||||||
|
<el-button type="primary" :loading="loading" :disabled="!fileText" @click="preview">预览</el-button>
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||||
|
<el-table v-if="result" :data="result.rows" class="preview-table"><el-table-column prop="rowNumber" label="行号" width="80" /><el-table-column prop="status" label="状态" /><el-table-column prop="error" label="错误" /></el-table>
|
||||||
|
<el-button v-if="result && result.validRows" type="success" :loading="committing" @click="commit">提交 {{ result.validRows }} 行</el-button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { post } from '@/api/client'
|
||||||
|
const fileText = ref(''); const result = ref<any>(null); const error = ref(''); const loading = ref(false); const committing = ref(false)
|
||||||
|
function onFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { fileText.value = String(reader.result || '') }; reader.readAsText(file, 'UTF-8') }
|
||||||
|
async function preview() { loading.value = true; error.value = ''; try { const response: any = await post('/operator/users/import/preview', fileText.value, { 'Content-Type': 'text/csv' }); result.value = response.data } catch (e: any) { error.value = e.response?.data?.message || '预览失败' } finally { loading.value = false } }
|
||||||
|
async function commit() { if (!result.value?.batchId) return; committing.value = true; try { await post('/operator/users/import/commit', { batchId: result.value.batchId, idempotencyKey: crypto.randomUUID() }); ElMessage.success('导入提交成功'); result.value = null } finally { committing.value = false } }
|
||||||
|
</script>
|
||||||
|
<style scoped>.page-container{padding:28px}.form-card{padding:24px;display:grid;gap:16px}.preview-table{margin-top:12px}</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<h1>用户管理</h1>
|
<h1>用户管理</h1>
|
||||||
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
|
<div class="heading-actions"><el-button v-if="canCreate" type="primary" @click="router.push('/users/create')">后台建档</el-button><el-button v-if="canCreate" @click="router.push('/users/import')">CSV导入</el-button><el-tag type="info" effect="plain">共 {{ total }} 人</el-tag></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="surface-card filter-panel" aria-label="用户筛选">
|
<section class="surface-card filter-panel" aria-label="用户筛选">
|
||||||
@@ -101,12 +101,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const canCreate = computed(() => authStore.permissions.includes('user.create'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
|
||||||
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
|
||||||
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
|
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
|
||||||
|
{ path: 'users/create', name: 'UserCreate', component: () => import('@/modules/user/UserCreateView.vue'), meta: { title: '后台建档', permissions: ['user.create'] } },
|
||||||
|
{ path: 'users/import', name: 'UserImport', component: () => import('@/modules/user/UserImportView.vue'), meta: { title: '批量建档', permissions: ['user.create'] } },
|
||||||
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
|
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
|
||||||
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
|
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
|
||||||
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
|
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
|
||||||
|
{ path: 'matchmaker-team', name: 'MatchmakerTeamManagement', component: () => import('@/modules/operator/MatchmakerTeamManagementView.vue'), meta: { title: '红娘团队管理', permissions: ['matchmaker.role.manage'] } },
|
||||||
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
|
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
|
||||||
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
|
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
|
||||||
// V1.1 新增模块
|
// V1.1 新增模块
|
||||||
@@ -61,6 +64,10 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 权限检查
|
// 2. 权限检查
|
||||||
|
if ((to.path === '/referral' || to.path === '/matchmaker') && authStore.permissions.includes('permission.manage')) {
|
||||||
|
ElMessage.info('管理员请在工作台查看红娘业务统计')
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
|
||||||
ElMessage.warning('您没有访问该页面的权限')
|
ElMessage.warning('您没有访问该页面的权限')
|
||||||
return { path: '/dashboard' }
|
return { path: '/dashboard' }
|
||||||
|
|||||||
@@ -54,19 +54,18 @@ const emptyMetric = (
|
|||||||
key: string,
|
key: string,
|
||||||
label: string,
|
label: string,
|
||||||
tone: DashboardMetric['tone'],
|
tone: DashboardMetric['tone'],
|
||||||
route: string,
|
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, status: 'forbidden', route: '' })
|
||||||
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
|
|
||||||
|
|
||||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||||
return {
|
return {
|
||||||
followups: [],
|
followups: [],
|
||||||
metrics: [
|
metrics: [
|
||||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
emptyMetric('pending_reviews', '待审核资料', 'rose'),
|
||||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
|
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber'),
|
||||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
emptyMetric('counseling_todos', '咨询服务待办', 'blue'),
|
||||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
emptyMetric('station_appointments', '服务站预约', 'green'),
|
||||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
emptyMetric('effective_matches', '有效匹配', 'purple'),
|
||||||
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
|
emptyMetric('activity_registrations', '活动报名', 'red'),
|
||||||
],
|
],
|
||||||
todos: [],
|
todos: [],
|
||||||
activities: [],
|
activities: [],
|
||||||
@@ -84,7 +83,8 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
||||||
const canViewReviews = permissions.includes('review.view')
|
const canViewReviews = permissions.includes('review.view')
|
||||||
|
|
||||||
const [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
const [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||||
|
client.get('/operator/workbench/dashboard', { timeout: REQUEST_TIMEOUT }),
|
||||||
canViewAnalytics
|
canViewAnalytics
|
||||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
@@ -101,6 +101,17 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const workbench = fulfilledData<JsonRecord>(workbenchResult)
|
||||||
|
const workbenchMetrics = recordOf(workbench || undefined, 'metrics')
|
||||||
|
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
|
||||||
|
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
|
||||||
|
const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available)
|
||||||
|
const metricStatus = (key: string): DashboardMetric['status'] => {
|
||||||
|
if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
const metricAvailable = (key: string) => metricStatus(key) === 'ready'
|
||||||
|
const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : ''
|
||||||
const core = fulfilledData<JsonRecord>(coreResult)
|
const core = fulfilledData<JsonRecord>(coreResult)
|
||||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||||
@@ -109,9 +120,6 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||||
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||||
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||||
const matchmakerPending = canManageMatchmaker
|
|
||||||
? managedStats.pending
|
|
||||||
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
|
|
||||||
const matchmakerCompleted = canManageMatchmaker
|
const matchmakerCompleted = canManageMatchmaker
|
||||||
? managedStats.completed
|
? managedStats.completed
|
||||||
: numberOf(matchmaker || undefined, 'completedCount')
|
: numberOf(matchmaker || undefined, 'completedCount')
|
||||||
@@ -122,46 +130,47 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
const analyticsAvailable = Boolean(core || service)
|
const analyticsAvailable = Boolean(core || service)
|
||||||
|
|
||||||
const counseling = recordOf(service || undefined, 'counseling')
|
const counseling = recordOf(service || undefined, 'counseling')
|
||||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
|
||||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||||
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
const registrationSources = recordOf(service || undefined, 'registrationSources')
|
||||||
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
|
||||||
|
|
||||||
const metrics: DashboardMetric[] = [
|
const metrics: DashboardMetric[] = [
|
||||||
{
|
{
|
||||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
|
key: 'pending_reviews', label: '待审核资料', value: workbenchValue('pendingReviews'),
|
||||||
trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
|
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||||
value: matchmakerPending,
|
value: workbenchValue('matchmakerTodos'),
|
||||||
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
|
trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : '暂无授权数据', tone: 'amber', available: metricAvailable('matchmakerTodos'), status: metricStatus('matchmakerTodos'), route: metricRoute(metricStatus('matchmakerTodos'), permissions.includes('permission.manage') ? '' : '/matchmaker'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'counseling_todos', label: '咨询服务待办',
|
key: 'counseling_todos', label: '咨询服务待办',
|
||||||
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
|
value: workbenchValue('counselingTodos'),
|
||||||
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
|
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'station_appointments', label: '服务站预约',
|
key: 'station_appointments', label: '服务站预约',
|
||||||
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
|
value: workbenchValue('serviceStationAppointments'),
|
||||||
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
|
trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : '暂无授权数据', tone: 'green', available: metricAvailable('serviceStationAppointments'), status: metricStatus('serviceStationAppointments'), route: metricRoute(metricStatus('serviceStationAppointments'), '/service-appointment'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
|
key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
|
||||||
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
|
trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : '暂无授权数据', tone: 'purple', available: metricAvailable('effectiveMatches'), status: metricStatus('effectiveMatches'), route: metricRoute(metricStatus('effectiveMatches'), '/analytics'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
|
key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
|
||||||
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
|
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
|
||||||
},
|
},
|
||||||
|
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
|
||||||
]
|
]
|
||||||
|
const metricByKey = (key: string) => metrics.find(metric => metric.key === key)
|
||||||
|
|
||||||
const todos = ([
|
const todos = ([
|
||||||
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metricByKey('pending_reviews')?.value || 0, actionText: '立即审核', route: '/review', tone: 'rose', available: metricByKey('pending_reviews')?.available || false },
|
||||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metricByKey('counseling_todos')?.value || 0, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metricByKey('counseling_todos')?.available || false },
|
||||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metricByKey('station_appointments')?.value || 0, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metricByKey('station_appointments')?.available || false },
|
||||||
{ key: 'matchmaker', title: '红娘服务', description: metrics[1].trendText, count: metrics[1].value, actionText: '查看服务', route: '/matchmaker', tone: 'amber', available: metrics[1].available },
|
{ key: 'matchmaker', title: '红娘服务', description: metricByKey('matchmaker_todos')?.trendText || '暂无授权数据', count: metricByKey('matchmaker_todos')?.value || 0, actionText: permissions.includes('permission.manage') ? '统计' : '查看服务', route: permissions.includes('permission.manage') ? '' : '/matchmaker', tone: 'amber', available: metricByKey('matchmaker_todos')?.available || false },
|
||||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||||
] as DashboardTodo[]).filter((item) => item.available)
|
] as DashboardTodo[]).filter((item) => item.available)
|
||||||
|
|
||||||
@@ -192,8 +201,8 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
|||||||
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
||||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||||
|
|
||||||
const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
const rejectedCount = [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||||
const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
const anyData = Boolean(workbench) || analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||||
const notice = !anyData
|
const notice = !anyData
|
||||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface OperatorInfo {
|
|||||||
username: string
|
username: string
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
organizationId: string | null
|
organizationId: string | null
|
||||||
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewRolePermissions: Record<string, string[]> = {
|
const previewRolePermissions: Record<string, string[]> = {
|
||||||
@@ -22,7 +23,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
|
||||||
const previewRole = previewParams?.get('preview') || ''
|
const previewRole = previewParams?.get('preview') || ''
|
||||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null, roles: [previewRole] }
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||||||
@@ -42,6 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
username: data.username,
|
username: data.username,
|
||||||
permissions: data.permissions || [],
|
permissions: data.permissions || [],
|
||||||
organizationId: data.organizationId ?? null,
|
organizationId: data.organizationId ?? null,
|
||||||
|
roles: Array.isArray(data.roles) ? data.roles : (Array.isArray(data.roleCodes) ? data.roleCodes : [data.roleCode || data.role].filter(Boolean)),
|
||||||
}
|
}
|
||||||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||||
setAccessToken(token.value)
|
setAccessToken(token.value)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
|
||||||
|
export type DashboardMetricStatus = 'ready' | 'forbidden' | 'error'
|
||||||
|
|
||||||
export interface DashboardMetric {
|
export interface DashboardMetric {
|
||||||
key: string
|
key: string
|
||||||
@@ -7,6 +8,7 @@ export interface DashboardMetric {
|
|||||||
trendText: string
|
trendText: string
|
||||||
tone: DashboardTone
|
tone: DashboardTone
|
||||||
available: boolean
|
available: boolean
|
||||||
|
status: DashboardMetricStatus
|
||||||
route: string
|
route: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default defineConfig(({ command }) => ({
|
|||||||
server: {
|
server: {
|
||||||
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
||||||
port: 13520,
|
port: 13520,
|
||||||
|
// 允许同一局域网的浏览器访问开发页面。
|
||||||
|
host: '0.0.0.0',
|
||||||
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
||||||
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.danyang.match.platform.account.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/account/service-consent")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ServiceConsentController {
|
||||||
|
private final ServiceConsentService service;
|
||||||
|
@GetMapping public ApiResponse<?> get(@AuthenticationPrincipal UserPrincipal p) { return ApiResponse.ok(service.getConsentState(p.id())); }
|
||||||
|
@PostMapping public ApiResponse<?> agree(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, Object> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||||
|
@SuppressWarnings("unchecked") Map<String, String> versions = (Map<String, String>) body.get("documentVersions");
|
||||||
|
return ApiResponse.ok(service.agree(p.id(), versions, req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||||
|
}
|
||||||
|
@PostMapping("/withdraw") public ApiResponse<?> withdraw(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, String> body, jakarta.servlet.http.HttpServletRequest req) {
|
||||||
|
return ApiResponse.ok(service.withdraw(p.id(), body.get("consentType"), req.getRemoteAddr(), req.getHeader("User-Agent")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("users")
|
||||||
|
public class UserAccountLifecycle extends BaseEntity {
|
||||||
|
@TableField("phone_verification_status")
|
||||||
|
private String phoneVerificationStatus;
|
||||||
|
@TableField("account_creation_status")
|
||||||
|
private String accountCreationStatus;
|
||||||
|
@TableField("credential_delivery_status")
|
||||||
|
private String credentialDeliveryStatus;
|
||||||
|
@TableField("service_eligibility_status")
|
||||||
|
private String serviceEligibilityStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_consent_states")
|
||||||
|
public class UserConsentState extends BaseEntity {
|
||||||
|
private String userId;
|
||||||
|
private String consentType;
|
||||||
|
private String documentVersion;
|
||||||
|
private String status;
|
||||||
|
private Instant agreedAt;
|
||||||
|
private Instant withdrawnAt;
|
||||||
|
private String actorUserId;
|
||||||
|
private String ipAddress;
|
||||||
|
private String userAgent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_credentials")
|
||||||
|
public class UserCredential extends BaseEntity {
|
||||||
|
private String userId;
|
||||||
|
private String passwordHash;
|
||||||
|
private Integer credentialVersion;
|
||||||
|
private Boolean mustChangePassword;
|
||||||
|
private Instant issuedAt;
|
||||||
|
private Instant firstUsedAt;
|
||||||
|
private Instant lastChangedAt;
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.danyang.match.platform.account.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("user_service_states")
|
||||||
|
public class UserServiceState {
|
||||||
|
@TableId private String userId;
|
||||||
|
private Boolean serviceActive;
|
||||||
|
private String serviceBlockReason;
|
||||||
|
private Instant evaluatedAt;
|
||||||
|
private Instant createdAt;
|
||||||
|
private Instant updatedAt;
|
||||||
|
private Instant deletedAt;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.danyang.match.platform.account.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserConsentStateMapper extends BaseMapper<UserConsentState> {
|
||||||
|
@Select("SELECT COUNT(*) FROM user_consent_states WHERE user_id = #{userId} AND status = 'agreed' AND deleted_at IS NULL")
|
||||||
|
int countAgreed(String userId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY consent_type")
|
||||||
|
List<UserConsentState> findByUserId(String userId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND consent_type = #{consentType} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<UserConsentState> findByUserAndType(String userId, String consentType);
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.danyang.match.platform.account.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserServiceStateMapper extends BaseMapper<UserServiceState> {
|
||||||
|
@Select("SELECT * FROM user_service_states WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<UserServiceState> findByUserId(String userId);
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.danyang.match.platform.account.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||||
|
import com.danyang.match.platform.account.entity.UserServiceState;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserServiceStateMapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ServiceConsentService {
|
||||||
|
public static final String SERVICE_TERMS = "service_terms";
|
||||||
|
public static final String PRIVACY_SAFETY = "privacy_safety";
|
||||||
|
private final UserConsentStateMapper consentMapper;
|
||||||
|
private final UserServiceStateMapper serviceStateMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserProfileMapper profileMapper;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
@Value("${account.service-consent.service-terms-version:v1}") private String serviceTermsVersion = "v1";
|
||||||
|
@Value("${account.service-consent.privacy-safety-version:v1}") private String privacySafetyVersion = "v1";
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getConsentState(String userId) {
|
||||||
|
Map<String, UserConsentState> current = new HashMap<>();
|
||||||
|
consentMapper.findByUserId(userId).forEach(c -> current.put(c.getConsentType(), c));
|
||||||
|
return List.of(document(SERVICE_TERMS, serviceTermsVersion, current.get(SERVICE_TERMS)),
|
||||||
|
document(PRIVACY_SAFETY, privacySafetyVersion, current.get(PRIVACY_SAFETY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> agree(String userId, Map<String, String> versions, String ip, String ua) {
|
||||||
|
if (versions == null || !versions.containsKey(SERVICE_TERMS) || !versions.containsKey(PRIVACY_SAFETY))
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "两份服务授权必须分别确认");
|
||||||
|
agreeOne(userId, SERVICE_TERMS, serviceTermsVersion, versions.get(SERVICE_TERMS), ip, ua);
|
||||||
|
agreeOne(userId, PRIVACY_SAFETY, privacySafetyVersion, versions.get(PRIVACY_SAFETY), ip, ua);
|
||||||
|
boolean active = evaluate(userId);
|
||||||
|
audit(userId, "SERVICE_CONSENT_AGREE", "service_consent", ip, ua);
|
||||||
|
return Map.of("serviceActive", active, "consents", getConsentState(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> withdraw(String userId, String type, String ip, String ua) {
|
||||||
|
if (!required().contains(type)) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的授权类型");
|
||||||
|
UserConsentState state = consentMapper.findByUserAndType(userId, type)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该授权记录"));
|
||||||
|
state.setStatus("withdrawn"); state.setWithdrawnAt(Instant.now()); state.setActorUserId(userId);
|
||||||
|
state.setIpAddress(ip); state.setUserAgent(ua); consentMapper.updateById(state); evaluate(userId);
|
||||||
|
audit(userId, "SERVICE_CONSENT_WITHDRAW", type, ip, ua);
|
||||||
|
return Map.of("withdrawn", true, "consentType", type, "serviceActive", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireActive(String userId) {
|
||||||
|
User user = userMapper.findById(userId).orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
UserProfile profile = profileMapper.findByUserId(userId).orElse(null);
|
||||||
|
if (!Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) || !"verified".equals(user.getPhoneVerificationStatus()))
|
||||||
|
throw blocked("账号或手机号尚未满足服务条件");
|
||||||
|
if (profile == null || !Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
|
|| profile.getPublishedProfileRevision() == null
|
||||||
|
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||||
|
throw blocked("资料尚未审核通过");
|
||||||
|
if (consentMapper.countAgreed(userId) != required().size()) throw blocked("请先确认两份服务授权");
|
||||||
|
if (!"eligible".equals(user.getServiceEligibilityStatus())) throw blocked("当前账号不具备服务资格");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive(String userId) { try { requireActive(userId); return true; } catch (RuntimeException e) { return false; } }
|
||||||
|
|
||||||
|
private void agreeOne(String userId, String type, String expected, String actual, String ip, String ua) {
|
||||||
|
if (!Objects.equals(expected, actual)) throw new BusinessException(ApiResponseCode.CONFLICT, "授权文档版本已更新,请刷新后重新确认");
|
||||||
|
UserConsentState s = consentMapper.findByUserAndType(userId, type).orElseGet(UserConsentState::new);
|
||||||
|
s.setUserId(userId); s.setConsentType(type); s.setDocumentVersion(expected); s.setStatus("agreed"); s.setAgreedAt(Instant.now());
|
||||||
|
s.setWithdrawnAt(null); s.setActorUserId(userId); s.setIpAddress(ip); s.setUserAgent(ua);
|
||||||
|
if (s.getId() == null) { s.setId(UUID.randomUUID().toString()); consentMapper.insert(s); } else consentMapper.updateById(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean evaluate(String userId) {
|
||||||
|
UserServiceState s = serviceStateMapper.findByUserId(userId).orElseGet(UserServiceState::new);
|
||||||
|
s.setUserId(userId); boolean active = consentMapper.countAgreed(userId) == required().size();
|
||||||
|
s.setServiceActive(active); s.setServiceBlockReason(active ? null : "consent_required"); s.setEvaluatedAt(Instant.now());
|
||||||
|
if (s.getCreatedAt() == null) serviceStateMapper.insert(s); else serviceStateMapper.updateById(s);
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void audit(String userId, String action, String resourceId, String ip, String ua) {
|
||||||
|
AuditLog log = new AuditLog(); log.setSubjectUserId(userId); log.setAction(action); log.setResourceType("service_consent");
|
||||||
|
log.setResourceId(resourceId); log.setIp(ip); log.setUserAgent(ua); auditLogMapper.insert(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> required() { return Set.of(SERVICE_TERMS, PRIVACY_SAFETY); }
|
||||||
|
private BusinessException blocked(String message) { return new BusinessException(ApiResponseCode.FORBIDDEN, message); }
|
||||||
|
private Map<String, Object> document(String type, String version, UserConsentState state) {
|
||||||
|
return Map.of("consentType", type, "documentVersion", version, "status", state == null ? "required" : state.getStatus(), "agreed", state != null && "agreed".equals(state.getStatus()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 活动反馈与互选控制器
|
* 活动反馈与互选控制器
|
||||||
@@ -33,6 +34,8 @@ public class ActivityInterestController {
|
|||||||
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||||
private final ActivityMutualConsentService consentService;
|
private final ActivityMutualConsentService consentService;
|
||||||
private final ActivityMapper activityMapper;
|
private final ActivityMapper activityMapper;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@GetMapping("/operator/activity-interest-summary")
|
@GetMapping("/operator/activity-interest-summary")
|
||||||
@PreAuthorize("hasAuthority('activity.view')")
|
@PreAuthorize("hasAuthority('activity.view')")
|
||||||
@@ -63,6 +66,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody ActivityFeedbackDTO body) {
|
@RequestBody ActivityFeedbackDTO body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
Map<String, Object> result = interestService.submitFeedback(
|
Map<String, Object> result = interestService.submitFeedback(
|
||||||
userId,
|
userId,
|
||||||
id,
|
id,
|
||||||
@@ -83,6 +87,7 @@ public class ActivityInterestController {
|
|||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +101,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@PathVariable String partnerId) {
|
@PathVariable String partnerId) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("hasMutual", mutual);
|
result.put("hasMutual", mutual);
|
||||||
@@ -120,6 +126,7 @@ public class ActivityInterestController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
|
||||||
String partnerId = body.get("partnerId");
|
String partnerId = body.get("partnerId");
|
||||||
if (partnerId == null || partnerId.isBlank()) {
|
if (partnerId == null || partnerId.isBlank()) {
|
||||||
return ApiResponse.error(
|
return ApiResponse.error(
|
||||||
|
|||||||
+48
-11
@@ -9,6 +9,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import com.danyang.match.platform.user.dto.PhoneVerificationRequest;
|
||||||
|
import com.danyang.match.platform.user.dto.UserRegistrationRequest;
|
||||||
|
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||||
|
import com.danyang.match.platform.user.service.UserRegistrationService;
|
||||||
|
import com.danyang.match.platform.auth.service.PasswordAuthService;
|
||||||
|
import com.danyang.match.platform.user.dto.PasswordLoginRequest;
|
||||||
|
import com.danyang.match.platform.user.dto.InitialPasswordChangeRequest;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -18,6 +26,43 @@ import java.util.Map;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
private final UserRegistrationService userRegistrationService;
|
||||||
|
private final PasswordAuthService passwordAuthService;
|
||||||
|
|
||||||
|
@PostMapping("/password/login")
|
||||||
|
public ApiResponse<Map<String, Object>> passwordLogin(@Valid @RequestBody PasswordLoginRequest request) {
|
||||||
|
return ApiResponse.ok(passwordAuthService.login(request.phone(), request.password()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/password/change-initial")
|
||||||
|
@PreAuthorize("hasRole('USER')")
|
||||||
|
public ApiResponse<Void> changeInitialPassword(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody InitialPasswordChangeRequest request) {
|
||||||
|
passwordAuthService.changeInitialPassword(principal.id(), request.oldPassword(), request.newPassword());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody UserRegistrationRequest request) {
|
||||||
|
return ApiResponse.ok(userRegistrationService.register(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/phone/send-code")
|
||||||
|
public ApiResponse<Void> sendPhoneCode(@RequestBody Map<String, String> body) {
|
||||||
|
String phone = body.get("phone");
|
||||||
|
if (phone == null || !phone.matches("^1\\d{10}$")) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号格式不正确");
|
||||||
|
}
|
||||||
|
phoneVerificationService.sendCode(phone);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/phone/verify")
|
||||||
|
public ApiResponse<Map<String, String>> verifyPhone(@Valid @RequestBody PhoneVerificationRequest request) {
|
||||||
|
return ApiResponse.ok(Map.of("verificationTransaction",
|
||||||
|
phoneVerificationService.verify(request.phone(), request.code())));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信登录
|
* 微信登录
|
||||||
@@ -33,15 +78,6 @@ public class AuthController {
|
|||||||
return ApiResponse.ok(authService.wechatLogin(code));
|
return ApiResponse.ok(authService.wechatLogin(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/demo/login")
|
|
||||||
public ApiResponse<Map<String, Object>> demoLogin(@RequestBody Map<String, String> body) {
|
|
||||||
String code = body.get("code");
|
|
||||||
if (code == null || code.isBlank()) {
|
|
||||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号不能为空");
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(authService.demoLogin(code));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定手机号
|
* 绑定手机号
|
||||||
* POST /api/v1/auth/phone/bind
|
* POST /api/v1/auth/phone/bind
|
||||||
@@ -53,10 +89,11 @@ public class AuthController {
|
|||||||
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
|
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String phone = body.get("phone");
|
String phone = body.get("phone");
|
||||||
if (phone == null || phone.isBlank()) {
|
String transaction = body.get("verificationTransaction");
|
||||||
|
if (phone == null || phone.isBlank() || transaction == null || transaction.isBlank()) {
|
||||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
||||||
}
|
}
|
||||||
authService.bindPhone(principal.id(), phone);
|
authService.bindPhone(principal.id(), phone, transaction);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("!test & !demo")
|
||||||
|
public class ConfiguredSmsVerificationProvider implements SmsVerificationProvider {
|
||||||
|
private final String provider;
|
||||||
|
|
||||||
|
public ConfiguredSmsVerificationProvider(
|
||||||
|
@Value("${sms.provider:}") String provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendCode(String phone, String code) {
|
||||||
|
if (provider == null || provider.isBlank()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务未配置");
|
||||||
|
}
|
||||||
|
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务适配器尚未配置完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile({"test", "demo"})
|
||||||
|
public class MockSmsVerificationProvider implements SmsVerificationProvider {
|
||||||
|
private final Map<String, String> sentCodes = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendCode(String phone, String code) {
|
||||||
|
sentCodes.put(phone, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String lastCode(String phone) {
|
||||||
|
return sentCodes.get(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.danyang.match.platform.auth.provider;
|
||||||
|
|
||||||
|
public interface SmsVerificationProvider {
|
||||||
|
void sendCode(String phone, String code);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import com.danyang.match.platform.review.entity.ReviewTask;
|
|||||||
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
||||||
import com.danyang.match.platform.user.entity.User;
|
import com.danyang.match.platform.user.entity.User;
|
||||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.user.service.PhoneVerificationService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -47,40 +48,18 @@ public class AuthService {
|
|||||||
private final CapabilityService capabilityService;
|
private final CapabilityService capabilityService;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final ReviewTaskMapper reviewTaskMapper;
|
private final ReviewTaskMapper reviewTaskMapper;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
|
||||||
@Value("${jwt.access-token-expiration-ms:7200000}")
|
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||||
private long accessTokenExpireMs;
|
private long accessTokenExpireMs;
|
||||||
|
|
||||||
@Value("${demo.login.enabled:false}")
|
|
||||||
private boolean demoLoginEnabled;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信登录
|
* 微信登录
|
||||||
* 新用户自动创建账号,老用户直接返回 token
|
* 新用户自动创建账号,老用户直接返回 token
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> wechatLogin(String code) {
|
public Map<String, Object> wechatLogin(String code) {
|
||||||
return loginWithSession(wechatLoginProvider.getCodeSession(code));
|
Map<String, String> session = wechatLoginProvider.getCodeSession(code);
|
||||||
}
|
|
||||||
|
|
||||||
/** 演示环境专用登录,不调用微信 code2session。 */
|
|
||||||
@Transactional
|
|
||||||
public Map<String, Object> demoLogin(String code) {
|
|
||||||
if (!demoLoginEnabled) {
|
|
||||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示登录未启用");
|
|
||||||
}
|
|
||||||
if (code == null || !code.startsWith("demo_openid_")) {
|
|
||||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号无效");
|
|
||||||
}
|
|
||||||
Map<String, String> session = Map.of(
|
|
||||||
"openid", code,
|
|
||||||
"unionid", "demo_unionid_" + code,
|
|
||||||
"session_key", "demo_session_key"
|
|
||||||
);
|
|
||||||
return loginWithSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loginWithSession(Map<String, String> session) {
|
|
||||||
String openid = session.get("openid");
|
String openid = session.get("openid");
|
||||||
String unionid = session.get("unionid");
|
String unionid = session.get("unionid");
|
||||||
|
|
||||||
@@ -134,8 +113,8 @@ public class AuthService {
|
|||||||
* 绑定手机号
|
* 绑定手机号
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void bindPhone(String userId, String phoneCode) {
|
public void bindPhone(String userId, String phoneCode, String verificationTransaction) {
|
||||||
// TODO: Phase 03 对接真实短信验证码
|
phoneVerificationService.consumeTransaction(verificationTransaction, phoneCode);
|
||||||
User user = userMapper.findById(userId)
|
User user = userMapper.findById(userId)
|
||||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
|
||||||
@@ -147,6 +126,7 @@ public class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
user.setPhone(phoneCode);
|
user.setPhone(phoneCode);
|
||||||
|
user.setPhoneVerificationStatus("verified");
|
||||||
userMapper.updateById(user);
|
userMapper.updateById(user);
|
||||||
|
|
||||||
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
|
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
|
||||||
|
|||||||
+1
-17
@@ -19,7 +19,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -45,15 +44,6 @@ public class OperatorAuthService {
|
|||||||
private final AuditLogMapper auditLogMapper;
|
private final AuditLogMapper auditLogMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Value("${demo.login.enabled:false}")
|
|
||||||
private boolean demoLoginEnabled;
|
|
||||||
|
|
||||||
@Value("${demo.login.password:}")
|
|
||||||
private String demoLoginPassword;
|
|
||||||
|
|
||||||
@Value("${demo.login.usernames:admin,wanghongniang}")
|
|
||||||
private String demoLoginUsernames;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator 登录(含失败锁定)
|
* Operator 登录(含失败锁定)
|
||||||
*/
|
*/
|
||||||
@@ -77,13 +67,7 @@ public class OperatorAuthService {
|
|||||||
throw new AccessDeniedException("账号已禁用,请联系管理员");
|
throw new AccessDeniedException("账号已禁用,请联系管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean passwordMatches = passwordEncoder.matches(password, operator.getPasswordHash());
|
if (!passwordEncoder.matches(password, operator.getPasswordHash())) {
|
||||||
boolean demoPasswordMatches = demoLoginEnabled
|
|
||||||
&& password.equals(demoLoginPassword)
|
|
||||||
&& java.util.Arrays.stream(demoLoginUsernames.split(","))
|
|
||||||
.map(String::trim)
|
|
||||||
.anyMatch(operator.getUsername()::equals);
|
|
||||||
if (!passwordMatches && !demoPasswordMatches) {
|
|
||||||
// 记录失败次数
|
// 记录失败次数
|
||||||
String attemptKey = "auth:operator_login_attempts:" + operator.getId();
|
String attemptKey = "auth:operator_login_attempts:" + operator.getId();
|
||||||
Long attempts = redisTemplate.opsForValue().increment(attemptKey);
|
Long attempts = redisTemplate.opsForValue().increment(attemptKey);
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.danyang.match.platform.auth.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserCredential;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordAuthService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserCredentialMapper credentialMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenService jwtTokenService;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> login(String phone, String password) {
|
||||||
|
User user = userMapper.findByPhone(phone)
|
||||||
|
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||||
|
if (!"verified".equals(user.getPhoneVerificationStatus())
|
||||||
|
|| !Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())) {
|
||||||
|
throw new UnauthorizedException("账号尚未完成手机号核验或已停用");
|
||||||
|
}
|
||||||
|
UserCredential credential = credentialMapper.findByUserId(user.getId())
|
||||||
|
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
|
||||||
|
if (credential.getRevokedAt() != null || !passwordEncoder.matches(password, credential.getPasswordHash())) {
|
||||||
|
throw new UnauthorizedException("手机号或密码错误");
|
||||||
|
}
|
||||||
|
credential.setFirstUsedAt(credential.getFirstUsedAt() == null ? Instant.now() : credential.getFirstUsedAt());
|
||||||
|
credentialMapper.updateById(credential);
|
||||||
|
String access = jwtTokenService.generateAccessToken(user.getId(), null);
|
||||||
|
String refresh = jwtTokenService.generateRefreshToken(user.getId());
|
||||||
|
redisTemplate.opsForValue().set("auth:refresh_token:" + user.getId(), refresh,
|
||||||
|
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("accessToken", access);
|
||||||
|
result.put("refreshToken", refresh);
|
||||||
|
result.put("userId", user.getId());
|
||||||
|
result.put("mustChangePassword", Boolean.TRUE.equals(credential.getMustChangePassword()));
|
||||||
|
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void changeInitialPassword(String userId, String oldPassword, String newPassword) {
|
||||||
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "新密码至少 8 位");
|
||||||
|
}
|
||||||
|
UserCredential credential = credentialMapper.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "凭据不存在"));
|
||||||
|
if (!Boolean.TRUE.equals(credential.getMustChangePassword())
|
||||||
|
|| !passwordEncoder.matches(oldPassword, credential.getPasswordHash())) {
|
||||||
|
throw new UnauthorizedException("初始密码不正确或已完成修改");
|
||||||
|
}
|
||||||
|
credential.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
credential.setCredentialVersion((credential.getCredentialVersion() == null ? 0 : credential.getCredentialVersion()) + 1);
|
||||||
|
credential.setMustChangePassword(false);
|
||||||
|
credential.setLastChangedAt(Instant.now());
|
||||||
|
credentialMapper.updateById(credential);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -1,6 +1,7 @@
|
|||||||
package com.danyang.match.platform.capability.service;
|
package com.danyang.match.platform.capability.service;
|
||||||
|
|
||||||
import com.danyang.match.platform.home.dto.HomeDTO;
|
import com.danyang.match.platform.home.dto.HomeDTO;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
@@ -32,6 +33,7 @@ public class CapabilityService {
|
|||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
private final UserIdentityMapper userIdentityMapper;
|
private final UserIdentityMapper userIdentityMapper;
|
||||||
private final LocalEligibilityMapper localEligibilityMapper;
|
private final LocalEligibilityMapper localEligibilityMapper;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户的能力开关集合
|
* 获取用户的能力开关集合
|
||||||
@@ -45,7 +47,8 @@ public class CapabilityService {
|
|||||||
|
|
||||||
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|
||||||
&& isIdentityVerified(userId)
|
&& isIdentityVerified(userId)
|
||||||
&& isProfileApproved(user);
|
&& isProfileApproved(user)
|
||||||
|
&& serviceConsentService.isActive(userId);
|
||||||
|
|
||||||
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
|
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
|
||||||
caps.setCanReceiveRecommendations(baseEligible
|
caps.setCanReceiveRecommendations(baseEligible
|
||||||
@@ -77,10 +80,11 @@ public class CapabilityService {
|
|||||||
/** 资料是否审核通过 */
|
/** 资料是否审核通过 */
|
||||||
public boolean isProfileApproved(User user) {
|
public boolean isProfileApproved(User user) {
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
String status = userProfileMapper.findByUserId(user.getId())
|
return userProfileMapper.findByUserId(user.getId()).map(profile ->
|
||||||
.map(UserProfile::getProfileStatus).orElse(null);
|
Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
return Constants.PROFILE_STATUS_APPROVED.equals(status)
|
&& (profile.getPublishedProfileRevision() == null
|
||||||
|| Constants.PROFILE_STATUS_COMPLETED.equals(status);
|
|| java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
|
||||||
|
).orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否在认真寻找中 */
|
/** 是否在认真寻找中 */
|
||||||
|
|||||||
+7
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversation 端点
|
* Conversation 端点
|
||||||
@@ -35,6 +36,8 @@ public class ConversationController {
|
|||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
private final MatchMapper matchMapper;
|
private final MatchMapper matchMapper;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Match 创建/获取 Conversation
|
* 从 Match 创建/获取 Conversation
|
||||||
@@ -47,6 +50,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Match match = matchMapper.findById(matchId).orElse(null);
|
Match match = matchMapper.findById(matchId).orElse(null);
|
||||||
if (match == null) return ApiResponse.notFound();
|
if (match == null) return ApiResponse.notFound();
|
||||||
// 权限:必须是 Match 参与者
|
// 权限:必须是 Match 参与者
|
||||||
@@ -68,6 +72,7 @@ public class ConversationController {
|
|||||||
@RequestParam(defaultValue = "20") int limit,
|
@RequestParam(defaultValue = "20") int limit,
|
||||||
@RequestParam(defaultValue = "0") int offset) {
|
@RequestParam(defaultValue = "0") int offset) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
||||||
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
||||||
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
||||||
@@ -92,6 +97,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
|
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
||||||
@@ -107,6 +113,7 @@ public class ConversationController {
|
|||||||
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非成员不得读取他人会话未读
|
// 对象级鉴权:非成员不得读取他人会话未读
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
||||||
|
|||||||
+6
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message 端点(HTTP REST + 幂等)
|
* Message 端点(HTTP REST + 幂等)
|
||||||
@@ -36,12 +37,15 @@ public class MessageController {
|
|||||||
private static final int MAX_PAGE_SIZE = 100;
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@PostMapping("/{id}/messages")
|
@PostMapping("/{id}/messages")
|
||||||
public ApiResponse<MessageResponse> send(@PathVariable String id,
|
public ApiResponse<MessageResponse> send(@PathVariable String id,
|
||||||
@RequestBody SendRequest req,
|
@RequestBody SendRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
||||||
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
||||||
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
||||||
@@ -59,6 +63,7 @@ public class MessageController {
|
|||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
|
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
@@ -84,6 +89,7 @@ public class MessageController {
|
|||||||
@RequestBody Map<String, String> body,
|
@RequestBody Map<String, String> body,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 对象级鉴权:非成员不得改写他人会话的已读状态
|
// 对象级鉴权:非成员不得改写他人会话的已读状态
|
||||||
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
|
||||||
String lastReadId = body.get("lastReadMessageId");
|
String lastReadId = body.get("lastReadMessageId");
|
||||||
|
|||||||
+8
@@ -18,6 +18,7 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
|
||||||
@@ -56,6 +57,8 @@ public class CounselingController {
|
|||||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||||
|
|
||||||
private final CounselingService counselingService;
|
private final CounselingService counselingService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
// ==================== 用户端 ====================
|
// ==================== 用户端 ====================
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.apply(
|
return ApiResponse.ok(counselingService.apply(
|
||||||
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
principal.id(), // 修复:userId 来自认证主体,不取请求体
|
||||||
req.serviceType,
|
req.serviceType,
|
||||||
@@ -79,6 +83,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
|
||||||
// 列表场景剥离正文与 followups(INV-013)
|
// 列表场景剥离正文与 followups(INV-013)
|
||||||
list.forEach(CounselingService::sanitize);
|
list.forEach(CounselingService::sanitize);
|
||||||
@@ -98,6 +103,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
|
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
|
||||||
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
|
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
|
||||||
if (!UUID_PATTERN.matcher(id).matches()) {
|
if (!UUID_PATTERN.matcher(id).matches()) {
|
||||||
@@ -114,6 +120,7 @@ public class CounselingController {
|
|||||||
@RequestBody(required = false) CancelRequest req,
|
@RequestBody(required = false) CancelRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
|
||||||
req != null ? req.reason : null));
|
req != null ? req.reason : null));
|
||||||
}
|
}
|
||||||
@@ -124,6 +131,7 @@ public class CounselingController {
|
|||||||
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 找对象控制器
|
* 找对象控制器
|
||||||
@@ -21,6 +22,7 @@ public class DiscoverController {
|
|||||||
|
|
||||||
private final DiscoverService discoverService;
|
private final DiscoverService discoverService;
|
||||||
private final UserProfileMapper userProfileMapper;
|
private final UserProfileMapper userProfileMapper;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索用户(临时筛选)
|
* 搜索用户(临时筛选)
|
||||||
@@ -38,6 +40,7 @@ public class DiscoverController {
|
|||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
Map<String, Object> myProfile = getMyProfile(userId);
|
Map<String, Object> myProfile = getMyProfile(userId);
|
||||||
String myGender = (String) myProfile.get("gender");
|
String myGender = (String) myProfile.get("gender");
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ public class DiscoverController {
|
|||||||
public ApiResponse<Map<String, Object>> getProfile(
|
public ApiResponse<Map<String, Object>> getProfile(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String userId) {
|
@PathVariable String userId) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||||
|
|||||||
+8
-1
@@ -40,6 +40,8 @@ public class DiscoverService {
|
|||||||
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
||||||
wrapper.ne(UserProfile::getGender, myGender)
|
wrapper.ne(UserProfile::getGender, myGender)
|
||||||
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||||
|
.isNotNull(UserProfile::getPublishedProfileRevision)
|
||||||
|
.apply("profile_revision = published_profile_revision")
|
||||||
.isNull(UserProfile::getDeletedAt);
|
.isNull(UserProfile::getDeletedAt);
|
||||||
|
|
||||||
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
||||||
@@ -117,6 +119,11 @@ public class DiscoverService {
|
|||||||
}
|
}
|
||||||
return userProfileMapper.findByUserId(userId)
|
return userProfileMapper.findByUserId(userId)
|
||||||
.map(profile -> {
|
.map(profile -> {
|
||||||
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
|
|| profile.getPublishedProfileRevision() == null
|
||||||
|
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
map.put("userId", profile.getUserId());
|
map.put("userId", profile.getUserId());
|
||||||
map.put("displayName", profile.getDisplayName());
|
map.put("displayName", profile.getDisplayName());
|
||||||
@@ -135,7 +142,7 @@ public class DiscoverService {
|
|||||||
map.put("longTermDanyang", profile.getLongTermDanyang());
|
map.put("longTermDanyang", profile.getLongTermDanyang());
|
||||||
map.put("futureDanyang", profile.getFutureDanyang());
|
map.put("futureDanyang", profile.getFutureDanyang());
|
||||||
map.put("verified", true);
|
map.put("verified", true);
|
||||||
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
|
map.put("reviewed", true);
|
||||||
return map;
|
return map;
|
||||||
})
|
})
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import lombok.Data;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like 端点(canonical 路径,2026-09-14 版)
|
* Like 端点(canonical 路径,2026-09-14 版)
|
||||||
@@ -29,12 +30,14 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class LikeController {
|
public class LikeController {
|
||||||
|
|
||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
|
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,7 @@ public class LikeController {
|
|||||||
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
|
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
relationService.unlike(principal.id(), targetUserId);
|
relationService.unlike(principal.id(), targetUserId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match 端点(canonical 路径,2026-09-14 版)
|
* Match 端点(canonical 路径,2026-09-14 版)
|
||||||
@@ -46,10 +47,12 @@ public class MatchController {
|
|||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
private final RelationshipStatusService relationshipStatusService;
|
private final RelationshipStatusService relationshipStatusService;
|
||||||
private final ChatService chatService;
|
private final ChatService chatService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
||||||
List<MatchResponse> items = matches.stream()
|
List<MatchResponse> items = matches.stream()
|
||||||
.map(match -> MatchResponse.from(match, principal.id()))
|
.map(match -> MatchResponse.from(match, principal.id()))
|
||||||
@@ -62,6 +65,7 @@ public class MatchController {
|
|||||||
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Optional<Match> opt = matchMapper.findById(id);
|
Optional<Match> opt = matchMapper.findById(id);
|
||||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||||
Match m = opt.get();
|
Match m = opt.get();
|
||||||
@@ -77,6 +81,7 @@ public class MatchController {
|
|||||||
public ApiResponse<Void> unmatch(@PathVariable String id,
|
public ApiResponse<Void> unmatch(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
Optional<Match> opt = matchMapper.findById(id);
|
Optional<Match> opt = matchMapper.findById(id);
|
||||||
if (opt.isEmpty()) return ApiResponse.notFound();
|
if (opt.isEmpty()) return ApiResponse.notFound();
|
||||||
Match m = opt.get();
|
Match m = opt.get();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
|
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
|
||||||
@@ -28,12 +29,14 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class PassController {
|
public class PassController {
|
||||||
|
|
||||||
private final RelationService relationService;
|
private final RelationService relationService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
@Idempotent
|
@Idempotent
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<Void> pass(@RequestBody PassRequest req,
|
public ApiResponse<Void> pass(@RequestBody PassRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
|
||||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推荐控制器
|
* 推荐控制器
|
||||||
@@ -21,6 +22,7 @@ import java.util.Map;
|
|||||||
public class RecommendationController {
|
public class RecommendationController {
|
||||||
|
|
||||||
private final RecommendationEngineService engineService;
|
private final RecommendationEngineService engineService;
|
||||||
|
private final ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取今日推荐
|
* 获取今日推荐
|
||||||
@@ -30,6 +32,7 @@ public class RecommendationController {
|
|||||||
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
|
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
|
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ public class RecommendationController {
|
|||||||
@PathVariable String targetUserId,
|
@PathVariable String targetUserId,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
String action = body.get("action");
|
String action = body.get("action");
|
||||||
if (action == null) {
|
if (action == null) {
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
|
||||||
@@ -62,6 +66,7 @@ public class RecommendationController {
|
|||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String targetUserId) {
|
@PathVariable String targetUserId) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
boolean eligible = engineService.isStillEligible(userId, targetUserId);
|
boolean eligible = engineService.isStillEligible(userId, targetUserId);
|
||||||
return ApiResponse.ok(Map.of("eligible", eligible));
|
return ApiResponse.ok(Map.of("eligible", eligible));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -159,6 +159,9 @@ public class MatchEligibilityService {
|
|||||||
if (targetProfile == null) return false;
|
if (targetProfile == null) return false;
|
||||||
String ps = targetProfile.getProfileStatus();
|
String ps = targetProfile.getProfileStatus();
|
||||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
|
||||||
|
if (Constants.PROFILE_STATUS_APPROVED.equals(ps)
|
||||||
|
&& targetProfile.getPublishedProfileRevision() != null
|
||||||
|
&& !java.util.Objects.equals(targetProfile.getProfileRevision(), targetProfile.getPublishedProfileRevision())) return false;
|
||||||
|
|
||||||
// V1.1: 本地资格核验(可配置是否强制)
|
// V1.1: 本地资格核验(可配置是否强制)
|
||||||
if (matchConfig.isLocalEligibilityRequired()) {
|
if (matchConfig.isLocalEligibilityRequired()) {
|
||||||
|
|||||||
+2
@@ -127,6 +127,8 @@ public class RecommendationEngineService {
|
|||||||
|
|
||||||
// 检查审核状态
|
// 检查审核状态
|
||||||
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
|
||||||
|
if (target.getPublishedProfileRevision() != null
|
||||||
|
&& !java.util.Objects.equals(target.getProfileRevision(), target.getPublishedProfileRevision())) return false;
|
||||||
|
|
||||||
// 检查是否已被当前用户Block
|
// 检查是否已被当前用户Block
|
||||||
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
|
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
|
||||||
|
|||||||
+10
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 红娘服务端点(用户端)
|
* 红娘服务端点(用户端)
|
||||||
@@ -42,6 +43,8 @@ import java.util.Map;
|
|||||||
public class MatchmakerController {
|
public class MatchmakerController {
|
||||||
|
|
||||||
private final MatchmakerService matchmakerService;
|
private final MatchmakerService matchmakerService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
// ==================== 用户端 ====================
|
// ==================== 用户端 ====================
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
public ApiResponse<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
// 契约兼容:小程序传 requirement,后端契约为 description;缺省服务类型为 recommendation
|
// 契约兼容:小程序传 requirement,后端契约为 description;缺省服务类型为 recommendation
|
||||||
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
||||||
? "recommendation" : req.serviceType;
|
? "recommendation" : req.serviceType;
|
||||||
@@ -62,6 +66,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<List<MatchmakerServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
public ApiResponse<List<MatchmakerServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
|
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +84,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
|
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
|
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
@@ -85,6 +92,7 @@ public class MatchmakerController {
|
|||||||
@GetMapping("/recommendations")
|
@GetMapping("/recommendations")
|
||||||
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.pendingRecommendations(principal.id()));
|
return ApiResponse.ok(matchmakerService.pendingRecommendations(principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<Void> markViewed(@PathVariable String id,
|
public ApiResponse<Void> markViewed(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
matchmakerService.markRecommendationViewed(id, principal.id());
|
matchmakerService.markRecommendationViewed(id, principal.id());
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
@@ -101,6 +110,7 @@ public class MatchmakerController {
|
|||||||
public ApiResponse<MatchmakerService.LikeRecommendationResult> accept(@PathVariable String id,
|
public ApiResponse<MatchmakerService.LikeRecommendationResult> accept(@PathVariable String id,
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal == null) return ApiResponse.unauthorized();
|
if (principal == null) return ApiResponse.unauthorized();
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id()));
|
return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/workbench/details")
|
||||||
|
@PreAuthorize("hasAuthority('workbench.view')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerDashboardDetailController {
|
||||||
|
private final MatchmakerDashboardDetailService service;
|
||||||
|
|
||||||
|
@GetMapping("/{type}")
|
||||||
|
public ApiResponse<Map<String, Object>> list(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@PathVariable String type,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
return ApiResponse.ok(service.list(principal.id(), type, page, pageSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.service.PermissionService;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerDashboardDetailService {
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public Map<String, Object> list(String operatorId, String type, int page, int pageSize) {
|
||||||
|
MatchmakerDashboardScope scope = resolve(operatorId);
|
||||||
|
String permission = permissionFor(type);
|
||||||
|
if (!permissionService.hasPermission(operatorId, permission)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权查看该工作台明细");
|
||||||
|
}
|
||||||
|
Query query = queryFor(type, scope);
|
||||||
|
int safePage = Math.max(1, page);
|
||||||
|
int safeSize = Math.min(100, Math.max(1, pageSize));
|
||||||
|
long total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM (" + query.sql + ") q", Long.class, query.args);
|
||||||
|
List<Map<String, Object>> items = jdbcTemplate.queryForList(
|
||||||
|
query.sql + " ORDER BY created_at DESC NULLS LAST LIMIT ? OFFSET ?",
|
||||||
|
append(query.args, safeSize, (safePage - 1) * safeSize));
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("type", type);
|
||||||
|
result.put("scope", scope.type());
|
||||||
|
result.put("total", total);
|
||||||
|
result.put("page", safePage);
|
||||||
|
result.put("pageSize", safeSize);
|
||||||
|
result.put("items", items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerDashboardScope resolve(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
if (roles.stream().anyMatch(r -> Set.of("platform_admin", "super_admin").contains(String.valueOf(r.getCode())))) {
|
||||||
|
return new MatchmakerDashboardScope("ALL", operatorId, operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
boolean team = roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()));
|
||||||
|
if (team && (operator.getOrganizationId() == null || operator.getOrganizationId().isBlank())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CROSS_ORGANIZATION_DENIED, "红娘组长必须绑定组织");
|
||||||
|
}
|
||||||
|
return new MatchmakerDashboardScope(team ? "TEAM" : "SELF", operatorId, operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String permissionFor(String type) {
|
||||||
|
return switch (type) {
|
||||||
|
case "review" -> "review.view";
|
||||||
|
case "matchmaker" -> "matchmaker.view";
|
||||||
|
case "counseling" -> "counseling.service.view";
|
||||||
|
case "appointment" -> "service_appointment.view";
|
||||||
|
case "match" -> "analytics.view";
|
||||||
|
case "activity" -> "activity.review_registration";
|
||||||
|
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的工作台明细类型");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Query queryFor(String type, MatchmakerDashboardScope scope) {
|
||||||
|
String table = switch (type) {
|
||||||
|
case "review" -> "review_tasks";
|
||||||
|
case "matchmaker" -> "matchmaker_assignments";
|
||||||
|
case "counseling" -> "counseling_services";
|
||||||
|
case "appointment" -> "service_appointments";
|
||||||
|
case "activity" -> "activity_registrations";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (table != null) {
|
||||||
|
String owner = "activity".equals(type) ? "created_by_operator_id"
|
||||||
|
: "matchmaker".equals(type) ? "matchmaker_operator_id" : "assigned_operator_id";
|
||||||
|
String sql = "SELECT id, user_id, status, created_at FROM " + table
|
||||||
|
+ " WHERE deleted_at IS NULL AND status IN ("
|
||||||
|
+ statusFor(type) + ")"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = " + table + ".user_id"
|
||||||
|
+ " AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.team()) return new Query(sql + " AND " + owner + " IN (SELECT id FROM operators WHERE organization_id = ?)", new Object[]{scope.organizationId()});
|
||||||
|
if (!scope.all()) return new Query(sql + " AND " + owner + " = ?", new Object[]{scope.operatorId()});
|
||||||
|
return new Query(sql, new Object[]{});
|
||||||
|
}
|
||||||
|
String sql = "SELECT m.id, m.user_a_id, m.user_b_id, m.status, m.matched_at AS created_at FROM matches m"
|
||||||
|
+ " WHERE m.status = 'active' AND m.source = 'matchmaker'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id=m.user_a_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision=up.profile_revision)"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id=m.user_b_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision=up.profile_revision)";
|
||||||
|
if (scope.all()) return new Query(sql, new Object[]{});
|
||||||
|
String owner = scope.team() ? "o.organization_id = ?" : "(r.operator_id = ? OR s.assigned_operator_id = ?)";
|
||||||
|
String suffix = " AND EXISTS (SELECT 1 FROM matchmaker_recommendations r JOIN matchmaker_services s ON s.id=r.service_id JOIN operators o ON o.id=s.assigned_operator_id WHERE ((r.user_id = m.user_a_id AND r.target_user_id = m.user_b_id) OR (r.user_id = m.user_b_id AND r.target_user_id = m.user_a_id)) AND " + owner + ")";
|
||||||
|
return scope.team() ? new Query(sql + suffix, new Object[]{scope.organizationId()}) : new Query(sql + suffix, new Object[]{scope.operatorId(), scope.operatorId()});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String statusFor(String type) {
|
||||||
|
return switch (type) {
|
||||||
|
case "review" -> "'pending','processing'";
|
||||||
|
case "matchmaker" -> "'active'";
|
||||||
|
case "counseling" -> "'pending','assigned','scheduled','in_progress'";
|
||||||
|
case "appointment" -> "'submitted','pending_confirmation','confirmed'";
|
||||||
|
default -> "'pending','approved'";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object[] append(Object[] source, Object... values) {
|
||||||
|
Object[] result = java.util.Arrays.copyOf(source, source.length + values.length);
|
||||||
|
System.arraycopy(values, 0, result, source.length, values.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Query(String sql, Object[] args) {}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
public record MatchmakerDashboardScope(String type, String operatorId, String organizationId) {
|
||||||
|
public boolean all() { return "ALL".equals(type); }
|
||||||
|
public boolean team() { return "TEAM".equals(type); }
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/workbench")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("hasAuthority('workbench.view')")
|
||||||
|
public class MatchmakerWorkbenchController {
|
||||||
|
private final MatchmakerWorkbenchService service;
|
||||||
|
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ApiResponse<Map<String, Object>> dashboard(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(service.dashboard(principal.id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.danyang.match.platform.matchmaker.dashboard;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.service.PermissionService;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerWorkbenchService {
|
||||||
|
private final OperatorMapper operatorMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionService permissionService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public Map<String, Object> dashboard(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
MatchmakerDashboardScope scope = resolveScope(operator, roles);
|
||||||
|
|
||||||
|
Map<String, Object> metrics = new LinkedHashMap<>();
|
||||||
|
metrics.put("pendingReviews", metric(operatorId, "review.view", () -> count("review_tasks", "status IN ('pending','processing')", scope)));
|
||||||
|
metrics.put("counselingTodos", metric(operatorId, "counseling.service.view", () -> count("counseling_services", "status IN ('pending','assigned','scheduled','in_progress')", scope)));
|
||||||
|
metrics.put("serviceStationAppointments", metric(operatorId, "service_appointment.view", () -> count("service_appointments", "status IN ('submitted','pending_confirmation','confirmed')", scope)));
|
||||||
|
metrics.put("effectiveMatches", metric(operatorId, "analytics.view", () -> countMatches(scope)));
|
||||||
|
metrics.put("activityRegistrations", metric(operatorId, "activity.review_registration", () -> count("activity_registrations", "status IN ('pending','approved')", scope)));
|
||||||
|
metrics.put("matchmakerTodos", metric(operatorId, "matchmaker.view", () -> count("matchmaker_assignments", "status = 'active'", scope)));
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("scope", scope.type());
|
||||||
|
result.put("scopeLabel", "ALL".equals(scope.type()) ? "全平台" : "TEAM".equals(scope.type()) ? "本组织团队" : "本人服务");
|
||||||
|
result.put("metrics", metrics);
|
||||||
|
result.put("generatedAt", java.time.Instant.now());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MatchmakerDashboardScope resolveScope(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.UNAUTHORIZED, "运营账号不存在"));
|
||||||
|
return resolveScope(operator, roleMapper.findByOperatorId(operatorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchmakerDashboardScope resolveScope(Operator operator, List<Role> roles) {
|
||||||
|
boolean all = roles.stream().anyMatch(r -> Set.of("super_admin", "platform_admin").contains(r.getCode()));
|
||||||
|
if (all) return new MatchmakerDashboardScope("ALL", operator.getId(), operator.getOrganizationId());
|
||||||
|
boolean team = roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()));
|
||||||
|
if (team && (operator.getOrganizationId() == null || operator.getOrganizationId().isBlank())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CROSS_ORGANIZATION_DENIED, "红娘组长必须绑定组织");
|
||||||
|
}
|
||||||
|
return new MatchmakerDashboardScope(team ? "TEAM" : "SELF", operator.getId(), operator.getOrganizationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> metric(String operatorId, String detailPermission, java.util.function.LongSupplier valueSupplier) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("detailPermission", detailPermission);
|
||||||
|
if (!permissionService.hasPermission(operatorId, detailPermission)) {
|
||||||
|
item.put("available", false);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
item.put("value", valueSupplier.getAsLong());
|
||||||
|
item.put("available", true);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long count(String table, String statusPredicate, MatchmakerDashboardScope scope) {
|
||||||
|
String ownerColumn = "activity_registrations".equals(table) ? "created_by_operator_id"
|
||||||
|
: "matchmaker_assignments".equals(table) ? "matchmaker_operator_id" : "assigned_operator_id";
|
||||||
|
String sql = "SELECT COUNT(*) FROM " + table + " x WHERE x.deleted_at IS NULL AND " + statusPredicate
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = x.user_id"
|
||||||
|
+ " AND up.published_profile_revision IS NOT NULL"
|
||||||
|
+ " AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.all()) return jdbcTemplate.queryForObject(sql, Long.class);
|
||||||
|
if (scope.team()) {
|
||||||
|
return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " IN (SELECT id FROM operators WHERE organization_id = ?)", Long.class, scope.organizationId());
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForObject(sql + " AND x." + ownerColumn + " = ?", Long.class, scope.operatorId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long countMatches(MatchmakerDashboardScope scope) {
|
||||||
|
String base = "SELECT COUNT(DISTINCT m.id) FROM matches m WHERE m.status='active' AND m.source='matchmaker'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_a_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM user_profiles up WHERE up.user_id = m.user_b_id AND up.published_profile_revision IS NOT NULL AND up.published_profile_revision = up.profile_revision)";
|
||||||
|
if (scope.all()) return jdbcTemplate.queryForObject(base, Long.class);
|
||||||
|
String join = " AND EXISTS (SELECT 1 FROM matchmaker_recommendations r JOIN matchmaker_services s ON s.id=r.service_id JOIN operators o ON o.id=s.assigned_operator_id WHERE ((r.user_id=m.user_a_id AND r.target_user_id=m.user_b_id) OR (r.user_id=m.user_b_id AND r.target_user_id=m.user_a_id)) AND "
|
||||||
|
+ (scope.team() ? "o.organization_id = ?" : "(r.operator_id=? OR s.assigned_operator_id=?)") + ")";
|
||||||
|
return scope.team() ? jdbcTemplate.queryForObject(base + join, Long.class, scope.organizationId())
|
||||||
|
: jdbcTemplate.queryForObject(base + join, Long.class, scope.operatorId(), scope.operatorId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.danyang.match.platform.operator.controller;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.operator.service.OperatorManagementService;
|
||||||
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/operator/matchmaker-team")
|
||||||
|
@PreAuthorize("hasAuthority('matchmaker.role.manage')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MatchmakerTeamController {
|
||||||
|
private static final Set<String> MANAGED_ROLES = Set.of("matchmaker", "matchmaker_leader");
|
||||||
|
private final OperatorManagementService operatorService;
|
||||||
|
|
||||||
|
@GetMapping("/members")
|
||||||
|
public ApiResponse<Map<String, Object>> members(
|
||||||
|
@RequestParam(defaultValue = "0") Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") Integer pageSize,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String status) {
|
||||||
|
return ApiResponse.ok(operatorService.listMatchmakerOperators(page, pageSize, keyword, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public ApiResponse<List<Map<String, Object>>> roles() {
|
||||||
|
return ApiResponse.ok(operatorService.listMatchmakerRoleTemplates());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/members")
|
||||||
|
public ApiResponse<?> create(@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.createMatchmakerOperator(params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}")
|
||||||
|
public ApiResponse<Map<String, Object>> update(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.updateMatchmakerOperator(id, params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/enable")
|
||||||
|
public ApiResponse<Void> enable(@PathVariable String id, @AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
operatorService.enableOperator(id, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/disable")
|
||||||
|
public ApiResponse<Void> disable(@PathVariable String id, @AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
operatorService.disableOperator(id, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/members/{id}/reset-password")
|
||||||
|
public ApiResponse<Void> resetPassword(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, String> body,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
operatorService.assertMatchmakerTarget(id);
|
||||||
|
String newPassword = body.get("newPassword");
|
||||||
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "密码至少8位");
|
||||||
|
}
|
||||||
|
operatorService.resetPassword(id, newPassword, principal.id());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -34,12 +34,9 @@ public class OperatorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<?> create(@RequestBody Map<String, Object> params) {
|
public ApiResponse<?> create(@RequestBody Map<String, Object> params,
|
||||||
String name = (String) params.get("name");
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
String username = (String) params.get("username");
|
return ApiResponse.ok(operatorService.createOperator(params, principal.id()));
|
||||||
String password = (String) params.get("password");
|
|
||||||
String organizationId = (String) params.get("organizationId");
|
|
||||||
return ApiResponse.ok(operatorService.createOperator(name, username, password, organizationId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/disable")
|
@PutMapping("/{id}/disable")
|
||||||
@@ -73,4 +70,21 @@ public class OperatorController {
|
|||||||
return ApiResponse.ok(operatorService.listOrganizations());
|
return ApiResponse.ok(operatorService.listOrganizations());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public ApiResponse<List<?>> listRoles(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.listAssignableRoles(principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<Map<String, Object>> detail(@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(operatorService.getOperatorDetail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/roles")
|
||||||
|
public ApiResponse<Map<String, Object>> updateRoles(@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> params,
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||||
|
return ApiResponse.ok(operatorService.updateRoles(id, params, principal.id()));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-2
@@ -2,10 +2,17 @@ package com.danyang.match.platform.operator.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.danyang.match.platform.organization.entity.Organization;
|
import com.danyang.match.platform.organization.entity.Organization;
|
||||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||||
|
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
import com.danyang.match.platform.permission.entity.Operator;
|
import com.danyang.match.platform.permission.entity.Operator;
|
||||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.PermissionMapper;
|
||||||
|
import com.danyang.match.platform.permission.mapper.RoleMapper;
|
||||||
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
import com.danyang.match.platform.shared.constant.Constants;
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -21,9 +28,14 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class OperatorManagementService {
|
public class OperatorManagementService {
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private final OperatorMapper operatorMapper;
|
private final OperatorMapper operatorMapper;
|
||||||
private final OrganizationMapper organizationMapper;
|
private final OrganizationMapper organizationMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final PermissionMapper permissionMapper;
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
public Map<String, Object> listOperators(Integer page, Integer pageSize, String keyword, String status) {
|
public Map<String, Object> listOperators(Integer page, Integer pageSize, String keyword, String status) {
|
||||||
LambdaQueryWrapper<Operator> query = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<Operator> query = new LambdaQueryWrapper<>();
|
||||||
@@ -48,6 +60,10 @@ public class OperatorManagementService {
|
|||||||
m.put("name", op.getName());
|
m.put("name", op.getName());
|
||||||
m.put("username", op.getUsername());
|
m.put("username", op.getUsername());
|
||||||
m.put("status", op.getStatus());
|
m.put("status", op.getStatus());
|
||||||
|
m.put("organizationId", op.getOrganizationId());
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(op.getId());
|
||||||
|
m.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
m.put("scope", deriveScope(roles));
|
||||||
m.put("createdAt", op.getCreatedAt());
|
m.put("createdAt", op.getCreatedAt());
|
||||||
return m;
|
return m;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -60,8 +76,119 @@ public class OperatorManagementService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> listMatchmakerOperators(Integer page, Integer pageSize, String keyword, String status) {
|
||||||
|
List<Operator> filtered = operatorMapper.findMatchmakerOperators().stream()
|
||||||
|
.filter(op -> keyword == null || keyword.isBlank()
|
||||||
|
|| (op.getName() != null && op.getName().contains(keyword))
|
||||||
|
|| (op.getUsername() != null && op.getUsername().contains(keyword)))
|
||||||
|
.filter(op -> status == null || status.isBlank() || status.equals(op.getStatus()))
|
||||||
|
.toList();
|
||||||
|
int currentPage = Math.max(page == null ? 0 : page, 0);
|
||||||
|
int limit = Math.max(pageSize == null ? 20 : pageSize, 1);
|
||||||
|
int from = Math.min(currentPage * limit, filtered.size());
|
||||||
|
int to = Math.min(from + limit, filtered.size());
|
||||||
|
List<Map<String, Object>> items = filtered.subList(from, to).stream().map(this::operatorSummary).toList();
|
||||||
|
return Map.of("items", items, "total", filtered.size(), "page", currentPage, "pageSize", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listMatchmakerRoleTemplates() {
|
||||||
|
return roleMapper.selectList(new LambdaQueryWrapper<Role>()
|
||||||
|
.in(Role::getCode, List.of("matchmaker", "matchmaker_leader"))
|
||||||
|
.orderByAsc(Role::getCode)).stream()
|
||||||
|
.map(role -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("code", role.getCode());
|
||||||
|
item.put("name", role.getName());
|
||||||
|
item.put("description", role.getDescription());
|
||||||
|
item.put("scope", scopeForRole(role.getCode()));
|
||||||
|
item.put("permissions", roleMapper.findPermissionCodes(role.getId()));
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Operator createOperator(String name, String username, String password, String organizationId) {
|
public Map<String, Object> createMatchmakerOperator(Map<String, Object> params, String byOperatorId) {
|
||||||
|
List<String> roles = roleCodes(params.get("roleCodes"));
|
||||||
|
if (roles.isEmpty() && params.get("roleCode") != null) roles = List.of(String.valueOf(params.get("roleCode")));
|
||||||
|
assertMatchmakerRoles(roles);
|
||||||
|
Map<String, Object> normalized = new HashMap<>(params);
|
||||||
|
String name = requireText(params, "name");
|
||||||
|
validateMatchmakerName(name);
|
||||||
|
normalized.put("name", name);
|
||||||
|
normalized.put("username", generateMatchmakerUsername(name));
|
||||||
|
normalized.put("roleCodes", roles);
|
||||||
|
Operator created = createOperator(normalized, byOperatorId);
|
||||||
|
Map<String, Object> result = getOperatorDetail(created.getId());
|
||||||
|
result.put("usernameGenerated", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateMatchmakerOperator(String operatorId, Map<String, Object> params, String byOperatorId) {
|
||||||
|
if (operatorId.equals(byOperatorId)) throw new BusinessException(ApiResponseCode.FORBIDDEN, "不能修改自己的角色");
|
||||||
|
assertMatchmakerTarget(operatorId);
|
||||||
|
List<String> roles = roleCodes(params.get("roleCodes"));
|
||||||
|
if (roles.isEmpty() && params.get("roleCode") != null) roles = List.of(String.valueOf(params.get("roleCode")));
|
||||||
|
assertMatchmakerRoles(roles);
|
||||||
|
Map<String, Object> normalized = new HashMap<>(params);
|
||||||
|
normalized.put("roleCodes", roles);
|
||||||
|
return updateRoles(operatorId, normalized, byOperatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void assertMatchmakerTarget(String operatorId) {
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
if (roles.stream().noneMatch(role -> Set.of("matchmaker", "matchmaker_leader").contains(role.getCode()))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "目标账号不是红娘团队成员");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertMatchmakerRoles(List<String> roleCodes) {
|
||||||
|
if (roleCodes.size() != 1 || roleCodes.stream().noneMatch(code -> Set.of("matchmaker", "matchmaker_leader").contains(code))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "只能选择普通红娘或红娘组长角色");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMatchmakerName(String name) {
|
||||||
|
if (!name.matches("^[\\p{IsHan}]{2,20}$")) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "红娘姓名必须为2至20个中文字符");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateMatchmakerUsername(String name) {
|
||||||
|
String prefix = "红娘" + name;
|
||||||
|
for (int sequence = 1; sequence <= 9999; sequence++) {
|
||||||
|
String candidate = prefix + String.format("%04d", sequence);
|
||||||
|
if (operatorMapper.findByUsername(candidate).isEmpty()) return candidate;
|
||||||
|
}
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该姓名的红娘用户名已达到可分配上限");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> operatorSummary(Operator op) {
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(op.getId());
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", op.getId());
|
||||||
|
item.put("displayId", op.getDisplayId());
|
||||||
|
item.put("name", op.getName());
|
||||||
|
item.put("username", op.getUsername());
|
||||||
|
item.put("status", op.getStatus());
|
||||||
|
item.put("organizationId", op.getOrganizationId());
|
||||||
|
item.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
item.put("scope", deriveScope(roles));
|
||||||
|
item.put("createdAt", op.getCreatedAt());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Operator createOperator(Map<String, Object> params, String byOperatorId) {
|
||||||
|
String name = requireText(params, "name");
|
||||||
|
String username = requireText(params, "username");
|
||||||
|
String password = requireText(params, "password");
|
||||||
|
String organizationId = (String) params.get("organizationId");
|
||||||
|
List<String> roleCodes = roleCodes(params.get("roleCodes"));
|
||||||
|
validateRoles(roleCodes, organizationId, byOperatorId, null);
|
||||||
|
if (operatorMapper.findByUsername(username).isPresent()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
Operator operator = new Operator();
|
Operator operator = new Operator();
|
||||||
operator.setId(UUID.randomUUID().toString());
|
operator.setId(UUID.randomUUID().toString());
|
||||||
operator.setName(name);
|
operator.setName(name);
|
||||||
@@ -71,9 +198,115 @@ public class OperatorManagementService {
|
|||||||
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
operator.setCreatedAt(Instant.now());
|
operator.setCreatedAt(Instant.now());
|
||||||
operatorMapper.insert(operator);
|
operatorMapper.insert(operator);
|
||||||
|
bindRoles(operator.getId(), roleCodes);
|
||||||
|
audit(byOperatorId, "CREATE_OPERATOR", operator.getId(), roleCodes);
|
||||||
return operator;
|
return operator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listAssignableRoles(String operatorId) {
|
||||||
|
boolean superAdmin = permissionMapper.findPermissionsByOperatorId(operatorId).stream()
|
||||||
|
.anyMatch(p -> "account.retention.purge".equals(p.getCode()));
|
||||||
|
return roleMapper.findAssignableTemplates().stream()
|
||||||
|
.filter(r -> superAdmin || !Set.of("platform_admin").contains(r.getCode()))
|
||||||
|
.map(r -> {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("code", r.getCode());
|
||||||
|
item.put("name", r.getName());
|
||||||
|
item.put("description", r.getDescription());
|
||||||
|
item.put("scope", scopeForRole(r.getCode()));
|
||||||
|
item.put("permissions", roleMapper.findPermissionCodes(r.getId()));
|
||||||
|
return item;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getOperatorDetail(String operatorId) {
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"));
|
||||||
|
List<Role> roles = roleMapper.findByOperatorId(operatorId);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("id", operator.getId());
|
||||||
|
result.put("username", operator.getUsername());
|
||||||
|
result.put("name", operator.getName());
|
||||||
|
result.put("organizationId", operator.getOrganizationId());
|
||||||
|
result.put("status", operator.getStatus());
|
||||||
|
result.put("roles", roles.stream().map(Role::getCode).toList());
|
||||||
|
result.put("scope", deriveScope(roles));
|
||||||
|
result.put("permissions", roles.stream().flatMap(r -> roleMapper.findPermissionCodes(r.getId()).stream()).distinct().sorted().toList());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateRoles(String operatorId, Map<String, Object> params, String byOperatorId) {
|
||||||
|
if (operatorId.equals(byOperatorId)) throw new BusinessException(ApiResponseCode.FORBIDDEN, "不能修改自己的角色");
|
||||||
|
Operator operator = operatorMapper.findById(operatorId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在"));
|
||||||
|
List<String> roleCodes = roleCodes(params.get("roleCodes"));
|
||||||
|
String organizationId = params.containsKey("organizationId") ? (String) params.get("organizationId") : operator.getOrganizationId();
|
||||||
|
validateRoles(roleCodes, organizationId, byOperatorId, operatorId);
|
||||||
|
operator.setOrganizationId(organizationId);
|
||||||
|
operator.setUpdatedAt(Instant.now());
|
||||||
|
operatorMapper.updateById(operator);
|
||||||
|
roleMapper.unbindAll(operatorId);
|
||||||
|
bindRoles(operatorId, roleCodes);
|
||||||
|
audit(byOperatorId, "UPDATE_OPERATOR_ROLES", operatorId, roleCodes);
|
||||||
|
return getOperatorDetail(operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindRoles(String operatorId, List<String> roleCodes) {
|
||||||
|
for (String code : roleCodes) {
|
||||||
|
Role role = roleMapper.selectOne(new LambdaQueryWrapper<Role>().eq(Role::getCode, code));
|
||||||
|
if (role == null) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "角色不存在: " + code);
|
||||||
|
roleMapper.bind(operatorId, role.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRoles(List<String> roleCodes, String organizationId, String byOperatorId, String targetId) {
|
||||||
|
if (roleCodes.isEmpty()) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "至少选择一个角色");
|
||||||
|
if (roleCodes.contains("matchmaker_leader") && organizationId == null) {
|
||||||
|
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "红娘组长必须选择所属组织");
|
||||||
|
}
|
||||||
|
boolean superAdmin = permissionMapper.findPermissionsByOperatorId(byOperatorId).stream()
|
||||||
|
.anyMatch(p -> "account.retention.purge".equals(p.getCode()));
|
||||||
|
if (!superAdmin && roleCodes.stream().anyMatch(Set.of("super_admin", "platform_admin")::contains)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.FORBIDDEN, "当前账号不能分配管理员角色");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> roleCodes(Object value) {
|
||||||
|
if (!(value instanceof List<?> list)) return List.of();
|
||||||
|
return list.stream().map(String::valueOf).filter(s -> !s.isBlank()).distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireText(Map<String, Object> params, String key) {
|
||||||
|
String value = params.get(key) == null ? "" : String.valueOf(params.get(key)).trim();
|
||||||
|
if (value.isBlank()) throw new BusinessException(ApiResponseCode.BAD_REQUEST, key + "不能为空");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deriveScope(List<Role> roles) {
|
||||||
|
if (roles.stream().anyMatch(r -> Set.of("super_admin", "platform_admin").contains(r.getCode()))) return "ALL";
|
||||||
|
if (roles.stream().anyMatch(r -> "matchmaker_leader".equals(r.getCode()))) return "TEAM";
|
||||||
|
return "SELF";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scopeForRole(String code) {
|
||||||
|
return Set.of("super_admin", "platform_admin").contains(code) ? "ALL" : "matchmaker_leader".equals(code) ? "TEAM" : "SELF";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void audit(String byOperatorId, String action, String targetId, List<String> roles) {
|
||||||
|
AuditLog log = new AuditLog();
|
||||||
|
log.setOperatorId(byOperatorId);
|
||||||
|
log.setAction(action);
|
||||||
|
log.setResourceType("operator");
|
||||||
|
log.setResourceId(targetId);
|
||||||
|
try {
|
||||||
|
log.setAfterData(OBJECT_MAPPER.writeValueAsString(Map.of("roles", roles)));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("审计数据序列化失败", e);
|
||||||
|
}
|
||||||
|
auditLogMapper.insert(log);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void disableOperator(String operatorId, String byOperatorId) {
|
public void disableOperator(String operatorId, String byOperatorId) {
|
||||||
Operator operator = operatorMapper.selectById(operatorId);
|
Operator operator = operatorMapper.selectById(operatorId);
|
||||||
@@ -104,7 +337,7 @@ public class OperatorManagementService {
|
|||||||
public List<Organization> listOrganizations() {
|
public List<Organization> listOrganizations() {
|
||||||
return organizationMapper.selectList(
|
return organizationMapper.selectList(
|
||||||
new LambdaQueryWrapper<Organization>()
|
new LambdaQueryWrapper<Organization>()
|
||||||
.eq(Organization::getStatus, Constants.ACCOUNT_STATUS_NORMAL)
|
.eq(Organization::getStatus, Constants.ORG_STATUS_ACTIVE)
|
||||||
.orderByDesc(Organization::getCreatedAt));
|
.orderByDesc(Organization::getCreatedAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -166,7 +166,7 @@ public class OrganizationService {
|
|||||||
private static void requireAllowedType(String type) {
|
private static void requireAllowedType(String type) {
|
||||||
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
||||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
||||||
"机构类型不合法:" + type + ",允许值 " + ALLOWED_TYPES);
|
"机构类型不合法,请选择妇联、工会、公益服务中心、志愿者团队、民政合作单位或其他合作机构");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -6,14 +6,18 @@ import org.apache.ibatis.annotations.Mapper;
|
|||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface OperatorMapper extends BaseMapper<Operator> {
|
public interface OperatorMapper extends BaseMapper<Operator> {
|
||||||
|
|
||||||
@Select("SELECT * FROM operators WHERE username = #{username} AND deleted_at IS NULL LIMIT 1")
|
@Select("SELECT o.* FROM operators o LEFT JOIN operator_username_aliases a ON a.operator_id = o.id WHERE o.deleted_at IS NULL AND (o.username = #{username} OR a.username = #{username}) LIMIT 1")
|
||||||
Optional<Operator> findByUsername(@Param("username") String username);
|
Optional<Operator> findByUsername(@Param("username") String username);
|
||||||
|
|
||||||
@Select("SELECT * FROM operators WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
@Select("SELECT * FROM operators WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||||
Optional<Operator> findById(@Param("id") String id);
|
Optional<Operator> findById(@Param("id") String id);
|
||||||
|
|
||||||
|
@Select("SELECT DISTINCT o.* FROM operators o INNER JOIN operator_roles orl ON orl.operator_id = o.id INNER JOIN roles r ON r.id = orl.role_id WHERE o.deleted_at IS NULL AND r.code IN ('matchmaker','matchmaker_leader') ORDER BY o.created_at DESC")
|
||||||
|
List<Operator> findMatchmakerOperators();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -11,13 +11,13 @@ import java.util.List;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface PermissionMapper extends BaseMapper<Permission> {
|
public interface PermissionMapper extends BaseMapper<Permission> {
|
||||||
|
|
||||||
@Select("SELECT p.* FROM permissions p " +
|
@Select("SELECT DISTINCT p.* FROM permissions p " +
|
||||||
"INNER JOIN role_permissions rp ON p.id = rp.permission_id " +
|
"INNER JOIN role_permissions rp ON p.id = rp.permission_id " +
|
||||||
"INNER JOIN operator_roles ort ON rp.role_id = ort.role_id " +
|
"INNER JOIN operator_roles ort ON rp.role_id = ort.role_id " +
|
||||||
"WHERE ort.operator_id = #{operatorId}")
|
"WHERE ort.operator_id = #{operatorId}")
|
||||||
List<Permission> findPermissionsByOperatorId(@Param("operatorId") String operatorId);
|
List<Permission> findPermissionsByOperatorId(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
@Select("SELECT p.* FROM permissions p " +
|
@Select("SELECT DISTINCT p.* FROM permissions p " +
|
||||||
"INNER JOIN role_permissions rp ON p.id = rp.permission_id " +
|
"INNER JOIN role_permissions rp ON p.id = rp.permission_id " +
|
||||||
"INNER JOIN operator_roles ort ON rp.role_id = ort.role_id " +
|
"INNER JOIN operator_roles ort ON rp.role_id = ort.role_id " +
|
||||||
"WHERE ort.operator_id = #{operatorId} AND p.code = #{code}")
|
"WHERE ort.operator_id = #{operatorId} AND p.code = #{code}")
|
||||||
|
|||||||
@@ -3,7 +3,29 @@ package com.danyang.match.platform.permission.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.danyang.match.platform.permission.entity.Role;
|
import com.danyang.match.platform.permission.entity.Role;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface RoleMapper extends BaseMapper<Role> {
|
public interface RoleMapper extends BaseMapper<Role> {
|
||||||
|
|
||||||
|
@Select("SELECT r.* FROM roles r INNER JOIN operator_roles orl ON orl.role_id = r.id WHERE orl.operator_id = #{operatorId} ORDER BY r.code")
|
||||||
|
List<Role> findByOperatorId(@Param("operatorId") String operatorId);
|
||||||
|
|
||||||
|
@Select("SELECT r.* FROM roles r WHERE r.code IN ('matchmaker','matchmaker_leader','reviewer','activity_manager','risk_operator','service_station_operator','counselor','content_editor','platform_admin') ORDER BY r.code")
|
||||||
|
List<Role> findAssignableTemplates();
|
||||||
|
|
||||||
|
@Select("SELECT p.code FROM permissions p INNER JOIN role_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = #{roleId} ORDER BY p.code")
|
||||||
|
List<String> findPermissionCodes(@Param("roleId") String roleId);
|
||||||
|
|
||||||
|
@Insert("INSERT INTO operator_roles (operator_id, role_id) VALUES (#{operatorId}, #{roleId}) ON CONFLICT DO NOTHING")
|
||||||
|
int bind(@Param("operatorId") String operatorId, @Param("roleId") String roleId);
|
||||||
|
|
||||||
|
@Delete("DELETE FROM operator_roles WHERE operator_id = #{operatorId}")
|
||||||
|
int unbindAll(@Param("operatorId") String operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ public class UserProfile {
|
|||||||
private Integer profileCompletion;
|
private Integer profileCompletion;
|
||||||
private String profileStatus;
|
private String profileStatus;
|
||||||
|
|
||||||
|
@TableField("profile_revision")
|
||||||
|
private Integer profileRevision;
|
||||||
|
@TableField("published_profile_revision")
|
||||||
|
private Integer publishedProfileRevision;
|
||||||
|
@TableField("profile_review_status")
|
||||||
|
private String profileReviewStatus;
|
||||||
|
@TableField("profile_revision_updated_at")
|
||||||
|
private Instant profileRevisionUpdatedAt;
|
||||||
|
|
||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -113,6 +113,7 @@ public class UserProfileService {
|
|||||||
applyProfileFields(profile, fields);
|
applyProfileFields(profile, fields);
|
||||||
ensureIdentityFields(profile);
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
|
markRevisionForUpdate(profile, exists, false);
|
||||||
persistProfile(profile, exists);
|
persistProfile(profile, exists);
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
@@ -136,6 +137,7 @@ public class UserProfileService {
|
|||||||
ensureIdentityFields(profile);
|
ensureIdentityFields(profile);
|
||||||
profile.setProfileCompletion(calculateCompletion(profile));
|
profile.setProfileCompletion(calculateCompletion(profile));
|
||||||
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
|
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
|
||||||
|
markRevisionForUpdate(profile, exists, true);
|
||||||
persistProfile(profile, exists);
|
persistProfile(profile, exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,22 @@ public class UserProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已发布资料被修改时,保留旧发布版本并重新进入审核。 */
|
||||||
|
private void markRevisionForUpdate(UserProfile profile, boolean exists, boolean finalSubmit) {
|
||||||
|
if (profile.getProfileRevision() == null) profile.setProfileRevision(1);
|
||||||
|
boolean hasPublishedVersion = profile.getPublishedProfileRevision() != null;
|
||||||
|
if (exists && hasPublishedVersion) {
|
||||||
|
profile.setProfileRevision(profile.getProfileRevision() + 1);
|
||||||
|
profile.setProfileStatus(Constants.PROFILE_STATUS_PENDING_REVIEW);
|
||||||
|
profile.setProfileReviewStatus(Constants.PROFILE_STATUS_PENDING_REVIEW);
|
||||||
|
} else if (finalSubmit) {
|
||||||
|
profile.setProfileReviewStatus(Constants.PROFILE_STATUS_PENDING_REVIEW);
|
||||||
|
} else if (profile.getProfileReviewStatus() == null) {
|
||||||
|
profile.setProfileReviewStatus(profile.getProfileStatus());
|
||||||
|
}
|
||||||
|
profile.setProfileRevisionUpdatedAt(java.time.Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把请求字段映射到资料实体。
|
* 把请求字段映射到资料实体。
|
||||||
*
|
*
|
||||||
@@ -281,6 +299,11 @@ public class UserProfileService {
|
|||||||
public PublicUserProfileDTO getPublicProfile(String viewerId, String targetUserId) {
|
public PublicUserProfileDTO getPublicProfile(String viewerId, String targetUserId) {
|
||||||
UserProfile profile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
UserProfile profile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||||
if (profile == null) return null;
|
if (profile == null) return null;
|
||||||
|
if (!Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|
||||||
|
|| profile.getPublishedProfileRevision() == null
|
||||||
|
|| !java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// INV-004:Block 优先级最高 —— 任一方向拉黑后互相不可见。
|
// INV-004:Block 优先级最高 —— 任一方向拉黑后互相不可见。
|
||||||
// 必须排在隐私设置之前:拉黑是用户的显式阻断,不能被对方"允许搜索可见"的设置覆盖。
|
// 必须排在隐私设置之前:拉黑是用户的显式阻断,不能被对方"允许搜索可见"的设置覆盖。
|
||||||
@@ -306,7 +329,7 @@ public class UserProfileService {
|
|||||||
dto.setMaritalStatus(profile.getMaritalStatus());
|
dto.setMaritalStatus(profile.getMaritalStatus());
|
||||||
dto.setChildrenStatus(profile.getChildrenStatus());
|
dto.setChildrenStatus(profile.getChildrenStatus());
|
||||||
dto.setVerified(true); // Phase 03 已认证
|
dto.setVerified(true); // Phase 03 已认证
|
||||||
dto.setReviewed(Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
|
dto.setReviewed(true);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -540,10 +540,15 @@ public class ReviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateProfileStatus(String userId, String status) {
|
private void updateProfileStatus(String userId, String status) {
|
||||||
userProfileMapper.update(null,
|
LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile> update =
|
||||||
new LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile>()
|
new LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile>()
|
||||||
.eq(com.danyang.match.platform.profile.entity.UserProfile::getUserId, userId)
|
.eq(com.danyang.match.platform.profile.entity.UserProfile::getUserId, userId)
|
||||||
.set(com.danyang.match.platform.profile.entity.UserProfile::getProfileStatus, status));
|
.set(com.danyang.match.platform.profile.entity.UserProfile::getProfileStatus, status)
|
||||||
|
.set(com.danyang.match.platform.profile.entity.UserProfile::getProfileReviewStatus, status);
|
||||||
|
if (Constants.PROFILE_STATUS_APPROVED.equals(status)) {
|
||||||
|
update.setSql("published_profile_revision = profile_revision");
|
||||||
|
}
|
||||||
|
userProfileMapper.update(null, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createReviewItems(String taskId, String comment, boolean approved) {
|
private void createReviewItems(String taskId, String comment, boolean approved) {
|
||||||
|
|||||||
+7
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务预约控制器(用户端 + 后台)
|
* 服务预约控制器(用户端 + 后台)
|
||||||
@@ -25,6 +26,8 @@ import java.util.Map;
|
|||||||
public class ServiceAppointmentController {
|
public class ServiceAppointmentController {
|
||||||
|
|
||||||
private final ServiceAppointmentService serviceAppointmentService;
|
private final ServiceAppointmentService serviceAppointmentService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
// ---- 用户端 ----
|
// ---- 用户端 ----
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ public class ServiceAppointmentController {
|
|||||||
@RequestBody ServiceAppointmentRequest request) {
|
@RequestBody ServiceAppointmentRequest request) {
|
||||||
// 用户端只能为当前登录用户创建;代办登记必须走独立运营权限端点。
|
// 用户端只能为当前登录用户创建;代办登记必须走独立运营权限端点。
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(serviceAppointmentService.createAppointment(userId, request));
|
return ApiResponse.ok(serviceAppointmentService.createAppointment(userId, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ public class ServiceAppointmentController {
|
|||||||
public ApiResponse<List<ServiceAppointmentDTO>> getMyAppointments(
|
public ApiResponse<List<ServiceAppointmentDTO>> getMyAppointments(
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(serviceAppointmentService.getMyAppointments(userId));
|
return ApiResponse.ok(serviceAppointmentService.getMyAppointments(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +55,7 @@ public class ServiceAppointmentController {
|
|||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
ServiceAppointmentDTO detail = serviceAppointmentService.getAppointmentDetail(id, userId);
|
ServiceAppointmentDTO detail = serviceAppointmentService.getAppointmentDetail(id, userId);
|
||||||
if (detail == null) {
|
if (detail == null) {
|
||||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||||
@@ -63,6 +69,7 @@ public class ServiceAppointmentController {
|
|||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@RequestBody Map<String, String> body) {
|
@RequestBody Map<String, String> body) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
String reason = body.getOrDefault("reason", "");
|
String reason = body.getOrDefault("reason", "");
|
||||||
return ApiResponse.ok(serviceAppointmentService.cancelAppointment(id, userId, reason));
|
return ApiResponse.ok(serviceAppointmentService.cancelAppointment(id, userId, reason));
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -12,17 +12,21 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/service-programs")
|
@RequestMapping("/service-programs")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ServiceProgramController {
|
public class ServiceProgramController {
|
||||||
private final ServiceProgramService serviceProgramService;
|
private final ServiceProgramService serviceProgramService;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private ServiceConsentService serviceConsentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户端项目列表。**只返回已激活项目** —— 此前不限定状态,草稿项目会直接下发给小程序。
|
* 用户端项目列表。**只返回已激活项目** —— 此前不限定状态,草稿项目会直接下发给小程序。
|
||||||
*/
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ApiResponse<List<ServiceProgramDTO>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
public ApiResponse<List<ServiceProgramDTO>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(serviceProgramService.getActivePrograms());
|
return ApiResponse.ok(serviceProgramService.getActivePrograms());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ public class ServiceProgramController {
|
|||||||
public ApiResponse<ServiceProgramDTO> detail(
|
public ApiResponse<ServiceProgramDTO> detail(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
ServiceProgramDTO d = serviceProgramService.getPublicProgramDetail(id);
|
ServiceProgramDTO d = serviceProgramService.getPublicProgramDetail(id);
|
||||||
if (d == null) return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
if (d == null) return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||||
return ApiResponse.ok(d);
|
return ApiResponse.ok(d);
|
||||||
@@ -42,7 +47,9 @@ public class ServiceProgramController {
|
|||||||
public ApiResponse<ServiceProgramMemberDTO> apply(
|
public ApiResponse<ServiceProgramMemberDTO> apply(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(serviceProgramService.applyToProgram(userId, id));
|
return ApiResponse.ok(serviceProgramService.applyToProgram(userId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ public class ServiceProgramController {
|
|||||||
public ApiResponse<List<ServiceProgramMemberDTO>> getMyApplications(
|
public ApiResponse<List<ServiceProgramMemberDTO>> getMyApplications(
|
||||||
@AuthenticationPrincipal UserPrincipal principal) {
|
@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
String userId = principal.id();
|
String userId = principal.id();
|
||||||
|
serviceConsentService.requireActive(userId);
|
||||||
return ApiResponse.ok(serviceProgramService.getMyProgramMembers(userId));
|
return ApiResponse.ok(serviceProgramService.getMyProgramMembers(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ public class ServiceProgramController {
|
|||||||
public ApiResponse<ServiceProgramMemberDTO> withdraw(
|
public ApiResponse<ServiceProgramMemberDTO> withdraw(
|
||||||
@AuthenticationPrincipal UserPrincipal principal,
|
@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable String id) {
|
@PathVariable String id) {
|
||||||
|
serviceConsentService.requireActive(principal.id());
|
||||||
return ApiResponse.ok(serviceProgramService.withdrawFromProgram(principal.id(), id));
|
return ApiResponse.ok(serviceProgramService.withdrawFromProgram(principal.id(), id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class SecurityConfig {
|
|||||||
.accessDeniedHandler(forbiddenHandler(objectMapper)))
|
.accessDeniedHandler(forbiddenHandler(objectMapper)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// 公开接口(order=0 最高优先级)
|
// 公开接口(order=0 最高优先级)
|
||||||
.requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/refresh", "/auth/operator/login")
|
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers("/health/**").permitAll()
|
.requestMatchers("/health/**").permitAll()
|
||||||
.requestMatchers("/version").permitAll()
|
.requestMatchers("/version").permitAll()
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ package com.danyang.match.platform.user.controller;
|
|||||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||||
import com.danyang.match.platform.user.service.UserService;
|
import com.danyang.match.platform.user.service.UserService;
|
||||||
|
import com.danyang.match.platform.user.service.UserOnboardingService;
|
||||||
|
import com.danyang.match.platform.user.service.UserImportService;
|
||||||
|
import com.danyang.match.platform.user.service.UserCredentialService;
|
||||||
|
import com.danyang.match.platform.user.dto.OperatorCreateUserRequest;
|
||||||
|
import com.danyang.match.platform.user.dto.UserImportPreviewResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@@ -19,6 +26,40 @@ import java.util.Map;
|
|||||||
public class UserController {
|
public class UserController {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final UserOnboardingService onboardingService;
|
||||||
|
private final UserImportService importService;
|
||||||
|
private final UserCredentialService credentialService;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('user.create')")
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<Map<String, Object>> create(@Valid @RequestBody OperatorCreateUserRequest request) {
|
||||||
|
return ApiResponse.ok(onboardingService.create(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('user.create')")
|
||||||
|
@PostMapping(value = "/import/preview", consumes = "text/csv")
|
||||||
|
public ApiResponse<UserImportPreviewResponse> preview(@RequestBody byte[] csv) {
|
||||||
|
return ApiResponse.ok(importService.preview(csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('user.create')")
|
||||||
|
@PostMapping("/import/commit")
|
||||||
|
public ApiResponse<Map<String, Object>> commit(
|
||||||
|
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String idempotencyKey = body.get("idempotencyKey");
|
||||||
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
||||||
|
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "idempotencyKey不能为空");
|
||||||
|
}
|
||||||
|
int committed = importService.commit(body.get("batchId"), principal.id(), idempotencyKey);
|
||||||
|
return ApiResponse.ok(Map.of("committed", committed));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('user.create')")
|
||||||
|
@PostMapping("/{id}/issue-account")
|
||||||
|
public ApiResponse<Map<String, Object>> issueAccount(@PathVariable String id) {
|
||||||
|
return ApiResponse.ok(credentialService.issue(id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户列表(分页+筛选)
|
* 获取用户列表(分页+筛选)
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record InitialPasswordChangeRequest(@NotBlank String oldPassword, @NotBlank String newPassword) {}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record OperatorCreateUserRequest(
|
||||||
|
@NotBlank @Size(max = 100) String name,
|
||||||
|
@NotBlank @Size(max = 200) String organization,
|
||||||
|
@NotBlank @Size(max = 32) String organizationPhone,
|
||||||
|
@NotBlank @Pattern(regexp = "^1\\d{10}$") String phone
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record PasswordLoginRequest(@NotBlank String phone, @NotBlank String password) {}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
public record PhoneVerificationRequest(
|
||||||
|
@NotBlank @Pattern(regexp = "^1\\d{10}$") String phone,
|
||||||
|
@NotBlank String code
|
||||||
|
) {
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record UserImportPreviewResponse(String batchId, int totalRows, int validRows,
|
||||||
|
int failedRows, List<Map<String, Object>> rows) {}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.danyang.match.platform.user.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record UserRegistrationRequest(
|
||||||
|
@NotBlank @Size(max = 100) String name,
|
||||||
|
@NotBlank @Size(max = 200) String organization,
|
||||||
|
@NotBlank @Size(max = 32) String organizationPhone,
|
||||||
|
@NotBlank @Pattern(regexp = "^1\\d{10}$") String phone,
|
||||||
|
@NotBlank @Size(max = 64) String verificationTransaction
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -30,6 +30,15 @@ public class User extends BaseEntity {
|
|||||||
@TableField("recommendation_enabled")
|
@TableField("recommendation_enabled")
|
||||||
private Boolean recommendationEnabled;
|
private Boolean recommendationEnabled;
|
||||||
|
|
||||||
|
@TableField("phone_verification_status")
|
||||||
|
private String phoneVerificationStatus;
|
||||||
|
@TableField("account_creation_status")
|
||||||
|
private String accountCreationStatus;
|
||||||
|
@TableField("credential_delivery_status")
|
||||||
|
private String credentialDeliveryStatus;
|
||||||
|
@TableField("service_eligibility_status")
|
||||||
|
private String serviceEligibilityStatus;
|
||||||
|
|
||||||
@TableField("last_login_at")
|
@TableField("last_login_at")
|
||||||
private java.time.Instant lastLoginAt;
|
private java.time.Instant lastLoginAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.danyang.match.platform.user.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_import_batches")
|
||||||
|
public class UserImportBatch extends BaseEntity {
|
||||||
|
private String createdBy;
|
||||||
|
private String sourceFilename;
|
||||||
|
private String sourceEncoding;
|
||||||
|
private String status;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private Integer totalRows;
|
||||||
|
private Integer validRows;
|
||||||
|
private Integer failedRows;
|
||||||
|
private Instant committedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.danyang.match.platform.user.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_import_rows")
|
||||||
|
public class UserImportRow extends BaseEntity {
|
||||||
|
private String batchId;
|
||||||
|
private Integer rowNumber;
|
||||||
|
private String normalizedData;
|
||||||
|
private String status;
|
||||||
|
private String errorCode;
|
||||||
|
private String errorMessage;
|
||||||
|
private String userId;
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.danyang.match.platform.user.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("user_registration_applications")
|
||||||
|
public class UserRegistrationApplication extends BaseEntity {
|
||||||
|
private String userId;
|
||||||
|
private String applicantName;
|
||||||
|
private String organizationName;
|
||||||
|
private String organizationPhone;
|
||||||
|
private String phone;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.danyang.match.platform.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.account.entity.UserCredential;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserCredentialMapper extends BaseMapper<UserCredential> {
|
||||||
|
@Select("SELECT * FROM user_credentials WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||||
|
Optional<UserCredential> findByUserId(String userId);
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.danyang.match.platform.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.user.entity.UserImportBatch;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserImportBatchMapper extends BaseMapper<UserImportBatch> {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.danyang.match.platform.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.user.entity.UserImportRow;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserImportRowMapper extends BaseMapper<UserImportRow> {}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.danyang.match.platform.user.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.danyang.match.platform.user.entity.UserRegistrationApplication;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserRegistrationApplicationMapper extends BaseMapper<UserRegistrationApplication> {
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.danyang.match.platform.user.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.auth.provider.SmsVerificationProvider;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PhoneVerificationService {
|
||||||
|
private static final Duration CODE_TTL = Duration.ofMinutes(5);
|
||||||
|
private static final Duration TRANSACTION_TTL = Duration.ofMinutes(10);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final SmsVerificationProvider smsProvider;
|
||||||
|
|
||||||
|
public void sendCode(String phone) {
|
||||||
|
if (Boolean.TRUE.equals(redisTemplate.hasKey(sendLimitKey(phone)))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.RATE_LIMITED, "验证码发送过于频繁,请稍后再试");
|
||||||
|
}
|
||||||
|
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||||
|
smsProvider.sendCode(phone, code);
|
||||||
|
redisTemplate.opsForValue().set(sendLimitKey(phone), "1", Duration.ofSeconds(60));
|
||||||
|
redisTemplate.opsForValue().set(codeKey(phone), hash(code), CODE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String verify(String phone, String code) {
|
||||||
|
String attempts = redisTemplate.opsForValue().get(attemptKey(phone));
|
||||||
|
if (attempts != null && Integer.parseInt(attempts) >= 5) {
|
||||||
|
throw new BusinessException(ApiResponseCode.RATE_LIMITED, "验证码错误次数过多,请重新获取");
|
||||||
|
}
|
||||||
|
String storedHash = redisTemplate.opsForValue().get(codeKey(phone));
|
||||||
|
if (storedHash == null || !storedHash.equals(hash(code))) {
|
||||||
|
redisTemplate.opsForValue().increment(attemptKey(phone));
|
||||||
|
redisTemplate.expire(attemptKey(phone), CODE_TTL);
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "验证码错误或已过期");
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(redisTemplate.delete(codeKey(phone)))) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "验证码已使用");
|
||||||
|
}
|
||||||
|
redisTemplate.delete(attemptKey(phone));
|
||||||
|
String transaction = UUID.randomUUID().toString();
|
||||||
|
redisTemplate.opsForValue().set(transactionKey(transaction), phone, TRANSACTION_TTL);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String consumeTransaction(String transaction, String expectedPhone) {
|
||||||
|
String phone = redisTemplate.opsForValue().get(transactionKey(transaction));
|
||||||
|
if (phone == null || !phone.equals(expectedPhone)) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "手机号核验事务无效或已过期");
|
||||||
|
}
|
||||||
|
redisTemplate.delete(transactionKey(transaction));
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String codeKey(String phone) { return "auth:phone:code:" + phone; }
|
||||||
|
private String attemptKey(String phone) { return "auth:phone:attempts:" + phone; }
|
||||||
|
private String sendLimitKey(String phone) { return "auth:phone:send-limit:" + phone; }
|
||||||
|
private String transactionKey(String transaction) { return "auth:phone:verified:" + transaction; }
|
||||||
|
|
||||||
|
private String hash(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("验证码摘要算法不可用", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package com.danyang.match.platform.user.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.entity.UserCredential;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserCredentialService {
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private final UserCredentialMapper credentialMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final UserProfileMapper profileMapper;
|
||||||
|
private final UserConsentStateMapper consentStateMapper;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> issue(String userId) {
|
||||||
|
User user = userMapper.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||||
|
UserProfile profile = profileMapper.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ApiResponseCode.CONFLICT, "用户资料尚未完成审核"));
|
||||||
|
if (!"verified".equals(user.getPhoneVerificationStatus())
|
||||||
|
|| !"approved".equals(profile.getProfileReviewStatus())
|
||||||
|
|| !java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())
|
||||||
|
|| consentStateMapper.countAgreed(userId) < 2) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "手机号、资料审核和服务授权未全部完成");
|
||||||
|
}
|
||||||
|
UserCredential credential = credentialMapper.findByUserId(userId).orElseGet(UserCredential::new);
|
||||||
|
String reference = "CRED-" + randomReference();
|
||||||
|
credential.setUserId(userId);
|
||||||
|
credential.setPasswordHash(passwordEncoder.encode(randomSecret()));
|
||||||
|
credential.setCredentialVersion(credential.getCredentialVersion() == null ? 1 : credential.getCredentialVersion() + 1);
|
||||||
|
credential.setMustChangePassword(true);
|
||||||
|
credential.setIssuedAt(Instant.now());
|
||||||
|
credential.setRevokedAt(null);
|
||||||
|
if (credential.getId() == null) credentialMapper.insert(credential); else credentialMapper.updateById(credential);
|
||||||
|
user.setCredentialDeliveryStatus("issued");
|
||||||
|
userMapper.updateById(user);
|
||||||
|
return Map.of("userId", userId, "credentialReference", reference, "deliveryStatus", "issued");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String randomSecret() {
|
||||||
|
return randomReference() + "A1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String randomReference() {
|
||||||
|
return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.danyang.match.platform.user.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.dto.UserImportPreviewResponse;
|
||||||
|
import com.danyang.match.platform.user.dto.OperatorCreateUserRequest;
|
||||||
|
import com.danyang.match.platform.user.entity.UserImportBatch;
|
||||||
|
import com.danyang.match.platform.user.entity.UserImportRow;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserImportBatchMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserImportRowMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserImportService {
|
||||||
|
private static final Duration PREVIEW_TTL = Duration.ofMinutes(30);
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final UserOnboardingService onboardingService;
|
||||||
|
private final UserImportBatchMapper batchMapper;
|
||||||
|
private final UserImportRowMapper rowMapper;
|
||||||
|
|
||||||
|
public UserImportPreviewResponse preview(byte[] bytes) {
|
||||||
|
String csv = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
if (csv.startsWith("\\uFEFF")) csv = csv.substring(1);
|
||||||
|
String[] lines = csv.split("\\R");
|
||||||
|
if (lines.length == 0 || !"name,organization,organizationPhone,phone".equals(lines[0].trim())) {
|
||||||
|
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "CSV 表头必须为 name,organization,organizationPhone,phone");
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
Set<String> phones = new HashSet<>();
|
||||||
|
int valid = 0;
|
||||||
|
for (int i = 1; i < lines.length; i++) {
|
||||||
|
if (lines[i].isBlank()) continue;
|
||||||
|
String[] values = lines[i].split(",", -1);
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("rowNumber", i + 1);
|
||||||
|
if (values.length != 4 || values[0].isBlank() || values[1].isBlank() || !values[3].matches("^1\\d{10}$") || !phones.add(values[3])) {
|
||||||
|
row.put("status", "invalid");
|
||||||
|
row.put("error", "字段缺失、手机号格式错误或文件内手机号重复");
|
||||||
|
} else {
|
||||||
|
row.put("status", "valid"); valid++;
|
||||||
|
}
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
redis.opsForValue().set(previewKey(batchId), Base64.getEncoder().encodeToString(csv.getBytes(StandardCharsets.UTF_8)), PREVIEW_TTL);
|
||||||
|
return new UserImportPreviewResponse(batchId, rows.size(), valid, rows.size() - valid, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
|
public int commit(String batchId, String operatorId, String idempotencyKey) {
|
||||||
|
String idempotencyRedisKey = "user:import:idempotency:" + operatorId + ":" + idempotencyKey;
|
||||||
|
String previous = redis.opsForValue().get(idempotencyRedisKey);
|
||||||
|
if (previous != null) return Integer.parseInt(previous);
|
||||||
|
String encoded = redis.opsForValue().get(previewKey(batchId));
|
||||||
|
if (encoded == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "导入预览已过期");
|
||||||
|
UserImportBatch batch = new UserImportBatch();
|
||||||
|
batch.setId(batchId);
|
||||||
|
batch.setCreatedBy(operatorId);
|
||||||
|
batch.setSourceFilename("uploaded.csv");
|
||||||
|
batch.setSourceEncoding("UTF-8");
|
||||||
|
batch.setStatus("committing");
|
||||||
|
batch.setIdempotencyKey(idempotencyKey);
|
||||||
|
String[] lines = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8).split("\\R");
|
||||||
|
int committed = 0;
|
||||||
|
batch.setTotalRows(Math.max(0, lines.length - 1));
|
||||||
|
batchMapper.insert(batch);
|
||||||
|
for (int i = 1; i < lines.length; i++) {
|
||||||
|
String[] values = lines[i].split(",", -1);
|
||||||
|
UserImportRow row = new UserImportRow();
|
||||||
|
row.setBatchId(batchId); row.setRowNumber(i + 1);
|
||||||
|
row.setStatus("invalid");
|
||||||
|
if (values.length == 4 && !values[0].isBlank() && !values[1].isBlank() && values[3].matches("^1\\d{10}$")) {
|
||||||
|
try {
|
||||||
|
onboardingService.create(new OperatorCreateUserRequest(values[0], values[1], values[2], values[3]));
|
||||||
|
row.setStatus("committed"); committed++;
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
row.setErrorCode("ROW_REJECTED"); row.setErrorMessage("手机号已存在或资料不符合要求");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.setErrorCode("INVALID_ROW"); row.setErrorMessage("字段缺失或手机号格式错误");
|
||||||
|
}
|
||||||
|
rowMapper.insert(row);
|
||||||
|
}
|
||||||
|
batch.setValidRows(committed); batch.setFailedRows(batch.getTotalRows() - committed); batch.setStatus("committed"); batch.setCommittedAt(java.time.Instant.now()); batchMapper.updateById(batch);
|
||||||
|
redis.opsForValue().set(idempotencyRedisKey, Integer.toString(committed), Duration.ofHours(24));
|
||||||
|
redis.delete(previewKey(batchId));
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String previewKey(String batchId) { return "user:import:preview:" + batchId; }
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.danyang.match.platform.user.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.dto.OperatorCreateUserRequest;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.entity.UserRegistrationApplication;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserRegistrationApplicationMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserOnboardingService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserRegistrationApplicationMapper applicationMapper;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> create(OperatorCreateUserRequest request) {
|
||||||
|
if (userMapper.findByPhone(request.phone()).isPresent()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已存在");
|
||||||
|
}
|
||||||
|
User user = new User();
|
||||||
|
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||||
|
user.setRecommendationEnabled(false);
|
||||||
|
user.setPhone(request.phone());
|
||||||
|
user.setPhoneVerificationStatus("unverified");
|
||||||
|
user.setAccountCreationStatus("operator_created");
|
||||||
|
user.setCredentialDeliveryStatus("not_issued");
|
||||||
|
user.setServiceEligibilityStatus("pending");
|
||||||
|
userMapper.insert(user);
|
||||||
|
|
||||||
|
UserRegistrationApplication app = new UserRegistrationApplication();
|
||||||
|
app.setUserId(user.getId());
|
||||||
|
app.setApplicantName(request.name());
|
||||||
|
app.setOrganizationName(request.organization());
|
||||||
|
app.setOrganizationPhone(request.organizationPhone());
|
||||||
|
app.setPhone(request.phone());
|
||||||
|
app.setStatus("pending_profile");
|
||||||
|
applicationMapper.insert(app);
|
||||||
|
return Map.of("userId", user.getId(), "status", "pending_profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.danyang.match.platform.user.service;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.shared.constant.Constants;
|
||||||
|
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.entity.UserRegistrationApplication;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserRegistrationApplicationMapper;
|
||||||
|
import com.danyang.match.platform.user.dto.UserRegistrationRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserRegistrationService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final UserRegistrationApplicationMapper applicationMapper;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> register(UserRegistrationRequest request) {
|
||||||
|
phoneVerificationService.consumeTransaction(request.verificationTransaction(), request.phone());
|
||||||
|
if (userMapper.findByPhone(request.phone()).isPresent()) {
|
||||||
|
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已注册");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setPhone(request.phone());
|
||||||
|
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||||
|
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||||
|
user.setRecommendationEnabled(false);
|
||||||
|
user.setPhoneVerificationStatus("verified");
|
||||||
|
user.setAccountCreationStatus("self_registered");
|
||||||
|
user.setCredentialDeliveryStatus("not_issued");
|
||||||
|
user.setServiceEligibilityStatus("pending");
|
||||||
|
userMapper.insert(user);
|
||||||
|
|
||||||
|
UserRegistrationApplication application = new UserRegistrationApplication();
|
||||||
|
application.setUserId(user.getId());
|
||||||
|
application.setApplicantName(request.name());
|
||||||
|
application.setOrganizationName(request.organization());
|
||||||
|
application.setOrganizationPhone(request.organizationPhone());
|
||||||
|
application.setPhone(request.phone());
|
||||||
|
application.setStatus("pending_profile");
|
||||||
|
applicationMapper.insert(application);
|
||||||
|
|
||||||
|
return Map.of("userId", user.getId(), "status", "pending_profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,6 @@ wechat:
|
|||||||
mock:
|
mock:
|
||||||
openid: ${WECHAT_MOCK_OPENID:}
|
openid: ${WECHAT_MOCK_OPENID:}
|
||||||
|
|
||||||
demo:
|
|
||||||
login:
|
|
||||||
enabled: ${DEMO_LOGIN_ENABLED:false}
|
|
||||||
password: ${DEMO_LOGIN_PASSWORD:demo123}
|
|
||||||
usernames: ${DEMO_LOGIN_USERNAMES:admin,wanghongniang}
|
|
||||||
|
|
||||||
encryption:
|
encryption:
|
||||||
key: ${ENCRYPTION_KEY}
|
key: ${ENCRYPTION_KEY}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- V1.2 账号生命周期与资料版本。仅增量扩展,保留 V1.1 字段语义。
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS phone_verification_status VARCHAR(32) NOT NULL DEFAULT 'unverified',
|
||||||
|
ADD COLUMN IF NOT EXISTS account_creation_status VARCHAR(32) NOT NULL DEFAULT 'existing',
|
||||||
|
ADD COLUMN IF NOT EXISTS credential_delivery_status VARCHAR(32) NOT NULL DEFAULT 'not_issued',
|
||||||
|
ADD COLUMN IF NOT EXISTS service_eligibility_status VARCHAR(32) NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
|
ALTER TABLE user_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS published_profile_revision INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_review_status VARCHAR(32) NOT NULL DEFAULT 'in_progress',
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_revision_updated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
UPDATE user_profiles
|
||||||
|
SET profile_review_status = CASE
|
||||||
|
WHEN profile_status = 'approved' THEN 'approved'
|
||||||
|
WHEN profile_status = 'need_edit' THEN 'need_edit'
|
||||||
|
WHEN profile_status = 'rejected' THEN 'rejected'
|
||||||
|
WHEN profile_status = 'pending_review' THEN 'pending_review'
|
||||||
|
ELSE 'in_progress'
|
||||||
|
END,
|
||||||
|
published_profile_revision = CASE
|
||||||
|
WHEN profile_status = 'approved' THEN COALESCE(profile_revision, 1)
|
||||||
|
ELSE published_profile_revision
|
||||||
|
END,
|
||||||
|
profile_revision_updated_at = COALESCE(profile_revision_updated_at, updated_at);
|
||||||
|
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_user_phone_verification_status;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT chk_user_phone_verification_status
|
||||||
|
CHECK (phone_verification_status IN ('unverified', 'pending', 'verified', 'failed'));
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_user_account_creation_status;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT chk_user_account_creation_status
|
||||||
|
CHECK (account_creation_status IN ('self_registered', 'operator_created', 'imported', 'existing'));
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_user_credential_delivery_status;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT chk_user_credential_delivery_status
|
||||||
|
CHECK (credential_delivery_status IN ('not_issued', 'pending', 'issued', 'failed'));
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_user_service_eligibility_status;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT chk_user_service_eligibility_status
|
||||||
|
CHECK (service_eligibility_status IN ('pending', 'eligible', 'ineligible', 'suspended'));
|
||||||
|
ALTER TABLE user_profiles DROP CONSTRAINT IF EXISTS chk_profile_review_status_v12;
|
||||||
|
ALTER TABLE user_profiles ADD CONSTRAINT chk_profile_review_status_v12
|
||||||
|
CHECK (profile_review_status IN ('in_progress', 'pending_review', 'approved', 'need_edit', 'rejected'));
|
||||||
|
ALTER TABLE user_profiles DROP CONSTRAINT IF EXISTS chk_profile_revision_positive;
|
||||||
|
ALTER TABLE user_profiles ADD CONSTRAINT chk_profile_revision_positive CHECK (profile_revision > 0);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_v12_lifecycle
|
||||||
|
ON users(account_creation_status, phone_verification_status, service_eligibility_status)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_v12_public_revision
|
||||||
|
ON user_profiles(profile_review_status, profile_revision, published_profile_revision)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
credential_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
issued_at TIMESTAMPTZ,
|
||||||
|
first_used_at TIMESTAMPTZ,
|
||||||
|
last_changed_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT chk_user_credential_version CHECK (credential_version > 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_active
|
||||||
|
ON user_credentials(user_id, revoked_at) WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- V1.2 后台建档/CSV 导入批次。预览与提交分离,批次提交可幂等重试。
|
||||||
|
CREATE TABLE IF NOT EXISTS user_import_batches (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
created_by UUID NOT NULL REFERENCES operators(id),
|
||||||
|
source_filename VARCHAR(255) NOT NULL,
|
||||||
|
source_encoding VARCHAR(32) NOT NULL DEFAULT 'UTF-8',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'previewed',
|
||||||
|
idempotency_key VARCHAR(255) NOT NULL,
|
||||||
|
total_rows INTEGER NOT NULL DEFAULT 0,
|
||||||
|
valid_rows INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_rows INTEGER NOT NULL DEFAULT 0,
|
||||||
|
committed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT chk_user_import_batch_status CHECK (status IN ('previewed', 'committing', 'committed', 'failed'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_import_batch_idempotency
|
||||||
|
ON user_import_batches(created_by, idempotency_key) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_import_rows (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
batch_id UUID NOT NULL REFERENCES user_import_batches(id),
|
||||||
|
row_number INTEGER NOT NULL,
|
||||||
|
normalized_data JSONB,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'valid',
|
||||||
|
error_code VARCHAR(64),
|
||||||
|
error_message VARCHAR(500),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT chk_user_import_row_status CHECK (status IN ('valid', 'invalid', 'committed', 'skipped')),
|
||||||
|
CONSTRAINT uk_user_import_row_number UNIQUE (batch_id, row_number)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_import_rows_batch_status
|
||||||
|
ON user_import_rows(batch_id, status, row_number);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- V1.2 服务授权与服务资格状态。授权文档按类型独立记录,禁止用单一布尔值代替。
|
||||||
|
CREATE TABLE IF NOT EXISTS user_consent_states (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
consent_type VARCHAR(64) NOT NULL,
|
||||||
|
document_version VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'required',
|
||||||
|
agreed_at TIMESTAMPTZ,
|
||||||
|
withdrawn_at TIMESTAMPTZ,
|
||||||
|
actor_user_id UUID REFERENCES users(id),
|
||||||
|
ip_address VARCHAR(64),
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uk_user_consent_type UNIQUE (user_id, consent_type),
|
||||||
|
CONSTRAINT chk_user_consent_status CHECK (status IN ('required', 'agreed', 'withdrawn', 'expired'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_consent_state
|
||||||
|
ON user_consent_states(user_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_service_states (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id),
|
||||||
|
service_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
service_block_reason VARCHAR(64),
|
||||||
|
evaluated_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- V1.2 自主注册申请。用户账号可先通过手机号注册,后续再关联微信 OpenID。
|
||||||
|
ALTER TABLE users ALTER COLUMN wechat_openid DROP NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_registration_applications (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
|
||||||
|
applicant_name VARCHAR(100) NOT NULL,
|
||||||
|
organization_name VARCHAR(200) NOT NULL,
|
||||||
|
organization_phone VARCHAR(32) NOT NULL,
|
||||||
|
phone VARCHAR(32) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending_profile',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT chk_registration_application_status
|
||||||
|
CHECK (status IN ('pending_profile', 'pending_review', 'approved', 'rejected'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_registration_application_phone
|
||||||
|
ON user_registration_applications(phone) WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- V1.2 后台建档/导入/账号发放权限。
|
||||||
|
INSERT INTO permissions (id, code, name, module, description)
|
||||||
|
VALUES ('10000000-0000-0000-0000-000000000080', 'user.create', '建立用户档案', 'user', '后台建档、导入和审核后账号发放')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('super_admin', 'platform_admin', 'organization_admin')
|
||||||
|
AND p.code = 'user.create'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
-- V067: 工作台聚合读取权限与红娘角色边界
|
||||||
|
INSERT INTO permissions (id, code, name, module, description)
|
||||||
|
VALUES (gen_random_uuid(), 'workbench.view', '查看工作台统计', 'workbench', '按当前角色数据范围查看工作台聚合统计')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader', 'platform_admin', 'super_admin')
|
||||||
|
AND p.code = 'workbench.view'
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 普通红娘不能直接分配服务对象;平台管理员使用 matchmaker.assign,组长使用 team.assign。
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE role_id = (SELECT id FROM roles WHERE code = 'matchmaker')
|
||||||
|
AND permission_id = (SELECT id FROM permissions WHERE code = 'matchmaker.assign');
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
-- V068: 红娘工作台五类卡片的只读权限
|
||||||
|
-- 仅开放查看入口;审核、分配、确认、完成等操作权限保持原有边界。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
AND p.code IN (
|
||||||
|
'review.view',
|
||||||
|
'counseling.service.view',
|
||||||
|
'service_appointment.view',
|
||||||
|
'analytics.view',
|
||||||
|
'activity.review_registration',
|
||||||
|
'activity.view'
|
||||||
|
)
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
-- V069: 红娘工作台业务操作权限
|
||||||
|
-- 只补齐五类工作台业务的操作门禁;敏感原始佐证/咨询备注查看仍需专门授权。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
AND p.code IN (
|
||||||
|
'review.process',
|
||||||
|
'local_eligibility.review',
|
||||||
|
'counseling.service.assign',
|
||||||
|
'counseling.service.process',
|
||||||
|
'counseling.note.create',
|
||||||
|
'service_appointment.assign',
|
||||||
|
'service_appointment.confirm',
|
||||||
|
'service_appointment.complete',
|
||||||
|
'service_appointment.cancel',
|
||||||
|
'activity.review_registration'
|
||||||
|
)
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
-- V070: 红娘角色权限收敛
|
||||||
|
-- 普通红娘与红娘组长只负责红娘业务,不提供公益咨询、活动管理、服务预约、数据统计。
|
||||||
|
DELETE FROM role_permissions rp
|
||||||
|
USING roles r, permissions p
|
||||||
|
WHERE rp.role_id = r.id
|
||||||
|
AND rp.permission_id = p.id
|
||||||
|
AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
AND (
|
||||||
|
p.code LIKE 'counseling.%'
|
||||||
|
OR p.code LIKE 'service_appointment.%'
|
||||||
|
OR p.code IN (
|
||||||
|
'activity.view',
|
||||||
|
'activity.checkin',
|
||||||
|
'activity.review_registration',
|
||||||
|
'analytics.view'
|
||||||
|
)
|
||||||
|
);
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
-- V071: 红娘角色权限最小化
|
||||||
|
-- 红娘只处理已分配的红娘服务,不承担资料审核或本地资格审核。
|
||||||
|
DELETE FROM role_permissions rp
|
||||||
|
USING roles r, permissions p
|
||||||
|
WHERE rp.role_id = r.id
|
||||||
|
AND rp.permission_id = p.id
|
||||||
|
AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
AND p.code IN (
|
||||||
|
'review.view',
|
||||||
|
'review.process',
|
||||||
|
'local_eligibility.review'
|
||||||
|
);
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- V072: 为操作员组织范围查询补充幂等索引。
|
||||||
|
-- 仅优化已有 nullable 外键列的查询;不改变存量组织归属,也不把 NULL 组织强行归组。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operators_organization_id
|
||||||
|
ON operators (organization_id)
|
||||||
|
WHERE organization_id IS NOT NULL;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- V073: 对齐 V1.2 其他运营角色的最小权限集合。
|
||||||
|
-- 仅按角色代码授予明确权限;不授予平台范围 analytics/data.export/permission.manage。
|
||||||
|
-- 幂等:重复执行不会产生重复 role_permissions。
|
||||||
|
|
||||||
|
-- reviewer:资料与本地资格审核,不进入红娘、咨询、活动或风险域。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code = 'reviewer'
|
||||||
|
AND p.code IN ('review.view', 'review.process', 'local_eligibility.view', 'local_eligibility.review')
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- activity_manager:活动流程及活动所需的非敏感用户基础信息。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code = 'activity_manager'
|
||||||
|
AND p.code IN ('user.view', 'activity.view', 'activity.create', 'activity.review_registration', 'activity.checkin')
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- risk_operator:举报处置及案件所需用户信息;敏感查看是风控专门授权。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code = 'risk_operator'
|
||||||
|
AND p.code IN ('user.view', 'user.sensitive.view', 'risk.report.view', 'risk.report.process', 'risk.user.ban')
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- organization_admin:仅本组织的运营管理权限;组织范围由服务端数据范围门禁执行。
|
||||||
|
-- 刻意不授 analytics.view:当前统计表缺少统一 organization_id,避免暴露全平台数据。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code = 'organization_admin'
|
||||||
|
AND p.code IN (
|
||||||
|
'user.view', 'review.view', 'review.process',
|
||||||
|
'matchmaker.view', 'matchmaker.service',
|
||||||
|
'activity.view', 'activity.create', 'activity.review_registration',
|
||||||
|
'organization.manage'
|
||||||
|
)
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- V074: 恢复红娘工作台六项卡片及已确认的业务操作权限。
|
||||||
|
-- 仅使用既有 permission code;不授予资格原始佐证、咨询备注查看或代用户决策权限。
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
AND p.code IN (
|
||||||
|
'workbench.view', 'review.view', 'review.process', 'local_eligibility.review',
|
||||||
|
'counseling.service.view', 'counseling.service.assign', 'counseling.service.process',
|
||||||
|
'counseling.note.create', 'service_appointment.view', 'service_appointment.assign',
|
||||||
|
'service_appointment.confirm', 'service_appointment.complete', 'activity.view',
|
||||||
|
'activity.review_registration', 'analytics.view'
|
||||||
|
)
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
-- V075: 平台管理员专用的红娘团队角色管理权限。
|
||||||
|
INSERT INTO permissions (id, code, name, module, description)
|
||||||
|
VALUES (gen_random_uuid(), 'matchmaker.role.manage', '管理红娘团队角色', 'matchmaker', '创建和维护普通红娘、红娘组长账号')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r CROSS JOIN permissions p
|
||||||
|
WHERE r.code IN ('platform_admin', 'super_admin')
|
||||||
|
AND p.code = 'matchmaker.role.manage'
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
-- V076: 统一机构类型编码,并将历史红娘用户名迁移为中文规范用户名。
|
||||||
|
|
||||||
|
UPDATE organizations SET type = 'women_federation' WHERE type = 'fulian';
|
||||||
|
UPDATE organizations SET type = 'trade_union' WHERE type = 'gonghui';
|
||||||
|
UPDATE organizations SET type = 'charity_center' WHERE type = 'public_service';
|
||||||
|
UPDATE organizations SET type = 'volunteer_team' WHERE type = 'volunteer';
|
||||||
|
UPDATE organizations SET status = 'active' WHERE status = 'normal';
|
||||||
|
UPDATE organizations SET status = 'inactive' WHERE status = 'suspended';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operator_username_aliases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
operator_id UUID NOT NULL REFERENCES operators(id),
|
||||||
|
username VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO operator_username_aliases (operator_id, username)
|
||||||
|
SELECT o.id, o.username
|
||||||
|
FROM operators o
|
||||||
|
JOIN operator_roles orl ON orl.operator_id = o.id
|
||||||
|
JOIN roles r ON r.id = orl.role_id
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
WITH red_members AS (
|
||||||
|
SELECT o.id,
|
||||||
|
row_number() OVER (ORDER BY o.id) AS sequence,
|
||||||
|
COALESCE(NULLIF(regexp_replace(o.name, '[^一-龥]', '', 'g'), ''), '成员') AS clean_name
|
||||||
|
FROM operators o
|
||||||
|
JOIN operator_roles orl ON orl.operator_id = o.id
|
||||||
|
JOIN roles r ON r.id = orl.role_id
|
||||||
|
WHERE r.code IN ('matchmaker', 'matchmaker_leader')
|
||||||
|
)
|
||||||
|
UPDATE operators o
|
||||||
|
SET username = '红娘' || red_members.clean_name || lpad(red_members.sequence::text, 4, '0')
|
||||||
|
FROM red_members
|
||||||
|
WHERE o.id = red_members.id;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.danyang.match.platform.account;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.entity.UserConsentState;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
|
||||||
|
import com.danyang.match.platform.account.mapper.UserServiceStateMapper;
|
||||||
|
import com.danyang.match.platform.account.service.ServiceConsentService;
|
||||||
|
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||||
|
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||||
|
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ServiceConsentGateTest {
|
||||||
|
private UserConsentStateMapper consents;
|
||||||
|
private UserServiceStateMapper serviceStates;
|
||||||
|
private UserMapper users;
|
||||||
|
private UserProfileMapper profiles;
|
||||||
|
private ServiceConsentService service;
|
||||||
|
private AuditLogMapper audits;
|
||||||
|
private User user;
|
||||||
|
private UserProfile profile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
consents = mock(UserConsentStateMapper.class); serviceStates = mock(UserServiceStateMapper.class);
|
||||||
|
users = mock(UserMapper.class); profiles = mock(UserProfileMapper.class);
|
||||||
|
audits = mock(AuditLogMapper.class);
|
||||||
|
service = new ServiceConsentService(consents, serviceStates, users, profiles, audits);
|
||||||
|
user = new User(); user.setId("u1"); user.setAccountStatus("normal"); user.setPhoneVerificationStatus("verified"); user.setServiceEligibilityStatus("eligible");
|
||||||
|
profile = new UserProfile(); profile.setUserId("u1"); profile.setProfileStatus("approved"); profile.setProfileRevision(2); profile.setPublishedProfileRevision(2);
|
||||||
|
when(users.findById("u1")).thenReturn(Optional.of(user)); when(profiles.findByUserId("u1")).thenReturn(Optional.of(profile));
|
||||||
|
when(consents.findByUserId("u1")).thenReturn(List.of()); when(serviceStates.findByUserId("u1")).thenReturn(Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingConsentIsRejected() {
|
||||||
|
when(consents.countAgreed("u1")).thenReturn(0);
|
||||||
|
assertThrows(BusinessException.class, () -> service.requireActive("u1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bothCurrentVersionsActivateService() {
|
||||||
|
when(consents.findByUserAndType(any(), any())).thenReturn(Optional.empty());
|
||||||
|
when(consents.countAgreed("u1")).thenReturn(2);
|
||||||
|
service.agree("u1", java.util.Map.of("service_terms", "v1", "privacy_safety", "v1"), "127.0.0.1", "test");
|
||||||
|
verify(consents, times(2)).insert(any(UserConsentState.class));
|
||||||
|
verify(serviceStates).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleDocumentVersionIsRejected() {
|
||||||
|
assertThrows(BusinessException.class, () -> service.agree("u1", java.util.Map.of("service_terms", "old", "privacy_safety", "v1"), "ip", "ua"));
|
||||||
|
verify(consents, never()).insert(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -62,27 +61,4 @@ class OperatorAuthServiceTest {
|
|||||||
verify(jwtTokenService, never()).generateOperatorToken(
|
verify(jwtTokenService, never()).generateOperatorToken(
|
||||||
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap());
|
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void demoPasswordCanRecoverSeedHashDriftOnlyWhenDemoLoginIsEnabled() {
|
|
||||||
Operator operator = new Operator();
|
|
||||||
operator.setId("operator-1");
|
|
||||||
operator.setUsername("admin");
|
|
||||||
operator.setPasswordHash("stale-hash");
|
|
||||||
operator.setStatus("active");
|
|
||||||
when(operatorMapper.findByUsername("admin")).thenReturn(Optional.of(operator));
|
|
||||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
||||||
when(passwordEncoder.matches("demo123", "stale-hash")).thenReturn(false);
|
|
||||||
when(permissionMapper.findPermissionsByOperatorId("operator-1")).thenReturn(java.util.List.of());
|
|
||||||
when(jwtTokenService.generateOperatorToken(org.mockito.ArgumentMatchers.eq("operator-1"), org.mockito.ArgumentMatchers.anyMap()))
|
|
||||||
.thenReturn("access");
|
|
||||||
when(jwtTokenService.generateRefreshToken("operator-1")).thenReturn("refresh");
|
|
||||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
|
||||||
ReflectionTestUtils.setField(service, "demoLoginPassword", "demo123");
|
|
||||||
ReflectionTestUtils.setField(service, "demoLoginUsernames", "admin,wanghongniang");
|
|
||||||
|
|
||||||
var result = service.login("admin", "demo123");
|
|
||||||
|
|
||||||
org.junit.jupiter.api.Assertions.assertEquals("access", result.get("accessToken"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.danyang.match.platform.auth;
|
||||||
|
|
||||||
|
import com.danyang.match.platform.account.entity.UserCredential;
|
||||||
|
import com.danyang.match.platform.auth.service.PasswordAuthService;
|
||||||
|
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||||
|
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
|
||||||
|
import com.danyang.match.platform.user.entity.User;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
|
||||||
|
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class PasswordAuthServiceTest {
|
||||||
|
@Test
|
||||||
|
void loginReturnsInitialPasswordFlag() {
|
||||||
|
UserMapper users = mock(UserMapper.class);
|
||||||
|
UserCredentialMapper credentials = mock(UserCredentialMapper.class);
|
||||||
|
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||||
|
JwtTokenService jwt = mock(JwtTokenService.class);
|
||||||
|
StringRedisTemplate redis = mock(StringRedisTemplate.class, RETURNS_DEEP_STUBS);
|
||||||
|
User user = new User(); user.setId("u1"); user.setPhoneVerificationStatus("verified"); user.setAccountStatus("normal");
|
||||||
|
UserCredential credential = new UserCredential(); credential.setUserId("u1"); credential.setPasswordHash("hash"); credential.setMustChangePassword(true);
|
||||||
|
when(users.findByPhone("13800138000")).thenReturn(Optional.of(user));
|
||||||
|
when(credentials.findByUserId("u1")).thenReturn(Optional.of(credential));
|
||||||
|
when(encoder.matches("secret", "hash")).thenReturn(true);
|
||||||
|
when(jwt.generateAccessToken(eq("u1"), isNull())).thenReturn("access");
|
||||||
|
when(jwt.generateRefreshToken("u1")).thenReturn("refresh");
|
||||||
|
when(jwt.getRefreshExpirationMs()).thenReturn(1000L);
|
||||||
|
when(jwt.getExpirationMs()).thenReturn(2000L);
|
||||||
|
|
||||||
|
var result = new PasswordAuthService(users, credentials, encoder, jwt, redis).login("13800138000", "secret");
|
||||||
|
|
||||||
|
assertEquals(true, result.get("mustChangePassword"));
|
||||||
|
assertEquals("access", result.get("accessToken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changingInitialPasswordInvalidatesInitialFlagAndRotatesVersion() {
|
||||||
|
UserMapper users = mock(UserMapper.class);
|
||||||
|
UserCredentialMapper credentials = mock(UserCredentialMapper.class);
|
||||||
|
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||||
|
JwtTokenService jwt = mock(JwtTokenService.class);
|
||||||
|
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||||
|
UserCredential credential = new UserCredential(); credential.setUserId("u1"); credential.setPasswordHash("old"); credential.setMustChangePassword(true); credential.setCredentialVersion(2);
|
||||||
|
when(credentials.findByUserId("u1")).thenReturn(Optional.of(credential));
|
||||||
|
when(encoder.matches("old-pass", "old")).thenReturn(true);
|
||||||
|
when(encoder.encode("new-pass-1")).thenReturn("new");
|
||||||
|
|
||||||
|
new PasswordAuthService(users, credentials, encoder, jwt, redis).changeInitialPassword("u1", "old-pass", "new-pass-1");
|
||||||
|
|
||||||
|
assertFalse(credential.getMustChangePassword());
|
||||||
|
assertEquals(3, credential.getCredentialVersion());
|
||||||
|
assertEquals("new", credential.getPasswordHash());
|
||||||
|
verify(credentials).updateById(credential);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user