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 无语法错误。
这个提交包含在:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户