Files
danyang-match/miniapp/pages/matchmaker-workbench/matchmaker-workbench.js
T
WorkBuddy d3735e3afa 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
2026-10-11 13:08:02 +08:00

250 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 红娘工作台主页
*
* ZC-MM-002(A1 / DEC-MATCHMAKER-002 锁定决策):
* - 普通红娘禁止认领(原 POST /services/{id}/claim 端点已删除),本页不再有任何接单逻辑;
* - 列表数据源从 GET /operator/matchmaker/pending(现仅分配权持有者可调,普通红娘 403)
* 改为 GET /operator/matchmaker/assignments/my(仅返回本人生效中的服务对象分配)。
* 响应为 assignment 字段(userId/assignmentReason/status/assignedAt/serviceId 等),
* 无服务类型/诉求描述字段,故按实际字段精简展示,不前端拼空值。
*
* 展示待处理服务、工作台数据概览
*/
const api = require('../../utils/api');
Page({
data: {
operatorName: '',
pendingCount: 0,
inProgressCount: 0,
todayCount: 0,
completedCount: 0,
myAssignments: [],
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() {
wx.setNavigationBarTitle({ title: '红娘工作台' });
// 获取操作员信息
const operatorName = wx.getStorageSync('operatorName') || '红娘';
this.setData({ operatorName });
// 设置当前日期
const now = new Date();
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
this.setData({ currentDate: dateStr });
this.loadDashboard();
},
onShow() {
// 每次显示刷新数据
this.loadDashboard();
},
async loadDashboard() {
this.setData({ loading: true });
try {
const token = wx.getStorageSync('operatorToken');
if (!token) {
wx.redirectTo({ url: '/pages/matchmaker-login/matchmaker-login' });
return;
}
// 加载工作台数据 + 本人服务对象分配(ZC-MM-002:不再调用 /pending,普通红娘无该权限)
const [dashRes, assignmentRes] = await Promise.all([
api.get('/operator/matchmaker/dashboard', {}, { Authorization: `Bearer ${token}` }),
api.get('/operator/matchmaker/assignments/my', {}, { Authorization: `Bearer ${token}` }),
]);
if (dashRes.success) {
this.setData({
pendingCount: dashRes.data.pendingCount || 0,
inProgressCount: dashRes.data.inProgressCount || 0,
todayCount: dashRes.data.todayCount || 0,
completedCount: dashRes.data.completedCount || 0,
});
}
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);
} finally {
this.setData({ loading: false });
}
},
onViewService(e) {
const serviceId = e.currentTarget.dataset.id;
if (!serviceId) {
// 纯用户级分配(无关联服务单)无处可跳,不拼空参数
return;
}
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 = {
active: '生效中',
ended: '已结束',
revoked: '已撤销',
transferred: '已转分配',
expired: '已过期',
};
return labels[code] || code || '';
},
// 分配范围 → 中文
assignmentScopeLabel(code) {
const labels = { primary: '主责', collaborating: '协同' };
return labels[code] || code || '';
},
formatTime(timeStr) {
if (!timeStr) return '';
const date = new Date(timeStr);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / 86400000);
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return days + '天前';
const m = date.getMonth() + 1;
const d = date.getDate();
return m + '月' + d + '日';
},
onLogout() {
wx.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
wx.removeStorageSync('operatorToken');
wx.removeStorageSync('operatorName');
wx.reLaunch({ url: '/pages/matchmaker-login/matchmaker-login' });
}
},
});
},
});