Author SHA1 Message Date
WorkBuddy 377429995c release: freeze V1.2 demo acceptance 2026-10-25
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-25 14:29:29 +08:00
WorkBuddy f6744d3f91 fix: restore matchmaker workbench permissions 2026-10-25 09:30:13 +08:00
WorkBuddy 692d71696a test: record v1.2 permission scope acceptance gaps 2026-10-25 09:20:30 +08:00
WorkBuddy ec9252bd3d feat: align v12 operator role permissions 2026-10-25 09:15:12 +08:00
WorkBuddy dd291868fb fix: define dashboard metric status 2026-10-25 09:07:09 +08:00
WorkBuddy b38056f1d0 fix admin dashboard permission scope display 2026-10-25 09:06:15 +08:00
WorkBuddy 34b9ea1169 docs: record dashboard detail scope gap 2026-10-25 08:56:36 +08:00
WorkBuddy 88f4176e60 fix: 收口红娘工作台权限与组织范围 2026-10-25 08:50:18 +08:00
WorkBuddy a3bd1e8a95 docs: freeze v1.2 permission baseline 2026-10-25 08:36:39 +08:00
WorkBuddy d51c4c6a76 chore: ignore local temporary workspaces
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-20 14:20:23 +08:00
WorkBuddy 69ebf33e18 release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-19 20:36:22 +08:00
307 changed files with 26189 additions and 240 deletions
+3
View File
@@ -61,6 +61,9 @@ tests/fixtures/*.json
.worktrees/ .worktrees/
.zcode/ .zcode/
.workbuddy/ .workbuddy/
.mimosa/
.trae/
.wb_tmp_bcrypt/
project.private.config.json project.private.config.json
/nul /nul
/infrastructure/nul /infrastructure/nul
@@ -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;这些属于后续任务门禁。
@@ -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 错误。
## 范围
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
+18 -8
View File
@@ -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('')
+7
View File
@@ -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' }
+40 -31
View File
@@ -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 ? '部分模块暂时不可用,其余数据已正常加载' : '')
+3 -1
View File
@@ -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)
+2
View File
@@ -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
} }
+2
View File
@@ -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,
@@ -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")));
}
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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()));
}
}
@@ -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(
@@ -12,6 +12,8 @@ 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.risk.entity.RiskAction; import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig; import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ public class AppealService {
private final AppealMapper appealMapper; private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper; private final RiskActionMapper riskActionMapper;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
@@ -281,6 +284,11 @@ public class AppealService {
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId()); log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
} }
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
// users.account_status=banned/suspended 无人回滚。
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
// 通知用户 // 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT, notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过", "申诉审核通过",
@@ -371,6 +379,38 @@ public class AppealService {
return "limited"; return "limited";
} }
/**
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
*
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
*
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
*/
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
if (stillRestricted) {
return;
}
User user = userMapper.selectById(userId);
if (user == null) {
return;
}
String oldStatus = user.getAccountStatus();
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
return;
}
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}",
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
}
private void saveAuditLog(String operatorId, String action, String resourceType, private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) { String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog(); AuditLog auditLog = new AuditLog();
@@ -378,6 +418,10 @@ public class AppealService {
auditLog.setAction(action); auditLog.setAction(action);
auditLog.setResourceType(resourceType); auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId); auditLog.setResourceId(resourceId);
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog); auditLogMapper.insert(auditLog);
} }
} }
@@ -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())));
}
/** /**
* 微信登录 * 微信登录
@@ -44,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();
} }
@@ -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, "短信服务适配器尚未配置完成");
}
}
@@ -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);
}
}
@@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map<String, String> getCodeSession(String code) { public Map<String, String> getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code); log.debug("[Mock] 微信登录 code: {}", code);
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
if (code != null && code.startsWith("demo_openid_")) {
return Map.of(
"openid", code,
"unionid", "mock_unionid_" + code,
"session_key", "mock_session_key",
"appid", appId
);
}
// 模拟:相同 code 返回相同 openid // 模拟:相同 code 返回相同 openid
String openid = configuredOpenid != null && !configuredOpenid.isBlank() String openid = configuredOpenid != null && !configuredOpenid.isBlank()
? configuredOpenid ? configuredOpenid
@@ -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,6 +48,7 @@ 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;
@@ -111,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, "用户不存在"));
@@ -124,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);
@@ -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);
}
}
@@ -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);
} }
/** 是否在认真寻找中 */ /** 是否在认真寻找中 */
@@ -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)));
@@ -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");
@@ -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()));
} }
@@ -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, "用户不存在");
@@ -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不能为空");
} }
@@ -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));
} }
@@ -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()) {
@@ -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()) {
@@ -310,7 +312,11 @@ public class RecommendationEngineService {
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setScore(java.math.BigDecimal.valueOf(score)); record.setScore(java.math.BigDecimal.valueOf(score));
record.setLevel(toLevel(score)); record.setLevel(toLevel(score));
record.setReasons(String.join(";", reasons)); // recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
// /home 聚合接口 500。改为序列化为 JSON 数组。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -342,6 +348,16 @@ public class RecommendationEngineService {
} }
} }
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/** /**
* 内部评分结果 * 内部评分结果
*/ */
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.UserProfileService; import com.danyang.match.platform.profile.service.UserProfileService;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
public class RecommendationService { public class RecommendationService {
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class); private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchEligibilityService eligibilityService; private final MatchEligibilityService eligibilityService;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
@@ -61,7 +63,9 @@ public class RecommendationService {
record.setTargetUserId(targetUserId); record.setTargetUserId(targetUserId);
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY); record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setLevel(level); record.setLevel(level);
record.setReasons(new java.util.ArrayList<>(reasons).toString()); // recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本) // MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -70,6 +74,16 @@ public class RecommendationService {
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level); log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
} }
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/** /**
* 记录用户对推荐的响应(Like/Pass/Block) * 记录用户对推荐的响应(Like/Pass/Block)
*/ */
@@ -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,17 +53,20 @@ 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;
String description = req.description != null ? req.description : req.requirement; String description = req.description != null ? req.description : req.requirement;
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description)); return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
req.targetUserId, req.authorizationAccepted));
} }
@GetMapping("/services") @GetMapping("/services")
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));
} }
@@ -69,12 +75,24 @@ 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()));
} }
@Idempotent
@PostMapping("/services/{id}/withdraw")
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
return ApiResponse.ok();
}
@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()));
} }
@@ -82,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();
} }
@@ -91,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()));
} }
@@ -111,5 +131,10 @@ public class MatchmakerController {
private String description; private String description;
/** 兼容字段:小程序约定的需求描述(requirement -> description) */ /** 兼容字段:小程序约定的需求描述(requirement -> description) */
private String requirement; private String requirement;
private String targetUserId;
private Boolean authorizationAccepted;
} }
@Data
public static class WithdrawRequest { private String reason; }
} }
@@ -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));
}
}
@@ -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) {}
}
@@ -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); }
}
@@ -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()));
}
}
@@ -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());
}
}
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
private String userId; private String userId;
private String serviceType; private String serviceType;
private String description; private String description;
private String targetUserId;
private java.time.Instant authorizationAt;
private java.time.Instant withdrawnAt;
private String withdrawReason;
private String status; private String status;
private String organizationId; private String organizationId;
private String assignedOperatorId; private String assignedOperatorId;
@@ -14,13 +14,16 @@ import java.util.Optional;
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> { public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " + @Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
"s.description, s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " + "s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " + "u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
"tu.display_number AS \"targetUserDisplayNumber\", " +
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " + "(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " + "ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " + "(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " + "ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " + "FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " + "LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " + "WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>") "AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
@@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}") @Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit); List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */ /** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " + @Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL") "WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
@@ -12,6 +12,8 @@ import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode; 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.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard; import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
@@ -55,6 +57,7 @@ import java.util.stream.Collectors;
public class MatchmakerAssignmentService { public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment"; private static final String RESOURCE_TYPE = "matchmaker_assignment";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final MatchmakerAssignmentMapper assignmentMapper; private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper; private final MatchmakerServiceMapper serviceMapper;
@@ -87,6 +90,20 @@ public class MatchmakerAssignmentService {
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false); "组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> { assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
// 仍在进行中的服务继续按唯一主责约束返回 409。
MatchmakerServiceRecord linked = existing.getServiceId() == null
? null : serviceMapper.selectById(existing.getServiceId());
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
existing.setEndedAt(Instant.now());
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
assignmentMapper.updateById(existing);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
return;
}
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销"); "该用户已存在生效中的主责分配,请先转分配或撤销");
}); });
@@ -388,10 +405,15 @@ public class MatchmakerAssignmentService {
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */ /** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) { private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
String summary = "matchmakerOperatorId=" + matchmakerOperatorId Map<String, String> summary = Map.of(
+ ";scope=" + scope "matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
+ ";reason=" + (reason == null ? "" : reason); "scope", scope == null ? "" : scope,
return summary.length() > 500 ? summary.substring(0, 500) : summary; "reason", reason == null ? "" : reason);
try {
return OBJECT_MAPPER.writeValueAsString(summary);
} catch (JsonProcessingException e) {
throw new IllegalStateException("audit afterData JSON serialization failed", e);
}
} }
private void saveAudit(String operatorId, String action, String resourceId, String afterData) { private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
@@ -64,17 +64,38 @@ public class MatchmakerService {
*/ */
@Transactional @Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) { public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
return submitServiceRequest(userId, serviceType, description, null, null);
}
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
String targetUserId, Boolean authorizationAccepted) {
boolean assistance = "recommendation_assistance".equals(serviceType);
if (assistance) {
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
}
MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString()); svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId); svc.setUserId(userId);
svc.setServiceType(serviceType); svc.setServiceType(serviceType);
svc.setDescription(description); svc.setDescription(description);
svc.setTargetUserId(targetUserId);
if (assistance) svc.setAuthorizationAt(Instant.now());
svc.setStatus("submitted"); svc.setStatus("submitted");
serviceMapper.insert(svc); serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType); log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc; return svc;
} }
@Transactional
public void withdrawService(String serviceId, String userId, String reason) {
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
}
/** 查询我的红娘服务历史 */ /** 查询我的红娘服务历史 */
public List<MatchmakerServiceRecord> myServices(String userId, int limit) { public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50)); return serviceMapper.findByUser(userId, Math.min(limit, 50));
@@ -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();
}
}
@@ -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()));
}
} }
@@ -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));
} }
} }
@@ -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); "机构类型不合法,请选择妇联、工会、公益服务中心、志愿者团队、民政合作单位或其他合作机构");
} }
} }
@@ -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();
} }
@@ -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);
} }
@@ -35,6 +35,15 @@ public class UserProfile {
private String selfIntro; private String selfIntro;
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;
@@ -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) {
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId)); return ApiResponse.ok(reportService.getMyReports(userId));
} }
/**
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
* GET /api/v1/risk/reports/{reportId}
*/
@GetMapping("/{reportId}")
public ApiResponse<Map<String, Object>> getMyReportDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String reportId) {
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
}
/**
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
* POST /api/v1/risk/reports/evidence/upload-token
*/
@PostMapping("/evidence/upload-token")
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
}
} }
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog; import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.notification.service.NotificationService; import com.danyang.match.platform.notification.service.NotificationService;
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.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.risk.dto.ReportItemDTO; import com.danyang.match.platform.risk.dto.ReportItemDTO;
import com.danyang.match.platform.risk.dto.ReportProcessDTO; import com.danyang.match.platform.risk.dto.ReportProcessDTO;
import com.danyang.match.platform.risk.dto.ReportSubmitDTO; import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper; import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.risk.mapper.RiskEventMapper; import com.danyang.match.platform.risk.mapper.RiskEventMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor; import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
@@ -30,9 +40,11 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.List; import java.util.List;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; 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.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/** /**
* 举报与风控服务 * 举报与风控服务
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
* - 封禁需要人工复核,不能自动永久封禁 * - 封禁需要人工复核,不能自动永久封禁
*/ */
@Service @Service
@RequiredArgsConstructor
public class ReportService { public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class); private static final Logger log = LoggerFactory.getLogger(ReportService.class);
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
private static final int MAX_DESCRIPTION_LENGTH = 500;
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
private static final int DEFAULT_MAX_EVIDENCE = 9;
/** 运营端按状态列表查询的单页上限。 */
private static final int STATUS_LIST_LIMIT = 200;
/** 高风险举报原因:必须补充文字说明。 */
private static final Set<String> HIGH_RISK_REASONS = Set.of(
Constants.REPORT_REASON_FRAUD,
Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION,
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
);
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final RiskEventMapper riskEventMapper; private final RiskEventMapper riskEventMapper;
private final RiskActionMapper riskActionMapper; private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final UserMapper userMapper;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final AuditLogService auditLogService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final ObjectMapper objectMapper;
/** 证据对象的归属/存在性校验走内网客户端。 */
private final AmazonS3 amazonS3;
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
private int maxEvidenceCount;
@Autowired
public ReportService(
ReportMapper reportMapper,
RiskEventMapper riskEventMapper,
RiskActionMapper riskActionMapper,
UserProfileMapper userProfileMapper,
UserMapper userMapper,
AuditLogMapper auditLogMapper,
AuditLogService auditLogService,
NotificationService notificationService,
ObjectMapper objectMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.reportMapper = reportMapper;
this.riskEventMapper = riskEventMapper;
this.riskActionMapper = riskActionMapper;
this.userProfileMapper = userProfileMapper;
this.userMapper = userMapper;
this.auditLogMapper = auditLogMapper;
this.auditLogService = auditLogService;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
}
// 风险评分权重 // 风险评分权重
private static final Map<String, Integer> REASON_SCORES = Map.of( private static final Map<String, Integer> REASON_SCORES = Map.of(
@@ -75,10 +142,30 @@ public class ReportService {
*/ */
@Transactional @Transactional
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) { public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 举报对象校验
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
}
// 不允许举报自己 // 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) { if (reporterUserId.equals(dto.getReportedUserId())) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己"); throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
} }
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
String reasonType = dto.getReasonType();
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
}
// 补充说明长度 + 高风险必填
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
}
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
}
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
// 检查是否已被封禁 // 检查是否已被封禁
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId); List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
@@ -91,34 +178,35 @@ public class ReportService {
Report report = new Report(); Report report = new Report();
report.setReporterUserId(reporterUserId); report.setReporterUserId(reporterUserId);
report.setReportedUserId(dto.getReportedUserId()); report.setReportedUserId(dto.getReportedUserId());
report.setReasonType(dto.getReasonType()); report.setReasonType(reasonType);
report.setDescription(dto.getDescription()); report.setDescription(description.isEmpty() ? null : description);
if (dto.getEvidenceFileIds() != null) { // evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString()); report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
}
report.setStatus(Constants.REPORT_STATUS_PENDING); report.setStatus(Constants.REPORT_STATUS_PENDING);
reportMapper.insert(report); reportMapper.insert(report);
// 触发风险事件:被举报用户得分增加 // 触发风险事件:被举报用户得分增加
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5); int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
RiskEvent riskEvent = new RiskEvent(); RiskEvent riskEvent = new RiskEvent();
riskEvent.setUserId(dto.getReportedUserId()); riskEvent.setUserId(dto.getReportedUserId());
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT); riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
riskEvent.setScore(scoreAdd); riskEvent.setScore(scoreAdd);
riskEvent.setSource("report"); riskEvent.setSource("report");
riskEvent.setSourceId(report.getId()); riskEvent.setSourceId(report.getId());
riskEvent.setNote("举报触发,原因: " + dto.getReasonType()); riskEvent.setNote("举报触发,原因: " + reasonType);
riskEventMapper.insert(riskEvent); riskEventMapper.insert(riskEvent);
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充) // 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT, if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) { Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
report.setStatus(Constants.REPORT_STATUS_PROCESSING); report.setStatus(Constants.REPORT_STATUS_PROCESSING);
reportMapper.updateById(report); reportMapper.updateById(report);
} }
// 审计 // 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null); // 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("reportId", report.getId()); result.put("reportId", report.getId());
@@ -135,6 +223,45 @@ public class ReportService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
*/
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null || !userId.equals(report.getReporterUserId())) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
return detail;
}
/**
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
*/
public Map<String, Object> getEvidenceUploadToken(String userId) {
String fileKey = String.format("reports/%s/evidence/%s.jpg",
userId, UUID.randomUUID().toString().replace("-", ""));
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
token.put("uploadUrl", uploadUrl);
token.put("expiresIn", 3600);
return token;
}
// ==================== 管理端举报处理 ==================== // ==================== 管理端举报处理 ====================
/** /**
@@ -157,6 +284,13 @@ public class ReportService {
Map<String, Object> detail = new HashMap<>(); Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report)); detail.put("report", toDTO(report));
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
// 被举报人风险记录 // 被举报人风险记录
String reportedUserId = report.getReportedUserId(); String reportedUserId = report.getReportedUserId();
if (reportedUserId != null) { if (reportedUserId != null) {
@@ -215,7 +349,10 @@ public class ReportService {
} }
// 通知被举报用户 // 通知被举报用户
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote()); sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
// 高风险举报(婚姻状态欺诈)额外记录风险事件 // 高风险举报(婚姻状态欺诈)额外记录风险事件
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) { if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
@@ -257,8 +394,20 @@ public class ReportService {
return m; return m;
}).collect(java.util.stream.Collectors.toList()); }).collect(java.util.stream.Collectors.toList());
} }
// For other statuses, return empty or implement full query // 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
return List.of(); return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", report.getId());
m.put("reasonType", report.getReasonType());
m.put("reasonText", getReasonText(report.getReasonType()));
m.put("reportedUserId", report.getReportedUserId());
m.put("status", report.getStatus());
m.put("description", report.getDescription());
m.put("createdAt", report.getCreatedAt());
m.put("processResult", report.getProcessResult());
m.put("processNote", report.getProcessNote());
return m;
}).collect(java.util.stream.Collectors.toList());
} }
public Map<String, Object> getReportStats() { public Map<String, Object> getReportStats() {
@@ -298,6 +447,83 @@ public class ReportService {
// ==================== 私有辅助方法 ==================== // ==================== 私有辅助方法 ====================
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return List.of();
}
if (evidenceFileIds.size() > maxEvidenceCount) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"证据截图最多 " + maxEvidenceCount + " 张");
}
String prefix = "reports/" + reporterUserId + "/";
for (String key : evidenceFileIds) {
if (key == null || !key.startsWith(prefix)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, key)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
}
}
return evidenceFileIds;
}
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
private String writeEvidenceJson(List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(evidenceFileIds);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new ServerErrorException("证据序列化失败", e);
}
}
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
private List<String> parseEvidence(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
return List.of();
}
}
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
private String signEvidenceUrl(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
return amazonS3Signer.generatePresignedUrl(
new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.GET)
.withExpiration(expiration)).toString();
}
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
private String displayNameOf(String userId) {
if (userId == null) return null;
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
}
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
if (reporterUserId == null) return;
String title = "举报处理结果";
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
? "您提交的举报经核实未发现问题,感谢您的监督。"
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
try {
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"report", reportId);
} catch (Exception e) {
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
}
}
private void applyRiskAction(String userId, String result, String note, String operatorId) { private void applyRiskAction(String userId, String result, String note, String operatorId) {
RiskAction action = new RiskAction(); RiskAction action = new RiskAction();
action.setUserId(userId); action.setUserId(userId);
@@ -329,6 +555,34 @@ public class ReportService {
action.setEffectiveAt(Instant.now()); action.setEffectiveAt(Instant.now());
action.setReasonDetail(note); action.setReasonDetail(note);
riskActionMapper.insert(action); riskActionMapper.insert(action);
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
String newAccountStatus = switch (result) {
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
default -> null; // warning / feature_limit 不改变账号状态
};
if (newAccountStatus != null) {
syncAccountStatus(userId, newAccountStatus, operatorId);
}
}
/**
* 把处置结果写入 users.account_status 并留痕。
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
*/
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) return;
String oldStatus = user.getAccountStatus();
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
user.setAccountStatus(newStatus);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
} }
private String calculateRiskLevel(int score) { private String calculateRiskLevel(int score) {
@@ -351,6 +605,8 @@ public class ReportService {
dto.setProcessNote(report.getProcessNote()); dto.setProcessNote(report.getProcessNote());
// 填充显示名称 // 填充显示名称
dto.setReasonText(getReasonText(report.getReasonType())); dto.setReasonText(getReasonText(report.getReasonType()));
dto.setReporterName(displayNameOf(report.getReporterUserId()));
dto.setReportedName(displayNameOf(report.getReportedUserId()));
return dto; return dto;
} }
@@ -368,7 +624,7 @@ public class ReportService {
}; };
} }
private void sendProcessNotification(String userId, String result, String note) { private void sendProcessNotification(String userId, String result, String note, String businessId) {
if (userId == null) return; if (userId == null) return;
String title = "账号处置通知"; String title = "账号处置通知";
String content = switch (result) { String content = switch (result) {
@@ -378,8 +634,9 @@ public class ReportService {
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。"; case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
default -> "您的账号相关处置已完成。"; default -> "您的账号相关处置已完成。";
}; };
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content, notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis()); "risk_action", businessId);
} }
private void saveAuditLog(String operatorId, String action, String resourceType, private void saveAuditLog(String operatorId, String action, String resourceType,
@@ -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));
} }
@@ -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,8 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper))) .accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级) // 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll() .requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.permitAll()
.requestMatchers("/health/**").permitAll() .requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll() .requestMatchers("/version").permitAll()
.requestMatchers("/app/config").permitAll() .requestMatchers("/app/config").permitAll()
@@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private static final List<String> PERMITTED_PATHS = List.of( private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**", "/api/v1/auth/**",
"/auth/**",
"/api/v1/health/**", "/api/v1/health/**",
"/health/**",
"/api/v1/version", "/api/v1/version",
"/version",
"/api/v1/operator/auth/**", "/api/v1/operator/auth/**",
"/api/v1/app/**", "/api/v1/app/**",
"/api/v1/activities" "/app/**",
"/api/v1/activities",
"/activities"
); );
@Override @Override
@@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
FilterChain chain) throws ServletException, IOException { FilterChain chain) throws ServletException, IOException {
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
String servletPath = request.getServletPath(); String servletPath = request.getServletPath();
if (servletPath == null || servletPath.isEmpty()) {
servletPath = request.getRequestURI();
}
String effectivePath = (contextPath != null && !contextPath.isEmpty()) String effectivePath = (contextPath != null && !contextPath.isEmpty())
? contextPath + servletPath : servletPath; ? contextPath + servletPath : servletPath;
String method = request.getMethod(); String method = request.getMethod();
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) { if ("OPTIONS".equals(method)
|| isPermittedPath(effectivePath)
|| isPermittedPath(request.getRequestURI())) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
@@ -136,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
private boolean isPermittedPath(String path) { private boolean isPermittedPath(String path) {
String normalizedPath = path.startsWith("/api/v1")
? path
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
return PERMITTED_PATHS.stream() return PERMITTED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path)); .anyMatch(pattern -> pathMatcher.match(pattern, path)
|| pathMatcher.match(pattern, normalizedPath));
} }
} }
@@ -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));
}
/** /**
* 获取用户列表(分页+筛选) * 获取用户列表(分页+筛选)
@@ -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) {}
@@ -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) {}
@@ -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
) {
}
@@ -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) {}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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> {}
@@ -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> {
}
@@ -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);
}
}
}
@@ -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; }
}
@@ -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");
}
}
@@ -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");
}
}
@@ -2,7 +2,6 @@
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL) -- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
-- ============================================================ -- ============================================================
BEGIN; BEGIN;
SET session_replication_role = 'replica';
-- 1. 组织 -- 1. 组织
INSERT INTO organizations (id, name, type, contact_name, status) VALUES INSERT INTO organizations (id, name, type, contact_name, status) VALUES
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active'); (gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
-- 2. 工作人员(V1.0原有 + V1.1新增) -- 2. 工作人员(V1.0原有 + V1.1新增)
INSERT INTO operators (id, organization_id, name, username, password_hash, status) INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center' FROM organizations WHERE type='charity_center'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='trade_union' FROM organizations WHERE type='trade_union'
-- V1.1 新增演示操作员 -- V1.1 新增演示操作员
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation' FROM organizations WHERE type='women_federation'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center' FROM organizations WHERE type='charity_center'
UNION ALL UNION ALL
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active' SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'; FROM organizations WHERE type='women_federation';
-- 操作员角色绑定(V1.0原有 + V1.1新增) -- 操作员角色绑定(V1.0原有 + V1.1新增)
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
-- ============================================================ -- ============================================================
-- 4. 审核任务 -- 4. 审核任务
-- ============================================================ -- ============================================================
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version) INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved', SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
(SELECT id FROM operators WHERE username='reviewer01'), (SELECT id FROM operators WHERE username='reviewer01'),
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1 NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05'); FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
-- ============================================================ -- ============================================================
@@ -331,17 +331,17 @@ VALUES
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1), WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1), trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1) mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text, INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
start_at, end_at, location_name, location_address, start_at, end_at, location_name, location_address,
male_limit, female_limit, total_limit, min_age, max_age, male_limit, female_limit, total_limit, min_age, max_age,
registration_start_at, registration_end_at, need_review, status, created_by) registration_start_at, registration_end_at, need_review, status, created_by)
VALUES VALUES
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', (gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。', '面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会', (SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号', '2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)), 15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', (gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
'七夕节特别策划,浪漫主题相亲活动。', '七夕节特别策划,浪漫主题相亲活动。',
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心', (SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号', '2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
@@ -442,7 +442,6 @@ VALUES
('manual_review_needed', '需人工复核', 'quality', true, 7) ('manual_review_needed', '需人工复核', 'quality', true, 7)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
SET session_replication_role = 'origin';
DROP TABLE IF EXISTS _conv_result; DROP TABLE IF EXISTS _conv_result;
-- ============================================================ -- ============================================================
@@ -0,0 +1,487 @@
-- ============================================================
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
-- ============================================================
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
--
-- 约束(与任务一致):
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
-- ============================================================
BEGIN;
-- ============================================================
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
-- created_by 引用既有操作员 contenteditor
-- ============================================================
INSERT INTO home_banners
(id, title, subtitle, button_text, target_type, target_value,
audience_scope, sort_order, start_at, end_at, status, created_by)
VALUES
('a1111111-1111-1111-1111-111111110001'::uuid,
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110002'::uuid,
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110003'::uuid,
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. 婚恋课堂分类 content_categories(4 个)
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
-- ============================================================
INSERT INTO content_categories (id, name, code, description, sort_order, status)
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
FROM (VALUES
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
'恋爱沟通、关系经营与择偶知识', 1),
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
'婚恋诈骗识别与防范知识', 2),
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
'婚前准备与关系建立指导', 3),
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
'传统婚俗与文明新风尚', 4)
) AS v(id, name, code, description, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
-- ============================================================
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
-- ============================================================
INSERT INTO content_articles
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
VALUES
('a3333333-3333-3333-3333-333333330001'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'初次见面聊什么?五个不冷场的话题',
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
('a3333333-3333-3333-3333-333333330002'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'如何判断两个人是否合适',
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
'判断两个人是否合适,可以从三个维度观察。'
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
('a3333333-3333-3333-3333-333333330003'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'从喜欢到结婚:健康关系的发展节奏',
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
'从喜欢到结婚,往往要经历几个自然阶段。'
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
('a3333333-3333-3333-3333-333333330004'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'婚恋反诈指南:识别常见情感骗局',
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
'婚恋交友中,以下情况需要高度警惕。'
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
('a3333333-3333-3333-3333-333333330005'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'「杀猪盘」套路全解析',
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
('a3333333-3333-3333-3333-333333330006'::uuid,
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
'婚前一定要聊清楚的十个问题',
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
'走进婚姻前,建议和对方认真聊清以下问题。'
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
('a3333333-3333-3333-3333-333333330007'::uuid,
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
'婚事新办,文明婚俗新风尚',
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
-- ============================================================
INSERT INTO service_locations
(id, name, short_name, address, latitude, longitude,
contact_phone_display, opening_hours_text, service_description,
primary_organization_id, status)
VALUES
('a4444444-4444-4444-4444-444444440001'::uuid,
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
31.9210000, 119.6090000, '0511-86666888',
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
('a4444444-4444-4444-4444-444444440002'::uuid,
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
31.8600000, 119.4900000, '0511-86688866',
'周一至周五 9:00-16:30(法定节假日除外)',
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
-- 用户端公开列表只展示 status = active
-- ============================================================
INSERT INTO service_programs
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
VALUES
('a5555555-5555-5555-5555-555555550001'::uuid,
'公益红娘一对一服务计划',
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550002'::uuid,
'婚恋反诈宣传进社区',
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550003'::uuid,
'婚前辅导公益课堂(筹备中)',
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
(SELECT id FROM operators WHERE username = 'admin'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 6. 婚恋咨询 counseling_services(2 条)
-- service_type / status 取值对齐生产 CHECK 约束
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
preferred_service_mode, scheduled_start_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
'relationship_guidance', 'user_self',
'离异后重新开始,希望了解如何建立健康的新关系。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
('a6666666-6666-6666-6666-666666660002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
'emotional_support', 'user_self',
'工作压力较大,希望有人倾听并给出情绪调节建议。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'assigned', 'online', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
-- 婚前辅导复用 counseling_services 表,无独立表
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
'premarital_guidance', 'user_self',
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
('a6666666-6666-6666-6666-666666660004'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
'premarital_guidance', 'user_self',
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'scheduled',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
NOW() + INTERVAL '5 days', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 8. 本地资格 user_local_eligibilities(3 条)
-- eligibility_type / verification_status 对齐生产 CHECK 约束
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
-- ============================================================
INSERT INTO user_local_eligibilities
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
VALUES
('a7777777-7777-7777-7777-777777770001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
'household_registration', '321181', 'verified',
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
'employment', '321181', 'verified',
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
'residence', '321100', 'pending_review', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
-- ============================================================
INSERT INTO local_eligibility_evidences
(id, local_eligibility_id, file_key, evidence_type, status)
VALUES
('a8888888-8888-8888-8888-888888880001'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
('a8888888-8888-8888-8888-888888880002'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
-- ------------------------------------------------------------
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
--
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
-- ============================================================
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
ON CONFLICT DO NOTHING;
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
region_code, region_name, marital_status, children_status,
education_code, occupation_code, smoking_status, drinking_status,
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
'是', '是', '结婚', 93, 'approved'),
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
'是', '是', '结婚', 91, 'approved'),
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
'是', '是', '结婚', 94, 'approved')
ON CONFLICT (user_id) DO NOTHING;
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
INSERT INTO user_preferences
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
ON CONFLICT (user_id) DO NOTHING;
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
INSERT INTO user_privacy_settings
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
INSERT INTO user_identities
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
VALUES
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
ON CONFLICT DO NOTHING;
-- 照片(头像 + 生活照,与既有演示用户一致)
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
VALUES
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
-- ------------------------------------------------------------
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
-- 保持一致(均指向演示红娘 wanghongniang)。
-- ============================================================
INSERT INTO matchmaker_assignments
(id, user_id, matchmaker_operator_id, service_id, assigned_by,
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at)
VALUES
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
('a9944444-4444-4444-4444-444444440001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
('a9944444-4444-4444-4444-444444440002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
('a9944444-4444-4444-4444-444444440003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:服务已完结', 'ended',
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 12. 服务站运营人员数据范围(service_location_operators)
-- ------------------------------------------------------------
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
-- ============================================================
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
VALUES
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW())
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
-- ============================================================
-- 验证
-- ============================================================
DO $$
DECLARE
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
BEGIN
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO rec_cnt FROM users
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
AND deleted_at IS NULL;
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
'a9900000-0000-0000-0000-000000000008'::uuid,
'a9900000-0000-0000-0000-000000000009'::uuid);
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
RAISE NOTICE '========================================';
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
RAISE NOTICE '========================================';
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
RAISE NOTICE '========================================';
END $$;
COMMIT;
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'), ('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'); ('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001'); INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
@@ -0,0 +1,12 @@
-- 指定对象的公益红娘协助申请(不交换联系方式)
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500);
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention', 'join_activity',
'no_match_help', 'chat_help', 'other', 'recommendation',
'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station'
));
CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC);
@@ -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;

Some files were not shown because too many files have changed in this diff Show More