diff --git a/addons/shopro/controller/commission/Agent.php b/addons/shopro/controller/commission/Agent.php index 7a32cd3..f6472ae 100644 --- a/addons/shopro/controller/commission/Agent.php +++ b/addons/shopro/controller/commission/Agent.php @@ -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)); diff --git a/addons/shopro/service/partner/ActiveService.php b/addons/shopro/service/partner/ActiveService.php index b29ea3a..fce356a 100644 --- a/addons/shopro/service/partner/ActiveService.php +++ b/addons/shopro/service/partner/ActiveService.php @@ -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; diff --git a/addons/shopro/service/partner/IdentityService.php b/addons/shopro/service/partner/IdentityService.php index be52701..4ed9133 100644 --- a/addons/shopro/service/partner/IdentityService.php +++ b/addons/shopro/service/partner/IdentityService.php @@ -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'; diff --git a/addons/shopro/service/user/NicknameService.php b/addons/shopro/service/user/NicknameService.php new file mode 100644 index 0000000..fc93947 --- /dev/null +++ b/addons/shopro/service/user/NicknameService.php @@ -0,0 +1,105 @@ += 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; + } + } +} diff --git a/addons/shopro/service/user/UserAuth.php b/addons/shopro/service/user/UserAuth.php index a84f94f..1c99315 100755 --- a/addons/shopro/service/user/UserAuth.php +++ b/addons/shopro/service/user/UserAuth.php @@ -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 ]; diff --git a/application/common/library/Auth.php b/application/common/library/Auth.php index 17e9558..09730cb 100755 --- a/application/common/library/Auth.php +++ b/application/common/library/Auth.php @@ -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, diff --git a/tests/verify_active_countdown_spec.js b/tests/verify_active_countdown_spec.js index 9121c03..9bb7208 100644 --- a/tests/verify_active_countdown_spec.js +++ b/tests/verify_active_countdown_spec.js @@ -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; diff --git a/tests/verify_nickname_service_spec.js b/tests/verify_nickname_service_spec.js new file mode 100644 index 0000000..a902a9c --- /dev/null +++ b/tests/verify_nickname_service_spec.js @@ -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); +}