feat(api): v1.0.10 免费发布条数(周期 每日/每月/累计)+ 计费顺序(套餐额度 → 免费条数 → 单条付费)

需求:免费发布不能是「要么全免费、要么一开付费就是硬付费墙」,需要可配「前 N 条免费」,
超出的按付费发布标准计费。

- 后台「发布与费用配置」新增「免费发布条数 + 统计周期」:post.freeQuota / post.freeQuotaCycle
  (day 每日 / month 每月 / total 累计;0 = 不限制,免费发布模式下「免费到底」)
  Publish::save 校验周期枚举与条数非负;页面补说明(计费顺序:套餐额度 → 免费发布条数 → 单条付费)
- FcInstall::upgradePublish 与内置种子各补两枚配置(幂等,不覆盖运营已改的值;老库缺失时
  API 回落 fee.freePostPerDay 按日计,行为与升级前一致)
- POST /post/create 计费重构为三级顺序:
  ① 套餐额度(fc_user_ext.post_quota_left)→ ② 免费发布条数(按周期统计本人已发布条数,不含软删)
  → ③ 单条付费(post.singlePrice,即「付费发布」标准)
  免费发布模式下免费条数用完时明确提示「免费额度已用完,请按 X.XX 元/条付费发布」
  (服务端不主动扣款,需客户端切到 plan=single 并选支付方式后重新提交)
- GET /post/publish-config 字段调整:freePerDay/freeUsedToday/freeLeftToday
  → freeQuota / freeQuotaCycle / freeQuotaUsed / freeQuotaLeft / freeUnlimited(-1 = 不限制)
- tools/verify-api.mjs P9 同步断言新字段与「能否免费发布」判定;后台语言包补齐中文映射
- application/config.php:site.version 1.6.6.20261016 → 1.6.7.20261016(后台视图/JS 变更后强制刷新静态资源缓存)

自检:node tools/check-admin-i18n.mjs(错误 0/警告 0)、node tools/check-theme.mjs(错误 0/警告 0)、
5 个 PHP 文件 php -n -l 无语法错误。
这个提交包含在:
linda
2026-10-17 11:45:36 +08:00
父节点 d278c9a758
当前提交 d452be1d60
共有 7 个文件被更改,包括 201 次插入 和 73 次删除

查看文件

@@ -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();
}
/**