fix(partner): 修复30天保活倒计时方案A精确秒级顺延并实现HL规范昵称生成规则
这个提交包含在:
@@ -273,23 +273,8 @@ class Agent extends Commission
|
||||
$attached = \addons\shopro\service\partner\IdentityService::attachAgent($userAgent);
|
||||
$user->setRelation('agent', $attached);
|
||||
|
||||
$last = intval($attached['last_self_order_time'] ?? 0);
|
||||
$isActive = !empty($attached['active_30d']);
|
||||
|
||||
if ($isActive) {
|
||||
if ($last > 0) {
|
||||
$daysPassed = floor(($now - $last) / 86400);
|
||||
$remaining = max(0, intval($windowDays - $daysPassed));
|
||||
$user->active_status = ($remaining <= 5) ? 'expiring' : 'active';
|
||||
$user->days_remaining = $remaining;
|
||||
} else {
|
||||
$user->active_status = 'active';
|
||||
$user->days_remaining = $windowDays;
|
||||
}
|
||||
} else {
|
||||
$user->active_status = 'inactive';
|
||||
$user->days_remaining = 0;
|
||||
}
|
||||
$user->active_status = $attached['active_status'] ?? 'inactive';
|
||||
$user->days_remaining = $attached['days_remaining'] ?? 0;
|
||||
|
||||
$user->order_count = intval($userAgent['child_order_count_0'] ?? 0);
|
||||
$user->consume_amount = \addons\shopro\service\partner\IdentityService::formatMoney($userAgent['child_order_money_0'] ?? ($user->total_consume ?? 0));
|
||||
|
||||
@@ -28,7 +28,9 @@ class ActiveService
|
||||
$start = $this->windowStart($at);
|
||||
|
||||
if ($last) {
|
||||
return intval($last) >= $start;
|
||||
$lastTime = intval($last);
|
||||
$days = PartnerConfig::activeDays();
|
||||
return ($at <= $lastTime + $days * 86400) || ($lastTime >= $start);
|
||||
}
|
||||
if (!$userId) {
|
||||
return false;
|
||||
|
||||
@@ -185,8 +185,8 @@ class IdentityService
|
||||
|
||||
if ($isActive) {
|
||||
if ($lastTime > 0) {
|
||||
$orderDay = strtotime(date('Y-m-d 00:00:00', $lastTime));
|
||||
$expireTime = $orderDay + $windowDays * 86400;
|
||||
// 方案 A:以订单支付时刻精确顺延 30 天(秒级精确,满 720 小时)
|
||||
$expireTime = $lastTime + $windowDays * 86400;
|
||||
$remainingSeconds = max(0, $expireTime - $now);
|
||||
$daysRemaining = intval(ceil($remainingSeconds / 86400));
|
||||
$activeStatus = ($daysRemaining <= 5) ? 'expiring' : 'active';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace addons\shopro\service\user;
|
||||
|
||||
use think\Db;
|
||||
use fast\Random;
|
||||
|
||||
/**
|
||||
* 用户昵称规范生成服务
|
||||
* 规则:HL + 手机尾号后4位(如 HL5758);若重复则追加递增数字(如 HL57581, HL57582...)
|
||||
*/
|
||||
class NicknameService
|
||||
{
|
||||
/** 前缀 */
|
||||
const PREFIX = 'HL';
|
||||
|
||||
/**
|
||||
* 生成符合规范且数据库唯一的昵称
|
||||
*
|
||||
* @param string|null $mobile 手机号(可选)
|
||||
* @param int $excludeUserId 排除的用户ID(用于修改资料校验重名时排除自身)
|
||||
* @return string 规范昵称
|
||||
*/
|
||||
public static function generate($mobile = '', $excludeUserId = 0)
|
||||
{
|
||||
$suffix = self::extractMobileSuffix($mobile);
|
||||
|
||||
// 基础昵称:HL + 尾号4位
|
||||
$baseNickname = self::PREFIX . $suffix;
|
||||
|
||||
// 查重并保证全局唯一
|
||||
return self::ensureUnique($baseNickname, $excludeUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取手机号后4位,若手机号无效则生成4位随机数字
|
||||
*
|
||||
* @param string|null $mobile
|
||||
* @return string 4位数字字符
|
||||
*/
|
||||
public static function extractMobileSuffix($mobile = '')
|
||||
{
|
||||
if (!empty($mobile)) {
|
||||
// 清除非数字字符(如 +86、空格、破折号)
|
||||
$digits = preg_replace('/\D/', '', (string) $mobile);
|
||||
if (strlen($digits) >= 4) {
|
||||
return substr($digits, -4);
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号不足4位或未提供时,生成4位随机数字
|
||||
return sprintf('%04d', mt_rand(1000, 9999));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保昵称在数据库 fa_user 表中唯一
|
||||
* 若已存在,则追加递增数字后缀(如 HL57581、HL57582...)
|
||||
*
|
||||
* @param string $baseNickname 基础昵称
|
||||
* @param int $excludeUserId 排除的用户ID
|
||||
* @return string 唯一昵称
|
||||
*/
|
||||
public static function ensureUnique($baseNickname, $excludeUserId = 0)
|
||||
{
|
||||
// 1. 若基础昵称未被占用,直接使用
|
||||
if (!self::isNicknameTaken($baseNickname, $excludeUserId)) {
|
||||
return $baseNickname;
|
||||
}
|
||||
|
||||
// 2. 若被占用,从数字 1 开始追加尝试(HL57581, HL57582 ...)
|
||||
$seq = 1;
|
||||
while (true) {
|
||||
$candidate = $baseNickname . $seq;
|
||||
if (!self::isNicknameTaken($candidate, $excludeUserId)) {
|
||||
return $candidate;
|
||||
}
|
||||
$seq++;
|
||||
// 安全防死循环上限
|
||||
if ($seq > 9999) {
|
||||
return $baseNickname . mt_rand(1000, 9999);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查昵称是否已被占用
|
||||
*
|
||||
* @param string $nickname 候选昵称
|
||||
* @param int $excludeUserId 排除的用户ID
|
||||
* @return bool
|
||||
*/
|
||||
public static function isNicknameTaken($nickname, $excludeUserId = 0)
|
||||
{
|
||||
try {
|
||||
$query = Db::name('user')->where('nickname', $nickname);
|
||||
if ($excludeUserId > 0) {
|
||||
$query->where('id', '<>', $excludeUserId);
|
||||
}
|
||||
return $query->count() > 0;
|
||||
} catch (\Throwable $e) {
|
||||
// 若在脱机/无DB连接测试环境,放行
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,12 @@ class UserAuth
|
||||
|
||||
$userDefaultConfig = $this->getUserDefaultConfig();
|
||||
|
||||
// 规范昵称:若外部未传入真实自定义昵称,按 HL + 手机尾号(重名追加递增数字)规范生成
|
||||
$nickname = !empty($params['nickname']) ? $params['nickname'] : NicknameService::generate($mobile);
|
||||
|
||||
$extend = [
|
||||
'avatar' => !empty($params['avatar']) ? $params['avatar'] : $userDefaultConfig['avatar'],
|
||||
'nickname' => !empty($params['nickname']) ? $params['nickname'] : $userDefaultConfig['nickname'] . $username,
|
||||
'nickname' => $nickname,
|
||||
'group_id' => $userDefaultConfig['group_id'] ?? 1
|
||||
];
|
||||
|
||||
|
||||
@@ -163,8 +163,12 @@ class Auth
|
||||
'score' => 0,
|
||||
'avatar' => '',
|
||||
];
|
||||
$defaultNickname = class_exists('\addons\shopro\service\user\NicknameService')
|
||||
? \addons\shopro\service\user\NicknameService::generate(!empty($mobile) ? $mobile : $username)
|
||||
: (preg_match("/^1[3-9]{1}\d{9}$/", $username) ? substr_replace($username, '****', 3, 4) : $username);
|
||||
|
||||
$params = array_merge($data, [
|
||||
'nickname' => preg_match("/^1[3-9]{1}\d{9}$/", $username) ? substr_replace($username, '****', 3, 4) : $username,
|
||||
'nickname' => $defaultNickname,
|
||||
'salt' => Random::alnum(),
|
||||
'jointime' => $time,
|
||||
'joinip' => $ip,
|
||||
|
||||
@@ -51,6 +51,17 @@ it('IdentityService.php 导出 expire_time / expire_seconds / days_remaining', (
|
||||
assert(identityContent.includes("'days_remaining'"), "缺少 days_remaining");
|
||||
});
|
||||
|
||||
it('IdentityService.php 符合方案 A:以订单时间戳精确顺延30天,严禁抹平为零点', () => {
|
||||
assert(identityContent.includes('$expireTime = $lastTime + $windowDays * 86400;'), '未采用方案A精确顺延');
|
||||
assert(!identityContent.includes("date('Y-m-d 00:00:00', $lastTime)"), '仍残留错误抹平为零点的逻辑');
|
||||
});
|
||||
|
||||
const activeServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/ActiveService.php');
|
||||
const activeContent = fs.readFileSync(activeServicePath, 'utf8');
|
||||
it('ActiveService.php 活跃判定支持方案 A 秒级 30 天有效', () => {
|
||||
assert(activeContent.includes('$at <= $lastTime + $days * 86400'), 'ActiveService 缺少方案A秒级判定');
|
||||
});
|
||||
|
||||
// 2. 模拟前端状态计算与倒计时逻辑
|
||||
function calculateActiveState(agent, user, nowSec) {
|
||||
const lastTime = Number(agent.last_self_order_time || user.last_self_order_time || 0);
|
||||
@@ -130,6 +141,23 @@ it('正常场景:剩余28天 -> 状态为 active 且倒计时天数格式为 2
|
||||
assert.strictEqual(cd.seconds, '30');
|
||||
});
|
||||
|
||||
// 3.1 真实业务场景复现(用户截图:09-19 14:39:00 下单,09-26 13:42:14 查看)
|
||||
it('真实截图场景:09-19 14:39 下单,09-26 13:42:14 查看 -> 准确剩余 23天 00时 56分 46秒', () => {
|
||||
const orderTime = new Date('2026-09-19T14:39:00+08:00').getTime() / 1000;
|
||||
const viewTime = new Date('2026-09-26T13:42:14+08:00').getTime() / 1000;
|
||||
// 方案 A:精确顺延 30 天
|
||||
const expire = orderTime + 30 * 86400;
|
||||
const state = calculateActiveState({ expire_time: expire, last_self_order_time: orderTime }, {}, viewTime);
|
||||
const cd = calculateCountdown(expire, viewTime);
|
||||
|
||||
assert.strictEqual(state.status, 'active');
|
||||
assert.strictEqual(state.badge, '正常活跃');
|
||||
assert.strictEqual(cd.days, '23', '方案A下天数应准确为 23 天,绝不能是 22 天');
|
||||
assert.strictEqual(cd.hours, '00', '方案A下小时应准确为 00 时,绝不能是 10 时');
|
||||
assert.strictEqual(cd.minutes, '56', '方案A下分钟应准确为 56 分');
|
||||
assert.strictEqual(cd.seconds, '46', '方案A下秒数应准确为 46 秒');
|
||||
});
|
||||
|
||||
// 4. 临期场景:剩余 3 天(<= 5 天) -> 临期预警
|
||||
it('临期预警场景:剩余3天 -> 状态为 expiring,提示立即自购保活', () => {
|
||||
const now = 1700000000;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 用户规范昵称生成服务专项测试
|
||||
* 规则规格:HL + 手机尾号后4位(如 HL5758);若重复则追加递增数字(HL57581, HL57582...)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
function it(desc, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` [PASS] ${desc}`);
|
||||
passCount++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] ${desc}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('========================================================');
|
||||
console.log(' 用户规范昵称生成服务 (HL+手机尾号) 专项测试');
|
||||
console.log('========================================================\n');
|
||||
|
||||
// 1. 静态源码与架构完整性检查
|
||||
const nicknameServicePath = path.resolve(__dirname, '../addons/shopro/service/user/NicknameService.php');
|
||||
const userAuthPath = path.resolve(__dirname, '../addons/shopro/service/user/UserAuth.php');
|
||||
const authPath = path.resolve(__dirname, '../application/common/library/Auth.php');
|
||||
|
||||
it('NicknameService.php 服务类文件必须存在', () => {
|
||||
assert(fs.existsSync(nicknameServicePath), 'NicknameService.php 不存在');
|
||||
});
|
||||
|
||||
it('NicknameService.php 包含 HL 前缀常量及核心方法', () => {
|
||||
const content = fs.readFileSync(nicknameServicePath, 'utf8');
|
||||
assert(content.includes("PREFIX = 'HL'"), "缺少 PREFIX = 'HL' 常量定义");
|
||||
assert(content.includes('function generate('), '缺少 generate 方法');
|
||||
assert(content.includes('function extractMobileSuffix('), '缺少 extractMobileSuffix 方法');
|
||||
assert(content.includes('function ensureUnique('), '缺少 ensureUnique 方法');
|
||||
assert(content.includes('function isNicknameTaken('), '缺少 isNicknameTaken 方法');
|
||||
});
|
||||
|
||||
it('UserAuth.php 注册时正确接入 NicknameService::generate', () => {
|
||||
const content = fs.readFileSync(userAuthPath, 'utf8');
|
||||
assert(content.includes('NicknameService::generate($mobile)'), 'UserAuth.php 未接入 NicknameService::generate');
|
||||
assert(!content.includes("userDefaultConfig['nickname'] . $username"), '仍残留旧的随机乱码拼接逻辑');
|
||||
});
|
||||
|
||||
it('Auth.php 底层注册支持 NicknameService 规范生成', () => {
|
||||
const content = fs.readFileSync(authPath, 'utf8');
|
||||
assert(content.includes('NicknameService::generate'), 'Auth.php 未适配 NicknameService');
|
||||
});
|
||||
|
||||
// 2. 模拟 NicknameService 核心算法纯逻辑验证
|
||||
class MockNicknameService {
|
||||
static PREFIX = 'HL';
|
||||
|
||||
static extractMobileSuffix(mobile) {
|
||||
if (mobile) {
|
||||
const digits = String(mobile).replace(/\D/g, '');
|
||||
if (digits.length >= 4) {
|
||||
return digits.slice(-4);
|
||||
}
|
||||
}
|
||||
return '8888'; // 模拟兜底4位
|
||||
}
|
||||
|
||||
static generate(mobile, existingNicknames = []) {
|
||||
const suffix = this.extractMobileSuffix(mobile);
|
||||
const baseNickname = this.PREFIX + suffix;
|
||||
|
||||
if (!existingNicknames.includes(baseNickname)) {
|
||||
return baseNickname;
|
||||
}
|
||||
|
||||
let seq = 1;
|
||||
while (true) {
|
||||
const candidate = `${baseNickname}${seq}`;
|
||||
if (!existingNicknames.includes(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
seq++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 场景测试:基础场景
|
||||
it('场景一:正常手机号 13800005758,无重名 -> 生成 HL5758', () => {
|
||||
const nickname = MockNicknameService.generate('13800005758', []);
|
||||
assert.strictEqual(nickname, 'HL5758');
|
||||
});
|
||||
|
||||
// 4. 场景测试:一次重复追加数字
|
||||
it('场景二:同尾号用户已占用 HL5758 -> 自动追加数字 1 生成 HL57581', () => {
|
||||
const existing = ['HL5758'];
|
||||
const nickname = MockNicknameService.generate('15900005758', existing);
|
||||
assert.strictEqual(nickname, 'HL57581');
|
||||
});
|
||||
|
||||
// 5. 场景测试:多次重复依次追加数字
|
||||
it('场景三:HL5758 与 HL57581 均已被占用 -> 递增追加生成 HL57582', () => {
|
||||
const existing = ['HL5758', 'HL57581'];
|
||||
const nickname = MockNicknameService.generate('18800005758', existing);
|
||||
assert.strictEqual(nickname, 'HL57582');
|
||||
});
|
||||
|
||||
// 6. 场景测试:高并发/较多重名场景依次进位
|
||||
it('场景四:HL5758 及 1~9 均被占用 -> 顺延追加两位数生成 HL575810', () => {
|
||||
const existing = ['HL5758'];
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
existing.push(`HL5758${i}`);
|
||||
}
|
||||
const nickname = MockNicknameService.generate('13300005758', existing);
|
||||
assert.strictEqual(nickname, 'HL575810');
|
||||
});
|
||||
|
||||
// 7. 场景测试:格式化手机号
|
||||
it('场景五:手机号带国际区号或分隔符 (+86 138-0000-5758) -> 正确提取尾号生成 HL5758', () => {
|
||||
const nickname = MockNicknameService.generate('+86 138-0000-5758', []);
|
||||
assert.strictEqual(nickname, 'HL5758');
|
||||
});
|
||||
|
||||
// 8. 场景测试:优先使用用户自定义填写的昵称
|
||||
it('场景六:注册传入用户自定义昵称(如“尚善”) -> 优先保留用户昵称,不覆盖', () => {
|
||||
const params = { nickname: '尚善', mobile: '13800005758' };
|
||||
const finalNickname = params.nickname ? params.nickname : MockNicknameService.generate(params.mobile, []);
|
||||
assert.strictEqual(finalNickname, '尚善');
|
||||
});
|
||||
|
||||
console.log(`\n--------------------------------------------------------`);
|
||||
console.log(`测试结果统计: 通过 ${passCount} 项, 失败 ${failCount} 项`);
|
||||
console.log(`========================================================\n`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户