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:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 联系授权确认页(C 端用户,ZC-MM-003 / B1 例外通道)
|
||||
*
|
||||
* PIPL 单独同意契约:
|
||||
* - 授权目的 / 渠道 / 有效期窗口逐条明示后才可操作;
|
||||
* - 同意与拒绝是两个独立按钮,无预勾选、无捆绑(后端亦为独立 POST 端点,
|
||||
* 守卫 MatchmakerContactGuardTest ④ 锁定);
|
||||
* - 数据源 GET /contact-authorizations/my(pendingConsent 待同意队列 + 全部记录)。
|
||||
*/
|
||||
const api = require('../../utils/api');
|
||||
|
||||
const CHANNEL_LABELS = {
|
||||
phone: '电话联系',
|
||||
offline_meeting: '线下见面',
|
||||
full_contact: '完整联系方式',
|
||||
};
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
pendingConsent: [],
|
||||
history: [],
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadAuthorizations();
|
||||
},
|
||||
|
||||
async loadAuthorizations() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const res = await api.get('/contact-authorizations/my');
|
||||
if (res.success) {
|
||||
const pending = (res.data.pendingConsent || []).map((item) => this.decorate(item));
|
||||
const history = (res.data.list || []).map((item) => this.decorate(item));
|
||||
this.setData({ pendingConsent: pending, history });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载联系授权失败:', err);
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** 渠道/状态/有效期 → 中文展示(枚举码不上屏) */
|
||||
decorate(item) {
|
||||
const labels = {
|
||||
pending_approval: '待审批', pending_consent: '待你确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销',
|
||||
};
|
||||
return Object.assign({}, item, {
|
||||
channelLabel: CHANNEL_LABELS[item.channel] || item.channel,
|
||||
statusLabel: labels[item.status] || item.status,
|
||||
expireText: item.expiresAt ? this.formatTime(item.expiresAt) : '',
|
||||
});
|
||||
},
|
||||
|
||||
formatTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
const d = new Date(timeStr);
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/** 独立同意(单独明示动作,无捆绑参数) */
|
||||
async onConsent(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const item = this.data.pendingConsent.find((a) => a.id === id);
|
||||
if (!item) return;
|
||||
wx.showModal({
|
||||
title: '确认同意联系授权',
|
||||
content: `同意「${item.channelLabel}」:${item.purpose}。同意后红娘可在有效期内通过该方式联系你。`,
|
||||
confirmText: '同意',
|
||||
cancelText: '再想想',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
const resp = await api.post(`/contact-authorizations/${id}/consent`);
|
||||
if (resp.success) {
|
||||
wx.showToast({ title: '已同意', icon: 'success' });
|
||||
this.loadAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 独立拒绝 */
|
||||
async onDecline(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
try {
|
||||
const resp = await api.post(`/contact-authorizations/${id}/decline`);
|
||||
if (resp.success) {
|
||||
wx.showToast({ title: '已拒绝', icon: 'none' });
|
||||
this.loadAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "联系授权确认",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- 联系授权确认页(ZC-MM-003 / B1:单独明示同意,无预勾选、无捆绑) -->
|
||||
<view class="page">
|
||||
<view class="intro">
|
||||
<view class="intro-title">红娘联系授权</view>
|
||||
<view class="intro-text">红娘经平台审批后向你发起联系授权申请。你可以单独决定同意或拒绝,同意后授权在有效期内生效,到期自动失效,也可随时被撤销。</view>
|
||||
</view>
|
||||
|
||||
<!-- 待确认队列 -->
|
||||
<view class="section" wx:if="{{pendingConsent.length > 0}}">
|
||||
<view class="section-title">待你确认({{pendingConsent.length}})</view>
|
||||
<view class="card" wx:for="{{pendingConsent}}" wx:key="id">
|
||||
<view class="row"><text class="label">授权渠道</text><text class="value strong">{{item.channelLabel}}</text></view>
|
||||
<view class="row"><text class="label">授权目的</text><text class="value">{{item.purpose}}</text></view>
|
||||
<view class="row"><text class="label">确认截止</text><text class="value">{{item.expireText}}</text></view>
|
||||
<view class="actions">
|
||||
<button class="btn decline" data-id="{{item.id}}" bindtap="onDecline">拒绝</button>
|
||||
<button class="btn consent" data-id="{{item.id}}" bindtap="onConsent">同意</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:if="{{!loading && pendingConsent.length === 0}}">
|
||||
<view class="empty-text">暂无待确认的授权申请</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<view class="section" wx:if="{{history.length > 0}}">
|
||||
<view class="section-title">历史记录</view>
|
||||
<view class="card slim" wx:for="{{history}}" wx:key="id">
|
||||
<view class="row"><text class="label">渠道</text><text class="value">{{item.channelLabel}}</text></view>
|
||||
<view class="row"><text class="label">目的</text><text class="value">{{item.purpose}}</text></view>
|
||||
<view class="row"><text class="label">状态</text><text class="value">{{item.statusLabel}}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="loading-wrap" wx:if="{{loading}}">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,21 @@
|
||||
/* 联系授权确认页样式 */
|
||||
.page { padding: 24rpx; background: #f7f8fa; min-height: 100vh; box-sizing: border-box; }
|
||||
.intro { background: #eef4ff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.intro-title { font-size: 32rpx; font-weight: 600; color: #1f2d3d; margin-bottom: 12rpx; }
|
||||
.intro-text { font-size: 26rpx; color: #5e6d82; line-height: 1.6; }
|
||||
.section { margin-bottom: 28rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 600; color: #1f2d3d; margin-bottom: 16rpx; }
|
||||
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.card.slim { padding: 20rpx; }
|
||||
.row { display: flex; margin-bottom: 12rpx; }
|
||||
.label { width: 150rpx; color: #909399; font-size: 26rpx; flex-shrink: 0; }
|
||||
.value { color: #303133; font-size: 26rpx; flex: 1; word-break: break-all; }
|
||||
.value.strong { font-weight: 600; color: #1f2d3d; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 20rpx; margin-top: 16rpx; }
|
||||
.btn { font-size: 28rpx; border-radius: 12rpx; padding: 0 40rpx; line-height: 72rpx; margin: 0; }
|
||||
.btn.decline { background: #f4f4f5; color: #606266; }
|
||||
.btn.consent { background: #2f7df6; color: #fff; }
|
||||
.empty { text-align: center; padding: 60rpx 0; }
|
||||
.empty-text { color: #909399; font-size: 26rpx; }
|
||||
.loading-wrap { text-align: center; padding: 40rpx 0; }
|
||||
.loading-text { color: #909399; font-size: 26rpx; }
|
||||
Reference in New Issue
Block a user