feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)

后端:
- V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口)
- ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审
- ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询
- ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志)
- 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig

守卫翻转(机械锁强制复审机制按设计生效):
- MatchmakerContactGuardTest 由「锁不存在」翻转为「锁新契约」五条:回显白名单类收敛/端点前缀收敛(受治理三包)/到期语义/consent 独立性三端扫描(后端+miniapp+admin-web,修复原只扫后端的预同意参数逃逸缺口)/ReferralService 白名单与无 @Scheduled 保留
- run-backend-contracts CURATED 18→20

前端:
- miniapp 新页 pages/contact-consent(目的/渠道/窗口明示+独立同意拒绝按钮无预勾选,app.json 第 64 页) + 红娘工作台申请/列表/撤销
- admin-web「联系授权审批」tab(审批岗队列+本人授权撤销,枚举码中文映射)

验证:mvn verify 895/895(3 skip)、UI 契约 24/24(64 页)、后端契约门禁 228/CURATED 20、小程序真编译 87 WXML 0 失败;负向注入 5(工程师)+2(QA 不同落点)判红→还原复绿;QA 抓 disclose 键数收敛断言缺口已补并回归;QA fresh eyes PASS 0 P0/P1
This commit is contained in:
WorkBuddy
2026-10-11 13:08:02 +08:00
parent 4248e9574e
commit d3735e3afa
27 changed files with 1996 additions and 144 deletions
@@ -23,6 +23,16 @@ Page({
assignmentCount: 0,
loading: true,
currentDate: '',
// ZC-MM-003 / B1:受控联系授权(申请入口 + 本人授权列表/撤销;purpose 必填)
contactTargets: [],
contactTargetIndex: 0,
contactChannelLabels: ['电话联系', '线下见面', '完整联系方式'],
contactChannels: ['phone', 'offline_meeting', 'full_contact'],
contactChannelIndex: 0,
contactPurpose: '',
contactAuths: [],
contactAuthCount: 0,
submittingAuth: false,
},
onLoad() {
@@ -70,10 +80,18 @@ Page({
if (assignmentRes.success) {
const assignments = assignmentRes.data || [];
// 联系授权申请目标 = 本人生效中的分配对象(B1:只能对分配对象发起)
const targets = assignments.map((a) => ({
userId: a.userId,
label: '用户 ' + String(a.userId).slice(0, 8) + '…(' + (a.assignmentReason || '服务对象') + ')',
}));
this.setData({
myAssignments: assignments,
assignmentCount: assignments.length,
contactTargets: targets,
contactTargetIndex: 0,
});
this.loadContactAuthorizations();
}
} catch (err) {
console.error('加载工作台数据失败:', err);
@@ -91,6 +109,98 @@ Page({
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
},
// ==================== ZC-MM-003 / B1:受控联系授权 ====================
/** 本人授权列表(GET /operator/matchmaker/contact-authorizations/my) */
async loadContactAuthorizations() {
try {
const token = wx.getStorageSync('operatorToken');
const res = await api.get('/operator/matchmaker/contact-authorizations/my',
{}, { Authorization: `Bearer ${token}` });
if (res.success) {
const items = (res.data.list || []).map((item) => Object.assign({}, item, {
channelLabel: this.contactChannelLabel(item.channel),
statusLabel: this.contactStatusLabel(item.status),
expireText: item.expiresAt ? this.formatTime(item.expiresAt) : '',
revocable: item.status === 'active' || item.status === 'pending_approval'
|| item.status === 'pending_consent',
}));
this.setData({ contactAuths: items, contactAuthCount: items.length });
}
} catch (err) {
console.error('加载联系授权失败:', err);
}
},
contactChannelLabel(code) {
const labels = { phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式' };
return labels[code] || code || '';
},
contactStatusLabel(code) {
const labels = {
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
rejected: '用户已拒绝', expired: '已过期', revoked: '已撤销',
};
return labels[code] || code || '';
},
onContactTargetChange(e) { this.setData({ contactTargetIndex: Number(e.detail.value) }); },
onContactChannelChange(e) { this.setData({ contactChannelIndex: Number(e.detail.value) }); },
onContactPurposeInput(e) { this.setData({ contactPurpose: e.detail.value }); },
/** 发起授权申请(purpose 必填;B1:用户随后单独明示同意,本页不产生任何用户授权) */
async submitContactRequest() {
if (this.data.submittingAuth) return;
const target = this.data.contactTargets[this.data.contactTargetIndex];
if (!target) { wx.showToast({ title: '暂无可申请的服务对象', icon: 'none' }); return; }
const purpose = (this.data.contactPurpose || '').trim();
if (!purpose) { wx.showToast({ title: '授权目的必填', icon: 'none' }); return; }
const channel = this.data.contactChannels[this.data.contactChannelIndex];
this.setData({ submittingAuth: true });
try {
const token = wx.getStorageSync('operatorToken');
const res = await api.post('/operator/matchmaker/contact-authorizations', {
userId: target.userId, channel: channel, purpose: purpose,
}, { Authorization: `Bearer ${token}` });
if (res.success) {
wx.showToast({ title: '申请已提交,等待审批', icon: 'none' });
this.setData({ contactPurpose: '' });
this.loadContactAuthorizations();
}
} catch (err) {
wx.showToast({ title: err.message || '申请失败', icon: 'none' });
} finally {
this.setData({ submittingAuth: false });
}
},
/** 撤销本人授权(reason 必填) */
revokeContactAuth(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '撤销联系授权',
editable: true,
placeholderText: '请输入撤销理由(必填)',
success: async (res) => {
if (!res.confirm) return;
const reason = (res.content || '').trim();
if (!reason) { wx.showToast({ title: '撤销理由必填', icon: 'none' }); return; }
try {
const token = wx.getStorageSync('operatorToken');
const resp = await api.post(`/operator/matchmaker/contact-authorizations/${id}/revoke`,
{ reason: reason }, { Authorization: `Bearer ${token}` });
if (resp.success) {
wx.showToast({ title: '已撤销', icon: 'none' });
this.loadContactAuthorizations();
}
} catch (err) {
wx.showToast({ title: '撤销失败', icon: 'none' });
}
},
});
},
// 分配状态 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
assignmentStatusLabel(code) {
const labels = {
@@ -72,6 +72,57 @@
</view>
</view>
<!-- 联系授权(ZC-MM-003 / B1:受控联系,用户单独同意后才可生效) -->
<view class="section" wx:if="{{!loading}}">
<view class="section-header">
<view class="section-title-wrap">
<text class="section-dot"></text>
<text class="section-title">联系授权(受控联系)</text>
</view>
<text class="section-count" wx:if="{{contactAuthCount > 0}}">共{{contactAuthCount}}条</text>
</view>
<!-- 申请入口:对本人分配对象发起,purpose 必填 -->
<view class="contact-form" wx:if="{{contactTargets.length > 0}}">
<picker mode="selector" range="{{contactTargets}}" range-key="label" value="{{contactTargetIndex}}" bindchange="onContactTargetChange">
<view class="form-row">
<text class="form-label">服务对象</text>
<text class="form-value">{{contactTargets[contactTargetIndex].label}}</text>
</view>
</picker>
<picker mode="selector" range="{{contactChannelLabels}}" value="{{contactChannelIndex}}" bindchange="onContactChannelChange">
<view class="form-row">
<text class="form-label">联系渠道</text>
<text class="form-value">{{contactChannelLabels[contactChannelIndex]}}</text>
</view>
</picker>
<view class="form-row">
<text class="form-label">授权目的</text>
<input class="form-input" value="{{contactPurpose}}" bindinput="onContactPurposeInput"
placeholder="必填:说明为何需要联系该用户" maxlength="200" />
</view>
<view class="form-hint">提交后须经平台审批,并由用户单独确认同意后授权方可生效</view>
<button class="submit-btn" size="mini" bindtap="submitContactRequest" disabled="{{submittingAuth}}">提交申请</button>
</view>
<view class="empty-state" wx:else>
<view class="empty-hint">暂无服务对象分配,无法发起联系授权</view>
</view>
<!-- 本人授权列表 -->
<view class="auth-list" wx:if="{{contactAuths.length > 0}}">
<view class="auth-card" wx:for="{{contactAuths}}" wx:key="id">
<view class="auth-row">
<text class="auth-channel">{{item.channelLabel}}</text>
<text class="auth-status">{{item.statusLabel}}</text>
</view>
<view class="auth-purpose">{{item.purpose}}</view>
<view class="auth-meta" wx:if="{{item.expireText}}">有效期至 {{item.expireText}}</view>
<button class="revoke-btn" size="mini" wx:if="{{item.revocable}}"
data-id="{{item.id}}" bindtap="revokeContactAuth">撤销</button>
</view>
</view>
</view>
<!-- 加载中 -->
<view class="loading-wrap" wx:if="{{loading}}">
<view class="loading-spinner"></view>
@@ -264,3 +264,20 @@
border-radius:42rpx;
border:2rpx solid var(--color-border);
min-width:var(--tap-min);}
/* ===== 联系授权(ZC-MM-003 / B1) ===== */
.contact-form { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
.form-row { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
.form-label { width: 160rpx; color: #909399; font-size: 26rpx; flex-shrink: 0; }
.form-value { color: #303133; font-size: 26rpx; flex: 1; }
.form-input { flex: 1; font-size: 26rpx; color: #303133; }
.form-hint { font-size: 22rpx; color: #b0b3b8; margin-top: 12rpx; line-height: 1.5; }
.submit-btn { margin-top: 16rpx; background: #2f7df6; color: #fff; }
.auth-list { margin-top: 8rpx; }
.auth-card { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 14rpx; }
.auth-row { display: flex; justify-content: space-between; align-items: center; }
.auth-channel { font-size: 28rpx; font-weight: 600; color: #1f2d3d; }
.auth-status { font-size: 24rpx; color: #909399; }
.auth-purpose { font-size: 26rpx; color: #5e6d82; margin-top: 8rpx; word-break: break-all; }
.auth-meta { font-size: 22rpx; color: #b0b3b8; margin-top: 8rpx; }
.revoke-btn { margin-top: 12rpx; background: #f4f4f5; color: #606266; }