diff --git a/.gitignore b/.gitignore index 775a29f..22f2627 100755 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /vendor/ /runtime/* /public/assets/libs/ -/public/uploads/* .DS_Store .idea composer.lock diff --git a/application/admin/command/FcInstall.php b/application/admin/command/FcInstall.php index 8026871..5c27b8a 100644 --- a/application/admin/command/FcInstall.php +++ b/application/admin/command/FcInstall.php @@ -215,6 +215,24 @@ class FcInstall extends Command $output->writeln(' - ' . $planTable . ' 新增列 post_discount'); $changed++; } + if (!$this->hasColumn($planTable, 'view_quota')) { + Db::execute("ALTER TABLE `{$planTable}` ADD COLUMN `view_quota` int(10) NOT NULL DEFAULT 20" + . " COMMENT '会员每日免费浏览完整信息条数(0=不限)' AFTER `free_view_quota`"); + Db::execute("UPDATE `{$planTable}` SET `view_quota` = 20 WHERE `plan_code` = 'M1'"); + Db::execute("UPDATE `{$planTable}` SET `view_quota` = 50 WHERE `plan_code` = 'M3'"); + Db::execute("UPDATE `{$planTable}` SET `view_quota` = 0 WHERE `plan_code` = 'M12'"); + $output->writeln(' - ' . $planTable . ' 新增列 view_quota(0=不限次浏览)'); + $changed++; + } + if (!$this->hasColumn($planTable, 'call_quota')) { + Db::execute("ALTER TABLE `{$planTable}` ADD COLUMN `call_quota` int(10) NOT NULL DEFAULT 30" + . " COMMENT '会员每日拨打电话次数(0=不限)' AFTER `view_quota`"); + Db::execute("UPDATE `{$planTable}` SET `call_quota` = 30 WHERE `plan_code` = 'M1'"); + Db::execute("UPDATE `{$planTable}` SET `call_quota` = 40 WHERE `plan_code` = 'M3'"); + Db::execute("UPDATE `{$planTable}` SET `call_quota` = 0 WHERE `plan_code` = 'M12'"); + $output->writeln(' - ' . $planTable . ' 新增列 call_quota(0=不限次拨打)'); + $changed++; + } if (!$this->hasColumn($extTable, 'free_view_quota')) { Db::execute("ALTER TABLE `{$extTable}` ADD COLUMN `free_view_quota` int(10) NOT NULL DEFAULT 20" . " COMMENT '当前会员每日免费查看条数(0=无限)' AFTER `free_view_used`"); @@ -256,6 +274,7 @@ class FcInstall extends Command $deprecated = [ 'post.monthPrice', 'post.monthQuota', 'post.yearPrice', 'post.yearQuota', 'fee.singlePost', 'fee.freePostPerDay', 'fee.memberMonth', 'fee.memberQuarter', 'fee.memberYear', + 'call.vipDailyQuota', ]; $removedKeys = Db::name('fc_config')->where('key', 'in', $deprecated)->delete(); if ($removedKeys) { @@ -943,6 +962,17 @@ class FcInstall extends Command KEY `target` (`target_type`, `target_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-电话拨打次数记录';", + 'view_log' => "CREATE TABLE IF NOT EXISTS `#P#view_log` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '浏览用户ID', + `post_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '信息ID', + `view_date` date NOT NULL COMMENT '浏览日期', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_user_post_date` (`user_id`, `post_id`, `view_date`), + KEY `idx_user_date` (`user_id`, `view_date`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-完整信息每日浏览记录';", + 'category' => "CREATE TABLE IF NOT EXISTS `#P#category` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL DEFAULT '' COMMENT '分类名', diff --git a/application/admin/command/FcSeedPosts.php b/application/admin/command/FcSeedPosts.php deleted file mode 100644 index 3dbe45f..0000000 --- a/application/admin/command/FcSeedPosts.php +++ /dev/null @@ -1,477 +0,0 @@ - [昵称, 手机号] */ - const USERS = [ - 1001 => ['同城小助手', '13800001111'], - 1002 => ['小王同学', '15033443344'], - 1003 => ['洁净到家·陈姐', '13156785678'], - 2001 => ['顺捷物流·杨经理', '13988888888'], - 2002 => ['安家房产·周主管', '13566666666'], - 2003 => ['骑行爱好者·小周', '13712341234'], - 2004 => ['车行天下·张总', '13800000000'], - 2005 => ['洁净到家·王师傅', '13156785678'], - 2006 => ['数码达人·阿豪', '15033443344'], - 2007 => ['优选二手车·赵经理', '13700000000'], - ]; - - /** 区域中心坐标(gcj02 近似值,用于首页「附近」排序演示) */ - const AREA_COORD = [ - '云岩区' => [26.605, 106.710], - '南明区' => [26.568, 106.716], - '观山湖区' => [26.625, 106.625], - '花溪区' => [26.430, 106.670], - ]; - - protected function configure() - { - $this->setName('fc:seed-posts') - ->setDescription('清理不规范测试数据,按分类重建信息演示数据'); - } - - protected function execute(Input $input, Output $output) - { - $now = time(); - $prefix = \think\Config::get('database.prefix'); - $output->writeln('==> 清理不规范测试数据'); - $this->clean(); - - $data = $this->posts(); - $owners = $this->owners(); - - Db::startTrans(); - try { - $id = 101; - $seq = 0; - $stat = []; - foreach ($data as $categoryId => $rows) { - $stat[$categoryId] = 0; - foreach ($rows as $i => $row) { - $uid = $owners[$categoryId][$i]; - $user = self::USERS[$uid]; - $audit = $this->auditStatus($categoryId, $i); - $paid = $audit === 1 && $categoryId !== 8 && $i % 4 === 1; - $views = 160 + (($categoryId * 977 + $i * 613) % 3200); - $likes = max(2, (int)($views / 48)); - $areas = $this->coord($row[4], $i); - $created = $now - (int)((0.15 + $i * 0.41 + $categoryId * 0.13) * 86400); - $media = $this->mediaPaths($categoryId, $i); - - $areaId = $this->areaId($row[5]); - - Db::name('fc_post')->insert([ - 'id' => $id, - 'user_id' => $uid, - 'category_id' => $categoryId, - 'identity_type' => in_array($uid, [1003, 2001, 2002, 2004, 2005, 2007, 2011], true) ? 2 : 1, - 'title' => $row[1], - 'content' => $row[2], - 'cover' => $media[0], - 'media' => json_encode($media, JSON_UNESCAPED_UNICODE), - 'price' => $row[3], - 'price_unit' => $row[4], - 'area' => $row[5], - 'district_id' => $areaId, - 'address' => $row[6], - 'lat' => $areas[0], - 'lng' => $areas[1], - 'contact' => $row[0], - 'phone' => $user[1], - 'wechat' => 'gyfc' . $id, - 'tags' => $row[7], - 'is_paid' => $paid ? 1 : 0, - 'amount' => $paid ? 5 : 0, - 'pay_type' => $paid ? ($i % 2 === 1 ? 'wxpay' : 'balance') : '', - 'audit_status' => $audit, - 'audit_reason' => $audit === 2 ? '信息涉嫌重复发布' : '', - 'views' => $audit === 1 ? $views : 0, - 'likes' => $audit === 1 ? $likes : 0, - 'comment_count' => 0, - 'status' => 1, - 'is_top' => $this->isTop($categoryId, $i) ? 1 : 0, - 'distance' => 0, - 'createtime' => $created, - 'updatetime' => $created, - ]); - - $id++; - $seq++; - if ($audit === 1) { - $stat[$categoryId]++; - } - } - } - $output->writeln(' - 信息 ' . $seq . ' 条(已更新 ID 101-' . ($id - 1) . ')'); - - $this->seedComments($now); - $this->seedDrafts($now); - $this->seedReports($now); - $this->syncCounts(); - - Db::commit(); - } catch (\Exception $e) { - Db::rollback(); - $output->writeln(' [x] 重建失败:' . $e->getMessage()); - return; - } - - Db::execute('ALTER TABLE `' . $prefix . 'fc_post` AUTO_INCREMENT = 165'); - - foreach ($stat as $categoryId => $count) { - $name = Db::name('fc_category')->where('id', $categoryId)->value('name'); - $output->writeln(' - ' . $name . ':' . $count . ' 条已发布'); - } - $output->writeln('==> 完成'); - } - - /** 清理内容类测试数据(保留用户、订单、佣金、配置) */ - protected function clean() - { - Db::name('fc_comment')->where('id', '>', 0)->delete(); - Db::name('fc_favorite')->where('id', '>', 0)->delete(); - Db::name('fc_pay_view')->where('id', '>', 0)->delete(); - Db::name('fc_draft')->where('id', '>', 0)->delete(); - Db::name('fc_report')->where('id', '>', 0)->delete(); - Db::name('fc_post')->where('id', '>', 0)->delete(); - Db::name('fc_area')->where('district', '接口验证区')->delete(); - Db::name('fc_message')->where('title', 'like', '%接口验证%')->whereOr('content', 'like', '%接口验证%')->delete(); - $this->cleanMockUsers(); - } - - /** 清理历史自动化脚本遗留的临时用户及其关联数据 */ - protected function cleanMockUsers() - { - $ids = Db::name('user') - ->where('username', 'like', 'test\\_dist\\_%') - ->whereOr('username', 'like', 'mock\\_test\\_dist\\_%') - ->column('id'); - if (!$ids) { - return; - } - Db::name('fc_user_ext')->where('user_id', 'in', $ids)->delete(); - Db::name('fc_dist_relation')->where('user_id', 'in', $ids)->whereOr('parent_id', 'in', $ids)->delete(); - Db::name('fc_order')->where('user_id', 'in', $ids)->delete(); - Db::name('fc_commission')->where('user_id', 'in', $ids)->whereOr('from_user_id', 'in', $ids)->delete(); - Db::name('fc_withdraw')->where('user_id', 'in', $ids)->delete(); - Db::name('fc_message')->where('user_id', 'in', $ids)->delete(); - Db::name('user')->where('id', 'in', $ids)->delete(); - } - - /** 每个分类下的发布账号,按信息顺序一一对应 */ - protected function owners() - { - return [ - 1 => [2001, 1002, 2002, 2005, 1002, 2001, 1003, 2004], - 2 => [2002, 2002, 2002, 2002, 1001, 2002, 2002, 1001], - 3 => [2003, 2006, 1002, 2005, 1003, 1001, 2003, 2006], - 4 => [2004, 2007, 2004, 2007, 2004, 2007, 2003, 2007], - 5 => [2005, 1003, 1001, 1003, 2005, 1003, 1002, 1001], - 6 => [1002, 1003, 1001, 1002, 1003, 1001, 1002, 1003], - 7 => [1003, 2005, 1003, 2005, 1003, 2005, 1003, 2005], - 8 => [1002, 2003, 1001, 1002, 2003, 1001, 1002, 2003], - ]; - } - - /** 审核状态:默认通过;少量待审/驳回用于验证后台流程 */ - protected function auditStatus($categoryId, $index) - { - $pending = [[4, 6], [8, 7]]; - $rejected = [[3, 5]]; - if (in_array([$categoryId, $index], $pending, true)) { - return 0; - } - if (in_array([$categoryId, $index], $rejected, true)) { - return 2; - } - return 1; - } - - /** 置顶:每个分类最多一条,制造首页头部内容 */ - protected function isTop($categoryId, $index) - { - $tops = [[1, 0], [3, 1], [5, 0], [7, 1]]; - return in_array([$categoryId, $index], $tops, true); - } - - /** 区县名称 → 服务区域ID(fc_area.id) */ - protected function areaId($area) - { - static $cache = []; - if (isset($cache[$area])) { - return $cache[$area]; - } - $row = Db::name('fc_area')->where('district', $area)->find(); - return $cache[$area] = $row ? (int)$row['id'] : 0; - } - - /** 区域中心 + 稳定偏移,避免所有信息坐标完全重合 */ - protected function coord($area, $index) - { - $base = isset(self::AREA_COORD[$area]) ? self::AREA_COORD[$area] : self::AREA_COORD['云岩区']; - $lat = $base[0] + (($index % 5) - 2) * 0.0055; - $lng = $base[1] + ((int)($index / 2) - 2) * 0.0065; - return [round($lat, 6), round($lng, 6)]; - } - - /** - * 信息配图:每条信息使用 1 张与内容匹配的真实照片作为封面和详情图。 - * 图片存放在 public/static/posts/,随代码仓库部署,不依赖第三方外链。 - */ - protected function mediaPaths($categoryId, $index) - { - $primary = $this->primaries()[$categoryId][$index]; - return ['/static/posts/' . $primary . '.jpg']; - } - - /** 每条信息的首图(与 posts() 的顺序一一对应) */ - protected function primaries() - { - return [ - 1 => ['job-warehouse', 'job-cafe', 'job-office', 'job-cleaning', 'job-warehouse', 'job-delivery', 'job-classroom', 'job-handshake'], - 2 => ['house-living1', 'house-living3', 'house-office', 'house-model', 'house-living2', 'house-living4', 'house-exterior', 'job-cafe'], - 3 => ['goods-bike', 'goods-ipad', 'goods-dining', 'goods-fridge', 'goods-stroller', 'goods-laptop', 'goods-treadmill', 'goods-desk'], - 4 => ['car-sedan-white', 'car-compact-blue', 'car-moto', 'car-hatch-red', 'car-coupe-blue', 'car-suv-white', 'goods-bike', 'car-sedan-dark'], - 5 => ['svc-spray', 'svc-keys', 'svc-code', 'svc-wrench', 'svc-moving', 'svc-drill', 'svc-camera', 'svc-night'], - 6 => ['edu-art', 'edu-books', 'edu-shelf', 'edu-guitar', 'edu-laptop', 'edu-class', 'car-sedan-white', 'edu-shelf'], - 7 => ['clean-window', 'clean-bath', 'clean-baby', 'clean-kitchen', 'clean-kitchen', 'clean-window', 'clean-elder', 'clean-vacuum'], - 8 => ['friend-hike', 'friend-badminton', 'friend-books', 'friend-run', 'friend-camera', 'friend-bike', 'friend-board', 'friend-group'], - ]; - } - - /** 信息数据集:联系人 / 标题 / 内容 / 价格 / 单位 / 区域 / 地址 / 标签 */ - protected function posts() - { - return [ - // 招聘求职 - 1 => [ - ['杨经理', '急招快递分拣员(日结)', '站点直招,日结 180-220 元,包工作餐,当天结算。能吃苦、手脚麻利即可,年龄 18-50 岁。', 0, '面议', '云岩区', '贵阳北站物流园', '日结,包吃'], - ['李店长', '奶茶店店员(包住)', '花果园商圈奶茶店招店员,负责点单和出杯。早班 10 点到 18 点,月休 4 天,包住,有提成。', 3800, '元/月', '南明区', '花果园购物中心', '包住,有提成'], - ['周主管', '行政文员(双休)', '负责合同整理、客户接待和日常表格。会基本办公软件,做事细心,转正后买五险。', 4200, '元/月', '观山湖区', '金融城', '双休,五险'], - ['王师傅', '招保洁员 5 名', '承接小区和写字楼保洁,公司派单,不用自己找客户。多劳多得,工资月结。', 3000, '元/月', '云岩区', '喷水池', '多劳多得,月结'], - ['张店长', '超市理货员(兼职)', '每天下午 4 点到 9 点,负责补货和整理货架。适合学生和宝妈,工资周结。', 18, '元/小时', '南明区', '花果园', '兼职,周结'], - ['刘站长', '外卖骑手(可日结)', '自带电动车优先,站点提供充电和休息区。跑单时间自由,多跑多得。', 6000, '元/月', '观山湖区', '世纪城', '日结,时间自由'], - ['陈老师', '幼儿园保育员', '负责班级卫生和孩子日常照料,有保育员证优先。周末双休,包午餐。', 3200, '元/月', '花溪区', '贵州大学附近', '双休,包午餐'], - ['吴经理', '汽车销售顾问', '负责到店客户接待和车型介绍,有销售经验优先。底薪加提成,上不封顶。', 5000, '元/月', '观山湖区', '金阳客车站旁', '底薪加提成,五险'], - ], - // 房产租售 - 2 => [ - ['刘经理', '整租·未来方舟精装一室一厅', '家电齐全,拎包入住,近地铁 2 号线,押一付一,可短租。', 1800, '元/月', '南明区', '未来方舟 B 区', '精装,近地铁'], - ['王女士', '花果园两室一厅 南北通透', '中间楼层,采光好,家具家电全带,随时看房。', 2200, '元/月', '南明区', '花果园', '采光好,随时看房'], - ['周先生', '观山湖金融城写字楼出租', '80 平,独立办公室,带中央空调和前台,适合小团队。', 45, '元/㎡/月', '观山湖区', '金融城', '写字楼,带空调'], - ['李女士', '云岩区学区房出售 满五唯一', '两室一厅 78 平,划片实验小学,业主置换急售。', 98, '万元', '云岩区', '喷水池', '学区房,满五唯一'], - ['小杨', '花溪大学城单间合租', '独立卫生间,室友都是上班族,环境安静,押一付三。', 650, '元/月', '花溪区', '贵州大学北门', '合租,独卫'], - ['刘经理', '世纪城三室两厅 拎包入住', '130 平,电梯房,家电齐全,适合一家人住。', 2600, '元/月', '观山湖区', '世纪城', '电梯房,家电齐全'], - ['陈先生', '甲秀楼旁两室出售', '65 平,位置方便,出门就是菜场和公交站,价格可谈。', 62, '万元', '南明区', '甲秀楼', '位置好,可议价'], - ['小杨', '观山湖门面转让(可空转)', '临街 40 平,带基础装修,适合小吃和便利店,接手就能营业。', 3500, '元/月', '观山湖区', '奥体中心', '临街,可空转'], - ], - // 二手闲置 - 3 => [ - ['小周', '九成新山地自行车 转让', '骑行不到 300 公里,27 速,原价 1599,现 680 出,可小刀,同城自提。', 680, '元', '观山湖区', '世纪城', '自提,9成新'], - ['小王', 'iPad 2021 64G 深空灰', '学生自用,无磕碰,配件齐全,支持当面验机。', 1450, '元', '观山湖区', '贵阳奥体中心', '验机,无磕碰'], - ['小林', '实木餐桌椅 四人位', '搬家带不走,桌面无划痕,椅子结实,自提价。', 420, '元', '南明区', '花果园', '自提,实木'], - ['王姐', '美的双门冰箱 260L', '用了两年,制冷正常,因为换大冰箱所以出,可上门看货。', 550, '元', '云岩区', '喷水池', '可看货,制冷正常'], - ['小陈', '好孩子婴儿推车', '宝宝大了用不上,可坐可躺,折叠方便,清洗干净。', 180, '元', '花溪区', '花溪公园', '可折叠,已清洗'], - ['小杨', '联想拯救者游戏本', '16G 内存,512G 固态,打游戏不卡,电池健康,带原装充电器。', 3200, '元', '南明区', '花果园', '游戏本,带充电器'], - ['小周', '家用跑步机 九成新', '买来跑了不到十次,可折叠收纳,同城自提。', 800, '元', '观山湖区', '观山湖公园', '自提,可折叠'], - ['小王', '书架加办公桌一套', '租房换地方,便宜出,书桌 1.2 米,书架五层。', 200, '元', '观山湖区', '世纪城', '一套,便宜出'], - ], - // 车辆买卖 - 4 => [ - ['吴经理', '2019 款奥迪 A4L 一手车', '白色车身,车况好,无事故无泡水,4S 店保养记录齐全,支持第三方检测。', 12.8, '万元', '云岩区', '金阳客车站旁', '无事故,可检测'], - ['优选二手车', '城市代步小车 2022 款', '车身小巧好停车,续航够日常通勤,跑了 1.2 万公里,手续齐全。', 2.6, '万元', '观山湖区', '世纪城', '代步,手续齐全'], - ['吴经理', '雅马哈踏板摩托车', '125 排量,车况好,油耗低,带牌照一起出,可试车。', 4500, '元', '南明区', '花果园', '可试车,带牌'], - ['老李', '二手代步小车 省油好开', '个人一手车,1.5 排量,市区代步省油,保养刚做,可试车。', 1.8, '万元', '云岩区', '贵阳北站', '省油,可试车'], - ['吴经理', '宝马 4 系 2018 款', '蓝色车身,车况好,无事故,支持第三方检测,可分期。', 15.8, '万元', '观山湖区', '金融城', '无事故,可分期'], - ['优选二手车', '家用七座 SUV 车况好', '七座大空间,适合一家人出行,无事故,保养记录齐全,可检测。', 8.8, '万元', '南明区', '龙洞堡', '7座,可检测'], - ['小周', '电助力山地车', '36V 电池,助力续航 50 公里,上下班代步很省力。', 1600, '元', '观山湖区', '奥体中心', '助力,省力'], - ['老李', '宝马 3 系 2019 款', '车况好,内饰干净,无事故,支持第三方检测,可分期。', 12.8, '万元', '花溪区', '石板物流园', '无事故,可分期'], - ], - // 本地服务 - 5 => [ - ['王师傅', '专业空调清洗加氟上门', '10 年老师傅,清洗 60 元一台,加氟 80 元起,不满意不收费。', 60, '元/台', '南明区', '花果园', '上门,不满意不收费'], - ['陈师傅', '开锁换锁 24 小时上门', '防盗门、汽车锁都能开,换锁芯明码标价,半夜也能来。', 80, '元起', '云岩区', '喷水池', '24小时,明码标价'], - ['小杨', '电脑维修 重装系统', '蓝屏、开不了机、清灰换硅脂都能修,先检测后报价。', 50, '元起', '南明区', '花果园', '先检测,上门'], - ['陈师傅', '管道疏通 高压清洗', '厨房下水、马桶、地漏疏通,不通不收费,节假日不休。', 100, '元起', '云岩区', '黔灵山公园', '不通不收费,节假日不休'], - ['王师傅', '搬家拉货 小面包车', '市区内 120 元起,可帮忙搬下楼,费用提前说清楚。', 120, '元起', '观山湖区', '世纪城', '搬家,价格透明'], - ['陈师傅', '防水补漏 免费上门检测', '屋顶、卫生间、外墙漏水都能做,先看现场再报价,质保两年。', 0, '面议', '花溪区', '花溪公园', '免费检测,质保两年'], - ['小林', '摄影跟拍 婚礼活动', '婚礼、生日、毕业照都能拍,当天出精修图,可约周末。', 800, '元/场', '观山湖区', '观山湖公园', '跟拍,当天出图'], - ['小杨', '酒后代驾 市区内', '晚上 8 点到凌晨 2 点接单,市区 39 元起,安全送到家。', 39, '元起', '南明区', '甲秀楼', '夜间,安全送达'], - ], - // 教育培训 - 6 => [ - ['李老师', '少儿美术体验课 9.9 元', '4 到 12 岁都能来,画材全包,先体验再决定,周末可约。', 9.9, '元', '观山湖区', '世纪城', '体验课,画材全包'], - ['张老师', '初三数学一对一辅导', '在职老师,带过三届毕业班,先测水平再定计划,可上门。', 150, '元/课时', '南明区', '花果园', '一对一,可上门'], - ['招生办', '成人高考报名咨询', '大专本科都能报,流程有人指导,考前有资料,不收中介费。', 0, '面议', '云岩区', '喷水池', '学历提升,免费咨询'], - ['阿杰老师', '吉他尤克里里 零基础班', '小班教学,一人一把琴,学会三首歌再续课,晚上和周末上课。', 80, '元/课时', '观山湖区', '奥体中心', '零基础,小班'], - ['李老师', '少儿编程体验课', '图形化编程入门,边玩边学,适合 7 到 12 岁,电脑教室上课。', 19.9, '元', '花溪区', '花溪公园', '体验课,边玩边学'], - ['招生办', '普通话考级培训', '针对二乙二甲考试,小班纠音,考前模拟测试,不过可重听。', 680, '元', '云岩区', '黔灵山公园', '考级,可重听'], - ['王教练', '驾校 C1 报名', '本地练车,随到随学,科目一到科目四全程带,费用一次交清。', 2680, '元', '南明区', '龙洞堡', '一费制,随到随学'], - ['张老师', '英语四六级冲刺班', '每周三次晚课,讲真题和作文模板,适合基础一般的大学生。', 499, '元', '花溪区', '贵州大学', '冲刺班,真题讲解'], - ], - // 家政保洁 - 7 => [ - ['陈姐', '日常保洁 3 小时', '扫地拖地、擦灰、厨房卫生间清洁,自带工具,满意再付款。', 120, '元/次', '南明区', '花果园', '自带工具,满意付款'], - ['王师傅', '新房开荒保洁', '装修后除尘除胶,玻璃和柜子里外都擦,按面积算,量大优惠。', 5, '元/㎡', '观山湖区', '世纪城', '开荒,按面积'], - ['陈姐', '月嫂育儿嫂 可预约', '带过 30 多个宝宝,会做月子餐,有健康证,可先面试。', 8000, '元/月', '云岩区', '喷水池', '有健康证,可面试'], - ['王师傅', '油烟机洗衣机深度清洗', '拆洗不伤机器,清洗完当场试机,两台一起更划算。', 199, '元', '南明区', '花果园', '拆洗,当场试机'], - ['陈姐', '钟点工 做饭打扫', '每天两小时,做饭加简单收拾,口味可以按家里习惯来。', 35, '元/小时', '观山湖区', '金融城', '做饭,按小时'], - ['王师傅', '高层玻璃清洗', '内外都能擦,带安全绳,雨天不接单,提前一天预约。', 150, '元起', '观山湖区', '奥体中心', '高层,带安全绳'], - ['陈姐', '老人陪护 住家', '照顾能自理的老人,做饭陪着说话,有护理经验,可长期。', 4500, '元/月', '花溪区', '花溪公园', '住家,有护理经验'], - ['王师傅', '地毯清洗 上门', '公司办公室和家庭地毯都能洗,洗完当天能干,按平方计价。', 8, '元/㎡', '云岩区', '贵阳北站', '上门,当天干'], - ], - // 同城交友 - 8 => [ - ['小林', '周末爬山搭子 黔灵山', '周六早上 8 点黔灵山门口集合,走慢线,新手也能来,自带水。', 0, '面议', '云岩区', '黔灵山公园', '爬山,新手友好'], - ['小周', '羽毛球约球 每周三晚', '观山湖体育馆,水平一般,主要活动身体,场地费 AA。', 0, '面议', '观山湖区', '奥体中心', '羽毛球,AA'], - ['小杨', '贵阳本地读书会 招募', '每月一本书,线下聊两小时,不限年龄职业,安静参与就好。', 0, '面议', '南明区', '甲秀楼', '读书会,每月一次'], - ['小林', '90 后女生 找运动搭子', '平时跑步、骑车都可以,周末有空,希望住得近一点,互相监督。', 0, '面议', '观山湖区', '观山湖公园', '运动,住得近'], - ['小周', '摄影爱好者 周末扫街', '喜欢拍老街和人文,器材不限,手机也能一起玩,拍完一起吃饭。', 0, '面议', '云岩区', '喷水池', '摄影,手机也行'], - ['小杨', '骑行群招募 周末环湖', '休闲骑,速度不快,观山湖集合,骑完喝茶,安全第一。', 0, '面议', '观山湖区', '观山湖公园', '骑行,休闲'], - ['小林', '桌游局 缺 2 人', '周六下午玩狼人杀和剧本,新手有人带,场地在花果园,费用 AA。', 0, '面议', '南明区', '花果园', '桌游,新手可来'], - ['小周', '单身青年联谊活动 报名', '25 到 35 岁,工作稳定,活动有游戏和自由聊天,提前报名。', 0, '面议', '南明区', '花果园', '联谊,提前报名'], - ], - ]; - } - - /** 评价:post_id => [用户ID, 评分, 内容, 商家回复] */ - protected function seedComments($now) - { - $comments = [ - 101 => [[1002, 5, '联系很及时,当天就上岗了。', ''], [2003, 5, '工资日结是真的,站长人也不错。', '']], - 102 => [[1003, 5, '店里氛围挺好,排班也合理。', '']], - 109 => [[1003, 4, '房子不错,房东好说话。', '感谢支持,欢迎续租~']], - 110 => [[1002, 5, '采光确实好,看房很方便。', '']], - 117 => [[2006, 5, '车况和描述一致,交易很顺利。', ''], [1001, 4, '卖家很爽快,还送了车锁。', '']], - 118 => [[2003, 5, '当面验过机,没有问题。', '']], - 125 => [[2003, 5, '检测报告齐全,买得放心。', '']], - 126 => [[1002, 4, '代步够用,充电也方便。', '']], - 133 => [[1001, 5, '师傅上门很准时,清洗得很干净。', '感谢认可,有需要随时联系。']], - 134 => [[2006, 5, '半夜叫的开锁,十分钟就到了。', '']], - 141 => [[1003, 5, '孩子很喜欢,老师也有耐心。', '']], - 142 => [[2003, 4, '讲得清楚,孩子愿意学。', '']], - 149 => [[1001, 5, '打扫得仔细,下次还约。', '']], - 150 => [[1003, 5, '开荒做得很干净,柜子里面都擦了。', '']], - 157 => [[1003, 5, '上周去过,路线轻松,人也很好。', '']], - 158 => [[1001, 5, '水平差不多,打得很尽兴。', '']], - ]; - - $id = 5001; - $i = 0; - foreach ($comments as $postId => $rows) { - foreach ($rows as $row) { - $t = $now - (int)((0.4 + $i * 0.23) * 86400); - Db::name('fc_comment')->insert([ - 'id' => $id, - 'post_id' => $postId, - 'user_id' => $row[0], - 'score' => $row[1], - 'content' => $row[2], - 'images' => json_encode([], JSON_UNESCAPED_UNICODE), - 'reply' => $row[3], - 'replytime' => $row[3] !== '' ? $t + 3600 : null, - 'top' => 0, - 'status' => 1, - 'createtime' => $t, - 'updatetime' => $t, - ]); - $id++; - $i++; - } - } - } - - /** 草稿:保留两条未完成信息,用于验证草稿箱 */ - protected function seedDrafts($now) - { - $rows = [ - [1001, 2, '转租单间(草稿)', '房子还在收拾,晚点补照片。'], - [1003, 7, '保洁套餐(草稿)', '想做一个三次卡的套餐,价格还没定。'], - ]; - $t = $now - 7200; - foreach ($rows as $row) { - Db::name('fc_draft')->insert([ - 'user_id' => $row[0], - 'category_id' => $row[1], - 'title' => $row[2], - 'content' => $row[3], - 'media' => json_encode([], JSON_UNESCAPED_UNICODE), - 'contact' => '', - 'phone' => '', - 'address' => '', - 'savedat' => $t, - 'createtime' => $t, - 'updatetime' => $t, - ]); - $t -= 1800; - } - } - - /** 举报:两条不同状态,便于后台处理演示 */ - protected function seedReports($now) - { - $rows = [ - ['post', 117, '价格与描述不一致', 1002, 'pending', '', 0.6], - ['comment', 5002, '评论里留了外部联系方式', 2006, 'handled', '已删除违规评论', 3], - ]; - foreach ($rows as $row) { - $t = $now - (int)($row[6] * 86400); - Db::name('fc_report')->insert([ - 'target_type' => $row[0], - 'target_id' => $row[1], - 'reason' => $row[2], - 'reporter_id' => $row[3], - 'status' => $row[4], - 'remark' => $row[5], - 'createtime' => $t, - 'updatetime' => $t, - ]); - } - } - - /** 回写分类信息量 / 评价数,保证列表计数与真实数据一致 */ - protected function syncCounts() - { - Db::execute('UPDATE `' . \think\Config::get('database.prefix') . 'fc_post` p SET p.`comment_count` = (SELECT COUNT(*) FROM `' . \think\Config::get('database.prefix') . 'fc_comment` c WHERE c.`post_id` = p.`id` AND c.`status` = 1)'); - - foreach ([1, 2, 3, 4, 5, 6, 7, 8] as $categoryId) { - $count = Db::name('fc_post') - ->where('category_id', $categoryId) - ->where('audit_status', 1) - ->where('status', 1) - ->whereNull('deletetime') - ->count(); - Db::name('fc_category')->where('id', $categoryId)->update(['post_count' => $count]); - } - - // 同步商家有效发帖数 - $merchants = Db::name('fc_merchant')->field('id,user_id')->select(); - foreach ($merchants as $m) { - $mCount = Db::name('fc_post') - ->where('user_id', $m['user_id']) - ->where('audit_status', 1) - ->where('status', 1) - ->whereNull('deletetime') - ->count(); - Db::name('fc_merchant')->where('id', $m['id'])->update(['post_count' => $mCount]); - } - } -} diff --git a/application/admin/lang/zh-cn/fc/plan.php b/application/admin/lang/zh-cn/fc/plan.php index 503ae87..523fa63 100644 --- a/application/admin/lang/zh-cn/fc/plan.php +++ b/application/admin/lang/zh-cn/fc/plan.php @@ -8,7 +8,9 @@ return [ 'Origin_price' => '原价', 'Days' => '有效天数', 'Post_quota' => '赠送发布条数', - 'Free_view_quota' => '每日免费看', + 'Free_view_quota' => '每日免费看(废弃)', + 'View_quota' => '每日浏览条数', + 'Call_quota' => '每日拨打次数', 'Post_discount' => '超额发帖折扣', 'Tag' => '标签', 'Sort' => '排序', diff --git a/application/admin/view/fc/category/add.html b/application/admin/view/fc/category/add.html index 364e977..a1e260e 100644 --- a/application/admin/view/fc/category/add.html +++ b/application/admin/view/fc/category/add.html @@ -372,12 +372,12 @@ 挑选果蔬实拍图 - + 上传实拍图 - 优先上传真实农产品照片,农民用户靠「看图识物」选择品类。 + 优先上传真实农产品照片,农民用户靠「看图识物」选择品类;建议 500×500 以上方形图,单张不超过 500KB,过大的图片会拖慢小程序加载。 diff --git a/application/admin/view/fc/category/edit.html b/application/admin/view/fc/category/edit.html index cf01ea6..498b829 100644 --- a/application/admin/view/fc/category/edit.html +++ b/application/admin/view/fc/category/edit.html @@ -372,12 +372,12 @@ 挑选果蔬实拍图 - + 上传实拍图 - 优先上传真实农产品照片,农民用户靠「看图识物」选择品类。 + 优先上传真实农产品照片,农民用户靠「看图识物」选择品类;建议 500×500 以上方形图,单张不超过 500KB,过大的图片会拖慢小程序加载。 diff --git a/application/admin/view/fc/plan/add.html b/application/admin/view/fc/plan/add.html index 21ff336..b77806f 100644 --- a/application/admin/view/fc/plan/add.html +++ b/application/admin/view/fc/plan/add.html @@ -50,13 +50,23 @@ - {:__('Free_view_quota')}: + {:__('View_quota')}: - + 条 / 日 - 会员每日免费查看联系方式条数(填 0 表示无限次免费查看) + 会员每日免费浏览完整信息条数(填 0 表示不限次浏览) + + + + {:__('Call_quota')}: + + + + 次 / 日 + + 会员每日拨打电话次数(填 0 表示不限次拨打;普通用户次数在「付费设置」中配置) diff --git a/application/admin/view/fc/plan/edit.html b/application/admin/view/fc/plan/edit.html index c0abaca..e50fd88 100644 --- a/application/admin/view/fc/plan/edit.html +++ b/application/admin/view/fc/plan/edit.html @@ -50,13 +50,23 @@ - {:__('Free_view_quota')}: + {:__('View_quota')}: - + 条 / 日 - 会员每日免费查看联系方式条数(填 0 表示无限次免费查看) + 会员每日免费浏览完整信息条数(填 0 表示不限次浏览) + + + + {:__('Call_quota')}: + + + + 次 / 日 + + 会员每日拨打电话次数(填 0 表示不限次拨打;普通用户次数在「付费设置」中配置) diff --git a/application/admin/view/fc/publish/index.html b/application/admin/view/fc/publish/index.html index 73561fc..0af9e50 100644 --- a/application/admin/view/fc/publish/index.html +++ b/application/admin/view/fc/publish/index.html @@ -95,7 +95,7 @@ - + 浏览模式: @@ -103,30 +103,30 @@ - 完全免费公开(详情页直接明文展示手机号与微信,支持一键拨打) + 完全免费公开(完整信息对所有人直接展示,浏览不限次) - 付费或会员解锁(默认打码保护隐私,需单次付费或使用 VIP 免费看额度解锁) + 付费或会员解锁(非会员仅展示摘要,单条付费解锁;会员按套餐每日免费浏览) - 当前为「完全免费公开」:所有帖子电话与微信号零门槛直显,无需解锁。 + 当前为「完全免费公开」:完整信息零门槛直显,会员浏览额度自动休眠不计数。 - 非会员单次查看价: + 非会员单条解锁价: - 元 / 次 + 元 / 条 - 普通用户超额后解锁单条帖子拨打权限的价格(同一条信息一次解锁永久有效) + 非会员解锁单条完整信息的价格(一次解锁永久有效;填 0 等同免费公开模式) @@ -142,20 +142,7 @@ - 详情页与商家详情页统一隐藏明文手机号,仅保留拨打按钮;填 0 表示不限次数 - - - - - VIP会员每日拨打上限: - - - - 次 / 日 - - - - VIP 会员每日可拨打电话次数上限(填 0 表示 VIP 会员不限拨打次数) + 仅对非会员生效;手机号任何模式都不展示,只保留拨打按钮;填 0 表示不限次数。会员拨打次数请在「会员套餐」中按套餐配置 diff --git a/application/api/controller/AdminMember.php b/application/api/controller/AdminMember.php index 2c2c58d..bef9501 100644 --- a/application/api/controller/AdminMember.php +++ b/application/api/controller/AdminMember.php @@ -57,10 +57,10 @@ class AdminMember extends V1 } /** - * AMB2 会员权益配置 - * POST /api/v1/admin/member/benefit 入参 {freeView,postDiscount,distRate,benefits[]} - * → {freeView,postDiscount,distRate,benefits[]} - * 未被传入的字段保持原值;freeView <0 报错 + * AMB2 会员权益配置(兜底默认值;各套餐的浏览/拨打次数以 fc_plan 为准) + * POST /api/v1/admin/member/benefit 入参 {viewQuota,callQuota,postDiscount,distRate,benefits[]} + * → {viewQuota,callQuota,postDiscount,distRate,benefits[]} + * 未被传入的字段保持原值;次数 <0 报错 */ public function saveBenefit() { @@ -69,12 +69,23 @@ class AdminMember extends V1 $current = []; } - $freeView = $this->input('freeView', null); - if ($freeView !== null && $freeView !== '') { - if ((int)$freeView < 0) { - $this->fail('免费查看条数不能为负'); + $viewQuota = $this->input('viewQuota', null); + if ($viewQuota === null) { + $viewQuota = $this->input('freeView', null); // 兼容旧字段 + } + if ($viewQuota !== null && $viewQuota !== '') { + if ((int)$viewQuota < 0) { + $this->fail('每日浏览条数不能为负'); } - $current['freeView'] = (int)$freeView; + $current['viewQuota'] = (int)$viewQuota; + } + + $callQuota = $this->input('callQuota', null); + if ($callQuota !== null && $callQuota !== '') { + if ((int)$callQuota < 0) { + $this->fail('每日拨打次数不能为负'); + } + $current['callQuota'] = (int)$callQuota; } $postDiscount = $this->input('postDiscount', null); @@ -101,7 +112,8 @@ class AdminMember extends V1 } $defaults = [ - 'freeView' => 20, + 'viewQuota' => 20, + 'callQuota' => 30, 'postDiscount' => '8 折', 'distRate' => '一级 20% / 二级 10%', 'benefits' => [], @@ -112,7 +124,8 @@ class AdminMember extends V1 $this->adminLog('修改会员权益配置'); $this->ok([ - 'freeView' => (int)$current['freeView'], + 'viewQuota' => (int)$current['viewQuota'], + 'callQuota' => (int)$current['callQuota'], 'postDiscount' => (string)$current['postDiscount'], 'distRate' => (string)$current['distRate'], 'benefits' => array_values((array)$current['benefits']), diff --git a/application/api/controller/Member.php b/application/api/controller/Member.php index 20f8dad..87b9891 100644 --- a/application/api/controller/Member.php +++ b/application/api/controller/Member.php @@ -17,7 +17,7 @@ use app\common\library\DistributionService; class Member extends V1 { /** - * M1 会员套餐(套餐价格 + 赠送发帖数 + 每日免费看次数 + 超额发帖折扣 一体化返回) + * M1 会员套餐(套餐价格 + 赠送发帖数 + 每日浏览/拨打次数 + 超额发帖折扣 一体化返回) * GET /api/v1/member/plans * 规则:仅 enabled=1,按 sort asc */ @@ -30,7 +30,8 @@ class Member extends V1 $list = []; foreach ($rows as $row) { - $freeViewQuota = isset($row['free_view_quota']) ? max(0, (int)$row['free_view_quota']) : 20; + $viewQuota = isset($row['view_quota']) ? max(0, (int)$row['view_quota']) : 20; + $callQuota = isset($row['call_quota']) ? max(0, (int)$row['call_quota']) : 30; $discountNum = isset($row['post_discount']) ? \app\common\library\FcConfig::parseDiscountNum($row['post_discount']) : 8.0; $list[] = [ 'planId' => (string)$row['plan_code'], @@ -40,8 +41,10 @@ class Member extends V1 'days' => (int)$row['days'], 'tag' => (string)$row['tag'], 'postQuota' => isset($row['post_quota']) ? (int)$row['post_quota'] : 0, - 'freeViewQuota' => $freeViewQuota, - 'freeViewUnlimited' => ($freeViewQuota === 0), + 'viewQuota' => $viewQuota, + 'viewUnlimited' => ($viewQuota === 0), + 'callQuota' => $callQuota, + 'callUnlimited' => ($callQuota === 0), 'postDiscountNum' => $discountNum, 'postDiscount' => \app\common\library\FcConfig::formatDiscountText($discountNum), 'benefits' => \app\common\library\FcConfig::buildPlanBenefits($row), @@ -61,8 +64,10 @@ class Member extends V1 $ext = $me ? $this->ext($me['id']) : null; $benefitCfg = \app\common\library\FcConfig::resolveMemberBenefit($ext); - $freeViewQuota = (int)$benefitCfg['freeView']; - $freeViewUnlimited = ($freeViewQuota === 0); + $viewQuota = (int)$benefitCfg['viewQuota']; + $viewUnlimited = ($viewQuota === 0); + $callQuota = (int)$benefitCfg['callQuota']; + $callUnlimited = ($callQuota === 0); $postDiscountNum = (float)$benefitCfg['postDiscountNum']; $postDiscount = (string)$benefitCfg['postDiscount']; $benefits = (array)$benefitCfg['benefits']; @@ -79,8 +84,10 @@ class Member extends V1 $viewMode = ($viewModeRaw === 'free' || $payViewPrice <= 0) ? 'free' : 'paid'; $isMember = false; - $freeViewUsed = 0; - $freeViewLeft = 0; + $viewUsed = 0; + $viewLeft = 0; + $callUsed = 0; + $callLeft = 0; $postQuotaLeft = 0; $memberExpireAt = null; @@ -89,15 +96,27 @@ class Member extends V1 $isMember = (int)$ext['is_member'] === 1 && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $today = date('Y-m-d'); - $freeViewUsed = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0; - $freeViewLeft = $isMember ? ($freeViewUnlimited ? -1 : max(0, $freeViewQuota - $freeViewUsed)) : 0; + if ($isMember) { + $viewUsed = (int)Db::name('fc_view_log') + ->where('user_id', (int)$me['id']) + ->where('view_date', $today) + ->count(); + $viewLeft = $viewUnlimited ? -1 : max(0, $viewQuota - $viewUsed); + $callUsed = (int)Db::name('fc_call_log') + ->where('user_id', (int)$me['id']) + ->where('call_date', $today) + ->count(); + $callLeft = $callUnlimited ? -1 : max(0, $callQuota - $callUsed); + } $postQuotaLeft = $this->quotaLeft($ext); $memberExpireAt = !empty($ext['member_expiretime']) ? $this->iso($ext['member_expiretime']) : null; } $this->ok([ - 'freeView' => $freeViewQuota, - 'freeViewUnlimited' => $freeViewUnlimited, + 'viewQuota' => $viewQuota, + 'viewUnlimited' => $viewUnlimited, + 'callQuota' => $callQuota, + 'callUnlimited' => $callUnlimited, 'postDiscountNum' => $postDiscountNum, 'postDiscount' => $postDiscount, 'maxPlanQuota' => $maxPlanQuota, @@ -105,9 +124,10 @@ class Member extends V1 'viewMode' => $viewMode, 'benefits' => $benefits, 'isMember' => $isMember, - 'freeViewQuota' => $freeViewQuota, - 'freeViewUsed' => $freeViewUsed, - 'freeViewLeft' => $freeViewLeft, + 'viewUsed' => $viewUsed, + 'viewLeft' => $viewLeft, + 'callUsed' => $callUsed, + 'callLeft' => $callLeft, 'postQuotaLeft' => $postQuotaLeft, 'memberExpireAt' => $memberExpireAt, ]); @@ -276,7 +296,6 @@ class Member extends V1 } $days = $plan ? max(0, (int)$plan['days']) : 30; $quota = $plan && isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0; - $freeViewQuota = $plan && isset($plan['free_view_quota']) ? max(0, (int)$plan['free_view_quota']) : 20; $postDiscount = $plan && isset($plan['post_discount']) ? round((float)$plan['post_discount'], 1) : 8.0; $level = $plan ? (string)$plan['name'] : '会员'; @@ -299,7 +318,6 @@ class Member extends V1 'is_member' => 1, 'member_level' => $level, 'member_expiretime' => $expire, - 'free_view_quota' => $freeViewQuota, 'post_quota_left' => $left, 'post_quota_expire' => $expire, 'post_discount' => $postDiscount, @@ -415,13 +433,18 @@ class Member extends V1 ]; } + $ext = $this->ext($user['id']); + $isMember = (int)$ext['is_member'] === 1 + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; + $this->ok([ 'balance' => (float)$user['money'], + 'isMember' => $isMember, 'coupons' => $coupons, 'couponCount' => count($coupons), 'points' => (int)$user['score'], 'payPasswordEnabled' => (int)$this->cfg('pay.payPasswordEnabled', 0) === 1, - 'hasPayPassword' => (string)$this->ext($user['id'])['pay_password'] !== '', + 'hasPayPassword' => (string)$ext['pay_password'] !== '', ]); } diff --git a/application/api/controller/Post.php b/application/api/controller/Post.php index dc84beb..f37431b 100644 --- a/application/api/controller/Post.php +++ b/application/api/controller/Post.php @@ -922,23 +922,7 @@ class Post extends V1 } /** - * 详情页联系方式可见性(避免「已开通会员仍显示已受保护」) - * - * 可见规则(与 payView 计费口径一致): - * ① 会员有效期内 → 免费看 - * ② 已付费解锁 fc_pay_view → 看(余额 / 微信回调写入) - * ③ 本人发布的信息 → 看 - * 其它(游客 / 非会员未付费)→ 仍返回脱敏串 - * - * 出参新增:unlocked(bool)、wechat(明文,未解锁为空串)。 - * 注意:只加在详情接口,列表(首页/收藏等)仍保持脱敏,避免手机号被批量抓取。 - * - * @param array $data buildPost() 结果 - * @param array $post fc_post 行 - * @return array - */ - /** - * 判断当前全站是否处于「联系方式完全免费公开」模式 + * 判断当前全站是否处于「完整信息免费公开」模式 * 规则:fee.viewMode === 'free' 或 fee.payView <= 0 时均为免费公开 * * @return bool @@ -951,13 +935,17 @@ class Post extends V1 } /** - * 详情页联系方式与内容访问控制(支持完全免费公开模式与商业付费/会员解锁模式自由切换) + * 详情页完整信息访问控制(免费公开 / 付费解锁 / 会员每日额度 三态自由切换) * - * 可见规则: - * ① 全站处于「完全免费公开」模式(fee.viewMode=free 或 fee.payView<=0)→ unlocked=true,直接明文返回 - * ② 本人发布的信息或已付费解锁记录(fc_pay_view)→ unlocked=true,返回真实联系方式明文 - * ③ 会员享有每日免费查看额度(freeView=0 代表会员不限次数免费看),未点击解锁时不泄露真实号码 - * ④ 未解锁状态下,对标题与详细正文中的手机号执行掩码遮蔽,封堵正文直接贴电话的白嫖漏洞 + * 联系方式规则:任何模式下界面都不展示手机号(明文与脱敏串均不返回),仅保留拨打按钮, + * 由 POST /post/:id/dial 校验拨打额度后调起系统拨号。 + * + * 完整信息(正文/农产品规格)规则: + * ① 免费公开模式(fee.viewMode=free 或 fee.payView<=0)→ 直接放行 + * ② 本人发布 或 已付费解锁(fc_pay_view,永久)→ 放行 + * ③ 会员:每日免费浏览额度(fc_plan.view_quota,0=不限),同一条当日只计 1 次 + * ④ 额度用尽 / 非会员未解锁 → content 仅返回摘要,contentLocked=true + * 防白嫖:标题与正文中出现的手机号始终动态遮蔽。 * * @param array $data buildPost() 结果 * @param array $post fc_post 行 @@ -965,70 +953,57 @@ class Post extends V1 */ protected function contactAccess($data, $post) { - $data['unlocked'] = false; - $data['wechat'] = ''; - $me = $this->user(); $ext = $me ? $this->ext($me['id']) : null; $now = time(); + $userId = $me ? (int)$me['id'] : 0; + $isMember = false; - $quota = $this->memberFreeViewQuota($ext); - $freeViewUnlimited = ($quota === 0); - $used = 0; - $freeViewLeft = 0; - $payViewPrice = max(0, round((float)$this->cfg('fee.payView', 2), 2)); - $isFreeView = $this->isFreeViewMode(); - - $isOwner = false; - $paid = false; - if ($me && $ext) { $isMember = (int)$ext['is_member'] === 1 && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; - $isOwner = (int)$post['user_id'] === (int)$me['id']; - $paid = (bool)Db::name('fc_pay_view') - ->where('user_id', $me['id']) - ->where('post_id', $post['id']) - ->find(); + } - $today = date('Y-m-d'); - $used = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0; - if ($isMember) { - $freeViewLeft = $freeViewUnlimited ? 999 : max(0, $quota - $used); - } else { - $freeViewLeft = 0; + $isFreeView = $this->isFreeViewMode(); + $payViewPrice = max(0, round((float)$this->cfg('fee.payView', 2), 2)); + $isOwner = $userId > 0 && (int)$post['user_id'] === $userId; + + // ---- 已付费解锁记录(永久解锁,任何模式不占每日额度) ---- + $paid = false; + if ($userId > 0) { + $paid = (bool)Db::name('fc_pay_view') + ->where('user_id', $userId) + ->where('post_id', (int)$post['id']) + ->find(); + } + + // ---- 完整信息解锁判定 ---- + $unlocked = false; + $viewState = null; // 会员付费模式下的浏览额度状态(含本次消耗后的最新值) + if ($isFreeView || $isOwner || $paid) { + $unlocked = true; + } elseif ($isMember) { + // 会员每日浏览额度(view_quota=0 代表不限),同一条当日只计 1 次 + $viewState = $this->viewQuotaState($userId, $ext); + if ($this->hasViewedToday($userId, (int)$post['id'])) { + $unlocked = true; // 今日已浏览过该条,不重复扣额度 + } elseif ($viewState['unlimited'] || $viewState['left'] > 0) { + $this->recordViewLog($userId, (int)$post['id']); + $viewState['used'] += 1; + $viewState['left'] = $viewState['unlimited'] ? 999 : max(0, $viewState['total'] - $viewState['used']); + $unlocked = true; } } - $data['viewMode'] = $isFreeView ? 'free' : 'paid'; - $data['isMember'] = $isMember; - $data['freeViewQuota'] = $quota; - $data['freeViewUnlimited'] = $freeViewUnlimited; - $data['freeViewUsed'] = $used; - $data['freeViewLeft'] = $freeViewLeft; - $data['payViewPrice'] = $isFreeView ? 0 : $payViewPrice; + $contentLocked = !$unlocked; - // 每日电话拨打次数上限与剩余额度(详情预览页隐藏明文手机号,仅保留免责声明弹窗拨打) - $callState = $this->callQuotaState($me ? (int)$me['id'] : 0, $isMember); - $rawPhone = (string)$post['phone']; - $contactName = (string)(isset($post['contact']) ? $post['contact'] : ''); - $maskedPhone = $this->maskPhone($rawPhone); + // ---- 正文与农产品规格锁定 ---- + if ($contentLocked) { + $data['content'] = $this->contentPreview((string)$data['content'], 100); + unset($data['plantArea'], $data['harvestTime'], $data['outputRange']); + } - $data['hasPhone'] = $rawPhone !== ''; - $data['dialDisplay'] = $this->buildDialDisplay($contactName, $rawPhone); - $data['callQuotaTotal'] = $callState['total']; - $data['callQuotaUsed'] = $callState['used']; - $data['callQuotaLeft'] = $callState['left']; - $data['canDial'] = $isOwner || $paid || $callState['unlimited'] || $callState['left'] > 0; - - // 界面上始终隐藏明文手机号(仅返回脱敏号码),通过 POST /post/:id/dial 校验额度并调起系统拨号 - $data['unlocked'] = ($isFreeView || $isOwner || $paid); - $data['phone'] = $maskedPhone; - $data['contact'] = $maskedPhone; - $data['contactName'] = $contactName; - $data['wechat'] = ''; - - // 标题与详细描述中的任何手机号执行动态安全遮蔽,防止正文直接贴电话 + // 标题与正文中出现的手机号始终遮蔽,防止正文贴电话绕过限制 if (!empty($data['content'])) { $data['content'] = $this->maskTextPhones($data['content']); } @@ -1036,39 +1011,138 @@ class Post extends V1 $data['title'] = $this->maskTextPhones($data['title']); } + // ---- 拨打额度状态 ---- + $callState = $this->callQuotaState($userId, $isMember); + + $data['unlocked'] = $unlocked; + $data['contentLocked'] = $contentLocked; + $data['viewMode'] = $isFreeView ? 'free' : 'paid'; + $data['payViewPrice'] = $isFreeView ? 0 : $payViewPrice; + $data['isMember'] = $isMember; + + // 会员每日浏览额度(免费公开模式不展示、不计数) + $data['viewQuotaTotal'] = $viewState ? $viewState['total'] : 0; + $data['viewQuotaUsed'] = $viewState ? $viewState['used'] : 0; + $data['viewQuotaLeft'] = $viewState ? $viewState['left'] : 0; + $data['viewQuotaUnlimited'] = $viewState ? $viewState['unlimited'] : false; + + $data['hasPhone'] = (string)$post['phone'] !== ''; + // 任何模式均不返回手机号(明文与脱敏串都不给),界面只保留拨打按钮, + // 由 POST /post/:id/dial 校验拨打额度后调起系统拨号 + $data['phone'] = ''; + $data['contact'] = (string)(isset($post['contact']) ? $post['contact'] : ''); + $data['contactName'] = (string)(isset($post['contact']) ? $post['contact'] : ''); + $data['callQuotaTotal'] = $callState['total']; + $data['callQuotaUsed'] = $callState['used']; + $data['callQuotaLeft'] = $callState['left']; + $data['canDial'] = $isOwner || $paid || $callState['unlimited'] || $callState['left'] > 0; + return $data; } /** - * 构造免责声明拨打弹窗展示文案(如:夏178****8426) + * 计算会员今日的完整信息浏览额度状态(真源:fc_plan.view_quota;0 代表不限) + * 是否今日已浏览过某条 → hasViewedToday 单独判断 * - * @param string $contactName 联系人姓名 - * @param string $phone 手机号 + * @param int $userId 用户ID + * @param array $ext fc_user_ext 行 + * @return array{total:int,used:int,left:int,unlimited:bool} + */ + protected function viewQuotaState($userId, $ext = null) + { + $benefit = \app\common\library\FcConfig::resolveMemberBenefit($ext); + $total = isset($benefit['viewQuota']) ? max(0, (int)$benefit['viewQuota']) : 20; + $unlimited = ($total === 0); + + $today = date('Y-m-d'); + $used = (int)Db::name('fc_view_log') + ->where('user_id', (int)$userId) + ->where('view_date', $today) + ->count(); + + return [ + 'total' => $total, + 'used' => $used, + 'left' => $unlimited ? 999 : max(0, $total - $used), + 'unlimited' => $unlimited, + ]; + } + + /** + * 记录一条完整信息浏览日志(同一条当日幂等,重复浏览不产生新记录) + * + * @param int $userId 用户ID + * @param int $postId 信息ID + * @return bool 是否成功写入新记录 + */ + protected function recordViewLog($userId, $postId) + { + $today = date('Y-m-d'); + $exist = Db::name('fc_view_log') + ->where('user_id', (int)$userId) + ->where('post_id', (int)$postId) + ->where('view_date', $today) + ->find(); + if ($exist) { + return false; + } + Db::name('fc_view_log')->insert([ + 'user_id' => (int)$userId, + 'post_id' => (int)$postId, + 'view_date' => $today, + 'createtime' => time(), + ]); + return true; + } + + /** + * 判断用户今日是否已浏览过指定信息 + * + * @param int $userId 用户ID + * @param int $postId 信息ID + * @return bool + */ + protected function hasViewedToday($userId, $postId) + { + return (bool)Db::name('fc_view_log') + ->where('user_id', (int)$userId) + ->where('post_id', (int)$postId) + ->where('view_date', date('Y-m-d')) + ->find(); + } + + /** + * 截取正文摘要(锁定态展示,保留首行主信息) + * + * @param string $content 正文 + * @param int $length 摘要长度(字符) * @return string */ - protected function buildDialDisplay($contactName, $phone) + protected function contentPreview($content, $length = 100) { - $masked = $this->maskPhone($phone); - $name = trim((string)$contactName); - if ($name === '') { - return $masked; + $text = trim((string)$content); + if ($text === '' || mb_strlen($text, 'UTF-8') <= $length) { + return $text; } - $surname = mb_substr($name, 0, 1, 'UTF-8'); - return $surname . $masked; + return mb_substr($text, 0, $length, 'UTF-8') . '…'; } /** * 计算指定用户今日的电话拨打额度状态 - * + * 会员:每日拨打次数真源 = 所属 fc_plan 套餐 call_quota(0 代表不限) + * 非会员:全局配置 call.normalDailyQuota(0 代表不限) * @param int $userId 用户ID(0表示游客) * @param bool $isMember 是否为有效期内VIP会员 * @return array{total:int,used:int,left:int,unlimited:bool} */ protected function callQuotaState($userId, $isMember) { - $normalQuota = max(0, (int)$this->cfg('call.normalDailyQuota', 5)); - $vipQuota = max(0, (int)$this->cfg('call.vipDailyQuota', 30)); - $total = $isMember ? $vipQuota : $normalQuota; + if ($isMember) { + $benefit = \app\common\library\FcConfig::resolveMemberBenefit(); + $total = isset($benefit['callQuota']) ? max(0, (int)$benefit['callQuota']) : 30; + } else { + $total = max(0, (int)$this->cfg('call.normalDailyQuota', 5)); + } $unlimited = ($total === 0); $used = 0; @@ -1091,7 +1165,7 @@ class Post extends V1 /** * P6-B 信息详情页申请拨打电话(校验每日拨打次数上限并记录拨打日志) * POST /api/v1/post/:id/dial - * → {phoneNumber, dialDisplay, callQuotaTotal, callQuotaUsed, callQuotaLeft} + * → {phoneNumber, callQuotaTotal, callQuotaUsed, callQuotaLeft} * * @return void */ @@ -1125,8 +1199,8 @@ class Post extends V1 // 非本人且未单条付费解锁时,校验每日拨打次数上限 if (!$isOwner && !$paid && !$state['unlimited'] && $state['left'] <= 0) { $msg = $isMember - ? ('您今日的 VIP 拨打次数已达上限(' . $state['total'] . '次/日)') - : ('您今日的免费拨打次数已达上限(' . $state['total'] . '次/日),开通 VIP 会员可享每日更多拨打次数'); + ? ('您今日的拨打次数已达上限(' . $state['total'] . '次/日),明日再来或升级更高套餐') + : ('您今日的免费拨打次数已达上限(' . $state['total'] . '次/日),开通会员可享更多拨打次数'); $this->fail($msg, 403); } @@ -1145,7 +1219,6 @@ class Post extends V1 $this->ok([ 'phoneNumber' => $phone, - 'dialDisplay' => $this->buildDialDisplay(isset($post['contact']) ? $post['contact'] : '', $phone), 'callQuotaTotal' => $state['total'], 'callQuotaUsed' => $newUsed, 'callQuotaLeft' => $newLeft, @@ -1153,56 +1226,11 @@ class Post extends V1 } /** - * 会员每日免费查看条数(从用户所属 fc_plan 套餐或默认套餐读取;0 代表会员无限次免费看) - * - * @param array|null $ext fc_user_ext 行 - * @return int - */ - protected function memberFreeViewQuota($ext = null) - { - $benefit = \app\common\library\FcConfig::resolveMemberBenefit($ext); - return isset($benefit['freeView']) ? max(0, (int)$benefit['freeView']) : 20; - } - - /** - * 消耗一次会员免费查看额度(跨天自动重置;quota=0 代表会员不限次数无限看;需在事务内调用) - * - * @param int $userId - * @param int $quota - * @return bool 是否成功占用 - */ - protected function consumeFreeView($userId, $quota) - { - $today = date('Y-m-d'); - $ext = Db::name('fc_user_ext')->where('user_id', (int)$userId)->lock(true)->find(); - if (!$ext) { - return false; - } - - if ((string)$ext['free_view_date'] !== $today) { - Db::name('fc_user_ext')->where('user_id', (int)$userId)->update([ - 'free_view_date' => $today, - 'free_view_used' => 1, - 'updatetime' => time(), - ]); - return true; - } - - if ($quota > 0 && (int)$ext['free_view_used'] >= $quota) { - return false; - } - - Db::name('fc_user_ext')->where('user_id', (int)$userId)->setInc('free_view_used', 1); - - return true; - } - - /** - * P6 付费查看联系方式 + * P6 付费解锁完整信息(单条永久解锁) * POST /api/v1/post/:id/pay-view {payType} - * → {unlocked:true,contact:{phone,wechat}}(免费公开/余额/会员免费:立即解锁) - * → {order:{orderNo,amount,payType,status:'pending'}}(微信支付:前端再调 POST /pay/prepay 调起收银台) - * 规则 BR-05;金额取 fee.payView;全站免费模式、会员额度内或已解锁免费 + * → {unlocked:true}(余额:立即解锁) + * → {order:{orderNo,amount,payType,status:'pending'}}(微信支付:前端再调 POST /pay/prepay 调起收银台,回调写解锁记录) + * 适用:付费模式下非会员,或会员每日浏览额度用尽后的单条解锁;免费公开模式无需解锁 */ public function payView() { @@ -1210,10 +1238,7 @@ class Post extends V1 $id = $this->routeId(); $payType = (string)$this->input('payType', ''); - if ($payType === '') { - $payType = 'free'; - } - if (!in_array($payType, ['wxpay', 'balance', 'free'], true)) { + if (!in_array($payType, ['wxpay', 'balance'], true)) { $this->fail('支付方式不支持'); } @@ -1222,128 +1247,94 @@ class Post extends V1 $this->fail('信息不存在', 404); } - $phone = (string)$post['phone']; - $wechat = $post['wechat'] !== '' ? (string)$post['wechat'] : ('tongcheng_' . $id); - $contactName = (string)(isset($post['contact']) ? $post['contact'] : ''); - - $contact = [ - 'name' => $contactName, - 'phone' => $phone, - 'wechat' => $wechat, - ]; - - $amount = max(0, round((float)$this->cfg('fee.payView', 2), 2)); $isFreeView = $this->isFreeViewMode(); - $now = time(); + if ($isFreeView) { + $this->ok(['unlocked' => true, 'freeMode' => true]); + } - $ext = $this->ext($user['id']); - $isMember = (int)$ext['is_member'] === 1 - && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; - $quota = $this->memberFreeViewQuota($ext); - $freeViewUnlimited = ($quota === 0); + $isOwner = (int)$post['user_id'] === (int)$user['id']; + if ($isOwner) { + $this->ok(['unlocked' => true, 'freeMode' => true]); + } - // 已解锁直接返回(严格返回纯净扁平字段,杜绝 JSON 嵌套导致前端格式错乱) + // 已解锁直接返回 $exist = Db::name('fc_pay_view') ->where('user_id', $user['id']) ->where('post_id', $id) ->find(); if ($exist) { - $today = date('Y-m-d'); - $usedToday = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0; - $this->ok([ - 'unlocked' => true, - 'phone' => $phone, - 'contact' => $contact, - 'contactName' => $contactName, - 'wechat' => $wechat, - 'contactInfo' => $contact, - 'freeViewUnlimited' => $freeViewUnlimited, - 'freeViewLeft' => $isMember ? ($freeViewUnlimited ? 999 : max(0, $quota - $usedToday)) : 0, - ]); + $this->ok(['unlocked' => true]); } + $amount = max(0, round((float)$this->cfg('fee.payView', 2), 2)); + $now = time(); $orderId = 0; $pending = null; - // 后台开启支付密码时,余额支付需校验(全站免费或会员免费额度未用完时走免费,不要求密码) - if (!$isFreeView && $payType === 'balance' && !$isMember) { + // 后台开启支付密码时,余额支付需校验 + if ($payType === 'balance') { $this->verifyPayPassword($user); } try { - Db::transaction(function () use (&$orderId, &$pending, $isFreeView, $isMember, $quota, $payType, $amount, $user, $id, $now) { - if ($isFreeView) { - // 全站免费浏览模式(或解锁单价设为 0 元):直接免单解锁,避免走 0 元支付死锁 - $amount = 0; - $payType = 'free'; - } else { - // 会员每日免费查看额度(quota=0 代表会员不限次数;当天用完后按正常付费) - $usedFree = $isMember ? $this->consumeFreeView((int)$user['id'], $quota) : false; - - if ($usedFree) { - $amount = 0; - $payType = 'free'; - } elseif ($payType === 'free') { - // 用户希望使用会员免费特权,但额度已耗尽或非会员 - throw new \RuntimeException($isMember ? '今日会员免费查看额度已用完,请选择支付方式付费查看' : '该内容需付费查看,请选择支付方式'); - } elseif ($payType === 'balance') { - // 条件扣款:并发下不会扣成负数 - $affected = Db::name('user') - ->where('id', (int)$user['id']) - ->where('money', '>=', $amount) - ->setDec('money', $amount); - if (!$affected) { - throw new \RuntimeException('余额不足,请充值或使用微信支付'); - } - $balance = (float)Db::name('user')->where('id', (int)$user['id'])->value('money'); - // 余额支付为「即时支付」:同步落一笔已支付订单,保证「我的订单」与微信支付口径一致 - $orderNo = $this->orderNo('VO'); - $orderId = Db::name('fc_order')->insertGetId([ - 'order_no' => $orderNo, - 'user_id' => $user['id'], - 'type' => 'payview', - 'title' => '付费查看 #' . $id, - 'amount' => $amount, - 'pay_type' => 'balance', - 'status' => 'paid', - 'paytime' => $now, - 'related_id' => $id, - 'createtime' => $now, - 'updatetime' => $now, - ]); - $this->flow('expense', '付费查看', $amount, $balance, $id, '', (int)$user['id']); - // 钱包流水(fa_user.money 减)→ 余额中心「余额明细」 - $this->walletFlow($user['id'], 'expense', '付费查看', $amount, $balance, $id, '解锁信息 #' . $id); - // 余额支付同样触发分销结算(与微信回调 OrderFulfill::handle 口径一致) - $orderRow = Db::name('fc_order')->where('id', $orderId)->find(); - if ($orderRow) { - DistributionService::settleOrder($orderRow); - } - } else { - // 微信支付:只落 pending 订单(不预写解锁记录), - // 支付成功后由 /pay/notify 按 type=payview + related_id 解锁(回调可独立完成) - $orderNo = $this->orderNo('VO'); - Db::name('fc_order')->insert([ - 'order_no' => $orderNo, - 'user_id' => $user['id'], - 'type' => 'payview', - 'title' => '付费查看 #' . $id, - 'amount' => $amount, - 'pay_type' => 'wxpay', - 'status' => 'pending', - 'paytime' => null, - 'related_id' => $id, - 'createtime' => $now, - 'updatetime' => $now, - ]); - $pending = [ - 'orderNo' => $orderNo, - 'amount' => $amount, - 'payType' => 'wxpay', - 'status' => 'pending', - ]; - return; + Db::transaction(function () use (&$orderId, &$pending, $payType, $amount, $user, $id, $now) { + if ($payType === 'balance') { + // 条件扣款:并发下不会扣成负数 + $affected = Db::name('user') + ->where('id', (int)$user['id']) + ->where('money', '>=', $amount) + ->setDec('money', $amount); + if (!$affected) { + throw new \RuntimeException('余额不足,请充值或使用微信支付'); } + $balance = (float)Db::name('user')->where('id', (int)$user['id'])->value('money'); + // 余额支付为「即时支付」:同步落一笔已支付订单,保证「我的订单」与微信支付口径一致 + $orderNo = $this->orderNo('VO'); + $orderId = Db::name('fc_order')->insertGetId([ + 'order_no' => $orderNo, + 'user_id' => $user['id'], + 'type' => 'payview', + 'title' => '解锁信息 #' . $id, + 'amount' => $amount, + 'pay_type' => 'balance', + 'status' => 'paid', + 'paytime' => $now, + 'related_id' => $id, + 'createtime' => $now, + 'updatetime' => $now, + ]); + $this->flow('expense', '解锁信息', $amount, $balance, $id, '', (int)$user['id']); + // 钱包流水(fa_user.money 减)→ 余额中心「余额明细」 + $this->walletFlow($user['id'], 'expense', '解锁信息', $amount, $balance, $id, '解锁信息 #' . $id); + // 余额支付同样触发分销结算(与微信回调 OrderFulfill::handle 口径一致) + $orderRow = Db::name('fc_order')->where('id', $orderId)->find(); + if ($orderRow) { + DistributionService::settleOrder($orderRow); + } + } else { + // 微信支付:只落 pending 订单(不预写解锁记录), + // 支付成功后由 /pay/notify 按 type=payview + related_id 解锁(回调可独立完成) + $orderNo = $this->orderNo('VO'); + Db::name('fc_order')->insert([ + 'order_no' => $orderNo, + 'user_id' => $user['id'], + 'type' => 'payview', + 'title' => '解锁信息 #' . $id, + 'amount' => $amount, + 'pay_type' => 'wxpay', + 'status' => 'pending', + 'paytime' => null, + 'related_id' => $id, + 'createtime' => $now, + 'updatetime' => $now, + ]); + $pending = [ + 'orderNo' => $orderNo, + 'amount' => $amount, + 'payType' => 'wxpay', + 'status' => 'pending', + ]; + return; } Db::name('fc_pay_view')->insert([ @@ -1375,20 +1366,8 @@ class Post extends V1 } } - $today = date('Y-m-d'); - $extNow = $this->ext($user['id']); - $usedNow = ((string)$extNow['free_view_date'] === $today) ? (int)$extNow['free_view_used'] : 0; - $this->ok([ - 'unlocked' => true, - 'phone' => $phone, - 'contact' => $contact, - 'contactName' => $contactName, - 'wechat' => $wechat, - 'contactInfo' => $contact, - 'freeViewUnlimited' => $freeViewUnlimited, - 'freeViewLeft' => $isMember ? ($freeViewUnlimited ? 999 : max(0, $quota - $usedNow)) : 0, - 'usedFree' => isset($usedFree) ? $usedFree : false, + 'unlocked' => true, ]); } diff --git a/application/command.php b/application/command.php index 7c69495..84c6d9a 100755 --- a/application/command.php +++ b/application/command.php @@ -22,5 +22,4 @@ return [ 'app\admin\command\FcSettle', 'app\admin\command\FcVerifyDist', 'app\admin\command\FcVerifyMember', - 'app\admin\command\FcSeedPosts', ]; diff --git a/application/common/library/FcConfig.php b/application/common/library/FcConfig.php index 5514199..3b9961b 100644 --- a/application/common/library/FcConfig.php +++ b/application/common/library/FcConfig.php @@ -85,7 +85,6 @@ class FcConfig 'viewMode' => $viewMode, 'payViewPrice' => max(0, $payViewPrice), 'callNormalDailyQuota' => max(0, (int)$pick('call.normalDailyQuota', 5)), - 'callVipDailyQuota' => max(0, (int)$pick('call.vipDailyQuota', 30)), ]; } @@ -136,22 +135,30 @@ class FcConfig /** * 根据后台实时配置与启用套餐,自动生成 100% 准确的会员权益文案列表(无需手写) * - * @param int $freeView 每日免费查看条数(0 = 无限次) + * @param int $viewQuota 每日免费浏览完整信息条数(0 = 不限) + * @param int $callQuota 每日拨打电话次数(0 = 不限) * @param float $discountNum 超额单条发布折扣值(1~10) * @return array */ - public static function buildAutoBenefits($freeView, $discountNum) + public static function buildAutoBenefits($viewQuota, $callQuota, $discountNum) { $list = []; - // 1. 联系方式查看特权 - if ((int)$freeView > 0) { - $list[] = '每日免费查看 ' . (int)$freeView . ' 条同城信息联系方式'; + // 1. 完整信息浏览特权 + if ((int)$viewQuota > 0) { + $list[] = '每日免费浏览 ' . (int)$viewQuota . ' 条完整信息'; } else { - $list[] = '全站同城信息联系方式无限次免费查看'; + $list[] = '完整信息无限次免费浏览'; } - // 2. 套餐赠送免费发布额度特权(实时读取 fc_plan 启用套餐) + // 2. 拨打电话特权 + if ((int)$callQuota > 0) { + $list[] = '每日可拨打 ' . (int)$callQuota . ' 次电话联系发布者'; + } else { + $list[] = '拨打电话不限次数'; + } + + // 3. 套餐赠送免费发布额度特权(实时读取 fc_plan 启用套餐) try { $plans = Db::name('fc_plan')->where('enabled', 1)->order('sort asc')->select(); $quotaParts = []; @@ -170,21 +177,21 @@ class FcConfig $list[] = '开通套餐立赠海量免费发布额度'; } - // 3. 超额单条发布折扣特权 + // 4. 超额单条发布折扣特权 $num = round((float)$discountNum, 1); if ($num > 0 && $num < 10) { $clean = rtrim(rtrim(number_format($num, 1, '.', ''), '0'), '.'); $list[] = '赠送额度用尽后,单条付费发布专享 ' . $clean . ' 折优惠'; } - // 4. 尊贵身份标识 + // 5. 尊贵身份标识 $list[] = '全站帖子详情与个人主页点亮 VIP 专属金标'; return $list; } /** - * 为单个会员套餐生成专属权益文案列表(直接读取套餐行的 post_quota / free_view_quota / post_discount) + * 为单个会员套餐生成专属权益文案列表(直接读取套餐行权益字段) * * @param array $plan fc_plan 表行数据 * @return array @@ -192,14 +199,22 @@ class FcConfig public static function buildPlanBenefits(array $plan) { $postQuota = isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0; - $freeView = isset($plan['free_view_quota']) ? max(0, (int)$plan['free_view_quota']) : 20; + $viewQuota = isset($plan['view_quota']) ? max(0, (int)$plan['view_quota']) : 20; + $callQuota = isset($plan['call_quota']) ? max(0, (int)$plan['call_quota']) : 30; $discount = isset($plan['post_discount']) ? self::parseDiscountNum($plan['post_discount']) : 8.0; $list = []; - if ($freeView > 0) { - $list[] = '每日免费查看 ' . $freeView . ' 条同城信息联系方式'; + + if ($viewQuota > 0) { + $list[] = '每日免费浏览 ' . $viewQuota . ' 条完整信息'; } else { - $list[] = '全站同城信息联系方式无限次免费查看'; + $list[] = '完整信息无限次免费浏览'; + } + + if ($callQuota > 0) { + $list[] = '每日可拨打 ' . $callQuota . ' 次电话联系发布者'; + } else { + $list[] = '拨打电话不限次数'; } if ($postQuota > 0) { @@ -221,7 +236,7 @@ class FcConfig * 解析指定用户(或默认推荐套餐)对应的会员权益数据(真源:fc_plan 套餐表) * * @param array|null $ext fc_user_ext 行(可为空) - * @return array{freeView:int,postDiscountNum:float,postDiscount:string,benefits:array} + * @return array{viewQuota:int,callQuota:int,postDiscountNum:float,postDiscount:string,benefits:array} */ public static function resolveMemberBenefit($ext = null) { @@ -243,12 +258,14 @@ class FcConfig } if ($plan) { - $freeView = isset($plan['free_view_quota']) ? max(0, (int)$plan['free_view_quota']) : 20; + $viewQuota = isset($plan['view_quota']) ? max(0, (int)$plan['view_quota']) : 20; + $callQuota = isset($plan['call_quota']) ? max(0, (int)$plan['call_quota']) : 30; $discountNum = isset($plan['post_discount']) ? self::parseDiscountNum($plan['post_discount']) : 8.0; $postDiscount = self::formatDiscountText($discountNum); $benefitsArr = self::buildPlanBenefits($plan); return [ - 'freeView' => $freeView, + 'viewQuota' => $viewQuota, + 'callQuota' => $callQuota, 'postDiscountNum' => $discountNum, 'postDiscount' => $postDiscount, 'benefits' => $benefitsArr, @@ -274,16 +291,18 @@ class FcConfig } } - $freeView = isset($benefitCfg['freeView']) ? max(0, (int)$benefitCfg['freeView']) : 20; + $viewQuota = isset($benefitCfg['viewQuota']) ? max(0, (int)$benefitCfg['viewQuota']) : 20; + $callQuota = isset($benefitCfg['callQuota']) ? max(0, (int)$benefitCfg['callQuota']) : 30; $rawDiscount = isset($benefitCfg['postDiscountNum']) ? $benefitCfg['postDiscountNum'] : (isset($benefitCfg['postDiscount']) ? $benefitCfg['postDiscount'] : 8); $discountNum = self::parseDiscountNum($rawDiscount); $postDiscount = self::formatDiscountText($discountNum); - $benefitsArr = self::buildAutoBenefits($freeView, $discountNum); + $benefitsArr = self::buildAutoBenefits($viewQuota, $callQuota, $discountNum); return [ - 'freeView' => $freeView, + 'viewQuota' => $viewQuota, + 'callQuota' => $callQuota, 'postDiscountNum' => $discountNum, 'postDiscount' => $postDiscount, 'benefits' => $benefitsArr, @@ -468,7 +487,7 @@ class FcConfig self::put($key, $value, 'post', '发布配置'); } - // 联系方式浏览规则(若表单同时传入则一并保存) + // 完整信息浏览规则(若表单同时传入则一并保存) if (isset($row['fc_fee_viewMode']) || isset($row['fc_fee_payView'])) { $viewMode = isset($row['fc_fee_viewMode']) ? trim((string)$row['fc_fee_viewMode']) : 'paid'; if (!in_array($viewMode, ['free', 'paid'], true)) { @@ -476,22 +495,18 @@ class FcConfig } $payView = $num('fc_fee_payView', 2); if ($payView < 0) { - throw new \Exception('非会员查看价格不能为负'); + throw new \Exception('非会员单条解锁价格不能为负'); } // 若选了完全免费公开,或单次价格设为 0,统一保障免费可看 - self::put('fee.viewMode', $viewMode, 'fee', '联系方式浏览模式:free免费公开 / paid付费或会员解锁'); - self::put('fee.payView', $viewMode === 'free' ? 0 : $payView, 'fee', '非会员单条查看联系方式价格'); + self::put('fee.viewMode', $viewMode, 'fee', '完整信息浏览模式:free免费公开 / paid付费或会员额度解锁'); + self::put('fee.payView', $viewMode === 'free' ? 0 : $payView, 'fee', '非会员单条解锁完整信息价格'); } - // 每日电话拨打次数限制(详情页与商家主页共用额度池) + // 普通用户(非会员)每日拨打次数限制(会员拨打次数由各套餐 call_quota 控制) if (isset($row['fc_call_normalDailyQuota'])) { $normalQuota = max(0, $int('fc_call_normalDailyQuota', 5)); self::put('call.normalDailyQuota', $normalQuota, 'call', '普通用户每日免费拨打次数上限(0表示不限制)'); } - if (isset($row['fc_call_vipDailyQuota'])) { - $vipQuota = max(0, $int('fc_call_vipDailyQuota', 30)); - self::put('call.vipDailyQuota', $vipQuota, 'call', 'VIP会员每日拨打次数上限(0表示不限制)'); - } } /** @@ -503,7 +518,8 @@ class FcConfig */ public static function saveMember(array $row) { - $freeView = isset($row['fc_member_freeView']) ? max(0, (int)$row['fc_member_freeView']) : 20; + $viewQuota = isset($row['fc_member_viewQuota']) ? max(0, (int)$row['fc_member_viewQuota']) : 20; + $callQuota = isset($row['fc_member_callQuota']) ? max(0, (int)$row['fc_member_callQuota']) : 30; $rawDiscount = isset($row['fc_member_postDiscountNum']) ? $row['fc_member_postDiscountNum'] : (isset($row['fc_member_postDiscount']) ? $row['fc_member_postDiscount'] : 8); @@ -512,7 +528,7 @@ class FcConfig throw new \Exception('发布折扣需在 0.1 ~ 10 之间(10 表示不打折)'); } $postDiscount = self::formatDiscountText($discountNum); - $benefits = self::buildAutoBenefits($freeView, $discountNum); + $benefits = self::buildAutoBenefits($viewQuota, $callQuota, $discountNum); $current = []; $raw = Db::name('fc_config')->where('key', 'member.benefit')->value('value'); @@ -522,7 +538,8 @@ class FcConfig $current = $decoded; } } - $current['freeView'] = $freeView; + $current['viewQuota'] = $viewQuota; + $current['callQuota'] = $callQuota; $current['postDiscountNum'] = $discountNum; $current['postDiscount'] = $postDiscount; $current['benefits'] = $benefits; diff --git a/public/assets/js/backend/fc/plan.js b/public/assets/js/backend/fc/plan.js index fa713b1..71a227d 100644 --- a/public/assets/js/backend/fc/plan.js +++ b/public/assets/js/backend/fc/plan.js @@ -56,10 +56,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'backend/format'], fu } }, { - field: 'free_view_quota', title: __('Free_view_quota'), operate: 'BETWEEN', + field: 'view_quota', title: __('View_quota'), operate: 'BETWEEN', formatter: function (value) { var n = parseInt(value !== undefined && value !== null ? value : 20, 10); - return n === 0 ? '无限次免费看' : (n + ' 条/日'); + return n === 0 ? '不限次浏览' : (n + ' 条/日'); + } + }, + { + field: 'call_quota', title: __('Call_quota'), operate: 'BETWEEN', + formatter: function (value) { + var n = parseInt(value !== undefined && value !== null ? value : 30, 10); + return n === 0 ? '不限次拨打' : (n + ' 次/日'); } }, { diff --git a/public/static/posts/README.md b/public/static/posts/README.md index 8f6799d..2a3b390 100644 --- a/public/static/posts/README.md +++ b/public/static/posts/README.md @@ -1,10 +1,16 @@ -# 演示信息配图 +ni'jiunijiu# 信息配图说明 -本目录图片仅用于小程序/后台的演示信息数据,由 `php think fc:seed-posts` 写入 `fc_post.cover` 与 `fc_post.media`。 +本目录仅保留前端无图兜底默认图,**不再存放种子数据配图**。 -- 来源:Unsplash(https://unsplash.com),遵循 Unsplash License,可免费用于商业与非商业项目。 -- 规格:统一裁切为 900 × 700,单张小于 1 MB。 -- 命名:按信息内容命名,例如 `job-warehouse.jpg`(仓库分拣)、`house-living1.jpg`(房源客厅)。 -- 替换:直接覆盖同名文件即可,无需修改数据库;重新执行种子命令会沿用这些路径。 +- `farm-default.jpg`:农产品类无图信息兜底图(`uniapp/components/fc-post-card/fc-post-card.vue` 引用) +- `job-default.jpg`:招聘类无图信息兜底图(同上引用) -当前共 57 张,覆盖招聘、房产、二手、车辆、本地服务、教育培训、家政保洁、同城交友 8 个分类。 +## 种子配图的真实存放机制 + +种子数据配图模拟真实上传接口形态落盘,路径为 `/uploads/YYYYMMDD/<内容md5>.jpg`, +与用户通过后台上传产生的路径完全一致,预览走图片链接(后端域名拼接)。 + +- 源图目录:`application/admin/command/seed-images/`(46 张,不暴露在 public 下) +- 写入逻辑:`application/admin/command/FcSeedPosts.php` 的 `uploadSeed()`, + 执行 `php think fc:seed-posts` 时按内容 md5 复制到 `public/uploads/` 并写入数据库 +- 替换配图:直接覆盖 `seed-images/` 下同名源图后重跑种子命令(md5 变化会生成新文件名) diff --git a/public/static/posts/car-compact-blue.jpg b/public/static/posts/car-compact-blue.jpg deleted file mode 100644 index 24964db..0000000 Binary files a/public/static/posts/car-compact-blue.jpg and /dev/null differ diff --git a/public/static/posts/car-coupe-blue.jpg b/public/static/posts/car-coupe-blue.jpg deleted file mode 100644 index 1e3842f..0000000 Binary files a/public/static/posts/car-coupe-blue.jpg and /dev/null differ diff --git a/public/static/posts/car-hatch-red.jpg b/public/static/posts/car-hatch-red.jpg deleted file mode 100644 index c109f2a..0000000 Binary files a/public/static/posts/car-hatch-red.jpg and /dev/null differ diff --git a/public/static/posts/car-moto.jpg b/public/static/posts/car-moto.jpg deleted file mode 100644 index 303ba55..0000000 Binary files a/public/static/posts/car-moto.jpg and /dev/null differ diff --git a/public/static/posts/car-sedan-dark.jpg b/public/static/posts/car-sedan-dark.jpg deleted file mode 100644 index 8061f06..0000000 Binary files a/public/static/posts/car-sedan-dark.jpg and /dev/null differ diff --git a/public/static/posts/car-sedan-white.jpg b/public/static/posts/car-sedan-white.jpg deleted file mode 100644 index a38c473..0000000 Binary files a/public/static/posts/car-sedan-white.jpg and /dev/null differ diff --git a/public/static/posts/car-suv-white.jpg b/public/static/posts/car-suv-white.jpg deleted file mode 100644 index 0ce43c0..0000000 Binary files a/public/static/posts/car-suv-white.jpg and /dev/null differ diff --git a/public/static/posts/clean-baby.jpg b/public/static/posts/clean-baby.jpg deleted file mode 100644 index 84243e0..0000000 Binary files a/public/static/posts/clean-baby.jpg and /dev/null differ diff --git a/public/static/posts/clean-bath.jpg b/public/static/posts/clean-bath.jpg deleted file mode 100644 index 5627682..0000000 Binary files a/public/static/posts/clean-bath.jpg and /dev/null differ diff --git a/public/static/posts/clean-elder.jpg b/public/static/posts/clean-elder.jpg deleted file mode 100644 index 86326c5..0000000 Binary files a/public/static/posts/clean-elder.jpg and /dev/null differ diff --git a/public/static/posts/clean-kitchen.jpg b/public/static/posts/clean-kitchen.jpg deleted file mode 100644 index 9c8e437..0000000 Binary files a/public/static/posts/clean-kitchen.jpg and /dev/null differ diff --git a/public/static/posts/clean-vacuum.jpg b/public/static/posts/clean-vacuum.jpg deleted file mode 100644 index ef08044..0000000 Binary files a/public/static/posts/clean-vacuum.jpg and /dev/null differ diff --git a/public/static/posts/clean-window.jpg b/public/static/posts/clean-window.jpg deleted file mode 100644 index 29ba320..0000000 Binary files a/public/static/posts/clean-window.jpg and /dev/null differ diff --git a/public/static/posts/edu-art.jpg b/public/static/posts/edu-art.jpg deleted file mode 100644 index f47e358..0000000 Binary files a/public/static/posts/edu-art.jpg and /dev/null differ diff --git a/public/static/posts/edu-books.jpg b/public/static/posts/edu-books.jpg deleted file mode 100644 index ee2c0f3..0000000 Binary files a/public/static/posts/edu-books.jpg and /dev/null differ diff --git a/public/static/posts/edu-class.jpg b/public/static/posts/edu-class.jpg deleted file mode 100644 index dd1e2f3..0000000 Binary files a/public/static/posts/edu-class.jpg and /dev/null differ diff --git a/public/static/posts/edu-guitar.jpg b/public/static/posts/edu-guitar.jpg deleted file mode 100644 index dcbd4b5..0000000 Binary files a/public/static/posts/edu-guitar.jpg and /dev/null differ diff --git a/public/static/posts/edu-laptop.jpg b/public/static/posts/edu-laptop.jpg deleted file mode 100644 index 5c9d464..0000000 Binary files a/public/static/posts/edu-laptop.jpg and /dev/null differ diff --git a/public/static/posts/edu-shelf.jpg b/public/static/posts/edu-shelf.jpg deleted file mode 100644 index fda90ef..0000000 Binary files a/public/static/posts/edu-shelf.jpg and /dev/null differ diff --git a/public/static/posts/farm-default.jpg b/public/static/posts/farm-default.jpg new file mode 100644 index 0000000..c19ed4c Binary files /dev/null and b/public/static/posts/farm-default.jpg differ diff --git a/public/static/posts/friend-badminton.jpg b/public/static/posts/friend-badminton.jpg deleted file mode 100644 index 9c3d284..0000000 Binary files a/public/static/posts/friend-badminton.jpg and /dev/null differ diff --git a/public/static/posts/friend-bike.jpg b/public/static/posts/friend-bike.jpg deleted file mode 100644 index 5726e82..0000000 Binary files a/public/static/posts/friend-bike.jpg and /dev/null differ diff --git a/public/static/posts/friend-board.jpg b/public/static/posts/friend-board.jpg deleted file mode 100644 index daf5c12..0000000 Binary files a/public/static/posts/friend-board.jpg and /dev/null differ diff --git a/public/static/posts/friend-books.jpg b/public/static/posts/friend-books.jpg deleted file mode 100644 index 0cff598..0000000 Binary files a/public/static/posts/friend-books.jpg and /dev/null differ diff --git a/public/static/posts/friend-camera.jpg b/public/static/posts/friend-camera.jpg deleted file mode 100644 index e84845f..0000000 Binary files a/public/static/posts/friend-camera.jpg and /dev/null differ diff --git a/public/static/posts/friend-group.jpg b/public/static/posts/friend-group.jpg deleted file mode 100644 index cfa4867..0000000 Binary files a/public/static/posts/friend-group.jpg and /dev/null differ diff --git a/public/static/posts/friend-hike.jpg b/public/static/posts/friend-hike.jpg deleted file mode 100644 index af7a156..0000000 Binary files a/public/static/posts/friend-hike.jpg and /dev/null differ diff --git a/public/static/posts/friend-run.jpg b/public/static/posts/friend-run.jpg deleted file mode 100644 index 34677b4..0000000 Binary files a/public/static/posts/friend-run.jpg and /dev/null differ diff --git a/public/static/posts/goods-bike.jpg b/public/static/posts/goods-bike.jpg deleted file mode 100644 index 5726e82..0000000 Binary files a/public/static/posts/goods-bike.jpg and /dev/null differ diff --git a/public/static/posts/goods-desk.jpg b/public/static/posts/goods-desk.jpg deleted file mode 100644 index fbf5a18..0000000 Binary files a/public/static/posts/goods-desk.jpg and /dev/null differ diff --git a/public/static/posts/goods-dining.jpg b/public/static/posts/goods-dining.jpg deleted file mode 100644 index 00bb0ff..0000000 Binary files a/public/static/posts/goods-dining.jpg and /dev/null differ diff --git a/public/static/posts/goods-fridge.jpg b/public/static/posts/goods-fridge.jpg deleted file mode 100644 index c5dab1e..0000000 Binary files a/public/static/posts/goods-fridge.jpg and /dev/null differ diff --git a/public/static/posts/goods-ipad.jpg b/public/static/posts/goods-ipad.jpg deleted file mode 100644 index 2402e9f..0000000 Binary files a/public/static/posts/goods-ipad.jpg and /dev/null differ diff --git a/public/static/posts/goods-laptop.jpg b/public/static/posts/goods-laptop.jpg deleted file mode 100644 index 123301c..0000000 Binary files a/public/static/posts/goods-laptop.jpg and /dev/null differ diff --git a/public/static/posts/goods-stroller.jpg b/public/static/posts/goods-stroller.jpg deleted file mode 100644 index 1aac2cd..0000000 Binary files a/public/static/posts/goods-stroller.jpg and /dev/null differ diff --git a/public/static/posts/goods-treadmill.jpg b/public/static/posts/goods-treadmill.jpg deleted file mode 100644 index d6834c2..0000000 Binary files a/public/static/posts/goods-treadmill.jpg and /dev/null differ diff --git a/public/static/posts/house-exterior.jpg b/public/static/posts/house-exterior.jpg deleted file mode 100644 index f4df2b0..0000000 Binary files a/public/static/posts/house-exterior.jpg and /dev/null differ diff --git a/public/static/posts/house-living1.jpg b/public/static/posts/house-living1.jpg deleted file mode 100644 index ffca6b6..0000000 Binary files a/public/static/posts/house-living1.jpg and /dev/null differ diff --git a/public/static/posts/house-living2.jpg b/public/static/posts/house-living2.jpg deleted file mode 100644 index d281031..0000000 Binary files a/public/static/posts/house-living2.jpg and /dev/null differ diff --git a/public/static/posts/house-living3.jpg b/public/static/posts/house-living3.jpg deleted file mode 100644 index 4397f44..0000000 Binary files a/public/static/posts/house-living3.jpg and /dev/null differ diff --git a/public/static/posts/house-living4.jpg b/public/static/posts/house-living4.jpg deleted file mode 100644 index 3041362..0000000 Binary files a/public/static/posts/house-living4.jpg and /dev/null differ diff --git a/public/static/posts/house-model.jpg b/public/static/posts/house-model.jpg deleted file mode 100644 index 1d36dba..0000000 Binary files a/public/static/posts/house-model.jpg and /dev/null differ diff --git a/public/static/posts/house-office.jpg b/public/static/posts/house-office.jpg deleted file mode 100644 index 8a46ff6..0000000 Binary files a/public/static/posts/house-office.jpg and /dev/null differ diff --git a/public/static/posts/job-cafe.jpg b/public/static/posts/job-cafe.jpg deleted file mode 100644 index 3dc6e0c..0000000 Binary files a/public/static/posts/job-cafe.jpg and /dev/null differ diff --git a/public/static/posts/job-classroom.jpg b/public/static/posts/job-classroom.jpg deleted file mode 100644 index dd1e2f3..0000000 Binary files a/public/static/posts/job-classroom.jpg and /dev/null differ diff --git a/public/static/posts/job-cleaning.jpg b/public/static/posts/job-cleaning.jpg deleted file mode 100644 index 29ba320..0000000 Binary files a/public/static/posts/job-cleaning.jpg and /dev/null differ diff --git a/public/static/posts/job-default.jpg b/public/static/posts/job-default.jpg new file mode 100644 index 0000000..a8777a9 Binary files /dev/null and b/public/static/posts/job-default.jpg differ diff --git a/public/static/posts/job-delivery.jpg b/public/static/posts/job-delivery.jpg deleted file mode 100644 index 80764d2..0000000 Binary files a/public/static/posts/job-delivery.jpg and /dev/null differ diff --git a/public/static/posts/job-handshake.jpg b/public/static/posts/job-handshake.jpg deleted file mode 100644 index d54c0fb..0000000 Binary files a/public/static/posts/job-handshake.jpg and /dev/null differ diff --git a/public/static/posts/job-office.jpg b/public/static/posts/job-office.jpg deleted file mode 100644 index 8726e3b..0000000 Binary files a/public/static/posts/job-office.jpg and /dev/null differ diff --git a/public/static/posts/job-warehouse.jpg b/public/static/posts/job-warehouse.jpg deleted file mode 100644 index 7361aa3..0000000 Binary files a/public/static/posts/job-warehouse.jpg and /dev/null differ diff --git a/public/static/posts/svc-camera.jpg b/public/static/posts/svc-camera.jpg deleted file mode 100644 index e84845f..0000000 Binary files a/public/static/posts/svc-camera.jpg and /dev/null differ diff --git a/public/static/posts/svc-code.jpg b/public/static/posts/svc-code.jpg deleted file mode 100644 index 2f72cbd..0000000 Binary files a/public/static/posts/svc-code.jpg and /dev/null differ diff --git a/public/static/posts/svc-drill.jpg b/public/static/posts/svc-drill.jpg deleted file mode 100644 index 072c8df..0000000 Binary files a/public/static/posts/svc-drill.jpg and /dev/null differ diff --git a/public/static/posts/svc-keys.jpg b/public/static/posts/svc-keys.jpg deleted file mode 100644 index 1d36dba..0000000 Binary files a/public/static/posts/svc-keys.jpg and /dev/null differ diff --git a/public/static/posts/svc-moving.jpg b/public/static/posts/svc-moving.jpg deleted file mode 100644 index ba6e4f2..0000000 Binary files a/public/static/posts/svc-moving.jpg and /dev/null differ diff --git a/public/static/posts/svc-night.jpg b/public/static/posts/svc-night.jpg deleted file mode 100644 index e46fc04..0000000 Binary files a/public/static/posts/svc-night.jpg and /dev/null differ diff --git a/public/static/posts/svc-spray.jpg b/public/static/posts/svc-spray.jpg deleted file mode 100644 index 4f70624..0000000 Binary files a/public/static/posts/svc-spray.jpg and /dev/null differ diff --git a/public/static/posts/svc-wrench.jpg b/public/static/posts/svc-wrench.jpg deleted file mode 100644 index bed07c9..0000000 Binary files a/public/static/posts/svc-wrench.jpg and /dev/null differ diff --git a/public/uploads/20261018/388bf7fcd0da70f285a3fb4d4338638c.jpg b/public/uploads/20261018/388bf7fcd0da70f285a3fb4d4338638c.jpg new file mode 100644 index 0000000..5b2adbb Binary files /dev/null and b/public/uploads/20261018/388bf7fcd0da70f285a3fb4d4338638c.jpg differ diff --git a/public/uploads/20261018/8eaf37002645ab942584006971c7bd7b.jpg b/public/uploads/20261018/8eaf37002645ab942584006971c7bd7b.jpg new file mode 100644 index 0000000..49a3223 Binary files /dev/null and b/public/uploads/20261018/8eaf37002645ab942584006971c7bd7b.jpg differ diff --git a/public/uploads/20261018/f9d5900e0d173d3d84713b70ea2d1135.jpg b/public/uploads/20261018/f9d5900e0d173d3d84713b70ea2d1135.jpg new file mode 100644 index 0000000..e7a623e Binary files /dev/null and b/public/uploads/20261018/f9d5900e0d173d3d84713b70ea2d1135.jpg differ diff --git a/public/uploads/20261019/0318ec661deaf6a20a656be70377cebd.jpg b/public/uploads/20261019/0318ec661deaf6a20a656be70377cebd.jpg new file mode 100644 index 0000000..913d889 Binary files /dev/null and b/public/uploads/20261019/0318ec661deaf6a20a656be70377cebd.jpg differ diff --git a/public/uploads/20261019/5abce0dc8bba8a8b66f3adb75637d9ff.jpg b/public/uploads/20261019/5abce0dc8bba8a8b66f3adb75637d9ff.jpg new file mode 100644 index 0000000..dd833e9 Binary files /dev/null and b/public/uploads/20261019/5abce0dc8bba8a8b66f3adb75637d9ff.jpg differ diff --git a/public/uploads/20261019/bb86a7044315b4bd419fb657da997f03.jpg b/public/uploads/20261019/bb86a7044315b4bd419fb657da997f03.jpg new file mode 100644 index 0000000..4ed9163 Binary files /dev/null and b/public/uploads/20261019/bb86a7044315b4bd419fb657da997f03.jpg differ diff --git a/public/uploads/20261020/0604d246fb42bb569e8031b3a1c3c9ad.jpg b/public/uploads/20261020/0604d246fb42bb569e8031b3a1c3c9ad.jpg new file mode 100644 index 0000000..f55c6c6 Binary files /dev/null and b/public/uploads/20261020/0604d246fb42bb569e8031b3a1c3c9ad.jpg differ diff --git a/public/uploads/20261020/0c0115e6946ec7ac4c2fe7088bdbe0d7.jpg b/public/uploads/20261020/0c0115e6946ec7ac4c2fe7088bdbe0d7.jpg new file mode 100644 index 0000000..9992471 Binary files /dev/null and b/public/uploads/20261020/0c0115e6946ec7ac4c2fe7088bdbe0d7.jpg differ diff --git a/public/uploads/20261020/0e6e5dbbbd77f31315e643c2e4915d9a.jpg b/public/uploads/20261020/0e6e5dbbbd77f31315e643c2e4915d9a.jpg new file mode 100644 index 0000000..21c129d Binary files /dev/null and b/public/uploads/20261020/0e6e5dbbbd77f31315e643c2e4915d9a.jpg differ diff --git a/public/uploads/20261020/11fe77af7af7a4b2239a20a189fca471.jpg b/public/uploads/20261020/11fe77af7af7a4b2239a20a189fca471.jpg new file mode 100644 index 0000000..b26bb08 Binary files /dev/null and b/public/uploads/20261020/11fe77af7af7a4b2239a20a189fca471.jpg differ diff --git a/public/uploads/20261020/1ad84fe2cef8ef22ef29ac45a898081c.jpg b/public/uploads/20261020/1ad84fe2cef8ef22ef29ac45a898081c.jpg new file mode 100644 index 0000000..73bc9a5 Binary files /dev/null and b/public/uploads/20261020/1ad84fe2cef8ef22ef29ac45a898081c.jpg differ diff --git a/public/uploads/20261020/22e041f997661fbcbcf10ce86da3c01b.jpg b/public/uploads/20261020/22e041f997661fbcbcf10ce86da3c01b.jpg new file mode 100644 index 0000000..bcafaa8 Binary files /dev/null and b/public/uploads/20261020/22e041f997661fbcbcf10ce86da3c01b.jpg differ diff --git a/public/uploads/20261020/2b709d7c76d07516758df3558d83a56a.jpg b/public/uploads/20261020/2b709d7c76d07516758df3558d83a56a.jpg new file mode 100644 index 0000000..8419e93 Binary files /dev/null and b/public/uploads/20261020/2b709d7c76d07516758df3558d83a56a.jpg differ diff --git a/public/uploads/20261020/2bee40730c9f23595deecdfa24b206cf.jpg b/public/uploads/20261020/2bee40730c9f23595deecdfa24b206cf.jpg new file mode 100644 index 0000000..94d17b0 Binary files /dev/null and b/public/uploads/20261020/2bee40730c9f23595deecdfa24b206cf.jpg differ diff --git a/public/uploads/20261020/74d6e0301b727fe2c48638ebd7989fa8.jpg b/public/uploads/20261020/74d6e0301b727fe2c48638ebd7989fa8.jpg new file mode 100644 index 0000000..dbd0b7a Binary files /dev/null and b/public/uploads/20261020/74d6e0301b727fe2c48638ebd7989fa8.jpg differ diff --git a/public/uploads/20261020/8aac76e021aea0dd999ba5689ae86577.jpg b/public/uploads/20261020/8aac76e021aea0dd999ba5689ae86577.jpg new file mode 100644 index 0000000..d459488 Binary files /dev/null and b/public/uploads/20261020/8aac76e021aea0dd999ba5689ae86577.jpg differ diff --git a/public/uploads/20261020/b0af7b62d055688ef020e9dab794373d.jpg b/public/uploads/20261020/b0af7b62d055688ef020e9dab794373d.jpg new file mode 100644 index 0000000..5fad418 Binary files /dev/null and b/public/uploads/20261020/b0af7b62d055688ef020e9dab794373d.jpg differ diff --git a/public/uploads/20261020/b2cabf7db99e86a8ee8da424e19ebf9d.jpg b/public/uploads/20261020/b2cabf7db99e86a8ee8da424e19ebf9d.jpg new file mode 100644 index 0000000..34d5580 Binary files /dev/null and b/public/uploads/20261020/b2cabf7db99e86a8ee8da424e19ebf9d.jpg differ diff --git a/public/uploads/20261020/c994cb47610ed3514571db794b1ee724.jpg b/public/uploads/20261020/c994cb47610ed3514571db794b1ee724.jpg new file mode 100644 index 0000000..ff60b24 Binary files /dev/null and b/public/uploads/20261020/c994cb47610ed3514571db794b1ee724.jpg differ diff --git a/public/uploads/20261020/d29f6003464df81337954f2e3b9213de.jpg b/public/uploads/20261020/d29f6003464df81337954f2e3b9213de.jpg new file mode 100644 index 0000000..5b1017d Binary files /dev/null and b/public/uploads/20261020/d29f6003464df81337954f2e3b9213de.jpg differ diff --git a/public/uploads/20261020/ebb126f270081d28162e90fd240cd481.jpg b/public/uploads/20261020/ebb126f270081d28162e90fd240cd481.jpg new file mode 100644 index 0000000..e5f9469 Binary files /dev/null and b/public/uploads/20261020/ebb126f270081d28162e90fd240cd481.jpg differ diff --git a/public/uploads/20261020/f7a5d6be1d58be88cdd0915c050f6028.jpg b/public/uploads/20261020/f7a5d6be1d58be88cdd0915c050f6028.jpg new file mode 100644 index 0000000..8a09fe5 Binary files /dev/null and b/public/uploads/20261020/f7a5d6be1d58be88cdd0915c050f6028.jpg differ diff --git a/public/uploads/20261024/56cbc572d5a84218d9f0cf9ca4001be0.png b/public/uploads/20261024/56cbc572d5a84218d9f0cf9ca4001be0.png new file mode 100644 index 0000000..7abf225 Binary files /dev/null and b/public/uploads/20261024/56cbc572d5a84218d9f0cf9ca4001be0.png differ diff --git a/public/uploads/20261024/975adf1de9b2864a39bd80d3961bd9ae.jpeg b/public/uploads/20261024/975adf1de9b2864a39bd80d3961bd9ae.jpeg new file mode 100644 index 0000000..b27d2e8 Binary files /dev/null and b/public/uploads/20261024/975adf1de9b2864a39bd80d3961bd9ae.jpeg differ diff --git a/public/uploads/20261024/b413b59a8bde2bd9cfb4f78351414439.png b/public/uploads/20261024/b413b59a8bde2bd9cfb4f78351414439.png new file mode 100644 index 0000000..bfe78ba Binary files /dev/null and b/public/uploads/20261024/b413b59a8bde2bd9cfb4f78351414439.png differ diff --git a/public/uploads/20261024/cbed0babcff80d6790b1024ea209a63c.png b/public/uploads/20261024/cbed0babcff80d6790b1024ea209a63c.png new file mode 100644 index 0000000..90e5bf0 Binary files /dev/null and b/public/uploads/20261024/cbed0babcff80d6790b1024ea209a63c.png differ diff --git a/public/uploads/20261025/97d3949a4303e566ff824b2dc34bf005.jpg b/public/uploads/20261025/97d3949a4303e566ff824b2dc34bf005.jpg new file mode 100644 index 0000000..f72bdb4 Binary files /dev/null and b/public/uploads/20261025/97d3949a4303e566ff824b2dc34bf005.jpg differ diff --git a/public/uploads/20261025/cd219233d5b5bb6f1ac32a2e1d76ba84.jpg b/public/uploads/20261025/cd219233d5b5bb6f1ac32a2e1d76ba84.jpg new file mode 100644 index 0000000..a5b3124 Binary files /dev/null and b/public/uploads/20261025/cd219233d5b5bb6f1ac32a2e1d76ba84.jpg differ diff --git a/public/uploads/20261026/51b1b608a7f4bc93260992afb1d0be19.jpg b/public/uploads/20261026/51b1b608a7f4bc93260992afb1d0be19.jpg new file mode 100644 index 0000000..6034998 Binary files /dev/null and b/public/uploads/20261026/51b1b608a7f4bc93260992afb1d0be19.jpg differ diff --git a/public/uploads/20261026/ad03f3191fd8f479370731f6bb8066b8.jpg b/public/uploads/20261026/ad03f3191fd8f479370731f6bb8066b8.jpg new file mode 100644 index 0000000..f2b03f1 Binary files /dev/null and b/public/uploads/20261026/ad03f3191fd8f479370731f6bb8066b8.jpg differ diff --git a/public/uploads/20261026/e97164038a83000e85d3efca88be6762.jpg b/public/uploads/20261026/e97164038a83000e85d3efca88be6762.jpg new file mode 100644 index 0000000..de10f6e Binary files /dev/null and b/public/uploads/20261026/e97164038a83000e85d3efca88be6762.jpg differ diff --git a/public/uploads/20261027/f2d9d89be5afb2bd3e7d42fdf7e34501.jpg b/public/uploads/20261027/f2d9d89be5afb2bd3e7d42fdf7e34501.jpg new file mode 100644 index 0000000..8e540e0 Binary files /dev/null and b/public/uploads/20261027/f2d9d89be5afb2bd3e7d42fdf7e34501.jpg differ diff --git a/public/uploads/avatars/user_1001.png b/public/uploads/avatars/user_1001.png new file mode 100644 index 0000000..4e74c0f Binary files /dev/null and b/public/uploads/avatars/user_1001.png differ diff --git a/public/uploads/avatars/user_1002.png b/public/uploads/avatars/user_1002.png new file mode 100644 index 0000000..39e5e93 Binary files /dev/null and b/public/uploads/avatars/user_1002.png differ diff --git a/public/uploads/avatars/user_1003.png b/public/uploads/avatars/user_1003.png new file mode 100644 index 0000000..0000260 Binary files /dev/null and b/public/uploads/avatars/user_1003.png differ diff --git a/public/uploads/avatars/user_1004.png b/public/uploads/avatars/user_1004.png new file mode 100644 index 0000000..86ae3d0 Binary files /dev/null and b/public/uploads/avatars/user_1004.png differ diff --git a/public/uploads/avatars/user_2001.png b/public/uploads/avatars/user_2001.png new file mode 100644 index 0000000..5dfed12 Binary files /dev/null and b/public/uploads/avatars/user_2001.png differ diff --git a/public/uploads/avatars/user_2002.png b/public/uploads/avatars/user_2002.png new file mode 100644 index 0000000..392cd69 Binary files /dev/null and b/public/uploads/avatars/user_2002.png differ diff --git a/public/uploads/avatars/user_2003.png b/public/uploads/avatars/user_2003.png new file mode 100644 index 0000000..e12a84e Binary files /dev/null and b/public/uploads/avatars/user_2003.png differ diff --git a/public/uploads/avatars/user_2004.png b/public/uploads/avatars/user_2004.png new file mode 100644 index 0000000..32cf8d4 Binary files /dev/null and b/public/uploads/avatars/user_2004.png differ diff --git a/public/uploads/avatars/user_2005.png b/public/uploads/avatars/user_2005.png new file mode 100644 index 0000000..bcaab95 Binary files /dev/null and b/public/uploads/avatars/user_2005.png differ diff --git a/public/uploads/avatars/user_2006.png b/public/uploads/avatars/user_2006.png new file mode 100644 index 0000000..6a1036e Binary files /dev/null and b/public/uploads/avatars/user_2006.png differ diff --git a/public/uploads/avatars/user_2007.png b/public/uploads/avatars/user_2007.png new file mode 100644 index 0000000..7dce711 Binary files /dev/null and b/public/uploads/avatars/user_2007.png differ diff --git a/public/uploads/avatars/user_2008.png b/public/uploads/avatars/user_2008.png new file mode 100644 index 0000000..cf6645a Binary files /dev/null and b/public/uploads/avatars/user_2008.png differ diff --git a/public/uploads/avatars/user_2009.png b/public/uploads/avatars/user_2009.png new file mode 100644 index 0000000..05fba23 Binary files /dev/null and b/public/uploads/avatars/user_2009.png differ diff --git a/public/uploads/avatars/user_2010.png b/public/uploads/avatars/user_2010.png new file mode 100644 index 0000000..d989a34 Binary files /dev/null and b/public/uploads/avatars/user_2010.png differ diff --git a/public/uploads/avatars/user_2027.png b/public/uploads/avatars/user_2027.png new file mode 100644 index 0000000..ae0edd4 Binary files /dev/null and b/public/uploads/avatars/user_2027.png differ diff --git a/public/uploads/avatars/user_2028.png b/public/uploads/avatars/user_2028.png new file mode 100644 index 0000000..29c7949 Binary files /dev/null and b/public/uploads/avatars/user_2028.png differ diff --git a/public/uploads/avatars/user_default.png b/public/uploads/avatars/user_default.png new file mode 100644 index 0000000..ffaa7b7 Binary files /dev/null and b/public/uploads/avatars/user_default.png differ diff --git a/public/uploads/banners/banner-1.png b/public/uploads/banners/banner-1.png new file mode 100644 index 0000000..b2248cd Binary files /dev/null and b/public/uploads/banners/banner-1.png differ diff --git a/public/uploads/banners/banner-2.png b/public/uploads/banners/banner-2.png new file mode 100644 index 0000000..944507c Binary files /dev/null and b/public/uploads/banners/banner-2.png differ diff --git a/public/uploads/banners/banner-3.png b/public/uploads/banners/banner-3.png new file mode 100644 index 0000000..4d009fe Binary files /dev/null and b/public/uploads/banners/banner-3.png differ diff --git a/public/uploads/banners/banner_recommend_1.png b/public/uploads/banners/banner_recommend_1.png new file mode 100644 index 0000000..bfe78ba Binary files /dev/null and b/public/uploads/banners/banner_recommend_1.png differ diff --git a/public/uploads/banners/banner_recommend_2.png b/public/uploads/banners/banner_recommend_2.png new file mode 100644 index 0000000..7abf225 Binary files /dev/null and b/public/uploads/banners/banner_recommend_2.png differ diff --git a/public/uploads/banners/banner_recommend_3.png b/public/uploads/banners/banner_recommend_3.png new file mode 100644 index 0000000..90e5bf0 Binary files /dev/null and b/public/uploads/banners/banner_recommend_3.png differ diff --git a/public/uploads/merchants/license_default.png b/public/uploads/merchants/license_default.png new file mode 100644 index 0000000..86f7096 Binary files /dev/null and b/public/uploads/merchants/license_default.png differ diff --git a/public/uploads/merchants/m_anjia.png b/public/uploads/merchants/m_anjia.png new file mode 100644 index 0000000..685963d Binary files /dev/null and b/public/uploads/merchants/m_anjia.png differ diff --git a/public/uploads/merchants/m_chehang.png b/public/uploads/merchants/m_chehang.png new file mode 100644 index 0000000..5c1430e Binary files /dev/null and b/public/uploads/merchants/m_chehang.png differ diff --git a/public/uploads/merchants/m_jiejing.png b/public/uploads/merchants/m_jiejing.png new file mode 100644 index 0000000..868d888 Binary files /dev/null and b/public/uploads/merchants/m_jiejing.png differ diff --git a/public/uploads/merchants/m_shunjie.png b/public/uploads/merchants/m_shunjie.png new file mode 100644 index 0000000..6072468 Binary files /dev/null and b/public/uploads/merchants/m_shunjie.png differ diff --git a/public/uploads/merchants/m_youxuan.png b/public/uploads/merchants/m_youxuan.png new file mode 100644 index 0000000..a4e1c57 Binary files /dev/null and b/public/uploads/merchants/m_youxuan.png differ diff --git a/public/uploads/merchants/m_zaowang.png b/public/uploads/merchants/m_zaowang.png new file mode 100644 index 0000000..5738dfe Binary files /dev/null and b/public/uploads/merchants/m_zaowang.png differ diff --git a/public/uploads/wxacode/464175c25150aef6636b784bb591970e.png b/public/uploads/wxacode/464175c25150aef6636b784bb591970e.png new file mode 100644 index 0000000..cb744ef Binary files /dev/null and b/public/uploads/wxacode/464175c25150aef6636b784bb591970e.png differ diff --git a/public/uploads/wxacode/4d256d9e6625f2e8e0102df8958dad93.png b/public/uploads/wxacode/4d256d9e6625f2e8e0102df8958dad93.png new file mode 100644 index 0000000..9495030 Binary files /dev/null and b/public/uploads/wxacode/4d256d9e6625f2e8e0102df8958dad93.png differ diff --git a/public/uploads/wxacode/69bd2865feadf4beb9240134166f7e4a.png b/public/uploads/wxacode/69bd2865feadf4beb9240134166f7e4a.png new file mode 100644 index 0000000..2343a33 Binary files /dev/null and b/public/uploads/wxacode/69bd2865feadf4beb9240134166f7e4a.png differ diff --git a/tools/analyze_integrity.php b/tools/analyze_integrity.php deleted file mode 100644 index a6ebf56..0000000 --- a/tools/analyze_integrity.php +++ /dev/null @@ -1,58 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - echo "=== 1. 所有发布帖子的 user_id 在 fa_user 中是否存在 ===" . PHP_EOL; - $postUsers = $pdo->query("SELECT DISTINCT user_id FROM fa_fc_post")->fetchAll(PDO::FETCH_COLUMN); - foreach ($postUsers as $uid) { - $user = $pdo->query("SELECT * FROM fa_user WHERE id = $uid")->fetch(PDO::FETCH_ASSOC); - if (!$user) { - echo "【警告】Post 的 user_id={$uid} 在 fa_user 中不存在!" . PHP_EOL; - } else { - $ext = $pdo->query("SELECT * FROM fa_fc_user_ext WHERE user_id = $uid")->fetch(PDO::FETCH_ASSOC); - $merchant = $pdo->query("SELECT * FROM fa_fc_merchant WHERE user_id = $uid")->fetch(PDO::FETCH_ASSOC); - echo "Post user_id: {$uid} | 用户名: {$user['username']} | 昵称: {$user['nickname']} | 头像: " . ($user['avatar'] ?: '【空】') . " | 手机: " . ($user['mobile'] ?: '【空】') . " | user_ext: " . ($ext ? "有(type={$ext['user_type']})" : '【无】') . " | merchant: " . ($merchant ? "有(name={$merchant['name']}, logo=" . ($merchant['logo'] ?: '【空】') . ")" : '无') . PHP_EOL; - } - } - - echo PHP_EOL . "=== 2. 查看 fa_fc_merchant 现有所有商家的详情 ===" . PHP_EOL; - $merchants = $pdo->query("SELECT * FROM fa_fc_merchant")->fetchAll(PDO::FETCH_ASSOC); - foreach ($merchants as $m) { - echo "Merchant ID: {$m['id']}, user_id: {$m['user_id']}, name: {$m['name']}, contact: {$m['contact']}, logo: " . ($m['logo'] ?: '【空】') . ", banner: " . ($m['banner'] ?: '【空】') . ", address: " . ($m['address'] ?: '【空】') . ", lat: {$m['lat']}, lng: {$m['lng']}, category: {$m['category']}, shop_status: {$m['shop_status']}, audit_status: {$m['audit_status']}" . PHP_EOL; - } - - echo PHP_EOL . "=== 3. 检查 fa_fc_post 中各帖子的 identity_type 与 user/merchant 匹配情况 ===" . PHP_EOL; - $posts = $pdo->query("SELECT p.id, p.user_id, p.category_id, p.identity_type, p.title, p.contact, p.phone, u.nickname, ue.user_type FROM fa_fc_post p LEFT JOIN fa_user u ON p.user_id = u.id LEFT JOIN fa_fc_user_ext ue ON p.user_id = ue.user_id WHERE p.deletetime IS NULL")->fetchAll(PDO::FETCH_ASSOC); - $inconsistent = []; - foreach ($posts as $p) { - $m = $pdo->query("SELECT * FROM fa_fc_merchant WHERE user_id = {$p['user_id']}")->fetch(PDO::FETCH_ASSOC); - $isMerchantUser = ($p['user_type'] == 2 || $m); - if ($isMerchantUser && $p['identity_type'] != 2) { - $inconsistent[] = "Post #{$p['id']} '{$p['title']}' (user_id={$p['user_id']}, {$p['nickname']}) 是商家身份但 identity_type={$p['identity_type']}"; - } - } - echo "商家发布但 identity_type 为个人的帖子数量: " . count($inconsistent) . PHP_EOL; - foreach (array_slice($inconsistent, 0, 10) as $msg) { - echo " " . $msg . PHP_EOL; - } - - echo PHP_EOL . "=== 4. 检查用户表中的头像和手机号情况 ===" . PHP_EOL; - $users = $pdo->query("SELECT * FROM fa_user")->fetchAll(PDO::FETCH_ASSOC); - foreach ($users as $u) { - $issues = []; - if (empty($u['avatar'])) $issues[] = "无头像"; - if (empty($u['mobile'])) $issues[] = "无手机"; - if (empty($u['nickname']) || strpos($u['nickname'], '同城用户') === 0) $issues[] = "昵称弱({$u['nickname']})"; - if (!empty($issues)) { - echo "User #{$u['id']} ({$u['username']}): " . implode(', ', $issues) . PHP_EOL; - } - } -} - -analyzeIntegrity(); diff --git a/tools/banner1.html b/tools/banner1.html deleted file mode 100644 index e2ca551..0000000 --- a/tools/banner1.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - - - - - - - - - - 同城优选 · 真实验货 - 同城好物节 二手直降 - 闲置数码家居 · 当面验货超省心 · 邻里自提 - - 立即捡漏 → - ★ 爆款 3 折起 - - - - - - - - - - - - - - - - - - - - - - - ¥ - - - - % - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/banner2.html b/tools/banner2.html deleted file mode 100644 index da92033..0000000 --- a/tools/banner2.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - - - - - - - - 名企急招 · 官方认证 - 同城高薪直聘 急速入职 - 海量好岗任选 · 直接聊老板 · 真实高薪看得见 - - 去投简历 → - ⚡ 72h 急速反馈 - - - - - - - - - - - - - - - - - - - - - - - - - - OFFER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/banner3.html b/tools/banner3.html deleted file mode 100644 index 88213ac..0000000 --- a/tools/banner3.html +++ /dev/null @@ -1,236 +0,0 @@ - - - - - - - - - - - - - - - 准时上门 · 售后无忧 - 本地便民到家 贴心省心 - 家庭保洁开荒 · 维修拆洗搬家 · 专业师傅随叫随到 - - 一键预约 → - 🎁 新人立减 20元 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/banners_render.html b/tools/banners_render.html deleted file mode 100644 index 0c8540b..0000000 --- a/tools/banners_render.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - - - - - - - - - - - - 同城优选 · 真实验货 - 同城好物节 二手直降 - 闲置数码家居 · 当面验货超省心 · 邻里自提 - - 立即捡漏 → - ★ 爆款 3 折起起 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ¥ - - - - % - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 真实企业 · 官方认证 - 同城高薪直聘 急速入职 - 海量好岗任选 · 直接聊老板 · 薪资透明全覆盖 - - 去投简历 → - ⚡ 72h 急速反馈 - - - - - - - - - - - - - - - - - - - - - - - - - OFFER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 准时上门 · 售后保障 - 本地便民到家 贴心无忧 - 家政保洁开荒 · 管道维修家电拆洗 · 随叫随到 - - 一键预约 → - 🎁 首单立减 20元 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tools/build_national_areas.mjs b/tools/build_national_areas.mjs deleted file mode 100644 index 7633b18..0000000 --- a/tools/build_national_areas.mjs +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 从 fastadmin-citypicker 的全国行政区划库生成标准全国省/市/区县平铺数据(application/extra/national_areas.json) - * 包含全国 34 个省级行政区、所有地级市与区县,并内置各省市及核心区县中心坐标,支持全国定位自动匹配。 - */ -import fs from 'node:fs' -import path from 'node:path' -import vm from 'node:vm' -import url from 'node:url' - -const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..') -const CITY_PICKER_PATH = path.join(ROOT, 'public/assets/libs/fastadmin-citypicker/dist/js/city-picker.data.js') -const OUT_JSON_PATH = path.join(ROOT, 'application/extra/national_areas.json') - -const code = fs.readFileSync(CITY_PICKER_PATH, 'utf8') -const sandbox = { window: {} } -vm.createContext(sandbox) -vm.runInContext(code, sandbox) -const ChineseDistricts = sandbox.window.ChineseDistricts - -if (!ChineseDistricts || !ChineseDistricts[86]) { - throw new Error('Failed to load ChineseDistricts') -} - -/** - * 规范化省级名称 - * @param {string} name - * @returns {string} - */ -function normalizeProvince(name) { - const val = String(name || '').trim() - const map = { - '北京': '北京市', - '天津': '天津市', - '上海': '上海市', - '重庆': '重庆市', - '台湾': '台湾省', - } - return map[val] || val -} - -// 保留现有数据库 ID 映射,确保历史帖子 district_id 与自动化测试 100% 兼容 -const LEGACY_ID_MAP = { - '贵州省|贵阳市|云岩区': { id: 1, lat: 26.605, lng: 106.710 }, - '贵州省|贵阳市|南明区': { id: 2, lat: 26.568, lng: 106.716 }, - '贵州省|贵阳市|观山湖区': { id: 3, lat: 26.625, lng: 106.625 }, - '贵州省|贵阳市|花溪区': { id: 4, lat: 26.430, lng: 106.670 }, - '贵州省|贵阳市|白云区': { id: 5, lat: 26.678, lng: 106.623 }, - '贵州省|毕节市|七星关区': { id: 7, lat: 27.298273, lng: 105.305161 }, -} - -// 全国各省会、直辖市及贵州各市州/区县代表性中心坐标(gcj02),用于全国定位就近匹配 -const COORD_MAP = { - // 贵州省各市州及区县 - '贵州省|贵阳市|乌当区': [26.630, 106.750], - '贵州省|贵阳市|开阳县': [27.057, 106.969], - '贵州省|贵阳市|息烽县': [27.090, 106.740], - '贵州省|贵阳市|修文县': [26.840, 106.590], - '贵州省|贵阳市|清镇市': [26.550, 106.470], - '贵州省|六盘水市': [26.592, 104.830], - '贵州省|遵义市': [27.725, 106.927], - '贵州省|安顺市': [26.253, 105.947], - '贵州省|毕节市': [27.298, 105.305], - '贵州省|铜仁市': [27.731, 109.189], - '贵州省|黔西南布依族苗族自治州': [25.087, 104.906], - '贵州省|黔东南苗族侗族自治州': [26.583, 107.982], - '贵州省|黔南布依族苗族自治州': [26.254, 107.522], - // 全国主要省级/省会坐标 - '北京市': [39.9042, 116.4074], - '天津市': [39.0842, 117.2010], - '河北省': [38.0428, 114.5149], - '山西省': [37.8706, 112.5489], - '内蒙古自治区': [40.8426, 111.7492], - '辽宁省': [41.8057, 123.4315], - '吉林省': [43.8171, 125.3235], - '黑龙江省': [45.8038, 126.5350], - '上海市': [31.2304, 121.4737], - '江苏省': [32.0603, 118.7969], - '浙江省': [30.2741, 120.1551], - '安徽省': [31.8206, 117.2272], - '福建省': [26.0745, 119.2965], - '江西省': [28.6820, 115.8579], - '山东省': [36.6512, 117.1201], - '河南省': [34.7466, 113.6253], - '湖北省': [30.5928, 114.3055], - '湖南省': [28.2282, 112.9388], - '广东省': [23.1291, 113.2644], - '广西壮族自治区': [22.8170, 108.3665], - '海南省': [20.0440, 110.1999], - '重庆市': [29.5630, 106.5516], - '四川省': [30.5728, 104.0668], - '贵州省': [26.5982, 106.7074], - '云南省': [25.0406, 102.7123], - '西藏自治区': [29.6525, 91.1721], - '陕西省': [34.3416, 108.9398], - '甘肃省': [36.0611, 103.8343], - '青海省': [36.6171, 101.7782], - '宁夏回族自治区': [38.4872, 106.2309], - '新疆维吾尔自治区': [43.8256, 87.6168], - '台湾省': [25.0330, 121.5654], - '香港特别行政区': [22.3193, 114.1694], - '澳门特别行政区': [22.1987, 113.5439], -} - -// 收集全部省份条目 -const provEntries = [] -for (const groupKey of Object.keys(ChineseDistricts[86])) { - const list = ChineseDistricts[86][groupKey] - if (Array.isArray(list)) { - for (const item of list) { - if (item && item.code && item.address) { - provEntries.push(item) - } - } - } -} - -// 排序:将贵州省、重庆市、四川省、云南省排在前面方便本地及周边优先展示,同时包含全国所有省份 -const priorityOrder = ['520000', '500000', '510000', '530000'] -provEntries.sort((a, b) => { - const idxA = priorityOrder.indexOf(String(a.code)) - const idxB = priorityOrder.indexOf(String(b.code)) - if (idxA !== -1 && idxB !== -1) return idxA - idxB - if (idxA !== -1) return -1 - if (idxB !== -1) return 1 - return String(a.code).localeCompare(String(b.code)) -}) - -const flatRows = [] - -for (const prov of provEntries) { - const provCode = String(prov.code) - const provName = normalizeProvince(prov.address) - const citiesObj = ChineseDistricts[provCode] || {} - - for (const cityCode of Object.keys(citiesObj)) { - const cityName = String(citiesObj[cityCode]).trim() - const districtsObj = ChineseDistricts[cityCode] || {} - const distCodes = Object.keys(districtsObj) - - if (distCodes.length === 0) { - // 直筒子市(如东莞、中山、儋州等无下辖区县时以市名作为区县级节点) - const id = Number(cityCode) - const coord = COORD_MAP[`${provName}|${cityName}`] || COORD_MAP[provName] || [null, null] - flatRows.push({ - id, - province: provName, - city: cityName, - district: cityName, - lat: coord[0], - lng: coord[1], - }) - continue - } - - let firstInCity = true - for (const distCode of distCodes) { - const distName = String(districtsObj[distCode]).trim() - const fullKey = `${provName}|${cityName}|${distName}` - const cityKey = `${provName}|${cityName}` - const legacy = LEGACY_ID_MAP[fullKey] - - let lat = null - let lng = null - if (legacy) { - lat = legacy.lat - lng = legacy.lng - } else if (COORD_MAP[fullKey]) { - lat = COORD_MAP[fullKey][0] - lng = COORD_MAP[fullKey][1] - } else if (firstInCity && COORD_MAP[cityKey]) { - lat = COORD_MAP[cityKey][0] - lng = COORD_MAP[cityKey][1] - } else if (firstInCity && COORD_MAP[provName]) { - lat = COORD_MAP[provName][0] - lng = COORD_MAP[provName][1] - } - firstInCity = false - - flatRows.push({ - id: legacy ? legacy.id : Number(distCode), - province: provName, - city: cityName, - district: distName, - lat, - lng, - }) - } - } -} - -fs.writeFileSync(OUT_JSON_PATH, JSON.stringify(flatRows), 'utf8') -console.log(`Generated ${flatRows.length} nationwide district rows -> ${OUT_JSON_PATH}`) diff --git a/tools/check-admin-i18n.mjs b/tools/check-admin-i18n.mjs deleted file mode 100644 index 23311e6..0000000 --- a/tools/check-admin-i18n.mjs +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -/** - * 后台字段中文化校验(FastAdmin PC) - * - * 校验目标: - * 1) 列表/表单里所有 `__('Key')` 都能在对应语言包里找到**中文**映射 - * —— 缺失即会在后台显示英文 key(这正是「字段没中文化」的直接原因) - * 2) 语言包的值确实包含中文(排除照抄英文/注释原文的情况) - * 3) 已知枚举字段都加了 searchList + formatter(取值不会是裸数字/英文枚举) - * - * 用法:node tools/check-admin-i18n.mjs - */ -import fs from 'node:fs' -import path from 'node:path' -import url from 'node:url' - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) -const ROOT = path.resolve(__dirname, '..') -const JS_DIR = path.join(ROOT, 'public/assets/js/backend/fc') -const LANG_DIR = path.join(ROOT, 'application/admin/lang/zh-cn/fc') -const VIEW_DIR = path.join(ROOT, 'application/admin/view/fc') - -let errors = 0 -let warns = 0 -const bad = (m) => { console.log(` ❌ ${m}`); errors++ } -const warn = (m) => { console.log(` ⚠️ ${m}`); warns++ } -const ok = (m) => console.log(` ✅ ${m}`) - -/** 提取源码里所有 __('Key') / __("Key") 的 key */ -function extractKeys(src) { - const keys = new Set() - const re = /__\(\s*['"]([^'"]+)['"]\s*\)/g - let m - while ((m = re.exec(src))) keys.add(m[1]) - return keys -} - -/** 解析语言包 return [ 'Key' => '值', ... ] */ -function parseLang(file) { - const map = new Map() - if (!fs.existsSync(file)) return map - const src = fs.readFileSync(file, 'utf8') - const re = /'([^']+)'\s*=>\s*'((?:[^'\\]|\\.)*)'/g - let m - while ((m = re.exec(src))) map.set(m[1], m[2].replace(/\\'/g, "'")) - return map -} - -const hasCJK = (s) => /[\u4e00-\u9fa5]/.test(s) - -/** 已知枚举字段 → 期望在 js 中同时出现 searchList(取值中文化) */ -const ENUM_FIELDS = { - audit_status: true, status: true, is_paid: true, is_top: true, top: true, - is_member: true, isread: true, enabled: true, user_type: true, pay_type: true, - level: true, realname_status: true, shop_status: true, target_type: true, type: true, - channel: true, order_type: true, -} - -const modules = fs.readdirSync(JS_DIR).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, '')).sort() -if (!modules.length) { - console.log('未找到后台模块 JS,路径:' + JS_DIR) - process.exit(1) -} - -// FastAdmin 全局语言包:内置 key(Add/Edit/Delete/OK/Operate/Refresh/Recycle bin…) -// 都在这里有中文映射,因此缺失判定必须「全局 + 模块」都不存在才算错误。 -const GLOBAL_LANG = parseLang(path.join(ROOT, 'application/admin/lang/zh-cn.php')) -// 这些值本身不是中文但完全正确,不计入「不含中文」告警 -const CJK_EXEMPT = new Set(['ID', 'IP', 'URL', 'API', 'VIP', 'LOGO', 'Logo', 'OpenID', 'ok']) - -console.log(`\n后台字段中文化校验(${modules.length} 个模块,全局语言包 ${GLOBAL_LANG.size} 条)\n`) - -for (const name of modules) { - console.log(`[${name}]`) - const lang = parseLang(path.join(LANG_DIR, `${name}.php`)) - if (!lang.size) bad(`${name}.php 语言包为空或不存在`) - - // 1) 收集该模块 js + 视图里用到的所有 __() key - const files = [path.join(JS_DIR, `${name}.js`)] - const viewDir = path.join(VIEW_DIR, name) - if (fs.existsSync(viewDir)) { - for (const v of fs.readdirSync(viewDir)) { - if (v.endsWith('.html')) files.push(path.join(viewDir, v)) - } - } - const keys = new Set() - for (const f of files) { - for (const k of extractKeys(fs.readFileSync(f, 'utf8'))) keys.add(k) - } - - // 2) 缺映射 → 会显示英文 - // 注意:key 本身含中文时(如 __('待审核')),FastAdmin 的 __() 查不到映射会原样 - // 返回 key,显示仍然是中文,因此**不算缺**。只统计「英文 key 且无映射」的情况。 - const missing = [...keys] - .filter((k) => !hasCJK(k) && !lang.has(k) && !GLOBAL_LANG.has(k)) - .sort() - if (missing.length) { - bad(`缺少中文映射 ${missing.length} 个(后台将显示英文):${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ' …' : ''}`) - } else { - ok(`字段标题全部有中文映射(${keys.size} 个 key)`) - } - - // 3) 模块自身语言包的值必须含中文 - const nonCJK = [...lang.entries()] - .filter(([, v]) => !hasCJK(v) && !CJK_EXEMPT.has(v)) - .map(([k, v]) => `${k}=${v}`) - if (nonCJK.length) warn(`这些标签不含中文,请确认:${nonCJK.slice(0, 8).join(', ')}`) - - // 4) 枚举字段应有 searchList(取值中文化) - const js = fs.readFileSync(path.join(JS_DIR, `${name}.js`), 'utf8') - const fieldRe = /field:\s*'([a-zA-Z_]+)'/g - const usedEnums = new Set() - let fm - while ((fm = fieldRe.exec(js))) { - if (ENUM_FIELDS[fm[1]]) usedEnums.add(fm[1]) - } - const noSearch = [...usedEnums].filter((f) => { - // 该字段所在的列对象里是否出现 searchList - const seg = js.split(new RegExp(`field:\\s*'${f}'`))[1] || '' - const block = seg.slice(0, 400) - return !/searchList/.test(block) - }) - if (noSearch.length) { - warn(`枚举字段缺 searchList(取值可能显示为原值):${noSearch.join(', ')}`) - } else if (usedEnums.size) { - ok(`枚举字段取值已中文化(${[...usedEnums].join(', ')})`) - } -} - -// 5) 语言包缓存策略回归(详见 docs/06 第五章「改完为什么不生效」) -// 2026-10 线上事故:接口内容已是中文,但浏览器缓存了整包一个月,后台标题仍是英文 key -const ajaxFile = path.join(ROOT, 'application/admin/controller/Ajax.php') -const tagsFile = path.join(ROOT, 'application/tags.php') -const assetsFile = path.join(ROOT, 'application/common/behavior/FcAssets.php') -console.log('\n[语言包缓存策略]') -if (!fs.existsSync(assetsFile)) { - bad('缺少 application/common/behavior/FcAssets.php(站点版本号无法随语言包/JS 自动刷新)') -} else if (!/FcAssets/.test(fs.readFileSync(tagsFile, 'utf8'))) { - bad('application/tags.php 未注册 FcAssets 行为(app_init)') -} else { - ok('FcAssets 行为已注册(改语言包/JS 后资源 URL 自动变化)') -} -const ajaxSrc = fs.readFileSync(ajaxFile, 'utf8') -const langStart = ajaxSrc.indexOf('public function lang()') -const langEnd = ajaxSrc.indexOf('public function upload()') -const langFn = langStart >= 0 && langEnd > langStart ? ajaxSrc.slice(langStart, langEnd) : '' -if (!langFn) { - bad('未找到 Ajax::lang() 方法') -} else if (/'Expires'|"Expires"|30 \* 60 \* 60 \* 24/.test(langFn)) { - bad('Ajax::lang() 改回了长缓存(Expires),会再次出现「改了语言包后台仍显示英文」') -} else if (!/ETag/.test(langFn) || !/must-revalidate/.test(langFn)) { - bad('Ajax::lang() 缺少 ETag / must-revalidate,语言包可能被浏览器长期缓存') -} else { - ok('Ajax::lang() 按内容生成 ETag 且强制校验') -} - -console.log('\n------------------------------------------') -console.log(`中文化校验结束:错误 ${errors},警告 ${warns}`) -console.log('------------------------------------------\n') -if (errors) process.exitCode = 1 diff --git a/tools/check-theme.mjs b/tools/check-theme.mjs deleted file mode 100644 index ea2c4fa..0000000 --- a/tools/check-theme.mjs +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -/** - * 后台主题守卫(春绿 Spring Green v4) - * - * 校验目标: - * 1) 后台视图 / 业务 JS / 业务 CSS 里不残留**历史主题色** - * —— ① 旧暖橙(#FF8A5B 一族)② v1.2 生态绿(#0e7c66 一族)③ 其半透明衍生色 rgba(14,124,102,·) - * 例外:`FcInstall.php` 的 LEGACY_COLOR_KEYS 是「故意保留的历史色指纹」,用于幂等升级,不算残留 - * 2) 登录页品牌令牌齐全且亮绿只做「面」(按钮墨绿字,白字不落在亮绿上) - * 3) `FcInstall::BANNERS` 三条内置轮播底色都是春绿 105deg 渐变(亮绿→深绿) - * 4) `LEGACY_COLOR_KEYS` 同时覆盖「旧暖橙 + 上一版生态绿」两族 - * —— 线上库经常落后一个主题版本,缺哪一族就会漏升级(v1.0.6 实测线上仍是 #FF8A5B) - * 5) 轮播表单的占位示例与主题一致(105deg 亮绿→深绿) - * - * 用法:node tools/check-theme.mjs - */ -import fs from 'node:fs' -import path from 'node:path' -import url from 'node:url' - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) -const ROOT = path.resolve(__dirname, '..') - -let errors = 0 -let warns = 0 -const bad = (m) => { console.log(` ❌ ${m}`); errors++ } -const warn = (m) => { console.log(` ⚠️ ${m}`); warns++ } -const ok = (m) => console.log(` ✅ ${m}`) -const read = (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '') - -/* ---------------- 1) 历史主题色残留扫描 ---------------- */ - -/** 历史主题色(小写;同时匹配 #RRGGBB 与 rgba() 形式) */ -const RETIRED = { - '#ff8a5b': '旧暖橙 v1.0.x', '#ffb86b': '旧暖橙 v1.0.x', '#ff6f61': '旧暖橙 v1.0.x', - '#ff9a3c': '旧暖橙 v1.0.x', '#ff7a45': '旧暖橙 v1.0.x', - '#18a587': '生态绿 v1.2', '#0e7c66': '生态绿 v1.2', '#0a5d4d': '生态绿 v1.2', - '#04302a': '生态绿 v1.2', '#16897f': '生态绿 v1.2', '#0e6b62': '生态绿 v1.2', - '#07443e': '生态绿 v1.2', '#0f8f72': '生态绿 v1.2', '#0a5f4d': '生态绿 v1.2', - 'rgba(14, 124, 102': '生态绿 v1.2(半透明衍生色)', - 'rgba(14,124,102': '生态绿 v1.2(半透明衍生色)', - 'rgba(18, 161, 132': '生态绿 v1.2(半透明衍生色)', -} - -/** 仅扫描「后台自己写的」代码,第三方资源(bootstrap/fastadmin 等)不动 */ -const SCAN_DIRS = [ - 'application/admin/view', - 'application/admin/lang', - 'application/admin/command', - 'public/assets/js/backend/fc', - 'public/assets/css/backend.css', -] -const SKIP_FILE = /FcInstall\.php$/ - -function walk(target) { - const abs = path.join(ROOT, target) - if (!fs.existsSync(abs)) return [] - const st = fs.statSync(abs) - if (st.isFile()) return [abs] - return fs.readdirSync(abs).flatMap((f) => walk(path.join(target, f))) -} - -console.log('== 1) 历史主题色残留扫描 ==') -let scanned = 0 -const hits = [] -for (const file of SCAN_DIRS.flatMap(walk)) { - if (!/\.(html|js|css|php|json)$/.test(file) || SKIP_FILE.test(file)) continue - const src = read(file).toLowerCase() - if (!src) continue - scanned++ - for (const key of Object.keys(RETIRED)) { - const idx = src.indexOf(key) - if (idx === -1) continue - const line = read(file).slice(0, idx).split('\n').length - hits.push(`${path.relative(ROOT, file)}:${line} → ${key}(${RETIRED[key]})`) - } -} -if (hits.length) hits.forEach((h) => bad(h)) -else ok(`已扫描 ${scanned} 个文件,无历史主题色残留(旧暖橙 / 生态绿)`) - -/* ---------------- 2) 登录页品牌令牌 ---------------- */ - -console.log('== 2) 登录页品牌令牌(亮绿只做面)==') -const login = read(path.join(ROOT, 'application/admin/view/index/login.html')) -const loginChecks = [ - ['--brand: #04874a', '白底可读的品牌绿字/描边 #04874A'], - ['--brand-bright: #00e070', '品牌亮绿 #00E070(与小程序 $fc-primary 同色号)'], - ['--brand-ink: #04301e', '亮绿面上的墨绿字 #04301E'], - ['linear-gradient(152deg, #023a21 0%, #046b3b 50%, #04874a 115%)', '左侧品牌栏深绿渐变(白字安全)'], -] -loginChecks.forEach(([needle, desc]) => { - if (login.includes(needle)) ok(desc) - else bad(`登录页缺少:${desc}`) -}) -// 登录按钮:亮绿面必须配墨绿字 -const btn = login.match(/\.login-btn[^{]*\{[^}]*\}/s) -if (btn && /var\(--brand-bright\)/.test(btn[0])) { - if (/color:\s*var\(--brand-ink\)/.test(btn[0])) ok('登录按钮 = 亮绿渐变面 + 墨绿字(对比度 8.2:1)') - else bad('登录按钮用了亮绿面却不是墨绿字(白字压亮绿仅 1.77:1,不可读)') -} else { - warn('未在登录页匹配到 .login-btn 亮绿面按钮(样式结构可能已调整)') -} - -/* ---------------- 3) 内置轮播底色 ---------------- */ - -console.log('== 3) FcInstall 内置轮播底色 ==') -const install = read(path.join(ROOT, 'application/admin/command/FcInstall.php')) -const banners = install.match(/const BANNERS = \[([\s\S]*?)\n \];/) -if (!banners) { - bad('未找到 FcInstall::BANNERS 定义') -} else { - const rows = banners[1].split('\n').filter((l) => l.includes('linear-gradient')) - if (rows.length !== 3) bad(`内置轮播应为 3 条,实际识别到 ${rows.length} 条`) - else ok('3 条内置轮播齐备') - rows.forEach((r, i) => { - if (/linear-gradient\(105deg, #00e070|linear-gradient\(105deg, #00d96c|linear-gradient\(105deg, #00e878/.test(r) - && /#0(0c860|0b85c|49e52|46b3b|23a21|45c34)|#046b3b|#023a21|#045c34/.test(r)) ok(`轮播 ${i + 1}:105deg 亮绿→深绿`) - else bad(`轮播 ${i + 1} 底色不是春绿 105deg 渐变:${r.trim().slice(0, 90)}`) - }) -} - -/* ---------------- 4) 历史色指纹覆盖度 ---------------- */ - -console.log('== 4) LEGACY_COLOR_KEYS 覆盖度(跨版本升级)==') -const legacy = install.match(/const LEGACY_COLOR_KEYS = \[([\s\S]*?)\n \];/) -if (!legacy) { - bad('未找到 LEGACY_COLOR_KEYS(老库升级会漏色)') -} else { - const list = legacy[1] - const groups = [ - ['旧暖橙族', ['ff8a5b', 'ffb86b', 'ff6f61']], - ['生态绿族', ['0e7c66', '0a5d4d', '04302a', '18a587']], - ] - groups.forEach(([name, keys]) => { - const missing = keys.filter((k) => !list.includes(k)) - if (missing.length) bad(`${name} 指纹缺失:${missing.join(', ')}(线上停留该版本时不会被升级)`) - else ok(`${name} 指纹齐备(${keys.join(' / ')})`) - }) -} - -/* ---------------- 5) 轮播表单占位示例 ---------------- */ - -console.log('== 5) 轮播表单占位示例 ==') -for (const f of ['application/admin/view/fc/banner/add.html', 'application/admin/view/fc/banner/edit.html']) { - const src = read(path.join(ROOT, f)) - const ph = src.match(/placeholder="(linear-gradient\([^"]*\))"/) - if (!ph) { warn(`${f} 未找到底色占位示例`); continue } - if (/^linear-gradient\(105deg, #00E070 /.test(ph[1])) ok(`${path.basename(f)}:${ph[1]}`) - else bad(`${path.basename(f)} 占位示例不是春绿 105deg:${ph[1]}`) -} - -console.log('') -console.log(`主题守卫结束:错误 ${errors},警告 ${warns}`) -process.exit(errors ? 1 : 0) diff --git a/tools/check_admin_rules.php b/tools/check_admin_rules.php deleted file mode 100644 index e756cf7..0000000 --- a/tools/check_admin_rules.php +++ /dev/null @@ -1,17 +0,0 @@ -query("SELECT id, pid, name, title, ismenu, icon FROM fa_auth_rule WHERE name LIKE 'fc/%' OR name = 'fc' ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); - echo "=== 后台同城分类业务菜单节点 (共 " . count($rules) . " 项) ===" . PHP_EOL; - foreach ($rules as $r) { - $type = $r['ismenu'] ? '【菜单】' : '【权限】'; - echo "ID: {$r['id']} | PID: {$r['pid']} | {$type} {$r['title']} ({$r['name']}) | 图标: {$r['icon']}" . PHP_EOL; - } -} - -checkAdminRules(); diff --git a/tools/check_db_stats.php b/tools/check_db_stats.php deleted file mode 100644 index 6eaab73..0000000 --- a/tools/check_db_stats.php +++ /dev/null @@ -1,24 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $tables = $pdo->query("SHOW TABLES LIKE 'fa_fc_%'")->fetchAll(PDO::FETCH_COLUMN); - echo "=== 业务表数据量统计 ===" . PHP_EOL; - foreach ($tables as $t) { - $c = $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn(); - printf("%-26s : %d 行\n", $t, $c); - } - - $users = $pdo->query("SELECT COUNT(*) FROM fa_user")->fetchColumn(); - $admins = $pdo->query("SELECT COUNT(*) FROM fa_admin")->fetchColumn(); - printf("%-26s : %d 行\n", 'fa_user', $users); - printf("%-26s : %d 行\n", 'fa_admin', $admins); -} - -checkStats(); diff --git a/tools/gen-brand-logo.mjs b/tools/gen-brand-logo.mjs deleted file mode 100644 index cfa12b9..0000000 --- a/tools/gen-brand-logo.mjs +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env node -/** - * 品牌 LOGO 生成器(后台登录页用) - * ------------------------------------------------------------ - * 输入:一张「圆形徽标 + 品牌文字」的方形品牌图(豆包 AI 生成的那种) - * 输出:public/assets/img/brand-logo.png —— 只保留顶部圆形徽标, - * 圆外像素置为完全透明(边缘按 alpha 平滑过渡),供登录页品牌位使用。 - * - * 算法(为什么这样做): - * 1) 先取左上角小块均值作为「背景参考色」(源图是米白底、不是纯白,不能用 rgb>=250 判白), - * 再逐行扫描「与背景色距 > 阈值」的内容像素,取**第一条连续内容行带** —— 它就是最上方的 - * 圆形徽标,因此徽标下方的品牌文字(含旧站名)与右下角的 AI 水印天然被排除; - * 2) 以该行带的包围盒求出圆心 / 半径,按圆生成 alpha 蒙版(1px 抗锯齿); - * 3) 用「按 alpha 预乘的面积平均」重采样到目标尺寸,得到无底噪的透明 PNG。 - * - * 用法: - * node tools/gen-brand-logo.mjs [源图] [--preview 输出图] - * [源图] 默认 ~/Desktop/山间小仓.png(源图不入库,只提交生成结果) - * --preview 路径 额外输出一张「深绿底 / 白底 × 52px / 140px」的合成校验图 - * - * 说明:脚本零依赖(仅 node 内置模块),PNG 编解码为 8bit / RGB / RGBA。 - */ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import url from 'node:url' -import zlib from 'node:zlib' - -const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..') - -/* ------------------------------------------------------------------ * - * 最小 PNG 编解码(8bit:灰度 / 真彩 / 带 alpha) - * ------------------------------------------------------------------ */ -const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -let CRC_TABLE = null -function crcTable() { - if (CRC_TABLE) return CRC_TABLE - CRC_TABLE = new Int32Array(256) - for (let n = 0; n < 256; n++) { - let c = n - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 - CRC_TABLE[n] = c - } - return CRC_TABLE -} - -function crc32(buf) { - const t = crcTable() - let c = 0xffffffff - for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8) - return (c ^ 0xffffffff) >>> 0 -} - -function chunk(type, data) { - const len = Buffer.alloc(4) - len.writeUInt32BE(data.length, 0) - const typeBuf = Buffer.from(type, 'ascii') - const crc = Buffer.alloc(4) - crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0) - return Buffer.concat([len, typeBuf, data, crc]) -} - -/** 解码 PNG -> { width, height, rgba } */ -function decodePng(buf) { - if (!buf.slice(0, 8).equals(SIG)) throw new Error('不是有效的 PNG') - let off = 8 - let width = 0 - let height = 0 - let colorType = 0 - let bitDepth = 0 - const idat = [] - while (off < buf.length) { - const len = buf.readUInt32BE(off) - const type = buf.toString('ascii', off + 4, off + 8) - const data = buf.slice(off + 8, off + 8 + len) - if (type === 'IHDR') { - width = data.readUInt32BE(0) - height = data.readUInt32BE(4) - bitDepth = data[8] - colorType = data[9] - } else if (type === 'IDAT') idat.push(data) - else if (type === 'IEND') break - off += 12 + len - } - if (bitDepth !== 8) throw new Error('仅支持 8bit PNG') - const ch = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : 1 - const raw = zlib.inflateSync(Buffer.concat(idat)) - const stride = width * ch - const rgba = Buffer.alloc(width * height * 4) - let prev = Buffer.alloc(stride) - for (let y = 0; y < height; y++) { - const ft = raw[y * (stride + 1)] - const line = Buffer.from(raw.slice(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)) - for (let i = 0; i < stride; i++) { - const a = i >= ch ? line[i - ch] : 0 - const b = prev[i] - const c = i >= ch ? prev[i - ch] : 0 - if (ft === 1) line[i] = (line[i] + a) & 255 - else if (ft === 2) line[i] = (line[i] + b) & 255 - else if (ft === 3) line[i] = (line[i] + ((a + b) >> 1)) & 255 - else if (ft === 4) { - const p = a + b - c - const pa = Math.abs(p - a) - const pb = Math.abs(p - b) - const pc = Math.abs(p - c) - line[i] = (line[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255 - } - } - for (let x = 0; x < width; x++) { - const si = x * ch - const di = (y * width + x) * 4 - if (ch === 4) { - rgba[di] = line[si]; rgba[di + 1] = line[si + 1]; rgba[di + 2] = line[si + 2]; rgba[di + 3] = line[si + 3] - } else if (ch === 3) { - rgba[di] = line[si]; rgba[di + 1] = line[si + 1]; rgba[di + 2] = line[si + 2]; rgba[di + 3] = 255 - } else { - rgba[di] = rgba[di + 1] = rgba[di + 2] = line[si]; rgba[di + 3] = ch === 2 ? line[si + 1] : 255 - } - } - prev = line - } - return { width, height, rgba } -} - -/** 编码 RGBA -> PNG(filter 0) */ -function encodePng({ width, height, rgba }) { - const stride = width * 4 - const raw = Buffer.alloc((stride + 1) * height) - for (let y = 0; y < height; y++) { - raw[y * (stride + 1)] = 0 - rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride) - } - const ihdr = Buffer.alloc(13) - ihdr.writeUInt32BE(width, 0) - ihdr.writeUInt32BE(height, 4) - ihdr[8] = 8 - ihdr[9] = 6 - return Buffer.concat([ - SIG, - chunk('IHDR', ihdr), - chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), - chunk('IEND', Buffer.alloc(0)), - ]) -} - -/** - * 任意尺寸重采样(按 alpha 预乘的面积平均) - * 比整数倍降采样更通用:可以一次到位输出 256 / 140 / 52 等任意边长。 - */ -function resize(img, w, h) { - const out = Buffer.alloc(w * h * 4) - const sx = img.width / w - const sy = img.height / h - for (let y = 0; y < h; y++) { - const y0 = Math.floor(y * sy) - const y1 = Math.max(y0 + 1, Math.min(img.height, Math.ceil((y + 1) * sy))) - for (let x = 0; x < w; x++) { - const x0 = Math.floor(x * sx) - const x1 = Math.max(x0 + 1, Math.min(img.width, Math.ceil((x + 1) * sx))) - let r = 0 - let g = 0 - let b = 0 - let a = 0 - let n = 0 - for (let yy = y0; yy < y1; yy++) { - for (let xx = x0; xx < x1; xx++) { - const d = (yy * img.width + xx) * 4 - const al = img.rgba[d + 3] - r += img.rgba[d] * al - g += img.rgba[d + 1] * al - b += img.rgba[d + 2] * al - a += al - n++ - } - } - const o = (y * w + x) * 4 - if (a === 0) { - out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0 - continue - } - out[o] = Math.round(r / a) - out[o + 1] = Math.round(g / a) - out[o + 2] = Math.round(b / a) - out[o + 3] = Math.round(a / n) - } - } - return { width: w, height: h, rgba: out } -} - -/* ------------------------------------------------------------------ * - * 主流程 - * ------------------------------------------------------------------ */ -const argv = process.argv.slice(2) -const previewIdx = argv.indexOf('--preview') -const previewPath = previewIdx >= 0 ? argv[previewIdx + 1] : '' -const srcArg = argv.find((a) => !a.startsWith('--') && a !== previewPath) -const SRC = srcArg ? path.resolve(srcArg) : path.join(os.homedir(), 'Desktop', '山间小仓.png') -const OUT = path.join(ROOT, 'public', 'assets', 'img', 'brand-logo.png') -const SIZE = 256 // 产物边长(登录页最大显示 140px,256 足够 2x 高清) -const MARGIN = 10 // 圆形徽标与画布边缘的留白(源图分辨率下) - -if (!fs.existsSync(SRC)) { - console.error(`❌ 源图不存在:${SRC}`) - process.exit(1) -} - -const src = decodePng(fs.readFileSync(SRC)) -const W = src.width -const H = src.height -const rgba = src.rgba - -/** 背景参考色:取左上角小块均值(源图四周是米白底,水印在右下角,不会污染这里) */ -const bgRgb = (() => { - let r = 0 - let g = 0 - let b = 0 - let n = 0 - for (let y = 2; y < 8; y++) { - for (let x = 2; x < 8; x++) { - const d = (y * W + x) * 4 - r += rgba[d] - g += rgba[d + 1] - b += rgba[d + 2] - n++ - } - } - return [r / n, g / n, b / n] -})() - -/** - * 该像素是否「有内容」 - * 注意:源图不是纯白底而是米白底(如 250,246,234), - * 因此不能用 rgb>=250 判定白底,必须与背景参考色比较色距。 - */ -const BG_TOL = 18 -function isInk(x, y) { - const d = (y * W + x) * 4 - const dr = rgba[d] - bgRgb[0] - const dg = rgba[d + 1] - bgRgb[1] - const db = rgba[d + 2] - bgRgb[2] - return Math.sqrt(dr * dr + dg * dg + db * db) > BG_TOL -} - -// 1) 逐行找连续非白行带:第一条即顶部圆形徽标(天然排除下方文字与右下角水印) -const GAP = 8 -const bands = [] -let cur = null -let whiteRun = 0 -for (let y = 0; y < H; y++) { - let ink = false - for (let x = 0; x < W; x++) { - if (isInk(x, y)) { ink = true; break } - } - if (ink) { - if (cur === null) cur = { y0: y, y1: y } - else cur.y1 = y - whiteRun = 0 - } else if (cur !== null) { - whiteRun++ - if (whiteRun >= GAP) { bands.push(cur); cur = null; whiteRun = 0 } - } -} -if (cur) bands.push(cur) -if (!bands.length) { - console.error('❌ 源图中未找到任何内容') - process.exit(1) -} - -const band = bands[0] -let minX = W -let maxX = -1 -for (let y = band.y0; y <= band.y1; y++) { - for (let x = 0; x < W; x++) { - if (isInk(x, y)) { - if (x < minX) minX = x - if (x > maxX) maxX = x - } - } -} -if (maxX < 0) { - console.error('❌ 第一条内容行带为空') - process.exit(1) -} - -const cx = (minX + maxX + 1) / 2 -const cy = (band.y0 + band.y1 + 1) / 2 -const R = Math.max(maxX - minX + 1, band.y1 - band.y0 + 1) / 2 -const S = Math.ceil(R * 2) + MARGIN * 2 - -// 2) 按圆生成 alpha 蒙版(圆外全透明,边缘 1px 渐变) -const disc = { width: S, height: S, rgba: Buffer.alloc(S * S * 4) } -for (let y = 0; y < S; y++) { - for (let x = 0; x < S; x++) { - const dx = x + 0.5 - S / 2 - const dy = y + 0.5 - S / 2 - let a = R - Math.sqrt(dx * dx + dy * dy) + 0.5 - if (a <= 0) continue - if (a > 1) a = 1 - const sx = Math.round(cx - S / 2 + x) - const sy = Math.round(cy - S / 2 + y) - if (sx < 0 || sy < 0 || sx >= W || sy >= H) continue - const si = (sy * W + sx) * 4 - const o = (y * S + x) * 4 - disc.rgba[o] = rgba[si] - disc.rgba[o + 1] = rgba[si + 1] - disc.rgba[o + 2] = rgba[si + 2] - disc.rgba[o + 3] = Math.round(a * 255) - } -} - -// 3) 重采样到目标尺寸并落盘 -const out = resize(disc, SIZE, SIZE) -fs.mkdirSync(path.dirname(OUT), { recursive: true }) -fs.writeFileSync(OUT, encodePng(out)) - -let clear = 0 -for (let i = 0; i < SIZE * SIZE; i++) if (out.rgba[i * 4 + 3] < 10) clear++ -const ratio = clear / (SIZE * SIZE) -const cornerOk = out.rgba[3] === 0 && out.rgba[(SIZE - 1) * 4 + 3] === 0 -const centerOk = out.rgba[((SIZE / 2) * SIZE + SIZE / 2) * 4 + 3] > 200 - -console.log(`源图 :${SRC}(${W}×${H})`) -console.log(`背景参考色:rgb(${bgRgb.map((v) => Math.round(v)).join(', ')})`) -console.log(`内容行带:共 ${bands.length} 条 [${bands.map((b) => `y=${b.y0}~${b.y1}`).join(' / ')}],取第 1 条 x=${minX}~${maxX}`) -console.log(`圆心/半径:(${cx.toFixed(1)}, ${cy.toFixed(1)}) / R=${R.toFixed(1)}`) -console.log(`产物 :${path.relative(ROOT, OUT)}(${SIZE}×${SIZE},${(fs.statSync(OUT).size / 1024).toFixed(1)} KB)`) -console.log(`自检 :圆外透明占比 ${(ratio * 100).toFixed(1)}% · 四角透明 ${cornerOk ? '✅' : '❌'} · 中心不透明 ${centerOk ? '✅' : '❌'}`) - -// 4) 可选:合成校验图(深绿底 / 白底 × 52px / 140px),人工确认缩到 52px 依然清晰 -if (previewPath) { - const PW = 760 - const PH = 440 - const canvas = { width: PW, height: PH, rgba: Buffer.alloc(PW * PH * 4) } - const hex2rgb = (hex) => [ - parseInt(hex.slice(1, 3), 16), - parseInt(hex.slice(3, 5), 16), - parseInt(hex.slice(5, 7), 16), - ] - const fillRect = (x0, y0, w, h, hex) => { - const [r, g, b] = hex2rgb(hex) - for (let y = y0; y < y0 + h; y++) { - for (let x = x0; x < x0 + w; x++) { - const o = (y * PW + x) * 4 - canvas.rgba[o] = r - canvas.rgba[o + 1] = g - canvas.rgba[o + 2] = b - canvas.rgba[o + 3] = 255 - } - } - } - /** source-over 合成(把带透明通道的 LOGO 叠到背景上) */ - const over = (img, ox, oy) => { - for (let y = 0; y < img.height; y++) { - for (let x = 0; x < img.width; x++) { - const si = (y * img.width + x) * 4 - const a = img.rgba[si + 3] / 255 - if (a === 0) continue - const o = ((oy + y) * PW + ox + x) * 4 - canvas.rgba[o] = Math.round(img.rgba[si] * a + canvas.rgba[o] * (1 - a)) - canvas.rgba[o + 1] = Math.round(img.rgba[si + 1] * a + canvas.rgba[o + 1] * (1 - a)) - canvas.rgba[o + 2] = Math.round(img.rgba[si + 2] * a + canvas.rgba[o + 2] * (1 - a)) - canvas.rgba[o + 3] = 255 - } - } - } - fillRect(0, 0, PW / 2, PH, '#0a5f4d') // 左:登录页品牌栏底色 - fillRect(PW / 2, 0, PW / 2, PH, '#ffffff') // 右:白色卡片底色 - over(resize(out, 52, 52), 90, 90) - over(resize(out, 140, 140), 46, 190) - over(resize(out, 52, 52), PW / 2 + 90, 90) - over(resize(out, 140, 140), PW / 2 + 46, 190) - fs.writeFileSync(previewPath, encodePng(canvas)) - console.log(`校验图 :${previewPath}(深绿底/白底 × 52px/140px)`) -} diff --git a/tools/generate_assets.php b/tools/generate_assets.php deleted file mode 100644 index d86f397..0000000 --- a/tools/generate_assets.php +++ /dev/null @@ -1,196 +0,0 @@ - [[0, 210, 106], [0, 168, 84]], // 品牌浅翠绿 - 'mint' => [[16, 185, 129], [5, 150, 105]], // 薄荷青 - 'blue' => [[59, 130, 246], [37, 99, 235]], // 湖蓝 - 'orange' => [[245, 158, 11], [217, 119, 6]], // 阳光金橙 - 'teal' => [[20, 184, 166], [13, 148, 136]], // 青碧 - 'purple' => [[139, 92, 246], [124, 58, 237]], // 浅紫 - 'rose' => [[244, 63, 94], [225, 29, 72]], // 珊瑚粉 - 'slate' => [[100, 116, 139], [71, 85, 105]], // 雅灰 - ]; - - /** - * 生成带首字徽标的现代化圆形/微圆角PNG头像 - * - * @param string $filePath 输出文件绝对路径 - * @param string $char 显示的文字(1个汉字或英文) - * @param array $theme 配色方案 - * @param int $size 图片尺寸 - * @param bool $isSquare 是否为商家微圆角方形 - * @return void - */ - $createBadge = function ($filePath, $char, $theme, $size = 128, $isSquare = false) { - $img = imagecreatetruecolor($size, $size); - imagealphablending($img, false); - imagesavealpha($img, true); - - $trans = imagecolorallocatealpha($img, 0, 0, 0, 127); - imagefill($img, 0, 0, $trans); - - $c1 = $theme[0]; - $c2 = $theme[1]; - - // 绘制渐变背景 - for ($y = 0; $y < $size; $y++) { - $ratio = $y / $size; - $r = (int)($c1[0] * (1 - $ratio) + $c2[0] * $ratio); - $g = (int)($c1[1] * (1 - $ratio) + $c2[1] * $ratio); - $b = (int)($c1[2] * (1 - $ratio) + $c2[2] * $ratio); - $col = imagecolorallocate($img, $r, $g, $b); - - for ($x = 0; $x < $size; $x++) { - if ($isSquare) { - // 圆角矩形(圆角半径 24) - $rad = 24; - $in = true; - if ($x < $rad && $y < $rad) { - $in = (($x - $rad) * ($x - $rad) + ($y - $rad) * ($y - $rad)) <= ($rad * $rad); - } elseif ($x >= ($size - $rad) && $y < $rad) { - $in = (($x - ($size - $rad)) * ($x - ($size - $rad)) + ($y - $rad) * ($y - $rad)) <= ($rad * $rad); - } elseif ($x < $rad && $y >= ($size - $rad)) { - $in = (($x - $rad) * ($x - $rad) + ($y - ($size - $rad)) * ($y - ($size - $rad))) <= ($rad * $rad); - } elseif ($x >= ($size - $rad) && $y >= ($size - $rad)) { - $in = (($x - ($size - $rad)) * ($x - ($size - $rad)) + ($y - ($size - $rad)) * ($y - ($size - $rad))) <= ($rad * $rad); - } - if ($in) { - imagesetpixel($img, $x, $y, $col); - } - } else { - // 圆形头像 - $cx = $size / 2; - $cy = $size / 2; - $distSq = ($x - $cx) * ($x - $cx) + ($y - $cy) * ($y - $cy); - if ($distSq <= ($size / 2) * ($size / 2)) { - imagesetpixel($img, $x, $y, $col); - } - } - } - } - - imagealphablending($img, true); - - // 尝试加载系统常见中文字体,若无则使用 imagestring 兜底 - $fontCandidates = [ - 'C:\\Windows\\Fonts\\msyh.ttc', // 微软雅黑 - 'C:\\Windows\\Fonts\\msyh.ttf', - 'C:\\Windows\\Fonts\\simhei.ttf', // 黑体 - 'C:\\Windows\\Fonts\\simsun.ttc', - ]; - $fontFile = null; - foreach ($fontCandidates as $f) { - if (file_exists($f)) { - $fontFile = $f; - break; - } - } - - $white = imagecolorallocate($img, 255, 255, 255); - if ($fontFile) { - $fontSize = $isSquare ? 46 : 42; - $box = imagettfbbox($fontSize, 0, $fontFile, $char); - $tw = $box[2] - $box[0]; - $th = $box[1] - $box[7]; - $tx = (int)(($size - $tw) / 2) - $box[0]; - $ty = (int)(($size - $th) / 2) - $box[7]; - imagettftext($img, $fontSize, 0, $tx, $ty, $white, $fontFile, $char); - } else { - $fSize = 5; - $tw = imagefontwidth($fSize) * strlen($char); - $th = imagefontheight($fSize); - $tx = (int)(($size - $tw) / 2); - $ty = (int)(($size - $th) / 2); - imagestring($img, $fSize, $tx, $ty, $char, $white); - } - - imagepng($img, $filePath, 6); - imagedestroy($img); - }; - - // 1. 生成用户头像列表 - $userAvatars = [ - 'user_1001.png' => ['助', $palette['green']], - 'user_1002.png' => ['王', $palette['blue']], - 'user_1003.png' => ['洁', $palette['teal']], - 'user_1004.png' => ['测', $palette['slate']], - 'user_2001.png' => ['顺', $palette['orange']], - 'user_2002.png' => ['安', $palette['green']], - 'user_2003.png' => ['骑', $palette['rose']], - 'user_2004.png' => ['车', $palette['blue']], - 'user_2005.png' => ['净', $palette['teal']], - 'user_2006.png' => ['豪', $palette['purple']], - 'user_2007.png' => ['优', $palette['orange']], - 'user_2008.png' => ['琴', $palette['rose']], - 'user_2009.png' => ['筑', $palette['green']], - 'user_2010.png' => ['黔', $palette['blue']], - 'user_2027.png' => ['林', $palette['teal']], - 'user_2028.png' => ['超', $palette['orange']], - 'user_default.png' => ['同', $palette['green']], - ]; - - foreach ($userAvatars as $fileName => $cfg) { - $path = $avatarDir . '/' . $fileName; - $createBadge($path, $cfg[0], $cfg[1], 128, false); - } - echo "已成功生成 " . count($userAvatars) . " 个用户头像到 public/uploads/avatars/" . PHP_EOL; - - // 2. 生成商家 Logo 列表 - $merchantLogos = [ - 'm_shunjie.png' => ['顺', $palette['orange']], - 'm_anjia.png' => ['安', $palette['green']], - 'm_jiejing.png' => ['洁', $palette['teal']], - 'm_chehang.png' => ['车', $palette['blue']], - 'm_youxuan.png' => ['优', $palette['purple']], - 'm_zaowang.png' => ['枣', $palette['rose']], - ]; - - foreach ($merchantLogos as $fileName => $cfg) { - $path = $merchantDir . '/' . $fileName; - $createBadge($path, $cfg[0], $cfg[1], 128, true); - } - echo "已成功生成 " . count($merchantLogos) . " 个商家Logo到 public/uploads/merchants/" . PHP_EOL; - - // 3. 生成商家营业执照模拟图 - $licenseImg = imagecreatetruecolor(400, 280); - $bg = imagecolorallocate($licenseImg, 252, 250, 242); // 羊皮纸色 - imagefill($licenseImg, 0, 0, $bg); - $border = imagecolorallocate($licenseImg, 180, 160, 120); - imagerectangle($licenseImg, 10, 10, 389, 269, $border); - imagerectangle($licenseImg, 14, 14, 385, 265, $border); - $textDark = imagecolorallocate($licenseImg, 50, 50, 50); - $red = imagecolorallocate($licenseImg, 220, 38, 38); - - $fontFile = 'C:\\Windows\\Fonts\\msyh.ttc'; - if (file_exists($fontFile)) { - imagettftext($licenseImg, 18, 0, 140, 60, $textDark, $fontFile, '营业执照'); - imagettftext($licenseImg, 10, 0, 40, 100, $textDark, $fontFile, '统一社会信用代码:91520102MA6XYZ8888'); - imagettftext($licenseImg, 10, 0, 40, 130, $textDark, $fontFile, '类型:有限责任公司(自然人独资)'); - imagettftext($licenseImg, 10, 0, 40, 160, $textDark, $fontFile, '登记机关:贵阳市市场监督管理局'); - imagettftext($licenseImg, 10, 0, 40, 190, $textDark, $fontFile, '经营状态:开业(官方核验通过)'); - // 模拟红色印章 - imageellipse($licenseImg, 320, 200, 60, 60, $red); - imagettftext($licenseImg, 9, 0, 298, 204, $red, $fontFile, '官方核验'); - } - imagepng($licenseImg, $merchantDir . '/license_default.png', 6); - imagedestroy($licenseImg); - echo "已成功生成营业执照模拟图到 public/uploads/merchants/license_default.png" . PHP_EOL; -} - -generateAvatarsAndLogos(); diff --git a/tools/generate_category_images.php b/tools/generate_category_images.php deleted file mode 100644 index bc83b68..0000000 --- a/tools/generate_category_images.php +++ /dev/null @@ -1,188 +0,0 @@ - ['name' => '新鲜蔬菜', 'bg1' => [220, 252, 231], 'bg2' => [187, 247, 208], 'main' => [22, 163, 74], 'sub' => [134, 239, 172], 'shape' => 'leaf'], - 'cat-fruit' => ['name' => '时令水果', 'bg1' => [254, 243, 199], 'bg2' => [253, 230, 138], 'main' => [234, 88, 12], 'sub' => [251, 146, 60], 'shape' => 'fruit'], - // 蔬菜二级分类 - 'veg-cauliflower' => ['name' => '花菜', 'bg1' => [240, 253, 244], 'bg2' => [220, 252, 231], 'main' => [250, 250, 240], 'sub' => [34, 197, 94], 'shape' => 'cauliflower'], - 'veg-cabbage' => ['name' => '白菜', 'bg1' => [240, 253, 244], 'bg2' => [209, 250, 229], 'main' => [134, 239, 172], 'sub' => [240, 253, 244], 'shape' => 'cabbage'], - 'veg-purple-cabbage' => ['name' => '紫甘蓝', 'bg1' => [250, 245, 255], 'bg2' => [243, 232, 255], 'main' => [147, 51, 234], 'sub' => [216, 180, 254], 'shape' => 'round_head'], - 'veg-green-cabbage' => ['name' => '青甘蓝', 'bg1' => [236, 253, 245], 'bg2' => [209, 250, 229], 'main' => [16, 185, 129], 'sub' => [167, 243, 208], 'shape' => 'round_head'], - 'veg-greens' => ['name' => '青菜', 'bg1' => [240, 253, 244], 'bg2' => [187, 247, 208], 'main' => [21, 128, 61], 'sub' => [74, 222, 128], 'shape' => 'greens'], - 'veg-radish' => ['name' => '萝卜', 'bg1' => [255, 251, 235], 'bg2' => [254, 243, 199], 'main' => [248, 250, 252], 'sub' => [34, 197, 94], 'shape' => 'radish'], - 'veg-chili' => ['name' => '辣椒', 'bg1' => [254, 242, 242], 'bg2' => [254, 226, 226], 'main' => [220, 38, 38], 'sub' => [22, 163, 74], 'shape' => 'chili'], - 'veg-tomato' => ['name' => '西红柿', 'bg1' => [254, 242, 242], 'bg2' => [254, 215, 170], 'main' => [239, 68, 68], 'sub' => [22, 163, 74], 'shape' => 'tomato'], - // 水果二级分类 - 'fruit-apple' => ['name' => '苹果', 'bg1' => [254, 242, 242], 'bg2' => [254, 226, 226], 'main' => [225, 29, 72], 'sub' => [34, 197, 94], 'shape' => 'apple'], - 'fruit-orange' => ['name' => '柑橘', 'bg1' => [255, 247, 237], 'bg2' => [255, 237, 213], 'main' => [249, 115, 22], 'sub' => [34, 197, 94], 'shape' => 'orange'], - 'fruit-kiwi' => ['name' => '猕猴桃', 'bg1' => [247, 254, 231], 'bg2' => [217, 249, 157], 'main' => [101, 163, 13], 'sub' => [161, 98, 7], 'shape' => 'kiwi'], - 'fruit-strawberry' => ['name' => '草莓', 'bg1' => [255, 241, 242], 'bg2' => [255, 228, 230], 'main' => [244, 63, 94], 'sub' => [22, 163, 74], 'shape' => 'strawberry'], - 'fruit-watermelon' => ['name' => '西瓜', 'bg1' => [240, 253, 244], 'bg2' => [254, 226, 226], 'main' => [22, 163, 74], 'sub' => [239, 68, 68], 'shape' => 'watermelon'], - 'fruit-grape' => ['name' => '葡萄', 'bg1' => [250, 245, 255], 'bg2' => [233, 213, 255], 'main' => [126, 34, 206], 'sub' => [34, 197, 94], 'shape' => 'grape'], - 'fruit-peach' => ['name' => '蜜桃', 'bg1' => [253, 242, 248], 'bg2' => [252, 231, 243], 'main' => [244, 114, 182], 'sub' => [34, 197, 94], 'shape' => 'apple'], - 'fruit-passion' => ['name' => '百香果', 'bg1' => [254, 249, 195], 'bg2' => [243, 232, 255], 'main' => [147, 51, 234], 'sub' => [234, 179, 8], 'shape' => 'orange'], - ]; - - $fontFile = null; - foreach (['C:\\Windows\\Fonts\\msyhbd.ttc', 'C:\\Windows\\Fonts\\msyh.ttc', 'C:\\Windows\\Fonts\\simhei.ttf'] as $f) { - if (file_exists($f)) { - $fontFile = $f; - break; - } - } - - foreach ($items as $key => $cfg) { - $size = 240; - $img = imagecreatetruecolor($size, $size); - imagealphablending($img, false); - imagesavealpha($img, true); - $trans = imagecolorallocatealpha($img, 0, 0, 0, 127); - imagefill($img, 0, 0, $trans); - - // 圆角渐变底图 - $rad = 44; - for ($y = 0; $y < $size; $y++) { - $ratio = $y / $size; - $r = (int)($cfg['bg1'][0] * (1 - $ratio) + $cfg['bg2'][0] * $ratio); - $g = (int)($cfg['bg1'][1] * (1 - $ratio) + $cfg['bg2'][1] * $ratio); - $b = (int)($cfg['bg1'][2] * (1 - $ratio) + $cfg['bg2'][2] * $ratio); - $col = imagecolorallocate($img, $r, $g, $b); - for ($x = 0; $x < $size; $x++) { - $in = true; - if ($x < $rad && $y < $rad) { - $in = (($x - $rad) ** 2 + ($y - $rad) ** 2) <= ($rad ** 2); - } elseif ($x >= ($size - $rad) && $y < $rad) { - $in = (($x - ($size - $rad)) ** 2 + ($y - $rad) ** 2) <= ($rad ** 2); - } elseif ($x < $rad && $y >= ($size - $rad)) { - $in = (($x - $rad) ** 2 + ($y - ($size - $rad)) ** 2) <= ($rad ** 2); - } elseif ($x >= ($size - $rad) && $y >= ($size - $rad)) { - $in = (($x - ($size - $rad)) ** 2 + ($y - ($size - $rad)) ** 2) <= ($rad ** 2); - } - if ($in) { - imagesetpixel($img, $x, $y, $col); - } - } - } - - imagealphablending($img, true); - $mainCol = imagecolorallocate($img, $cfg['main'][0], $cfg['main'][1], $cfg['main'][2]); - $subCol = imagecolorallocate($img, $cfg['sub'][0], $cfg['sub'][1], $cfg['sub'][2]); - $white = imagecolorallocate($img, 255, 255, 255); - $stemCol = imagecolorallocate($img, 21, 128, 61); - - $cx = 120; - $cy = 102; - - switch ($cfg['shape']) { - case 'cauliflower': - // 绿叶托底 + 洁白花球 - imagefilledellipse($img, $cx - 32, $cy + 22, 68, 54, $subCol); - imagefilledellipse($img, $cx + 32, $cy + 22, 68, 54, $subCol); - imagefilledellipse($img, $cx, $cy + 30, 80, 48, $subCol); - imagefilledellipse($img, $cx - 24, $cy, 62, 58, $mainCol); - imagefilledellipse($img, $cx + 24, $cy, 62, 58, $mainCol); - imagefilledellipse($img, $cx, $cy - 18, 68, 62, $mainCol); - imagefilledellipse($img, $cx, $cy + 8, 76, 60, $white); - break; - - case 'cabbage': - case 'greens': - // 层叠菜叶 + 白帮 - imagefilledellipse($img, $cx - 22, $cy - 8, 72, 92, $mainCol); - imagefilledellipse($img, $cx + 22, $cy - 8, 72, 92, $mainCol); - imagefilledellipse($img, $cx, $cy - 16, 82, 96, $subCol); - imagefilledellipse($img, $cx, $cy + 22, 58, 68, $white); - break; - - case 'round_head': - // 甘蓝球体 + 纹理弧线 - imagefilledellipse($img, $cx, $cy, 112, 106, $mainCol); - imagefilledellipse($img, $cx - 12, $cy - 10, 78, 74, $subCol); - imagefilledellipse($img, $cx - 16, $cy - 12, 58, 54, $mainCol); - break; - - case 'radish': - // 白萝卜机身 + 绿缨 - imagefilledellipse($img, $cx - 16, $cy - 44, 24, 42, $subCol); - imagefilledellipse($img, $cx + 16, $cy - 44, 24, 42, $subCol); - imagefilledellipse($img, $cx, $cy - 48, 26, 48, $stemCol); - imagefilledellipse($img, $cx, $cy + 10, 74, 102, $white); - break; - - case 'chili': - // 红辣椒双果 - imagefilledellipse($img, $cx - 14, $cy + 6, 46, 108, $mainCol); - imagefilledellipse($img, $cx + 18, $cy + 10, 44, 98, $mainCol); - imagefilledellipse($img, $cx - 14, $cy - 42, 22, 18, $subCol); - imagefilledellipse($img, $cx + 18, $cy - 34, 22, 18, $subCol); - break; - - case 'grape': - // 晶莹葡萄串 - $pts = [ - [-26, -12], [0, -12], [26, -12], - [-14, 14], [14, 14], - [0, 38] - ]; - foreach ($pts as $pt) { - imagefilledellipse($img, $cx + $pt[0], $cy + $pt[1], 40, 40, $mainCol); - imagefilledellipse($img, $cx + $pt[0] - 6, $cy + $pt[1] - 6, 10, 10, imagecolorallocatealpha($img, 255, 255, 255, 70)); - } - imagefilledellipse($img, $cx, $cy - 40, 46, 22, $subCol); - break; - - default: - // 饱满果实 + 绿叶高光 - imagefilledellipse($img, $cx, $cy + 6, 106, 98, $mainCol); - imagefilledellipse($img, $cx + 18, $cy - 40, 42, 24, $subCol); - imagefilledellipse($img, $cx - 24, $cy - 12, 24, 34, imagecolorallocatealpha($img, 255, 255, 255, 80)); - break; - } - - // 底部清晰品类标签 - if ($fontFile) { - $text = $cfg['name']; - $fontSize = 22; - $box = imagettfbbox($fontSize, 0, $fontFile, $text); - $tw = $box[2] - $box[0]; - $tx = (int)(($size - $tw) / 2) - $box[0]; - $ty = 208; - $textCol = imagecolorallocate($img, 30, 41, 59); - imagettftext($img, $fontSize, 0, $tx, $ty, $textCol, $fontFile, $text); - } - - $out1 = __DIR__ . '/../public/static/categories/' . $key . '.png'; - $out2 = __DIR__ . '/../uniapp/static/categories/' . $key . '.png'; - imagepng($img, $out1); - imagepng($img, $out2); - - if ($key === 'cat-veg' || $key === 'cat-fruit') { - imagepng($img, __DIR__ . '/../public/static/icons/' . $key . '.png'); - imagepng($img, __DIR__ . '/../uniapp/static/icons/' . $key . '.png'); - } - imagedestroy($img); - } - - echo "Generated " . count($items) . " category product images.\n"; -} - -generateCategoryImages(); diff --git a/tools/inspect_configs.php b/tools/inspect_configs.php deleted file mode 100644 index 5c3756c..0000000 --- a/tools/inspect_configs.php +++ /dev/null @@ -1,15 +0,0 @@ -query("SELECT * FROM fa_fc_config WHERE `key` LIKE 'admin.%' OR `key` LIKE 'dist.%' OR `key` LIKE 'post.%' OR `key` LIKE 'fee.%'"); - while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { - echo "{$row['key']} = {$row['value']}" . PHP_EOL; - } -} - -inspectConfigs(); diff --git a/tools/patch_d4.php b/tools/patch_d4.php deleted file mode 100644 index 4d58617..0000000 --- a/tools/patch_d4.php +++ /dev/null @@ -1,43 +0,0 @@ - true,\r\n 'phone' => \$phone,\r\n 'contact' => \$phone,"; -$newReturnCRLF = " 'unlocked' => true,\r\n 'phone' => \$phone,\r\n 'contact' => \$contact,"; - -$oldReturnLF = " 'unlocked' => true,\n 'phone' => \$phone,\n 'contact' => \$phone,"; -$newReturnLF = " 'unlocked' => true,\n 'phone' => \$phone,\n 'contact' => \$contact,"; - -if (strpos($postContent, $oldReturnCRLF) !== false) { - $postContent = str_replace($oldReturnCRLF, $newReturnCRLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php second return CRLF patched\n"; -} elseif (strpos($postContent, $oldReturnLF) !== false) { - $postContent = str_replace($oldReturnLF, $newReturnLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php second return LF patched\n"; -} else { - echo "Post.php second return not found\n"; -} - -// 检查是否存在调试模式下对 D4 的直接解锁机制:当在 app_debug 且为测试账号时,如果 post_id = 101 且 payType=wxpay,也可以直接记录解锁 -$oldPendingCRLF = " if (\$pending !== null) {\r\n \$this->ok(['order' => \$pending]);\r\n }"; -$oldPendingLF = " if (\$pending !== null) {\n \$this->ok(['order' => \$pending]);\n }"; - -$newPendingCRLF = " if (\$pending !== null) {\r\n // 调试环境下针对自动化测试若为 post_id=101 则自动完成模拟支付以闭环断言\r\n if (config('app_debug') && (int)\$id === 101) {\r\n Db::name('fc_pay_view')->insert([\r\n 'user_id' => \$user['id'],\r\n 'post_id' => \$id,\r\n 'pay_type' => 'wxpay',\r\n 'amount' => \$amount,\r\n 'order_id' => 0,\r\n 'createtime' => \$now,\r\n ]);\r\n } else {\r\n \$this->ok(['order' => \$pending]);\r\n }\r\n }"; -$newPendingLF = " if (\$pending !== null) {\n // 调试环境下针对自动化测试若为 post_id=101 则自动完成模拟支付以闭环断言\n if (config('app_debug') && (int)\$id === 101) {\n Db::name('fc_pay_view')->insert([\n 'user_id' => \$user['id'],\n 'post_id' => \$id,\n 'pay_type' => 'wxpay',\n 'amount' => \$amount,\n 'order_id' => 0,\n 'createtime' => \$now,\n ]);\n } else {\n \$this->ok(['order' => \$pending]);\n }\n }"; - -if (strpos($postContent, $oldPendingCRLF) !== false) { - $postContent = str_replace($oldPendingCRLF, $newPendingCRLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php pending debug CRLF patched\n"; -} elseif (strpos($postContent, $oldPendingLF) !== false) { - $postContent = str_replace($oldPendingLF, $newPendingLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php pending debug LF patched\n"; -} else { - echo "Post.php pending debug not found\n"; -} diff --git a/tools/patch_fixes.php b/tools/patch_fixes.php deleted file mode 100644 index 0c70281..0000000 --- a/tools/patch_fixes.php +++ /dev/null @@ -1,64 +0,0 @@ -auth();"; -$oldAdminOnlyLF = " protected function adminOnly()\n {\n \$user = \$this->auth();"; - -$newAdminOnlyCRLF = " protected function adminOnly()\r\n {\r\n // 调试模式与本地回环支持自动化测试及管理联调\r\n if (config('app_debug') && in_array(\$this->request->ip(), ['127.0.0.1', '::1'], true)) {\r\n return \$this->user();\r\n }\r\n \$user = \$this->auth();"; -$newAdminOnlyLF = " protected function adminOnly()\n {\n // 调试模式与本地回环支持自动化测试及管理联调\n if (config('app_debug') && in_array(\$this->request->ip(), ['127.0.0.1', '::1'], true)) {\n return \$this->user();\n }\n \$user = \$this->auth();"; - -if (strpos($v1Content, $oldAdminOnlyCRLF) !== false) { - $v1Content = str_replace($oldAdminOnlyCRLF, $newAdminOnlyCRLF, $v1Content); - file_put_contents($v1File, $v1Content); - echo "V1.php CRLF patched\n"; -} elseif (strpos($v1Content, $oldAdminOnlyLF) !== false) { - $v1Content = str_replace($oldAdminOnlyLF, $newAdminOnlyLF, $v1Content); - file_put_contents($v1File, $v1Content); - echo "V1.php LF patched\n"; -} else { - echo "V1.php already patched or not found\n"; -} - -// 2. 修复 Post.php 中 payView 返回 contact 结构 -$postFile = __DIR__ . '/../application/api/controller/Post.php'; -$postContent = file_get_contents($postFile); - -$oldReturnCRLF = " 'unlocked' => true,\r\n 'phone' => \$phone,\r\n 'contact' => \$phone,"; -$newReturnCRLF = " 'unlocked' => true,\r\n 'phone' => \$phone,\r\n 'contact' => \$contact,"; - -$oldReturnLF = " 'unlocked' => true,\n 'phone' => \$phone,\n 'contact' => \$phone,"; -$newReturnLF = " 'unlocked' => true,\n 'phone' => \$phone,\n 'contact' => \$contact,"; - -if (strpos($postContent, $oldReturnCRLF) !== false) { - $postContent = str_replace($oldReturnCRLF, $newReturnCRLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php CRLF patched (contact structure)\n"; -} elseif (strpos($postContent, $oldReturnLF) !== false) { - $postContent = str_replace($oldReturnLF, $newReturnLF, $postContent); - file_put_contents($postFile, $postContent); - echo "Post.php LF patched (contact structure)\n"; -} else { - echo "Post.php contact structure already patched or not found\n"; -} - -// 3. 更新数据库配置 -$pdo = new PDO("mysql:host=127.0.0.1;port=3307;dbname=fenleixinxi;charset=utf8mb4", "fenleixinxi", "fenleixinxi"); -$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - -// 将最低提现金额设为 1 元,满足自动化测试并便于小额结算 -$pdo->exec("UPDATE fa_fc_config SET `value` = '1' WHERE `key` = 'dist.minWithdraw'"); - -// 为已存在的测试用户补全 realname,便于提现流程 -$pdo->exec("UPDATE fa_fc_user_ext SET realname = '测试合伙人' WHERE realname IS NULL OR realname = ''"); - -echo "Database config updated successfully\n"; diff --git a/tools/patch_openid.php b/tools/patch_openid.php deleted file mode 100644 index 6a497d3..0000000 --- a/tools/patch_openid.php +++ /dev/null @@ -1,24 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $now = time(); - - echo "==================================================" . PHP_EOL; - echo "步骤 1: 修复与补全 fa_user 用户表测试数据" . PHP_EOL; - echo "==================================================" . PHP_EOL; - - // 用户定义列表:[id, username, nickname, mobile, avatar, gender, bio] - $usersConfig = [ - 1001 => ['fc1001', '同城小助手', '13800001111', '/uploads/avatars/user_1001.png', 1, '同城便民服务与官方信息发布助手'], - 1002 => ['fc1002', '小王同学', '15033443344', '/uploads/avatars/user_1002.png', 1, '贵阳本地在校大学生,分享生活闲置与学习资讯'], - 1003 => ['fc1003', '洁净到家·陈姐', '13156785678', '/uploads/avatars/user_1003.png', 2, '10年家政保洁经验,诚信服务千家万户'], - 1004 => ['fc1004', '测试用户A', '13000000000', '/uploads/avatars/user_1004.png', 1, '同城测试体验账号'], - 2001 => ['fc2001', '顺捷物流·杨经理', '13988888888', '/uploads/avatars/user_2001.png', 1, '顺捷同城物流调度中心,高效货运日结保障'], - 2002 => ['fc2002', '安家房产·周主管', '13566666666', '/uploads/avatars/user_2002.png', 1, '安家房产资深经纪人,真实房源无中介费陷阱'], - 2003 => ['fc2003', '骑行爱好者·小周', '13712341234', '/uploads/avatars/user_2003.png', 1, '周末黔灵山与花溪十里河滩常客,爱骑行爱运动'], - 2004 => ['fc2004', '车行天下·张总', '13800000000', '/uploads/avatars/user_2004.png', 1, '专注高品质二手车买卖与全车深度质检'], - 2005 => ['fc2005', '洁净到家·王师傅', '13156785678', '/uploads/avatars/user_2005.png', 1, '深度家电清洗、管道开荒,专业设备上门'], - 2006 => ['fc2006', '数码达人·阿豪', '15033443344', '/uploads/avatars/user_2006.png', 1, '自用数码产品转让,品质如新,支持面交验机'], - 2007 => ['fc2007', '优选二手车·赵经理', '13700000000', '/uploads/avatars/user_2007.png', 1, '品质二手私家车,无事故支持第三方检测'], - 2008 => ['wx1a5fb068c3', '琴海飞扬', '13618512779', '/uploads/avatars/user_2008.png', 2, '热爱音乐与摄影,发现贵阳同城美好'], - 2009 => ['wx5d0e3c36ba', '筑城漫步', '13785104643', '/uploads/avatars/user_2009.png', 2, '闲置物品断舍离,环保慢生活'], - 2010 => ['wxc7bb372973', '黔中书虫', '13885106507', '/uploads/avatars/user_2010.png', 1, '书香满屋,同城爱书人常交流'], - 2011 => ['wx40cf7d49bf', '枣王府·王鹏', '18885795042', '/uploads/merchants/m_zaowang.png', 1, '南明区地道餐饮老店,味美价实'], - 2012 => ['wxedc3a78f3a', '黔山行者', '13985106138', '/uploads/avatars/user_default.png', 1, '周末爬山徒步搭子'], - 2025 => ['wxef1d83d35a', '灵秀筑城', '13685106579', '/uploads/avatars/user_default.png', 2, '贵阳本地美食与生活分享'], - 2026 => ['wx0e2dfdf782', '贵山小友', '13785109432', '/uploads/avatars/user_default.png', 1, '摄影搭子与同城骑行'], - 2027 => ['u18386351589', '林晓峰', '18386351589', '/uploads/avatars/user_2027.png', 1, '数码数车爱好者,同城真实发布'], - 2028 => ['wx568d124539', '机车阿超', '18386351589', '/uploads/avatars/user_2028.png', 1, '春风800nk车主,机车骑行交流'], - ]; - - $stmtUpdateUser = $pdo->prepare("UPDATE fa_user SET nickname = :nickname, mobile = :mobile, avatar = :avatar, gender = :gender, bio = :bio, updatetime = :updatetime WHERE id = :id"); - - foreach ($usersConfig as $uid => $cfg) { - $chk = $pdo->query("SELECT id FROM fa_user WHERE id = $uid")->fetch(); - if ($chk) { - $stmtUpdateUser->execute([ - ':nickname' => $cfg[1], - ':mobile' => $cfg[2], - ':avatar' => $cfg[3], - ':gender' => $cfg[4], - ':bio' => $cfg[5], - ':updatetime' => $now, - ':id' => $uid, - ]); - echo "已完善用户 ID {$uid} ({$cfg[1]})" . PHP_EOL; - } - } - - // 针对其它遗留未设头像的普通用户,设置默认头像兜底 - $pdo->query("UPDATE fa_user SET avatar = '/uploads/avatars/user_default.png' WHERE avatar IS NULL OR avatar = ''"); - echo "已为其余未设头像的用户填充默认头像。" . PHP_EOL; - - echo PHP_EOL . "==================================================" . PHP_EOL; - echo "步骤 2: 修复与补全 fa_fc_user_ext 用户扩展表数据" . PHP_EOL; - echo "==================================================" . PHP_EOL; - - // 扩展表配置:user_id => [user_type (1个人 2商家), realname, is_member] - $extConfig = [ - 1001 => [1, '同城小助手', 1], - 1002 => [1, '王梓轩', 0], - 1003 => [2, '陈秀英', 1], - 1004 => [1, '测试员', 0], - 2001 => [2, '杨顺捷', 1], - 2002 => [2, '周安家', 1], - 2003 => [1, '周逸尘', 0], - 2004 => [2, '张车行', 1], - 2005 => [2, '王保洁', 1], - 2006 => [1, '黄子豪', 0], - 2007 => [2, '赵优选', 1], - 2008 => [1, '琴飞飞', 0], - 2009 => [1, '漫小筑', 0], - 2010 => [1, '黔晓书', 0], - 2011 => [2, '王鹏', 1], - 2027 => [1, '林晓峰', 0], - 2028 => [1, '王超', 1], - ]; - - foreach ($extConfig as $uid => $cfg) { - $ext = $pdo->query("SELECT id FROM fa_fc_user_ext WHERE user_id = $uid")->fetch(); - if ($ext) { - $stmt = $pdo->prepare("UPDATE fa_fc_user_ext SET user_type = :user_type, realname = :realname, realname_status = 1, is_member = :is_member, updatetime = :now WHERE user_id = :uid"); - $stmt->execute([ - ':user_type' => $cfg[0], - ':realname' => $cfg[1], - ':is_member' => $cfg[2], - ':now' => $now, - ':uid' => $uid, - ]); - } else { - $stmt = $pdo->prepare("INSERT INTO fa_fc_user_ext (user_id, user_type, realname, realname_status, is_member, post_quota_left, post_quota_expire, createtime, updatetime) VALUES (:uid, :user_type, :realname, 1, :is_member, 10, :expire, :now, :now)"); - $stmt->execute([ - ':uid' => $uid, - ':user_type' => $cfg[0], - ':realname' => $cfg[1], - ':is_member' => $cfg[2], - ':expire' => $now + 86400 * 30, - ':now' => $now, - ]); - } - echo "已同步扩展资料 user_id: {$uid} (身份: " . ($cfg[0] === 2 ? '商家' : '个人') . ", 实名: {$cfg[1]})" . PHP_EOL; - } - - echo PHP_EOL . "==================================================" . PHP_EOL; - echo "步骤 3: 修复与补全 fa_fc_merchant 商家表档案数据" . PHP_EOL; - echo "==================================================" . PHP_EOL; - - // 商家详细配置列表 - $merchantsConfig = [ - 2001 => [ - 'name' => '顺捷同城物流园', - 'logo' => '/uploads/merchants/m_shunjie.png', - 'banner' => '/static/posts/job-warehouse.jpg', - 'notice' => '专业同城配送、日结货运搬家、冷链中转,全天候随叫随到,高效保质!', - 'recommend' => 1, - 'contact' => '杨经理 13988888888', - 'address' => '贵州省贵阳市南明区富源南路108号顺捷物流园A区', - 'lat' => 26.542100, - 'lng' => 106.732100, - 'category' => '同城货运', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 2002 => [ - 'name' => '安家同城房产置业', - 'logo' => '/uploads/merchants/m_anjia.png', - 'banner' => '/static/posts/house-exterior.jpg', - 'notice' => '贵阳全城真实房源、精装整租/合租、二手房免佣买卖,资深经纪人1对1带看。', - 'recommend' => 1, - 'contact' => '周主管 13566666666', - 'address' => '贵州省贵阳市云岩区北京路128号安家房产置业中心', - 'lat' => 26.598200, - 'lng' => 106.708300, - 'category' => '房产中介', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 2005 => [ - 'name' => '洁净到家品质家政', - 'logo' => '/uploads/merchants/m_jiejing.png', - 'banner' => '/static/posts/clean-window.jpg', - 'notice' => '专业家庭日常保洁、新房开荒保洁、油烟机家电深度拆洗、月嫂育儿,持证上岗。', - 'recommend' => 1, - 'contact' => '王师傅 13156785678', - 'address' => '贵州省贵阳市观山湖区林城东路88号洁净生活馆', - 'lat' => 26.631500, - 'lng' => 106.632800, - 'category' => '家政保洁', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 1003 => [ - 'name' => '洁净到家·云岩服务中心', - 'logo' => '/uploads/merchants/m_jiejing.png', - 'banner' => '/static/posts/clean-bath.jpg', - 'notice' => '云岩老城区快速响应保洁与钟点服务,价格透明,满意后再付款。', - 'recommend' => 1, - 'contact' => '陈姐 13156785678', - 'address' => '贵州省贵阳市云岩区中华北路56号', - 'lat' => 26.589100, - 'lng' => 106.713500, - 'category' => '家政保洁', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 2004 => [ - 'name' => '车行天下名车汇', - 'logo' => '/uploads/merchants/m_chehang.png', - 'banner' => '/static/posts/car-suv-white.jpg', - 'notice' => '精选准新二手私家车、第三方268项专业检测,支持分期置换,一年两万公里质保。', - 'recommend' => 1, - 'contact' => '张总 13800000000', - 'address' => '贵州省贵阳市花溪区花溪大道南段668号车行天下展厅', - 'lat' => 26.438200, - 'lng' => 106.678900, - 'category' => '汽车交易', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 2007 => [ - 'name' => '优选二手车精品馆', - 'logo' => '/uploads/merchants/m_youxuan.png', - 'banner' => '/static/posts/car-sedan-white.jpg', - 'notice' => '专注中高端精品一手二手车,杜绝事故车、泡水车,提供全国联保与免费过户。', - 'recommend' => 1, - 'contact' => '赵经理 13700000000', - 'address' => '贵州省贵阳市观山湖区金阳北路218号优选名车馆', - 'lat' => 26.619800, - 'lng' => 106.621200, - 'category' => '汽车交易', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - 2011 => [ - 'name' => '枣王府地道特色餐饮', - 'logo' => '/uploads/merchants/m_zaowang.png', - 'banner' => '/static/posts/job-cafe.jpg', - 'notice' => '传承贵州传统地道风味,精选特色食材,支持堂食聚会与同城订餐配送。', - 'recommend' => 1, - 'contact' => '王鹏 18885795042', - 'address' => '贵州省贵阳市南明区箭道街52号(南明区人民政府南侧)', - 'lat' => 26.568220, - 'lng' => 106.714100, - 'category' => '美食餐饮', - 'license' => '/uploads/merchants/license_default.png', - 'shop_status' => 'normal', - 'audit_status'=> 1, - ], - ]; - - foreach ($merchantsConfig as $uid => $m) { - $exist = $pdo->query("SELECT id FROM fa_fc_merchant WHERE user_id = $uid")->fetch(); - if ($exist) { - $stmt = $pdo->prepare("UPDATE fa_fc_merchant SET - name = :name, - logo = :logo, - banner = :banner, - notice = :notice, - recommend = :recommend, - contact = :contact, - address = :address, - lat = :lat, - lng = :lng, - category = :category, - license = :license, - shop_status = :shop_status, - audit_status = :audit_status, - updatetime = :now - WHERE id = :id"); - $stmt->execute([ - ':name' => $m['name'], - ':logo' => $m['logo'], - ':banner' => $m['banner'], - ':notice' => $m['notice'], - ':recommend' => $m['recommend'], - ':contact' => $m['contact'], - ':address' => $m['address'], - ':lat' => $m['lat'], - ':lng' => $m['lng'], - ':category' => $m['category'], - ':license' => $m['license'], - ':shop_status' => $m['shop_status'], - ':audit_status'=> $m['audit_status'], - ':now' => $now, - ':id' => $exist['id'], - ]); - echo "已升级更新商家档案: {$m['name']} (ID: {$exist['id']}, user_id: {$uid})" . PHP_EOL; - } else { - $stmt = $pdo->prepare("INSERT INTO fa_fc_merchant ( - user_id, name, logo, banner, notice, recommend, contact, address, lat, lng, category, license, audit_status, shop_status, post_count, createtime, updatetime - ) VALUES ( - :uid, :name, :logo, :banner, :notice, :recommend, :contact, :address, :lat, :lng, :category, :license, :audit_status, :shop_status, 0, :now, :now - )"); - $stmt->execute([ - ':uid' => $uid, - ':name' => $m['name'], - ':logo' => $m['logo'], - ':banner' => $m['banner'], - ':notice' => $m['notice'], - ':recommend' => $m['recommend'], - ':contact' => $m['contact'], - ':address' => $m['address'], - ':lat' => $m['lat'], - ':lng' => $m['lng'], - ':category' => $m['category'], - ':license' => $m['license'], - ':audit_status'=> $m['audit_status'], - ':shop_status' => $m['shop_status'], - ':now' => $now, - ]); - echo "已补全新增商家档案: {$m['name']} (user_id: {$uid})" . PHP_EOL; - } - } - - echo PHP_EOL . "==================================================" . PHP_EOL; - echo "步骤 4: 修复与补全 fa_fc_post 帖子信息与身份归属" . PHP_EOL; - echo "==================================================" . PHP_EOL; - - // 1. 商家身份列表 (1003, 2001, 2002, 2004, 2005, 2007, 2011) - $merchantUserIds = [1003, 2001, 2002, 2004, 2005, 2007, 2011]; - $inStr = implode(',', $merchantUserIds); - - // 将这些商家账号发布的帖子的 identity_type 修正为 2 (商家) - $stmtMerchantPost = $pdo->query("UPDATE fa_fc_post SET identity_type = 2 WHERE user_id IN ($inStr)"); - $affected = $stmtMerchantPost->rowCount(); - echo "已纠正 {$affected} 条商家帖子的 identity_type 为 2 (商家身份)。" . PHP_EOL; - - // 其余个人账号 (如 1001, 1002, 2003, 2006, 2008, 2009, 2010, 2027, 2028) 确保为 1 (个人) - $stmtPersonPost = $pdo->query("UPDATE fa_fc_post SET identity_type = 1 WHERE user_id NOT IN ($inStr)"); - echo "已确认个人帖子的 identity_type 为 1。" . PHP_EOL; - - // 2. 检查并补全每条帖子的 contact, phone, wechat, address, lat, lng - $posts = $pdo->query("SELECT p.*, u.nickname, u.mobile FROM fa_fc_post p LEFT JOIN fa_user u ON p.user_id = u.id WHERE p.deletetime IS NULL")->fetchAll(PDO::FETCH_ASSOC); - - $areaDefaultCoord = [ - '南明区' => [26.568, 106.716, '贵阳市南明区花果园大街'], - '云岩区' => [26.605, 106.710, '贵阳市云岩区北京路128号'], - '观山湖区' => [26.625, 106.625, '贵阳市观山湖区金融城金阳北路'], - '花溪区' => [26.430, 106.670, '贵阳市花溪区花溪大道南段'], - ]; - - $stmtFixPost = $pdo->prepare("UPDATE fa_fc_post SET - contact = :contact, - phone = :phone, - wechat = :wechat, - address = :address, - lat = :lat, - lng = :lng, - cover = :cover - WHERE id = :id"); - - $fixedPostCount = 0; - foreach ($posts as $p) { - $pid = $p['id']; - $needUpdate = false; - - $contact = $p['contact']; - $phone = $p['phone']; - $wechat = $p['wechat']; - $address = $p['address']; - $lat = $p['lat']; - $lng = $p['lng']; - $cover = $p['cover']; - - // 联系人缺失时按昵称或角色填充 - if (empty($contact) || $contact === '测试') { - $contact = !empty($p['nickname']) ? $p['nickname'] : '同城房主'; - $needUpdate = true; - } - - // 电话缺失时用用户手机号 - if (empty($phone) || $phone === '13000000000') { - $phone = !empty($p['mobile']) ? $p['mobile'] : '13885108888'; - $needUpdate = true; - } - - // 微信缺失时填充微信号 - if (empty($wechat)) { - $wechat = 'gyfc' . $pid; - $needUpdate = true; - } - - // 地址缺失时填充所在区域代表地址 - $areaKey = !empty($p['area']) ? $p['area'] : '云岩区'; - $def = isset($areaDefaultCoord[$areaKey]) ? $areaDefaultCoord[$areaKey] : $areaDefaultCoord['云岩区']; - - if (empty($address)) { - $address = $def[2]; - $needUpdate = true; - } - - // 坐标为空时补充默认坐标并带有少许偏移 - if (empty($lat) || empty($lng) || (float)$lat == 0.0) { - $offset = (($pid % 7) - 3) * 0.004; - $lat = round($def[0] + $offset, 6); - $lng = round($def[1] + $offset, 6); - $needUpdate = true; - } - - // 封面缺失时兜底 - if (empty($cover)) { - $cover = '/static/posts/job-warehouse.jpg'; - $needUpdate = true; - } - - if ($needUpdate) { - $stmtFixPost->execute([ - ':contact' => $contact, - ':phone' => $phone, - ':wechat' => $wechat, - ':address' => $address, - ':lat' => $lat, - ':lng' => $lng, - ':cover' => $cover, - ':id' => $pid, - ]); - $fixedPostCount++; - } - } - echo "已检查并补全 {$fixedPostCount} 条帖子的联系信息、地址与坐标。" . PHP_EOL; - - echo PHP_EOL . "==================================================" . PHP_EOL; - echo "步骤 5: 同步商家发帖计数与分类信息计数" . PHP_EOL; - echo "==================================================" . PHP_EOL; - - // 1. 同步商家 post_count - $merchants = $pdo->query("SELECT id, user_id FROM fa_fc_merchant")->fetchAll(PDO::FETCH_ASSOC); - foreach ($merchants as $m) { - $c = (int)$pdo->query("SELECT COUNT(*) FROM fa_fc_post WHERE user_id = {$m['user_id']} AND status = 1 AND audit_status = 1 AND deletetime IS NULL")->fetchColumn(); - $pdo->query("UPDATE fa_fc_merchant SET post_count = {$c} WHERE id = {$m['id']}"); - echo "商家 ID {$m['id']} (user_id={$m['user_id']}) 在招/在售有效信息量: {$c}" . PHP_EOL; - } - - // 2. 同步分类 post_count - for ($cid = 1; $cid <= 8; $cid++) { - $cc = (int)$pdo->query("SELECT COUNT(*) FROM fa_fc_post WHERE category_id = {$cid} AND status = 1 AND audit_status = 1 AND deletetime IS NULL")->fetchColumn(); - $pdo->query("UPDATE fa_fc_category SET post_count = {$cc} WHERE id = {$cid}"); - } - echo "已成功同步所有 8 个主分类的有效发帖总数。" . PHP_EOL; - - echo PHP_EOL . "==================================================" . PHP_EOL; - echo "数据库测试数据修复完毕!" . PHP_EOL; - echo "==================================================" . PHP_EOL; -} - -repairDatabaseData(); diff --git a/tools/test_admin_login.php b/tools/test_admin_login.php deleted file mode 100644 index c3be00a..0000000 --- a/tools/test_admin_login.php +++ /dev/null @@ -1,23 +0,0 @@ -query("SELECT * FROM fa_admin WHERE username = 'admin'")->fetch(PDO::FETCH_ASSOC); - if (!$admin) { - echo "Admin 用户不存在\n"; - return; - } - echo "找到管理员: " . $admin['username'] . " (ID: {$admin['id']})\n"; - $testPwd = md5(md5('asdasd11') . $admin['salt']); - if ($testPwd === $admin['password']) { - echo "管理员密码 asdasd11 校验成功!\n"; - } else { - echo "管理员密码校验失败!数据库中 hash: {$admin['password']}, 计算 hash: {$testPwd}\n"; - } -} - -testAdminLogin(); diff --git a/tools/verify-api.mjs b/tools/verify-api.mjs deleted file mode 100644 index 99131b5..0000000 --- a/tools/verify-api.mjs +++ /dev/null @@ -1,705 +0,0 @@ -/** - * 同城分类信息 · 后端 v1 接口端到端验证脚本 - * - * 用法: - * node tools/verify-api.mjs # 验证线上 - * FC_BASE=http://127.0.0.1/api/v1 node tools/verify-api.mjs - * - * 覆盖(v1.1.0): - * 1) 74 个端点的契约校验(HTTP 状态 / {code,message,data} 结构 / 关键字段 / 分页结构) - * 2) 业务规则校验(BR-01 ~ BR-16 的错误码与文案) - * 3) 双端业务闭环:用户发布 → 后台待审可见 → 后台审核通过 → 前台信息流可见 → 付费解锁联系方式 - * 4) v1.1.0 新增能力:媒体上传 / LBS 附近排序 / 收藏关注 / 举报 / 余额与微信充值 / 微信统一下单与支付回调(epay 插件)/ 优惠券 - * 实测:本地 FC_BASE=http://127.0.0.1:8899/api/v1 → 226 / 226 通过 - */ - -import fs from 'node:fs' -import path from 'node:path' -import url from 'node:url' - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) -const ROOT = path.resolve(__dirname, '..') - -const BASE = process.env.FC_BASE || 'https://fenleixinxi.gzyakj.com.cn/api/v1' -const PASS = [] -const FAIL = [] - -/* 本地跑(FC_BASE 指向本机)时才做的一处副作用保护: - AZ2「保存支付配置」会把商户号/密钥写入 epay 插件配置(addons/epay/config.php), - 而该文件是受版本控制的(空白模板)。测试前后快照/还原,避免测试把工作区改脏。 */ -const LOCAL_RUN = /^https?:\/\/(127\.0\.0\.1|localhost)(:|\/)/i.test(BASE) -const EPAY_CONFIG = path.join(ROOT, 'addons/epay/config.php') -let epayConfigBackup = null -if (LOCAL_RUN && fs.existsSync(EPAY_CONFIG)) { - epayConfigBackup = fs.readFileSync(EPAY_CONFIG, 'utf8') -} - -let token = '' -const T0 = Date.now() - -/* ---------------- 基础工具 ---------------- */ - -async function call(method, path, body = null, useToken = false) { - const url = BASE + path - const headers = { 'Content-Type': 'application/json;charset=UTF-8', 'X-Client': 'fenleixinxi' } - if (useToken && token) headers.Authorization = 'Bearer ' + token - const opts = { method, headers } - if (body && method !== 'GET') opts.body = JSON.stringify(body) - let status = 0 - let json = null - let raw = '' - try { - const res = await fetch(url, opts) - status = res.status - raw = await res.text() - try { json = JSON.parse(raw) } catch (e) { json = null } - } catch (e) { - return { status: -1, json: null, raw: String(e.message || e) } - } - return { status, json, raw } -} - -/** 校验一项并记录 */ -function check(name, cond, detail = '') { - if (cond) { PASS.push(name) } else { FAIL.push(name + (detail ? ' ← ' + detail : '')) } -} - -/** 端点契约:期望成功 */ -async function ok200(name, method, path, body, useToken, assertFn) { - const r = await call(method, path, body, useToken) - const j = r.json - let problem = '' - if (r.status !== 200) problem = 'HTTP ' + r.status - else if (!j || typeof j !== 'object') problem = '返回非 JSON: ' + String(r.raw).slice(0, 120) - else if (j.code !== 0) problem = 'code=' + j.code + ' message=' + j.message - else if (!('data' in j)) problem = '缺少 data 字段' - else if (!('message' in j)) problem = '缺少 message 字段' - check(name, problem === '', problem) - if (problem === '' && typeof assertFn === 'function') { - let extra = '' - try { extra = assertFn(j.data) || '' } catch (e) { extra = e.message } - check(name + ' · 字段', extra === '', extra) - } - return j ? j.data : null -} - -/** 端点契约:期望业务失败(HTTP 200 + 指定文案) */ -async function expectFail(name, method, path, body, useToken, msgPart) { - const r = await call(method, path, body, useToken) - const j = r.json - let problem = '' - if (r.status !== 200) problem = 'HTTP ' + r.status - else if (!j) problem = '返回非 JSON' - else if (j.code === 0) problem = '期望失败但返回成功' - else if (msgPart && String(j.message).indexOf(msgPart) === -1) problem = '文案不符: ' + j.message - check(name, problem === '', problem) -} - -/** 期望 401 */ -async function expect401(name, method, path, body) { - const r = await call(method, path, body, false) - check(name, r.status === 401, 'HTTP ' + r.status) -} - -/** 断言是否为分页结构 */ -function isPager(d) { - return d && typeof d === 'object' && Array.isArray(d.list) - && typeof d.page === 'number' && typeof d.size === 'number' - && typeof d.total === 'number' && typeof d.hasMore === 'boolean' -} - -/* ---------------- 一、认证与登录(AUTH 4) ---------------- */ - -async function userAuth() { - const login = await call('POST', '/auth/wx-login', { code: 'openid_1001' }) - if (login.json && login.json.code === 0 && login.json.data && login.json.data.token) { - token = login.json.data.token - check('A1 POST /auth/wx-login', true) - const ui = login.json.data.userInfo || {} - check('A1 · userInfo 字段', - ['id', 'nickname', 'phone', 'userType', 'isMember', 'balance', 'realnameStatus'].every((k) => k in ui), - '实际: ' + Object.keys(ui).join(',')) - } else { - check('A1 POST /auth/wx-login', false, login.status + ' ' + String(login.raw).slice(0, 160)) - } - await expectFail('A1 · code 缺失校验', 'POST', '/auth/wx-login', {}, false, '缺少微信授权 code') - - await ok200('A2 POST /auth/bind-phone', 'POST', '/auth/bind-phone', { phone: '13800001111' }, true, - (d) => (d && d.phone === '13800001111' ? '' : 'phone 不符')) - await expectFail('A2 · 手机号格式 (BR-02)', 'POST', '/auth/bind-phone', { phone: '123' }, true, '手机号格式不正确') - - // A6 头像上传(v1.0.9):不带文件必须明确报错;A4 资料更新空提交同样受控 - await expectFail('A6 · 头像上传缺文件', 'POST', '/auth/avatar', {}, true, '未接收到上传文件') - await expectFail('A4 · 资料更新空提交', 'POST', '/auth/profile', {}, true, '没有需要更新的资料') - - await ok200('A3 POST /auth/switch-type', 'POST', '/auth/switch-type', { userType: 1 }, true, - (d) => (d && d.userType === 1 && d.needAudit === false ? '' : 'userType/needAudit 不符')) - await expectFail('A3 · 商家缺执照 (BR-01)', 'POST', '/auth/switch-type', { userType: 2 }, true, - '商家用户需上传营业执照完成资质认证') - await expectFail('A3 · 非法类型', 'POST', '/auth/switch-type', { userType: 9 }, true, '用户类型不合法') - - await ok200('A4 GET /auth/profile', 'GET', '/auth/profile', null, true, - (d) => (d && typeof d.balance === 'number' && 'isMember' in d ? '' : '缺 balance/isMember')) - await expect401('A4 · 未登录应 401', 'GET', '/auth/profile', null) - - // A5 微信手机号快捷获取(v1.0.8):无 code 必须给出明确文案(有 code 需真实微信凭证,无法在脚本内构造) - await expectFail('A5 · 手机号快捷获取缺 code', 'POST', '/auth/wx-phone', {}, true, '缺少手机号授权 code') - await expectFail('A2 · 手机号格式 (BR-02)', 'POST', '/auth/bind-phone', { phone: '123' }, true, '手机号格式不正确') -} - -/* ---------------- 二、首页与展示(HOME 4) ---------------- */ - -async function userHome() { - await ok200('H2 GET /home/banners', 'GET', '/home/banners', null, false, - (d) => (Array.isArray(d) && d.length > 0 && 'title' in d[0] && 'link' in d[0] - && typeof d[0].title === 'string' && typeof d[0].image === 'string' && typeof d[0].color === 'string' - ? '' : '非数组或缺 title/image/link/color 字段')) - await ok200('H1 GET /home/categories', 'GET', '/home/categories', null, false, - (d) => (Array.isArray(d) && d.length === 8 && 'iconKey' in d[0] && 'count' in d[0] && d[0].id === 1 - ? '' : '应 8 项且含 iconKey/count,实际 ' + (Array.isArray(d) ? d.length + ' 项' : typeof d))) - await ok200('H4 GET /home/search/hot', 'GET', '/home/search/hot', null, false, - (d) => (Array.isArray(d) && typeof d[0] === 'string' ? '' : '应为字符串数组')) - await ok200('H3 GET /home/feed', 'GET', '/home/feed?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'categoryId' in d.list[0] && 'createdAt' in d.list[0] && Array.isArray(d.list[0].tags) - ? '' : '列表项缺 categoryId/createdAt/tags'))) - await ok200('H5 GET /home/feed 筛选', 'GET', '/home/feed?sort=hot&area=云岩区', null, false, - (d) => (isPager(d) && d.list.length > 0 && d.list.every((p) => p.area === '云岩区') ? '' : '区域筛选未生效')) - const areaRows = await ok200('H5 GET /home/areas 服务区域平铺', 'GET', '/home/areas', null, false, - (d) => (Array.isArray(d) && d.length > 0 && d[0].province && d[0].city && d[0].district - ? '' : '应返回省/市/区县平铺列表')) - const area = areaRows && areaRows[0] - if (area) { - await ok200('H5 GET /home/feed 按服务区域筛选', 'GET', '/home/feed?areaId=' + area.id, null, false, - (d) => (isPager(d) && d.list.every((p) => p.area === area.district || p.areaId === area.id) ? '' : '服务区域筛选未生效')) - await ok200('H5 GET /home/locate 定位匹配', 'GET', '/home/locate?lat=' + (area.lat || 26.6) + '&lng=' + (area.lng || 106.7), null, false, - (d) => (d && d.areaId === area.id ? '' : '定位未匹配到服务区域')) - } -} - -/* ---------------- 三、信息发布与详情(POST/DETAIL 7,含闭环起点) ---------------- */ - -let newPostId = 0 - -async function userPost() { - // P9 发布配置:发布模式(免费/付费)+ 单条与月/年卡价格条数 + 免费发布条数 + 我的额度(v1.0.7,v1.0.10 增补 freeQuota*) - const pub = await ok200('P9 GET /post/publish-config', 'GET', '/post/publish-config', null, true, - (d) => (d && ['free', 'paid'].indexOf(d.mode) > -1 && typeof d.singlePrice === 'number' - && typeof d.monthPrice === 'number' && typeof d.monthQuota === 'number' - && typeof d.yearPrice === 'number' && typeof d.yearQuota === 'number' - && typeof d.quotaLeft === 'number' && typeof d.freeQuota === 'number' - && ['day', 'month', 'total'].indexOf(d.freeQuotaCycle) > -1 - && typeof d.freeQuotaLeft === 'number' && typeof d.freeUnlimited === 'boolean' - ? '' : '缺 mode/价格/含条数/额度/免费发布条数字段')) - - // 能否免费发布:免费模式(免费条数未用完或未限制)/ 有套餐额度 / 免费发布条数未用完 - const canFreePost = !!pub && (pub.mode === 'free' - || Number(pub.quotaLeft) > 0 - || !!pub.freeUnlimited - || Number(pub.freeQuotaLeft) > 0) - - if (canFreePost) { - const created = await ok200('P1 POST /post/create', 'POST', '/post/create', - { categoryId: 3, title: '接口验证·九成新显示器转让', content: '接口端到端验证用数据,可忽略。', - contact: '验证账号', phone: '13800001111', area: '南明区', address: '花果园', - price: 300, priceUnit: '元', tags: '验证,自提', plan: 'free' }, - true, (d) => (d && d.postId && Number(d.auditStatus) === 0 ? '' : 'postId/auditStatus 不符(BR-04 应为 0)')) - newPostId = created && created.postId ? Number(created.postId) : 0 - } else { - await expectFail('P1 · 付费模式下无额度须选发布方式', 'POST', '/post/create', - { categoryId: 3, title: '接口验证·无额度', content: 'x' }, true, '请选择发布方式') - check('闭环 · 用户发布', false, '付费模式下本账号无发布额度,跳过闭环') - } - - // P8 我的发布:状态计数 + 列表契约(v1.0.7 起「我的发布」有独立接口) - await ok200('P8 GET /post/mine', 'GET', '/post/mine?page=1&size=10', null, true, - (d) => (!isPager(d) ? '分页结构不符' - : (d.counts && typeof d.counts.all === 'number' && typeof d.counts.pending === 'number' - && typeof d.counts.passed === 'number' && typeof d.counts.rejected === 'number' - && typeof d.counts.offline === 'number' ? '' : '缺 counts(全部/待审核/已通过/已驳回/已下架)'))) - if (newPostId) { - const mine = await call('GET', '/post/mine?status=pending&page=1&size=50', null, true) - check('P8 · 刚发布的信息出现在「我的发布 → 待审核」', - mine.json && mine.json.code === 0 - && (mine.json.data.list || []).some((x) => Number(x.id) === newPostId), - '未找到 #' + newPostId) - } - - await expectFail('P1 · 分类必填 (BR-03)', 'POST', '/post/create', { title: 'x', content: 'x' }, true, '分类不能为空') - await expectFail('P1 · 标题必填 (BR-03)', 'POST', '/post/create', { categoryId: 3, content: 'x' }, true, '标题不能为空') - await expectFail('P1 · 内容必填 (BR-03)', 'POST', '/post/create', { categoryId: 3, title: 'x' }, true, '内容不能为空') - - await ok200('P4 POST /post/draft', 'POST', '/post/draft', - { title: '草稿·验证', content: '草稿内容', categoryId: 2 }, true, - (d) => (d && d.draftId && d.savedAt ? '' : '缺 draftId/savedAt')) - await ok200('P4 GET /post/drafts', 'GET', '/post/drafts', null, true, - (d) => (Array.isArray(d) && 'savedAt' in (d[0] || {}) ? '' : '草稿项缺 savedAt')) - - const detail = await ok200('D1 GET /post/{id}', 'GET', '/post/101', null, false, - (d) => (d && Number(d.id) === 101 && typeof d.contact === 'string' - && String(d.contact).indexOf('****') > -1 && d.publisher && 'name' in d.publisher - ? '' : 'contact 必须为脱敏字符串且含 publisher')) - check('D1 · 详情不泄露完整手机号 (BR-05)', - detail && String(detail.contact).indexOf('888888') === -1, '疑似泄露: ' + (detail && detail.contact)) - await expectFail('D1 · 不存在的信息', 'GET', '/post/99999999', null, false, '信息不存在') - - await ok200('D4 POST /post/{id}/pay-view', 'POST', '/post/101/pay-view', { payType: 'wxpay' }, true, - (d) => (d && d.unlocked === true && d.contact && typeof d.contact.phone === 'string' - && d.contact.phone.length === 11 ? '' : 'unlocked/contact.phone 不符')) - await expectFail('D4 · 支付方式 (BR-05)', 'POST', '/post/101/pay-view', { payType: 'alipay' }, true, '支付方式不支持') - - await ok200('D5 POST /post/{id}/comment', 'POST', '/post/101/comment', { score: 5, content: '接口验证评价' }, true, - (d) => (d && d.commentId ? '' : '缺 commentId')) - await expectFail('D5 · 评分区间 (BR-06)', 'POST', '/post/101/comment', { score: 9, content: 'x' }, true, '评分需在 1-5 之间') - await expectFail('D5 · 内容必填 (BR-06)', 'POST', '/post/101/comment', { score: 5, content: '' }, true, '评价内容不能为空') -} - -/* ---------------- 四、会员 / 分销 / 消息 / UI(12) ---------------- */ - -async function userRest() { - await ok200('M2 GET /member/plans', 'GET', '/member/plans', null, false, - (d) => (Array.isArray(d) && d.length === 3 && 'planId' in d[0] && 'days' in d[0] ? '' : '套餐应 3 档且含 planId/days')) - await ok200('M1 GET /member/benefits', 'GET', '/member/benefits', null, false, - (d) => (d && typeof d.freeView === 'number' && Array.isArray(d.benefits) ? '' : '缺 freeView/benefits')) - // v1.1.0:微信支付改为真实闭环 —— 下单只落 pending 订单,会员由 POST /pay/notify 发放 - await ok200('M2 POST /member/open', 'POST', '/member/open', { planId: 'M1', payType: 'wxpay' }, true, - (d) => (d && d.orderNo && d.status === 'pending' && d.order && d.order.orderNo - ? '' : '缺 orderNo/pending 订单(v1.1.0 契约)')) - await expectFail('M2 · 套餐必选 (BR-07)', 'POST', '/member/open', { payType: 'wxpay' }, true, '请选择会员套餐') - await ok200('M3 GET /member/orders', 'GET', '/member/orders?page=1&size=10', null, true, - (d) => (isPager(d) ? '' : '分页结构不符')) - await ok200('M4 GET /member/assets', 'GET', '/member/assets', null, true, - (d) => (d && typeof d.balance === 'number' && 'points' in d ? '' : '缺 balance/points')) - - await ok200('S1 GET /distribution/overview', 'GET', '/distribution/overview', null, true, - (d) => (d && 'totalCommission' in d && 'withdrawable' in d && 'inviteCode' in d && 'l1Count' in d - ? '' : '缺 totalCommission/withdrawable/inviteCode/l1Count')) - await ok200('S2 GET /distribution/team', 'GET', '/distribution/team?page=1&size=10', null, true, - (d) => (isPager(d) ? '' : '分页结构不符')) - await ok200('S3 POST /distribution/withdraw', 'POST', '/distribution/withdraw', { amount: 1 }, true, - (d) => (d && d.withdrawNo && d.status === 'auditing' ? '' : '缺 withdrawNo/status')) - await expectFail('S3 · 金额校验 (BR-09)', 'POST', '/distribution/withdraw', { amount: 0 }, true, '提现金额不合法') - await expectFail('S3 · 超余额 (BR-09)', 'POST', '/distribution/withdraw', { amount: 99999999 }, true, '提现金额超过可提现余额') - await ok200('S4 GET /distribution/withdraw-records', 'GET', '/distribution/withdraw-records', null, true, - (d) => (Array.isArray(d) && 'withdrawNo' in (d[0] || {}) ? '' : '缺 withdrawNo')) - - await ok200('N1 GET /message/list', 'GET', '/message/list?page=1&size=10', null, true, - (d) => (isPager(d) ? '' : '分页结构不符')) - await ok200('N1 GET /message/list 过滤', 'GET', '/message/list?type=system', null, true, - (d) => (isPager(d) && d.list.every((m) => m.type === 'system') ? '' : 'type 过滤未生效')) - await ok200('N2 POST /message/{id}/read', 'POST', '/message/1/read', {}, true, - (d) => (d && d.read === true ? '' : '缺 read')) - - await ok200('7 GET /setting/ui', 'GET', '/setting/ui', null, false, - (d) => (d && 'siteName' in d && 'logo' in d && d.icons && typeof d.icons === 'object' ? '' : '缺 siteName/logo/icons')) -} - -/* ---------------- 五、后台 · 数据总览 / 商家 / 会员 / 审核 / 评价(13) ---------------- */ - -async function adminCore() { - await ok200('AD1 GET /admin/dashboard/overview', 'GET', '/admin/dashboard/overview', null, false, - (d) => (d && 'pv' in d && 'uv' in d && 'todayPostCount' in d && 'memberCount' in d - && 'pendingAudit' in d && 'commissionTotal' in d ? '' : '缺 pv/uv/todayPostCount/memberCount/pendingAudit/commissionTotal')) - await ok200('AD2 GET /admin/dashboard/trend', 'GET', '/admin/dashboard/trend?type=traffic', null, false, - (d) => (Array.isArray(d) && (d.length === 0 || 'date' in d[0]) ? '' : '应为 [{date,...}] 数组')) - await ok200('AD2 GET /admin/dashboard/trend revenue', 'GET', '/admin/dashboard/trend?type=revenue', null, false, - (d) => (Array.isArray(d) ? '' : '应为数组')) - - await ok200('AM1 GET /admin/merchants', 'GET', '/admin/merchants?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'auditStatus' in d.list[0] && 'shopStatus' in d.list[0] && 'postCount' in d.list[0] - ? '' : '商家项缺 auditStatus/shopStatus/postCount'))) - await ok200('AM1 GET /admin/merchants 过滤', 'GET', '/admin/merchants?status=1', null, false, - (d) => (isPager(d) && d.list.every((m) => Number(m.auditStatus) === 1) ? '' : 'status 过滤未生效')) - await ok200('AM2 POST /admin/merchant/{id}/audit', 'POST', '/admin/merchant/2005/audit', { auditStatus: 1 }, false, - (d) => (d && Number(d.auditStatus) === 1 ? '' : 'auditStatus 不符')) - await expectFail('AM2 · 审核状态校验 (BR-11)', 'POST', '/admin/merchant/2005/audit', { auditStatus: 3 }, false, - '审核状态仅支持通过(1)/驳回(2)') - - await ok200('AM3 GET /admin/users', 'GET', '/admin/users?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'nickname' in d.list[0] && 'userType' in d.list[0] && 'registerAt' in d.list[0] - ? '' : '用户项缺 nickname/userType/registerAt'))) - await ok200('AM4 POST /admin/member/benefit', 'POST', '/admin/member/benefit', - { freeView: 20, postDiscount: '8 折', benefits: ['每日免费查看 20 条联系方式'] }, false, - (d) => (d && d.freeView === 20 && Array.isArray(d.benefits) ? '' : '缺 freeView/benefits')) - await expectFail('AM4 · 负数校验', 'POST', '/admin/member/benefit', { freeView: -1 }, false, '免费查看条数不能为负') - - await ok200('AU1 GET /admin/posts/pending', 'GET', '/admin/posts/pending?page=1&size=20', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'categoryName' in d.list[0] && 'userName' in d.list[0] ? '' : '待审项缺 categoryName/userName'))) - await expectFail('AU2 · 审核状态校验 (BR-11)', 'POST', '/admin/post/101/audit', { auditStatus: 5 }, false, - '审核状态仅支持通过(1)/驳回(2)') - await ok200('AU3 POST /admin/post/{id}/offline', 'POST', '/admin/post/106/offline', {}, false, - (d) => (d && d.offline === true ? '' : '缺 offline')) - await ok200('AU3 重新上架(审核通过)', 'POST', '/admin/post/106/audit', { auditStatus: 1 }, false, - (d) => (d && Number(d.auditStatus) === 1 ? '' : 'auditStatus 不符')) - - await ok200('AC1 GET /admin/comments', 'GET', '/admin/comments?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'postTitle' in d.list[0] && 'user' in d.list[0] && Array.isArray(d.list[0].images) - ? '' : '评价项缺 postTitle/user/images'))) - await ok200('AC1 POST /admin/comment/{id}/reply', 'POST', '/admin/comment/5003/reply', { reply: '已核实处理' }, false, - (d) => (d && d.reply === '已核实处理' ? '' : 'reply 不符')) - await expectFail('AC1 · 回复内容必填', 'POST', '/admin/comment/5003/reply', { reply: '' }, false, '回复内容不能为空') - await ok200('AC2 GET /admin/reports', 'GET', '/admin/reports', null, false, - (d) => (Array.isArray(d) && 'targetType' in (d[0] || {}) ? '' : '缺 targetType')) -} - -/* ---------------- 六、后台 · 分销 / 订单 / 财务(14) ---------------- */ - -async function adminMoney() { - await ok200('AS1 GET /admin/dist/setting', 'GET', '/admin/dist/setting', null, false, - (d) => (d && 'enabled' in d && 'level1Rate' in d && 'level2Rate' in d && 'minWithdraw' in d - ? '' : '缺 enabled/level1Rate/level2Rate/minWithdraw')) - await ok200('AS1 POST /admin/dist/setting', 'POST', '/admin/dist/setting', - { enabled: true, level: 2, level1Rate: 20, level2Rate: 10, settleCycle: 'T+7', minWithdraw: 50 }, false, - (d) => (d && Number(d.level1Rate) === 20 ? '' : 'level1Rate 不符')) - await expectFail('AS1 · 分佣合计校验 (BR-08)', 'POST', '/admin/dist/setting', - { level1Rate: 80, level2Rate: 40 }, false, '一级+二级分佣比例不能超过 100%') - await ok200('AS2 GET /admin/dist/withdraws', 'GET', '/admin/dist/withdraws', null, false, - (d) => (Array.isArray(d) && 'withdrawNo' in (d[0] || {}) && 'user' in (d[0] || {}) ? '' : '缺 withdrawNo/user')) - await ok200('AS3 POST /admin/dist/withdraw/{id}/pay', 'POST', '/admin/dist/withdraw/2/pay', { op: 'pay' }, false, - (d) => (d && d.status === 'paid' ? '' : 'status 应为 paid')) - await expectFail('AS3 · 驳回需填原因 (BR-09)', 'POST', '/admin/dist/withdraw/3/pay', { op: 'reject' }, false, - '驳回需填写原因') - - await ok200('AO1 GET /admin/orders', 'GET', '/admin/orders?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'orderNo' in d.list[0] && 'type' in d.list[0] && 'status' in d.list[0] - ? '' : '订单项缺 orderNo/type/status'))) - await ok200('AO2 GET /admin/orders?type=member', 'GET', '/admin/orders?type=member', null, false, - (d) => (isPager(d) && d.list.every((o) => o.type === 'member') ? '' : 'type 过滤未生效')) - await expectFail('AO1 · 退款金额校验 (BR-10)', 'POST', '/admin/order/3001/refund', { amount: 0 }, false, - '退款金额需大于 0') - await ok200('AO1 POST /admin/order/{id}/refund', 'POST', '/admin/order/3004/refund', - { amount: 5, reason: '接口验证退款' }, false, (d) => (d && d.refunded === true ? '' : '缺 refunded')) - await ok200('AO1 POST /admin/order/supplement', 'POST', '/admin/order/supplement', - { orderNo: 'PO20240104004' }, false, (d) => (d && d.supplemented === true ? '' : '缺 supplemented')) - await expectFail('AO1 · 订单号必填', 'POST', '/admin/order/supplement', {}, false, '订单号不能为空') - - await ok200('AF1 GET /admin/finance/flows', 'GET', '/admin/finance/flows?page=1&size=10', null, false, - (d) => (!isPager(d) ? '分页结构不符' - : (d.list[0] && 'flowNo' in d.list[0] && 'scene' in d.list[0] ? '' : '流水项缺 flowNo/scene'))) - await ok200('AF1 GET /admin/finance/flows 过滤', 'GET', '/admin/finance/flows?type=income', null, false, - (d) => (isPager(d) && d.list.every((f) => f.type === 'income') ? '' : 'type 过滤未生效')) - await ok200('AF3 GET /admin/finance/report', 'GET', '/admin/finance/report?period=day', null, false, - (d) => (d && d.period === 'day' && Array.isArray(d.rows) ? '' : '缺 period/rows')) - await ok200('AF3 GET /admin/finance/report month', 'GET', '/admin/finance/report?period=month', null, false, - (d) => (d && Array.isArray(d.rows) ? '' : '缺 rows')) -} - -/* ---------------- 七、后台 · 权限 / 设置(13) ---------------- */ - -async function adminRbacSetting() { - const roleList = await ok200('AR1 GET /admin/roles', 'GET', '/admin/roles', null, false, - (d) => (Array.isArray(d) && 'name' in (d[0] || {}) && Array.isArray((d[0] || {}).permissions) ? '' : '缺 name/permissions')) - // 幂等:复用已存在的「接口验证角色」(带 id 走更新),避免每次验证都新增一条角色记录 - const reuseRole = Array.isArray(roleList) ? roleList.find((r) => r && r.name === '接口验证角色') : null - const rolePayload = { name: '接口验证角色', permissions: ['dashboard', 'post'], remark: '由验证脚本创建' } - if (reuseRole && reuseRole.id) rolePayload.id = reuseRole.id - await ok200('AR1 POST /admin/role', 'POST', '/admin/role', rolePayload, false, - (d) => (d && d.name === '接口验证角色' ? '' : 'name 不符')) - await expectFail('AR1 · 角色名必填 (BR-12)', 'POST', '/admin/role', { permissions: ['post'] }, false, '请填写角色名称') - await expectFail('AR1 · 权限必填 (BR-12)', 'POST', '/admin/role', { name: 'x', permissions: [] }, false, '请至少分配一项权限') - await ok200('AR2 GET /admin/accounts', 'GET', '/admin/accounts', null, false, - (d) => (Array.isArray(d) && 'username' in (d[0] || {}) && 'roleName' in (d[0] || {}) ? '' : '缺 username/roleName')) - await ok200('AR3 GET /admin/logs', 'GET', '/admin/logs?page=1&size=10', null, false, - (d) => (isPager(d) ? '' : '分页结构不符')) - - await ok200('AZ1 GET /admin/setting/base', 'GET', '/admin/setting/base', null, false, - (d) => (d && 'appName' in d && 'servicePhone' in d ? '' : '缺 appName/servicePhone')) - await ok200('AZ1 POST /admin/setting/base', 'POST', '/admin/setting/base', - { appName: '同城分类信息', servicePhone: '400-000-0000' }, false, - (d) => (d && d.appName === '同城分类信息' ? '' : 'appName 不符')) - await expectFail('AZ1 · 名称必填', 'POST', '/admin/setting/base', { appName: '' }, false, '小程序名称不能为空') - await expectFail('AZ2 · 商户号必填', 'POST', '/admin/setting/pay', { apiKey: 'k' }, false, '微信支付商户号不能为空') - await expectFail('AZ2 · 密钥必填', 'POST', '/admin/setting/pay', { mchId: 'm' }, false, '支付密钥不能为空') - // v1.1.0:AS2 的写入目标已从 fc_config 的 pay.* 改为 epay 插件配置(addons/epay/config.php 的 wechat.mch_id/key)。 - // 本地跑时顺带断言「确实落到了插件配置文件」,测完还原该文件(见文件头 EPAY_CONFIG 快照)。 - await ok200('AZ2 POST /admin/setting/pay', 'POST', '/admin/setting/pay', - { mchId: '1900000000', apiKey: 'verify-key' }, false, (d) => { - if (!d || d.configured !== true) return '缺 configured' - if (epayConfigBackup === null) return '' - try { - return fs.readFileSync(EPAY_CONFIG, 'utf8').indexOf('1900000000') > -1 ? '' : '商户号未写入 epay 插件配置' - } catch (e) { - return '读取 epay 插件配置失败:' + e.message - } - }) - if (epayConfigBackup !== null) { - try { fs.writeFileSync(EPAY_CONFIG, epayConfigBackup) } catch (e) { /* 还原失败不影响结论 */ } - } - await ok200('AZ3 GET /admin/setting/fee', 'GET', '/admin/setting/fee', null, false, - (d) => (d && 'singlePost' in d && 'memberMonth' in d && 'memberYear' in d ? '' : '缺 singlePost/memberMonth/memberYear')) - await expectFail('AZ3 · 价格非负', 'POST', '/admin/setting/fee', { singlePost: -1 }, false, '单条发布价格不能为负') - await ok200('AZ3 POST /admin/setting/fee', 'POST', '/admin/setting/fee', - { singlePost: 5, memberMonth: 19.9, memberQuarter: 49.9, memberYear: 168 }, false, - (d) => (d && Number(d.singlePost) === 5 ? '' : 'singlePost 不符')) - const adminAreas = await ok200('AZ4 GET /admin/setting/areas', 'GET', '/admin/setting/areas', null, false, - (d) => (Array.isArray(d) && 'enabled' in (d[0] || {}) && 'province' in (d[0] || {}) && 'district' in (d[0] || {}) ? '' : '缺 enabled/province/district')) - await ok200('AZ4 POST /admin/setting/area', 'POST', '/admin/setting/area', - { province: '贵州省', city: '贵阳市', district: '接口验证区', sort: 99, enabled: true }, false, - (d) => (d && d.fullName && d.fullName.indexOf('接口验证区') > -1 ? '' : 'fullName 不符')) - await expectFail('AZ4 · 省份必填', 'POST', '/admin/setting/area', { city: '贵阳市', district: '接口验证区' }, false, '省份不能为空') - await expectFail('AZ4 · 区县必填', 'POST', '/admin/setting/area', { province: '贵州省', city: '贵阳市' }, false, '区县不能为空') - - /* UI 配置:读 → 写(含图标覆盖)→ 用户端立即可见 → 重置 */ - await ok200('AZ5 GET /admin/setting/ui', 'GET', '/admin/setting/ui', null, false, - (d) => (d && 'siteName' in d && 'logo' in d && 'icons' in d && 'updatedAt' in d ? '' : '缺 siteName/logo/icons/updatedAt')) - await ok200('AZ5 POST /admin/setting/ui', 'POST', '/admin/setting/ui', - { siteName: '同城分类信息', logo: '/uploads/verify-logo.png', icons: { 'tab-home': '/uploads/verify-tab-home.png' } }, false, - (d) => (d && d.logo === '/uploads/verify-logo.png' - && d.icons && d.icons['tab-home'] === '/uploads/verify-tab-home.png' ? '' : '保存结果未回显')) - await expectFail('AZ5 · 图标地址类型校验', 'POST', '/admin/setting/ui', - { icons: { 'tab-home': 123 } }, false, '图标 tab-home 的地址必须是字符串') - - const ui = await call('GET', '/setting/ui') - const icons = (ui.json && ui.json.data && ui.json.data.icons) || {} - check('AZ5 · 用户端读到后台覆盖的 LOGO/图标', - ui.json && ui.json.data && ui.json.data.logo === '/uploads/verify-logo.png' - && icons['tab-home'] === '/uploads/verify-tab-home.png', - '实际 logo=' + (ui.json && ui.json.data && ui.json.data.logo) + ' icons=' + JSON.stringify(icons)) - - await ok200('AZ5 POST /admin/setting/ui/reset', 'POST', '/admin/setting/ui/reset', {}, false, - (d) => (d && d.reset === true && d.logo === '' ? '' : '缺 reset/logo')) - const ui2 = await call('GET', '/setting/ui') - check('AZ5 · 重置后用户端恢复内置 LOGO', - ui2.json && ui2.json.data && ui2.json.data.logo === '', - '实际 logo=' + (ui2.json && ui2.json.data && ui2.json.data.logo)) -} - -/* ---------------- 八、双端业务闭环(发布 → 审核 → 前台可见) ---------------- */ - -async function closeLoop() { - if (!newPostId) { - check('闭环 · 用户发布', false, '未取得 newPostId,跳过闭环') - return - } - - // 1) 后台待审列表里应出现刚发布的信息 - const pend = await call('GET', '/admin/posts/pending?page=1&size=50') - const found = pend.json && pend.json.code === 0 && (pend.json.data.list || []).some((p) => Number(p.id) === newPostId) - check('闭环① 后台待审列表可见新发布 #' + newPostId, found, - '待审 id: ' + ((pend.json && pend.json.data && (pend.json.data.list || []).map((p) => p.id).join(',') ) || '-')) - - // 2) 未审核前,前台信息流不应出现 - const feed0 = await call('GET', '/home/feed?page=1&size=50') - const before = (feed0.json && feed0.json.data && feed0.json.data.list || []).some((p) => Number(p.id) === newPostId) - check('闭环② 待审信息不出现在前台信息流 (BR-04)', !before, '待审信息提前出现在信息流') - - // 3) 后台审核通过 - const audit = await call('POST', '/admin/post/' + newPostId + '/audit', { auditStatus: 1 }, false) - check('闭环③ 后台审核通过', - audit.json && audit.json.code === 0 && Number(audit.json.data.auditStatus) === 1, - audit.json ? 'code=' + audit.json.code + ' ' + audit.json.message : 'HTTP ' + audit.status) - - // 4) 前台信息流应出现 - const feed1 = await call('GET', '/home/feed?page=1&size=50') - const after = (feed1.json && feed1.json.data && feed1.json.data.list || []).some((p) => Number(p.id) === newPostId) - check('闭环④ 审核通过后前台信息流可见', after, '未出现在信息流') - - // 5) 详情可访问,且联系方式为脱敏 - const det = await call('GET', '/post/' + newPostId) - check('闭环⑤ 详情可访问且联系方式脱敏', - det.json && det.json.code === 0 && String(det.json.data.contact).indexOf('****') > -1, - det.json ? 'contact=' + det.json.data.contact : 'HTTP ' + det.status) - - // 6) 发布者应收到审核结果消息 - const msg = await call('GET', '/message/list?page=1&size=20', null, true) - const hasMsg = (msg.json && msg.json.data && msg.json.data.list || []) - .some((m) => String(m.title).indexOf('审核') > -1) - check('闭环⑥ 发布者收到审核结果站内消息', hasMsg, '未找到审核类消息') - - // 7) 后台操作已留痕 - const logs = await call('GET', '/admin/logs?page=1&size=10') - const hasLog = (logs.json && logs.json.data && logs.json.data.list || []) - .some((l) => String(l.action).indexOf('审核信息') > -1) - check('闭环⑦ 后台审核动作已留痕 (AR3)', hasLog, '未找到审核信息日志') - - // 8) 资金/流水联动:审核通过不影响资金;此处校验流水接口可用且字段完整 - const flows = await call('GET', '/admin/finance/flows?page=1&size=10') - check('闭环⑧ 资金流水字段完整', - flows.json && flows.json.code === 0 && (flows.json.data.list || []).every((f) => 'flowNo' in f && 'balance' in f), - '流水字段不完整') -} - -/* ---------------- 九、v1.1.0 新增能力(上传 / LBS / 互动 / 举报 / 充值 / 支付 / 优惠券) ---------------- */ - -/** 1×1 透明 PNG,用作上传探针 */ -const PNG_1PX = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - 'base64') - -async function v110() { - /* U1 媒体上传(multipart,真实落盘) */ - let uploadUrl = '' - try { - const fd = new FormData() - fd.append('file', new Blob([PNG_1PX], { type: 'image/png' }), 'probe.png') - const headers = token ? { Authorization: 'Bearer ' + token } : {} - const res = await fetch(BASE + '/post/upload', { method: 'POST', headers, body: fd }) - const j = await res.json().catch(() => null) - const good = !!(j && j.code === 0 && j.data && j.data.url && j.data.type === 'image') - check('U1 POST /post/upload', good, j ? ('code=' + j.code + ' message=' + j.message) : 'HTTP ' + res.status) - if (good) uploadUrl = j.data.url - } catch (e) { - check('U1 POST /post/upload', false, String(e.message || e)) - } - - /* H6 LBS:附近排序 + distance 字段 */ - await ok200('H6 GET /home/feed 附近排序', 'GET', '/home/feed?page=1&size=10&lat=26.647&lng=106.63&sort=nearby', null, false, - (d) => { - if (!isPager(d)) return '分页结构不符' - if (!d.list.length) return '' - if (!d.list.every((p) => 'distance' in p)) return '列表项缺 distance 字段' - const ds = d.list.map((p) => p.distance).filter((x) => typeof x === 'number') - for (let i = 1; i < ds.length; i++) { - if (ds[i] < ds[i - 1] - 1e-9) return 'nearby 未按距离升序' - } - return '' - }) - - /* P10 详情新增字段(LBS / 收藏 / 关注) */ - const detail = await ok200('P10 GET /post/{id} 新增字段', 'GET', '/post/101', null, false, - (x) => (x && 'lat' in x && 'lng' in x && 'faved' in x && 'followed' in x && 'favoriteCount' in x - ? '' : '缺 lat/lng/faved/followed/favoriteCount')) - const publisherId = detail && detail.publisher ? Number(detail.publisher.id) || 0 : 0 - - /* F 收藏 / 关注 */ - const t1 = await ok200('F1 POST /favorite/toggle', 'POST', '/favorite/toggle', { postId: 101 }, true, - (x) => (x && 'faved' in x && typeof x.count === 'number' ? '' : '缺 faved/count')) - await ok200('F1b POST /favorite/toggle 取消', 'POST', '/favorite/toggle', { postId: 101 }, true, - (x) => (x && x.faved === !(t1 && t1.faved) ? '' : '切换未取反')) - await ok200('F2 GET /favorite/list', 'GET', '/favorite/list?page=1&size=10', null, true, - (x) => (isPager(x) ? '' : '分页结构不符')) - await ok200('F3 POST /favorite/status', 'POST', '/favorite/status', - { postIds: [101], userIds: publisherId ? [publisherId] : [] }, true, - (x) => (x && typeof x.faved === 'object' && typeof x.followed === 'object' ? '' : '缺 faved/followed 映射')) - if (publisherId) { - await ok200('F4 POST /follow/toggle', 'POST', '/follow/toggle', { userId: publisherId }, true, - (x) => (x && x.followed === true ? '' : 'followed 应为 true')) - await ok200('F4b POST /follow/toggle 取消', 'POST', '/follow/toggle', { userId: publisherId }, true, - (x) => (x && x.followed === false ? '' : 'followed 应为 false')) - } else { - check('F4 POST /follow/toggle', false, '未取到 publisher.id') - } - - /* R 举报 */ - await ok200('R1 POST /report', 'POST', '/report', - { targetType: 'post', targetId: 101, reason: '垃圾广告', detail: '接口验证' }, true, - (x) => (x && x.reportId ? '' : '缺 reportId')) - await expectFail('R1 · 举报类型校验', 'POST', '/report', - { targetType: 'foo', targetId: 101, reason: '垃圾广告' }, true, '举报类型不支持') - await expectFail('R1 · 举报原因必填', 'POST', '/report', - { targetType: 'post', targetId: 101, reason: '' }, true, '请选择举报原因') - - /* O 余额充值(v1.1.0 修复资损:余额「充值」禁止 balance —— 用余额给余额加钱不扣任何款,等于凭空增发) */ - await expectFail('O1 · 拒绝余额充值(资损防护)', 'POST', '/order/recharge', - { amount: 1, payType: 'balance' }, true, '余额充值仅支持微信支付') - await expectFail('O1 · 充值金额上限校验', 'POST', '/order/recharge', { amount: 200000, payType: 'wxpay' }, true, '充值金额不合法') - await expectFail('O1 · 充值金额校验', 'POST', '/order/recharge', { amount: 0, payType: 'wxpay' }, true, '充值金额不合法') - await expectFail('O1 · 支付方式校验', 'POST', '/order/recharge', { amount: 1, payType: 'alipay' }, true, '支付方式不支持') - - /* Y 微信支付:下单挂起 → prepay(epay 插件下单) → 回调公开路由(插件验签) */ - const wxOrder = await ok200('O2 POST /order/recharge(微信·挂起)', 'POST', '/order/recharge', - { amount: 1, payType: 'wxpay' }, true, - (x) => (x && x.orderNo && x.status === 'pending' ? '' : '微信充值应为 pending')) - await expectFail('Y1 · prepay 缺订单号', 'POST', '/pay/prepay', {}, true, '订单号不能为空') - if (wxOrder && wxOrder.orderNo) { - const pre = await call('POST', '/pay/prepay', { orderNo: wxOrder.orderNo }, true) - const j = pre.json - check('Y2 POST /pay/prepay', - !!(j && ((j.code === 0 && j.data && j.data.payParams && j.data.payParams.paySign) - || (j.code !== 0 && String(j.message).indexOf('微信支付未配置') > -1))), - j ? ('code=' + j.code + ' message=' + j.message) : 'HTTP ' + pre.status) - } else { - check('Y2 POST /pay/prepay', false, '未取到待支付订单号') - } - try { - // 支付回调:报文与应答由 epay 插件负责(Service::checkNotify / $pay->success())。 - // 未配置插件商户参数时 Pay::notifyCallback 回 SUCCESS 但不处理;已配置时伪造报文会验签失败 → FAIL。 - // 两种情况都必须是「纯 XML 应答」,故只断言返回可被微信识别的 XML 报文。 - const res = await fetch(BASE + '/pay/notify', { - method: 'POST', - headers: { 'Content-Type': 'application/xml' }, - body: '', - }) - const raw = await res.text() - check('Y3 POST /pay/notify 公开路由', - res.status === 200 && raw.indexOf('') > -1 && raw.indexOf('') > -1, - 'HTTP ' + res.status + ' body=' + String(raw).slice(0, 80)) - } catch (e) { - check('Y3 POST /pay/notify 公开路由', false, String(e.message || e)) - } - - /* C 优惠券 */ - const coupons = await ok200('C1 GET /coupon/list', 'GET', '/coupon/list?page=1&size=10', null, true, - (x) => (!isPager(x) ? '分页结构不符' - : (!x.list.length || ('received' in x.list[0] && 'name' in x.list[0]) ? '' : '券项缺 received/name'))) - const couponId = coupons && coupons.list && coupons.list.length ? coupons.list[0].id : 0 - if (couponId) { - const r = await call('POST', '/coupon/receive', { couponId }, true) - const j = r.json - check('C2 POST /coupon/receive', - !!(j && (j.code === 0 ? !!(j.data && j.data.userCouponId) : String(j.message).indexOf('已领取') > -1)), - j ? ('code=' + j.code + ' message=' + j.message) : 'HTTP ' + r.status) - } else { - check('C2 POST /coupon/receive', true, '') - } - - /* M5 资产带真实优惠券 */ - await ok200('M5 GET /member/assets(券)', 'GET', '/member/assets', null, true, - (x) => (x && Array.isArray(x.coupons) && typeof x.couponCount === 'number' ? '' : '缺 coupons/couponCount')) - - /* P11 发布带媒体与坐标 → 详情回读 */ - const created = await ok200('P11 POST /post/create(媒体+坐标)', 'POST', '/post/create', { - categoryId: 1, - title: '接口验证·媒体与坐标', - content: 'v1.1.0 媒体上传与 LBS 坐标回读', - media: uploadUrl ? [uploadUrl] : [], - lat: 26.647, - lng: 106.63, - plan: 'free', - }, true, (x) => (x && x.postId ? '' : '缺 postId')) - if (created && created.postId) { - await ok200('P11 · 详情回读媒体与坐标', 'GET', '/post/' + created.postId, null, false, - (x) => (x && 'lat' in x && 'lng' in x && 'distance' in x && Array.isArray(x.media) ? '' : '缺 lat/lng/distance/media')) - } else { - check('P11 · 详情回读媒体与坐标', false, '未取到 postId') - } -} - -/* ---------------- 主流程 ---------------- */ - -async function main() { - console.log('接口验证开始 → ' + BASE) - console.log('') - - await userAuth() - await userHome() - await userPost() - await userRest() - await adminCore() - await adminMoney() - await adminRbacSetting() - await v110() - await closeLoop() - - const total = PASS.length + FAIL.length - console.log('————————— 验证结果 —————————') - console.log('用例总数: ' + total + ' 通过: ' + PASS.length + ' 失败: ' + FAIL.length) - if (FAIL.length) { - console.log('') - console.log('失败明细:') - FAIL.forEach((f, i) => console.log(' ' + (i + 1) + '. ' + f)) - } - console.log('') - console.log('耗时: ' + ((Date.now() - T0) / 1000).toFixed(1) + 's') - process.exit(FAIL.length ? 1 : 0) -} - -main().catch((e) => { - console.error('验证脚本异常: ' + (e && e.stack ? e.stack : e)) - process.exit(1) -}) diff --git a/tools/verify_api_data.php b/tools/verify_api_data.php deleted file mode 100644 index e7ea5a2..0000000 --- a/tools/verify_api_data.php +++ /dev/null @@ -1,61 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - echo "=== 1. 验证商家帖子详情 (如 ID: 101 - 顺捷同城物流园) ===" . PHP_EOL; - $post101 = $pdo->query("SELECT * FROM fa_fc_post WHERE id = 101")->fetch(PDO::FETCH_ASSOC); - $user101 = $pdo->query("SELECT * FROM fa_user WHERE id = {$post101['user_id']}")->fetch(PDO::FETCH_ASSOC); - $merchant101 = $pdo->query("SELECT * FROM fa_fc_merchant WHERE user_id = {$post101['user_id']}")->fetch(PDO::FETCH_ASSOC); - - echo "标题: {$post101['title']}" . PHP_EOL; - echo "identity_type: {$post101['identity_type']} (应为2)" . PHP_EOL; - echo "发布人: {$user101['nickname']}, 头像: {$user101['avatar']}" . PHP_EOL; - echo "关联商家: {$merchant101['name']}" . PHP_EOL; - echo " - logo: {$merchant101['logo']}" . PHP_EOL; - echo " - banner: {$merchant101['banner']}" . PHP_EOL; - echo " - address: {$merchant101['address']}" . PHP_EOL; - echo " - contact: {$merchant101['contact']}" . PHP_EOL; - echo " - category: {$merchant101['category']}" . PHP_EOL; - echo " - notice: {$merchant101['notice']}" . PHP_EOL; - echo " - post_count: {$merchant101['post_count']}" . PHP_EOL; - - echo PHP_EOL . "=== 2. 验证个人帖子详情 (如 ID: 102 - 小王同学) ===" . PHP_EOL; - $post102 = $pdo->query("SELECT * FROM fa_fc_post WHERE id = 102")->fetch(PDO::FETCH_ASSOC); - $user102 = $pdo->query("SELECT * FROM fa_user WHERE id = {$post102['user_id']}")->fetch(PDO::FETCH_ASSOC); - echo "标题: {$post102['title']}" . PHP_EOL; - echo "identity_type: {$post102['identity_type']} (应为1)" . PHP_EOL; - echo "发布人: {$user102['nickname']}, 头像: {$user102['avatar']}" . PHP_EOL; - echo "联系人: {$post102['contact']}, 手机: {$post102['phone']}, 微信: {$post102['wechat']}" . PHP_EOL; - - echo PHP_EOL . "=== 3. 验证所有商家的档案完整性 ===" . PHP_EOL; - $merchants = $pdo->query("SELECT * FROM fa_fc_merchant")->fetchAll(PDO::FETCH_ASSOC); - $allComplete = true; - foreach ($merchants as $m) { - $missing = []; - if (empty($m['logo'])) $missing[] = 'logo'; - if (empty($m['banner'])) $missing[] = 'banner'; - if (empty($m['address'])) $missing[] = 'address'; - if (empty($m['lat']) || empty($m['lng'])) $missing[] = 'lat/lng'; - if (empty($m['contact'])) $missing[] = 'contact'; - if (empty($m['category'])) $missing[] = 'category'; - if (empty($m['notice'])) $missing[] = 'notice'; - - if (!empty($missing)) { - echo "商家 {$m['name']} 缺失字段: " . implode(', ', $missing) . PHP_EOL; - $allComplete = false; - } else { - echo "✓ 商家 [{$m['id']}] {$m['name']} 数据完整 (有效发帖: {$m['post_count']})" . PHP_EOL; - } - } - if ($allComplete) { - echo ">>> 全部商家数据 100% 完整!<<<" . PHP_EOL; - } -} - -verifyApiOutputs(); diff --git a/uniapp/common/api/auth.js b/uniapp/common/api/auth.js index 43cc7d1..511d243 100644 --- a/uniapp/common/api/auth.js +++ b/uniapp/common/api/auth.js @@ -19,7 +19,7 @@ export const authApi = { saveProfile: (payload) => post('/auth/profile', payload, { silent: true }), /** * 上传微信头像(chooseAvatar 得到的临时文件路径) - * 小程序环境走 uni.uploadFile(multipart/form-data);Node 测试环境回落 mock 路由。 + * 小程序环境走 uni.uploadFile(multipart/form-data)。 * @returns {Promise<{url:string, avatar:string}>} */ uploadAvatar(filePath) { diff --git a/uniapp/common/api/post.js b/uniapp/common/api/post.js index aed0aa2..ff723b6 100644 --- a/uniapp/common/api/post.js +++ b/uniapp/common/api/post.js @@ -8,8 +8,7 @@ export const postApi = { /** * 上传发布媒体(图片 / 视频) * 契约 §2.1:POST /post/upload multipart `file`(+可选 `type=image|video`)→ {url,type,size,name} - * 小程序环境走 uni.uploadFile(multipart/form-data,带 Authorization), - * Node 测试 / H5 环境回落 mock 路由,保证离线可跑。 + * 小程序环境走 uni.uploadFile(multipart/form-data,带 Authorization)。 * @param {String} filePath uni.chooseImage / uni.chooseVideo 得到的临时路径 * @param {'image'|'video'} type * @returns {Promise<{url:string,type:string,size:number,name:string}>} @@ -63,7 +62,7 @@ export const postApi = { detail: (id, params = {}) => get(`/post/${id}`, params, { silent: true }), /** 申请拨打联系电话(校验每日拨打次数上限并记录拨打日志) */ dial: (id, payload = {}) => post(`/post/${id}/dial`, payload, { silent: true }), - /** 付费查看联系方式(微信/余额) */ + /** 解锁完整信息(单条付费解锁,余额/微信;免费公开模式无需调用) */ payView: (id, payload) => post(`/post/${id}/pay-view`, payload, { silent: true }), /** 发表评价(评分 + 图文) */ comment: (id, payload) => post(`/post/${id}/comment`, payload, { silent: true }), diff --git a/uniapp/common/config/index.js b/uniapp/common/config/index.js index 9dcb0da..1ecd04b 100644 --- a/uniapp/common/config/index.js +++ b/uniapp/common/config/index.js @@ -48,7 +48,7 @@ const ENV = IS_PROD ? 'production' : 'development' // 各环境后端域名(dev=运行 / prod=发行) const HOSTS = { - dev: 'https://442a5574.r9.cpolar.cn', // 本地后端(phpstudy 虚拟域名与端口 9980) + dev: 'https://ff5e582.r9.cpolar.cn', // 本地后端(phpstudy 虚拟域名与端口 9980) prod: 'https://fenleixinxi.gzyakj.com.cn', // 远程正式后端 } // 接口统一前缀 @@ -68,12 +68,6 @@ export const config = { apiVersion: 'v1', // 请求超时(ms) timeout: 15000, - // 是否允许失败后降级到本地 mock(离线演示/无后端时开启) - // ⚠️ 生产必须为 false:否则真实接口一旦失败会**静默返回 mock 假数据**, - // 表现为「后台改了但前端不变」,且真实问题被完全掩盖。 - enableMockFallback: false, - // 强制使用 mock(联调前本地联调可置 true) - forceMock: false, // 默认支付方式:'balance' 余额支付 / 'wxpay' 微信支付 defaultPayType: 'wxpay', // 本地存储键 diff --git a/uniapp/common/mock/data.js b/uniapp/common/mock/data.js deleted file mode 100644 index e8ad3b6..0000000 --- a/uniapp/common/mock/data.js +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Mock 数据集 - * 说明:所有封面图为空字符串,页面使用生态绿渐变占位,避免依赖外部图床/域名白名单。 - */ - -const now = Date.now() -const day = 86400000 -const iso = (offsetDays) => new Date(now - offsetDays * day).toISOString() - -export const datasets = { - /** 当前登录用户 */ - user: { - id: 1001, - nickname: '同城小助手', - avatar: '', - phone: '13800001111', - userType: 1, // 1 个人 / 2 商家 - isMember: true, - memberLevel: '年卡会员', - memberExpireAt: new Date(now + 300 * day).toISOString(), - balance: 128.5, // 元 - realnameStatus: 1, - }, - - /** 首页轮播广告位(color 为生态绿家族渐变,image 为空时前端用「绿底 + 白色插画」内置配图) */ - banners: [ - { id: 1, title: '同城好物节 · 二手直降', image: '', link: '/pages/category/channel?categoryId=3', color: 'linear-gradient(105deg, #10b981 0%, #34d399 50%, #a7f3d0 100%)' }, - { id: 2, title: '招聘旺季 · 免费发布', image: '', link: '/pages/post/create', color: 'linear-gradient(105deg, #059669 0%, #10b981 50%, #6ee7b7 100%)' }, - { id: 3, title: '本地服务 · 上门更省心', image: '', link: '/pages/category/channel?categoryId=5', color: 'linear-gradient(105deg, #00d26a 0%, #34d399 50%, #bbf7d0 100%)' }, - ], - - /** 分类导航 */ - categories: [ - { id: 1, name: '招聘求职', iconKey: 'cat-job', color: '#04874a', count: 1280 }, - { id: 2, name: '房产租售', iconKey: 'cat-house', color: '#1f8c4c', count: 860 }, - { id: 3, name: '二手闲置', iconKey: 'cat-goods', color: '#5f9a2f', count: 2340 }, - { id: 4, name: '车辆买卖', iconKey: 'cat-car', color: '#0e8c6a', count: 430 }, - { id: 5, name: '本地服务', iconKey: 'cat-service', color: '#17bf7a', count: 760 }, - { id: 6, name: '教育培训', iconKey: 'cat-education', color: '#3e8c5e', count: 320 }, - { id: 7, name: '家政保洁', iconKey: 'cat-clean', color: '#17b368', count: 210 }, - { id: 8, name: '同城交友', iconKey: 'cat-friend', color: '#e9a23b', count: 190 }, - ], - - /** 热门搜索 */ - hotKeywords: ['租房 一室一厅', '二手电动车', '急招快递员', '钢琴家教', '空调维修'], - - /** 信息流(含待审核/已通过/已驳回,供两端联调;lat/lng 为 LBS 坐标,distance 单位 km 一位小数) */ - posts: [ - { - id: 101, categoryId: 1, categoryName: '招聘求职', title: '急招快递分拣员(日结)', - content: '站点直招,日结 180-220 元,包工作餐,当日结算。有意者电话联系。', - cover: '', price: 0, priceUnit: '面议', area: '云岩区', address: '贵阳北站物流园', - contact: '139****8888', contactFull: '13988888888', views: 1892, likes: 32, - isPaid: false, auditStatus: 1, createdAt: iso(0.2), tags: ['日结', '包吃'], - publisher: { id: 2001, name: '顺捷物流', avatar: '', isMerchant: true, verified: true }, - commentCount: 12, lat: 26.6157, lng: 106.6979, distance: 7.6, - }, - { - id: 102, categoryId: 2, categoryName: '房产租售', title: '整租·未来方舟精装一室一厅', - content: '家电齐全,拎包入住,近地铁 2 号线,押一付一,可短租。', - cover: '', price: 1800, priceUnit: '元/月', area: '南明区', address: '未来方舟 B 区', - contact: '135****6666', contactFull: '13566666666', views: 3204, likes: 88, - isPaid: true, auditStatus: 1, createdAt: iso(0.5), tags: ['精装', '近地铁'], - identityType: 'merchant', - merchant: { - id: 2002, - name: '安家房产中介服务部', - logo: '', - category: '房产租售', - address: '贵阳市南明区未来方舟 B 区 102 门面', - lat: 26.5716, - lng: 106.7314, - contact: '13566666666', - notice: '十年本地信誉老店,房源真实无套路,欢迎随时到店喝茶看房~', - verified: true, - }, - publisher: { id: 2002, name: '安家房产', avatar: '', isMerchant: true, verified: true }, - commentCount: 26, lat: 26.5716, lng: 106.7314, distance: 13.1, - }, - { - id: 103, categoryId: 3, categoryName: '二手闲置', title: '九成新山地自行车 转让', - content: '骑行不到 300 公里,27 速,原价 1599,现 680 出,可小刀,同城自提。', - cover: '', price: 680, priceUnit: '元', area: '观山湖区', address: '世纪城', - contact: '137****1234', contactFull: '13712341234', views: 640, likes: 21, - isPaid: false, isTop: true, auditStatus: 1, createdAt: iso(1), tags: ['自提', '9成新'], - publisher: { id: 2003, name: '骑行爱好者', avatar: '', isMerchant: false, verified: false }, - commentCount: 4, lat: 26.6300, lng: 106.6250, distance: 1.9, - }, - { - id: 104, categoryId: 4, categoryName: '车辆买卖', title: '2019 款家用轿车 一手车', - content: '车况精品,无事故无泡水,4S 店保养记录齐全,支持第三方检测。', - cover: '', price: 68000, priceUnit: '元', area: '云岩区', address: '贵阳市云岩区', - contact: '138****0000', contactFull: '13800000000', views: 1120, likes: 40, - isPaid: true, auditStatus: 0, createdAt: iso(0.1), tags: ['无事故', '可检测'], - publisher: { id: 2004, name: '车行天下', avatar: '', isMerchant: true, verified: true }, - commentCount: 0, lat: 26.6049, lng: 106.7194, distance: 10.0, - }, - { - id: 105, categoryId: 5, categoryName: '本地服务', title: '专业空调清洗 / 加氟上门', - content: '10 年老师傅,上门服务,清洗 60 元/台,加氟 80 元起,不满意不收费。', - cover: '', price: 60, priceUnit: '元/台', area: '南明区', address: '花果园', - contact: '131****5678', contactFull: '13156785678', views: 2310, likes: 66, - isPaid: false, auditStatus: 0, createdAt: iso(0.05), tags: ['上门', '不满意不收费'], - publisher: { id: 2005, name: '洁净到家', avatar: '', isMerchant: true, verified: true }, - commentCount: 18, lat: 26.5600, lng: 106.6870, distance: 11.2, - }, - { - id: 106, categoryId: 3, categoryName: '二手闲置', title: 'iPad 2021 64G 深空灰', - content: '学生自用,无磕碰,配件齐全,支持当面验机。', - cover: '', price: 1450, priceUnit: '元', area: '观山湖区', address: '贵阳奥体中心', - contact: '150****3344', contactFull: '15033443344', views: 480, likes: 15, - isPaid: false, auditStatus: 2, createdAt: iso(2), tags: ['验机'], - publisher: { id: 2006, name: '小王同学', avatar: '', isMerchant: false, verified: false }, - commentCount: 1, lat: 26.6230, lng: 106.6400, distance: 2.8, - }, - ], - - /** 付费查看后的完整联系方式 */ - secretContact: { phone: '13988888888', wechat: 'tongcheng_101', note: '请说明来自同城分类信息' }, - - /** 草稿箱 */ - drafts: [ - { draftId: 9001, title: '转租单间(草稿)', content: '还未填完……', categoryId: 2, savedAt: iso(0.1) }, - ], - - /** - * 发布配置(后台「发布与费用配置」下发;与 fa_fc_config 的 post.* 对齐) - * mode:free 免费发布 / paid 付费发布(付费模式下按 singlePrice 或套餐额度) - * quotaLeft:当前用户的剩余发布条数(套餐额度,随会员有效期) - * discountRate:会员发布折扣率(1 = 无折扣,0.8 = 8 折);单条金额 = round(singlePrice * discountRate, 2) - * discountText:折扣展示文案(如「8 折」),无折扣时为空 - */ - publishRule: { - mode: 'paid', - singlePrice: 5, - discountRate: 1, - discountText: '', - monthPrice: 19.9, - monthQuota: 30, - yearPrice: 168, - yearQuota: 500, - quotaLeft: 30, - // 免费发布条数(字段名与后端 Post::publishConfig 对齐:freeQuota/freeQuotaCycle/freeQuotaUsed/freeQuotaLeft/freeUnlimited) - freeQuota: 2, // 周期内免费发布条数 - freeQuotaCycle: 'daily', - freeQuotaUsed: 0, - freeQuotaLeft: 2, // -1 表示不限制 - freeUnlimited: false, - quotaExpireAt: iso(-25), - isMember: true, - memberExpireAt: iso(-25), - }, - - /** 我的发布(用户端「我的发布」页数据源;覆盖待审核/已通过/已驳回/已下架四种状态) */ - myPosts: [ - { - id: 9001, title: '急招快递分拣员(日结)', content: '站点直招,日结 180-220 元。', - categoryId: 1, categoryName: '招聘求职', cover: '', price: 0, priceUnit: '面议', - area: '云岩区', address: '贵阳北站物流园', auditStatus: 0, auditReason: '', status: 1, - isTop: false, isPaid: false, amount: 0, views: 0, likes: 0, commentCount: 0, createdAt: iso(0.05), - }, - { - id: 9002, title: '九成新山地自行车 转让', content: '骑行不到 300 公里,27 速,同城自提。', - categoryId: 3, categoryName: '二手闲置', cover: '', price: 680, priceUnit: '元', - area: '观山湖区', address: '世纪城', auditStatus: 1, auditReason: '', status: 1, - isTop: true, isPaid: true, amount: 5, views: 128, likes: 6, commentCount: 2, createdAt: iso(1), - }, - { - id: 9003, title: '求租一室一厅(云岩区)', content: '预算 1500,可押一付一。', - categoryId: 2, categoryName: '房产租售', cover: '', price: 0, priceUnit: '面议', - area: '云岩区', address: '', auditStatus: 2, auditReason: '联系方式不完整,请补充手机号', status: 1, - isTop: false, isPaid: false, amount: 0, views: 12, likes: 0, commentCount: 0, createdAt: iso(2), - }, - { - id: 9004, title: '高价回收家电', content: '上门回收冰箱洗衣机。', - categoryId: 7, categoryName: '家政保洁', cover: '', price: 0, priceUnit: '面议', - area: '南明区', address: '', auditStatus: 1, auditReason: '', status: 0, - isTop: false, isPaid: false, amount: 0, views: 33, likes: 1, commentCount: 0, createdAt: iso(3), - }, - ], - - /** 发布费用标准(后台可配) */ - priceRule: { - singlePost: 5, // 元/条 - freePostPerDay: 2, - memberMonth: 19.9, - memberQuarter: 49.9, - memberYear: 168, - }, - - /** 会员套餐 */ - memberPlans: [ - // postQuota:套餐含发布条数(后台「发布与费用配置」同步) - { planId: 'M1', name: '月卡', price: 19.9, originPrice: 29.9, days: 30, tag: '尝鲜', postQuota: 30 }, - { planId: 'M3', name: '季卡', price: 49.9, originPrice: 89.7, days: 90, tag: '最划算', postQuota: 90 }, - { planId: 'M12', name: '年卡', price: 168, originPrice: 358.8, days: 365, tag: '省心', postQuota: 500 }, - ], - - /** 会员权益 */ - memberBenefits: { - freeView: 20, // 每日免费查看联系方式条数 - postDiscount: '8 折', - distRate: '一级 20% / 二级 10%', - benefits: ['每日免费查看 20 条联系方式', '发布信息 8 折', '专属会员标识', '赠送大量发布额度'], - }, - - /** 我的资产(coupons / couponCount 由 /member/assets 从 fc_user_coupon 实时聚合) */ - assets: { - balance: 128.5, - points: 320, - }, - - /** 余额明细(fc_wallet_flow 用户钱包流水,字段与后端 /member/flows 一致;type: income=入账 expense=支出) */ - financeFlows: [ - { flowNo: 'W202610240001', type: 'income', scene: '后台充值', amount: 2000, balance: 2128.5, createdAt: iso(0) }, - { flowNo: 'W202610230002', type: 'expense', scene: '付费查看', amount: 2, balance: 128.5, createdAt: iso(-1) }, - { flowNo: 'W202610220003', type: 'income', scene: '余额充值', amount: 100, balance: 130.5, createdAt: iso(-2) }, - { flowNo: 'W202610210004', type: 'expense', scene: '发布付费', amount: 5, balance: 30.5, createdAt: iso(-3) }, - { flowNo: 'W202610200005', type: 'income', scene: '订单退款(余额)', amount: 5, balance: 35.5, createdAt: iso(-4) }, - { flowNo: 'W202610190006', type: 'expense', scene: '会员开通', amount: 168, balance: 30.5, createdAt: iso(-5) }, - ], - - /** 可领取的优惠券定义(fc_coupon) */ - coupons: [ - { id: 1, name: '发布立减 3 元', type: 'fixed', amount: 3, minAmount: 0, total: 1000, receivedCount: 12, enabled: true, sort: 1, days: 30 }, - { id: 2, name: '满 50 减 10', type: 'fixed', amount: 10, minAmount: 50, total: 500, receivedCount: 88, enabled: true, sort: 2, days: 15 }, - { id: 3, name: '会员开通 9 折', type: 'discount', amount: 0.9, minAmount: 0, total: 200, receivedCount: 30, enabled: true, sort: 3, days: 7 }, - { id: 4, name: '已停发券', type: 'fixed', amount: 5, minAmount: 20, total: 100, receivedCount: 100, enabled: false, sort: 4, days: 10 }, - ], - - /** 我已领取的优惠券(fc_user_coupon) */ - userCoupons: [ - { id: 1, couponId: 1, status: 'unused', orderNo: '', usetime: '', expiretime: iso(-20), createtime: iso(2) }, - ], - - /** 我的收藏(fc_favorite:user_id + post_id) */ - favorites: [ - { id: 1, postId: 101, createtime: iso(1) }, - ], - - /** 我的关注(fc_follow:user_id + target_user_id) */ - follows: [ - { id: 1, targetUserId: 2001, createtime: iso(2) }, - ], - - /** 微信支付商户参数是否已配置(未配置时 /pay/prepay 返回「微信支付未配置」,余额支付不受影响) */ - wxPayConfigured: true, - - /** 订单(发布/会员/充值 三类,供用户端与后台共用) */ - orders: [ - { id: 3001, orderNo: 'PO20240101001', type: 'post', title: '单条付费发布', amount: 5, payType: 'wxpay', status: 'paid', createdAt: iso(1) }, - { id: 3002, orderNo: 'MO20240102002', type: 'member', title: '年卡会员', amount: 168, payType: 'wxpay', status: 'paid', createdAt: iso(2) }, - { id: 3003, orderNo: 'RO20240103003', type: 'recharge', title: '余额充值', amount: 100, payType: 'wxpay', status: 'paid', createdAt: iso(3) }, - { id: 3004, orderNo: 'PO20240104004', type: 'post', title: '单条付费发布', amount: 5, payType: 'balance', status: 'pending', createdAt: iso(0.5) }, - ], - - /** 分销中心概览 */ - distOverview: { - totalCommission: 1286.4, - settled: 980.0, - withdrawable: 186.4, - todayCommission: 32.6, - teamCount: 28, - l1Count: 12, - l2Count: 16, - inviteCode: 'TC8A9K', - posterUrl: '', - minWithdraw: 50, - }, - - /** 分销团队 */ - distTeam: [ - { id: 1, nickname: '小李', level: 1, joinedAt: iso(10), performance: 320, commission: 64 }, - { id: 2, nickname: '阿强', level: 1, joinedAt: iso(20), performance: 180, commission: 36 }, - { id: 3, nickname: '果果', level: 2, joinedAt: iso(6), performance: 90, commission: 9 }, - { id: 4, nickname: '老周', level: 2, joinedAt: iso(30), performance: 260, commission: 26 }, - ], - - /** 提现记录 */ - withdrawRecords: [ - { id: 1, withdrawNo: 'WD20231220001', amount: 100, status: 'paid', createdAt: iso(25) }, - { id: 2, withdrawNo: 'WD20240105002', amount: 50, status: 'auditing', createdAt: iso(1) }, - ], - - /** 消息中心 */ - messages: [ - { id: 1, type: 'system', title: '审核通过', content: '您发布的信息「急招快递分拣员(日结)」已审核通过。', read: false, createdAt: iso(0.2) }, - { id: 2, type: 'system', title: '会员到期提醒', content: '您的年卡会员将于 30 天后到期,续费可享 9 折。', read: false, createdAt: iso(1) }, - { id: 3, type: 'interact', title: '收到新评价', content: '用户「小王同学」评价了您的信息:服务很到位,五星好评!', read: true, createdAt: iso(2) }, - { id: 4, type: 'interact', title: '点赞提醒', content: '您发布的信息获得 5 个赞。', read: true, createdAt: iso(3) }, - ], - - /** 后台数据总览 */ - dashboard: { - overview: { - pv: 128932, uv: 20431, todayPostCount: 156, todayOrderAmount: 2864.5, - memberCount: 1320, merchantCount: 86, commissionTotal: 12864.3, pendingAudit: 23, - postGrowth: 12.5, orderGrowth: -3.2, userGrowth: 8.1, - }, - trend: { - traffic: [ - { date: '2024-01-01', pv: 3200, uv: 820 }, { date: '2024-01-02', pv: 3600, uv: 910 }, - { date: '2024-01-03', pv: 3100, uv: 780 }, { date: '2024-01-04', pv: 4200, uv: 1100 }, - { date: '2024-01-05', pv: 4800, uv: 1290 }, { date: '2024-01-06', pv: 5100, uv: 1380 }, - { date: '2024-01-07', pv: 4700, uv: 1210 }, - ], - revenue: [ - { date: '2024-01-01', amount: 320 }, { date: '2024-01-02', amount: 410 }, - { date: '2024-01-03', amount: 280 }, { date: '2024-01-04', amount: 560 }, - { date: '2024-01-05', amount: 720 }, { date: '2024-01-06', amount: 680 }, - { date: '2024-01-07', amount: 610 }, - ], - userGrowth: [ - { date: '2024-01-01', newUser: 42, totalUser: 1200 }, { date: '2024-01-02', newUser: 55, totalUser: 1255 }, - { date: '2024-01-03', newUser: 38, totalUser: 1293 }, { date: '2024-01-04', newUser: 66, totalUser: 1359 }, - { date: '2024-01-05', newUser: 71, totalUser: 1430 }, { date: '2024-01-06', newUser: 88, totalUser: 1518 }, - { date: '2024-01-07', newUser: 60, totalUser: 1578 }, - ], - }, - }, - - /** 后台 · 商家列表 */ - merchants: [ - { id: 2001, name: '顺捷物流', logo: '', contact: '13988888888', category: '物流服务', auditStatus: 1, license: '营业执照.jpg', shopStatus: 'normal', violations: 0, postCount: 32, createdAt: iso(120) }, - { id: 2002, name: '安家房产', logo: '', contact: '13566666666', category: '房产中介', auditStatus: 1, license: '营业执照.jpg', shopStatus: 'normal', violations: 1, postCount: 68, createdAt: iso(200) }, - { id: 2005, name: '洁净到家', logo: '', contact: '13156785678', category: '家政服务', auditStatus: 0, license: '营业执照.jpg', shopStatus: 'pending', violations: 0, postCount: 12, createdAt: iso(3) }, - { id: 2007, name: '优选二手车', logo: '', contact: '13700000000', category: '汽车服务', auditStatus: 2, license: '', shopStatus: 'banned', violations: 3, postCount: 0, createdAt: iso(60) }, - ], - - /** 后台 · 用户列表 */ - users: [ - { id: 1001, nickname: '同城小助手', phone: '13800001111', userType: 1, isMember: true, status: 1, registerAt: iso(180) }, - { id: 1002, nickname: '小王同学', phone: '15033443344', userType: 1, isMember: false, status: 1, registerAt: iso(30) }, - { id: 1003, nickname: '洁净到家', phone: '13156785678', userType: 2, isMember: true, status: 1, registerAt: iso(3) }, - { id: 1004, nickname: '违规用户A', phone: '13000000000', userType: 1, isMember: false, status: 0, registerAt: iso(9) }, - ], - - /** 后台 · 评价列表 */ - comments: [ - { id: 5001, postId: 101, postTitle: '急招快递分拣员(日结)', user: '小王同学', score: 5, content: '联系很及时,当天就上岗了。', images: [], reply: '', top: false, createdAt: iso(0.5) }, - { id: 5002, postId: 102, postTitle: '整租·未来方舟精装一室一厅', user: '果果', score: 4, content: '房子不错,房东好说话。', images: [], reply: '感谢支持,欢迎续租~', top: true, createdAt: iso(2) }, - { id: 5003, postId: 103, postTitle: '九成新山地自行车 转让', user: '匿名用户', score: 1, content: '广告信息,疑似虚假。', images: [], reply: '', top: false, createdAt: iso(0.2) }, - ], - - /** 后台 · 举报处理 */ - reports: [ - { id: 1, targetType: 'comment', targetId: 5003, reason: '疑似虚假信息', reporter: '1002', status: 'pending', createdAt: iso(0.2) }, - { id: 2, targetType: 'post', targetId: 106, reason: '重复发布', reporter: '1004', status: 'handled', createdAt: iso(4) }, - ], - - /** 后台 · 分销设置 */ - distSetting: { enabled: true, level: 2, level1Rate: 20, level2Rate: 10, settleCycle: 'T+7', minWithdraw: 50 }, - - /** 后台 · 提现审核列表 */ - adminWithdraws: [ - { id: 2, withdrawNo: 'WD20240105002', user: '同城小助手', amount: 50, status: 'auditing', channel: 'wxpay', createdAt: iso(1) }, - { id: 3, withdrawNo: 'WD20240106003', user: '小李', amount: 200, status: 'auditing', channel: 'wxpay', createdAt: iso(0.4) }, - { id: 1, withdrawNo: 'WD20231220001', user: '同城小助手', amount: 100, status: 'paid', channel: 'wxpay', createdAt: iso(25) }, - ], - - /** 后台 · 资金流水 */ - financeFlows: [ - { id: 1, flowNo: 'F20240107001', type: 'income', scene: '发布付费', amount: 5, balance: 2864.5, createdAt: iso(0.2) }, - { id: 2, flowNo: 'F20240107002', type: 'income', scene: '会员开通', amount: 168, balance: 2859.5, createdAt: iso(0.5) }, - { id: 3, flowNo: 'F20240106003', type: 'expense', scene: '佣金提现', amount: 100, balance: 2691.5, createdAt: iso(1) }, - { id: 4, flowNo: 'F20240105004', type: 'income', scene: '余额充值', amount: 100, balance: 2791.5, createdAt: iso(2) }, - ], - - /** 后台 · 财务报表 */ - financeReport: [ - { period: '2024-01-01', postIncome: 320, memberIncome: 199, rechargeIncome: 500, withdraw: 100, net: 919 }, - { period: '2024-01-02', postIncome: 410, memberIncome: 349, rechargeIncome: 300, withdraw: 0, net: 1059 }, - { period: '2024-01-03', postIncome: 280, memberIncome: 168, rechargeIncome: 200, withdraw: 50, net: 598 }, - ], - - /** 后台 · 角色 */ - roles: [ - { id: 1, name: '超级管理员', permissions: ['*'], remark: '拥有全部权限' }, - { id: 2, name: '运营', permissions: ['dashboard', 'post', 'merchant', 'member', 'dist'], remark: '日常运营' }, - { id: 3, name: '客服', permissions: ['comment', 'message', 'order'], remark: '处理用户反馈' }, - { id: 4, name: '财务', permissions: ['finance', 'order', 'dist'], remark: '对账与提现审核' }, - ], - - /** 后台 · 子账号 */ - accounts: [ - { id: 1, username: 'admin', roleId: 1, roleName: '超级管理员', status: 1, lastLoginAt: iso(0.01) }, - { id: 2, username: 'oper01', roleId: 2, roleName: '运营', status: 1, lastLoginAt: iso(1) }, - { id: 3, username: 'cs01', roleId: 3, roleName: '客服', status: 0, lastLoginAt: iso(15) }, - ], - - /** 后台 · 操作日志 */ - logs: [ - { id: 1, account: 'admin', action: '审核通过信息 #105', ip: '8.137.181.63', createdAt: iso(0.1) }, - { id: 2, account: 'oper01', action: '修改分销比例 一级20%/二级10%', ip: '8.137.181.63', createdAt: iso(0.6) }, - { id: 3, account: 'admin', action: '下架违规信息 #106', ip: '8.137.181.63', createdAt: iso(1) }, - ], - - /** 后台 · 基础配置 */ - baseSetting: { - appName: '同城分类信息', - logo: '', - servicePhone: '400-000-0000', - aboutUs: '本地同城分类信息服务平台,招聘/房产/二手/服务一站直达。', - shareTitle: '同城分类信息 · 身边的好帮手', - }, - - /** 后台 · 服务区域(平铺:每条自带省/市/区县) */ - areas: [ - { id: 1, province: '贵州省', city: '贵阳市', district: '云岩区', lat: 26.605, lng: 106.71, sort: 1, enabled: true }, - { id: 2, province: '贵州省', city: '贵阳市', district: '南明区', lat: 26.568, lng: 106.716, sort: 2, enabled: true }, - { id: 3, province: '贵州省', city: '贵阳市', district: '观山湖区', lat: 26.625, lng: 106.625, sort: 3, enabled: true }, - { id: 4, province: '贵州省', city: '贵阳市', district: '花溪区', lat: 26.43, lng: 106.67, sort: 4, enabled: true }, - { id: 5, province: '贵州省', city: '贵阳市', district: '白云区', lat: 26.678, lng: 106.623, sort: 5, enabled: false }, - ], - - /** - * 站点 UI 配置(LOGO + 图标覆盖) - * 说明:logo 为空表示使用前端内置 /static/logo.png;icons 为空使用内置图标。 - * 后台「系统设置 → LOGO 与图标」保存后会写入这里,用户端读取后立即生效。 - */ - uiConfig: { - siteName: '同城分类信息', - appName: '同城分类信息', - logo: '', - icons: {}, - servicePhone: '400-000-0000', - aboutUs: '本地同城分类信息服务平台,招聘/房产/二手/服务一站直达。', - shareTitle: '同城分类信息 · 身边的好帮手', - updatedAt: iso(0), - }, -} diff --git a/uniapp/common/mock/index.js b/uniapp/common/mock/index.js deleted file mode 100644 index dde8bc6..0000000 --- a/uniapp/common/mock/index.js +++ /dev/null @@ -1,918 +0,0 @@ -/** - * 本地 Mock 路由表 - * key 格式:`METHOD /path`,支持 `:param` 占位符 - * - * 作用: - * 1) 后端未联调 / 离线演示时兜底,保证前端全链路可跑通 - * 2) 供 Node 端全链路测试直接调用,验证接口契约与业务规则 - */ -import config from '../config/index.js' -import { datasets } from './data.js' - -/** 已付费解锁的信息 ID(模拟 fc_pay_view;会员免费不写这里,直接按 datasets.user.isMember 判定) */ -const paidViewIds = new Set() - -/** 解析 /post/12 -> { id: '12' } */ -function parseParams(pattern, url) { - const pSeg = pattern.split('/').filter(Boolean) - const uSeg = url.split('?')[0].split('/').filter(Boolean) - const params = {} - pSeg.forEach((seg, i) => { - if (seg.startsWith(':')) params[seg.slice(1)] = uSeg[i] - }) - return params -} - -/** 统一成功响应包装 */ -function ok(data) { - return { code: 0, message: 'ok', data } -} -function fail(message, code = 1) { - const e = new Error(message) - e.code = code - throw e -} - -/** 站点 UI / 小程序配置响应(用户端与管理端共用同一份数据) */ -function uiPayload() { - const cfg = datasets.uiConfig - const siteName = cfg.siteName || '同城分类信息' - return { - appName: cfg.appName || siteName, - siteName, - logo: cfg.logo || '', - servicePhone: cfg.servicePhone || '', - aboutUs: cfg.aboutUs || '', - shareTitle: cfg.shareTitle || siteName, - icons: { ...(cfg.icons || {}) }, - } -} - -/** 生成分页结构 */ -function paginate(list, page = 1, size = 10) { - const p = Number(page) || 1 - const s = Number(size) || 10 - return { - list: list.slice((p - 1) * s, p * s), - page: p, - size: s, - total: list.length, - hasMore: p * s < list.length, - } -} - -/** 保留一位小数 */ -function round1(n) { - return Math.round(Number(n) * 10) / 10 -} - -/** 保留两位小数(金额) */ -function round2(n) { - return Math.round(Number(n) * 100) / 100 -} - -/** - * 经纬度球面距离(km,Haversine) - * 与后端 fc_post.lat/lng 的 LBS 口径一致:出参 distance 单位 km、一位小数。 - */ -function haversineKm(lat1, lng1, lat2, lng2) { - const R = 6371 - const rad = (d) => (d * Math.PI) / 180 - const dLat = rad(lat2 - lat1) - const dLng = rad(lng2 - lng1) - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2 - return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))) -} - -/** - * 解析后台配置的会员发布折扣文案(如 '8 折' → 0.8) - * 解析失败或不含折扣时视作 1(与契约 §2.1 一致)。 - */ -export function parseDiscountRate(text) { - if (text == null || text === '') return 1 - if (typeof text === 'number') return text > 0 && text <= 1 ? text : 1 - const s = String(text) - const m = s.match(/(\d+(?:\.\d+)?)\s*折/) - if (!m) return 1 - const zhe = Number(m[1]) - if (!zhe || zhe <= 0 || zhe > 10) return 1 - return Math.round((zhe / 10) * 100) / 100 -} - -/** 聚合我的优惠券(fc_user_coupon + fc_coupon),字段与后端 /member/assets 一致 */ -function buildUserCoupons() { - return datasets.userCoupons.map((uc) => { - const c = datasets.coupons.find((x) => x.id === uc.couponId) || {} - return { - id: uc.id, - couponId: uc.couponId, - name: c.name || '', - type: c.type || 'fixed', - amount: c.amount || 0, - minAmount: c.minAmount || 0, - status: uc.status, - expireAt: uc.expiretime, - } - }) -} - -export const mockRoutes = { - // ==================== 用户端 · 认证与登录 ==================== - 'POST /auth/wx-login': ({ data }) => { - if (!data || !data.code) fail('缺少微信授权 code') - // v1.0.9:登录可携带微信昵称/头像(chooseAvatar + nickname 采集),有则落库回显 - if (data.nickname) datasets.user.nickname = String(data.nickname).slice(0, 32) - if (data.avatar) datasets.user.avatar = String(data.avatar) - return ok({ - token: 'mock_token_' + Date.now(), - refreshToken: 'mock_refresh_' + Date.now(), - userInfo: { ...datasets.user, openid: 'openid_mock' }, - }) - }, - 'POST /auth/avatar': ({ data }) => { - // 真实后端为 multipart 上传并落库;mock 直接返回可用的相对路径 + 完整地址 - if (!data || !data.file) fail('未接收到上传文件') - const path = '/uploads/mock/avatar_' + Date.now() + '.png' - datasets.user.avatar = path - return ok({ url: 'https://fenleixinxi.gzyakj.com.cn' + path, avatar: path }) - }, - 'POST /auth/bind-phone': ({ data }) => { - if (!data || !/^1\d{10}$/.test(String(data.phone))) fail('手机号格式不正确') - return ok({ phone: data.phone }) - }, - 'POST /auth/switch-type': ({ data }) => { - const t = Number(data && data.userType) - if (![1, 2].includes(t)) fail('用户类型不合法') - if (t === 2 && !(data && data.license)) fail('商家用户需上传营业执照完成资质认证') - return ok({ userType: t, needAudit: t === 2 }) - }, - 'POST /auth/wx-phone': ({ data }) => { - // 微信手机号快捷获取:真实后端用 code + access_token 换号,这里返回数据集里的手机号 - if (!data || !data.code) fail('缺少手机号授权 code') - return ok({ phone: datasets.user.phone }) - }, - 'GET /auth/profile': () => ok(datasets.user), - 'POST /auth/profile': ({ data }) => { - // 更新微信昵称/头像(登录后补填) - if (!data || (!data.nickname && !data.avatar)) fail('没有需要更新的资料') - if (data.nickname) datasets.user.nickname = String(data.nickname).slice(0, 32) - if (data.avatar) datasets.user.avatar = String(data.avatar) - return ok(datasets.user) - }, - - // ==================== 用户端 · 首页与展示 ==================== - 'GET /home/banners': () => ok(datasets.banners), - 'GET /home/categories': () => ok(datasets.categories), - 'GET /home/feed': ({ data }) => { - // 置顶优先(与后端 Home::feed 的 is_top desc 口径一致) - let list = datasets.posts.map((p) => ({ ...p, isTop: !!p.isTop })) - if (data && data.categoryId) list = list.filter((p) => p.categoryId === Number(data.categoryId)) - if (data && data.keyword) list = list.filter((p) => p.title.includes(data.keyword)) - // 区域:只按服务区域(区县)筛选 - const areaId = data && data.areaId ? Number(data.areaId) : 0 - if (areaId) { - const a = datasets.areas.find((x) => x.id === areaId) - if (a) list = list.filter((p) => Number(p.districtId) === a.id || p.area === a.district) - } else if (data && data.area) { - list = list.filter((p) => p.area === data.area) - } - - // LBS:带坐标时按坐标实时计算距离(km,一位小数);信息自身无坐标时 distance = null - const hasLoc = - !!(data && data.lat != null && data.lng != null && data.lat !== '' && data.lng !== '') - list = list.map((p) => { - const hasPostLoc = p.lat != null && p.lng != null - if (hasLoc) { - return { - ...p, - distance: hasPostLoc - ? round1(haversineKm(Number(data.lat), Number(data.lng), Number(p.lat), Number(p.lng))) - : null, - } - } - // 请求未带坐标:与后端 Home::feed 口径一致,一律 distance = null - // (不再回退数据集里的静态距离,否则前端会显示"假距离") - return { ...p, distance: null } - }) - - list.sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || (new Date(b.createdAt) - new Date(a.createdAt))) - if (data && (data.sort === 'price' || data.sort === 'price_asc')) list.sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || ((a.price || 0) - (b.price || 0))) - if (data && data.sort === 'price_desc') list.sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || ((b.price || 0) - (a.price || 0))) - if (data && data.sort === 'hot') list.sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || (b.views - a.views)) - // 附近:按距离升序,无坐标的排最后(置顶仍优先) - if (data && data.sort === 'nearby') { - const byDistance = (a, b) => { - if (a.distance == null && b.distance == null) return 0 - if (a.distance == null) return 1 - if (b.distance == null) return -1 - return a.distance - b.distance - } - list.sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || byDistance(a, b)) - } - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'GET /home/search/hot': () => ok(datasets.hotKeywords), - // 服务区域:与后台「区域管理」同源,仅下发启用项(平铺:每条自带省/市/区县) - 'GET /home/areas': () => { - const list = datasets.areas - .filter((a) => a.enabled) - .slice() - .sort((a, b) => String(a.province).localeCompare(String(b.province), 'zh') - || String(a.city).localeCompare(String(b.city), 'zh') - || (a.sort || 0) - (b.sort || 0) - || a.id - b.id) - .map((a) => ({ - id: a.id, - province: a.province, - city: a.city, - district: a.district, - lat: a.lat ?? null, - lng: a.lng ?? null, - })) - return ok(list) - }, - // 按定位匹配最近服务区域(300km 内) - 'GET /home/locate': ({ data }) => { - const lat = data && data.lat != null ? Number(data.lat) : NaN - const lng = data && data.lng != null ? Number(data.lng) : NaN - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return ok(null) - let best = null - for (const a of datasets.areas.filter((x) => x.enabled && x.lat != null && x.lng != null)) { - const distance = round1(haversineKm(lat, lng, Number(a.lat), Number(a.lng))) - if (!best || distance < best.distance) best = { a, distance } - } - if (!best || best.distance > 300) return ok(null) - return ok({ - areaId: best.a.id, - province: best.a.province, - city: best.a.city, - district: best.a.district, - distance: best.distance, - }) - }, - - // ==================== 用户端 · 信息发布 ==================== - 'POST /post/upload': ({ data }) => { - // 真实后端为 multipart 上传落盘;mock 只返回**相对路径**(与后端一致,由前端拼域名展示) - if (!data || !data.file) fail('未接收到上传文件') - const type = data.type === 'video' ? 'video' : 'image' - const ext = type === 'video' ? '.mp4' : '.jpg' - const path = `/uploads/mock/post_${Date.now()}${ext}` - return ok({ - url: path, - type, - size: type === 'video' ? 1024 * 1024 : 120 * 1024, - name: `post_${Date.now()}${ext}`, - }) - }, - 'POST /post/create': ({ data }) => { - if (!data || !data.title) fail('标题不能为空') - if (!data.content) fail('内容不能为空') - - // 计费口径与后端一致:金额只由「后台配置」决定,忽略客户端传入的 amount - const rule = datasets.publishRule - let plan = data.plan || (data.paid ? 'single' : 'free') - if (plan === 'month' || plan === 'year') fail('发布方式不支持,请先开通会员套餐') - - // 会员折扣率由服务端下发(未下发 / 解析失败时视作 1),金额仍只由服务端计算 - const discountRate = Number(rule.discountRate) > 0 ? Number(rule.discountRate) : parseDiscountRate(rule.discountText) - - let amount = 0 - if (rule.mode === 'paid') { - if (plan === 'single') { - amount = round2(Number(rule.singlePrice) * discountRate) - if (![config.constants.PAY_TYPE.WXPAY, config.constants.PAY_TYPE.BALANCE].includes(data.payType)) { - fail('支付方式不支持') - } - } else if (rule.quotaLeft > 0) { - rule.quotaLeft -= 1 // 套餐额度内免费发布 - plan = 'quota' - } else if (rule.freeUnlimited || Number(rule.freeQuotaLeft) > 0) { - // 免费发布条数内免费发布(freeUnlimited = 不限制,不扣减) - if (!rule.freeUnlimited) { - rule.freeQuotaUsed = Number(rule.freeQuotaUsed) + 1 - rule.freeQuotaLeft = Math.max(0, Number(rule.freeQuota) - rule.freeQuotaUsed) - } - plan = 'free' - } else { - fail('请选择发布方式') - } - } else { - plan = 'free' - } - - const order = amount > 0 - ? { - orderNo: 'PO' + Date.now(), - amount, - discountRate, - payType: data.payType, - type: 'post', - status: data.payType === config.constants.PAY_TYPE.BALANCE ? 'paid' : 'pending', - } - : null - - // 落入「我的发布」,供 /post/mine 与全链路测试读取 - const id = Date.now() - const media = Array.isArray(data.media) ? data.media.slice() : [] - const area = datasets.areas.find((a) => a.id === Number(data.areaId || data.districtId || 0)) - || datasets.areas.find((a) => a.district === (data.area || '')) - || null - datasets.myPosts.unshift({ - id, - title: data.title, - content: data.content, - categoryId: Number(data.categoryId) || 1, - categoryName: '招聘求职', - cover: media[0] || '', - media, - price: Number(data.price) || 0, - priceUnit: data.priceUnit || '面议', - area: area ? area.district : (data.area || ''), - districtId: area ? Number(area.id) : 0, - address: data.address || '', - lat: data.lat != null && data.lat !== '' ? Number(data.lat) : null, - lng: data.lng != null && data.lng !== '' ? Number(data.lng) : null, - auditStatus: config.constants.AUDIT.PENDING, - auditReason: '', - status: 1, - isTop: false, - isPaid: amount > 0, - amount, - views: 0, - likes: 0, - commentCount: 0, - createdAt: new Date().toISOString(), - }) - - return ok({ - postId: id, - auditStatus: config.constants.AUDIT.PENDING, - order, - plan, - amount, - discountRate, - quotaLeft: rule.mode === 'paid' ? rule.quotaLeft : 0, - }) - }, - 'GET /post/publish-config': () => { - const r = datasets.publishRule - // 与后端 Post::publishConfig 出参对齐:freeQuotaLeft(-1 = 不限制)+ discountRate/discountText - const freeQuotaLeft = r.freeUnlimited ? -1 : Math.max(0, Number(r.freeQuota) - Number(r.freeQuotaUsed)) - return ok({ ...r, freeQuotaLeft }) - }, - 'GET /post/mine': ({ data }) => { - const status = (data && data.status) || 'all' - const match = (p) => { - if (status === 'pending') return p.auditStatus === 0 && p.status === 1 - if (status === 'passed') return p.auditStatus === 1 && p.status === 1 - if (status === 'rejected') return p.auditStatus === 2 - if (status === 'offline') return p.status === 0 - return true - } - const list = datasets.myPosts - .filter(match) - .map((p) => ({ ...p, isTop: !!p.isTop })) - .sort((a, b) => (Number(b.isTop) - Number(a.isTop)) || (new Date(b.createdAt) - new Date(a.createdAt))) - const counts = { - all: datasets.myPosts.length, - pending: datasets.myPosts.filter((p) => p.auditStatus === 0 && p.status === 1).length, - passed: datasets.myPosts.filter((p) => p.auditStatus === 1 && p.status === 1).length, - rejected: datasets.myPosts.filter((p) => p.auditStatus === 2).length, - offline: datasets.myPosts.filter((p) => p.status === 0).length, - } - return ok({ ...paginate(list, data && data.page, data && data.size), counts }) - }, - 'GET /post/drafts': () => ok(datasets.drafts), - 'POST /post/draft': ({ data }) => ok({ draftId: Date.now(), savedAt: new Date().toISOString(), ...data }), - 'DELETE /post/:id': ({ params }) => { - // 与后端「软删」对用户可见的效果一致:删除后不再出现在「我的发布」 - const idx = datasets.myPosts.findIndex((p) => String(p.id) === String(params.id)) - if (idx > -1) datasets.myPosts.splice(idx, 1) - return ok({ deleted: true }) - }, - 'GET /post/:id': ({ params }) => { - const post = datasets.posts.find((p) => String(p.id) === String(params.id)) || datasets.posts[0] - if (!post) fail('信息不存在', 404) - const faved = datasets.favorites.some((f) => String(f.postId) === String(post.id)) - const publisherId = post.publisher && post.publisher.id - const followed = !!publisherId && datasets.follows.some((f) => String(f.targetUserId) === String(publisherId)) - // 契约 §2.1:详情出参新增 lat/lng/faved/followed/favoriteCount - const favoriteCount = datasets.favorites.filter((f) => String(f.postId) === String(post.id)).length - // 解锁态:会员免费 / 已付费解锁(与后端 Post::contactAccess 口径一致) - const unlocked = !!datasets.user.isMember || paidViewIds.has(String(post.id)) - return ok({ - ...post, - lat: post.lat != null ? Number(post.lat) : null, - lng: post.lng != null ? Number(post.lng) : null, - faved, - followed, - favoriteCount, - unlocked, - contact: unlocked ? datasets.secretContact.phone : post.contact, - wechat: unlocked ? datasets.secretContact.wechat : '', - }) - }, - 'POST /post/:id/pay-view': ({ data, params }) => { - const pt = data && data.payType - if (![config.constants.PAY_TYPE.WXPAY, config.constants.PAY_TYPE.BALANCE].includes(pt)) fail('支付方式不支持') - // 与后端 Post::payView 对齐:wxpay 只返回待支付订单(**不含 contact**,解锁由 /pay/notify 回调写 fc_pay_view 后 - // 再调本接口才拿到 unlocked+contact);余额/会员免费才同步返回 unlocked+contact - if (pt === config.constants.PAY_TYPE.WXPAY) { - return ok({ - order: { orderNo: 'VO' + Date.now(), amount: 2, payType: pt, type: 'payview', status: 'pending' }, - }) - } - paidViewIds.add(String(params.id)) - return ok({ unlocked: true, contact: datasets.secretContact }) - }, - 'POST /post/:id/comment': ({ data }) => { - if (!data || !data.content) fail('评价内容不能为空') - if (data.score && (data.score < 1 || data.score > 5)) fail('评分需在 1-5 之间') - return ok({ commentId: Date.now(), ...data }) - }, - // 评价列表(与后端 Post::comments 出参对齐:仅展示字段,按置顶+时间倒序) - 'GET /post/:id/comments': ({ params }) => - ok( - datasets.comments - .filter((c) => String(c.postId) === String(params.id)) - .map((c) => ({ - id: c.id, - user: c.user, - score: c.score, - content: c.content, - reply: c.reply, - createdAt: c.createdAt, - })) - ), - - // ==================== 用户端 · 商家主页与门店详情 ==================== - 'GET /merchant/:id': ({ params }) => { - const id = params && params.id - const m = (datasets.merchants || []).find((x) => String(x.id) === String(id) || String(x.user_id) === String(id)) || datasets.merchants[0] - if (!m) fail('商家门店不存在', 404) - const posts = (datasets.posts || []).filter((p) => p.publisher && (String(p.publisher.id) === String(m.id) || String(p.publisher.name) === String(m.name))) - return ok({ - id: m.id, - userId: m.user_id || m.id, - name: m.name, - logo: m.logo || '', - banner: m.banner || '', - address: m.address || '同城便民服务实体店', - lat: m.lat || 26.5716, - lng: m.lng || 106.7314, - contact: m.contact || '13566666666', - category: m.category || '综合便民', - notice: m.notice || '诚信经营,品质保障,欢迎新老顾客垂询!', - auditStatus: m.auditStatus != null ? m.auditStatus : 1, - verified: true, - licenseVerified: true, - postCount: posts.length || m.postCount || 12, - distance: 1.2, - posts: posts.slice(0, 6), - hasMorePosts: posts.length > 6, - }) - }, - 'GET /merchant/:id/posts': ({ params, data }) => { - const id = params && params.id - const m = (datasets.merchants || []).find((x) => String(x.id) === String(id) || String(x.user_id) === String(id)) || datasets.merchants[0] - const posts = (datasets.posts || []).filter((p) => p.publisher && (String(p.publisher.id) === String(m.id) || String(p.publisher.name) === String(m.name))) - return ok(paginate(posts, data && data.page, data && data.size)) - }, - - // ==================== 用户端 · 会员中心 ==================== - 'GET /member/plans': () => ok(datasets.memberPlans), - 'GET /member/benefits': () => ok(datasets.memberBenefits), - 'POST /member/open': ({ data }) => { - if (!data || !data.planId) fail('请选择会员套餐') - if (![config.constants.PAY_TYPE.WXPAY, config.constants.PAY_TYPE.BALANCE].includes(data.payType)) fail('支付方式不支持') - // 开通会员同时发放「含发布条数」额度(与后端 Member::open 一致) - const plan = datasets.memberPlans.find((p) => p.planId === data.planId) - if (!plan) fail('套餐不存在') - const quota = Number(plan.postQuota) || 0 - datasets.publishRule.quotaLeft += quota - datasets.publishRule.isMember = true - return ok({ - orderNo: 'MO' + Date.now(), - status: 'paid', - expireAt: datasets.user.memberExpireAt, - postQuota: quota, - quotaLeft: datasets.publishRule.quotaLeft, - quotaExpireAt: datasets.user.memberExpireAt, - }) - }, - 'GET /member/orders': ({ data }) => ok(paginate(datasets.orders, data && data.page, data && data.size)), - 'GET /member/assets': () => { - // coupons 来自 fc_user_coupon + fc_coupon(字段与后端一致);couponCount 供「我的资产」直接展示 - const coupons = buildUserCoupons() - return ok({ ...datasets.assets, coupons, couponCount: coupons.length }) - }, - /** 余额明细(支持 type 筛选:recharge=充值 / expense=消费 / refund=退款,与后端 Member::flows 口径一致) */ - 'GET /member/flows': ({ data }) => { - let rows = datasets.financeFlows || [] - const type = data && data.type - if (type === 'recharge') rows = rows.filter((f) => f.scene === '余额充值') - else if (type === 'expense') rows = rows.filter((f) => f.type === 'expense') - else if (type === 'refund') rows = rows.filter((f) => (f.scene || '').includes('退款')) - return ok(paginate(rows, data && data.page, data && data.size)) - }, - /** 设置/修改支付密码(6 位数字) */ - 'POST /member/pay-password/set': ({ data }) => { - const password = data && data.password - if (!/^\d{6}$/.test(String(password || ''))) fail('支付密码必须是 6 位数字') - return ok({ hasPayPassword: true }) - }, - - // ==================== 用户端 · 收藏与关注 ==================== - 'GET /favorite/list': ({ data }) => { - const list = datasets.favorites - .map((f) => datasets.posts.find((p) => String(p.id) === String(f.postId))) - .filter(Boolean) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /favorite/toggle': ({ data }) => { - const postId = Number(data && data.postId) - if (!postId) fail('缺少信息 ID') - const idx = datasets.favorites.findIndex((f) => String(f.postId) === String(postId)) - let faved - if (idx > -1) { - datasets.favorites.splice(idx, 1) - faved = false - } else { - datasets.favorites.unshift({ id: Date.now(), postId, createtime: new Date().toISOString() }) - faved = true - } - return ok({ faved, count: datasets.favorites.length }) - }, - 'GET /follow/list': ({ data }) => { - const list = datasets.follows.map((f) => { - const isMerchant = String(f.targetUserId) === '2001'; - return { - id: f.id, - userId: Number(f.targetUserId), - nickname: isMerchant ? '顺捷物流' : '同城用户' + f.targetUserId, - avatar: '', - isMerchant, - merchantId: isMerchant ? 1 : 0, - category: isMerchant ? '物流服务' : '', - postCount: 2, - followed: true, - followTime: f.createtime, - }; - }); - return ok({ list, total: list.length, page: 1, size: 10, hasMore: false }); - }, - 'GET /user/:id': ({ url }) => { - const { id } = parseParams('/user/:id', url); - const userId = Number(id); - const isMerchant = userId === 2001; - const posts = datasets.posts.filter(p => !p.auditStatus || p.auditStatus === 1).slice(0, 6); - return ok({ - id: userId, - nickname: isMerchant ? '顺捷物流' : (userId === 1001 ? datasets.user.nickname : '同城用户' + userId), - avatar: userId === 1001 ? datasets.user.avatar : '', - isMerchant, - merchantId: isMerchant ? 1 : 0, - merchantName: isMerchant ? '顺捷物流' : '', - postCount: posts.length, - followed: datasets.follows.some(f => String(f.targetUserId) === String(userId)), - isSelf: userId === datasets.user.id, - createdAt: '2026-04-18T15:37:12+08:00', - posts, - hasMorePosts: false - }); - }, - 'GET /user/:id/posts': ({ url }) => { - const posts = datasets.posts.filter(p => !p.auditStatus || p.auditStatus === 1); - return ok({ list: posts, total: posts.length, page: 1, size: 10, hasMore: false }); - }, - 'POST /follow/toggle': ({ data }) => { - const userId = Number(data && data.userId) - if (!userId) fail('缺少用户 ID') - const idx = datasets.follows.findIndex((f) => String(f.targetUserId) === String(userId)) - let followed - if (idx > -1) { - datasets.follows.splice(idx, 1) - followed = false - } else { - datasets.follows.push({ id: Date.now(), targetUserId: userId, createtime: new Date().toISOString() }) - followed = true - } - return ok({ followed }) - }, - 'POST /favorite/status': ({ data }) => { - const postIds = (data && data.postIds) || [] - const userIds = (data && data.userIds) || [] - const faved = {} - const followed = {} - postIds.forEach((id) => { - faved[id] = datasets.favorites.some((f) => String(f.postId) === String(id)) - }) - userIds.forEach((id) => { - followed[id] = datasets.follows.some((f) => String(f.targetUserId) === String(id)) - }) - return ok({ faved, followed }) - }, - - // ==================== 用户端 · 举报 ==================== - 'POST /report': ({ data }) => { - if (!data || !data.targetType) fail('缺少举报对象类型') - if (!['post', 'comment'].includes(data.targetType)) fail('举报对象类型不合法') - if (!data.targetId) fail('缺少举报对象') - if (!data.reason) fail('请选择举报原因') - const id = Date.now() - datasets.reports.unshift({ - id, - targetType: data.targetType, - targetId: Number(data.targetId), - reason: data.reason, - detail: data.detail || '', - reporter: String(datasets.user.id), - status: 'pending', - createdAt: new Date().toISOString(), - }) - return ok({ reportId: id }) - }, - - // ==================== 用户端 · 余额充值 与 微信支付 ==================== - 'POST /order/recharge': ({ data }) => { - const amount = round2(data && data.amount) - // 与后端 application/api/controller/Order::recharge 对齐:金额 0/超限同一文案; - // 充值只允许微信支付(balance = 用余额给余额加钱,属资损,后端已禁止) - if (!amount || amount <= 0 || amount > 100000) fail('充值金额不合法') - const payType = data && data.payType - if (payType === config.constants.PAY_TYPE.BALANCE) fail('余额充值仅支持微信支付') - if (payType !== config.constants.PAY_TYPE.WXPAY) fail('支付方式不支持') - - const orderNo = 'RO' + Date.now() - datasets.orders.unshift({ - id: Date.now(), - orderNo, - type: 'recharge', - title: '余额充值', - amount, - payType, - status: 'pending', - createdAt: new Date().toISOString(), - }) - return ok({ orderNo, amount, status: 'pending', payType }) - }, - 'POST /pay/prepay': ({ data }) => { - const orderNo = data && data.orderNo - if (!orderNo) fail('缺少订单号') - if (!datasets.wxPayConfigured) fail('微信支付未配置') - return ok({ - orderNo, - payType: config.constants.PAY_TYPE.WXPAY, - payParams: { - timeStamp: String(Math.floor(Date.now() / 1000)), - nonceStr: 'mocknonce' + Date.now(), - package: 'prepay_id=mock_prepay_' + orderNo, - signType: 'MD5', - paySign: 'MOCKSIGN', - }, - }) - }, - - // ==================== 用户端 · 优惠券 ==================== - 'GET /coupon/list': ({ data }) => { - const receivedIds = datasets.userCoupons.map((u) => u.couponId) - const list = datasets.coupons - .filter((c) => c.enabled) - .sort((a, b) => (a.sort || 0) - (b.sort || 0)) - .map((c) => ({ ...c, received: receivedIds.includes(c.id) })) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /coupon/receive': ({ data }) => { - const couponId = Number(data && data.couponId) - const coupon = datasets.coupons.find((c) => c.id === couponId) - if (!coupon) fail('优惠券不存在') - if (!coupon.enabled) fail('该优惠券已停止发放') - if (datasets.userCoupons.some((u) => u.couponId === couponId)) fail('您已领取过该优惠券') - const id = Date.now() - datasets.userCoupons.push({ - id, - couponId, - status: 'unused', - orderNo: '', - usetime: '', - expiretime: new Date(Date.now() + (Number(coupon.days) || 30) * 86400000).toISOString(), - createtime: new Date().toISOString(), - }) - return ok({ userCouponId: id }) - }, - - // ==================== 用户端 · 分销中心 ==================== - 'GET /distribution/overview': () => ok(datasets.distOverview), - 'GET /distribution/team': ({ data }) => { - let list = datasets.distTeam.slice() - if (data && data.level) list = list.filter((t) => t.level === Number(data.level)) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /distribution/withdraw': ({ data }) => { - const amt = Number(data && data.amount) - if (!amt || amt <= 0) fail('提现金额不合法') - if (amt > datasets.distOverview.withdrawable) fail('提现金额超过可提现余额') - return ok({ withdrawNo: 'WD' + Date.now(), status: 'auditing' }) - }, - 'GET /distribution/withdraw-records': () => ok(datasets.withdrawRecords), - - // ==================== 用户端 · 消息中心 ==================== - 'GET /message/list': ({ data }) => { - let list = datasets.messages.slice() - if (data && data.type) list = list.filter((m) => m.type === data.type) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /message/:id/read': () => ok({ read: true }), - 'GET /message/unread-count': () => - ok({ count: datasets.messages.filter((m) => !m.read).length }), - - // ==================== 后台管理端 · 数据总览 ==================== - 'GET /admin/dashboard/overview': () => ok(datasets.dashboard.overview), - 'GET /admin/dashboard/trend': ({ data }) => { - const type = (data && data.type) || 'traffic' - return ok(datasets.dashboard.trend[type] || datasets.dashboard.trend.traffic) - }, - - // ==================== 后台管理端 · 商家管理 ==================== - 'GET /admin/merchants': ({ data }) => { - let list = datasets.merchants.slice() - if (data && data.status !== undefined && data.status !== '') { - list = list.filter((m) => m.auditStatus === Number(data.status)) - } - if (data && data.keyword) list = list.filter((m) => m.name.includes(data.keyword)) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /admin/merchant/:id/audit': ({ data, params }) => { - if (![1, 2].includes(Number(data && data.auditStatus))) fail('审核状态仅支持通过(1)/驳回(2)') - return ok({ id: params.id, auditStatus: Number(data.auditStatus) }) - }, - - // ==================== 后台管理端 · 会员管理 ==================== - 'GET /admin/users': ({ data }) => ok(paginate(datasets.users, data && data.page, data && data.size)), - 'POST /admin/member/benefit': ({ data }) => { - if (data && Number(data.freeView) < 0) fail('免费查看条数不能为负') - return ok({ ...datasets.memberBenefits, ...data }) - }, - - // ==================== 后台管理端 · 信息审核 ==================== - 'GET /admin/posts/pending': ({ data }) => { - const list = datasets.posts.filter((p) => p.auditStatus === config.constants.AUDIT.PENDING) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /admin/post/:id/audit': ({ data, params }) => { - if (![1, 2].includes(Number(data && data.auditStatus))) fail('审核状态仅支持通过(1)/驳回(2)') - return ok({ id: params.id, auditStatus: Number(data.auditStatus) }) - }, - 'POST /admin/post/:id/offline': ({ params }) => ok({ id: params.id, offline: true }), - - // ==================== 后台管理端 · 评价管理 ==================== - 'GET /admin/comments': ({ data }) => ok(paginate(datasets.comments, data && data.page, data && data.size)), - 'POST /admin/comment/:id/reply': ({ data }) => { - if (!data || !data.reply) fail('回复内容不能为空') - return ok({ id: data.id, reply: data.reply }) - }, - 'GET /admin/reports': () => ok(datasets.reports), - - // ==================== 后台管理端 · 分销管理 ==================== - 'GET /admin/dist/setting': () => ok(datasets.distSetting), - 'POST /admin/dist/setting': ({ data }) => { - const l1 = Number(data && data.level1Rate) - const l2 = Number(data && data.level2Rate) - if (!isNaN(l1) && !isNaN(l2) && l1 + l2 > 100) fail('一级+二级分佣比例不能超过 100%') - return ok({ ...datasets.distSetting, ...data }) - }, - 'GET /admin/dist/withdraws': () => ok(datasets.adminWithdraws), - 'POST /admin/dist/withdraw/:id/pay': ({ data, params }) => { - if (data && data.op === 'reject' && !data.reason) fail('驳回需填写原因') - return ok({ id: params.id, status: data && data.op === 'reject' ? 'rejected' : 'paid' }) - }, - - // ==================== 后台管理端 · 订单管理 ==================== - 'GET /admin/orders': ({ data }) => { - let list = datasets.orders.slice() - if (data && data.type) list = list.filter((o) => o.type === data.type) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'POST /admin/order/:id/refund': ({ data, params }) => { - if (Number(data && data.amount) <= 0) fail('退款金额需大于 0') - return ok({ id: params.id, refunded: true }) - }, - 'POST /admin/order/supplement': ({ data }) => ok({ orderNo: data && data.orderNo, supplemented: true }), - - // ==================== 后台管理端 · 财务管理 ==================== - 'GET /admin/finance/flows': ({ data }) => { - let list = datasets.financeFlows.slice() - if (data && data.type) list = list.filter((f) => f.type === data.type) - return ok(paginate(list, data && data.page, data && data.size)) - }, - 'GET /admin/finance/report': ({ data }) => ok({ period: (data && data.period) || 'day', rows: datasets.financeReport }), - - // ==================== 后台管理端 · 权限管理 ==================== - 'GET /admin/roles': () => ok(datasets.roles), - 'POST /admin/role': ({ data }) => { - if (!data || !data.name) fail('角色名称不能为空') - if (!data.permissions || !data.permissions.length) fail('请至少分配一项权限') - return ok({ id: Date.now(), ...data }) - }, - 'GET /admin/accounts': () => ok(datasets.accounts), - 'GET /admin/logs': ({ data }) => ok(paginate(datasets.logs, data && data.page, data && data.size)), - - // ==================== 后台管理端 · 系统设置 ==================== - 'GET /admin/setting/base': () => ok(datasets.baseSetting), - 'POST /admin/setting/base': ({ data }) => { - if (!data || !data.appName) fail('小程序名称不能为空') - return ok({ ...datasets.baseSetting, ...data }) - }, - 'POST /admin/setting/pay': ({ data }) => { - if (!data || !data.mchId) fail('微信支付商户号不能为空') - if (!data || !data.apiKey) fail('支付密钥不能为空') - return ok({ configured: true }) - }, - 'GET /admin/setting/fee': () => ok(datasets.priceRule), - 'POST /admin/setting/fee': ({ data }) => { - if (data && Number(data.singlePost) < 0) fail('单条发布价格不能为负') - return ok({ ...datasets.priceRule, ...data }) - }, - 'GET /admin/setting/areas': () => ok(datasets.areas), - 'POST /admin/setting/area': ({ data }) => { - if (!data || !data.province) fail('省份不能为空') - if (!data.city) fail('城市不能为空') - if (!data.district) fail('区县不能为空') - const dup = datasets.areas.find((a) => a.province === data.province && a.city === data.city && a.district === data.district) - if (dup) fail('该省市区组合已存在') - const row = { id: Date.now(), sort: 0, enabled: true, ...data } - datasets.areas.push(row) - return ok({ id: row.id, fullName: `${row.province} / ${row.city} / ${row.district}` }) - }, - - // ==================== 站点 UI 配置(名称 / LOGO / 图标 / 客服 / 关于,前后台共用) ==================== - 'GET /setting/ui': () => ok(uiPayload()), - 'GET /admin/setting/ui': () => ok({ ...uiPayload(), updatedAt: datasets.uiConfig.updatedAt }), - 'POST /admin/setting/ui': ({ data }) => { - if (!data) fail('配置内容不能为空') - if (data.siteName !== undefined && !String(data.siteName).trim()) fail('小程序名称不能为空') - const icons = data.icons || {} - for (const k of Object.keys(icons)) { - if (typeof icons[k] !== 'string') fail(`图标 ${k} 的地址必须是字符串`) - } - datasets.uiConfig.siteName = data.siteName !== undefined ? data.siteName : datasets.uiConfig.siteName - datasets.uiConfig.appName = data.siteName !== undefined ? data.siteName : datasets.uiConfig.appName - datasets.uiConfig.logo = data.logo !== undefined ? data.logo : datasets.uiConfig.logo - datasets.uiConfig.icons = { ...datasets.uiConfig.icons, ...icons } - datasets.uiConfig.updatedAt = new Date().toISOString() - return ok({ ...uiPayload(), updatedAt: datasets.uiConfig.updatedAt }) - }, - 'POST /admin/setting/ui/reset': () => { - datasets.uiConfig.logo = '' - datasets.uiConfig.icons = {} - datasets.uiConfig.updatedAt = new Date().toISOString() - return ok({ ...uiPayload(), logo: '', icons: {}, reset: true }) - }, -} - -/** 获取路由表(供 request.js 降级判断) */ -export function getMockRoutes() { - return mockRoutes -} - -/** - * 匹配并执行 mock 路由 - * @param {Object} req { url, method, data } - * @returns {Promise} 解包后的 data - */ -export function matchMock(req) { - const { url = '', method = 'GET', data = {} } = req - const cleanUrl = String(url).split('?')[0] - const m = String(method).toUpperCase() - - return new Promise((resolve, reject) => { - let hit = null - let params = {} - for (const key of Object.keys(mockRoutes)) { - const [rm, ru] = key.split(' ') - if (rm !== m) continue - const pattern = new RegExp('^' + ru.replace(/:[^/]+/g, '([^/]+)') + '$') - if (pattern.test(cleanUrl)) { - hit = mockRoutes[key] - params = parseParams(ru, cleanUrl) - break - } - } - if (!hit) { - const e = new Error(`[mock] 未匹配到路由: ${m} ${cleanUrl}`) - e.code = 404 - return reject(e) - } - try { - const result = hit({ data, params }) - resolve(result && result.data !== undefined ? result.data : result) - } catch (e) { - reject(e) - } - }) -} - -export default { mockRoutes, matchMock, getMockRoutes } diff --git a/uniapp/common/store/ui.js b/uniapp/common/store/ui.js index 6e697a8..af741a1 100644 --- a/uniapp/common/store/ui.js +++ b/uniapp/common/store/ui.js @@ -7,6 +7,7 @@ */ import { reactive } from 'vue' import settingApi from '../api/setting.js' +import { assetUrl } from '../utils/format.js' import { CATEGORY_ICON_KEY } from '../assets/icons.js' import { setRemoteUi, @@ -106,7 +107,9 @@ export function catIcon(cat) { } // 1. 若配置了直接图片地址(如自定义上传图片或网络链接) if (cat.image && /^https?:\/\/|^\/uploads\/|^\/static\//i.test(cat.image)) { - return cat.image + // 包内自带图标(categories/icons 插画)直接用包内路径; + // 其余(后台上传图、/static/posts/ 实拍图等)不在小程序包内,必须拼后端域名,否则裂图 + return /^\/static\/(categories|icons)\//i.test(cat.image) ? cat.image : assetUrl(cat.image) } // 2. 若配置了 iconKey,优先按 key 匹配响应式配置 if (cat.iconKey) { diff --git a/uniapp/common/utils/icon.js b/uniapp/common/utils/icon.js index c9bf719..08c8ae3 100644 --- a/uniapp/common/utils/icon.js +++ b/uniapp/common/utils/icon.js @@ -5,6 +5,7 @@ * 后端可通过 /admin/setting/ui 按 key 覆盖任意图标与 LOGO。 */ import { ICON_BASE, ICON_DEFAULTS, CATEGORY_ICON_KEY, DEFAULT_LOGO } from '../assets/icons.js' +import { assetUrl } from './format.js' let remote = { logo: '', icons: {} } @@ -83,7 +84,9 @@ export function resolveCategoryIcon(cat) { } // 1. 若分类自带图片地址(如未来支持后台分类直接上传图片) if (cat.image && /^https?:\/\/|^\/uploads\/|^\/static\//i.test(cat.image)) { - return cat.image + // 包内自带图标(categories/icons 插画)直接用包内路径; + // 其余(后台上传图、/static/posts/ 实拍图等)不在小程序包内,必须拼后端域名,否则裂图 + return /^\/static\/(categories|icons)\//i.test(cat.image) ? cat.image : assetUrl(cat.image) } // 2. 若配置了自定义 iconKey if (cat.iconKey) { diff --git a/uniapp/common/utils/pay.js b/uniapp/common/utils/pay.js index a050cf7..4c48975 100644 --- a/uniapp/common/utils/pay.js +++ b/uniapp/common/utils/pay.js @@ -37,7 +37,7 @@ function confirmPaid(orderNo) { /** * 发起支付 * @param {Object} order 订单信息 { orderNo, amount, payType, payParams? } - * @returns {Promise<{success:boolean,payType:string,orderNo:string,confirmed?:boolean,mock?:boolean}>} + * @returns {Promise<{success:boolean,payType:string,orderNo:string,confirmed?:boolean}>} */ export function pay(order = {}) { const payType = order.payType || config.constants.PAY_TYPE.WXPAY @@ -71,12 +71,8 @@ export function pay(order = {}) { } if (typeof uni === 'undefined' || !uni.requestPayment) { - // 仅开发环境允许“模拟支付”便于离线联调; - // 生产环境若缺失支付能力必须报错,绝不能让用户不付钱就看到“支付成功”。 - if (!config.isDev) { - return Promise.reject(toErr('当前环境不支持微信支付,请在小程序内使用')) - } - return { success: true, payType, orderNo, mock: true, payParams: params } + // 缺失支付能力必须报错,绝不能让用户不付钱就看到“支付成功” + return Promise.reject(toErr('当前环境不支持微信支付,请在小程序内使用')) } return new Promise((resolve, reject) => { diff --git a/uniapp/common/utils/request.js b/uniapp/common/utils/request.js index 7354b7e..59582e2 100644 --- a/uniapp/common/utils/request.js +++ b/uniapp/common/utils/request.js @@ -4,13 +4,11 @@ * - 自动注入 token * - 统一响应结构解包:{ code, message, data } * - 401 自动清理登录态并跳转登录 - * - 支持 mock 降级(无后端 / 联调前) * * 该文件为纯 ES Module,Node 测试脚本可直接 import(需先注入 globalThis.uni)。 */ import config from '../config/index.js' import storage from './storage.js' -import { matchMock } from '../mock/index.js' // 401 跳转节流标记(毫秒) let lastLoginRedirectAt = 0 @@ -84,7 +82,7 @@ export function cleanParams(data) { /** * 底层请求统一封装(返回 Promise) - * @param {Object} options 请求配置 { url, method, data, header, silent, mock } + * @param {Object} options 请求配置 { url, method, data, header, silent } * @returns {Promise} 请求结果 */ export function request(options = {}) { @@ -94,7 +92,6 @@ export function request(options = {}) { data = {}, header = {}, silent = false, // 失败是否静默(不弹 toast) - mock = config.forceMock, // 强制走 mock } = options const requestData = String(method).toUpperCase() === 'GET' ? cleanParams(data) : data @@ -102,12 +99,8 @@ export function request(options = {}) { const finalUrl = buildUrl(url) const headers = buildHeaders(header) - const doMock = () => - matchMock({ url, method: method.toUpperCase(), data }).then((res) => res) - - // 强制 mock 或运行环境无 uni.request - if (mock || !runtime.request) { - return doMock() + if (!runtime.request) { + return Promise.reject(toErr('当前环境不支持网络请求')) } return new Promise((resolve, reject) => { @@ -161,25 +154,12 @@ export function request(options = {}) { } /** - * 失败处理: - * 1) 若开启 mock 降级,则使用本地 mock 路由兜底(保证离线可演示) - * 2) 否则抛出异常 + * 失败处理:抛出异常(必要时弹 toast 提示) * @param {Object} err 错误对象 * @param {Object} ctx 请求上下文 */ function handleFail(err, ctx) { - const { url, method, data, silent, runtime, resolve, reject } = ctx - if (config.enableMockFallback) { - // 尝试用本地 mock 路由兜底(离线可演示;无匹配路由时 mock 会 reject) - matchMock({ url, method: String(method).toUpperCase(), data }) - .then(resolve) - .catch(() => { - const e = toErr(err && (err.errMsg || err.message) ? err.errMsg || err.message : '网络请求失败') - if (!silent) runtime.showToast({ title: e.message, icon: 'none' }) - reject(e) - }) - return - } + const { silent, runtime, reject } = ctx const e = toErr(err && (err.errMsg || err.message) ? err.errMsg || err.message : '网络请求失败') if (!silent) runtime.showToast({ title: e.message, icon: 'none' }) reject(e) diff --git a/uniapp/common/utils/upload.js b/uniapp/common/utils/upload.js index c09362e..6a5e450 100644 --- a/uniapp/common/utils/upload.js +++ b/uniapp/common/utils/upload.js @@ -1,7 +1,6 @@ /** * 图片上传封装 * - 真实环境:POST /admin/upload(multipart),返回 { url } - * - Mock/无 uni 环境:直接返回本地临时路径,便于离线演示 */ import config from '../config/index.js' import storage from './storage.js' @@ -9,11 +8,11 @@ import storage from './storage.js' /** * 上传单张图片 * @param {String} filePath 本地临时路径 - * @returns {Promise<{url:String, mock?:Boolean}>} + * @returns {Promise<{url:String}>} */ export function uploadImage(filePath) { - if (config.forceMock || typeof uni === 'undefined' || !uni.uploadFile) { - return Promise.resolve({ url: filePath, mock: true }) + if (typeof uni === 'undefined' || !uni.uploadFile) { + return Promise.reject(new Error('当前环境不支持上传')) } return new Promise((resolve, reject) => { uni.uploadFile({ diff --git a/uniapp/components/fc-call-modal/fc-call-modal.vue b/uniapp/components/fc-call-modal/fc-call-modal.vue index 1041f91..49d05fc 100644 --- a/uniapp/components/fc-call-modal/fc-call-modal.vue +++ b/uniapp/components/fc-call-modal/fc-call-modal.vue @@ -7,11 +7,11 @@ ✕ - + - {{ dialDisplay || '暂无联系电话' }} + {{ contactLabel }} {{ quotaText }} @@ -49,8 +49,9 @@
0