chore(release): 冻结 v1.1.0 版本

- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

未验证项(打标签 ≠ 生产发布):
- 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限)
- 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收
- 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
This commit is contained in:
WorkBuddy
2026-09-30 12:10:00 +08:00
parent 1ad9793ea2
commit ca0db57f24
53 changed files with 1256 additions and 83 deletions
+37
View File
@@ -0,0 +1,37 @@
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
#
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
#
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
# —— 默认兜底 ——
* @g1307092382
# —— 后端(Spring Boot / Java)——
/backend/ @g1307092382
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
/backend/src/main/resources/db/migration/ @g1307092382
# —— 微信小程序(原生 WXML/WXSS/TS)——
/miniapp/ @g1307092382
# —— 管理端(Vue3 / TS / pnpm)——
/admin-web/ @g1307092382
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
/tests/ @g1307092382
/ui-tests/ @g1307092382
/scripts/run-ui-contracts.mjs @g1307092382
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
/infrastructure/ @g1307092382
# —— 治理与审查机制文档(变更须经技术负责人确认)——
/docs/governance/ @g1307092382
/.github/ @g1307092382
+69
View File
@@ -0,0 +1,69 @@
<!--
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
-->
## 变更摘要
<!-- 改了什么、为什么、影响面 -->
## 风险分级(见审查标准 §2)
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
- [ ] T1 常规(单端功能/Bug/UI/单 API)
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
## 验证方式(必填,证据优先)
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
- 本地测试/契约守卫命令与结果:
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
## 技术栈审查清单(按本 PR 涉及项打勾)
### 后端 Java / Spring Boot
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
### 小程序 miniapp
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
- [ ] 胶囊安全区让出 `--nav-right`
### 管理端 admin-web
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
- [ ] 表单校验类型安全、出错保留输入
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
### 数据库迁移 SQL
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
### 测试 / 契约守卫
- [ ] 无空壳 `skip`/`pass` 测试
- [ ] 已做负向注入证明判别力
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
### 脚本 / 基础设施
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
## 判据纪律自查(见审查标准 §5,对应改动必查)
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
## 已知阻塞 / 决策关联
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
## 关联
<!-- 关联 Issue / 决策 / 风险编号 -->
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1
1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "danyang-match-admin",
"version": "1.0.0-rc.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -19,6 +19,9 @@
# 后者生成 Vue 版本桥接),允许它们执行。
packages:
- '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
onlyBuiltDependencies:
- esbuild
- vue-demi
+43 -5
View File
@@ -1,14 +1,18 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
// 防止 401 时并发触发多次刷新
let refreshing = false
client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('accessToken')
router.push('/login')
async (err) => {
const status = err.response?.status
const original = err.config
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
refreshing = true
try {
const resp: any = await client.post('/auth/operator/refresh', {})
if (resp?.success) {
const newToken = resp.data.accessToken
setAccessToken(newToken)
refreshing = false
;(original as any).__retried = true
const headers = (original.headers || {}) as Record<string, string>
headers.Authorization = `Bearer ${newToken}`
original.headers = headers as any
return client(original)
}
} catch {
// 刷新失败:清除并跳登录
} finally {
refreshing = false
}
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
return Promise.reject(err)
}
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
if (status === 401) {
if (!isAuthEndpoint) {
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
} else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message)
} else {
@@ -80,6 +80,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* U32 活动签到工作台(Phase 15)
*
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
}
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
const formatTime = (v: any) => {
if (!v) return '-'
@@ -42,6 +42,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || []
tableData.value = searchActivityId.value
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
drawerVisible.value = true
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
mutualList.value = mutualRes.data || []
} catch (e) {
@@ -168,6 +168,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* 活动管理(V1.1 Phase 14)
*
@@ -259,7 +260,7 @@ function emptyForm() {
}
function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
return { Authorization: `Bearer ${getAccessToken()}` }
}
async function loadData() {
@@ -213,6 +213,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
@@ -302,7 +303,7 @@ async function loadFunnel() {
try {
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
@@ -328,7 +329,7 @@ async function loadFunnel() {
async function loadDailySummary() {
try {
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || []
} catch (e) { console.error(e) }
@@ -84,6 +84,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -119,7 +120,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve'
decisionNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -148,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` })
+2 -1
View File
@@ -38,6 +38,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined,
page: page.value,
@@ -62,6 +62,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -89,7 +90,7 @@ async function loadData() {
async function loadOrgs() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res.data || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
@@ -105,7 +106,7 @@ async function save() {
if (!form.value.username || !form.value.password) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false
loadData()
@@ -115,7 +116,7 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData()
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) }
}
@@ -60,6 +60,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
} catch (e) { console.error(e) }
@@ -101,7 +102,7 @@ async function save() {
if (!form.value.name) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}`
@@ -119,7 +120,7 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}`
@@ -98,6 +98,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -124,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -134,7 +135,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -152,7 +153,7 @@ async function openDetail(reportId: string) {
processResult.value = 'no_action'
processNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -162,7 +163,7 @@ async function handleProcess() {
if (!processResult.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` })
+4 -1
View File
@@ -48,10 +48,13 @@ const routes: RouteRecordRaw[] = [
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
+52 -21
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { post } from '@/api/client'
import { setAccessToken, clearAccessToken } from '@/utils/token'
interface OperatorInfo {
operatorId: string
@@ -23,38 +24,68 @@ export const useAuthStore = defineStore('auth', () => {
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
: null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value)
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
function applyAuthData(data: any) {
token.value = data.accessToken || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password })
if (res.success) {
token.value = res.data.accessToken
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: res.data.organizationId ?? null,
}
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value))
applyAuthData(res.data)
return res.data
}
throw new Error(res.message || '登录失败')
}
function logout() {
token.value = ''
operator.value = null
localStorage.removeItem('accessToken')
localStorage.removeItem('operator')
/**
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
*/
async function initializeSession(): Promise<boolean> {
if (sessionInitialized) return isLoggedIn.value
sessionInitialized = true
if (token.value) return true
try {
const res: any = await post('/auth/operator/refresh', {})
if (res.success) {
applyAuthData(res.data)
return true
}
} catch {
// 未登录或 refresh 失效,交由路由守卫跳转登录页
}
return false
}
return { token, operator, isLoggedIn, permissions, login, logout }
function logout() {
// 尽力通知后端注销并清除 Cookie(best-effort)
try {
post('/auth/operator/logout', {}).catch(() => {})
} catch {
// ignore
}
token.value = ''
operator.value = null
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
})
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version>
<version>1.1.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties>
<java.version>21</java.version>
@@ -1,8 +1,13 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -17,14 +22,83 @@ import java.util.Map;
public class OperatorAuthController {
private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String username = body.get("username");
String password = body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
}
return ApiResponse.ok(operatorAuthService.login(username, password));
Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result);
}
}
/**
* 刷新 Operator Token。
*
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String refreshToken = (body != null) ? body.get("refreshToken") : null;
if (refreshToken == null || refreshToken.isBlank()) {
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
}
if (refreshToken == null || refreshToken.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
}
String operatorId = (body != null) ? body.get("operatorId") : null;
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result);
}
/**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* token 取自 Authorization 头,缺省时回退到 access Cookie。
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {
String token = readBearer(request);
if (token != null) {
try {
String operatorId = jwtTokenService.getUserIdFromToken(token);
operatorAuthService.logout(operatorId, token);
} catch (Exception ignored) {
// token 已失效也无妨,仍清除 Cookie
}
}
cookieWriter.clearCookies(response);
return ApiResponse.ok();
}
private String readCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie c : cookies) {
if (name.equals(c.getName())) return c.getValue();
}
return null;
}
private String readBearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
}
}
@@ -0,0 +1,50 @@
package com.danyang.match.platform.auth.cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
/**
* 管理端鉴权 Cookie 写入工具。
*
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
* 前端 localStorage,XSS 可读写并伪造权限)。
*
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
* dev / docker(http)为 false,否则浏览器拒绝写入。
*
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
*/
@Component
public class OperatorCookieWriter {
public static final String ACCESS_COOKIE = "dy_op_at";
public static final String REFRESH_COOKIE = "dy_op_rt";
@Value("${app.auth-cookie-secure:true}")
private boolean secure;
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
}
public void clearCookies(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
}
private ResponseCookie build(String name, String value, long maxAgeSec) {
return ResponseCookie.from(name, value)
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.path("/api/v1")
.maxAge(maxAgeSec)
.build();
}
}
@@ -13,10 +13,15 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Mock 微信登录 Provider(开发/测试环境使用)
* Mock 微信登录 Provider(开发/测试环境使用)。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@@ -0,0 +1,100 @@
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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 真实微信登录 Provider(生产环境使用)。
*
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>安全要点:
* <ul>
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
* <li>绝不把 appSecret / session_key 回传给前端。</li>
* </ul>
*/
@Component
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
public class RealWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
@Value("${wechat.app-id:}")
private String appId;
@Value("${wechat.app-secret:}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, String> getCodeSession(String code) {
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
}
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
}
String url = CODE2SESSION_URL
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
try {
String resp = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(resp);
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
}
String openid = node.has("openid") ? node.get("openid").asText() : null;
if (openid == null || openid.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
Map<String, String> result = new HashMap<>();
result.put("openid", openid);
result.put("unionid", unionid);
result.put("session_key", sessionKey);
result.put("appid", appId);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Wechat] code2session 调用异常", e);
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
}
}
@Override
public boolean isMock() {
return false;
}
}
@@ -91,7 +91,9 @@ public class AuthService {
// 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
// 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
@@ -173,9 +175,9 @@ public class AuthService {
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken);
@@ -177,6 +177,11 @@ public class OperatorAuthService {
throw new UnauthorizedException("Refresh Token 无效");
}
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效");
@@ -194,6 +199,12 @@ public class OperatorAuthService {
result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId"));
result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
result.put("permissions", operatorInfo.get("permissions"));
return result;
}
@@ -215,9 +215,21 @@ public class JwtTokenService {
}
/**
* 获取 Token 过期时间
* 获取 Access Token 过期时间(毫秒)
*/
public long getExpirationMs() {
return accessExpirationMs;
}
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
}
@@ -232,6 +232,25 @@ public class MeController {
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
}
/**
* 确认直传照片落库(REST §29 补)。
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
* POST /api/v1/me/photos/confirm
*/
@PostMapping("/photos/confirm")
public ApiResponse<Map<String, Object>> confirmPhoto(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String fileKey = body.get("fileKey");
String photoType = body.getOrDefault("photoType", "lifestyle");
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("photoId", photo.getId());
result.put("photoType", photo.getPhotoType());
return ApiResponse.ok(result);
}
/**
* 删除本人照片(REST §32)。
* DELETE /api/v1/me/photos/{photoId}
@@ -187,6 +187,31 @@ public class PhotoService {
return photo;
}
/**
* 确认直传照片落库(客户端直传对象存储成功后调用)。
*
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
* <ol>
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
* </ol>
*/
@Transactional
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1;
return savePhoto(userId, fileKey, photoType, sortOrder);
}
/**
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
*/
@@ -7,6 +7,7 @@ import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -58,12 +59,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else {
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("dy_op_at".equals(c.getName())) {
token = c.getValue();
break;
}
}
}
}
if (token == null) {
chain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtTokenService.validateToken(token)) {
sendUnauthorized(response);
return;
@@ -12,10 +12,15 @@ import java.util.UUID;
/**
* Mock 实名认证 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
@@ -0,0 +1,190 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 真实实名认证 Provider(生产环境使用)。
*
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>核验策略(fail-closed,绝不静默放行):
* <ul>
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
* 绝不能回退成「放行」。</li>
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
* </ul>
*
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
*/
@Component
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
private static final String CHECK_CODES = "10X98765432";
@Value("${identity.verification.real.api-url:}")
private String apiUrl;
@Value("${identity.verification.real.api-key:}")
private String apiKey;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, Object> verify(String realName, String idNumber) {
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
}
if (realName == null || realName.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
}
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
if (!isValidChecksum(idNumber)) {
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
}
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
String gender = parseGenderFromIdNumber(idNumber);
// 2. 第三方核验(fail-closed)
boolean matched = callThirdParty(realName, idNumber);
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
}
/**
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
*/
private boolean callThirdParty(String realName, String idNumber) {
try {
Map<String, Object> body = new HashMap<>();
body.put("realName", realName);
body.put("idNumber", idNumber);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
return false;
}
JsonNode node = objectMapper.readTree(resp.getBody());
return isExplicitlyVerified(node);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Identity] 第三方核验调用异常", e);
return false;
}
}
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
private boolean isExplicitlyVerified(JsonNode node) {
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
JsonNode v = node.get(field);
if (v != null && v.isBoolean() && v.asBoolean()) {
return true;
}
}
// 兼容 { "code": 0, "message": "ok" } 形态
JsonNode code = node.get("code");
if (code != null && code.isNumber() && code.asInt() == 0) {
return true;
}
return false;
}
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
String birthDate, String gender, String requestId) {
Map<String, Object> result = new HashMap<>();
result.put("verified", verified);
result.put("realName", realName);
result.put("idNumberHash", idNumber);
result.put("birthDate", birthDate);
result.put("gender", gender);
result.put("requestId", requestId);
return result;
}
@Override
public boolean isMock() {
return false;
}
/** GB 11643-1999 校验位(mod 11-2)。 */
private boolean isValidChecksum(String idNumber) {
if (idNumber == null || idNumber.length() != 18) {
return false;
}
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
return false;
}
char last = idNumber.charAt(17);
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
return false;
}
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
}
char expected = CHECK_CODES.charAt(sum % 11);
return Character.toUpperCase(last) == expected;
}
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
try {
int year = Integer.parseInt(idNumber.substring(6, 10));
int month = Integer.parseInt(idNumber.substring(10, 12));
int day = Integer.parseInt(idNumber.substring(12, 14));
return LocalDate.of(year, month, day);
} catch (Exception e) {
return LocalDate.now().minusYears(25);
}
}
private String parseGenderFromIdNumber(String idNumber) {
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
return (genderDigit % 2 == 1) ? "male" : "female";
}
private String maskIdNumber(String idNumber) {
if (idNumber == null || idNumber.length() < 10) return "****";
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
}
}
@@ -38,10 +38,17 @@ object-storage:
region: us-east-1
signature-version: s3v4
verification:
provider: ${VERIFICATION_PROVIDER:mock}
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
identity:
verification:
provider: ${VERIFICATION_PROVIDER:mock}
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
app:
auth-cookie-secure: false
match:
rule-version: 1
@@ -14,3 +14,27 @@ spring:
user:
name: disabled
password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
wechat:
login:
provider: real
app-id: ${WECHAT_APP_ID}
app-secret: ${WECHAT_APP_SECRET}
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
identity:
verification:
provider: real
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
app:
auth-cookie-secure: true
+178
View File
@@ -0,0 +1,178 @@
# 善缘丹阳 代码审查报告
> 审查日期:2026-09-24
> 审查范围:后端(Java 21 / Spring Boot 3)、微信小程序(原生 + TS)、管理端(Vue 3 + Vite + Pinia)
> 审查视角:CodeReviewExpert — 正确性 / 安全 / 可维护 / 性能 / 测试
> 核验声明:下方 🔴 阻断 1–4 与 🟡 刷新 Token TTL 均由主审查员**直接读源码 + grep 复核**确认;其余条目为子代理带文件路径核查,结论可信但建议落地前再回读一次原文件。
---
## 一、总览
**总体印象**:本项目的工程纪律落地得相当扎实——幂等(`@Idempotent` + 唯一索引)、数据权限守卫(`DataScopeGuard` 真实接入多处)、WXML 结构守卫、请求层"先 resolve 再收尾"的时序守卫,都不是文档承诺而是真实代码。这是少见的"纪律工程化"水平。
**但存在 2 个已确认的上线阻塞**(与 UAT 走查结论一致):微信登录无真实实现、照片直传不落库;此外发现若干安全/正确性缺陷,其中管理端 token 存储、小程序红娘接单鉴权头错位、刷新 Token TTL 误用会直接影响可用性与安全。
**优先级**:上线前必须关闭 🔴 1–4;🟡 建议同批修复。
---
## 二、🔴 阻断(必须修,否则不能上线)
### 1. 微信登录生产默认走 Mock —— 任意 code 可无限建号
`backend/.../auth/provider/MockWechatLoginProvider.java:19`
```java
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
// :35
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID()...);
```
**为什么是问题**:`matchIfMissing = true` 意味着**未显式配置即启用 Mock**。`application-production.yml` 中并无 `verification.provider`(只有 logging/springdoc/security),故生产环境默认走 Mock。Mock 实现对任意 `code` 都生成随机 openid 并自动建号——攻击者可凭任意字符串无限注册假账号,且 `wechat.app-id` 默认 `dev-app-id`。这是实名/风控链路的根基性缺口。
**修复**:
- 新增 `RealWechatLoginProvider`(`@ConditionalOnProperty(... havingValue = "real")`),调用 `https://api.weixin.qq.com/sns/jscode2session` 用真实 `code` 换取 openid/session_key,并校验 appid/secret。
- 生产 `application-production.yml` 显式置 `verification.provider=real`,并**删除 `matchIfMissing=true`**(或仅留给本地 dev profile)。
- 接入 `code2session` 失败、空 openid、appid 不匹配的拒绝分支。
### 2. 照片直传不落库 —— 直传后无"确认/落库"端点
`backend/.../me/controller/MeController.java:227` 与 `photo/controller/PhotoController.java:32` 均暴露 `POST /.../photos/upload-token`,调用 `PhotoService.getUploadToken` 返回 MinIO **预签名 PUT URL + fileKey**(`PhotoService.java:75-86`)。但全仓**没有任何端点接收直传的 `fileKey` 并写 `user_photos`**:
- `savePhoto(...)` 仅被 `PhotoService.java:233-245` 的多段上传 `POST /photos/upload`(`MultipartFile` 后端代传)调用;
- `getSignedUrl`(`:96-99`)按 `user_photos` 校验归属:`boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey()))`。
**为什么是问题**:走直传路径时,对象进了 MinIO,但 `user_photos` 无记录 → `getPhotos` 不展示,**且 `getSignedUrl` 归属校验直接失败,连访问鉴权都过不了**。直传等于"传了白传"。这是已知阻塞的真实根因。
**修复**:新增 `POST /me/photos/confirm`(或 `POST /photos/confirm`),入参 `fileKey + photoType`,服务端校验 fileKey 属当前用户 + 对象已存在于 MinIO 后调用 `savePhoto`,并触发人工审核状态机。小程序侧直传成功后调用该确认端点。
### 3. 小程序红娘"接单"鉴权头错位 —— 请求实际无鉴权
`miniapp/utils/api.js:272`
```js
post(url, data, headers, idempotencyKey) { ... }
```
`miniapp/pages/matchmaker-workbench/matchmaker-workbench.js:85`
```js
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, {}, { Authorization: `Bearer ${token}` })
```
**为什么是问题**:第 4 个参数是 `idempotencyKey`,第 3 个 `headers` 被传成 `{}`。于是 `Authorization` 对象被塞进幂等键位,而请求头里**没有任何鉴权头**。且 operator 流程用的是 `operatorToken`(非用户 `accessToken`),`api.getToken()` 读不到它——所以本请求必然 401,红娘"接单"必失败。同文件 `:50-51` 用 `api.get(url, {}, {Authorization})` 三参形式才正确,两处不一致即证据。
**修复**:改为 `api.post(url, {}, { Authorization: \`Bearer ${token}\` })`(去掉第 4 参)。
### 4. 管理端 token 存 localStorage + 权限清单可被本地篡改
`admin-web/src/stores/auth.ts:26,45-46`
```ts
const token = ref<string>(... localStorage.getItem('accessToken') || '')
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value)) // 含 permissions
```
`admin-web/src/api/client.ts:11` 拦截器统一注入 `Bearer`,但另有 30+ 处 `.vue` 直接 `localStorage.getItem('accessToken')` 手工注入(如 `ReportCenterView.vue:127/137/155/165`、`OperatorManagementView.vue` 多处、`ActivityCheckinView.vue:114`)。
**为什么是问题**:
- 令牌落 localStorage + 项目内大量 `any` 与裸 `console.error(e)`,任意 XSS 即可 `localStorage.getItem('accessToken')` 取走令牌直连后端,**账号接管**。
- `operator`(含 `permissions`)序列化进 localStorage,前端 `router/index.ts` 的 `hasPermission` 与 `MainLayout.vue` 菜单过滤只比对本地产物——攻击者改 `localStorage.operator.permissions` 即可在前端解锁菜单/按钮(虽服务端仍 403,但**可见性完全由可被篡改的本地数据决定**,违反"前端不是安全边界")。
**修复**:
- 短期:令牌改内存态 + 短时效,刷新令牌走 httpOnly Cookie;删除各 `.vue` 的手工 `localStorage.getItem('accessToken')`,统一依赖 `client.ts` 拦截器。
- 权限可见性由服务端下发 capability 信封驱动(见 🟡 #4),前端不再以本地 `permissions` 数组作为显隐的唯一依据。
---
## 三、🟡 建议(应优先修,真实缺陷)
### 5. 刷新 Token 的 Redis TTL 用错 → 用户每 4 小时被强制登出
`backend/.../auth/service/AuthService.java:94`(登出同 :177-178)
```java
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
```
**为什么是问题**:`getExpirationMs()` 返回 **access** 过期(默认 2h),`*2 = 4h`;而 refresh JWT 自身有效期是 `refreshExpirationMs`(默认 14 天,`JwtTokenService.java:39/81`)。`refreshToken()` 第 3 步(:167-169)用 Redis 记录做可撤销校验,4h 后记录消失即抛 `UnauthorizedException` → **用户每 4h 被迫重新登录**。
**修复**:改为 `Duration.ofMillis(jwtTokenService.getRefreshExpirationMs())`(或新增对应 getter),使 Redis 记录生命周期与 refresh JWT 一致。
### 6. 手机号绑定无真实校验(TODO 残留)
`AuthService.java:113`:`// TODO: Phase 03 对接真实短信验证码`,`bindPhone` 仅查重后 `user.setPhone(phoneCode)`。结合 #1 Mock 登录,任何人可绑定任意手机号,属实名链路缺口。需补短信验证码校验。
### 7. 小程序 `bind-phone` 空桩 + operator 登录双轨清 token
- `pages/bind-phone/bind-phone.js` 为 `Page({ data:{}, onLoad(){}, onShow(){} })` 空实现,用户进页空白。
- `pages/matchmaker-login/matchmaker-login.js:39-51` 自建 `operatorToken` 并裸用 `wx.request` 绕过 `utils/api.js`;当 operator 接口回 401 时,`api.onSessionExpired` → `api.clearToken()` 清的是**用户** `accessToken/refreshToken`(api.js:113-120),会**误清用户登录态**。建议 operator 通道也走 `api` 层并隔离失效回调。
### 8. 小程序生产域名是占位符
`miniapp/env.js:27` / `env.ts:24`:`: 'https://api.danyang-match.example.com/api/v1'`。`example.com` 为保留占位域名,生产构建连不上真实后端。需按环境注入真实域名(构建变量 / 配置中心)。
### 9. 小程序 `serviceType` 枚举直渲未映射中文
`pages/matchmaker-workbench/matchmaker-workbench.wxml:59`
```xml
<view class="service-tag">{{item.serviceType === 'recommend_partner' ? '推荐对象' : item.serviceType}}</view>
```
除 `recommend_partner` 外,其它 `serviceType`(如 `red_line_prevention`)会把英文枚举码直接显示给用户,违反 `constants/profileLabels.js`"界面不得出现英文枚举码"铁律。需补 `serviceType` 中文映射表。
### 10. 管理端敏感处置缺二次确认
`src/modules/risk/ReportCenterView.vue:91` 对封禁/暂停(30天)等直接 `post(.../process)`,无 `ElMessageBox.confirm`;而 `UserDetailView.vue:113` 改账号状态有确认。同类高危操作应统一二次确认 + 审计提示。
### 11. 管理端取数/显隐仍由前端权限启发式决定(Capability 应服务端驱动)
`src/services/dashboard.ts:66-67,93-119` 用 `permissions.includes('analytics.view')` 决定请求接口并打 `available` 控制显隐;`MainLayout.vue:209-222` 用权限拼"角色名"。能力虽源自服务端权限串,但映射仍在前端硬编码。理想做法是由服务端下发 capability 信封驱动可见性与取数。
### 12. 管理端 `any` 滥用、跨端字段漂移、举报列表未分页
- `UserDetailView.vue:90`、`UserManagementView.vue:112`、`ReportCenterView.vue` 多处 `ref<any>`、`catch(e)` 未标注类型;跨端字段如 `phoneMasked/sensitiveMasked`、`totalLimit↔capacity` 缺共享 TS 接口,易产生字段名不一致。
- `ReportCenterView.vue:129` 列表 `res.data || []` 直接渲染、未分页,大举报量有性能隐患。
---
## 四、💭 细节(已良好 / 留意)
**✅ 已落实良好的部分(值得肯定)**
- 请求层时序守卫:`utils/api.js:180-198` 已实现"先 `resolve(body.data)` 再做 `releaseIdempotencyKey` 收尾",正是工程纪律中"私有方法放 resolve 前致所有 Promise 永不 settle"坑的工程化规避;`:140-142` 的 `isAuthEndpoint` 豁免 `/auth/*` 自我递归。
- 数据权限守卫真实落地:`DataScopeGuard.deny` 已接入 `CheckinService`、`CounselingService`(Counselor 仅看 assigned + 敏感访问审计)、`ActivityService`、`ServiceAppointmentService`,非注释。
- 幂等机制扎实:`@Idempotent` 覆盖所有写端点,`IdempotencyInterceptor` 用 DB 唯一索引 `uk_idempotency_scope` 占位防并发重复。
- 配置可配置化良好:`MatchRuleConfigProperties` 权重/阈值/冷却期全部外部化(`match.*`),符合"不得写死业务规则"。
- 管理端设计系统 token 收敛良好(`theme/index.css` / `tokens.ts` 为唯一色源,输入框已含 `Microsoft YaHei`)。
**⚠️ 仍需留意 / 复核**
- SQL 注入:`.java` 内 `grep \${` 命中均为 `@Value` 占位符,mapper 走 MyBatis-Plus `LambdaQueryWrapper`,未见 `${}` 拼接 SQL。**但**项目含 MyBatis,需再扫 `src/main/resources` 下 `*Mapper.xml` 原生 SQL(`@Select` 等)确认无 `${}`。
- `SecurityConfig` 的 `requestMatchers` 相对 context-path `/api/v1` 是否被正确剥离(已有 `SecurityPathCoverageTest` 守护,风险低,建议回读确认)。
- `HomeBannerOperatorController:153` 仅拦截 `http(s)` 外链,需确认是否只允许 `/pages/` 内部跳转而非任意 http 内部域(INV-016 Banner 不能任意外链)。
- 跨端字段名靠运行时契约(tsc 不保护):`stores/authStore.js` 注释自曝已三次踩坑(`accessToken` 非 `token`、`isNewUser` 非 `!needPhone`、`regionCode/createdAt` 等 camelCase)。建议对关键响应用 `result.accessToken || result.token` 兜底断言。
---
## 五、表扬
- **纪律工程化**做到位:把"WXML 结构、私有方法调用、组件选择器、请求层时序、幂等"等易踩坑点都做成了守护测试与行为契约,而不是靠人肉 review。这是本项目最值得保留的资产。
- **文档与代码分离意识强**:AGENTS.md 明确"文档✅不是需求,只有代码/测试/验收才证明完成",且本审查中也确实发现若干文档声明与代码现实不符(微信登录、照片落库),说明团队有"不信绿"的底线。
---
## 六、下一步建议(优先级排序)
1. **上线前必须关闭**:🔴 #1 微信真实登录(已修复)、🔴 #2 照片直传落库端点(已修复)、🔴 #3 红娘接单鉴权头(已修复)、🔴 #4 管理端 token/权限存储(已修复,见第二节)。
2. **同批修复**:🟡 #5 刷新 Token TTL(影响所有用户登录体验)、#6 手机号真实校验。
3. **排期修复**:#7–#12。
4. **补一轮针对性测试**:直传落库端到端、刷新 Token 生命周期、operator 通道失效不误伤用户登录态、枚举中文映射契约测试。
> 说明:本审查以"证据优先"为原则,🔴 与 #5 均经主审查员直接读源码复核;落地修复前建议对每个文件再回读一次原文,确认行号与上下文未漂移。
---
## 七、第二轮修复(原延后项已落地,2026-09-24 续)
> 以下两项为上一轮"刻意未改/建议下一轮"的延后项,本轮已直接动手修复,均经源码级核验(本机 bash 不可用,未做编译/运行验证)。
### A. 生产实名核验 Mock → Real(对应 AGENTS.md 硬规则:本地资格/实名认证分离、敏感数据默认拒绝)
- 新增 `verification/provider/RealIdentityVerificationProvider.java`(`@ConditionalOnProperty(name="identity.verification.provider", havingValue="real")`):
- 本地强校验身份证号 **GB 11643-1999 校验位(mod 11-2)**,伪造证件号在外部调用前即拒绝;
- 第三方核验走可配置 `identity.verification.real.api-url` + `api-key`(环境变量 `VERIFICATION_API_URL` / `VERIFICATION_API_KEY`);
- **fail-closed**:任何无法明确判定为"通过"的响应一律视为未通过;**缺第三方配置即 fail-fast**(不静默放行任意证件)。
- `MockIdentityVerificationProvider` 属性从 `verification.provider` **解耦**为 `identity.verification.provider`(`matchIfMissing=true` 保留本地默认)。
- `application-production.yml` 显式置 `identity.verification.provider=real` + 注入 api-url/api-key;`application-dev.yml` / `application-docker.yml` 保持 mock 默认。
- **契约对接提示**:第三方请求/响应字段(如厂商选公安实名或运营商三要素)需在接入时最终确认;当前以 `success/match/verified/passed` 或 `code==0` 判定通过。
### B. 管理端 token 改 HttpOnly Cookie(对应原 🔴 #4)
- 后端 `OperatorCookieWriter`:登录/刷新成功下发 **HttpOnly + SameSite=Lax** 的 `dy_op_rt`(refresh)/ `dy_op_at`(access)Cookie;登出清除。`Secure` 由 `app.auth-cookie-secure` 控制(dev/docker false、production true)。
- `OperatorAuthController`:新增 `/auth/operator/refresh`(cookie 优先于 body,支持 silent-refresh)、`/auth/operator/logout`(清 Cookie + 黑名单);登录/刷新均重下发 Cookie。
- `JwtAuthFilter`:取 token 改为 **Authorization 头优先、缺则回退 `dy_op_at` Cookie**(小程序/用户端仍走头,不受影响)。
- 前端 `stores/auth.ts`:**移除 localStorage 存 token/权限**,改内存态;新增 `initializeSession()` 凭 HttpOnly refresh Cookie 静默恢复会话(刷新页面免重登);`client.ts` 拦截器 401 时自动静默刷新一次后重试(带防重入守卫);9 个 `.vue` 中的 `localStorage.getItem('accessToken')` 全部改为内存态 `getAccessToken()`。
- **安全收益**:XSS 无法直接读取/外泄令牌,也无法改写本地权限清单伪造菜单(服务端 `@PreAuthorize` 仍是真边界)。
- **验证缺口(需你确认)**:本机未编译/运行;建议后端补编译 + 管理端登录→刷新→登出端到端走查;dev 跨端口代理与 prod 同源场景均依赖 Cookie(Cookie 不按端口划分作用域,dev 同样可用)。
### 仍延后(非本轮范围 / 需外部凭证)
- **手机号绑定真实校验**:需接短信验证码网关,无凭证无法真实实现,保留 TODO,未伪造。
- 上一轮"仍需留意"项(MyBatis xml `${}` 原生 SQL、SecurityConfig 路径剥离、Banner 外链范围、跨端字段名契约)维持原结论,未改动。
+196
View File
@@ -0,0 +1,196 @@
# 代码审查标准与流程 — 善缘丹阳公益婚恋平台
> **版本**:V1.0 · **生效**:2026-09-23 · **owner**:技术负责人 / 全体提交者
> **配套**:`PLAYBOOK.md`(操作手法) · `known-risks.md`(已知债务) · `decision-register.md`(已确认决策) · `.github/workflows/ci.yml`(自动门禁)
> **目的**:本仓库是多端单体仓库(Spring Boot 后端 + 微信小程序 + Vue3 管理端 + 迁移脚本 + E2E),历史上已多次出现"本地/CI 全绿但线上失效"的**假绿**陷阱。本文档把已踩过的坑固化成**人在合并前必须执行的审查纪律**,与 CI 自动门禁形成互补——CI 是底线兜底,人工审查是质量上限。
---
## 0. 总原则
1. **证据优先,信任代码而非措辞**:PR 描述里的"✅ 已完成 / 已修复 / 已验证"**不是证据**,只是主张。审查者只认:可读的代码改动 + 能证明判别力的测试 + 运行时实测结论。文档/PPT/截图里写的"已完成"一律按"未证实"处理。
2. **审查是协作质量门,不是找茬**:目标是让合并后的代码比提交时更稳。区分"必须拦下"(Blocker/Major) 与"建议改进"(Minor/Nit)。
3. **清单是地板不是天花板**:本文清单覆盖的是本仓库**反复出过事**的点;未列出的通用工程规范(命名、复杂度、可读性)同样适用。
4. **任何放行都需可追溯**:审查意见、拒绝原因、已知债务豁免都留在 PR 或 `known-risks.md`,不靠口头"应该没问题"。
---
## 1. 适用范围与仓库分层
| 层 | 路径 | 技术栈 | 本仓库最高风险点 |
|----|------|--------|------------------|
| 后端 | `backend/` | Spring Boot / Java 21 / Maven / Flyway / JWT | 裸 `RuntimeException`→500、弱断言、业务不变量被绕过 |
| 小程序 | `miniapp/` | 原生 WXML/WXSS/TS,`app.json`/`pages.json` 声明式 | `tsc` 不保跨端字段名;`ignoreDevUnusedFiles` 吞资源;WXSS 编译静默失效 |
| 管理端 | `admin-web/` | Vue3 / TS / pnpm / Element Plus | 非响应式状态不重渲染;设计 token 编译期生成;`vite build` 清 `dist/` |
| 迁移 | `backend/src/main/resources/db/migration/V*.sql` | Flyway | 版本缺号/重复、CHECK 约束与 Java 枚举漂移 |
| 测试 | `tests/e2e/`(pytest)、`ui-tests/*.mjs`(契约守卫)、`backend/src/test/` | 多框架 | 空壳测试、缺负向注入、守卫数下限被悄悄删 |
| 脚本/基建 | `scripts/`、`infrastructure/` | Node/Python/Compose | 依赖声明与实际不符(本地绿/CI 红) |
---
## 2. 审查分级(按变更风险)
| 级 | 名称 | 触发条件 | 最少审查人数 | 审查深度 |
|----|------|----------|--------------|----------|
| **T0** | 琐碎 | 纯文案/注释/文档、`*.md`、与逻辑无关的格式化、资源重命名(无引用变更) | 1(可自审) | 仅看是否误伤其他引用 |
| **T1** | 常规 | 单端功能开发、Bug 修复、UI 调整、单个 API 增改 | 1 | 走 §4 对应技术栈清单 + §5 判据纪律 |
| **T2** | 高危 | 涉及:鉴权/登录链路、支付/资金、数据隐私(PII)、跨端契约字段、数据库 schema 变更、并发/幂等、未接真实现的已知阻塞项(见 §6.4) | 2(含 1 名对应该端熟悉者) | 全清单 + 运行时实测结论 + 回滚方案 |
> 判定不清时**就高不就低**。一人团队下"第 2 人"可由技术负责人交叉复核,或在 PR 中显式标注"已自审 + 已跑实测"并附命令与输出。
---
## 3. 严重级别定义
| 级 | 含义 | 合并策略 | 本项目真实示例 |
|----|------|----------|----------------|
| **Blocker** | 合并后会破坏生产或导致数据错误/安全泄露 | **必须修**,否则拒绝合并 | 裸 `RuntimeException` 掩盖业务拒绝(→500);`updateById` 对 `user_id` 主键静默 no-op;跨端字段名写错使请求永不落库 |
| **Major** | 重要功能不可用或存在明显技术债放大风险 | 必须修或可书面豁免(登记到 `known-risks.md`) | `tsc` 通过但 WXML 多一个 `</view>` 编译失败;守卫被删导致"假绿";新增死表/死配置 |
| **Minor** | 不影响功能但降低可维护性/一致性 | 建议修,可不阻塞 | 魔法数字未抽常量;日志缺失;字段命名与 `decision-register` 不一致 |
| **Nit** | 风格/偏好层面 | 不阻塞,鼓励修 | 换行、注释措辞、导入顺序 |
**原则**:Blocker/Major 必须在该 PR 闭环或明确转移到 `known-risks.md`;Minor/Nit 不得作为合并阻塞,避免"审查变成挑刺"。
---
## 4. 各技术栈审查清单(核心)
> 每项审查时**逐项打勾**(在 PR 模板里),打勾 = 已实际验证,不是"应该没问题"。
### 4.1 后端 Java / Spring Boot
- [ ] **异常分类正确**:业务拒绝抛 `BusinessException` + `ApiResponseCode`,HTTP 落在 `400 ≤ code < 500`;**不得**用裸 `RuntimeException`(`orElseThrow(() -> new RuntimeException(...))` 形态也要改,历史上漏过 17 处)。
- [ ] **断言有判别力**:测试对"业务拒绝"断言 `400 ≤ status < 500`,**不得**只断言 `!= 200`(会把 500 洗成"业务拒绝"假绿)。
- [ ] **不变量守住**:匹配/聊天/活动互选等核心规则(见 `decision-register` 的 LOCKED 项,如 INV-014/015 两阶段同意、Required/Mutual/Block)未被绕过;无"直连绕过"或"单方直建"的捷径。
- [ ] **数据落库正确**:主键/唯一键用法正确(如 `UserPreference` 真实主键是 `user_id`,不能用 `updateById` 对其 no-op);NOT NULL / CHECK 约束与 Java 枚举一致。
- [ ] **幂等/并发**:写接口有幂等键或乐观锁;无裸 `synchronized` 跨实例假设。
- [ ] **配置可信源**:新增开关走活键(`application*.yml` 的 `match.*`/`features.*`),**不得**新增 `system_configs`/`match_rule_configs` 这类"死表"(见 KR-02/KR-03)。
- [ ] **敏感信息**:密钥/口令不入库、不进日志;JWT Secret 来自环境变量。
### 4.2 小程序 miniapp(原生)
- [ ] **`app.json` / `pages.json` 一致性**:tabBar、页面路径、资源路径与磁盘真实文件一致;路径**不带前导 `/`**;引用的图标/图片文件确实存在。
- [ ] **资源不被吞**:`project.config.json` 的 `ignoreDevUnusedFiles` / `ignoreUploadUnusedFiles` 必须为 `false`(否则只被 `app.json` 声明、不被任何 WXML 引用的图标会被打包/上传剔除)。
- [ ] **跨端字段名**:小程序发出的字段名与后端规范名完全一致(历史漂移:`hasChildren`/`occupationCategory`/`workRegion`/`ageMin`/`educationAccepted`/`maritalStatusAccepted` 等);**tsc 不保护 JS 层字段名**,必须看值。
- [ ] **WXSS 合法性**:无通配符 `*`(编译期致命);不用属性选择器 `[attr]`(静默失效);注释不嵌套;`display:grid` 在 Skyline 不支持→用 flex。
- [ ] **胶囊安全区**:自定义导航让出 `--nav-right`(`utils/nav-metrics.js` 唯一事实源),不遮挡右上角胶囊。
- [ ] **`<image>` 高度**:只写 `width` 不写 `height` 会按默认 240px 撑高(卡片异常高大真因),必须显式高度或 `mode`。
### 4.3 管理端 admin-web(Vue3 / TS)
- [ ] **响应式状态**:涉及渲染的数组/对象改动走 `ref`/`reactive` 或 `reactive()` 包裹,**不**用普通声明数组当状态写且出现在模板(曾致"静态守卫全绿但页面不重渲染");改完必须补**渲染态实测**。
- [ ] **设计 token**:颜色/字号收敛到 `src/theme/index.css` + `src/theme/tokens.ts`(同名取值双向校验);Element Plus 主色阶是编译期静态生成,`--el-color-primary` 改了必须补全 `-light-N`/`-dark-2`/`-rgb`,否则残留默认蓝。
- [ ] **类型安全**:跨组件 props/接口用 TS 类型;不靠 `any` 绕过;表单校验用 Zod/类型安全方案,错误内联且**保留用户输入**(出错不清空)。
- [ ] **触摸目标**:交互元素 ≥ 44×44px,间距 ≥ 8px;尊重 `prefers-reduced-motion`。
### 4.4 数据库迁移 SQL(Flyway)
- [ ] **版本连续唯一**:`V<序号>__<描述>.sql` 命名,无缺号/重复(CI 的 `Migration Validation` job 会校验,但人也要看变更意图)。
- [ ] **向后兼容**:不破坏现有列/约束;NOT NULL 新列须有默认值或配套数据迁移。
- [ ] **CHECK 约束与枚举一致**:新增/修改 CHECK(如 `chk_profile_gender`、`chk_profile_edu`)必须与 Java 枚举值逐一对应,避免 `DataIntegrityViolationException`(500)。
- [ ] **可逆性**:破坏性变更(DROP/重命名)必须有专门的回滚迁移或明确登记为一次性。
### 4.5 测试(单测 / E2E / 契约守卫)
- [ ] **无空壳**:无 `skip`/`pass` 空测试(CI `Test Shell Guard` 会拦,但人也要看测试是否真在断言什么)。
- [ ] **负向注入**:新增或修改测试后**必须做负向注入**证明测试有判别力(故意改坏被测代码,测试应转红);不做负向注入 = 视为未验证。
- [ ] **守卫数下限**:`scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 **23**)是硬下限,增删守卫必须同步改该常量;审查时确认守卫数量未偷偷下降。
- [ ] **E2E 真实执行**:`tests/e2e` 用例在本机用真实栈跑过(非 import 期即崩的"假绿");依赖 `docker` 在 PATH(否则 `FileNotFoundError` 误判)。
### 4.6 脚本与基础设施
- [ ] **依赖声明即真实依赖**:脚本 import 的包(如 `ui-tests` 守卫依赖 `miniapp/node_modules/typescript`)必须在 CI 对应 job 里安装——"本地能跑"不等于"CI 能跑"。
- [ ] **幂等与可重入**:脚本可重复执行、不产生副作用累积;密钥仅在运行期、不落盘。
- [ ] **Compose 隔离**:本地栈固定 `-p danyang`,不与邻项目撞名(KR-09 教训:`-p infra` 互删容器)。
---
## 5. 判据纪律:审查必须拒收的"假绿"模式(重点)
> 以下每一条都来自本仓库真实事故。审查者看到对应改动,**必须主动排查**,不能因为 CI 绿就放行。
| # | 假绿模式 | 为什么是缺陷 | 审查动作 |
|---|----------|--------------|----------|
| 1 | **文档/PPT 写"✅ 已完成"** | 措辞不是证据 | 要求贴代码 diff / 测试输出 / 实测命令,否则按未证实处理 |
| 2 | **空数据假绿** | 列表/推荐接口返回空数组也能"通过",掩盖查询逻辑失效(推荐曾恒空) | 审查测试是否用**真实种子数据**断言非空结果 |
| 3 | **直连绕过假绿** | 测试直连 Service 而非走 Controller,跳过鉴权/序列化/HTTP 语义 | 关键路径至少有一个经 HTTP 层的端到端断言 |
| 4 | **测试库缺约束假绿** | 测试库没建 CHECK/外键,生产库的约束会在运行时炸成 500 | 约束相关改动必须在带约束环境下验证 |
| 5 | **弱断言 `!= 200`** | 把 500 洗成"业务拒绝",假绿 | 业务拒绝一律断言 `400 ≤ code < 500` |
| 6 | **裸 `RuntimeException`** | 属于 500 家族缺陷,掩盖真实错误 | grep `new RuntimeException` 与 `orElseThrow(() -> new RuntimeException`,要求改 `BusinessException` |
| 7 | **代码合法 ≠ 渲染正确** | Vue 普通声明数组当状态、WXML 结构错误,静态守卫全绿仍不渲染 | 涉及渲染的改动必须补**渲染态实测**(CDP/截图) |
| 8 | **job 名 ≠ 实际行为** | CI `Mini Program Compile Check` 只跑 `tsc --noEmit`,**不真编译** WXML/WXSS | 小程序改动不能只看该 job 绿;真编译走 `scripts/miniapp-compile-check.mjs`(本地) |
| 9 | **跨端字段名漂移** | JS 层字段名不受 tsc 保护,写错使请求字段落空/不落库 | 跨端契约改动逐字段核对 `miniapp` ↔ `backend` 规范名 |
| 10 | **同一文件并行 Edit** | 后者覆盖前者,改动丢失 | 确认 PR 内对同一文件无冲突编辑;合并后回读该文件 |
| 11 | **缺负向注入** | 测试必然全绿,无判别力 | 要求演示"改坏后测试转红" |
---
## 6. 合并准入(Definition of Done / 合并门槛)
### 6.1 硬性门槛(全部满足才可合并)
1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。
2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
4. 新增/修改的测试已做负向注入。
5. 跨端契约字段、数据库约束、设计 token 已对齐各自事实源。
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
| CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|--------|--------------|----------------------------|
| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
| Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 |
| Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 |
| Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 |
| Test Shell Guard | 无空壳测试 | 测试是否真在断言、是否缺负向注入 |
| UI Contracts | 23 守卫结构门 | 守卫数下限是否被偷偷删、守卫是否覆盖新契约 |
| E2E (live stack) | 活栈 pytest | 隐私/资金链路、已知阻塞项是否被正确处置 |
### 6.3 合并后验证(回读纪律)
- 合并/推送后**必须回读**:`git rev-parse HEAD` / `git log -1` / `git status --porcelain`,不只用退出码判断。
- 远端真值只认 `git ls-remote origin refs/heads/main`(本机 `origin/main` 会卡旧值谎报 ahead)。
- 推送后轮询 CI(`.workbuddy/poll_ci.py`),确认本次 head 的 run 真正 success,而非"上次绿的缓存印象"。
### 6.4 已知上线阻塞(触碰即须显式处置)
以下两项为当前**上线阻塞**(见 `PLAYBOOK.md §E`),任何相关 PR 必须:要么本次解决,要么在 PR 中明确说明"本次未解决,仍依赖 Mock/未落库,已登记 `known-risks`"。
- **微信登录无真实实现**:全库仅 `MockWechatLoginProvider`,`WECHAT_APP_SECRET` 无人读。
- **照片直传不落库**:无直传确认端点;小程序用 `photoId`、后端返 `fileKey`,契约不符。
---
## 7. 审查流程(端到端)
```
作者自检 ──▶ 提 PR(用 .github/PULL_REQUEST_TEMPLATE.md)──▶ Reviewer 按 T 级执行 §4+§5
│
▼
有 Blocker/Major? ──是──▶ 退回修改(登记若豁免)
│否
CI 7 门禁触发 ──失败──▶ 修复并重跑
│全绿
合并(保护分支)──▶ 合并后回读 + 轮询 CI
```
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`),自认 T 级。
- **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。
- **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。
- **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。
- **合并**:保护分支禁止直接 push `main`;合并即视为已承担该 PR 的审查结论。
- **合并后**:回读 + 轮询 CI(§6.3)。
---
## 8. Reviewer 职责与礼仪
- **区分 blocker 与 nit**:Blocker/Major 拦下并给方案;Minor/Nit 用"建议"语气,不阻塞。
- **给可执行的反馈**:"建议把这里的 `RuntimeException` 换成 `BusinessException(ApiResponseCode.USER_NOT_FOUND)`" 优于 "异常处理不规范"。
- **不自以为替代测试**:审查不豁免作者写测试与负向注入的责任。
- **尊重决策源**:与 `decision-register` 的 LOCKED 项冲突的改动,先要求回到决策层确认,不擅自放行。
- **留痕**:拒绝理由、豁免转移都留在 PR 或 `known-risks.md`,便于复盘"逃逸"。
---
## 9. 度量与持续改进
- **逃逸率**:合并后在生产/实测发现的缺陷 ÷ 合并总数,按月统计,目标递减。
- **审查时延**:PR 从开到合并的中位时长,避免积压。
- **假绿复发计数**:§5 的 11 类模式再次出现的次数,归零为目标。
- **季度回看**:结合 `known-risks.md` 与 `PLAYBOOK.md`,把新踩的坑补充进 §4/§5,把已解决的债务从 `known-risks` 移除。
- **守卫同步**:每次新增契约守卫,同步上调 `MIN_GUARDS`;审查时核对守卫数。
---
*最后更新: 2026-09-23 — V1.0 初版。基于 PLAYBOOK 判据纪律、known-risks、decision-register 与 ci.yml 门禁,建立人工审查分级/清单/严重级别/合并准入/端到端流程。*
+16
View File
@@ -104,6 +104,14 @@ const MATCHMAKER_SERVICE_STATUS_LABEL = {
cancelled: '已取消',
}
// ============ 红娘服务类型(matchmaker 工作台待处理卡片) ============
// 取值须与后端 chk_matchmaker_service_type 白名单一致:recommend_partner / review_preference / contact_intention
const MATCHMAKER_SERVICE_TYPE_LABEL = {
recommend_partner: '推荐对象',
review_preference: '择偶指导',
contact_intention: '牵线意向',
}
const GROUPS = {
maritalStatus: MARITAL,
childrenStatus: CHILDREN_STATUS,
@@ -145,6 +153,12 @@ function futureDanyangLabel(v) {
return v === undefined || v === null || v === '' ? '' : String(v)
}
/** 红娘服务类型 → 中文(未知码回退原值,便于排查) */
function serviceTypeLabel(code) {
if (code === undefined || code === null || code === '') return ''
return MATCHMAKER_SERVICE_TYPE_LABEL[code] !== undefined ? MATCHMAKER_SERVICE_TYPE_LABEL[code] : String(code)
}
// ============ picker 选项数组(value/label 形式,供 wxml range + range-key 使用) ============
const ACCEPT_CHILDREN_OPTIONS = [
{ value: 'any', label: '不限' },
@@ -193,10 +207,12 @@ module.exports = {
SMOKING,
DRINKING,
MATCHMAKER_SERVICE_STATUS_LABEL,
MATCHMAKER_SERVICE_TYPE_LABEL,
GROUPS,
labelOf,
yesNoLabel,
futureDanyangLabel,
serviceTypeLabel,
REGION_CODES,
regionCodeOf,
ACCEPT_CHILDREN_OPTIONS,
+4 -1
View File
@@ -22,8 +22,11 @@ const isDev = (function() {
})();
module.exports = {
// 生产环境后端地址(线上正式域名)。
// 部署前必须替换为真实域名,并到小程序后台「开发管理 → 开发设置 → 服务器域名」配置 request 合法域名。
// 注意:本文件不得再出现散落的硬编码地址,统一改这里即可。
API_BASE_URL: isDev
? 'http://localhost:8085/api/v1'
: 'https://api.danyang-match.example.com/api/v1',
: 'https://api.danyang-match.example.com/api/v1', // TODO(部署): 替换为真实生产域名
IS_DEV: isDev,
};
+9 -1
View File
@@ -19,8 +19,16 @@ function isDev(): boolean {
}
}
// 生产环境后端地址(线上正式域名)。
// 部署前必须替换为真实域名,并到小程序后台「开发管理 → 开发设置 → 服务器域名」配置 request 合法域名。
// 如需在构建期注入(CI/打包脚本),可在打包前以全局 __DY_PROD_API_BASE__ 覆盖本默认值。
declare const __DY_PROD_API_BASE__: string | undefined;
const PROD_API_BASE_URL: string =
(typeof __DY_PROD_API_BASE__ !== 'undefined' ? __DY_PROD_API_BASE__ : undefined)
|| 'https://api.danyang-match.example.com/api/v1'; // TODO(部署): 替换为真实生产域名
export const API_BASE_URL: string = isDev()
? 'http://localhost:8085/api/v1'
: 'https://api.danyang-match.example.com/api/v1';
: PROD_API_BASE_URL;
export const IS_DEV: boolean = isDev();
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "danyang-match-miniapp",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "danyang-match-miniapp",
"version": "1.0.0",
"version": "1.1.0",
"devDependencies": {
"miniprogram-api-typings": "^5.2.3",
"typescript": "^5.3.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "danyang-match-miniapp",
"version": "1.0.0",
"version": "1.1.0",
"description": "丹阳公益婚恋服务平台 - 微信小程序端",
"scripts": {
"dev": "echo 'Use WeChat DevTools to open miniapp/'",
@@ -4,6 +4,7 @@
* 展示待处理服务、工作台数据概览
*/
const api = require('../../utils/api');
const { serviceTypeLabel } = require('../../constants/profileLabels');
Page({
data: {
@@ -82,7 +83,9 @@ Page({
wx.showLoading({ title: '接单中...' });
const token = wx.getStorageSync('operatorToken');
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, {}, { Authorization: `Bearer ${token}` })
// 注意:api.post 签名为 (url, data, headers, idempotencyKey),Authorization 必须放第 3 参(headers),
// 此前误放到第 4 参(idempotencyKey),导致实际请求不携带任何鉴权头、接单必 401。
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, { Authorization: `Bearer ${token}` })
.then(res => {
wx.hideLoading();
if (res.success) {
@@ -112,6 +115,11 @@ Page({
return m + '月' + d + '日';
},
// 红娘服务类型 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
serviceTypeLabel(code) {
return serviceTypeLabel(code);
},
onLogout() {
wx.showModal({
title: '退出登录',
@@ -56,7 +56,7 @@
>
<view class="service-left">
<view class="service-header">
<view class="service-tag">{{item.serviceType === 'recommend_partner' ? '推荐对象' : item.serviceType}}</view>
<view class="service-tag">{{serviceTypeLabel(item.serviceType)}}</view>
<view class="service-time">{{formatTime(item.createdAt)}}</view>
</view>
<view class="service-desc">{{item.description}}</view>
@@ -110,9 +110,9 @@ Page({
async _uploadPhoto(filePath, photoType) {
appStore.showLoading('上传中…');
try {
// 1. 获取上传凭证(EXIF 由服务端清理)
// 1. 获取上传凭证(EXIF 由服务端清理);带上 photoType 使 fileKey 路径与类型一致
const fileName = `photo_${Date.now()}.jpg`;
const tokenRes = await profileService.requestUploadToken({ fileName, contentType: 'image/jpeg' });
const tokenRes = await profileService.requestUploadToken({ fileName, contentType: 'image/jpeg', photoType });
// 2. 上传到对象存储
await new Promise((resolve, reject) => {
@@ -129,9 +129,12 @@ Page({
});
});
// 3. 若上传的是头像区,或当前无主头像,则设为主头像
// 3. 直传成功后必须调 confirm 落库,否则对象成孤儿、照片不展示(修复"照片直传不落库")
const confirmRes = await profileService.confirmPhoto({ fileKey: tokenRes.fileKey, photoType });
// 4. 若上传的是头像区,或当前无主头像,则设为主头像(用真实 photoId)
if (photoType === 'avatar' || this.data.avatarPhotos.length === 0) {
await profileService.setMainPhoto(tokenRes.photoId);
await profileService.setMainPhoto(confirmRes.photoId);
}
appStore.hideLoading();
+13
View File
@@ -80,6 +80,18 @@ function requestUploadToken(payload) {
return api.post('/me/photos/upload-token', payload, undefined, payload.fileName);
}
/**
* 确认直传照片落库(对应 REST §29 补:POST /me/photos/confirm)
* 客户端直传对象存储成功后必须调用,否则对象成孤儿、照片不展示、签名访问也过不了归属校验。
* @param {{ fileKey: string, photoType: string }} payload
* @returns {Promise<{ photoId: string, photoType: string }>}
*/
function confirmPhoto(payload) {
// 以 fileKey 作幂等指纹:同一文件重复确认只落库一次(直传凭证可重试)
const idempotencyKey = api.idempotencyKey('photo-confirm', payload.fileKey);
return api.post('/me/photos/confirm', payload, undefined, idempotencyKey);
}
/**
* 获取隐私设置(API_INVENTORY 行 23)
* @returns {Promise<import('../types').PrivacySettings>}
@@ -178,6 +190,7 @@ module.exports = {
deletePhoto,
setMainPhoto,
requestUploadToken,
confirmPhoto,
getPrivacySettings,
updatePrivacySettings,
getReviewStatus,