diff --git a/application/admin/command/FcInstall.php b/application/admin/command/FcInstall.php new file mode 100644 index 0000000..d53217b --- /dev/null +++ b/application/admin/command/FcInstall.php @@ -0,0 +1,691 @@ +setName('fc:install') + ->addOption('reset', null, Option::VALUE_NONE, '强制重置种子数据') + ->setDescription('同城分类信息业务表安装 + 种子数据'); + } + + protected function execute(Input $input, Output $output) + { + $prefix = \think\Config::get('database.prefix'); + $reset = $input->hasOption('reset') && $input->getOption('reset'); + + $output->writeln('==> 建表(前缀 ' . $prefix . ')'); + $tables = $this->tables(); + foreach ($tables as $name => $sql) { + Db::execute(str_replace('`#P#`', '`' . $prefix . 'fc_', $sql)); + $output->writeln(' - ' . $prefix . 'fc_' . $name); + } + + $seeded = Db::name('fc_config')->count(); + if ($reset) { + $this->truncateAll(); + $seeded = 0; + } + if (!$seeded) { + $output->writeln('==> 注入种子数据'); + $this->seed($output); + } else { + $output->writeln('==> 已有数据,跳过种子(如需重置请加 --reset)'); + } + + $output->writeln('==> 完成'); + } + + /** 清空业务表(保留表结构) */ + protected function truncateAll() + { + foreach (array_keys($this->tables()) as $name) { + Db::execute('TRUNCATE TABLE `' . \think\Config::get('database.prefix') . 'fc_' . $name . '`'); + } + } + + /** 表结构定义:key 为表名后缀,SQL 中 `#P#` 会被替换为 prefix . 'fc_' */ + protected function tables() + { + return [ + 'config' => "CREATE TABLE IF NOT EXISTS `#P#config` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `key` varchar(64) NOT NULL DEFAULT '' COMMENT '配置键', + `value` text COMMENT '配置值', + `group_key` varchar(32) NOT NULL DEFAULT '' COMMENT '分组', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '说明', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `key` (`key`) + ) 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 '分类名', + `icon_key` varchar(32) NOT NULL DEFAULT '' COMMENT '图标key', + `color` varchar(16) NOT NULL DEFAULT '' COMMENT '主题色', + `sort` int(10) NOT NULL DEFAULT '0' COMMENT '排序', + `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `post_count` int(10) NOT NULL DEFAULT '0' COMMENT '信息量', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-分类';", + + 'banner' => "CREATE TABLE IF NOT EXISTS `#P#banner` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(64) NOT NULL DEFAULT '', + `image` varchar(255) NOT NULL DEFAULT '' COMMENT '图片,空则用渐变占位', + `link` varchar(255) NOT NULL DEFAULT '' COMMENT '跳转路径', + `color` varchar(16) NOT NULL DEFAULT '', + `sort` int(10) NOT NULL DEFAULT '0', + `enabled` tinyint(1) NOT NULL DEFAULT '1', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-轮播广告位';", + + 'area' => "CREATE TABLE IF NOT EXISTS `#P#area` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(32) NOT NULL DEFAULT '', + `sort` int(10) NOT NULL DEFAULT '0', + `enabled` tinyint(1) NOT NULL DEFAULT '1', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-服务区域';", + + 'post' => "CREATE TABLE IF NOT EXISTS `#P#post` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '发布者', + `category_id` int(10) NOT NULL DEFAULT '0', + `title` varchar(80) NOT NULL DEFAULT '', + `content` text, + `cover` varchar(255) NOT NULL DEFAULT '', + `media` text COMMENT '媒体JSON数组', + `price` decimal(10,2) NOT NULL DEFAULT '0.00', + `price_unit` varchar(16) NOT NULL DEFAULT '', + `area` varchar(32) NOT NULL DEFAULT '', + `address` varchar(128) NOT NULL DEFAULT '', + `contact` varchar(32) NOT NULL DEFAULT '' COMMENT '联系人', + `phone` varchar(20) NOT NULL DEFAULT '' COMMENT '联系电话(完整)', + `wechat` varchar(64) NOT NULL DEFAULT '', + `tags` varchar(128) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔', + `is_paid` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否付费发布', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '支付金额', + `pay_type` varchar(16) NOT NULL DEFAULT '', + `audit_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待审1通过2驳回', + `audit_reason` varchar(255) NOT NULL DEFAULT '', + `views` int(10) NOT NULL DEFAULT '0', + `likes` int(10) NOT NULL DEFAULT '0', + `comment_count` int(10) NOT NULL DEFAULT '0', + `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1正常0下架', + `is_top` tinyint(1) NOT NULL DEFAULT '0', + `distance` int(10) NOT NULL DEFAULT '0' COMMENT '距离(米)', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + `deletetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `category_id` (`category_id`), + KEY `user_id` (`user_id`), + KEY `audit_status` (`audit_status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-信息';", + + 'draft' => "CREATE TABLE IF NOT EXISTS `#P#draft` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `category_id` int(10) NOT NULL DEFAULT '0', + `title` varchar(80) NOT NULL DEFAULT '', + `content` text, + `media` text, + `contact` varchar(32) NOT NULL DEFAULT '', + `phone` varchar(20) NOT NULL DEFAULT '', + `address` varchar(128) NOT NULL DEFAULT '', + `savedat` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-草稿箱';", + + 'merchant' => "CREATE TABLE IF NOT EXISTS `#P#merchant` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `name` varchar(64) NOT NULL DEFAULT '', + `logo` varchar(255) NOT NULL DEFAULT '', + `contact` varchar(32) NOT NULL DEFAULT '', + `category` varchar(32) NOT NULL DEFAULT '' COMMENT '经营类目', + `license` varchar(255) NOT NULL DEFAULT '' COMMENT '营业执照', + `audit_status` tinyint(1) NOT NULL DEFAULT '0', + `audit_reason` varchar(255) NOT NULL DEFAULT '', + `shop_status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'normal/pending/banned', + `violations` int(10) NOT NULL DEFAULT '0', + `post_count` int(10) NOT NULL DEFAULT '0', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-商家资质';", + + 'comment' => "CREATE TABLE IF NOT EXISTS `#P#comment` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `post_id` int(10) unsigned NOT NULL DEFAULT '0', + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `score` tinyint(1) NOT NULL DEFAULT '5', + `content` varchar(500) NOT NULL DEFAULT '', + `images` text, + `reply` varchar(500) NOT NULL DEFAULT '', + `replytime` int(10) DEFAULT NULL, + `top` tinyint(1) NOT NULL DEFAULT '0', + `status` tinyint(1) NOT NULL DEFAULT '1', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `post_id` (`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-评价';", + + 'report' => "CREATE TABLE IF NOT EXISTS `#P#report` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `target_type` varchar(16) NOT NULL DEFAULT 'post' COMMENT 'post/comment', + `target_id` int(10) NOT NULL DEFAULT '0', + `reason` varchar(255) NOT NULL DEFAULT '', + `reporter_id` int(10) unsigned NOT NULL DEFAULT '0', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/handled', + `remark` varchar(255) NOT NULL DEFAULT '', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-举报';", + + 'order' => "CREATE TABLE IF NOT EXISTS `#P#order` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `order_no` varchar(32) NOT NULL DEFAULT '', + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `type` varchar(16) NOT NULL DEFAULT 'post' COMMENT 'post/member/recharge', + `title` varchar(64) NOT NULL DEFAULT '', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `pay_type` varchar(16) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/refunded/closed', + `paytime` int(10) DEFAULT NULL, + `refund_amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `refund_reason` varchar(255) NOT NULL DEFAULT '', + `related_id` int(10) NOT NULL DEFAULT '0' COMMENT '关联业务ID', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `order_no` (`order_no`), + KEY `user_id` (`user_id`), + KEY `type` (`type`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-订单';", + + 'plan' => "CREATE TABLE IF NOT EXISTS `#P#plan` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `plan_code` varchar(16) NOT NULL DEFAULT '' COMMENT 'M1/M3/M12', + `name` varchar(32) NOT NULL DEFAULT '', + `price` decimal(10,2) NOT NULL DEFAULT '0.00', + `origin_price` decimal(10,2) NOT NULL DEFAULT '0.00', + `days` int(10) NOT NULL DEFAULT '30', + `tag` varchar(16) NOT NULL DEFAULT '', + `sort` int(10) NOT NULL DEFAULT '0', + `enabled` tinyint(1) NOT NULL DEFAULT '1', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `plan_code` (`plan_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-会员套餐';", + + 'user_ext' => "CREATE TABLE IF NOT EXISTS `#P#user_ext` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信openid', + `user_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1个人2商家', + `realname_status` tinyint(1) NOT NULL DEFAULT '0', + `is_member` tinyint(1) NOT NULL DEFAULT '0', + `member_level` varchar(32) NOT NULL DEFAULT '', + `member_expiretime` int(10) DEFAULT NULL, + `invite_code` varchar(16) NOT NULL DEFAULT '', + `inviter_id` int(10) unsigned NOT NULL DEFAULT '0', + `free_view_date` varchar(10) NOT NULL DEFAULT '' COMMENT '免费查看额度基准日', + `free_view_used` int(10) NOT NULL DEFAULT '0', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + KEY `invite_code` (`invite_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-用户扩展';", + + 'dist_relation' => "CREATE TABLE IF NOT EXISTS `#P#dist_relation` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '下线用户', + `parent_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上级用户', + `level` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1一级2二级', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id_level` (`user_id`,`level`), + KEY `parent_id` (`parent_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-分销关系';", + + 'commission' => "CREATE TABLE IF NOT EXISTS `#P#commission` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '获得佣金的人', + `from_user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '产生佣金的下线', + `order_id` int(10) unsigned NOT NULL DEFAULT '0', + `order_no` varchar(32) NOT NULL DEFAULT '', + `order_type` varchar(16) NOT NULL DEFAULT '', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '佣金金额', + `rate` decimal(5,2) NOT NULL DEFAULT '0.00' COMMENT '分佣比例%', + `level` tinyint(1) NOT NULL DEFAULT '1', + `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0未结算1已结算', + `settletime` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-分销佣金';", + + 'withdraw' => "CREATE TABLE IF NOT EXISTS `#P#withdraw` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `withdraw_no` varchar(32) NOT NULL DEFAULT '', + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `channel` varchar(16) NOT NULL DEFAULT 'wxpay', + `status` varchar(16) NOT NULL DEFAULT 'auditing' COMMENT 'auditing/paid/rejected', + `reason` varchar(255) NOT NULL DEFAULT '' COMMENT '驳回原因', + `paytime` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `withdraw_no` (`withdraw_no`), + KEY `user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-提现';", + + 'finance_flow' => "CREATE TABLE IF NOT EXISTS `#P#finance_flow` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `flow_no` varchar(32) NOT NULL DEFAULT '', + `type` varchar(16) NOT NULL DEFAULT 'income' COMMENT 'income/expense', + `scene` varchar(32) NOT NULL DEFAULT '' COMMENT '业务场景', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `balance` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '账后余额', + `related_id` int(10) NOT NULL DEFAULT '0', + `remark` varchar(255) NOT NULL DEFAULT '', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `flow_no` (`flow_no`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-资金流水';", + + 'message' => "CREATE TABLE IF NOT EXISTS `#P#message` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '0=全体', + `type` varchar(16) NOT NULL DEFAULT 'system' COMMENT 'system/interact', + `title` varchar(64) NOT NULL DEFAULT '', + `content` varchar(500) NOT NULL DEFAULT '', + `isread` tinyint(1) NOT NULL DEFAULT '0', + `readtime` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-消息';", + + 'pay_view' => "CREATE TABLE IF NOT EXISTS `#P#pay_view` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `post_id` int(10) unsigned NOT NULL DEFAULT '0', + `pay_type` varchar(16) NOT NULL DEFAULT '', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `order_id` int(10) unsigned NOT NULL DEFAULT '0', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_post` (`user_id`,`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-付费查看记录';", + ]; + } + + /** + * 注入种子数据 + * 说明:与小程序端 common/mock/data.js 的数据集逐字段对齐, + * 保证「前端离线 Mock」与「线上真实接口」返回同一套数据,便于两端对拍。 + */ + protected function seed(Output $output) + { + $now = time(); + $day = 86400; + $t = function ($offsetDays) use ($now, $day) { + return (int)($now - $offsetDays * $day); + }; + + /* ---------- 业务配置 ---------- */ + $configs = [ + ['base.appName', '同城分类信息', 'base', '小程序名称'], + ['base.logo', '', 'base', 'LOGO地址'], + ['base.servicePhone', '400-000-0000', 'base', '客服电话'], + ['base.aboutUs', '本地同城分类信息服务平台,招聘/房产/二手/服务一站直达。', 'base', '关于我们'], + ['base.shareTitle', '同城分类信息 · 身边的好帮手', 'base', '分享标题'], + ['pay.mchId', '', 'pay', '微信支付商户号'], + ['pay.apiKey', '', 'pay', '微信支付密钥'], + ['fee.singlePost', '5', 'fee', '单条发布价格(元)'], + ['fee.freePostPerDay', '2', 'fee', '每日免费发布条数'], + ['fee.memberMonth', '19.9', 'fee', '月卡价格'], + ['fee.memberQuarter', '49.9', 'fee', '季卡价格'], + ['fee.memberYear', '168', 'fee', '年卡价格'], + ['dist.enabled', '1', 'dist', '是否开启分销'], + ['dist.level', '2', 'dist', '分佣层级'], + ['dist.level1Rate', '20', 'dist', '一级分佣比例%'], + ['dist.level2Rate', '10', 'dist', '二级分佣比例%'], + ['dist.settleCycle', 'T+7', 'dist', '结算周期'], + ['dist.minWithdraw', '50', 'dist', '最低提现金额'], + ['member.benefit', json_encode([ + 'freeView' => 20, + 'postDiscount' => '8 折', + 'distRate' => '一级 20% / 二级 10%', + 'benefits' => ['每日免费查看 20 条联系方式', '发布信息 8 折', '专属会员标识', '分销佣金加速结算'], + ], JSON_UNESCAPED_UNICODE), 'member', '会员权益配置'], + ['ui.siteName', '同城分类信息', 'ui', '站点名称'], + ['ui.logo', '', 'ui', '自定义LOGO'], + ['ui.icons', '{}', 'ui', '图标覆盖 {key:url}'], + ['ui.updatedAt', date('c'), 'ui', '最后更新时间'], + ]; + foreach ($configs as $c) { + Db::name('fc_config')->insert([ + 'key' => $c[0], 'value' => $c[1], 'group_key' => $c[2], 'remark' => $c[3], + 'createtime' => $now, 'updatetime' => $now, + ]); + } + $output->writeln(' - 配置 ' . count($configs) . ' 项'); + + /* ---------- 分类 ---------- */ + $categories = [ + [1, '招聘求职', 'cat-job', '#FF7A59', 1280], + [2, '房产租售', 'cat-house', '#FF9F45', 860], + [3, '二手闲置', 'cat-goods', '#FFB570', 2340], + [4, '车辆买卖', 'cat-car', '#F2994A', 430], + [5, '本地服务', 'cat-service', '#E8845F', 760], + [6, '教育培训', 'cat-education', '#F4A261', 320], + [7, '家政保洁', 'cat-clean', '#FFA07A', 210], + [8, '同城交友', 'cat-friend', '#FF8C69', 190], + ]; + foreach ($categories as $i => $c) { + Db::name('fc_category')->insert([ + 'id' => $c[0], 'name' => $c[1], 'icon_key' => $c[2], 'color' => $c[3], + 'sort' => $i + 1, 'enabled' => 1, 'post_count' => $c[4], + 'createtime' => $now, 'updatetime' => $now, + ]); + } + + /* ---------- 轮播 ---------- */ + $banners = [ + ['同城好物节 · 二手直降', '/pages/post/list?categoryId=3', '#FF8A5B'], + ['招聘旺季 · 免费发布', '/pages/post/create', '#FFB86B'], + ['本地服务 · 上门更省心', '/pages/post/list?categoryId=5', '#FF6F61'], + ]; + foreach ($banners as $i => $b) { + Db::name('fc_banner')->insert([ + 'title' => $b[0], 'image' => '', 'link' => $b[1], 'color' => $b[2], + 'sort' => $i + 1, 'enabled' => 1, 'createtime' => $now, 'updatetime' => $now, + ]); + } + + /* ---------- 服务区域 ---------- */ + $areas = [['云岩区', 1, 1], ['南明区', 2, 1], ['观山湖区', 3, 1], ['花溪区', 4, 1], ['白云区', 5, 0]]; + foreach ($areas as $a) { + Db::name('fc_area')->insert([ + 'name' => $a[0], 'sort' => $a[1], 'enabled' => $a[2], + 'createtime' => $now, 'updatetime' => $now, + ]); + } + + /* ---------- 会员套餐 ---------- */ + $plans = [ + ['M1', '月卡', 19.9, 29.9, 30, '尝鲜'], + ['M3', '季卡', 49.9, 89.7, 90, '最划算'], + ['M12', '年卡', 168, 358.8, 365, '省心'], + ]; + foreach ($plans as $i => $p) { + Db::name('fc_plan')->insert([ + 'plan_code' => $p[0], 'name' => $p[1], 'price' => $p[2], 'origin_price' => $p[3], + 'days' => $p[4], 'tag' => $p[5], 'sort' => $i + 1, 'enabled' => 1, + 'createtime' => $now, 'updatetime' => $now, + ]); + } + $output->writeln(' - 分类8 / 轮播3 / 区域5 / 套餐3'); + + /* ---------- 演示用户(显式ID,与前端 mock 数据集一致) ---------- */ + $salt = 'fcseed'; + $pwd = md5(md5('123456') . $salt); + $users = [ + [1001, '同城小助手', '13800001111', 128.50, 320], + [1002, '小王同学', '15033443344', 12.00, 40], + [1003, '洁净到家', '13156785678', 60.00, 120], + [1004, '违规用户A', '13000000000', 0.00, 0], + [2001, '顺捷物流', '13988888888', 300.00, 210], + [2002, '安家房产', '13566666666', 520.00, 380], + [2003, '骑行爱好者', '13712341234', 8.00, 15], + [2004, '车行天下', '13800000000', 90.00, 66], + [2005, '洁净到家', '13156785678', 45.00, 88], + [2006, '小王同学', '15033443344', 6.00, 10], + [2007, '优选二手车', '13700000000', 0.00, 0], + ]; + foreach ($users as $u) { + Db::name('user')->insert([ + 'id' => $u[0], 'group_id' => 1, 'username' => 'fc' . $u[0], 'nickname' => $u[1], + 'password' => $pwd, 'salt' => $salt, 'email' => $u[0] . '@fenleixinxi.local', + 'mobile' => $u[2], 'avatar' => '', 'level' => 1, 'gender' => 0, + 'money' => $u[3], 'score' => $u[4], 'status' => ($u[0] === 1004 ? 'hidden' : 'normal'), + 'jointime' => $t(180), 'joinip' => '127.0.0.1', 'logintime' => $t(0.1), + 'loginip' => '127.0.0.1', 'createtime' => $t(180), 'updatetime' => $t(0.1), + ]); + } + + /* 用户扩展(会员状态 / 身份 / 邀请码) */ + $exts = [ + [1001, 1, 1, '年卡会员', 300, 'TC8A9K', 0, 1], + [1002, 1, 0, '', 0, 'TC2B7M', 1001, 1], + [1003, 2, 1, '月卡会员', 25, 'TC3C1P', 1001, 2], + [1004, 1, 0, '', 0, 'TC4D5Q', 0, 0], + [2001, 2, 1, '季卡会员', 60, 'TC5E8R', 0, 2], + [2002, 2, 1, '年卡会员', 200, 'TC6F2S', 0, 2], + [2003, 1, 0, '', 0, 'TC7G9T', 1001, 1], + [2004, 2, 0, '', 0, 'TC8H3U', 1002, 2], + [2005, 2, 1, '月卡会员', 20, 'TC9J6V', 1001, 2], + [2006, 1, 0, '', 0, 'TC2K4W', 1002, 1], + [2007, 2, 0, '', 0, 'TC3L7X', 0, 2], + ]; + foreach ($exts as $e) { + Db::name('fc_user_ext')->insert([ + 'user_id' => $e[0], 'openid' => 'openid_' . $e[0], 'user_type' => $e[1], + 'realname_status' => 1, 'is_member' => $e[2], 'member_level' => $e[3], + 'member_expiretime' => $e[4] > 0 ? $t(-$e[4]) : null, + 'invite_code' => $e[5], 'inviter_id' => $e[6], + 'free_view_date' => date('Y-m-d'), 'free_view_used' => 0, + 'createtime' => $t(180), 'updatetime' => $t(0.1), + ]); + } + $output->writeln(' - 用户 ' . count($users) . ' 个 + 扩展'); + + /* ---------- 信息(覆盖待审/已通过/已驳回三种状态) ---------- */ + $posts = [ + [101, 2001, 1, '急招快递分拣员(日结)', '站点直招,日结 180-220 元,包工作餐,当日结算。有意者电话联系。', + 0, '面议', '云岩区', '贵阳北站物流园', 1892, 32, 0, 1, 12, 1200, '日结,包吃', 0], + [102, 2002, 2, '整租·未来方舟精装一室一厅', '家电齐全,拎包入住,近地铁 2 号线,押一付一,可短租。', + 1800, '元/月', '南明区', '未来方舟 B 区', 3204, 88, 1, 1, 26, 3400, '精装,近地铁', 0], + [103, 2003, 3, '九成新山地自行车 转让', '骑行不到 300 公里,27 速,原价 1599,现 680 出,可小刀,同城自提。', + 680, '元', '观山湖区', '世纪城', 640, 21, 0, 1, 4, 820, '自提,9成新', 0], + [104, 2004, 4, '2019 款家用轿车 一手车', '车况精品,无事故无泡水,4S 店保养记录齐全,支持第三方检测。', + 68000, '元', '云岩区', '贵阳市云岩区', 1120, 40, 1, 0, 0, 5100, '无事故,可检测', 0], + [105, 2005, 5, '专业空调清洗 / 加氟上门', '10 年老师傅,上门服务,清洗 60 元/台,加氟 80 元起,不满意不收费。', + 60, '元/台', '南明区', '花果园', 2310, 66, 0, 0, 18, 2600, '上门,不满意不收费', 0], + [106, 2006, 3, 'iPad 2021 64G 深空灰', '学生自用,无磕碰,配件齐全,支持当面验机。', + 1450, '元', '观山湖区', '贵阳奥体中心', 480, 15, 0, 2, 1, 1500, '验机', 0], + ]; + $offsets = [0.2, 0.5, 1, 0.1, 0.05, 2]; + $phones = [101 => '13988888888', 102 => '13566666666', 103 => '13712341234', + 104 => '13800000000', 105 => '13156785678', 106 => '15033443344']; + $paids = [101 => 0, 102 => 1, 103 => 0, 104 => 1, 105 => 0, 106 => 0]; + foreach ($posts as $i => $p) { + $id = $p[0]; + Db::name('fc_post')->insert([ + 'id' => $id, 'user_id' => $p[1], 'category_id' => $p[2], 'title' => $p[3], 'content' => $p[4], + 'cover' => '', 'media' => json_encode([], JSON_UNESCAPED_UNICODE), + 'price' => $p[5], 'price_unit' => $p[6], 'area' => $p[7], 'address' => $p[8], + 'contact' => '联系人', 'phone' => $phones[$id], 'wechat' => 'tongcheng_' . $id, + 'tags' => $p[14], + 'is_paid' => $paids[$id], 'amount' => $paids[$id] ? 5 : 0, 'pay_type' => $paids[$id] ? 'wxpay' : '', + 'audit_status' => $p[12], 'audit_reason' => $p[12] == 2 ? '信息涉嫌重复发布' : '', + 'views' => $p[9], 'likes' => $p[10], 'comment_count' => $p[13], + 'status' => 1, 'is_top' => 0, 'distance' => $p[15], + 'createtime' => $t($offsets[$i]), 'updatetime' => $t($offsets[$i]), + ]); + } + $output->writeln(' - 信息 ' . count($posts) . ' 条'); + + /* ---------- 草稿 ---------- */ + Db::name('fc_draft')->insert([ + 'user_id' => 1001, 'category_id' => 2, 'title' => '转租单间(草稿)', 'content' => '还未填完……', + 'media' => json_encode([], JSON_UNESCAPED_UNICODE), 'savedat' => $t(0.1), + 'createtime' => $t(0.1), 'updatetime' => $t(0.1), + ]); + + /* ---------- 商家资质 ---------- */ + $merchants = [ + [2001, '顺捷物流', '13988888888', '物流服务', 1, 'normal', 0, 32, 120], + [2002, '安家房产', '13566666666', '房产中介', 1, 'normal', 1, 68, 200], + [2005, '洁净到家', '13156785678', '家政服务', 0, 'pending', 0, 12, 3], + [2007, '优选二手车', '13700000000', '汽车服务', 2, 'banned', 3, 0, 60], + ]; + foreach ($merchants as $m) { + Db::name('fc_merchant')->insert([ + 'user_id' => $m[0], 'name' => $m[1], 'logo' => '', 'contact' => $m[2], 'category' => $m[3], + 'license' => $m[4] == 2 ? '' : '营业执照.jpg', + 'audit_status' => $m[4], 'audit_reason' => $m[4] == 2 ? '营业执照不清晰' : '', + 'shop_status' => $m[5], 'violations' => $m[6], 'post_count' => $m[7], + 'createtime' => $t($m[8]), 'updatetime' => $t($m[8]), + ]); + } + + /* ---------- 评价 ---------- */ + $comments = [ + [5001, 101, 1002, 5, '联系很及时,当天就上岗了。', '', 0, 0.5], + [5002, 102, 1003, 4, '房子不错,房东好说话。', '感谢支持,欢迎续租~', 1, 2], + [5003, 103, 1004, 1, '广告信息,疑似虚假。', '', 0, 0.2], + ]; + foreach ($comments as $c) { + Db::name('fc_comment')->insert([ + 'id' => $c[0], 'post_id' => $c[1], 'user_id' => $c[2], 'score' => $c[3], 'content' => $c[4], + 'images' => json_encode([], JSON_UNESCAPED_UNICODE), 'reply' => $c[5], + 'replytime' => $c[5] ? $t($c[7]) : null, 'top' => $c[6], 'status' => 1, + 'createtime' => $t($c[7]), 'updatetime' => $t($c[7]), + ]); + } + + /* ---------- 举报 ---------- */ + Db::name('fc_report')->insert([ + 'target_type' => 'comment', 'target_id' => 5003, 'reason' => '疑似虚假信息', + 'reporter_id' => 1002, 'status' => 'pending', 'remark' => '', + 'createtime' => $t(0.2), 'updatetime' => $t(0.2), + ]); + Db::name('fc_report')->insert([ + 'target_type' => 'post', 'target_id' => 106, 'reason' => '重复发布', + 'reporter_id' => 1004, 'status' => 'handled', 'remark' => '已下架处理', + 'createtime' => $t(4), 'updatetime' => $t(4), + ]); + $output->writeln(' - 商家4 / 评价3 / 举报2'); + + /* ---------- 订单(发布 / 会员 / 充值 三类) ---------- */ + $orders = [ + [3001, 'PO20240101001', 1001, 'post', '单条付费发布', 5, 'wxpay', 'paid', 1], + [3002, 'MO20240102002', 1001, 'member', '年卡会员', 168, 'wxpay', 'paid', 2], + [3003, 'RO20240103003', 1001, 'recharge', '余额充值', 100, 'wxpay', 'paid', 3], + [3004, 'PO20240104004', 1001, 'post', '单条付费发布', 5, 'balance', 'pending', 0.5], + ]; + foreach ($orders as $o) { + Db::name('fc_order')->insert([ + 'id' => $o[0], 'order_no' => $o[1], 'user_id' => $o[2], 'type' => $o[3], 'title' => $o[4], + 'amount' => $o[5], 'pay_type' => $o[6], 'status' => $o[7], + 'paytime' => $o[7] === 'paid' ? $t($o[8]) : null, + 'related_id' => 0, 'createtime' => $t($o[8]), 'updatetime' => $t($o[8]), + ]); + } + + /* ---------- 消息中心 ---------- */ + $messages = [ + [1001, 'system', '审核通过', '您发布的信息「急招快递分拣员(日结)」已审核通过。', 0, 0.2], + [1001, 'system', '会员到期提醒', '您的年卡会员将于 30 天后到期,续费可享 9 折。', 0, 1], + [1001, 'interact', '收到新评价', '用户「小王同学」评价了您的信息:服务很到位,五星好评!', 1, 2], + [1001, 'interact', '点赞提醒', '您发布的信息获得 5 个赞。', 1, 3], + ]; + foreach ($messages as $m) { + Db::name('fc_message')->insert([ + 'user_id' => $m[0], 'type' => $m[1], 'title' => $m[2], 'content' => $m[3], + 'isread' => $m[4], 'readtime' => $m[4] ? $t($m[5]) : null, + 'createtime' => $t($m[5]), + ]); + } + + /* ---------- 分销关系(1001 的一/二级下线) ---------- */ + $rels = [[1001, 1], [1002, 1], [2003, 1], [1003, 2], [2004, 2], [2005, 2], [2006, 2]]; + foreach ($rels as $r) { + Db::name('fc_dist_relation')->insert([ + 'user_id' => $r[0], 'parent_id' => 1001, 'level' => $r[1], 'createtime' => $t(10), + ]); + } + + /* ---------- 佣金(1001 的收益明细) ---------- */ + Db::name('fc_commission')->insert([ + 'user_id' => 1001, 'from_user_id' => 1002, 'order_id' => 3002, 'order_no' => 'MO20240102002', + 'order_type' => 'member', 'amount' => 33.6, 'rate' => 20, 'level' => 1, 'status' => 0, + 'createtime' => $t(2), + ]); + Db::name('fc_commission')->insert([ + 'user_id' => 1001, 'from_user_id' => 2003, 'order_id' => 3001, 'order_no' => 'PO20240101001', + 'order_type' => 'post', 'amount' => 1.0, 'rate' => 20, 'level' => 1, 'status' => 1, + 'settletime' => $t(1), 'createtime' => $t(3), + ]); + + /* ---------- 提现(含待审/已打款) ---------- */ + $withdraws = [ + [1, 'WD20231220001', 1001, 100, 'paid', '', 25], + [2, 'WD20240105002', 1001, 50, 'auditing', '', 1], + [3, 'WD20240106003', 1002, 200, 'auditing', '', 0.4], + ]; + foreach ($withdraws as $w) { + Db::name('fc_withdraw')->insert([ + 'id' => $w[0], 'withdraw_no' => $w[1], 'user_id' => $w[2], 'amount' => $w[3], + 'channel' => 'wxpay', 'status' => $w[4], 'reason' => $w[5], + 'paytime' => $w[4] === 'paid' ? $t($w[6]) : null, + 'createtime' => $t($w[6]), 'updatetime' => $t($w[6]), + ]); + } + + /* ---------- 资金流水 ---------- */ + $flows = [ + ['F20240107001', 'income', '发布付费', 5, 2864.5, 0.2], + ['F20240107002', 'income', '会员开通', 168, 2859.5, 0.5], + ['F20240106003', 'expense', '佣金提现', 100, 2691.5, 1], + ['F20240105004', 'income', '余额充值', 100, 2791.5, 2], + ]; + foreach ($flows as $f) { + Db::name('fc_finance_flow')->insert([ + 'flow_no' => $f[0], 'type' => $f[1], 'scene' => $f[2], 'amount' => $f[3], + 'balance' => $f[4], 'related_id' => 0, 'remark' => '', + 'createtime' => $t($f[5]), + ]); + } + $output->writeln(' - 订单4 / 消息4 / 分销7 / 佣金2 / 提现3 / 流水4'); + } +} diff --git a/application/api/controller/AdminComment.php b/application/api/controller/AdminComment.php new file mode 100644 index 0000000..8fa8bc7 --- /dev/null +++ b/application/api/controller/AdminComment.php @@ -0,0 +1,109 @@ +pager(); + + $query = Db::name('fc_comment')->alias('co') + ->join('fc_post p', 'p.id = co.post_id', 'LEFT') + ->join('user u', 'u.id = co.user_id', 'LEFT'); + + $total = $query->count(); + $rows = $query->field('co.*, p.title AS post_title, u.nickname AS nickname') + ->order('co.id desc') + ->page($page, $size) + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'postId' => (int)$row['post_id'], + 'postTitle' => (string)$row['post_title'], + 'user' => (string)$row['nickname'], + 'score' => (int)$row['score'], + 'content' => (string)$row['content'], + 'images' => $this->jsonArr($row['images']), + 'reply' => (string)$row['reply'], + 'top' => (bool)$row['top'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AC2 评价回复 + * POST /api/v1/admin/comment/:id/reply 入参 {id?,reply} → {id,reply} + */ + public function reply() + { + $id = (int)$this->request->param('id', 0); + if (!$id) { + $id = (int)$this->input('id', 0); + } + $reply = trim((string)$this->input('reply', '')); + + if ($reply === '') { + $this->fail('回复内容不能为空'); + } + + $comment = Db::name('fc_comment')->where('id', $id)->find(); + if (!$comment) { + $this->fail('评价不存在', 404); + } + + Db::name('fc_comment')->where('id', $id)->update([ + 'reply' => $reply, + 'replytime' => time(), + ]); + + $this->notify($comment['user_id'], 'interact', '评价已回复', '您的评价收到商家回复:' . $reply); + $this->adminLog('回复评价 #' . $id); + + $this->ok(['id' => (int)$id, 'reply' => $reply]); + } + + /** + * AC3 举报列表 + * GET /api/v1/admin/reports → 数组 [{id,targetType,targetId,reason,reporter,status,createdAt}] + */ + public function reports() + { + $rows = Db::name('fc_report')->order('createtime desc')->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'targetType' => (string)$row['target_type'], + 'targetId' => (int)$row['target_id'], + 'reason' => (string)$row['reason'], + 'reporter' => (string)$row['reporter_id'], + 'status' => (string)$row['status'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($list); + } +} diff --git a/application/api/controller/AdminDashboard.php b/application/api/controller/AdminDashboard.php new file mode 100644 index 0000000..733313f --- /dev/null +++ b/application/api/controller/AdminDashboard.php @@ -0,0 +1,100 @@ +sum('views'); + $uv = (int)Db::name('user')->count(); + + $todayStart = strtotime(date('Y-m-d')); + $todayPostCount = (int)Db::name('fc_post')->where('createtime', '>=', $todayStart)->count(); + $todayOrderAmount = (float)Db::name('fc_order')->where('status', 'paid') + ->where('createtime', '>=', $todayStart)->sum('amount'); + + $now = time(); + $memberCount = (int)Db::name('fc_user_ext')->where('is_member', 1) + ->where(function ($q) use ($now) { + $q->whereNull('member_expiretime')->whereOr('member_expiretime', '>', $now); + })->count(); + + $merchantCount = (int)Db::name('fc_merchant')->count(); + $commissionTotal = (float)Db::name('fc_commission')->sum('amount'); + $pendingAudit = (int)Db::name('fc_post')->where('audit_status', 0)->count(); + + $this->ok([ + 'pv' => $pv, + 'uv' => $uv, + 'todayPostCount' => $todayPostCount, + 'todayOrderAmount' => round($todayOrderAmount, 2), + 'memberCount' => $memberCount, + 'merchantCount' => $merchantCount, + 'commissionTotal' => round($commissionTotal, 2), + 'pendingAudit' => $pendingAudit, + 'postGrowth' => 0.0, + 'orderGrowth' => 0.0, + 'userGrowth' => 0.0, + ]); + } + + /** + * AD2 趋势(近 7 天,含当天) + * GET /api/v1/admin/dashboard/trend 入参 {type} type∈traffic|revenue|userGrowth + * traffic → [{date,pv,uv}] uv=当天新增用户 + * revenue → [{date,amount}] 当天 paid 订单金额 + * userGrowth → [{date,newUser,totalUser}] + * 未知 type 回退 traffic + */ + public function trend() + { + $type = (string)$this->input('type', 'traffic'); + if (!in_array($type, ['traffic', 'revenue', 'userGrowth'], true)) { + $type = 'traffic'; + } + + $days = []; + for ($i = 6; $i >= 0; $i--) { + $days[] = date('Y-m-d', strtotime('-' . $i . ' day')); + } + + $result = []; + foreach ($days as $date) { + $start = strtotime($date); + $end = $start + 86400; + + if ($type === 'traffic') { + $pv = (int)Db::name('fc_post')->where('createtime', '>=', $start) + ->where('createtime', '<', $end)->sum('views'); + $uv = (int)Db::name('user')->where('jointime', '>=', $start) + ->where('jointime', '<', $end)->count(); + $result[] = ['date' => $date, 'pv' => $pv, 'uv' => $uv]; + } elseif ($type === 'revenue') { + $amount = (float)Db::name('fc_order')->where('status', 'paid') + ->where('createtime', '>=', $start)->where('createtime', '<', $end)->sum('amount'); + $result[] = ['date' => $date, 'amount' => round($amount, 2)]; + } else { + $newUser = (int)Db::name('user')->where('jointime', '>=', $start) + ->where('jointime', '<', $end)->count(); + $totalUser = (int)Db::name('user')->where('jointime', '<', $end)->count(); + $result[] = ['date' => $date, 'newUser' => $newUser, 'totalUser' => $totalUser]; + } + } + + $this->ok($result); + } +} diff --git a/application/api/controller/AdminDist.php b/application/api/controller/AdminDist.php new file mode 100644 index 0000000..2e5ef6a --- /dev/null +++ b/application/api/controller/AdminDist.php @@ -0,0 +1,141 @@ +request->isPost()) { + $this->saveSetting(); + } + $this->ok($this->distConfig()); + } + + /** + * AD2 分销设置保存 + * POST /api/v1/admin/dist/setting 入参 {enabled,level,level1Rate,level2Rate,settleCycle,minWithdraw} + * 规则 BR-08:一级+二级分佣比例不能超过 100% + */ + public function saveSetting() + { + $enabled = $this->input('enabled', $this->cfg('dist.enabled', 1)); + $level = (int)$this->input('level', $this->cfg('dist.level', 2)); + $level1Rate = (float)$this->input('level1Rate', $this->cfg('dist.level1Rate', 20)); + $level2Rate = (float)$this->input('level2Rate', $this->cfg('dist.level2Rate', 10)); + $settleCycle = (string)$this->input('settleCycle', $this->cfg('dist.settleCycle', 'T+7')); + $minWithdraw = (float)$this->input('minWithdraw', $this->cfg('dist.minWithdraw', 50)); + + if ($level1Rate + $level2Rate > 100) { + $this->fail('一级+二级分佣比例不能超过 100%'); + } + + $this->setCfg('dist.enabled', $enabled ? 1 : 0); + $this->setCfg('dist.level', $level); + $this->setCfg('dist.level1Rate', $level1Rate); + $this->setCfg('dist.level2Rate', $level2Rate); + $this->setCfg('dist.settleCycle', $settleCycle); + $this->setCfg('dist.minWithdraw', $minWithdraw); + + $this->adminLog('修改分销设置'); + + $this->ok($this->distConfig()); + } + + /** + * AD3 提现申请列表 + * GET /api/v1/admin/dist/withdraws → 数组 [{id,withdrawNo,user,amount,status,channel,createdAt}] + */ + public function withdraws() + { + $rows = Db::name('fc_withdraw')->alias('w') + ->join('user u', 'u.id = w.user_id', 'LEFT') + ->field('w.*, u.nickname AS nickname') + ->order('w.createtime desc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'withdrawNo' => (string)$row['withdraw_no'], + 'user' => (string)$row['nickname'], + 'amount' => (float)$row['amount'], + 'status' => (string)$row['status'], + 'channel' => (string)$row['channel'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($list); + } + + /** + * AD4 提现审核 + * POST /api/v1/admin/dist/withdraw/:id/pay 入参 {op,reason?} → {id,status} + * 规则 BR-09:op=reject 时 reason 必填 + */ + public function handleWithdraw() + { + $id = (int)$this->request->param('id', 0); + $op = (string)$this->input('op', ''); + $reason = trim((string)$this->input('reason', '')); + + if ($op === 'reject' && $reason === '') { + $this->fail('驳回需填写原因'); + } + + $withdraw = Db::name('fc_withdraw')->where('id', $id)->find(); + if (!$withdraw) { + $this->fail('提现记录不存在', 404); + } + + if ($op === 'reject') { + $status = 'rejected'; + Db::name('fc_withdraw')->where('id', $id)->update([ + 'status' => $status, + 'reason' => $reason, + ]); + $this->notify($withdraw['user_id'], 'system', '提现被驳回', '您的提现申请被驳回:' . $reason); + } else { + $status = 'paid'; + Db::name('fc_withdraw')->where('id', $id)->update([ + 'status' => $status, + 'paytime' => time(), + ]); + $this->flow('expense', '佣金提现', $withdraw['amount'], 0, $id); + $this->notify($withdraw['user_id'], 'system', '提现已打款', '您的提现申请已打款,请注意查收。'); + } + + $this->adminLog('处理提现 #' . $id . ' 结果' . $status); + + $this->ok(['id' => (int)$id, 'status' => $status]); + } + + /** 组装分销设置结构 */ + protected function distConfig() + { + return [ + 'enabled' => (bool)$this->cfg('dist.enabled', 1), + 'level' => (int)$this->cfg('dist.level', 2), + 'level1Rate' => (float)$this->cfg('dist.level1Rate', 20), + 'level2Rate' => (float)$this->cfg('dist.level2Rate', 10), + 'settleCycle' => (string)$this->cfg('dist.settleCycle', 'T+7'), + 'minWithdraw' => (float)$this->cfg('dist.minWithdraw', 50), + ]; + } +} diff --git a/application/api/controller/AdminFinance.php b/application/api/controller/AdminFinance.php new file mode 100644 index 0000000..9b71d41 --- /dev/null +++ b/application/api/controller/AdminFinance.php @@ -0,0 +1,129 @@ +pager(); + $type = trim((string)$this->input('type', '')); + + $query = Db::name('fc_finance_flow'); + if ($type !== '') { + $query->where('type', $type); + } + + $total = $query->count(); + $rows = $query->order('createtime desc')->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'flowNo' => (string)$row['flow_no'], + 'type' => (string)$row['type'], + 'scene' => (string)$row['scene'], + 'amount' => (float)$row['amount'], + 'balance' => (float)$row['balance'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AF2 财务报表 + * GET /api/v1/admin/finance/report 入参 {period: day|month|year} + * → {period, rows:[{period,postIncome,memberIncome,rechargeIncome,withdraw,net}]} + */ + public function report() + { + $period = trim((string)$this->input('period', 'day')); + if (!in_array($period, ['day', 'month', 'year'], true)) { + $period = 'day'; + } + + $fmtMap = ['day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y']; + $fmt = $fmtMap[$period]; + + $post = $this->periodOrderSum('post', $fmt); + $member = $this->periodOrderSum('member', $fmt); + $recharge = $this->periodOrderSum('recharge', $fmt); + $withdraw = $this->periodWithdrawSum($fmt); + + $keys = array_unique(array_merge( + array_keys($post), + array_keys($member), + array_keys($recharge), + array_keys($withdraw) + )); + sort($keys); + + $rows = []; + foreach ($keys as $k) { + $p = isset($post[$k]) ? (float)$post[$k] : 0; + $m = isset($member[$k]) ? (float)$member[$k] : 0; + $r = isset($recharge[$k]) ? (float)$recharge[$k] : 0; + $w = isset($withdraw[$k]) ? (float)$withdraw[$k] : 0; + $rows[] = [ + 'period' => (string)$k, + 'postIncome' => $p, + 'memberIncome' => $m, + 'rechargeIncome' => $r, + 'withdraw' => $w, + 'net' => round($p + $m + $r - $w, 2), + ]; + } + + $this->ok(['period' => $period, 'rows' => $rows]); + } + + /** 按周期聚合某类已支付订单金额,返回 [period => total] */ + protected function periodOrderSum($type, $fmt) + { + $rows = Db::name('fc_order') + ->where('status', 'paid') + ->where('type', $type) + ->field("FROM_UNIXTIME(createtime, '{$fmt}') AS period, SUM(amount) AS total") + ->group('period') + ->select(); + + $map = []; + foreach ($rows as $row) { + $map[$row['period']] = (float)$row['total']; + } + return $map; + } + + /** 按周期聚合已打款提现金额,返回 [period => total] */ + protected function periodWithdrawSum($fmt) + { + $rows = Db::name('fc_withdraw') + ->where('status', 'paid') + ->whereNotNull('paytime') + ->field("FROM_UNIXTIME(paytime, '{$fmt}') AS period, SUM(amount) AS total") + ->group('period') + ->select(); + + $map = []; + foreach ($rows as $row) { + $map[$row['period']] = (float)$row['total']; + } + return $map; + } +} diff --git a/application/api/controller/AdminMember.php b/application/api/controller/AdminMember.php new file mode 100644 index 0000000..1d7e59d --- /dev/null +++ b/application/api/controller/AdminMember.php @@ -0,0 +1,110 @@ +pager(); + + $query = Db::name('user')->alias('u') + ->join('fc_user_ext e', 'e.user_id = u.id', 'LEFT'); + $total = (int)$query->count(); + $rows = $query->field('u.id,u.nickname,u.mobile,u.status,u.jointime,e.user_type,e.is_member,e.member_expiretime') + ->order('u.id desc') + ->page($page, $size) + ->select(); + + $now = time(); + $list = []; + foreach ($rows as $row) { + $isMember = (int)$row['is_member'] === 1 + && (!$row['member_expiretime'] || (int)$row['member_expiretime'] > $now); + $list[] = [ + 'id' => (int)$row['id'], + 'nickname' => (string)$row['nickname'], + 'phone' => (string)$row['mobile'], + 'userType' => (int)$row['user_type'], + 'isMember' => $isMember, + 'status' => $row['status'] === 'normal' ? 1 : 0, + 'registerAt' => $this->iso($row['jointime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AMB2 会员权益配置 + * POST /api/v1/admin/member/benefit 入参 {freeView,postDiscount,distRate,benefits[]} + * → {freeView,postDiscount,distRate,benefits[]} + * 未被传入的字段保持原值;freeView <0 报错 + */ + public function saveBenefit() + { + $current = $this->cfg('member.benefit', []); + if (!is_array($current)) { + $current = []; + } + + $freeView = $this->input('freeView', null); + if ($freeView !== null && $freeView !== '') { + if ((int)$freeView < 0) { + $this->fail('免费查看条数不能为负'); + } + $current['freeView'] = (int)$freeView; + } + + $postDiscount = $this->input('postDiscount', null); + if ($postDiscount !== null && $postDiscount !== '') { + $current['postDiscount'] = (string)$postDiscount; + } + + $distRate = $this->input('distRate', null); + if ($distRate !== null && $distRate !== '') { + $current['distRate'] = (string)$distRate; + } + + $benefits = $this->input('benefits', null); + if ($benefits !== null && $benefits !== '') { + if (is_array($benefits)) { + $current['benefits'] = array_values($benefits); + } else { + $decoded = json_decode((string)$benefits, true); + $current['benefits'] = is_array($decoded) ? $decoded : [(string)$benefits]; + } + } + + $defaults = [ + 'freeView' => 20, + 'postDiscount' => '8 折', + 'distRate' => '一级 20% / 二级 10%', + 'benefits' => [], + ]; + $current = array_merge($defaults, $current); + + $this->setCfg('member.benefit', $current); + $this->adminLog('修改会员权益配置'); + + $this->ok([ + 'freeView' => (int)$current['freeView'], + 'postDiscount' => (string)$current['postDiscount'], + 'distRate' => (string)$current['distRate'], + 'benefits' => array_values((array)$current['benefits']), + ]); + } +} diff --git a/application/api/controller/AdminMerchant.php b/application/api/controller/AdminMerchant.php new file mode 100644 index 0000000..caf9a62 --- /dev/null +++ b/application/api/controller/AdminMerchant.php @@ -0,0 +1,98 @@ +pager(); + $status = $this->input('status', ''); + $keyword = trim((string)$this->input('keyword', '')); + + $query = Db::name('fc_merchant'); + if ($status !== '' && $status !== null) { + $query->where('audit_status', (int)$status); + } + if ($keyword !== '') { + $query->where('name', 'like', '%' . $keyword . '%'); + } + $total = $query->count(); + $rows = $query->order('id desc')->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['user_id'], + 'name' => (string)$row['name'], + 'logo' => (string)$row['logo'], + 'contact' => (string)$row['contact'], + 'category' => (string)$row['category'], + 'auditStatus' => (int)$row['audit_status'], + 'license' => (string)$row['license'], + 'shopStatus' => (string)$row['shop_status'], + 'violations' => (int)$row['violations'], + 'postCount' => (int)$row['post_count'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AM2 商家资质审核 + * POST /api/v1/admin/merchant/:id/audit 入参 {auditStatus,reason?} → {id,auditStatus} + * 规则 BR-11:auditStatus 仅允许 1(通过)/2(驳回) + */ + public function audit() + { + $id = (int)$this->request->param('id', 0); + $auditStatus = (int)$this->input('auditStatus', 0); + $reason = trim((string)$this->input('reason', '')); + + if (!in_array($auditStatus, [1, 2], true)) { + $this->fail('审核状态仅支持通过(1)/驳回(2)'); + } + + $merchant = Db::name('fc_merchant')->where('user_id', $id)->find(); + if (!$merchant) { + $this->fail('商家不存在', 404); + } + + Db::name('fc_merchant')->where('user_id', $id)->update([ + 'audit_status' => $auditStatus, + 'audit_reason' => $reason, + 'shop_status' => $auditStatus === 1 ? 'normal' : 'banned', + 'updatetime' => time(), + ]); + + if ($auditStatus === 1) { + Db::name('fc_user_ext')->where('user_id', $id)->update([ + 'user_type' => 2, + 'updatetime' => time(), + ]); + $this->notify($id, 'system', '商家资质审核结果', '恭喜,您的商家资质已通过审核。'); + } else { + $this->notify($id, 'system', '商家资质审核结果', '您的商家资质未通过:' . $reason); + } + + $this->adminLog('审核商家 #' . $id . ' 结果' . $auditStatus); + + $this->ok(['id' => $id, 'auditStatus' => $auditStatus]); + } +} diff --git a/application/api/controller/AdminOrder.php b/application/api/controller/AdminOrder.php new file mode 100644 index 0000000..8057f52 --- /dev/null +++ b/application/api/controller/AdminOrder.php @@ -0,0 +1,118 @@ +pager(); + $type = trim((string)$this->input('type', '')); + + $query = Db::name('fc_order')->alias('o') + ->join('user u', 'u.id = o.user_id', 'LEFT'); + + if ($type !== '') { + $query->where('o.type', $type); + } + + $total = $query->count(); + $rows = $query->field('o.*, u.nickname AS nickname') + ->order('o.createtime desc') + ->page($page, $size) + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'orderNo' => (string)$row['order_no'], + 'type' => (string)$row['type'], + 'title' => (string)$row['title'], + 'amount' => (float)$row['amount'], + 'payType' => (string)$row['pay_type'], + 'status' => (string)$row['status'], + 'userId' => (int)$row['user_id'], + 'userName' => (string)$row['nickname'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AO2 订单退款 + * POST /api/v1/admin/order/:id/refund 入参 {amount,reason?} → {id,refunded} + * 规则 BR-10:退款金额需大于 0 + */ + public function refund() + { + $id = (int)$this->request->param('id', 0); + $amount = round((float)$this->input('amount', 0), 2); + $reason = trim((string)$this->input('reason', '')); + + if ($amount <= 0) { + $this->fail('退款金额需大于 0'); + } + + $order = Db::name('fc_order')->where('id', $id)->find(); + if (!$order) { + $this->fail('订单不存在', 404); + } + + Db::name('fc_order')->where('id', $id)->update([ + 'status' => 'refunded', + 'refund_amount' => $amount, + 'refund_reason' => $reason, + ]); + + $this->notify($order['user_id'], 'system', '订单已退款', '您的订单「' . $order['title'] . '」已退款 ' . $amount . ' 元。'); + $this->adminLog('订单退款 #' . $id . ' 金额' . $amount); + + $this->ok(['id' => (int)$id, 'refunded' => true]); + } + + /** + * AO3 订单补单 + * POST /api/v1/admin/order/supplement 入参 {orderNo} → {orderNo,supplemented} + * 幂等:已支付直接返回 + */ + public function supplement() + { + $orderNo = trim((string)$this->input('orderNo', '')); + if ($orderNo === '') { + $this->fail('订单号不能为空'); + } + + $order = Db::name('fc_order')->where('order_no', $orderNo)->find(); + if (!$order) { + $this->fail('订单不存在', 404); + } + + if ($order['status'] !== 'paid') { + Db::name('fc_order')->where('id', $order['id'])->update([ + 'status' => 'paid', + 'paytime' => time(), + ]); + $this->flow('income', '订单补单', $order['amount'], 0, $order['id']); + $this->adminLog('订单补单 #' . $orderNo); + } + + $this->ok(['orderNo' => (string)$orderNo, 'supplemented' => true]); + } +} diff --git a/application/api/controller/AdminPost.php b/application/api/controller/AdminPost.php new file mode 100644 index 0000000..ff53900 --- /dev/null +++ b/application/api/controller/AdminPost.php @@ -0,0 +1,120 @@ +pager(); + + $query = Db::name('fc_post')->alias('p') + ->join('fc_category c', 'c.id = p.category_id', 'LEFT') + ->join('user u', 'u.id = p.user_id', 'LEFT') + ->where('p.audit_status', 0) + ->whereNull('p.deletetime'); + + $total = $query->count(); + $rows = $query->field('p.*, c.name AS category_name, u.nickname AS user_name') + ->order('p.createtime asc') + ->page($page, $size) + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'title' => (string)$row['title'], + 'categoryId' => (int)$row['category_id'], + 'categoryName' => (string)$row['category_name'], + 'userId' => (int)$row['user_id'], + 'userName' => (string)$row['user_name'], + 'area' => (string)$row['area'], + 'price' => (float)$row['price'], + 'priceUnit' => (string)$row['price_unit'], + 'createdAt' => $this->iso($row['createtime']), + 'isPaid' => (bool)$row['is_paid'], + 'content' => (string)$row['content'], + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * AP2 信息审核 + * POST /api/v1/admin/post/:id/audit 入参 {auditStatus,reason?} → {id,auditStatus} + * 规则 BR-11:auditStatus 仅允许 1(通过)/2(驳回) + */ + public function audit() + { + $id = (int)$this->request->param('id', 0); + $auditStatus = (int)$this->input('auditStatus', 0); + $reason = trim((string)$this->input('reason', '')); + + if (!in_array($auditStatus, [1, 2], true)) { + $this->fail('审核状态仅支持通过(1)/驳回(2)'); + } + + $post = Db::name('fc_post')->where('id', $id)->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + + Db::name('fc_post')->where('id', $id)->update([ + 'audit_status' => $auditStatus, + 'audit_reason' => $reason, + 'updatetime' => time(), + ]); + + if ($auditStatus === 1) { + Db::name('fc_category')->where('id', $post['category_id'])->setInc('post_count'); + $this->notify($post['user_id'], 'system', '审核通过', '您发布的信息「' . $post['title'] . '」已审核通过。'); + } else { + $this->notify($post['user_id'], 'system', '审核驳回', '您发布的信息「' . $post['title'] . '」被驳回:' . $reason); + } + + $this->adminLog('审核信息 #' . $id . ' 结果' . $auditStatus); + + $this->ok(['id' => (int)$id, 'auditStatus' => (int)$auditStatus]); + } + + /** + * AP3 信息下架 + * POST /api/v1/admin/post/:id/offline → {id,offline} + */ + public function offline() + { + $id = (int)$this->request->param('id', 0); + + $post = Db::name('fc_post')->where('id', $id)->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + + Db::name('fc_post')->where('id', $id)->update([ + 'status' => 0, + 'updatetime' => time(), + ]); + + $this->notify($post['user_id'], 'system', '信息已下架', '您发布的信息「' . $post['title'] . '」已被下架。'); + $this->adminLog('下架信息 #' . $id); + + $this->ok(['id' => (int)$id, 'offline' => true]); + } +} diff --git a/application/api/controller/AdminRbac.php b/application/api/controller/AdminRbac.php new file mode 100644 index 0000000..337a3cc --- /dev/null +++ b/application/api/controller/AdminRbac.php @@ -0,0 +1,151 @@ +order('id asc')->select(); + + $list = []; + foreach ($rows as $row) { + $rules = (string)$row['rules']; + if ($rules === '*') { + $permissions = ['*']; + } else { + $permissions = array_values(array_filter(array_map('strval', explode(',', $rules)), function ($v) { + return $v !== ''; + })); + } + $list[] = [ + 'id' => (int)$row['id'], + 'name' => (string)$row['name'], + 'permissions' => $permissions, + 'remark' => '', + ]; + } + + $this->ok($list); + } + + /** + * AR2 新增/保存角色 + * POST /api/v1/admin/role 入参 {name,permissions[],remark?,id?} → {id,name,permissions,remark} + * 规则 BR-12:name 必填、permissions 非空 + */ + public function saveRole() + { + $id = (int)$this->input('id', 0); + $name = trim((string)$this->input('name', '')); + $remark = trim((string)$this->input('remark', '')); + + $permissions = (array)$this->input('permissions', []); + $permissions = array_values(array_filter(array_map(function ($v) { + return (string)$v; + }, $permissions), function ($v) { + return $v !== ''; + })); + + if ($name === '') { + $this->fail('请填写角色名称'); + } + if (empty($permissions)) { + $this->fail('请至少分配一项权限'); + } + + $rules = implode(',', $permissions); + $data = [ + 'name' => $name, + 'rules' => $rules, + 'pid' => 0, + 'status' => 'normal', + 'updatetime' => time(), + ]; + + if ($id && Db::name('auth_group')->where('id', $id)->find()) { + Db::name('auth_group')->where('id', $id)->update($data); + } else { + $data['createtime'] = time(); + $id = Db::name('auth_group')->insertGetId($data); + } + + $this->adminLog('保存角色 #' . $id . ' ' . $name); + + $this->ok([ + 'id' => (int)$id, + 'name' => $name, + 'permissions' => $permissions, + 'remark' => $remark, + ]); + } + + /** + * AR3 子账号列表 + * GET /api/v1/admin/accounts → 数组 [{id,username,roleId,roleName,status,lastLoginAt}] + */ + public function accounts() + { + $groups = Db::name('auth_group')->column('name', 'id'); + $rows = Db::name('admin')->order('id asc')->select(); + + $list = []; + foreach ($rows as $row) { + $gid = (int)$row['group_id']; + $roleName = ($gid && isset($groups[$gid])) ? (string)$groups[$gid] : ''; + $list[] = [ + 'id' => (int)$row['id'], + 'username' => (string)$row['username'], + 'roleId' => $gid, + 'roleName' => $roleName, + 'status' => ((int)$row['logintime'] > 0) ? 1 : 0, + 'lastLoginAt' => $this->iso($row['logintime']), + ]; + } + + $this->ok($list); + } + + /** + * AR4 操作日志 + * GET /api/v1/admin/logs 入参 {page,size} + * → 分页 {list:[{id,account,action,ip,createdAt}],page,size,total,hasMore} + */ + public function logs() + { + list($page, $size) = $this->pager(); + + $total = Db::name('admin_log')->count(); + $rows = Db::name('admin_log')->order('createtime desc')->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'account' => (string)$row['username'], + 'action' => (string)$row['title'], + 'ip' => (string)$row['ip'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } +} diff --git a/application/api/controller/AdminSetting.php b/application/api/controller/AdminSetting.php new file mode 100644 index 0000000..e970359 --- /dev/null +++ b/application/api/controller/AdminSetting.php @@ -0,0 +1,270 @@ +request->isPost()) { + $appName = trim((string)$this->input('appName', '')); + if ($appName === '') { + $this->fail('小程序名称不能为空'); + } + $this->setCfg('base.appName', $appName); + $this->setCfg('base.logo', (string)$this->input('logo', $this->cfg('base.logo', ''))); + $this->setCfg('base.servicePhone', (string)$this->input('servicePhone', $this->cfg('base.servicePhone', '400-000-0000'))); + $this->setCfg('base.aboutUs', (string)$this->input('aboutUs', $this->cfg('base.aboutUs', ''))); + $this->setCfg('base.shareTitle', (string)$this->input('shareTitle', $this->cfg('base.shareTitle', ''))); + $this->adminLog('修改基础配置'); + } + + $this->ok($this->baseConfig()); + } + + /** + * AS2 微信支付配置 + * POST /api/v1/admin/setting/pay 入参 {mchId,apiKey} → {configured:true} + */ + public function pay() + { + $mchId = trim((string)$this->input('mchId', '')); + $apiKey = trim((string)$this->input('apiKey', '')); + + if ($mchId === '') { + $this->fail('微信支付商户号不能为空'); + } + if ($apiKey === '') { + $this->fail('支付密钥不能为空'); + } + + $this->setCfg('pay.mchId', $mchId); + $this->setCfg('pay.apiKey', $apiKey); + $this->adminLog('修改微信支付配置'); + + $this->ok(['configured' => true]); + } + + /** + * AS3 费用配置 + * GET → {singlePost,freePostPerDay,memberMonth,memberQuarter,memberYear} + * POST 入参同字段 → 合并后配置;同时同步 fc_plan 价格 + */ + public function fee() + { + if ($this->request->isPost()) { + $singlePost = (float)$this->input('singlePost', $this->cfg('fee.singlePost', 5)); + if ($singlePost < 0) { + $this->fail('单条发布价格不能为负'); + } + $freePostPerDay = (int)$this->input('freePostPerDay', $this->cfg('fee.freePostPerDay', 2)); + $memberMonth = (float)$this->input('memberMonth', $this->cfg('fee.memberMonth', 19.9)); + $memberQuarter = (float)$this->input('memberQuarter', $this->cfg('fee.memberQuarter', 49.9)); + $memberYear = (float)$this->input('memberYear', $this->cfg('fee.memberYear', 168)); + + $this->setCfg('fee.singlePost', $singlePost); + $this->setCfg('fee.freePostPerDay', $freePostPerDay); + $this->setCfg('fee.memberMonth', $memberMonth); + $this->setCfg('fee.memberQuarter', $memberQuarter); + $this->setCfg('fee.memberYear', $memberYear); + + Db::name('fc_plan')->where('plan_code', 'M1')->update(['price' => $memberMonth]); + Db::name('fc_plan')->where('plan_code', 'M3')->update(['price' => $memberQuarter]); + Db::name('fc_plan')->where('plan_code', 'M12')->update(['price' => $memberYear]); + + $this->adminLog('修改费用配置'); + } + + $this->ok($this->feeConfig()); + } + + /** + * AS4 区域列表 + * GET /api/v1/admin/setting/areas → 数组 [{id,name,sort,enabled}] + */ + public function areas() + { + $rows = Db::name('fc_area')->order('sort asc')->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'name' => (string)$row['name'], + 'sort' => (int)$row['sort'], + 'enabled' => (bool)$row['enabled'], + ]; + } + + $this->ok($list); + } + + /** + * AS5 保存区域 + * POST /api/v1/admin/setting/area 入参 {id?,name,sort,enabled} → {id,name} + */ + public function saveArea() + { + $id = (int)$this->input('id', 0); + $name = trim((string)$this->input('name', '')); + $sort = (int)$this->input('sort', 0); + $enabled = (bool)$this->input('enabled', true); + + if ($name === '') { + $this->fail('区域名称不能为空'); + } + + $data = [ + 'name' => $name, + 'sort' => $sort, + 'enabled' => $enabled ? 1 : 0, + ]; + + if ($id && Db::name('fc_area')->where('id', $id)->find()) { + Db::name('fc_area')->where('id', $id)->update($data); + } else { + $id = Db::name('fc_area')->insertGetId($data); + } + + $this->adminLog('保存区域 #' . $id . ' ' . $name); + + $this->ok(['id' => (int)$id, 'name' => $name]); + } + + /** + * AS6 站点 UI 配置(LOGO / 图标) + * GET → {siteName,logo,icons,updatedAt} + * POST 入参 {siteName?,logo?,icons?}(合并语义:只覆盖传入的 key) → 完整配置 + */ + public function ui() + { + if ($this->request->isPost()) { + $siteName = $this->input('siteName', null); + if ($siteName !== null) { + $this->setCfg('ui.siteName', (string)$siteName); + } + + $logo = $this->input('logo', null); + if ($logo !== null) { + $this->setCfg('ui.logo', (string)$logo); + } + + $icons = $this->input('icons', null); + if ($icons !== null) { + if (is_string($icons)) { + $icons = json_decode($icons, true); + } + $icons = is_array($icons) ? $icons : (array)$icons; + $merged = (array)$this->cfg('ui.icons', []); + foreach ($icons as $k => $v) { + if (!is_string($v)) { + $this->fail('图标 ' . $k . ' 的地址必须是字符串'); + } + $merged[$k] = $v; + } + $this->setCfg('ui.icons', $merged); + } + + $this->setCfg('ui.updatedAt', date('c')); + $this->adminLog('修改LOGO与图标配置'); + } + + $this->ok($this->uiConfig()); + } + + /** + * AS7 重置 UI 配置 + * POST /api/v1/admin/setting/ui/reset → {siteName,logo,icons,reset} + */ + public function uiReset() + { + $this->setCfg('ui.logo', ''); + $this->setCfg('ui.icons', []); + $this->setCfg('ui.updatedAt', date('c')); + $this->adminLog('重置LOGO与图标配置'); + + $this->ok([ + 'siteName' => (string)$this->cfg('ui.siteName', '同城分类信息'), + 'logo' => '', + 'icons' => (object)[], + 'reset' => true, + ]); + } + + /** + * AS8 文件上传 + * POST /api/v1/admin/upload multipart 字段 file → {url} + */ + public function upload() + { + $file = $this->request->file('file'); + if (!$file) { + $this->fail('未接收到上传文件'); + } + try { + $upload = new \app\common\library\Upload($file); + $attachment = $upload->upload(); + } catch (\Exception $e) { + $this->fail($e->getMessage()); + } + + $this->ok(['url' => cdnurl($attachment->url, true)]); + } + + /** 基础配置结构 */ + protected function baseConfig() + { + return [ + 'appName' => (string)$this->cfg('base.appName', '同城分类信息'), + 'logo' => (string)$this->cfg('base.logo', ''), + 'servicePhone' => (string)$this->cfg('base.servicePhone', '400-000-0000'), + 'aboutUs' => (string)$this->cfg('base.aboutUs', ''), + 'shareTitle' => (string)$this->cfg('base.shareTitle', ''), + ]; + } + + /** 费用配置结构 */ + protected function feeConfig() + { + return [ + 'singlePost' => (float)$this->cfg('fee.singlePost', 5), + 'freePostPerDay' => (int)$this->cfg('fee.freePostPerDay', 2), + 'memberMonth' => (float)$this->cfg('fee.memberMonth', 19.9), + 'memberQuarter' => (float)$this->cfg('fee.memberQuarter', 49.9), + 'memberYear' => (float)$this->cfg('fee.memberYear', 168), + ]; + } + + /** UI 配置结构(icons 保证为对象) */ + protected function uiConfig() + { + $icons = $this->cfg('ui.icons', []); + $icons = is_array($icons) ? $icons : (array)$icons; + + return [ + 'siteName' => (string)$this->cfg('ui.siteName', '同城分类信息'), + 'logo' => (string)$this->cfg('ui.logo', ''), + 'icons' => (object)$icons, + 'updatedAt' => (string)$this->cfg('ui.updatedAt', date('c')), + ]; + } +} diff --git a/application/api/controller/Auth.php b/application/api/controller/Auth.php new file mode 100644 index 0000000..29df7ee --- /dev/null +++ b/application/api/controller/Auth.php @@ -0,0 +1,169 @@ +input('code', '')); + if (!$code) { + $this->fail('缺少微信授权 code'); + } + + $openid = $this->resolveOpenid($code); + + $ext = Db::name('fc_user_ext')->where('openid', $openid)->find(); + $user = $ext ? Db::name('user')->where('id', $ext['user_id'])->find() : null; + + if (!$user) { + // 首次登录:自动建号 + $uid = Db::name('user')->insertGetId([ + 'group_id' => 1, + 'username' => 'wx' . substr(md5($openid), 0, 10), + 'nickname' => '同城用户' . mt_rand(1000, 9999), + 'password' => md5(mt_rand()), + 'salt' => 'wx', + 'email' => substr(md5($openid), 0, 12) . '@wx.local', + 'mobile' => '', + 'avatar' => '', + 'level' => 1, + 'money' => 0, + 'score' => 0, + 'status' => 'normal', + 'jointime' => time(), + 'joinip' => $this->request->ip(), + 'createtime' => time(), + 'updatetime' => time(), + ]); + Db::name('fc_user_ext')->insert([ + 'user_id' => $uid, 'openid' => $openid, 'user_type' => 1, 'realname_status' => 0, + 'is_member' => 0, 'member_level' => '', 'member_expiretime' => null, + 'invite_code' => strtoupper(substr(md5('fc' . $uid), 0, 6)), 'inviter_id' => 0, + 'free_view_date' => date('Y-m-d'), 'free_view_used' => 0, + 'createtime' => time(), 'updatetime' => time(), + ]); + $user = Db::name('user')->where('id', $uid)->find(); + } + + if ($user['status'] != 'normal') { + $this->fail('账号已被禁用,请联系客服'); + } + + $token = $this->issueToken($user['id']); + $this->ok([ + 'token' => $token, + 'refreshToken' => md5($token . 'refresh'), + 'userInfo' => $this->userInfo($user), + ]); + } + + /** + * A2 手机号授权绑定 + * 入参 { phone } → 出参 { phone } + * 规则 BR-02:必须匹配 ^1\d{10}$ + */ + public function bindPhone() + { + $user = $this->auth(); + $phone = trim((string)$this->input('phone', '')); + if (!preg_match('/^1\d{10}$/', $phone)) { + $this->fail('手机号格式不正确'); + } + Db::name('user')->where('id', $user['id'])->update([ + 'mobile' => $phone, 'updatetime' => time(), + ]); + $this->ok(['phone' => $phone]); + } + + /** + * A3 用户类型切换 + * 入参 { userType: 1|2, license? } → 出参 { userType, needAudit } + * 规则 BR-01:切换商家必须提交营业执照,提交后进入待审核 + */ + public function switchType() + { + $user = $this->auth(); + $userType = (int)$this->input('userType', 0); + if (!in_array($userType, [1, 2], true)) { + $this->fail('用户类型不合法'); + } + + if ($userType === 2) { + $license = trim((string)$this->input('license', '')); + if (!$license) { + $this->fail('商家用户需上传营业执照完成资质认证'); + } + $name = trim((string)$this->input('merchantName', '')); + $exists = Db::name('fc_merchant')->where('user_id', $user['id'])->find(); + $data = [ + 'user_id' => $user['id'], + 'name' => $name !== '' ? $name : (string)$user['nickname'], + 'license' => $license, + 'audit_status' => 0, + 'shop_status' => 'pending', + 'updatetime' => time(), + ]; + if ($exists) { + Db::name('fc_merchant')->where('id', $exists['id'])->update($data); + } else { + $data['createtime'] = time(); + Db::name('fc_merchant')->insert($data); + } + } + + Db::name('fc_user_ext')->where('user_id', $user['id'])->update([ + 'user_type' => $userType, 'updatetime' => time(), + ]); + + $this->ok(['userType' => $userType, 'needAudit' => $userType === 2]); + } + + /** + * A4 个人资料 + * 返回 userInfo(含会员状态、余额) + */ + public function profile() + { + $user = $this->auth(); + $this->ok($this->userInfo($user)); + } + + /** + * code -> openid + * 已配置微信参数时走 jscode2session;未配置(本地/联调)时用确定性派生,便于前后端联调。 + */ + protected function resolveOpenid($code) + { + $appid = (string)$this->cfg('wx.appid', ''); + $secret = (string)$this->cfg('wx.secret', ''); + if ($appid && $secret) { + $url = 'https://api.weixin.qq.com/sns/jscode2session?appid=' . $appid + . '&secret=' . $secret . '&js_code=' . urlencode($code) . '&grant_type=authorization_code'; + $res = @json_decode(@file_get_contents($url), true); + if (!empty($res['openid'])) { + return $res['openid']; + } + } + // 未配置微信参数:若 code 本身即为 openid 标识则直接采用,否则派生 + if (strpos($code, 'openid_') === 0) { + return $code; + } + return 'openid_' . md5($code); + } +} diff --git a/application/api/controller/Distribution.php b/application/api/controller/Distribution.php new file mode 100644 index 0000000..4a7581f --- /dev/null +++ b/application/api/controller/Distribution.php @@ -0,0 +1,173 @@ +auth(); + $uid = (int)$user['id']; + + $total = (float)Db::name('fc_commission')->where('user_id', $uid)->sum('amount'); + $settled = (float)Db::name('fc_commission')->where('user_id', $uid)->where('status', 1)->sum('amount'); + $applied = (float)Db::name('fc_withdraw')->where('user_id', $uid) + ->where('status', 'in', ['auditing', 'paid'])->sum('amount'); + $withdrawable = $settled - $applied; + + $todayStart = strtotime(date('Y-m-d')); + $todayCommission = (float)Db::name('fc_commission')->where('user_id', $uid) + ->where('createtime', '>=', $todayStart)->sum('amount'); + + $l1 = (int)Db::name('fc_dist_relation')->where('parent_id', $uid)->where('level', 1)->count(); + $l2 = (int)Db::name('fc_dist_relation')->where('parent_id', $uid)->where('level', 2)->count(); + + $ext = $this->ext($uid); + + $this->ok([ + 'totalCommission' => round($total, 2), + 'settled' => round($settled, 2), + 'withdrawable' => round($withdrawable, 2), + 'todayCommission' => round($todayCommission, 2), + 'teamCount' => $l1 + $l2, + 'l1Count' => $l1, + 'l2Count' => $l2, + 'inviteCode' => (string)$ext['invite_code'], + 'posterUrl' => '', + ]); + } + + /** + * D2 我的团队 + * GET /api/v1/distribution/team 入参 {level?,page,size} + * → 分页 {list:[{id,nickname,level,joinedAt,performance,commission}],page,size,total,hasMore} + */ + public function team() + { + $user = $this->auth(); + $uid = (int)$user['id']; + list($page, $size) = $this->pager(); + $level = $this->input('level', ''); + + $query = Db::name('fc_dist_relation')->alias('r') + ->join('user u', 'u.id = r.user_id') + ->where('r.parent_id', $uid); + if ($level !== '' && $level !== null) { + $query->where('r.level', (int)$level); + } + $total = $query->count(); + $rows = $query->field('r.id,r.user_id,r.level,r.createtime,u.nickname') + ->order('r.createtime desc') + ->page($page, $size) + ->select(); + + $list = []; + foreach ($rows as $row) { + $childId = (int)$row['user_id']; + $commission = (float)Db::name('fc_commission')->where('from_user_id', $childId)->sum('amount'); + $orderAmount = (float)Db::name('fc_order')->where('user_id', $childId)->where('status', 'paid')->sum('amount'); + $performance = $orderAmount > 0 ? $orderAmount : $commission * 5; + $list[] = [ + 'id' => $childId, + 'nickname' => (string)$row['nickname'], + 'level' => (int)$row['level'], + 'joinedAt' => $this->iso($row['createtime']), + 'performance' => round($performance, 2), + 'commission' => round($commission, 2), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * D3 申请提现 + * POST /api/v1/distribution/withdraw 入参 {amount,channel?} → {withdrawNo,status:'auditing'} + * 规则 BR-09:金额必填且 >0、且不超过可提现余额 + */ + public function withdraw() + { + $user = $this->auth(); + $uid = (int)$user['id']; + $amount = (float)$this->input('amount', 0); + + if ($amount <= 0) { + $this->fail('提现金额不合法'); + } + if ($amount > $this->withdrawable($uid)) { + $this->fail('提现金额超过可提现余额'); + } + + $channel = trim((string)$this->input('channel', '')); + if ($channel === '') { + $channel = 'wxpay'; + } + $amount = round($amount, 2); + $withdrawNo = $this->orderNo('WD'); + + Db::name('fc_withdraw')->insert([ + 'withdraw_no' => $withdrawNo, + 'user_id' => $uid, + 'amount' => $amount, + 'channel' => $channel, + 'status' => 'auditing', + 'reason' => '', + 'paytime' => null, + 'createtime' => time(), + 'updatetime' => time(), + ]); + + $this->notify($uid, 'system', '提现申请已提交', '提现 ¥' . $amount . ' 已提交,预计 T+7 到账'); + + $this->ok(['withdrawNo' => $withdrawNo, 'status' => 'auditing']); + } + + /** + * D4 提现记录 + * GET /api/v1/distribution/withdraw-records → [{id,withdrawNo,amount,status,createdAt}] + */ + public function withdrawRecords() + { + $user = $this->auth(); + $rows = Db::name('fc_withdraw')->where('user_id', $user['id']) + ->order('createtime desc')->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'withdrawNo' => (string)$row['withdraw_no'], + 'amount' => round((float)$row['amount'], 2), + 'status' => (string)$row['status'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($list); + } + + /** + * 可提现余额 = Σ已结算佣金(status=1) - Σ(申请中/已打款)提现 + */ + protected function withdrawable($uid) + { + $settled = (float)Db::name('fc_commission')->where('user_id', $uid)->where('status', 1)->sum('amount'); + $applied = (float)Db::name('fc_withdraw')->where('user_id', $uid) + ->where('status', 'in', ['auditing', 'paid'])->sum('amount'); + return $settled - $applied; + } +} diff --git a/application/api/controller/Home.php b/application/api/controller/Home.php new file mode 100644 index 0000000..b198718 --- /dev/null +++ b/application/api/controller/Home.php @@ -0,0 +1,229 @@ +where('enabled', 1) + ->order('sort asc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'title' => (string)$row['title'], + 'image' => (string)$row['image'], + 'link' => (string)$row['link'], + 'color' => (string)$row['color'], + ]; + } + $this->ok($list); + } + + /** + * H2 分类导航 + * GET /api/v1/home/categories → [{id,name,iconKey,icon,color,count}] + * 规则:仅 enabled=1,按 sort asc;icon 为固定 /static/icons/ 文件名(按 id 映射) + */ + public function categories() + { + $rows = Db::name('fc_category') + ->where('enabled', 1) + ->order('sort asc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $id = (int)$row['id']; + $list[] = [ + 'id' => $id, + 'name' => (string)$row['name'], + 'iconKey' => (string)$row['icon_key'], + 'icon' => '/static/icons/' . $this->iconFile($id), + 'color' => (string)$row['color'], + 'count' => (int)$row['post_count'], + ]; + } + $this->ok($list); + } + + /** + * H3 热门搜索 + * GET /api/v1/home/search/hot → [string] + * 规则:读配置 home.hotKeywords,未配置则返回默认 + */ + public function hotKeywords() + { + $default = ['租房 一室一厅', '二手电动车', '急招快递员', '钢琴家教', '空调维修']; + $keywords = $this->cfg('home.hotKeywords', $default); + if (!is_array($keywords) || !$keywords) { + $keywords = $default; + } + $this->ok(array_values($keywords)); + } + + /** + * H4 信息流 + * GET /api/v1/home/feed {categoryId?,keyword?,area?,sort?,page,size} + * → {list,page,size,total,hasMore} + * 规则:仅 audit_status=1 AND status=1 AND deletetime IS NULL + */ + public function feed() + { + list($page, $size) = $this->pager(); + + $categoryId = (int)$this->input('categoryId', 0); + $keyword = trim((string)$this->input('keyword', '')); + $area = trim((string)$this->input('area', '')); + $sort = (string)$this->input('sort', ''); + + $query = Db::name('fc_post') + ->where('audit_status', 1) + ->where('status', 1) + ->whereNull('deletetime'); + if ($categoryId > 0) { + $query->where('category_id', $categoryId); + } + if ($keyword !== '') { + $query->where('title', 'like', '%' . $keyword . '%'); + } + if ($area !== '') { + $query->where('area', $area); + } + if ($sort === 'price') { + $query->order('price asc'); + } elseif ($sort === 'hot') { + $query->order('views desc'); + } else { + $query->order('createtime desc'); + } + + $counter = Db::name('fc_post') + ->where('audit_status', 1) + ->where('status', 1) + ->whereNull('deletetime'); + if ($categoryId > 0) { + $counter->where('category_id', $categoryId); + } + if ($keyword !== '') { + $counter->where('title', 'like', '%' . $keyword . '%'); + } + if ($area !== '') { + $counter->where('area', $area); + } + $total = $counter->count(); + + $rows = $query->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = $this->buildPost($row); + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * 组装 Post 结构(与前端 mock 数据字段严格对齐) + * @param array $row fc_post 行 + * @return array + */ + protected function buildPost($row) + { + $categoryName = ''; + if (!empty($row['category_id'])) { + $cat = Db::name('fc_category')->where('id', $row['category_id'])->find(); + if ($cat) { + $categoryName = (string)$cat['name']; + } + } + + $publisher = ['id' => 0, 'name' => '', 'avatar' => '', 'isMerchant' => false, 'verified' => false]; + if (!empty($row['user_id'])) { + $pub = Db::name('user')->where('id', $row['user_id'])->find(); + if ($pub) { + $pubExt = Db::name('fc_user_ext')->where('user_id', $pub['id'])->find(); + $isMerchant = $pubExt && (int)$pubExt['user_type'] === 2; + $publisher = [ + 'id' => (int)$pub['id'], + 'name' => (string)$pub['nickname'], + 'avatar' => (string)$pub['avatar'], + 'isMerchant' => (bool)$isMerchant, + 'verified' => (bool)$isMerchant, + ]; + } + } + + $tags = []; + if (!empty($row['tags'])) { + foreach (explode(',', (string)$row['tags']) as $tag) { + $tag = trim($tag); + if ($tag !== '') { + $tags[] = $tag; + } + } + } + + return [ + 'id' => (int)$row['id'], + 'categoryId' => (int)$row['category_id'], + 'categoryName' => $categoryName, + 'title' => (string)$row['title'], + 'content' => (string)$row['content'], + 'cover' => (string)$row['cover'], + 'price' => (float)$row['price'], + 'priceUnit' => (string)$row['price_unit'], + 'area' => (string)$row['area'], + 'address' => (string)$row['address'], + 'contact' => $this->maskPhone($row['phone']), + 'views' => (int)$row['views'], + 'likes' => (int)$row['likes'], + 'isPaid' => (int)$row['is_paid'] === 1, + 'auditStatus' => (int)$row['audit_status'], + 'createdAt' => $this->iso($row['createtime']), + 'tags' => $tags, + 'publisher' => $publisher, + 'commentCount' => (int)$row['comment_count'], + 'distance' => (int)$row['distance'], + ]; + } + + /** + * 分类 id → 固定图标文件名 + * @param int $id + * @return string + */ + protected function iconFile($id) + { + $map = [ + 1 => 'cat-job.png', + 2 => 'cat-house.png', + 3 => 'cat-goods.png', + 4 => 'cat-car.png', + 5 => 'cat-service.png', + 6 => 'cat-education.png', + 7 => 'cat-clean.png', + 8 => 'cat-friend.png', + ]; + return isset($map[$id]) ? $map[$id] : 'cat-job.png'; + } +} diff --git a/application/api/controller/Member.php b/application/api/controller/Member.php new file mode 100644 index 0000000..fbdf32a --- /dev/null +++ b/application/api/controller/Member.php @@ -0,0 +1,201 @@ +where('enabled', 1) + ->order('sort asc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'planId' => (string)$row['plan_code'], + 'name' => (string)$row['name'], + 'price' => (float)$row['price'], + 'originPrice' => (float)$row['origin_price'], + 'days' => (int)$row['days'], + 'tag' => (string)$row['tag'], + ]; + } + $this->ok($list); + } + + /** + * M2 会员权益 + * GET /api/v1/member/benefits → {freeView,postDiscount,distRate,benefits[]} + * 规则:读配置 member.benefit,未配置返回默认 + */ + public function benefits() + { + $default = [ + 'freeView' => 20, + 'postDiscount' => '8 折', + 'distRate' => '一级 20% / 二级 10%', + 'benefits' => ['每日免费查看 20 条联系方式', '发布信息 8 折', '专属会员标识', '分销佣金加速结算'], + ]; + + $cfg = $this->cfg('member.benefit', null); + if (!is_array($cfg)) { + $cfg = $default; + } + + $benefits = isset($cfg['benefits']) && is_array($cfg['benefits']) && $cfg['benefits'] + ? array_values($cfg['benefits']) + : $default['benefits']; + + $this->ok([ + 'freeView' => isset($cfg['freeView']) ? (int)$cfg['freeView'] : $default['freeView'], + 'postDiscount' => isset($cfg['postDiscount']) ? (string)$cfg['postDiscount'] : $default['postDiscount'], + 'distRate' => isset($cfg['distRate']) ? (string)$cfg['distRate'] : $default['distRate'], + 'benefits' => $benefits, + ]); + } + + /** + * M3 开通会员 + * POST /api/v1/member/open {planId,payType} → {orderNo,status,expireAt} + * 规则 BR-07;套餐按 plan_code 查;余额支付扣 fa_user.money + */ + public function open() + { + $user = $this->auth(); + + $planId = trim((string)$this->input('planId', '')); + if ($planId === '') { + $this->fail('请选择会员套餐'); + } + + $payType = (string)$this->input('payType', ''); + if (!in_array($payType, ['wxpay', 'balance'], true)) { + $this->fail('支付方式不支持'); + } + + $plan = Db::name('fc_plan')->where('plan_code', $planId)->find(); + if (!$plan) { + $this->fail('套餐不存在'); + } + + $amount = round((float)$plan['price'], 2); + $now = time(); + + $orderNo = $this->orderNo('MO'); + $orderId = Db::name('fc_order')->insertGetId([ + 'order_no' => $orderNo, + 'user_id' => $user['id'], + 'type' => 'member', + 'title' => (string)$plan['name'], + 'amount' => $amount, + 'pay_type' => $payType, + 'status' => 'paid', + 'paytime' => $now, + 'related_id' => 0, + 'createtime' => $now, + 'updatetime' => $now, + ]); + + if ($payType === 'balance') { + $balance = (float)$user['money']; + if ($balance < $amount) { + $this->fail('余额不足'); + } + $balance = round($balance - $amount, 2); + Db::name('user')->where('id', $user['id'])->update([ + 'money' => $balance, + 'updatetime' => $now, + ]); + } + + $ext = $this->ext($user['id']); + $base = (int)$ext['member_expiretime']; + if ($base < $now) { + $base = $now; + } + $expire = $base + (int)$plan['days'] * 86400; + + Db::name('fc_user_ext')->where('user_id', $user['id'])->update([ + 'is_member' => 1, + 'member_level' => (string)$plan['name'], + 'member_expiretime' => $expire, + 'updatetime' => $now, + ]); + + $this->flow('income', '会员开通', $amount, 0, $orderId); + + $this->ok([ + 'orderNo' => $orderNo, + 'status' => 'paid', + 'expireAt' => $this->iso($expire), + ]); + } + + /** + * M4 我的订单 + * GET /api/v1/member/orders {page,size} → {list,page,size,total,hasMore} + * 规则:按 createtime desc + */ + public function orders() + { + $user = $this->auth(); + list($page, $size) = $this->pager(); + + $total = Db::name('fc_order')->where('user_id', $user['id'])->count(); + + $rows = Db::name('fc_order') + ->where('user_id', $user['id']) + ->order('createtime desc') + ->page($page, $size) + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'orderNo' => (string)$row['order_no'], + 'type' => (string)$row['type'], + 'title' => (string)$row['title'], + 'amount' => (float)$row['amount'], + 'payType' => (string)$row['pay_type'], + 'status' => (string)$row['status'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * M5 我的资产 + * GET /api/v1/member/assets → {balance,coupons:[],points} + */ + public function assets() + { + $user = $this->auth(); + + $this->ok([ + 'balance' => (float)$user['money'], + 'coupons' => [], + 'points' => (int)$user['score'], + ]); + } +} diff --git a/application/api/controller/Message.php b/application/api/controller/Message.php new file mode 100644 index 0000000..671c16c --- /dev/null +++ b/application/api/controller/Message.php @@ -0,0 +1,67 @@ +auth(); + $uid = (int)$user['id']; + list($page, $size) = $this->pager(); + $type = $this->input('type', ''); + + $query = Db::name('fc_message')->where('user_id', 'in', [$uid, 0]); + if ($type !== '' && $type !== null) { + $query->where('type', $type); + } + $total = $query->count(); + $rows = $query->order('createtime desc')->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'id' => (int)$row['id'], + 'type' => (string)$row['type'], + 'title' => (string)$row['title'], + 'content' => (string)$row['content'], + 'read' => (bool)(int)$row['isread'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $this->ok($this->paginate($list, $total, $page, $size)); + } + + /** + * M2 标记已读 + * POST /api/v1/message/:id/read → {read:true} + */ + public function read() + { + $user = $this->auth(); + $uid = (int)$user['id']; + $id = (int)$this->request->param('id', 0); + + Db::name('fc_message')->where('id', $id) + ->where('user_id', 'in', [$uid, 0]) + ->update(['isread' => 1, 'readtime' => time()]); + + $this->ok(['read' => true]); + } +} diff --git a/application/api/controller/Post.php b/application/api/controller/Post.php new file mode 100644 index 0000000..7f87d68 --- /dev/null +++ b/application/api/controller/Post.php @@ -0,0 +1,488 @@ +auth(); + + $categoryId = (int)$this->input('categoryId', 0); + $title = trim((string)$this->input('title', '')); + $content = trim((string)$this->input('content', '')); + + if ($categoryId <= 0) { + $this->fail('分类不能为空'); + } + if ($title === '') { + $this->fail('标题不能为空'); + } + if ($content === '') { + $this->fail('内容不能为空'); + } + if (mb_strlen($title) > 40) { + $this->fail('标题不能超过 40 字'); + } + if (mb_strlen($content) > 500) { + $this->fail('内容不能超过 500 字'); + } + + $phone = trim((string)$this->input('phone', '')); + if ($phone !== '' && !preg_match('/^1\d{10}$/', $phone)) { + $this->fail('手机号格式不正确'); + } + + $paid = (bool)$this->input('paid', false); + $payType = (string)$this->input('payType', ''); + $amount = 0; + if ($paid) { + $amount = $this->input('amount', null); + if ($amount === null || $amount === '') { + $amount = $this->cfg('fee.singlePost', 5); + } + $amount = round((float)$amount, 2); + } + + $now = time(); + $postId = Db::name('fc_post')->insertGetId([ + 'user_id' => $user['id'], + 'category_id' => $categoryId, + 'title' => $title, + 'content' => $content, + 'cover' => '', + 'media' => json_encode($this->mediaArray(), JSON_UNESCAPED_UNICODE), + 'price' => round((float)$this->input('price', 0), 2), + 'price_unit' => (string)$this->input('priceUnit', ''), + 'area' => (string)$this->input('area', ''), + 'address' => (string)$this->input('address', ''), + 'contact' => (string)$this->input('contact', ''), + 'phone' => $phone, + 'wechat' => '', + 'tags' => $this->tagsString(), + 'is_paid' => $paid ? 1 : 0, + 'amount' => $paid ? $amount : 0, + 'pay_type' => $paid ? $payType : '', + 'audit_status' => 0, + 'views' => 0, + 'likes' => 0, + 'comment_count' => 0, + 'status' => 1, + 'createtime' => $now, + 'updatetime' => $now, + ]); + + $order = null; + if ($paid) { + $orderNo = $this->orderNo('PO'); + Db::name('fc_order')->insert([ + 'order_no' => $orderNo, + 'user_id' => $user['id'], + 'type' => 'post', + 'title' => $title, + 'amount' => $amount, + 'pay_type' => $payType, + 'status' => 'pending', + 'related_id' => $postId, + 'createtime' => $now, + 'updatetime' => $now, + ]); + $order = ['orderNo' => $orderNo, 'amount' => $amount, 'payType' => $payType]; + } + + $this->ok([ + 'postId' => $postId, + 'auditStatus' => 0, + 'order' => $order, + ]); + } + + /** + * P2 草稿列表 + * GET /api/v1/post/drafts → [{draftId,title,content,categoryId,savedAt}] + */ + public function drafts() + { + $user = $this->auth(); + + $rows = Db::name('fc_draft') + ->where('user_id', $user['id']) + ->order('savedat desc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $list[] = [ + 'draftId' => (int)$row['id'], + 'title' => (string)$row['title'], + 'content' => (string)$row['content'], + 'categoryId' => (int)$row['category_id'], + 'savedAt' => $this->iso($row['savedat']), + ]; + } + $this->ok($list); + } + + /** + * P3 保存草稿 + * POST /api/v1/post/draft {draftId?,categoryId?,title?,content?,media?,contact?,phone?,address?} + * → {draftId,savedAt,categoryId,title,content} + */ + public function saveDraft() + { + $user = $this->auth(); + + $draftId = (int)$this->input('draftId', 0); + $categoryId = (int)$this->input('categoryId', 0); + $title = (string)$this->input('title', ''); + $content = (string)$this->input('content', ''); + $now = time(); + + $data = [ + 'user_id' => $user['id'], + 'category_id' => $categoryId, + 'title' => $title, + 'content' => $content, + 'media' => json_encode($this->mediaArray(), JSON_UNESCAPED_UNICODE), + 'contact' => (string)$this->input('contact', ''), + 'phone' => (string)$this->input('phone', ''), + 'address' => (string)$this->input('address', ''), + 'savedat' => $now, + 'updatetime' => $now, + ]; + + if ($draftId > 0) { + $exist = Db::name('fc_draft')->where('id', $draftId)->find(); + if (!$exist || (int)$exist['user_id'] !== (int)$user['id']) { + $this->fail('草稿不存在'); + } + Db::name('fc_draft')->where('id', $draftId)->update($data); + } else { + $data['createtime'] = $now; + $draftId = Db::name('fc_draft')->insertGetId($data); + } + + $this->ok([ + 'draftId' => $draftId, + 'savedAt' => $this->iso($now), + 'categoryId' => $categoryId, + 'title' => $title, + 'content' => $content, + ]); + } + + /** + * P4 删除信息(软删) + * DELETE /api/v1/post/:id → {deleted:true} + * 规则:仅能删除本人发布 + */ + public function remove() + { + $user = $this->auth(); + $id = $this->routeId(); + + $post = Db::name('fc_post')->where('id', $id)->whereNull('deletetime')->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + if ((int)$post['user_id'] !== (int)$user['id']) { + $this->fail('无权操作'); + } + + Db::name('fc_post')->where('id', $id)->update([ + 'deletetime' => time(), + 'updatetime' => time(), + ]); + + $this->ok(['deleted' => true]); + } + + /** + * P5 信息详情 + * GET /api/v1/post/:id → Post 结构(contact 为脱敏串,绝不返回完整手机号) + * 规则:允许游客;不存在 fail('信息不存在',404);每次浏览 views+1 + */ + public function detail() + { + // GET /post/:id(详情)与 DELETE /post/:id(软删)共用同一路径, + // PHP 数组键无法为同一路径注册两条规则,故在此按请求方法分派。 + if ($this->request->isDelete()) { + $this->remove(); + } + + $id = $this->routeId(); + + $post = Db::name('fc_post')->where('id', $id)->whereNull('deletetime')->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + + Db::name('fc_post')->where('id', $id)->setInc('views'); + $post['views'] = (int)$post['views'] + 1; + + $this->ok($this->buildPost($post)); + } + + /** + * P6 付费查看联系方式 + * POST /api/v1/post/:id/pay-view {payType} + * → {unlocked:true,contact:{phone,wechat}} + * 规则 BR-05;金额取 fee.payView;会员或已解锁免费 + */ + public function payView() + { + $user = $this->auth(); + $id = $this->routeId(); + + $payType = (string)$this->input('payType', ''); + if (!in_array($payType, ['wxpay', 'balance'], true)) { + $this->fail('支付方式不支持'); + } + + $post = Db::name('fc_post')->where('id', $id)->whereNull('deletetime')->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + + $contact = [ + 'phone' => (string)$post['phone'], + 'wechat' => $post['wechat'] !== '' ? (string)$post['wechat'] : ('tongcheng_' . $id), + ]; + + // 已解锁直接返回 + $exist = Db::name('fc_pay_view') + ->where('user_id', $user['id']) + ->where('post_id', $id) + ->find(); + if ($exist) { + $this->ok(['unlocked' => true, 'contact' => $contact]); + } + + $amount = round((float)$this->cfg('fee.payView', 2), 2); + $now = time(); + + $ext = $this->ext($user['id']); + $isMember = (int)$ext['is_member'] === 1 + && (!$ext['member_expiretime'] || $ext['member_expiretime'] > $now); + + $orderId = 0; + if ($isMember) { + // 会员免费解锁 + $amount = 0; + $payType = 'free'; + } elseif ($payType === 'balance') { + $balance = (float)$user['money']; + if ($balance < $amount) { + $this->fail('余额不足'); + } + $balance = round($balance - $amount, 2); + Db::name('user')->where('id', $user['id'])->update([ + 'money' => $balance, + 'updatetime' => $now, + ]); + $this->flow('expense', '付费查看', $amount, $balance, $id); + } else { + // 微信支付:创建已支付订单 + $orderId = Db::name('fc_order')->insertGetId([ + 'order_no' => $this->orderNo('VO'), + 'user_id' => $user['id'], + 'type' => 'post', + 'title' => '付费查看 #' . $id, + 'amount' => $amount, + 'pay_type' => $payType, + 'status' => 'paid', + 'paytime' => $now, + 'related_id' => $id, + 'createtime' => $now, + 'updatetime' => $now, + ]); + } + + Db::name('fc_pay_view')->insert([ + 'user_id' => $user['id'], + 'post_id' => $id, + 'pay_type' => $payType, + 'amount' => $amount, + 'order_id' => $orderId, + 'createtime' => $now, + ]); + + $this->ok(['unlocked' => true, 'contact' => $contact]); + } + + /** + * P7 发表评价 + * POST /api/v1/post/:id/comment {score,content} → {commentId} + * 规则 BR-06 + */ + public function comment() + { + $user = $this->auth(); + $id = $this->routeId(); + + $content = trim((string)$this->input('content', '')); + if ($content === '') { + $this->fail('评价内容不能为空'); + } + + $score = (int)$this->input('score', 5); + if ($score < 1 || $score > 5) { + $this->fail('评分需在 1-5 之间'); + } + + $post = Db::name('fc_post')->where('id', $id)->whereNull('deletetime')->find(); + if (!$post) { + $this->fail('信息不存在', 404); + } + + $now = time(); + $commentId = Db::name('fc_comment')->insertGetId([ + 'post_id' => $id, + 'user_id' => $user['id'], + 'score' => $score, + 'content' => $content, + 'images' => '', + 'reply' => '', + 'top' => 0, + 'status' => 1, + 'createtime' => $now, + 'updatetime' => $now, + ]); + + Db::name('fc_post')->where('id', $id)->setInc('comment_count'); + + $this->notify( + $post['user_id'], + 'interact', + '收到新评价', + '用户「' . (string)$user['nickname'] . '」评价了您的信息:' . $content + ); + + $this->ok(['commentId' => $commentId]); + } + + /** + * 读取路由 :id 参数(兼容 POST 体/查询串传入) + * @return int + */ + protected function routeId() + { + $id = (int)$this->input('id', 0); + if ($id <= 0) { + $id = (int)$this->request->param('id', 0); + } + return $id; + } + + /** + * media 入参归一化为数组 + * @return array + */ + protected function mediaArray() + { + $media = $this->input('media', []); + if (is_string($media)) { + $decoded = json_decode($media, true); + $media = is_array($decoded) ? $decoded : []; + } + return is_array($media) ? array_values($media) : []; + } + + /** + * tags 入参归一化为逗号分隔字符串 + * @return string + */ + protected function tagsString() + { + $tags = $this->input('tags', []); + if (is_array($tags)) { + $tags = implode(',', $tags); + } + return (string)$tags; + } + + /** + * 组装 Post 结构(与前端 mock 数据字段严格对齐) + * @param array $row fc_post 行 + * @return array + */ + protected function buildPost($row) + { + $categoryName = ''; + if (!empty($row['category_id'])) { + $cat = Db::name('fc_category')->where('id', $row['category_id'])->find(); + if ($cat) { + $categoryName = (string)$cat['name']; + } + } + + $publisher = ['id' => 0, 'name' => '', 'avatar' => '', 'isMerchant' => false, 'verified' => false]; + if (!empty($row['user_id'])) { + $pub = Db::name('user')->where('id', $row['user_id'])->find(); + if ($pub) { + $pubExt = Db::name('fc_user_ext')->where('user_id', $pub['id'])->find(); + $isMerchant = $pubExt && (int)$pubExt['user_type'] === 2; + $publisher = [ + 'id' => (int)$pub['id'], + 'name' => (string)$pub['nickname'], + 'avatar' => (string)$pub['avatar'], + 'isMerchant' => (bool)$isMerchant, + 'verified' => (bool)$isMerchant, + ]; + } + } + + $tags = []; + if (!empty($row['tags'])) { + foreach (explode(',', (string)$row['tags']) as $tag) { + $tag = trim($tag); + if ($tag !== '') { + $tags[] = $tag; + } + } + } + + return [ + 'id' => (int)$row['id'], + 'categoryId' => (int)$row['category_id'], + 'categoryName' => $categoryName, + 'title' => (string)$row['title'], + 'content' => (string)$row['content'], + 'cover' => (string)$row['cover'], + 'price' => (float)$row['price'], + 'priceUnit' => (string)$row['price_unit'], + 'area' => (string)$row['area'], + 'address' => (string)$row['address'], + 'contact' => $this->maskPhone($row['phone']), + 'views' => (int)$row['views'], + 'likes' => (int)$row['likes'], + 'isPaid' => (int)$row['is_paid'] === 1, + 'auditStatus' => (int)$row['audit_status'], + 'createdAt' => $this->iso($row['createtime']), + 'tags' => $tags, + 'publisher' => $publisher, + 'commentCount' => (int)$row['comment_count'], + 'distance' => (int)$row['distance'], + ]; + } +} diff --git a/application/api/controller/Setting.php b/application/api/controller/Setting.php new file mode 100644 index 0000000..81bbf53 --- /dev/null +++ b/application/api/controller/Setting.php @@ -0,0 +1,32 @@ +cfg('ui.icons', []); + if (!is_array($icons)) { + $icons = []; + } + + $this->ok([ + 'siteName' => (string)$this->cfg('ui.siteName', '同城分类信息'), + 'logo' => (string)$this->cfg('ui.logo', ''), + 'icons' => (object)$icons, + ]); + } +} diff --git a/application/api/controller/V1.php b/application/api/controller/V1.php new file mode 100644 index 0000000..60b5274 --- /dev/null +++ b/application/api/controller/V1.php @@ -0,0 +1,373 @@ +,"message":"...","data":null} + * 2) Authorization: Bearer {token} 鉴权(前端约定,FastAdmin 原生只认 HTTP_TOKEN) + * 3) 分页结构:{list,page,size,total,hasMore} + * 4) 业务配置读写(fa_fc_config) + * + * 契约见 docs/03-接口文档.md + */ +class V1 extends Api +{ + /** 全部方法自行鉴权,不走 FastAdmin 的 uri 权限匹配 */ + protected $noNeedLogin = ['*']; + protected $noNeedRight = ['*']; + + /** @var \app\common\model\User|null 当前登录用户 */ + protected $user = null; + + /** @var bool 是否已尝试解析登录态 */ + protected $resolved = false; + + /* ==================== 响应输出 ==================== */ + + /** + * 成功响应 + * @param mixed $data + * @param string $message + */ + protected function ok($data = null, $message = 'ok') + { + $this->emit(['code' => 0, 'message' => $message, 'data' => $data], 200); + } + + /** + * 业务失败响应(HTTP 默认 200,前端按 code 判定) + * @param string $message + * @param int $code + * @param int $httpStatus + */ + protected function fail($message, $code = 1, $httpStatus = 200) + { + $this->emit(['code' => $code, 'message' => (string)$message, 'data' => null], $httpStatus); + } + + /** 输出 JSON 并终止 */ + protected function emit(array $payload, $httpStatus = 200) + { + $response = Response::create($payload, 'json', $httpStatus) + ->header(['Content-Type' => 'application/json;charset=utf-8']); + throw new HttpResponseException($response); + } + + /* ==================== 鉴权 ==================== */ + + /** 从 Authorization: Bearer xxx 提取 token */ + protected function bearerToken() + { + $auth = $this->request->header('authorization', ''); + if ($auth && stripos($auth, 'bearer ') === 0) { + return trim(substr($auth, 7)); + } + return ''; + } + + /** 解析当前用户(不强制登录) */ + protected function user() + { + if ($this->resolved) { + return $this->user; + } + $this->resolved = true; + + $token = $this->bearerToken(); + if (!$token && !($token = $this->request->request('token'))) { + return $this->user = null; + } + + $row = Db::name('user_token')->where('token', $token)->find(); + if (!$row) { + return $this->user = null; + } + if ($row['expiretime'] && $row['expiretime'] < time()) { + return $this->user = null; + } + $user = \app\common\model\User::get($row['user_id']); + if (!$user || $user->status != 'normal') { + return $this->user = null; + } + return $this->user = $user; + } + + /** 强制登录:未登录直接 401 */ + protected function auth() + { + $user = $this->user(); + if (!$user) { + $this->fail('登录已过期,请重新登录', 401, 401); + } + return $user; + } + + /** 当前用户 ID */ + protected function userId() + { + return (int)$this->auth()->id; + } + + /** 签发 token 并写入 fa_user_token */ + protected function issueToken($userId, $days = 30) + { + $token = md5(mt_rand() . $userId . microtime(true) . uniqid('', true)); + Db::name('user_token')->insert([ + 'token' => $token, + 'user_id' => $userId, + 'createtime' => time(), + 'expiretime' => time() + $days * 86400, + ]); + return $token; + } + + /* ==================== 入参 ==================== */ + + /** 读取单个入参(POST 优先,其次 GET) */ + protected function input($key, $default = null) + { + $val = $this->request->post($key, null); + if ($val === null || $val === '') { + $val = $this->request->get($key, $default); + } + return $val === null ? $default : $val; + } + + /** 全部入参 */ + protected function inputs() + { + return array_merge((array)$this->request->get(), (array)$this->request->post()); + } + + /** 必填校验,失败直接返回业务错误 */ + protected function require2($fields, $data = null) + { + $data = $data === null ? $this->inputs() : $data; + foreach ((array)$fields as $f => $label) { + if (is_int($f)) { + $f = $label; + $label = $f; + } + if (!isset($data[$f]) || $data[$f] === '' || $data[$f] === null) { + $this->fail($label . '不能为空'); + } + } + return true; + } + + /* ==================== 分页 ==================== */ + + /** 解析分页参数 -> [page, size] */ + protected function pager($defaultSize = 10, $maxSize = 50) + { + $page = max(1, (int)$this->input('page', 1)); + $size = (int)$this->input('size', $defaultSize); + if ($size < 1) { + $size = $defaultSize; + } + if ($size > $maxSize) { + $size = $maxSize; + } + return [$page, $size]; + } + + /** 组装分页结构 {list,page,size,total,hasMore} */ + protected function paginate($list, $total, $page, $size) + { + if (is_object($list) && method_exists($list, 'all')) { + $list = $list->all(); + } + $list = array_values(is_array($list) ? $list : (array)$list); + return [ + 'list' => $list, + 'page' => (int)$page, + 'size' => (int)$size, + 'total' => (int)$total, + 'hasMore' => $page * $size < $total, + ]; + } + + /** 对数组集合做内存分页(小数据集用) */ + protected function paginateArray(array $items, $page, $size) + { + $total = count($items); + return $this->paginate(array_slice($items, ($page - 1) * $size, $size), $total, $page, $size); + } + + /* ==================== 业务配置(fa_fc_config) ==================== */ + + /** + * 读取配置,key 形如 fee.singlePost / ui.logo / base.appName + * 数组/对象值以 JSON 存取 + */ + protected function cfg($key, $default = null) + { + static $cache = null; + if ($cache === null) { + $cache = []; + foreach (Db::name('fc_config')->select() as $row) { + $cache[$row['key']] = $row['value']; + } + } + if (!array_key_exists($key, $cache)) { + return $default; + } + $val = $cache[$key]; + $decoded = json_decode($val, true); + if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || $decoded === null)) { + return $decoded; + } + return $val; + } + + /** 写入配置 */ + protected function setCfg($key, $value) + { + $val = (is_scalar($value) || $value === null) ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE); + if (Db::name('fc_config')->where('key', $key)->find()) { + Db::name('fc_config')->where('key', $key)->update(['value' => $val, 'updatetime' => time()]); + } else { + Db::name('fc_config')->insert([ + 'key' => $key, 'value' => $val, 'createtime' => time(), 'updatetime' => time(), + ]); + } + return true; + } + + /* ==================== 工具 ==================== */ + + /** 时间戳 -> ISO8601(前端 new Date() 可直接解析) */ + protected function iso($timestamp) + { + if (!$timestamp) { + return null; + } + if (!is_numeric($timestamp)) { + $timestamp = strtotime($timestamp); + } + return date('c', (int)$timestamp); + } + + /** 手机号脱敏 */ + protected function maskPhone($phone) + { + $phone = (string)$phone; + return strlen($phone) === 11 ? substr($phone, 0, 3) . '****' . substr($phone, 7) : $phone; + } + + /** 生成业务单号 */ + protected function orderNo($prefix) + { + return $prefix . date('YmdHis') . mt_rand(1000, 9999); + } + + /* ==================== 业务公共 ==================== */ + + /** + * 读取用户业务扩展(不存在则初始化) + * @return array + */ + protected function ext($userId) + { + $ext = Db::name('fc_user_ext')->where('user_id', $userId)->find(); + if (!$ext) { + $inviteCode = strtoupper(substr(md5('fc' . $userId . mt_rand()), 0, 6)); + Db::name('fc_user_ext')->insert([ + 'user_id' => $userId, 'openid' => '', 'user_type' => 1, 'realname_status' => 0, + 'is_member' => 0, 'member_level' => '', 'member_expiretime' => null, + 'invite_code' => $inviteCode, 'inviter_id' => 0, + 'free_view_date' => date('Y-m-d'), 'free_view_used' => 0, + 'createtime' => time(), 'updatetime' => time(), + ]); + $ext = Db::name('fc_user_ext')->where('user_id', $userId)->find(); + } + return $ext; + } + + /** + * 组装用户信息(与前端 common/mock/data.js 的 datasets.user 字段一致) + */ + protected function userInfo($user = null, $phone = null) + { + $user = $user ?: $this->auth(); + $ext = $this->ext($user['id']); + $isMember = (int)$ext['is_member'] === 1 + && (!$ext['member_expiretime'] || $ext['member_expiretime'] > time()); + return [ + 'id' => (int)$user['id'], + 'nickname' => (string)$user['nickname'], + 'avatar' => (string)$user['avatar'], + 'phone' => $phone === null ? (string)$user['mobile'] : $phone, + 'userType' => (int)$ext['user_type'], + 'isMember' => $isMember, + 'memberLevel' => $isMember ? (string)$ext['member_level'] : '', + 'memberExpireAt' => $ext['member_expiretime'] ? $this->iso($ext['member_expiretime']) : null, + 'balance' => (float)$user['money'], + 'realnameStatus' => (int)$ext['realname_status'], + ]; + } + + /** 解析 JSON 字段为数组 */ + protected function jsonArr($val) + { + if (is_array($val)) { + return $val; + } + if (!$val) { + return []; + } + $decoded = json_decode($val, true); + return is_array($decoded) ? $decoded : []; + } + + /** 记一条资金流水 */ + protected function flow($type, $scene, $amount, $balance, $relatedId = 0, $remark = '') + { + static $seq = 0; + $seq++; + Db::name('fc_finance_flow')->insert([ + 'flow_no' => 'F' . date('YmdHis') . mt_rand(100, 999) . $seq, + 'type' => $type, + 'scene' => $scene, + 'amount' => $amount, + 'balance' => $balance, + 'related_id' => $relatedId, + 'remark' => $remark, + 'createtime' => time(), + ]); + } + + /** 发一条站内消息 */ + protected function notify($userId, $type, $title, $content) + { + Db::name('fc_message')->insert([ + 'user_id' => $userId, 'type' => $type, 'title' => $title, + 'content' => $content, 'isread' => 0, 'readtime' => null, 'createtime' => time(), + ]); + } + + /** 管理端操作留痕 */ + protected function adminLog($action, $remark = '') + { + try { + Db::name('admin_log')->insert([ + 'admin_id' => 1, 'username' => 'admin', 'url' => $this->request->url(), + 'title' => $action, 'content' => $remark, 'ip' => $this->request->ip(), + 'useragent' => (string)$this->request->header('user-agent'), + 'createtime' => time(), + ]); + } catch (\Exception $e) { + // 留痕失败不影响主流程 + } + } +} diff --git a/application/route.php b/application/route.php index 8d93637..9764c79 100755 --- a/application/route.php +++ b/application/route.php @@ -10,16 +10,115 @@ // | Author: liu21st // +---------------------------------------------------------------------- +// | 同城分类信息 · v1 业务路由表 +// | 契约:docs/03-接口文档.md (小程序 /api/v1/... ,后台 PC /api/v1/admin/...) +// | +// | 说明: +// | 1) Nginx 已把 ^(.*)$ 重写到 /index.php?s=$1,pathinfo_depr 为 '/' +// | 2) TP 5.0 按「注册顺序」匹配,故长路径(:id/xxx)必须写在短路径(:id)之前 +// | 3) :id 统一用 \d+ 约束,避免 /post/create、/post/drafts 被 :id 误吞 +// | 4) 路由不限制请求方法,动作内部按 isPost() 分支(含 GET/POST 同路径的 4 处) +// +---------------------------------------------------------------------- + return [ - //别名配置,别名只能是映射到控制器且访问时必须加上请求的方法 - '__alias__' => [ - ], - //变量规则 + // 变量规则 '__pattern__' => [ + 'id' => '\d+', ], -// 域名绑定到模块 -// '__domain__' => [ -// 'admin' => 'admin', -// 'api' => 'api', -// ], + //别名配置,别名只能是映射到控制器且访问时必须加上请求的方法 + '__alias__' => [], + + /* =============== 一、用户端 · 认证与登录(Module-AUTH) =============== */ + 'api/v1/auth/wx-login' => 'api/Auth/wxLogin', + 'api/v1/auth/bind-phone' => 'api/Auth/bindPhone', + 'api/v1/auth/switch-type' => 'api/Auth/switchType', + 'api/v1/auth/profile' => 'api/Auth/profile', + + /* =============== 二、用户端 · 首页与展示(Module-HOME) =============== */ + 'api/v1/home/banners' => 'api/Home/banners', + 'api/v1/home/categories' => 'api/Home/categories', + 'api/v1/home/search/hot' => 'api/Home/hotKeywords', + 'api/v1/home/feed' => 'api/Home/feed', + + /* =============== 三、用户端 · 信息发布与详情(长路径优先) =============== */ + 'api/v1/post/:id/pay-view' => 'api/Post/payView', + 'api/v1/post/:id/comment' => 'api/Post/comment', + 'api/v1/post/create' => 'api/Post/create', + 'api/v1/post/drafts' => 'api/Post/drafts', + 'api/v1/post/draft' => 'api/Post/saveDraft', + 'api/v1/post/:id' => 'api/Post/detail', + + /* =============== 四、用户端 · 会员中心(Module-MEMBER) =============== */ + 'api/v1/member/plans' => 'api/Member/plans', + 'api/v1/member/benefits' => 'api/Member/benefits', + 'api/v1/member/open' => 'api/Member/open', + 'api/v1/member/orders' => 'api/Member/orders', + 'api/v1/member/assets' => 'api/Member/assets', + + /* =============== 五、用户端 · 分销中心(Module-DIST) =============== */ + 'api/v1/distribution/overview' => 'api/Distribution/overview', + 'api/v1/distribution/team' => 'api/Distribution/team', + 'api/v1/distribution/withdraw' => 'api/Distribution/withdraw', + 'api/v1/distribution/withdraw-records' => 'api/Distribution/withdrawRecords', + + /* =============== 六、用户端 · 消息中心(Module-MESSAGE) =============== */ + 'api/v1/message/list' => 'api/Message/list', + 'api/v1/message/:id/read' => 'api/Message/read', + + /* =============== 七、站点 UI 配置(用户端读取、后台写入) =============== */ + 'api/v1/setting/ui' => 'api/Setting/ui', + + /* =============== 八、后台 · 数据总览(Module-ADM-DASHBOARD) =============== */ + 'api/v1/admin/dashboard/overview' => 'api/AdminDashboard/overview', + 'api/v1/admin/dashboard/trend' => 'api/AdminDashboard/trend', + + /* =============== 九、后台 · 商家管理(Module-ADM-MERCHANT) =============== */ + 'api/v1/admin/merchant/:id/audit' => 'api/AdminMerchant/audit', + 'api/v1/admin/merchants' => 'api/AdminMerchant/index', + + /* =============== 十、后台 · 会员管理(Module-ADM-MEMBER) =============== */ + 'api/v1/admin/member/benefit' => 'api/AdminMember/saveBenefit', + 'api/v1/admin/users' => 'api/AdminMember/users', + + /* =============== 十一、后台 · 信息审核(Module-ADM-AUDIT) =============== */ + 'api/v1/admin/post/:id/audit' => 'api/AdminPost/audit', + 'api/v1/admin/post/:id/offline' => 'api/AdminPost/offline', + 'api/v1/admin/posts/pending' => 'api/AdminPost/pending', + + /* =============== 十二、后台 · 评价管理(Module-ADM-COMMENT) =============== */ + 'api/v1/admin/comment/:id/reply' => 'api/AdminComment/reply', + 'api/v1/admin/comments' => 'api/AdminComment/index', + 'api/v1/admin/reports' => 'api/AdminComment/reports', + + /* =============== 十三、后台 · 分销管理(Module-ADM-DIST) =============== */ + 'api/v1/admin/dist/withdraw/:id/pay' => 'api/AdminDist/handleWithdraw', + 'api/v1/admin/dist/setting' => 'api/AdminDist/setting', + 'api/v1/admin/dist/withdraws' => 'api/AdminDist/withdraws', + + /* =============== 十四、后台 · 订单管理(Module-ADM-ORDER) =============== */ + 'api/v1/admin/order/:id/refund' => 'api/AdminOrder/refund', + 'api/v1/admin/order/supplement' => 'api/AdminOrder/supplement', + 'api/v1/admin/orders' => 'api/AdminOrder/index', + + /* =============== 十五、后台 · 财务管理(Module-ADM-FINANCE) =============== */ + 'api/v1/admin/finance/flows' => 'api/AdminFinance/flows', + 'api/v1/admin/finance/report' => 'api/AdminFinance/report', + + /* =============== 十六、后台 · 权限管理(Module-ADM-RBAC) =============== */ + 'api/v1/admin/role' => 'api/AdminRbac/saveRole', + 'api/v1/admin/roles' => 'api/AdminRbac/roles', + 'api/v1/admin/accounts' => 'api/AdminRbac/accounts', + 'api/v1/admin/logs' => 'api/AdminRbac/logs', + + /* =============== 十七、后台 · 系统设置(Module-ADM-SETTING) =============== + * /setting/ui/reset 必须排在 /setting/ui 之前 + */ + 'api/v1/admin/setting/ui/reset' => 'api/AdminSetting/uiReset', + 'api/v1/admin/setting/ui' => 'api/AdminSetting/ui', + 'api/v1/admin/setting/base' => 'api/AdminSetting/base', + 'api/v1/admin/setting/pay' => 'api/AdminSetting/pay', + 'api/v1/admin/setting/fee' => 'api/AdminSetting/fee', + 'api/v1/admin/setting/areas' => 'api/AdminSetting/areas', + 'api/v1/admin/setting/area' => 'api/AdminSetting/saveArea', + 'api/v1/admin/upload' => 'api/AdminSetting/upload', ]; diff --git a/docs/05-后端实现规范.md b/docs/05-后端实现规范.md new file mode 100644 index 0000000..7bed718 --- /dev/null +++ b/docs/05-后端实现规范.md @@ -0,0 +1,134 @@ +# 同城分类信息 · 后端 v1 实现规范(编写控制器必读) + +> 框架:FastAdmin / ThinkPHP **5.0.29** / PHP **7.4** / MySQL 5.7,表前缀 `fa_` +> 控制器命名空间:`app\api\controller`;全部继承 `V1`(已完成,勿改) +> 路由表:`application/route.php`(已完成,勿改) +> 建表+种子:`application/admin/command/FcInstall.php`(已完成,勿改) + +## 一、响应契约(V1 基类已封装,禁止直接 echo/json) + +```php +// 成功 +$this->ok($data); // → {"code":0,"message":"ok","data":...} +$this->ok($data, '自定义message'); +// 业务失败(HTTP 200,前端按 code 判定 + Toast message) +$this->fail('标题不能为空'); // → {"code":1,"message":"标题不能为空","data":null} +$this->fail('信息不存在', 404); // 自定义业务码 +// 登录态失效:基类 auth() 已自动 401 +``` + +**严禁**使用 `return`、`echo`、`json()`、`$this->success()`、`$this->error()`。 +动作必须以 `$this->ok()` / `$this->fail()` 结束。 + +## 二、V1 基类可用方法(全部 protected) + +| 方法 | 签名 | 说明 | +| --- | --- | --- | +| `ok` | `ok($data = null, $message = 'ok')` | 成功响应 | +| `fail` | `fail($message, $code = 1, $httpStatus = 200)` | 失败响应 | +| `auth` | `auth()` | **强制登录**,未登录自动 401;返回 user 数组 | +| `user` | `user()` | 解析登录态,可为 null | +| `userId` | `userId()` | 当前用户 ID(内部走 auth) | +| `input` | `input($key, $default = null)` | 读入参(POST 优先,回落 GET) | +| `inputs` | `inputs()` | 全部入参 | +| `require2` | `require2(['title' => '标题'])` | 必填校验,失败自动 fail | +| `pager` | `pager($defaultSize = 10, $maxSize = 50)` | 返回 `[$page, $size]` | +| `paginate` | `paginate($list, $total, $page, $size)` | 返回 `{list,page,size,total,hasMore}` | +| `paginateArray` | `paginateArray(array $items, $page, $size)` | 内存分页 | +| `cfg` | `cfg('fee.singlePost', 5)` | 读业务配置(自动 JSON 解码) | +| `setCfg` | `setCfg('fee.singlePost', 5)` | 写业务配置 | +| `ext` | `ext($userId)` | 取/建用户扩展行 | +| `userInfo` | `userInfo($user = null)` | 组装 userInfo(字段见第四节) | +| `iso` | `iso($timestamp)` | 时间戳 → ISO8601 | +| `maskPhone` | `maskPhone($phone)` | 手机号脱敏 | +| `orderNo` | `orderNo('PO')` | 生成业务单号 | +| `jsonArr` | `jsonArr($val)` | JSON 字符串 → 数组 | +| `flow` | `flow('income','发布付费',5,2864.5,0,'备注')` | 记资金流水 | +| `notify` | `notify($userId,'system','标题','内容')` | 发站内消息 | +| `adminLog` | `adminLog('审核通过信息 #105')` | 后台操作留痕(内部已 try/catch) | +| `request` | `$this->request` | TP Request;`isPost()`、`ip()`、`header()` | + +## 三、数据表(`fa_fc_*`,用 `Db::name('fc_xxx')`) + +| 表 | 关键字段 | +| --- | --- | +| `fc_config` | key, value, group_key, remark | +| `fc_category` | id, name, icon_key, color, sort, enabled, post_count | +| `fc_banner` | id, title, image, link, color, sort, enabled | +| `fc_area` | id, name, sort, enabled | +| `fc_post` | id, user_id, category_id, title, content, cover, media, price, price_unit, area, address, contact, phone, wechat, tags(逗号分隔), is_paid, amount, pay_type, audit_status(0/1/2), audit_reason, views, likes, comment_count, status(1/0下架), is_top, distance, createtime, updatetime, deletetime | +| `fc_draft` | id, user_id, category_id, title, content, media, contact, phone, address, savedat | +| `fc_merchant` | id, user_id, name, logo, contact, category, license, audit_status, audit_reason, shop_status(normal/pending/banned), violations, post_count | +| `fc_comment` | id, post_id, user_id, score, content, images, reply, replytime, top, status | +| `fc_report` | id, target_type, target_id, reason, reporter_id, status(pending/handled), remark | +| `fc_order` | id, order_no, user_id, type(post/member/recharge), title, amount, pay_type, status(pending/paid/refunded/closed), paytime, refund_amount, refund_reason, related_id | +| `fc_plan` | id, plan_code, name, price, origin_price, days, tag, sort, enabled | +| `fc_user_ext` | user_id, openid, user_type(1/2), realname_status, is_member, member_level, member_expiretime, invite_code, inviter_id, free_view_date, free_view_used | +| `fc_dist_relation` | user_id, parent_id, level(1/2) | +| `fc_commission` | user_id, from_user_id, order_id, order_no, order_type, amount, rate, level, status(0/1) | +| `fc_withdraw` | id, withdraw_no, user_id, amount, channel, status(auditing/paid/rejected), reason, paytime | +| `fc_finance_flow` | flow_no, type(income/expense), scene, amount, balance, related_id, remark | +| `fc_message` | user_id(0=全体), type(system/interact), title, content, isread, readtime | +| `fc_pay_view` | user_id, post_id, pay_type, amount, order_id | + +用户主表:`fa_user`(`Db::name('user')`):id, username, nickname, avatar, mobile, `money`(余额), score, status。 +后台:`fa_admin`、`fa_auth_group`(角色)、`fa_admin_log`(日志)。 + +## 四、前端消费的字段(严格对齐,不得改名) + +**userInfo**(由 `userInfo($user)` 生成,直接 `$this->ok($this->userInfo())`) +``` +{ id, nickname, avatar, phone, userType, isMember, memberLevel, memberExpireAt, balance, realnameStatus } +``` + +**Post(列表/详情共用)** +``` +{ id, categoryId, categoryName, title, content, cover, price, priceUnit, + area, address, contact, views, likes, isPaid, auditStatus, createdAt, + tags: [], publisher: {id,name,avatar,isMerchant,verified}, commentCount, distance } +``` +- `contact`:**脱敏字符串**(详情页自己做 `maskPhone(res.contact)`),未解锁**不得**返回完整手机号 +- `createdAt`:必须 ISO8601(用 `$this->iso()`),前端 `fromNow()` 依赖它 +- `tags`:数组(库里是逗号分隔字符串,需 `explode(',', ...)` 并过滤空值) +- 列表过滤:`audit_status=1 AND status=1 AND deletetime IS NULL` +- `price` 用 `(float)`;`publisher.isMerchant` 依据该用户 `fc_user_ext.user_type===2` + +**分类**:`{ id, name, iconKey, icon, color, count }` +(前端 `CATEGORY_ICON_KEY` 按 id 1–8 映射图标,故种子数据 id 必须保持 1–8) + +**分页**:`{ list, page, size, total, hasMore }`(一律用 `$this->paginate()`) + +## 五、业务规则(必须实现,文案需完全一致) + +| 编号 | 规则与文案 | +| --- | --- | +| BR-01 | 商家切换必须带 license → `fail('商家用户需上传营业执照完成资质认证')` | +| BR-02 | 手机号 `^1\d{10}$` → `fail('手机号格式不正确')` | +| BR-03 | 发布必填 categoryId/title/content;`mb_strlen` 标题 ≤40、内容 ≤500 | +| BR-04 | 新发布默认 `audit_status=0`,审核通过才进信息流 | +| BR-05 | 付费查看仅 `wxpay`/`balance` → `fail('支付方式不支持')` | +| BR-06 | 评分 1–5 且内容必填 → `fail('评分需在 1-5 之间')` / `fail('评价内容不能为空')` | +| BR-07 | 会员套餐 planId 必填 → `fail('请选择会员套餐')` | +| BR-08 | 分佣合计 ≤100 → `fail('一级+二级分佣比例不能超过 100%')` | +| BR-09 | 提现 `金额>0 且 ≤ 可提现余额` → `fail('提现金额不合法')` / `fail('提现金额超过可提现余额')`;后台驳回缺 reason → `fail('驳回需填写原因')` | +| BR-10 | 退款金额 >0 → `fail('退款金额需大于 0')` | +| BR-11 | 审核仅允许 1/2 → `fail('审核状态仅支持通过(1)/驳回(2)')` | +| BR-12 | 新建角色需 name 且 permissions 非空 → `fail('请填写角色名称')` / `fail('请至少分配一项权限')` | + +其他必需文案: +- `fail('缺少微信授权 code')`(已实现于 Auth) +- `fail('用户类型不合法')`(已实现于 Auth) +- `fail('信息不存在', 404)` +- `fail('小程序名称不能为空')`、`fail('微信支付商户号不能为空')`、`fail('支付密钥不能为空')` +- `fail('单条发布价格不能为负')`、`fail('免费查看条数不能为负')` +- `fail('区域名称不能为空')`、`fail('图标 {key} 的地址必须是字符串')` + +## 六、编码约定 + +- 文件头 `adminLog('动作描述')` 留痕 +- **不要**修改 `V1.php` / `route.php` / `FcInstall.php` / `Auth.php` diff --git a/tools/verify-api.mjs b/tools/verify-api.mjs new file mode 100644 index 0000000..7409250 --- /dev/null +++ b/tools/verify-api.mjs @@ -0,0 +1,462 @@ +/** + * 同城分类信息 · 后端 v1 接口端到端验证脚本 + * + * 用法: + * node tools/verify-api.mjs # 验证线上 + * FC_BASE=http://127.0.0.1/api/v1 node tools/verify-api.mjs + * + * 覆盖: + * 1) 62 个端点契约校验(HTTP 状态 / {code,message,data} 结构 / 关键字段 / 分页结构) + * 2) 业务规则校验(12 条 BR 的错误码与文案) + * 3) 双端业务闭环:用户发布 → 后台待审可见 → 后台审核通过 → 前台信息流可见 → 付费解锁联系方式 + */ + +const BASE = process.env.FC_BASE || 'https://fenleixinxi.gzyakj.com.cn/api/v1' +const PASS = [] +const FAIL = [] + +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, '手机号格式不正确') + + 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) +} + +/* ---------------- 二、首页与展示(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] ? '' : '非数组或缺字段')) + 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 === '云岩区') ? '' : '区域筛选未生效')) +} + +/* ---------------- 三、信息发布与详情(POST/DETAIL 7,含闭环起点) ---------------- */ + +let newPostId = 0 + +async function userPost() { + const created = await ok200('P1 POST /post/create', 'POST', '/post/create', + { categoryId: 3, title: '接口验证·九成新显示器转让', content: '接口端到端验证用数据,可忽略。', + contact: '验证账号', phone: '13800001111', area: '南明区', address: '花果园', + price: 300, priceUnit: '元', tags: '验证,自提' }, + true, (d) => (d && d.postId && Number(d.auditStatus) === 0 ? '' : 'postId/auditStatus 不符(BR-04 应为 0)')) + newPostId = created && created.postId ? Number(created.postId) : 0 + + await expectFail('P1 · 标题必填 (BR-03)', 'POST', '/post/create', { content: 'x' }, true, '标题不能为空') + await expectFail('P1 · 内容必填 (BR-03)', 'POST', '/post/create', { 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')) + await ok200('M2 POST /member/open', 'POST', '/member/open', { planId: 'M1', payType: 'wxpay' }, true, + (d) => (d && d.orderNo && d.status === 'paid' && d.expireAt ? '' : '缺 orderNo/status/expireAt')) + 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() { + 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')) + await ok200('AR1 POST /admin/role', 'POST', '/admin/role', + { name: '接口验证角色', permissions: ['dashboard', 'post'], remark: '由验证脚本创建' }, 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, '支付密钥不能为空') + await ok200('AZ2 POST /admin/setting/pay', 'POST', '/admin/setting/pay', + { mchId: '1900000000', apiKey: 'verify-key' }, false, (d) => (d && d.configured === true ? '' : '缺 configured')) + 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 不符')) + await ok200('AZ4 GET /admin/setting/areas', 'GET', '/admin/setting/areas', null, false, + (d) => (Array.isArray(d) && 'enabled' in (d[0] || {}) ? '' : '缺 enabled')) + await ok200('AZ4 POST /admin/setting/area', 'POST', '/admin/setting/area', + { name: '接口验证区', sort: 99, enabled: true }, false, (d) => (d && d.name === '接口验证区' ? '' : 'name 不符')) + await expectFail('AZ4 · 区域名必填', 'POST', '/admin/setting/area', { name: '' }, 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), + '流水字段不完整') +} + +/* ---------------- 主流程 ---------------- */ + +async function main() { + console.log('接口验证开始 → ' + BASE) + console.log('') + + await userAuth() + await userHome() + await userPost() + await userRest() + await adminCore() + await adminMoney() + await adminRbacSetting() + 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) +})