后端: - 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
250 lines
9.0 KiB
JavaScript
250 lines
9.0 KiB
JavaScript
/**
|
||
* 红娘工作台主页
|
||
*
|
||
* 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' });
|
||
}
|
||
},
|
||
});
|
||
},
|
||
});
|