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()}
+
+
+
+ {:__('说明')}:
+ {:__('本页是「发布模式 + 发布价格/条数」的唯一入口;保存后会同步「会员套餐」的月/年卡价格与含发布条数,因此用户端发布页、会员中心与后端计费口径一致。')}
+
+ {:__('「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。')}
+
+
+
+
+
+
+ {:__('当前会员套餐')}
+ {:__('(月/年卡的价格与含发布条数由本页同步,其余字段在「交易管理 → 会员套餐」维护)')}
+
+
+
+
+ | {:__('套餐编码')} |
+ {:__('套餐名称')} |
+ {:__('售价')} |
+ {:__('有效天数')} |
+ {:__('含发布条数')} |
+ {:__('是否启用')} |
+
+
+
+ {foreach $plans as $p}
+
+ | {$p.plan_code} |
+ {$p.name} |
+ ¥{$p.price} |
+ {$p.days} |
+ {$p.post_quota} |
+ {if $p.enabled}{:__('是')}{else/}{:__('否')}{/if} |
+
+ {/foreach}
+
+
+
+
+
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, '标题不能为空')