feat: 新增分销推荐人手动绑定功能,调整自动绑定规则

本次修改:
1. 新增分销绑定 API 接口与路由
2. 老用户不再自动绑定推荐人,需手动输入邀请码绑定
3. 新增绑定推荐人 UI 弹层与展示组件
4. 新增防环校验与关系补全逻辑
5. 调整邀请码存储逻辑,仅未登录用户自动存储
这个提交包含在:
2026-10-26 13:51:52 +08:00
父节点 7b58952cd4
当前提交 b9fdb7cd77
共有 11 个文件被更改,包括 273 次插入 和 29 次删除

查看文件

@@ -101,10 +101,7 @@ class Auth extends V1
DistributionService::bindRelation($uid, $inviterId, $inviteCode);
$user = Db::name('user')->where('id', $uid)->find();
} else {
// 老用户:若未绑定过推荐人,本次传入有效推荐人则尝试绑定
if ($inviterId > 0 || $inviteCode !== '') {
DistributionService::bindRelation($user['id'], $inviterId, $inviteCode);
}
// 老用户:不再自动补绑推荐人(老用户须在分销中心手动填写邀请码补绑)
// 老用户:仅在本次带上了新值且确实不同时更新(不覆盖运营在后台改过的资料)
$update = [];
if ($nickname !== '' && $nickname !== (string)$user['nickname']) {
@@ -550,20 +547,12 @@ class Auth extends V1
// 频率限制:同一手机号 + IP,1 分钟最多 5 次
$this->rateLimit('pwdlogin:' . $phone . ':' . $this->request->ip(), 5, 60);
$inviterId = (int)$this->input('inviterId', $this->input('inviter_id', 0));
$inviteCode = trim((string)$this->input('inviteCode', $this->input('invite_code', '')));
// 查用户:不存在直接失败,不再自动注册(防止批量造号)
$user = Db::name('user')->where('mobile', $phone)->find();
if (!$user) {
$this->fail('手机号未注册,请先使用微信登录');
}
// 老用户:若未绑定过推荐人,本次传入有效推荐人则尝试绑定
if ($inviterId > 0 || $inviteCode !== '') {
DistributionService::bindRelation($user['id'], $inviterId, $inviteCode);
}
// 校验密码:只认加盐哈希(已移除历史遗留的 123456 万能密码)
$salt = isset($user['salt']) ? (string)$user['salt'] : '';
$checkHash = md5(md5($password) . $salt);

查看文件

@@ -46,6 +46,22 @@ class Distribution extends V1
$code = (string)$ext['invite_code'];
$shareUrl = 'https://fenleixinxi.gzyakj.com.cn/#/pages/index/index?ic=' . urlencode($code);
// 查询当前用户的上级推荐人信息(用于前端展示「已绑定推荐人」或「手动绑定邀请码」入口)
$inviterId = (int)(isset($ext['inviter_id']) ? $ext['inviter_id'] : 0);
if ($inviterId <= 0) {
$l1Rel = Db::name('fc_dist_relation')->where('user_id', $uid)->where('level', 1)->find();
if ($l1Rel && (int)$l1Rel['parent_id'] > 0) {
$inviterId = (int)$l1Rel['parent_id'];
}
}
$inviterNickname = '';
if ($inviterId > 0) {
$inviterUser = Db::name('user')->where('id', $inviterId)->field('nickname,username')->find();
if ($inviterUser) {
$inviterNickname = (string)($inviterUser['nickname'] ?: $inviterUser['username']);
}
}
// 商家转账确认收款页需要的商户号与小程序 AppID(非敏感,可下发)
$epay = (array)get_addon_config('epay');
$wx = isset($epay['wechat']) && is_array($epay['wechat']) ? $epay['wechat'] : [];
@@ -60,6 +76,8 @@ class Distribution extends V1
'l1Count' => $l1,
'l2Count' => $l2,
'inviteCode' => $code,
'inviterId' => $inviterId,
'inviterNickname' => $inviterNickname,
'shareUrl' => $shareUrl,
'posterUrl' => '',
// 最低提现金额(后台「分销设置」下发,默认 50)
@@ -351,6 +369,72 @@ class Distribution extends V1
$this->ok($list);
}
/**
* D7 手动输入邀请码补绑推荐人(老用户不支持自动补绑,仅支持通过手动填写邀请码完成补绑)
* POST /api/v1/distribution/bind 入参 {inviteCode}
*
* @return void 输出绑定结果与推荐人信息JSON
*/
public function bind()
{
$user = $this->auth();
$uid = (int)$user['id'];
$inviteCode = strtoupper(trim((string)$this->input('inviteCode', $this->input('invite_code', ''))));
if ($inviteCode === '') {
$this->fail('请输入有效的邀请码');
}
// 检查当前用户是否已绑定过推荐人
$ext = $this->ext($uid);
$existingInviter = (int)(isset($ext['inviter_id']) ? $ext['inviter_id'] : 0);
if ($existingInviter <= 0) {
$l1Rel = Db::name('fc_dist_relation')->where('user_id', $uid)->where('level', 1)->find();
if ($l1Rel && (int)$l1Rel['parent_id'] > 0) {
$existingInviter = (int)$l1Rel['parent_id'];
}
}
if ($existingInviter > 0) {
$this->fail('您已绑定过推荐人,不可重复绑定');
}
// 校验邀请码并定位目标推荐人
if (isset($ext['invite_code']) && strtoupper((string)$ext['invite_code']) === $inviteCode) {
$this->fail('不能填写自己的邀请码');
}
$parentExt = Db::name('fc_user_ext')->where('invite_code', $inviteCode)->find();
if (!$parentExt || (int)$parentExt['user_id'] <= 0) {
$this->fail('邀请码不存在,请核对后重试');
}
$targetInviterId = (int)$parentExt['user_id'];
if ($targetInviterId === $uid) {
$this->fail('不能绑定自己为推荐人');
}
// 防环校验:不能绑定自己的下级成员
$isDownline = Db::name('fc_dist_relation')
->where('user_id', $targetInviterId)
->where('parent_id', $uid)
->count();
if ($isDownline > 0) {
$this->fail('不能绑定您的下级成员为推荐人');
}
$ok = \app\common\library\DistributionService::bindRelation($uid, $targetInviterId, $inviteCode);
if (!$ok) {
$this->fail('绑定失败,请核对邀请码后重试');
}
$inviterUser = Db::name('user')->where('id', $targetInviterId)->field('nickname,username')->find();
$inviterNickname = $inviterUser ? (string)($inviterUser['nickname'] ?: $inviterUser['username']) : '同城合伙人';
$this->ok([
'bound' => true,
'inviterId' => $targetInviterId,
'inviterNickname' => $inviterNickname,
]);
}
/**
* 计算当前用户可提现余额
* 可提现余额 = Σ已结算佣金(status=1) - Σ(申请中/已打款)提现

查看文件

@@ -37,8 +37,8 @@ class DistributionService
}
$parent1Id = 0;
// 优先通过邀请码查询推荐人
$inviteCode = trim((string)$inviteCode);
// 优先通过邀请码查询推荐人(忽略大小写)
$inviteCode = strtoupper(trim((string)$inviteCode));
if ($inviteCode !== '') {
$parentExt = Db::name('fc_user_ext')->where('invite_code', $inviteCode)->find();
if ($parentExt && (int)$parentExt['user_id'] > 0) {
@@ -59,6 +59,15 @@ class DistributionService
return false;
}
// 防环校验:不能将自己的直接或间接下线绑定为自己的推荐人
$isDownline = Db::name('fc_dist_relation')
->where('user_id', $parent1Id)
->where('parent_id', $userId)
->count();
if ($isDownline > 0) {
return false;
}
$now = time();
// 更新用户扩展表的直接推荐人ID
@@ -94,6 +103,32 @@ class DistributionService
}
}
// 3. 若当前用户在补绑推荐人之前已经发展了 1 级下线,则为这些 1 级下线补齐以 $parent1Id 为 2 级推荐人的关系
$myL1Children = Db::name('fc_dist_relation')
->where('parent_id', $userId)
->where('level', 1)
->select();
if (!empty($myL1Children)) {
foreach ($myL1Children as $childRow) {
$childId = (int)$childRow['user_id'];
if ($childId <= 0 || $childId === $parent1Id) {
continue;
}
$hasL2 = Db::name('fc_dist_relation')
->where('user_id', $childId)
->where('level', 2)
->count();
if ($hasL2 <= 0) {
Db::name('fc_dist_relation')->insert([
'user_id' => $childId,
'parent_id' => $parent1Id,
'level' => 2,
'createtime' => $now,
]);
}
}
}
return true;
}

查看文件

@@ -101,6 +101,7 @@ return [
'api/v1/distribution/poster' => 'api/Distribution/poster',
'api/v1/distribution/withdraw' => 'api/Distribution/withdraw',
'api/v1/distribution/withdraw-records' => 'api/Distribution/withdrawRecords',
'api/v1/distribution/bind' => 'api/Distribution/bind',
/* =============== 六、用户端 · 消息中心(Module-MESSAGE) =============== */
'api/v1/message/list' => 'api/Message/list',

查看文件

@@ -13,10 +13,14 @@ export default {
},
methods: {
/**
* 捕获并解析邀请分销参数(支持普通 query 与微信小程序码 scene 场景值)
* 捕获并解析邀请分销参数(仅供未注册新用户首次登录建档使用,老用户须手动输入邀请码补绑)
* @param {Object} options 小程序启动或页面入参
*/
captureInvite(options) {
// 已登录老用户不自动捕获或自动补绑,须在分销中心手动填写邀请码绑定
if (uni.getStorageSync('fc_token')) {
return
}
const query = (options && options.query) || {}
let ic = query.ic || query.inviteCode || query.invite_code
let inviterId = query.inviterId || query.inviter_id
@@ -31,9 +35,6 @@ export default {
ic = sceneStr.slice(3)
} else if (sceneStr.indexOf('uid_') === 0) {
inviterId = sceneStr.slice(4)
} else if (/^id_\d+_u_(\d+)$/.test(sceneStr)) {
const m = sceneStr.match(/^id_\d+_u_(\d+)$/)
if (m && m[1]) inviterId = m[1]
} else if (sceneStr.indexOf('uid=') === 0) {
inviterId = sceneStr.slice(4)
} else if (/^id_\d+_u_(\d+)$/.test(sceneStr)) {

查看文件

@@ -17,6 +17,8 @@ export const distributionApi = {
withdraw: (payload) => post('/distribution/withdraw', payload, { silent: true }),
/** 提现记录 */
withdrawRecords: () => get('/distribution/withdraw-records', {}, { silent: true }),
/** 绑定分销推荐人(扫码自动补绑 / 手动填写邀请码绑定) */
bind: (payload = {}) => post('/distribution/bind', payload, { silent: true }),
}
export default distributionApi

查看文件

@@ -41,6 +41,8 @@ export const useUserStore = () => {
storage.set(config.storageKeys.token, state.token)
storage.set(config.storageKeys.userInfo, state.userInfo)
storage.set(config.storageKeys.userType, state.userType)
storage.remove('fc_invite_code')
storage.remove('fc_inviter_id')
return res
}

查看文件

@@ -708,8 +708,10 @@ export default {
}
} catch (e) {}
}
if (ic) uni.setStorageSync('fc_invite_code', String(ic))
if (inviterId) uni.setStorageSync('fc_inviter_id', Number(inviterId))
if (!uni.getStorageSync('fc_token')) {
if (ic) uni.setStorageSync('fc_invite_code', String(ic))
if (inviterId) uni.setStorageSync('fc_inviter_id', Number(inviterId))
}
}
this.id = Number((query && query.id) || postIdFromScene || 101)
this.posterImagePath = ''

查看文件

@@ -82,7 +82,7 @@
</view>
</view>
<!-- 核心分享动作区(支持:原生分享给好友、生成专属海报、复制链接) -->
<!-- 核心分享动作区(支持:原生分享给好友、生成专属海报) -->
<view class="dist__invite-actions">
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序端:button open-type="share" 直接唤起微信好友选择列表 -->
@@ -95,7 +95,7 @@
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- H5/App 端:调用系统分享或复制邀请链接 -->
<!-- H5/App 端:调用系统分享或复制邀请码 -->
<view class="dist__btn dist__btn--primary" @click="shareInvite">
<view class="dist__btn-icon-circle">
<image class="dist__btn-icon" :src="iconUrl('share')" mode="aspectFit" />
@@ -109,12 +109,16 @@
<image class="dist__btn-icon dist__btn-icon--plain" :src="iconUrl('bar-poster')" mode="aspectFit" />
<text>专属海报</text>
</view>
</view>
<!-- 复制链接按钮 -->
<view class="dist__btn dist__btn--ghost" @click="copyLink">
<image class="dist__btn-icon dist__btn-icon--plain" :src="iconUrl('network')" mode="aspectFit" />
<text>复制链接</text>
</view>
<!-- 推荐人状态展示 / 手动填写邀请码绑定入口 -->
<view v-if="overview.inviterId > 0" class="dist__inviter-bar dist__inviter-bar--bound">
<text class="dist__inviter-label">我的推荐人:{{ overview.inviterNickname || ('合伙人 #' + overview.inviterId) }}</text>
<text class="dist__inviter-tag">已锁定</text>
</view>
<view v-else class="dist__inviter-bar" @click="openBindModal">
<text class="dist__inviter-label">有好友邀请码?填写后锁定推荐关系</text>
<text class="dist__inviter-action">填写邀请码 ›</text>
</view>
</view>
@@ -477,6 +481,44 @@
</view>
</view>
<!-- ==================== 手动绑定推荐人邀请码弹层 ==================== -->
<view v-if="showBindModal" class="dist__mask" @click="showBindModal = false" @touchmove.stop.prevent>
<view class="dist__sheet" @click.stop>
<view class="dist__sheet-handle"></view>
<view class="dist__sheet-head">
<text class="dist__sheet-title">绑定推荐人邀请码</text>
<view class="dist__sheet-close" @click="showBindModal = false">
<image class="dist__sheet-close-img" :src="iconUrl('close')" mode="aspectFit" />
</view>
</view>
<view class="dist__sheet-field">
<input
v-model="bindCodeInput"
class="dist__sheet-input dist__sheet-input--name"
type="text"
maxlength="16"
placeholder="请输入好友的专属邀请码"
placeholder-class="dist__sheet-placeholder"
/>
</view>
<view class="dist__sheet-tip-row">
<image class="dist__sheet-tip-icon" :src="iconUrl('info')" mode="aspectFit" />
<text class="dist__sheet-tip">每位用户仅可绑定一次推荐人,绑定后终身锁定不可更改</text>
</view>
<view
class="dist__sheet-btn"
:class="{ 'dist__sheet-btn--disabled': bindSubmitting || !bindCodeInput.trim() }"
@click="submitBindCode"
>
<text>{{ bindSubmitting ? '绑定中…' : '确认绑定' }}</text>
</view>
<view class="dist__sheet-safe"></view>
</view>
</view>
<!-- ==================== 专属邀请海报弹层 ==================== -->
<view v-if="showPosterModal" class="dist__mask" @click="showPosterModal = false" @touchmove.stop.prevent>
<view class="dist__poster-sheet" @click.stop>
@@ -557,6 +599,9 @@ export default {
minWithdraw: 50,
showWithdraw: false,
showRuleModal: false,
showBindModal: false,
bindCodeInput: '',
bindSubmitting: false,
showPosterModal: false,
posterLoading: false,
posterImagePath: '',
@@ -930,6 +975,49 @@ export default {
this.copyText(this.overview.inviteCode, '邀请码已复制')
},
/**
* 打开手动绑定推荐人邀请码弹层
*/
openBindModal() {
this.bindCodeInput = ''
this.showBindModal = true
},
/**
* 提交手动绑定推荐人邀请码
*/
async submitBindCode() {
if (this.bindSubmitting) return
const code = String(this.bindCodeInput || '').trim().toUpperCase()
if (!code) {
uni.showToast({ title: '请输入好友的邀请码', icon: 'none' })
return
}
if (this.overview && this.overview.inviteCode && code === String(this.overview.inviteCode).toUpperCase()) {
uni.showToast({ title: '不能填写自己的邀请码', icon: 'none' })
return
}
this.bindSubmitting = true
try {
const res = await distributionApi.bind({ inviteCode: code })
this.showBindModal = false
this.bindCodeInput = ''
if (res && res.inviterId) {
this.overview = {
...this.overview,
inviterId: res.inviterId,
inviterNickname: res.inviterNickname || '',
}
}
uni.showToast({ title: '推荐人绑定成功', icon: 'success' })
this.init()
} catch (e) {
uni.showToast({ title: (e && e.message) || '绑定失败,请核对邀请码', icon: 'none' })
} finally {
this.bindSubmitting = false
}
},
/**
* 复制我的专属推广链接
*/
@@ -1867,6 +1955,46 @@ export default {
margin-right: 8rpx;
}
/* 推荐人展示与手动绑定入口栏 */
.dist__inviter-bar {
margin-top: 20rpx;
padding: 18rpx 24rpx;
border-radius: 16rpx;
background: #F8FAFC;
border: 1rpx dashed #CBD5E1;
display: flex;
align-items: center;
justify-content: space-between;
&:active {
opacity: 0.85;
}
}
.dist__inviter-bar--bound {
background: #F0FDF4;
border: 1rpx solid #DCFCE7;
}
.dist__inviter-label {
font-size: 24rpx;
color: #475569;
}
.dist__inviter-bar--bound .dist__inviter-label {
color: #1E293B;
font-weight: 500;
}
.dist__inviter-action {
font-size: 24rpx;
font-weight: 600;
color: #00A854;
}
.dist__inviter-tag {
font-size: 20rpx;
font-weight: 600;
color: #00A854;
background: #DCFCE7;
padding: 4rpx 14rpx;
border-radius: 999rpx;
}
/* ==================== 顶部 Tab 导航 ==================== */
.dist__tabs-bar {
display: flex;

查看文件

@@ -423,7 +423,7 @@ export default {
* @param {Object} query 路由启动参数
*/
onLoad(query) {
if (query) {
if (query && !uni.getStorageSync('fc_token')) {
const ic = query.ic || query.inviteCode || query.invite_code
const inviterId = query.inviterId || query.inviter_id
if (ic) uni.setStorageSync('fc_invite_code', String(ic))

查看文件

@@ -233,7 +233,7 @@ export default {
* @param {Object} query 路由入参
*/
onLoad(query) {
if (query) {
if (query && !uni.getStorageSync('fc_token')) {
const ic = query.ic || query.inviteCode || query.invite_code
const inviterId = query.inviterId || query.inviter_id
if (ic) uni.setStorageSync('fc_invite_code', String(ic))