diff --git a/application/admin/command/FcInstall.php b/application/admin/command/FcInstall.php index 8bba16f..3a4ba3e 100644 --- a/application/admin/command/FcInstall.php +++ b/application/admin/command/FcInstall.php @@ -141,12 +141,13 @@ class FcInstall extends Command * * 背景:v1.0.7 新增「免费发布 / 付费发布」开关与付费发布的三档定价 * · 单条价格 post.singlePrice + * · 免费发布条数 post.freeQuota / post.freeQuotaCycle(v1.0.10:达到条数后按单条价格计费) * · 按月(价格 + 含条数)post.monthPrice / post.monthQuota * · 按年(价格 + 含条数)post.yearPrice / post.yearQuota * 老库缺列/缺配置时补齐,**不覆盖运营已改过的值**: * ① fc_user_ext 补 post_quota_left / post_quota_expire(剩余发布条数 + 有效期) * ② fc_plan 补 post_quota(套餐含发布条数,月/年卡赠送额度) - * ③ fc_config 补 post.* 六项(已存在则跳过) + * ③ fc_config 补 post.* 八项(已存在则跳过) * ④ 月卡/年卡的价格与条数以配置为准做一次同步(配置缺失时反向用套餐值填配置) */ private function upgradePublish(Output $output) @@ -181,7 +182,9 @@ class FcInstall extends Command // ③ 配置项(只补缺失的,不覆盖运营已改的值) $defaults = [ ['post.mode', 'free', 'post', '发布模式:free 免费发布 / paid 付费发布'], - ['post.singlePrice', '5', 'post', '单条发布价格(元)'], + ['post.singlePrice', '5', 'post', '单条发布价格(元)=付费发布标准价'], + ['post.freeQuota', '0', 'post', '免费发布条数(0 = 不限制/免费到底;达到条数后按单条发布价格计费)'], + ['post.freeQuotaCycle', 'day', 'post', '免费条数统计周期:day 每日 / month 每月 / total 累计'], ['post.monthPrice', '19.9', 'post', '按月发布价格(元)'], ['post.monthQuota', '30', 'post', '按月含发布条数'], ['post.yearPrice', '168', 'post', '按年发布价格(元)'], @@ -689,7 +692,9 @@ class FcInstall extends Command ['fee.memberYear', '168', 'fee', '年卡价格'], // 发布模式与发布套餐(后台「发布与费用配置」页维护;真源=配置,保存时同步 fc_plan) ['post.mode', 'free', 'post', '发布模式:free 免费发布 / paid 付费发布(默认免费,避免上线即撞付费墙)'], - ['post.singlePrice', '5', 'post', '单条发布价格(元)'], + ['post.singlePrice', '5', 'post', '单条发布价格(元)=付费发布标准价'], + ['post.freeQuota', '0', 'post', '免费发布条数(0 = 不限制/免费到底;达到条数后按单条发布价格计费)'], + ['post.freeQuotaCycle', 'day', 'post', '免费条数统计周期:day 每日 / month 每月 / total 累计'], ['post.monthPrice', '19.9', 'post', '按月发布价格(元)'], ['post.monthQuota', '30', 'post', '按月含发布条数'], ['post.yearPrice', '168', 'post', '按年发布价格(元)'], diff --git a/application/admin/controller/fc/Publish.php b/application/admin/controller/fc/Publish.php index 9ea7790..6dcfb85 100644 --- a/application/admin/controller/fc/Publish.php +++ b/application/admin/controller/fc/Publish.php @@ -12,12 +12,14 @@ use think\Db; * 真源 = fa_fc_config 的 post.* 分组;保存时同步 fa_fc_plan 的月/年卡价格与含发布条数, * 保证「发布页价格」「会员中心套餐价」「后端计费」三者一致。 * - * post.mode free 免费发布 / paid 付费发布 - * post.singlePrice 单条发布价格(元) - * post.monthPrice 按月发布价格(元) - * post.monthQuota 按月含发布条数 - * post.yearPrice 按年发布价格(元) - * post.yearQuota 按年含发布条数 + * post.mode free 免费发布 / paid 付费发布 + * post.singlePrice 单条发布价格(元)=「付费发布」标准价 + * post.freeQuota 免费发布条数(0 = 不限制/免费到底;超出即按 singlePrice 计费) + * post.freeQuotaCycle 免费条数统计周期:day 每日 / month 每月 / total 累计 + * post.monthPrice 按月发布价格(元) + * post.monthQuota 按月含发布条数 + * post.yearPrice 按年发布价格(元) + * post.yearQuota 按年含发布条数 * * @icon fa fa-cny */ @@ -26,6 +28,9 @@ class Publish extends Backend /** 发布模式取值 */ const MODES = ['free', 'paid']; + /** 免费条数统计周期取值 */ + const CYCLES = ['day', 'month', 'total']; + /** * 配置页(GET 显示 / POST 保存) */ @@ -73,6 +78,12 @@ class Publish extends Backend $yearPrice = $num('yearPrice', 168); $monthQuota = $int('monthQuota', 30); $yearQuota = $int('yearQuota', 500); + // 免费发布条数:0 = 不限制(免费到底);达到条数后按「单条发布价格」计费 + $freeQuota = $int('freeQuota', 0); + $freeQuotaCycle = isset($params['freeQuotaCycle']) ? trim((string)$params['freeQuotaCycle']) : 'day'; + if (!in_array($freeQuotaCycle, self::CYCLES, true)) { + $this->error('免费条数统计周期只支持「每日 / 每月 / 累计」'); + } foreach (['单条发布价格' => $single, '按月发布价格' => $monthPrice, '按年发布价格' => $yearPrice] as $label => $v) { if ($v < 0) { @@ -82,15 +93,20 @@ class Publish extends Backend if ($monthQuota < 0 || $yearQuota < 0) { $this->error('含发布条数不能为负'); } + if ($freeQuota < 0) { + $this->error('免费发布条数不能为负(0 表示不限制)'); + } $now = time(); $values = [ - 'post.mode' => $mode, - 'post.singlePrice' => $single, - 'post.monthPrice' => $monthPrice, - 'post.monthQuota' => $monthQuota, - 'post.yearPrice' => $yearPrice, - 'post.yearQuota' => $yearQuota, + 'post.mode' => $mode, + 'post.singlePrice' => $single, + 'post.freeQuota' => $freeQuota, + 'post.freeQuotaCycle' => $freeQuotaCycle, + '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(); @@ -128,12 +144,15 @@ class Publish extends Backend $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'), + 'mode' => in_array($mode, self::MODES, true) ? $mode : 'paid', + 'singlePrice' => $pick('post.singlePrice', $pick('fee.singlePost', '5')), + // 免费发布条数:老库未保存过时回落 fee.freePostPerDay(默认 0 = 不限制) + 'freeQuota' => $pick('post.freeQuota', $pick('fee.freePostPerDay', '0')), + 'freeQuotaCycle' => $pick('post.freeQuotaCycle', 'day'), + '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/publish.php b/application/admin/lang/zh-cn/fc/publish.php index edc50f1..c2c974e 100644 --- a/application/admin/lang/zh-cn/fc/publish.php +++ b/application/admin/lang/zh-cn/fc/publish.php @@ -6,6 +6,11 @@ return [ '免费发布(所有用户均可免费发布)' => '免费发布(所有用户均可免费发布)', '付费发布(按下面价格计费,套餐额度内免费)' => '付费发布(按下面价格计费,套餐额度内免费)', '会员单条发布价格' => '会员单条发布价格', + '免费发布条数' => '免费发布条数', + '条' => '条', + '每日' => '每日', + '每月' => '每月', + '累计' => '累计', '会员按月发布' => '会员按月发布', '会员按年发布' => '会员按年发布', '元/条' => '元/条', @@ -30,6 +35,10 @@ return [ => '「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。', '未开通套餐的用户按此价格付费发布(金额由服务端计算,前端不可传价)' => '未开通套餐的用户按此价格付费发布(金额由服务端计算,前端不可传价)', + '免费发布条数:免费发布模式下前 N 条免费,达到条数后按「会员单条发布价格」计费(计费标准与付费发布一致);付费发布模式下同样保留这 N 条免费额度(避免一开付费就是硬付费墙)。填 0 = 不限制(免费到底)。' + => '免费发布条数:免费发布模式下前 N 条免费,达到条数后按「会员单条发布价格」计费(计费标准与付费发布一致);付费发布模式下同样保留这 N 条免费额度(避免一开付费就是硬付费墙)。填 0 = 不限制(免费到底)。', + '计费顺序:套餐额度 → 免费发布条数 → 单条付费;免费发布条数达到后即按「会员单条发布价格」计费,金额一律由服务端计算。' + => '计费顺序:套餐额度 → 免费发布条数 → 单条付费;免费发布条数达到后即按「会员单条发布价格」计费,金额一律由服务端计算。', '价格与条数会同步到「会员套餐 → 月卡(M1)」;购买后立即赠送该条数的发布额度' => '价格与条数会同步到「会员套餐 → 月卡(M1)」;购买后立即赠送该条数的发布额度', '价格与条数会同步到「会员套餐 → 年卡(M12)」;购买后立即赠送该条数的发布额度' diff --git a/application/admin/view/fc/publish/index.html b/application/admin/view/fc/publish/index.html index d5bff6e..33d2622 100644 --- a/application/admin/view/fc/publish/index.html +++ b/application/admin/view/fc/publish/index.html @@ -7,6 +7,8 @@ {:__('本页是「发布模式 + 发布价格/条数」的唯一入口;保存后会同步「会员套餐」的月/年卡价格与含发布条数,因此用户端发布页、会员中心与后端计费口径一致。')}
{:__('「会员单条发布价格」= 未购买套餐时按条计费的价格;「按月/按年」= 会员套餐价格,并赠送对应条数的发布额度(额度随会员有效期,过期自动清零)。')} +
+ {:__('计费顺序:套餐额度 → 免费发布条数 → 单条付费;免费发布条数达到后即按「会员单条发布价格」计费,金额一律由服务端计算。')}
@@ -27,6 +29,24 @@ + + {:__('免费发布条数')} + +
+ + {:__('条')} +
+
+ +
+ {:__('免费发布条数:免费发布模式下前 N 条免费,达到条数后按「会员单条发布价格」计费(计费标准与付费发布一致);付费发布模式下同样保留这 N 条免费额度(避免一开付费就是硬付费墙)。填 0 = 不限制(免费到底)。')} + + {:__('会员单条发布价格')} diff --git a/application/api/controller/Post.php b/application/api/controller/Post.php index 71120e4..c6bfd88 100644 --- a/application/api/controller/Post.php +++ b/application/api/controller/Post.php @@ -19,17 +19,21 @@ use think\Db; * * 计费口径(后台「发布与费用配置」维护,真源=fc_config); * 金额一律由服务端按配置计算,**不接受客户端传入金额**; + * 判定顺序:① 套餐额度(fc_user_ext.post_quota_left,开通月/年卡赠送) + * → ② 免费发布条数(post.freeQuota,按 post.freeQuotaCycle 统计周期,0 = 不限制) + * → ③ 单条付费(post.singlePrice,即「付费发布」标准); + * 免费发布模式下免费条数用完即按 ③ 计费(客户端需切到「会员单条」并选支付方式重新提交); * 月卡/年卡(含发布条数)的购买复用 POST /member/open,本控制器只处理 - * 「套餐额度内免费发布」与「单条付费发布」两种。 + * 「套餐额度/免费条数内免费发布」与「单条付费发布」两种。 */ class Post extends V1 { /** * P1 发布信息 * POST /api/v1/post/create - * 入参 {categoryId,title,content,media[],contact,phone,address,area,price,priceUnit,tags,paid,amount,payType} - * → {postId,auditStatus,order} - * 规则 BR-03/BR-04/BR-02 + * 入参 {categoryId,title,content,media[],contact,phone,address,plan,payType} + * → {postId,auditStatus,order,plan,amount,quotaLeft,freeQuotaLeft} + * 规则 BR-03/BR-04/BR-02/BR-13(计费顺序:套餐额度 → 免费发布条数 → 单条付费) */ public function create() { @@ -77,37 +81,34 @@ class Post extends V1 $ext = $this->ext($user['id']); $quotaLeft = $this->quotaLeft($ext); + $free = $this->freeQuotaState($user['id']); $amount = 0; $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('余额不足'); - } + $useFreeQuota = false; // 免费发布条数内免费 + if ($plan === 'single') { + // 单条付费 = 付费发布标准(免费发布条数用完后即按此计费) + $amount = $rule['singlePrice']; + $payType = trim((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('余额不足'); + } + } elseif ($quotaLeft > 0) { + $useQuota = true; // ① 套餐额度(开通月/年卡赠送,随会员有效期) + } elseif ($free['unlimited'] || $free['left'] > 0) { + $useFreeQuota = true; // ② 免费发布条数(post.freeQuota + post.freeQuotaCycle) + } elseif ($rule['mode'] === 'free') { + // 免费发布模式下免费条数已用完 → 按「付费发布」标准计费: + // 需要客户端切到「会员单条」(plan=single)并选支付方式后重新提交, + // 服务端不主动扣款(避免未经用户确认就产生订单) + $this->fail('免费额度已用完,请按 ' . number_format((float)$rule['singlePrice'], 2) . ' 元/条付费发布'); + } else { + $this->fail('请选择发布方式'); } $paid = $amount > 0; @@ -186,12 +187,14 @@ class Post extends V1 } $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, + 'postId' => $postId, + 'auditStatus' => 0, + 'order' => $order, + 'plan' => $useQuota ? 'quota' : ($useFreeQuota ? 'free' : ($paid ? 'single' : 'free')), + 'amount' => $amount, + 'quotaLeft' => $quotaLeft, + // -1 = 不限制(免费发布模式且未设置免费条数) + 'freeQuotaLeft' => $free['unlimited'] ? -1 : max(0, $free['left'] - ($useFreeQuota ? 1 : 0)), ]); } @@ -285,9 +288,8 @@ class Post extends V1 $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; + // 免费发布条数(post.freeQuota / post.freeQuotaCycle):超出即按付费发布标准计费 + $free = $this->freeQuotaState($user['id']); $this->ok([ 'mode' => $rule['mode'], @@ -297,9 +299,12 @@ class Post extends V1 'yearPrice' => $rule['yearPrice'], 'yearQuota' => $rule['yearQuota'], 'quotaLeft' => $this->quotaLeft($ext), - 'freePerDay' => $freePerDay, - 'freeUsedToday' => $usedToday, - 'freeLeftToday' => max(0, $freePerDay - $usedToday), + // 免费发布条数(0 条 + freeUnlimited=true 表示「不限制」,免费到底) + 'freeQuota' => $free['quota'], + 'freeQuotaCycle' => $free['cycle'], + 'freeQuotaUsed' => $free['used'], + 'freeQuotaLeft' => $free['unlimited'] ? -1 : $free['left'], + 'freeUnlimited' => $free['unlimited'], 'quotaExpireAt' => $quotaExpire > 0 ? $this->iso($quotaExpire) : '', 'isMember' => (int)$ext['is_member'] === 1 && ($memberExpire === 0 || $memberExpire > $now), 'memberExpireAt' => $memberExpire > 0 ? $this->iso($memberExpire) : '', @@ -327,15 +332,81 @@ class Post extends V1 } /** - * 今日已发布条数(每日免费额度判定用,不含软删) + * 免费发布条数规则(v1.0.10) + * post.freeQuota 免费发布条数(0 = 不限制,免费发布模式下「免费到底」) + * post.freeQuotaCycle 统计周期:day 每日 / month 每月 / total 累计(不重置) + * 兼容:配置项 **缺失** 时(老库未执行新版 fc:install)回落 `fee.freePostPerDay` 按日计; + * 一旦配置项存在(后台「发布与费用配置」保存过或 fc:install 种过),就以配置为准, + * 此时 `0` 表示「不限制」而不再回落旧值。 + * @return array{quota:int,cycle:string} + */ + protected function freeQuotaRule() + { + $cycle = (string)$this->cfg('post.freeQuotaCycle', 'day'); + if (!in_array($cycle, ['day', 'month', 'total'], true)) { + $cycle = 'day'; + } + $raw = $this->cfg('post.freeQuota', null); + if ($raw === null || $raw === '') { + $legacy = max(0, (int)$this->cfg('fee.freePostPerDay', 0)); + return ['quota' => $legacy, 'cycle' => $legacy > 0 ? 'day' : $cycle]; + } + return ['quota' => max(0, (int)$raw), 'cycle' => $cycle]; + } + + /** + * 免费条数使用情况(按周期统计本人已发布条数,不含软删) * @param int $userId + * @return array{quota:int,cycle:string,used:int,left:int,unlimited:bool} + */ + protected function freeQuotaState($userId) + { + $rule = $this->freeQuotaRule(); + if ($rule['quota'] <= 0) { + // 0 = 不限制:免费发布模式「免费到底」;付费发布模式视为不额外提供免费条数(走套餐额度或单条付费) + $unlimited = $this->publishRule()['mode'] === 'free'; + return ['quota' => 0, 'cycle' => $rule['cycle'], 'used' => 0, 'left' => 0, 'unlimited' => $unlimited]; + } + $used = $this->periodPostCount($userId, $rule['cycle']); + return [ + 'quota' => $rule['quota'], + 'cycle' => $rule['cycle'], + 'used' => $used, + 'left' => max(0, $rule['quota'] - $used), + 'unlimited' => false, + ]; + } + + /** + * 周期起点时间戳 + * @param string $cycle day|month|total * @return int */ - protected function todayPostCount($userId) + protected function quotaCycleStart($cycle) { - $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(); + if ($cycle === 'day') { + return strtotime(date('Y-m-d') . ' 00:00:00'); + } + if ($cycle === 'month') { + return strtotime(date('Y-m') . '-01 00:00:00'); + } + return 0; + } + + /** + * 周期内已发布条数(不含软删) + * @param int $userId + * @param string $cycle + * @return int + */ + protected function periodPostCount($userId, $cycle) + { + $start = $this->quotaCycleStart($cycle); + $query = Db::name('fc_post')->where('user_id', $userId)->whereNull('deletetime'); + if ($start > 0) { + $query->where('createtime', '>=', $start); + } + return (int)$query->count(); } /** diff --git a/application/config.php b/application/config.php index 3f66c43..fd1b31b 100755 --- a/application/config.php +++ b/application/config.php @@ -306,7 +306,7 @@ return [ //允许跨域的域名,多个以,分隔 'cors_request_domain' => 'localhost,127.0.0.1', //版本号 - 'version' => '1.6.6.20261016', + 'version' => '1.6.7.20261016', //API接口地址 'api_url' => 'https://api.fastadmin.net', ], diff --git a/tools/verify-api.mjs b/tools/verify-api.mjs index 628f881..ad92a65 100644 --- a/tools/verify-api.mjs +++ b/tools/verify-api.mjs @@ -153,17 +153,21 @@ async function userHome() { let newPostId = 0 async function userPost() { - // P9 发布配置:发布模式(免费/付费)+ 单条与月/年卡价格条数 + 我的额度(v1.0.7) + // P9 发布配置:发布模式(免费/付费)+ 单条与月/年卡价格条数 + 免费发布条数 + 我的额度(v1.0.7,v1.0.10 增补 freeQuota*) const pub = await ok200('P9 GET /post/publish-config', 'GET', '/post/publish-config', null, true, (d) => (d && ['free', 'paid'].indexOf(d.mode) > -1 && typeof d.singlePrice === 'number' && typeof d.monthPrice === 'number' && typeof d.monthQuota === 'number' && typeof d.yearPrice === 'number' && typeof d.yearQuota === 'number' - && typeof d.quotaLeft === 'number' ? '' : '缺 mode/价格/含条数/额度字段')) + && typeof d.quotaLeft === 'number' && typeof d.freeQuota === 'number' + && ['day', 'month', 'total'].indexOf(d.freeQuotaCycle) > -1 + && typeof d.freeQuotaLeft === 'number' && typeof d.freeUnlimited === 'boolean' + ? '' : '缺 mode/价格/含条数/额度/免费发布条数字段')) - // 能否免费发布:免费模式 / 有套餐额度 / 今日免费额度未用完 + // 能否免费发布:免费模式(免费条数未用完或未限制)/ 有套餐额度 / 免费发布条数未用完 const canFreePost = !!pub && (pub.mode === 'free' || Number(pub.quotaLeft) > 0 - || (Number(pub.freePerDay) - Number(pub.freeUsedToday)) > 0) + || !!pub.freeUnlimited + || Number(pub.freeQuotaLeft) > 0) if (canFreePost) { const created = await ok200('P1 POST /post/create', 'POST', '/post/create',