diff --git a/application/admin/command/FcInstall.php b/application/admin/command/FcInstall.php index 8f0fd08..8bba16f 100644 --- a/application/admin/command/FcInstall.php +++ b/application/admin/command/FcInstall.php @@ -77,6 +77,9 @@ class FcInstall extends Command $output->writeln('==> 轮播底色与春绿主题对齐'); $this->upgradeBanners($output); + $output->writeln('==> 发布模式与发布套餐升级'); + $this->upgradePublish($output); + $output->writeln('==> 初始化后台菜单'); $this->menu($output); @@ -133,6 +136,121 @@ class FcInstall extends Command : ' - 轮播底色已是春绿渐变或非内置轮播,无需处理'); } + /** + * 发布模式与发布套餐升级(幂等,可重复执行) + * + * 背景:v1.0.7 新增「免费发布 / 付费发布」开关与付费发布的三档定价 + * · 单条价格 post.singlePrice + * · 按月(价格 + 含条数)post.monthPrice / post.monthQuota + * · 按年(价格 + 含条数)post.yearPrice / post.yearQuota + * 老库缺列/缺配置时补齐,**不覆盖运营已改过的值**: + * ① fc_user_ext 补 post_quota_left / post_quota_expire(剩余发布条数 + 有效期) + * ② fc_plan 补 post_quota(套餐含发布条数,月/年卡赠送额度) + * ③ fc_config 补 post.* 六项(已存在则跳过) + * ④ 月卡/年卡的价格与条数以配置为准做一次同步(配置缺失时反向用套餐值填配置) + */ + private function upgradePublish(Output $output) + { + $prefix = \think\Config::get('database.prefix'); + $changed = 0; + + // ① 用户扩展表:发布额度 + $extTable = $prefix . 'fc_user_ext'; + if (!$this->hasColumn($extTable, 'post_quota_left')) { + Db::execute("ALTER TABLE `{$extTable}` ADD COLUMN `post_quota_left` int(10) NOT NULL DEFAULT 0" + . " COMMENT '剩余发布条数(月/年套餐赠送)' AFTER `free_view_used`"); + $output->writeln(' - ' . $extTable . ' 新增列 post_quota_left'); + $changed++; + } + if (!$this->hasColumn($extTable, 'post_quota_expire')) { + Db::execute("ALTER TABLE `{$extTable}` ADD COLUMN `post_quota_expire` int(10) NULL" + . " COMMENT '发布额度有效期(=会员到期时间)' AFTER `post_quota_left`"); + $output->writeln(' - ' . $extTable . ' 新增列 post_quota_expire'); + $changed++; + } + + // ② 会员套餐:含发布条数 + $planTable = $prefix . 'fc_plan'; + if (!$this->hasColumn($planTable, 'post_quota')) { + Db::execute("ALTER TABLE `{$planTable}` ADD COLUMN `post_quota` int(10) NOT NULL DEFAULT 0" + . " COMMENT '含发布条数(月/年套餐赠送额度)' AFTER `days`"); + $output->writeln(' - ' . $planTable . ' 新增列 post_quota'); + $changed++; + } + + // ③ 配置项(只补缺失的,不覆盖运营已改的值) + $defaults = [ + ['post.mode', 'free', 'post', '发布模式:free 免费发布 / paid 付费发布'], + ['post.singlePrice', '5', 'post', '单条发布价格(元)'], + ['post.monthPrice', '19.9', 'post', '按月发布价格(元)'], + ['post.monthQuota', '30', 'post', '按月含发布条数'], + ['post.yearPrice', '168', 'post', '按年发布价格(元)'], + ['post.yearQuota', '500', 'post', '按年含发布条数'], + ]; + $now = time(); + $added = []; + foreach ($defaults as $d) { + if (Db::name('fc_config')->where('key', $d[0])->find()) { + continue; + } + Db::name('fc_config')->insert([ + 'key' => $d[0], 'value' => $d[1], 'group_key' => $d[2], 'remark' => $d[3], + 'createtime' => $now, 'updatetime' => $now, + ]); + $added[] = $d[0]; + } + if ($added) { + $output->writeln(' - 新增配置 ' . count($added) . ' 项:' . implode(' / ', $added)); + $changed++; + } + + // ④ 月/年卡「价格 + 含发布条数」与配置对齐 + $map = ['M1' => ['post.monthPrice', 'post.monthQuota'], 'M12' => ['post.yearPrice', 'post.yearQuota']]; + $synced = 0; + foreach ($map as $code => $keys) { + $plan = Db::name('fc_plan')->where('plan_code', $code)->find(); + if (!$plan) { + continue; + } + $price = round((float)$this->cfgValue($keys[0], $plan['price']), 2); + $quota = (int)$this->cfgValue($keys[1], $plan['post_quota']); + if (round((float)$plan['price'], 2) !== $price || (int)$plan['post_quota'] !== $quota) { + Db::name('fc_plan')->where('id', $plan['id'])->update([ + 'price' => $price, + 'post_quota' => $quota, + 'updatetime' => time(), + ]); + $synced++; + } + } + if ($synced) { + $output->writeln(' - 会员套餐已与发布配置对齐 ' . $synced . ' 条(月/年卡价格与含条数)'); + $changed++; + } + + $output->writeln($changed + ? ' - 完成(发布模式 ' . $this->cfgValue('post.mode', 'free') . ')' + : ' - 发布模式与套餐配置已是最新,无需处理'); + } + + /** 表是否存在某列 */ + private function hasColumn($table, $column) + { + $cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '" . addslashes($column) . "'"); + return !empty($cols); + } + + /** 读配置默认值(不依赖 api 基类的 cfg 缓存) */ + private function cfgValue($key, $default = null) + { + $row = Db::name('fc_config')->where('key', $key)->find(); + if (!$row || $row['value'] === '' || $row['value'] === null) { + return $default; + } + return $row['value']; + } + + /** * 初始化 FastAdmin 后台菜单(幂等) * @@ -211,6 +329,7 @@ class FcInstall extends Command 'fc/distrelation' => ['fc/dist', '分销关系', 'fa fa-sitemap', 95], 'fc/withdraw' => ['fc/dist', '提现审核', 'fa fa-credit-card', 90], 'fc/area' => ['fc/base', '服务区域', 'fa fa-map-o', 95], + 'fc/publish' => ['fc/base', '发布与费用配置', 'fa fa-cny', 85], 'fc/draft' => ['fc/base', '草稿箱', 'fa fa-folder-o', 90], ]; $done = 0; @@ -422,6 +541,7 @@ class FcInstall extends Command `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', + `post_quota` int(10) NOT NULL DEFAULT '0' COMMENT '含发布条数(月/年套餐赠送额度)', `tag` varchar(16) NOT NULL DEFAULT '', `sort` int(10) NOT NULL DEFAULT '0', `enabled` tinyint(1) NOT NULL DEFAULT '1', @@ -444,6 +564,8 @@ class FcInstall extends Command `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', + `post_quota_left` int(10) NOT NULL DEFAULT '0' COMMENT '剩余发布条数(月/年套餐赠送)', + `post_quota_expire` int(10) DEFAULT NULL COMMENT '发布额度有效期(=会员到期时间)', `createtime` int(10) DEFAULT NULL, `updatetime` int(10) DEFAULT NULL, PRIMARY KEY (`id`), @@ -565,6 +687,13 @@ class FcInstall extends Command ['fee.memberMonth', '19.9', 'fee', '月卡价格'], ['fee.memberQuarter', '49.9', 'fee', '季卡价格'], ['fee.memberYear', '168', 'fee', '年卡价格'], + // 发布模式与发布套餐(后台「发布与费用配置」页维护;真源=配置,保存时同步 fc_plan) + ['post.mode', 'free', 'post', '发布模式:free 免费发布 / paid 付费发布(默认免费,避免上线即撞付费墙)'], + ['post.singlePrice', '5', 'post', '单条发布价格(元)'], + ['post.monthPrice', '19.9', 'post', '按月发布价格(元)'], + ['post.monthQuota', '30', 'post', '按月含发布条数'], + ['post.yearPrice', '168', 'post', '按年发布价格(元)'], + ['post.yearQuota', '500', 'post', '按年含发布条数'], ['dist.enabled', '1', 'dist', '是否开启分销'], ['dist.level', '2', 'dist', '分佣层级'], ['dist.level1Rate', '20', 'dist', '一级分佣比例%'], @@ -629,14 +758,15 @@ class FcInstall extends Command /* ---------- 会员套餐 ---------- */ $plans = [ - ['M1', '月卡', 19.9, 29.9, 30, '尝鲜'], - ['M3', '季卡', 49.9, 89.7, 90, '最划算'], - ['M12', '年卡', 168, 358.8, 365, '省心'], + ['M1', '月卡', 19.9, 29.9, 30, '尝鲜', 30], + ['M3', '季卡', 49.9, 89.7, 90, '最划算', 90], + ['M12', '年卡', 168, 358.8, 365, '省心', 500], ]; 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, + 'post_quota' => $p[6], 'createtime' => $now, 'updatetime' => $now, ]); } diff --git a/application/admin/controller/fc/Post.php b/application/admin/controller/fc/Post.php index 1dc86c1..a899661 100644 --- a/application/admin/controller/fc/Post.php +++ b/application/admin/controller/fc/Post.php @@ -78,6 +78,46 @@ class Post extends Backend $this->success('审核成功'); } + /** + * 置顶 / 取消置顶(支持批量) + * POST fc/post/top {ids:'1,2', isTop:1|0} + * 说明:置顶信息在用户端主页与列表**排在非置顶之前**(Home::feed / Post::mine 均按 is_top desc 排序) + */ + public function top() + { + $ids = $this->request->post('ids'); + $isTop = (int)$this->request->post('isTop', 1); + if (!in_array($isTop, [0, 1], true)) { + $this->error('置顶状态仅支持置顶(1)/取消置顶(0)'); + } + $ids = is_array($ids) ? $ids : explode(',', (string)$ids); + $ids = array_filter($ids); + if (!$ids) { + $this->error('请选择要操作的信息'); + } + $posts = Db::name('fc_post')->where('id', 'in', $ids)->select(); + if (!$posts) { + $this->error('信息不存在'); + } + foreach ($posts as $post) { + Db::name('fc_post')->where('id', $post['id'])->update([ + 'is_top' => $isTop, + 'updatetime' => time(), + ]); + Db::name('fc_message')->insert([ + 'user_id' => $post['user_id'], + 'type' => 'system', + 'title' => $isTop === 1 ? '信息已置顶' : '已取消置顶', + 'content' => $isTop === 1 + ? '您发布的信息「' . $post['title'] . '」已被置顶,将在首页优先展示' + : '您发布的信息「' . $post['title'] . '」已取消置顶', + 'isread' => 0, + 'createtime' => time(), + ]); + } + $this->success($isTop === 1 ? '置顶成功' : '已取消置顶'); + } + /** * 下架信息 */ diff --git a/application/admin/controller/fc/Publish.php b/application/admin/controller/fc/Publish.php new file mode 100644 index 0000000..9ea7790 --- /dev/null +++ b/application/admin/controller/fc/Publish.php @@ -0,0 +1,139 @@ +request->isPost()) { + return $this->save(); + } + + $this->view->assign('row', $this->current()); + $this->view->assign('plans', Db::name('fc_plan')->order('sort asc')->select()); + return $this->view->fetch(); + } + + /** + * 保存配置并同步会员套餐 + */ + protected function save() + { + $params = $this->request->post('row/a'); + if (!is_array($params)) { + $params = $this->request->post(); + } + + $mode = isset($params['mode']) ? trim((string)$params['mode']) : 'paid'; + if (!in_array($mode, self::MODES, true)) { + $this->error('发布模式只支持「免费发布」或「付费发布」'); + } + + $num = function ($key, $default) use ($params) { + if (!isset($params[$key]) || $params[$key] === '') { + return $default; + } + return round((float)$params[$key], 2); + }; + $int = function ($key, $default) use ($params) { + if (!isset($params[$key]) || $params[$key] === '') { + return $default; + } + return (int)$params[$key]; + }; + + $single = $num('singlePrice', 5); + $monthPrice = $num('monthPrice', 19.9); + $yearPrice = $num('yearPrice', 168); + $monthQuota = $int('monthQuota', 30); + $yearQuota = $int('yearQuota', 500); + + foreach (['单条发布价格' => $single, '按月发布价格' => $monthPrice, '按年发布价格' => $yearPrice] as $label => $v) { + if ($v < 0) { + $this->error($label . '不能为负'); + } + } + if ($monthQuota < 0 || $yearQuota < 0) { + $this->error('含发布条数不能为负'); + } + + $now = time(); + $values = [ + 'post.mode' => $mode, + 'post.singlePrice' => $single, + 'post.monthPrice' => $monthPrice, + 'post.monthQuota' => $monthQuota, + 'post.yearPrice' => $yearPrice, + 'post.yearQuota' => $yearQuota, + ]; + foreach ($values as $key => $val) { + $exist = Db::name('fc_config')->where('key', $key)->find(); + if ($exist) { + Db::name('fc_config')->where('key', $key)->update(['value' => (string)$val, 'updatetime' => $now]); + } else { + Db::name('fc_config')->insert([ + 'key' => $key, 'value' => (string)$val, 'group_key' => 'post', + 'remark' => '发布与费用配置', 'createtime' => $now, 'updatetime' => $now, + ]); + } + } + + // 同步月/年卡(价格 + 含发布条数);季卡价格按「月卡 ×3」的默认口径不动 + Db::name('fc_plan')->where('plan_code', 'M1')->update([ + 'price' => $monthPrice, 'post_quota' => $monthQuota, 'updatetime' => $now, + ]); + Db::name('fc_plan')->where('plan_code', 'M12')->update([ + 'price' => $yearPrice, 'post_quota' => $yearQuota, 'updatetime' => $now, + ]); + + $this->success('发布配置已保存并同步会员套餐'); + } + + /** 当前配置(缺失时回落 fee.* 与内置默认) */ + protected function current() + { + $cfg = []; + foreach (Db::name('fc_config')->select() as $r) { + $cfg[$r['key']] = $r['value']; + } + $pick = function ($key, $default) use ($cfg) { + return (isset($cfg[$key]) && $cfg[$key] !== '') ? $cfg[$key] : $default; + }; + $mode = (string)$pick('post.mode', 'paid'); + + return [ + 'mode' => in_array($mode, self::MODES, true) ? $mode : 'paid', + 'singlePrice' => $pick('post.singlePrice', $pick('fee.singlePost', '5')), + 'monthPrice' => $pick('post.monthPrice', $pick('fee.memberMonth', '19.9')), + 'monthQuota' => $pick('post.monthQuota', '30'), + 'yearPrice' => $pick('post.yearPrice', $pick('fee.memberYear', '168')), + 'yearQuota' => $pick('post.yearQuota', '500'), + ]; + } +} diff --git a/application/admin/lang/zh-cn/fc/post.php b/application/admin/lang/zh-cn/fc/post.php index 7cb251b..8a8be1b 100644 --- a/application/admin/lang/zh-cn/fc/post.php +++ b/application/admin/lang/zh-cn/fc/post.php @@ -32,5 +32,11 @@ return [ 'Deletetime' => '删除时间', 'AuditPass' => '审核通过', 'AuditReject' => '审核驳回', - 'OfflinePost' => '下架' + 'OfflinePost' => '下架', + 'TopPost' => '置顶', + 'CancelTopPost' => '取消置顶', + '已置顶' => '已置顶', + '未置顶' => '未置顶', + '确认置顶选中的信息?' => '确认置顶选中的信息?置顶后将在用户端首页优先展示。', + '确认取消置顶选中的信息?' => '确认取消置顶选中的信息?', ]; diff --git a/application/admin/lang/zh-cn/fc/publish.php b/application/admin/lang/zh-cn/fc/publish.php new file mode 100644 index 0000000..edc50f1 --- /dev/null +++ b/application/admin/lang/zh-cn/fc/publish.php @@ -0,0 +1,39 @@ + '说明', + '发布模式' => '发布模式', + '免费发布(所有用户均可免费发布)' => '免费发布(所有用户均可免费发布)', + '付费发布(按下面价格计费,套餐额度内免费)' => '付费发布(按下面价格计费,套餐额度内免费)', + '会员单条发布价格' => '会员单条发布价格', + '会员按月发布' => '会员按月发布', + '会员按年发布' => '会员按年发布', + '元/条' => '元/条', + '元/月' => '元/月', + '元/年' => '元/年', + '条/月' => '条/月', + '条/年' => '条/年', + '保存' => '保存', + '是否' => '是否', + '是' => '是', + '否' => '否', + '当前会员套餐' => '当前会员套餐', + '套餐编码' => '套餐编码', + '套餐名称' => '套餐名称', + '售价' => '售价', + '有效天数' => '有效天数', + '含发布条数' => '含发布条数', + '是否启用' => '是否启用', + '本页是「发布模式 + 发布价格/条数」的唯一入口;保存后会同步「会员套餐」的月/年卡价格与含发布条数,因此用户端发布页、会员中心与后端计费口径一致。' + => '本页是「发布模式 + 发布价格/条数」的唯一入口;保存后会同步「会员套餐」的月/年卡价格与含发布条数,因此用户端发布页、会员中心与后端计费口径一致。', + '「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。' + => '「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。', + '未开通套餐的用户按此价格付费发布(金额由服务端计算,前端不可传价)' + => '未开通套餐的用户按此价格付费发布(金额由服务端计算,前端不可传价)', + '价格与条数会同步到「会员套餐 → 月卡(M1)」;购买后立即赠送该条数的发布额度' + => '价格与条数会同步到「会员套餐 → 月卡(M1)」;购买后立即赠送该条数的发布额度', + '价格与条数会同步到「会员套餐 → 年卡(M12)」;购买后立即赠送该条数的发布额度' + => '价格与条数会同步到「会员套餐 → 年卡(M12)」;购买后立即赠送该条数的发布额度', + '(月/年卡的价格与含发布条数由本页同步,其余字段在「交易管理 → 会员套餐」维护)' + => '(月/年卡的价格与含发布条数由本页同步,其余字段在「交易管理 → 会员套餐」维护)', +]; diff --git a/application/admin/view/fc/post/index.html b/application/admin/view/fc/post/index.html index 5506276..7cedb78 100644 --- a/application/admin/view/fc/post/index.html +++ b/application/admin/view/fc/post/index.html @@ -13,6 +13,8 @@ {:__('AuditPass')} {:__('AuditReject')} {:__('OfflinePost')} + {:__('TopPost')} + {:__('CancelTopPost')} diff --git a/application/admin/view/fc/publish/index.html b/application/admin/view/fc/publish/index.html new file mode 100644 index 0000000..d5bff6e --- /dev/null +++ b/application/admin/view/fc/publish/index.html @@ -0,0 +1,118 @@ +
+ {:build_heading()} + +
+
+ {:__('说明')}: + {:__('本页是「发布模式 + 发布价格/条数」的唯一入口;保存后会同步「会员套餐」的月/年卡价格与含发布条数,因此用户端发布页、会员中心与后端计费口径一致。')} +
+ {:__('「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。')} +
+ +
+
+ {:token()} + + + + + + + + + + + + + + + + + + + +
{:__('发布模式')} + + +
{:__('会员单条发布价格')} +
+ ¥ + + {:__('元/条')} +
+ {:__('未开通套餐的用户按此价格付费发布(金额由服务端计算,前端不可传价)')} +
{:__('会员按月发布')} +
+ ¥ + + {:__('元/月')} +
+
+ + {:__('条/月')} +
+ {:__('价格与条数会同步到「会员套餐 → 月卡(M1)」;购买后立即赠送该条数的发布额度')} +
{:__('会员按年发布')} +
+ ¥ + + {:__('元/年')} +
+
+ + {:__('条/年')} +
+ {:__('价格与条数会同步到「会员套餐 → 年卡(M12)」;购买后立即赠送该条数的发布额度')} +
+ +
+
+ +
+
+
+
+ +
+
+ {:__('当前会员套餐')} + {:__('(月/年卡的价格与含发布条数由本页同步,其余字段在「交易管理 → 会员套餐」维护)')} +
+ + + + + + + + + + + + + {foreach $plans as $p} + + + + + + + + + {/foreach} + +
{:__('套餐编码')}{:__('套餐名称')}{:__('售价')}{:__('有效天数')}{:__('含发布条数')}{:__('是否启用')}
{$p.plan_code}{$p.name}¥{$p.price}{$p.days}{$p.post_quota}{if $p.enabled}{:__('是')}{else/}{:__('否')}{/if}
+
+
+
diff --git a/application/api/controller/Home.php b/application/api/controller/Home.php index b198718..5961bed 100644 --- a/application/api/controller/Home.php +++ b/application/api/controller/Home.php @@ -110,11 +110,12 @@ class Home extends V1 $query->where('area', $area); } if ($sort === 'price') { - $query->order('price asc'); + $query->order('is_top desc, price asc'); } elseif ($sort === 'hot') { - $query->order('views desc'); + $query->order('is_top desc, views desc'); } else { - $query->order('createtime desc'); + // 后台「置顶」优先展示,其余按发布时间倒序 + $query->order('is_top desc, createtime desc'); } $counter = Db::name('fc_post') @@ -198,6 +199,7 @@ class Home extends V1 'views' => (int)$row['views'], 'likes' => (int)$row['likes'], 'isPaid' => (int)$row['is_paid'] === 1, + 'isTop' => (int)$row['is_top'] === 1, 'auditStatus' => (int)$row['audit_status'], 'createdAt' => $this->iso($row['createtime']), 'tags' => $tags, diff --git a/application/api/controller/Member.php b/application/api/controller/Member.php index b2f5c0a..10e5fb0 100644 --- a/application/api/controller/Member.php +++ b/application/api/controller/Member.php @@ -36,6 +36,8 @@ class Member extends V1 'originPrice' => (float)$row['origin_price'], 'days' => (int)$row['days'], 'tag' => (string)$row['tag'], + // 套餐含发布条数(后台「发布与费用配置」同步;0 表示不含发布额度) + 'postQuota' => isset($row['post_quota']) ? (int)$row['post_quota'] : 0, ]; } $this->ok($list); @@ -135,19 +137,33 @@ class Member extends V1 } $expire = $base + (int)$plan['days'] * 86400; + // 发布套餐额度:月/年卡赠送「含发布条数」,额度随会员有效期(过期后由发布接口自动清零) + $quota = isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0; + $left = (int)(isset($ext['post_quota_left']) ? $ext['post_quota_left'] : 0); + $quotaExpire = (int)(isset($ext['post_quota_expire']) ? $ext['post_quota_expire'] : 0); + if ($quotaExpire > 0 && $quotaExpire < $now) { + $left = 0; // 上一周期额度已过期,不再累计 + } + $left += $quota; + Db::name('fc_user_ext')->where('user_id', $user['id'])->update([ 'is_member' => 1, 'member_level' => (string)$plan['name'], 'member_expiretime' => $expire, + 'post_quota_left' => $left, + 'post_quota_expire' => $expire, 'updatetime' => $now, ]); $this->flow('income', '会员开通', $amount, 0, $orderId); $this->ok([ - 'orderNo' => $orderNo, - 'status' => 'paid', - 'expireAt' => $this->iso($expire), + 'orderNo' => $orderNo, + 'status' => 'paid', + 'expireAt' => $this->iso($expire), + 'postQuota' => $quota, + 'quotaLeft' => $left, + 'quotaExpireAt' => $this->iso($expire), ]); } diff --git a/application/api/controller/Post.php b/application/api/controller/Post.php index c614cc1..71120e4 100644 --- a/application/api/controller/Post.php +++ b/application/api/controller/Post.php @@ -7,13 +7,20 @@ use think\Db; /** * 用户端 · 信息发布与详情(Module-POST) * 路由: - * POST /api/v1/post/create P1 发布信息 + * POST /api/v1/post/create P1 发布信息(服务端定价) + * GET /api/v1/post/mine P8 我的发布(含审核状态与计数) + * GET /api/v1/post/publish-config P9 发布配置(发布模式/价格/我的剩余额度) * GET /api/v1/post/drafts P2 草稿列表 * POST /api/v1/post/draft P3 保存草稿 * DELETE /api/v1/post/:id P4 删除信息 * GET /api/v1/post/:id P5 信息详情 * POST /api/v1/post/:id/pay-view P6 付费查看联系方式 * POST /api/v1/post/:id/comment P7 发表评价 + * + * 计费口径(后台「发布与费用配置」维护,真源=fc_config); + * 金额一律由服务端按配置计算,**不接受客户端传入金额**; + * 月卡/年卡(含发布条数)的购买复用 POST /member/open,本控制器只处理 + * 「套餐额度内免费发布」与「单条付费发布」两种。 */ class Post extends V1 { @@ -53,23 +60,56 @@ class Post extends V1 $this->fail('手机号格式不正确'); } - $paid = (bool)$this->input('paid', false); - $payType = (string)$this->input('payType', ''); + // ---- 发布计费:金额一律由服务端按后台配置计算,不信任客户端传值 ---- + $rule = $this->publishRule(); + $plan = trim((string)$this->input('plan', '')); + if ($plan === '' && (bool)$this->input('paid', false)) { + // 兼容 v1.0.6 及更早客户端:paid=true 视为单条付费(金额仍由服务端决定) + $plan = 'single'; + } + if ($plan === '') { + $plan = 'free'; + } + if (!in_array($plan, ['free', 'single'], true)) { + // 月卡/年卡走会员接口开通(POST /member/open),拿到额度后再以 free 发布 + $this->fail('发布方式不支持,请先开通会员套餐'); + } + + $ext = $this->ext($user['id']); + $quotaLeft = $this->quotaLeft($ext); + $amount = 0; - if ($paid) { - if (!in_array($payType, ['wxpay', 'balance'], true)) { - $this->fail('支付方式不支持'); - } - $amount = $this->input('amount', null); - if ($amount === null || $amount === '') { - $amount = $this->cfg('fee.singlePost', 5); - } - $amount = round((float)$amount, 2); - // 余额校验须在落库之前:否则余额不足时会残留一条 is_paid=1 的无效发布 - if ($payType === 'balance' && (float)$user['money'] < $amount) { - $this->fail('余额不足'); + $payType = ''; + $useQuota = false; // 套餐额度内免费 + $useFreeQuota = false; // 每日免费额度内免费 + if ($rule['mode'] === 'paid') { + if ($plan === 'free') { + if ($quotaLeft > 0) { + $useQuota = true; // 套餐额度内免费发布 + } else { + // 每日免费额度(fee.freePostPerDay,0 = 不提供)内同样允许免费发布, + // 避免「付费发布」一开启就变成硬付费墙 + $freePerDay = max(0, (int)$this->cfg('fee.freePostPerDay', 0)); + $usedToday = $freePerDay > 0 ? $this->todayPostCount($user['id']) : 0; + if ($freePerDay > 0 && $usedToday < $freePerDay) { + $useFreeQuota = true; + } else { + $this->fail('请选择发布方式'); + } + } + } else { + $amount = $rule['singlePrice']; + $payType = (string)$this->input('payType', ''); + if ($amount > 0 && !in_array($payType, ['wxpay', 'balance'], true)) { + $this->fail('支付方式不支持'); + } + // 余额校验须在落库之前:否则余额不足时会残留一条 is_paid=1 的无效发布 + if ($amount > 0 && $payType === 'balance' && (float)$user['money'] < $amount) { + $this->fail('余额不足'); + } } } + $paid = $amount > 0; $now = time(); $postId = Db::name('fc_post')->insertGetId([ @@ -136,13 +176,187 @@ class Post extends V1 ]; } + // 套餐额度内免费发布:落库成功后扣减 1 条额度(先发布后扣,发布失败不白扣) + if ($useQuota) { + $quotaLeft = max(0, $quotaLeft - 1); + Db::name('fc_user_ext')->where('user_id', $user['id'])->update([ + 'post_quota_left' => $quotaLeft, + 'updatetime' => $now, + ]); + } + $this->ok([ 'postId' => $postId, 'auditStatus' => 0, 'order' => $order, + 'plan' => $rule['mode'] === 'free' ? 'free' : ($useQuota ? 'quota' : ($useFreeQuota ? 'free' : 'single')), + 'amount' => $amount, + 'quotaLeft' => $rule['mode'] === 'paid' ? $quotaLeft : 0, ]); } + /** + * P8 我的发布 + * GET /api/v1/post/mine {status?,page,size} + * → {list,page,size,total,hasMore,counts} + * status: all(默认) / pending 待审核 / passed 已通过 / rejected 已驳回 / offline 已下架 + * 规则:仅本人(过滤软删);置顶优先、其次按发布时间倒序;counts 为各状态条数 + */ + public function mine() + { + $user = $this->auth(); + list($page, $size) = $this->pager(); + + $status = (string)$this->input('status', 'all'); + + /** 组合状态筛选(all 不加条件) */ + $apply = function ($query, $status) { + if ($status === 'pending') { + $query->where('audit_status', 0)->where('status', 1); + } elseif ($status === 'passed') { + $query->where('audit_status', 1)->where('status', 1); + } elseif ($status === 'rejected') { + $query->where('audit_status', 2); + } elseif ($status === 'offline') { + $query->where('status', 0); + } + return $query; + }; + $base = function () use ($user) { + return Db::name('fc_post')->where('user_id', $user['id'])->whereNull('deletetime'); + }; + + $total = $apply($base(), $status)->count(); + $rows = $apply($base(), $status)->order('is_top desc, createtime desc') + ->page($page, $size)->select(); + + $list = []; + foreach ($rows as $row) { + $cat = Db::name('fc_category')->where('id', $row['category_id'])->find(); + $list[] = [ + 'id' => (int)$row['id'], + 'title' => (string)$row['title'], + 'content' => (string)$row['content'], + 'categoryId' => (int)$row['category_id'], + 'categoryName' => $cat ? (string)$cat['name'] : '', + 'cover' => (string)$row['cover'], + 'price' => (float)$row['price'], + 'priceUnit' => (string)$row['price_unit'], + 'area' => (string)$row['area'], + 'address' => (string)$row['address'], + 'auditStatus' => (int)$row['audit_status'], + 'auditReason' => (string)$row['audit_reason'], + 'status' => (int)$row['status'], + 'isTop' => (int)$row['is_top'] === 1, + 'isPaid' => (int)$row['is_paid'] === 1, + 'amount' => (float)$row['amount'], + 'views' => (int)$row['views'], + 'likes' => (int)$row['likes'], + 'commentCount' => (int)$row['comment_count'], + 'createdAt' => $this->iso($row['createtime']), + ]; + } + + $counts = [ + 'all' => (int)$base()->count(), + 'pending' => (int)$apply($base(), 'pending')->count(), + 'passed' => (int)$apply($base(), 'passed')->count(), + 'rejected' => (int)$apply($base(), 'rejected')->count(), + 'offline' => (int)$apply($base(), 'offline')->count(), + ]; + + $this->ok(array_merge($this->paginate($list, $total, $page, $size), ['counts' => $counts])); + } + + /** + * P9 发布配置 + * GET /api/v1/post/publish-config + * → {mode,singlePrice,monthPrice,monthQuota,yearPrice,yearQuota, + * quotaLeft,quotaExpireAt,isMember,memberExpireAt} + * 用途:发布页按「免费/付费」呈现并显示套餐价格与剩余额度(价格由后台配置,前端不再写死) + */ + public function publishConfig() + { + $user = $this->auth(); + $rule = $this->publishRule(); + $ext = $this->ext($user['id']); + $now = time(); + + $quotaExpire = (int)$ext['post_quota_expire']; + $memberExpire = (int)$ext['member_expiretime']; + + // 每日免费额度(付费模式下仍保留的免费次数) + $freePerDay = max(0, (int)$this->cfg('fee.freePostPerDay', 0)); + $usedToday = $freePerDay > 0 ? $this->todayPostCount($user['id']) : 0; + + $this->ok([ + 'mode' => $rule['mode'], + 'singlePrice' => $rule['singlePrice'], + 'monthPrice' => $rule['monthPrice'], + 'monthQuota' => $rule['monthQuota'], + 'yearPrice' => $rule['yearPrice'], + 'yearQuota' => $rule['yearQuota'], + 'quotaLeft' => $this->quotaLeft($ext), + 'freePerDay' => $freePerDay, + 'freeUsedToday' => $usedToday, + 'freeLeftToday' => max(0, $freePerDay - $usedToday), + 'quotaExpireAt' => $quotaExpire > 0 ? $this->iso($quotaExpire) : '', + 'isMember' => (int)$ext['is_member'] === 1 && ($memberExpire === 0 || $memberExpire > $now), + 'memberExpireAt' => $memberExpire > 0 ? $this->iso($memberExpire) : '', + ]); + } + + /** + * 发布规则(后台「发布与费用配置」维护;配置缺失时回落旧 fee.* 再回落内置默认) + * @return array + */ + protected function publishRule() + { + $mode = (string)$this->cfg('post.mode', 'paid'); + if (!in_array($mode, ['free', 'paid'], true)) { + $mode = 'paid'; + } + return [ + 'mode' => $mode, + 'singlePrice' => round((float)$this->cfg('post.singlePrice', $this->cfg('fee.singlePost', 5)), 2), + 'monthPrice' => round((float)$this->cfg('post.monthPrice', $this->cfg('fee.memberMonth', 19.9)), 2), + 'monthQuota' => max(0, (int)$this->cfg('post.monthQuota', 30)), + 'yearPrice' => round((float)$this->cfg('post.yearPrice', $this->cfg('fee.memberYear', 168)), 2), + 'yearQuota' => max(0, (int)$this->cfg('post.yearQuota', 500)), + ]; + } + + /** + * 今日已发布条数(每日免费额度判定用,不含软删) + * @param int $userId + * @return int + */ + protected function todayPostCount($userId) + { + $start = strtotime(date('Y-m-d') . ' 00:00:00'); + return (int)Db::name('fc_post')->where('user_id', $userId) + ->where('createtime', '>=', $start)->whereNull('deletetime')->count(); + } + + /** + * 剩余发布条数(超出有效期视为 0,并顺手把过期额度清零) + * @param array $ext fc_user_ext 行 + * @return int + */ + protected function quotaLeft($ext) + { + $left = (int)(isset($ext['post_quota_left']) ? $ext['post_quota_left'] : 0); + $expire = (int)(isset($ext['post_quota_expire']) ? $ext['post_quota_expire'] : 0); + if ($left > 0 && $expire > 0 && $expire < time()) { + Db::name('fc_user_ext')->where('user_id', $ext['user_id'])->update([ + 'post_quota_left' => 0, + 'updatetime' => time(), + ]); + return 0; + } + return $left; + } + /** * P2 草稿列表 * GET /api/v1/post/drafts → [{draftId,title,content,categoryId,savedAt}] diff --git a/public/assets/js/backend/fc/post.js b/public/assets/js/backend/fc/post.js index 6b92b5e..72464d1 100644 --- a/public/assets/js/backend/fc/post.js +++ b/public/assets/js/backend/fc/post.js @@ -87,14 +87,46 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'is_top', title: __('Is_top'), searchList: {0: __('否'), 1: __('是')}, formatter: function (value) { - var m = {0: __('否'), 1: __('是')}; - return m[value] !== undefined ? m[value] : '-'; + return Number(value) === 1 + ? '' + __('已置顶') + '' + : '' + __('未置顶') + ''; } }, {field: 'distance', title: __('Distance')}, {field: 'updatetime', title: __('Updatetime'), operate:'RANGE', addclass:'datetimerange', autocomplete:false, formatter: Table.api.formatter.datetime, visible: false}, {field: 'deletetime', title: __('Deletetime'), visible: false}, - {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate, + buttons: [ + { + name: 'top', + text: __('TopPost'), + title: __('TopPost'), + classname: 'btn btn-xs btn-info btn-ajax', + icon: 'fa fa-arrow-up', + url: 'fc/post/top', + data: {isTop: 1}, + visible: function (row) { + return Number(row.is_top) !== 1; + }, + confirm: __('确认置顶选中的信息?'), + refresh: true + }, + { + name: 'untop', + text: __('CancelTopPost'), + title: __('CancelTopPost'), + classname: 'btn btn-xs btn-default btn-ajax', + icon: 'fa fa-arrow-down', + url: 'fc/post/top', + data: {isTop: 0}, + visible: function (row) { + return Number(row.is_top) === 1; + }, + confirm: __('确认取消置顶选中的信息?'), + refresh: true + } + ] + } ] ] }); @@ -147,6 +179,36 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); }); + // 置顶(选中项在用户端首页优先展示) + $(document).on('click', '.btn-top', function () { + var ids = Table.api.selectedids(table); + Layer.confirm(__('确认置顶选中的信息?'), {icon: 3, title: __('提示')}, function (index) { + Fast.api.ajax({ + url: 'fc/post/top', + data: {ids: ids.join(','), isTop: 1} + }, function (data) { + table.bootstrapTable('refresh'); + return false; + }); + Layer.close(index); + }); + }); + + // 取消置顶 + $(document).on('click', '.btn-untop', function () { + var ids = Table.api.selectedids(table); + Layer.confirm(__('确认取消置顶选中的信息?'), {icon: 3, title: __('提示')}, function (index) { + Fast.api.ajax({ + url: 'fc/post/top', + data: {ids: ids.join(','), isTop: 0} + }, function (data) { + table.bootstrapTable('refresh'); + return false; + }); + Layer.close(index); + }); + }); + }, recyclebin: function () { // 初始化表格参数配置 diff --git a/public/assets/js/backend/fc/publish.js b/public/assets/js/backend/fc/publish.js new file mode 100644 index 0000000..ec68949 --- /dev/null +++ b/public/assets/js/backend/fc/publish.js @@ -0,0 +1,10 @@ +define(['jquery', 'bootstrap', 'backend', 'form'], function ($, undefined, Backend, Form) { + + var Controller = { + index: function () { + // 发布与费用配置:表单校验 + 提交(保存成功后提示并由 FastAdmin 统一处理) + Form.api.bindevent($("form[role=form]")); + } + }; + return Controller; +}); diff --git a/tools/verify-api.mjs b/tools/verify-api.mjs index e922a07..3990242 100644 --- a/tools/verify-api.mjs +++ b/tools/verify-api.mjs @@ -145,12 +145,44 @@ async function userHome() { 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 + // P9 发布配置:发布模式(免费/付费)+ 单条与月/年卡价格条数 + 我的额度(v1.0.7) + const pub = await ok200('P9 GET /post/publish-config', 'GET', '/post/publish-config', null, true, + (d) => (d && ['free', 'paid'].indexOf(d.mode) > -1 && typeof d.singlePrice === 'number' + && typeof d.monthPrice === 'number' && typeof d.monthQuota === 'number' + && typeof d.yearPrice === 'number' && typeof d.yearQuota === 'number' + && typeof d.quotaLeft === 'number' ? '' : '缺 mode/价格/含条数/额度字段')) + + // 能否免费发布:免费模式 / 有套餐额度 / 今日免费额度未用完 + const canFreePost = !!pub && (pub.mode === 'free' + || Number(pub.quotaLeft) > 0 + || (Number(pub.freePerDay) - Number(pub.freeUsedToday)) > 0) + + if (canFreePost) { + const created = await ok200('P1 POST /post/create', 'POST', '/post/create', + { categoryId: 3, title: '接口验证·九成新显示器转让', content: '接口端到端验证用数据,可忽略。', + contact: '验证账号', phone: '13800001111', area: '南明区', address: '花果园', + price: 300, priceUnit: '元', tags: '验证,自提', plan: 'free' }, + true, (d) => (d && d.postId && Number(d.auditStatus) === 0 ? '' : 'postId/auditStatus 不符(BR-04 应为 0)')) + newPostId = created && created.postId ? Number(created.postId) : 0 + } else { + await expectFail('P1 · 付费模式下无额度须选发布方式', 'POST', '/post/create', + { categoryId: 3, title: '接口验证·无额度', content: 'x' }, true, '请选择发布方式') + check('闭环 · 用户发布', false, '付费模式下本账号无发布额度,跳过闭环') + } + + // P8 我的发布:状态计数 + 列表契约(v1.0.7 起「我的发布」有独立接口) + await ok200('P8 GET /post/mine', 'GET', '/post/mine?page=1&size=10', null, true, + (d) => (!isPager(d) ? '分页结构不符' + : (d.counts && typeof d.counts.all === 'number' && typeof d.counts.pending === 'number' + && typeof d.counts.passed === 'number' && typeof d.counts.rejected === 'number' + && typeof d.counts.offline === 'number' ? '' : '缺 counts(全部/待审核/已通过/已驳回/已下架)'))) + if (newPostId) { + const mine = await call('GET', '/post/mine?status=pending&page=1&size=50', null, true) + check('P8 · 刚发布的信息出现在「我的发布 → 待审核」', + mine.json && mine.json.code === 0 + && (mine.json.data.list || []).some((x) => Number(x.id) === newPostId), + '未找到 #' + newPostId) + } await expectFail('P1 · 分类必填 (BR-03)', 'POST', '/post/create', { title: 'x', content: 'x' }, true, '分类不能为空') await expectFail('P1 · 标题必填 (BR-03)', 'POST', '/post/create', { categoryId: 3, content: 'x' }, true, '标题不能为空')