官方数据字典(单商户版 ls_goods/ls_goods_item)、宝塔部署文档里的 PHP 7.2/MySQL 5.7 版本要求、二开代码规范,以及两家大模型的现行价目我都重新核对了一遍 —— 这次按单商户标准版的字段写,不套高级版参数。
LikeShop 标准版文案智能化:把大模型接进商品后台,批量出标题和卖点
给 LikeShop 单商户标准版做商品文案智能化,落地方式就一句话:后台加一个 “AI 文案” 页,服务端加一个队列任务,产物先落扩展表、审核通过再回写 ls_goods.name 与 ls_goods.remark,官方代码一行不改。之所以要这么绕,是因为单商户标准版的环境是 PHP 7.2+MySQL 5.7 的老组合、后台接口又走 auth.php 的 action_auth 逐条授权,直接改 goods.goods/edit 要么在升级时被整目录覆盖,要么把商品保存链路带崩;而单次大模型调用要 1–3 秒、还会 429,塞进保存按钮里用户点一次等三秒就骂人。整套流程拆成五步:核对字段 → 建扩展表 → 写客户端与服务类 → 挂队列跑批量 → 做审核页与灰度。按 DeepSeek-V4.1-Flash 闲时价算,1000 条商品两三块钱,真正费时间的是质检和灰度,不是模型。
第一步:先核对字段,别照抄高级版
单商户标准版和高级版、SaaS 版的差别不在 “长得像”,而在字段名和 PHP 版本。这几条对不上,后面代码全白写:
| 项目 | 单商户标准版(企业免费版) | 单商户高级版 / SaaS 版 |
|---|---|---|
| PHP | 7.2 推荐,7.2–7.4 可用 | 需 8.0 |
| MySQL | 5.7 | 5.7/8.0 |
| 后台入口 | /admin | /admin(SaaS 为多开后台) |
| 后台接口前缀 | 以 app/ 下后台应用名为准 | /adminapi 为主 |
| 商品主表 | ls_goods | ls_goods |
| 商品名称字段 | name | name |
| 商品简介字段 | remark | content(详情) |
| 分类字段 | goods_category_id | goods_category_id 或 first_cate_id |
| SKU 价格字段 | ls_goods_item.price | ls_goods_item.sell_price |
| SKU 划线价 | market_price | lineation_price |
不确定就跑两条 SQL 自己验,比查文档快:
SHOW COLUMNS FROM `ls_goods` LIKE '%name%';
SHOW COLUMNS FROM `ls_goods` LIKE '%remark%';
SHOW COLUMNS FROM `ls_goods_item`;
再确认后台应用目录叫 admin 还是 adminapi:
ls server/app 输出 admin / api 之类,就是你的模块名
第二步:建两张 ls_ext_ 扩展表
二开的规矩是官方表不加字段、新功能新开表、表前缀 ls_ext_ 独立管理。这样官方升级跑自己的 SQL 时碰不到你:
-- 批量任务
CREATE TABLE `ls_ext_ai_copy_job` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '任务名',
`cate_id` int NOT NULL DEFAULT 0 COMMENT '分类id,0为全部',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待跑 1进行中 2完成 3中止',
`total` int NOT NULL DEFAULT 0,
`done` int NOT NULL DEFAULT 0,
`fail` int NOT NULL DEFAULT 0,
`model` varchar(64) NOT NULL DEFAULT '',
`prompt_ver` varchar(16) NOT NULL DEFAULT 'v1',
`admin_id` int NOT NULL DEFAULT 0,
`create_time` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI文案批量任务';
-- 单商品结果
CREATE TABLE `ls_ext_ai_copy_result` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`job_id` int unsigned NOT NULL DEFAULT 0,
`goods_id` int unsigned NOT NULL DEFAULT 0,
`hash` char(32) NOT NULL DEFAULT '' COMMENT 'md5(goods_id+prompt_ver+输入快照)',
`title` varchar(120) NOT NULL DEFAULT '',
`selling` text COMMENT '卖点JSON',
`keywords` varchar(255) NOT NULL DEFAULT '',
`score` tinyint NOT NULL DEFAULT 0 COMMENT '规则评分0-100',
`check_flag` tinyint NOT NULL DEFAULT 0 COMMENT '0未校验 1通过 2需人工 3不通过',
`check_msg` varchar(255) NOT NULL DEFAULT '',
`old_title` varchar(120) NOT NULL DEFAULT '' COMMENT '回写前原值,便于回滚',
`old_remark` varchar(255) NOT NULL DEFAULT '',
`tokens_in` int NOT NULL DEFAULT 0,
`tokens_out` int NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待审 1采用 2弃用 3已回写',
`create_time` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hash` (`hash`),
KEY `idx_job` (`job_id`), KEY `idx_goods` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI文案结果';
uk_hash 是整张表最值钱的一行:同一商品+同一 Prompt 版本+同一份输入只会付费一次,任务重跑、脚本重复执行全被唯一键挡掉。
第三步:客户端与服务类
密钥只放 .env,不进库、不下发前端:
[AI]
AI_BASE_URL = https://api.deepseek.com
AI_API_KEY = sk-xxxxxxxx
AI_MODEL = deepseek-flash
AI_TIMEOUT = 30
// config/ai.php
return [
'base_url' => Env::get('AI.AI_BASE_URL'),
'api_key' => Env::get('AI.AI_API_KEY'),
'model' => Env::get('AI.AI_MODEL', 'deepseek-flash'),
'timeout' => (int) Env::get('AI.AI_TIMEOUT', 30),
'max_retry' => 3,
'sleep_ms' => 200,
];
namespace app\common\service\ai;
class AiClient
{
public function chatJson(array $messages, float $temp = 0.4, int $maxTokens = 800): array
{
$conf = config('ai');
$payload = [
'model' => $conf['model'],
'messages' => $messages,
'temperature' => $temp,
'max_tokens' => $maxTokens,
'response_format' => ['type' => 'json_object'],
'stream' => false,
];
for ($i = 0; $i < (int) $conf['max_retry']; $i++) {
$ch = curl_init(rtrim($conf['base_url'], '/') . '/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $conf['timeout'],
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $conf['api_key'],
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw !== false && $code === 200) {
$body = json_decode($raw, true) ?: [];
return [
'content' => $body['choices'][0]['message']['content'] ?? '',
'usage' => $body['usage'] ?? [],
];
}
// 只对限流与服务端错误退避;鉴权/参数错误立刻抛
if ($code === 429 || $code >= 500 || $code === 0) {
usleep((int) (pow(2, $i) 500000));
continue;
}
throw new \RuntimeException('AI接口 HTTP ' . $code . ':' . mb_substr((string) $raw, 0, 160));
}
throw new \RuntimeException('AI接口重试 ' . $conf['max_retry'] . ' 次仍失败');
}
}
Prompt 的写法决定了 80% 的输出质量。核心是三件事:只喂真实字段、锁死输出结构、明确禁止编造。
namespace app\common\service\ai;
use think\facade\Db;
class CopyService
{
const PROMPT_VER = 'v1';
public function messages(array $goods): array
{
$cate = Db::name('goods_category')->where('id', $goods['goods_category_id'])->value('name') ?: '';
$sku = Db::name('goods_item')->where('goods_id', $goods['id'])
->order('price asc')->column('spec_value_str, price');
$facts = [
'商品原名' => $goods['name'],
'类目' => $cate,
'简介' => (string) ($goods['remark'] ?? ''),
'价格区间' => $goods['min_price'] . '~' . $goods['max_price'] . ' 元',
'规格与价格' => $sku,
];
$system = << 'system', 'content' => $system],
['role' => 'user', 'content' => "商品事实:\n" . json_encode($facts, JSON_UNESCAPED_UNICODE)],
];
}
/ 规则评分:0–100,低于 60 直接判不通过 /
public function score(array $parsed, array $goods): array
{
$title = trim($parsed['title'] ?? '');
$sp = $parsed['selling_points'] ?? [];
$ded = 0; $msg = [];
if ($title === '') { $ded += 40; $msg[] = '标题为空'; }
if (mb_strlen($title) > 30) { $ded += 15; $msg[] = '标题超30字'; }
if (count($sp) < 3 || count($sp) > 5) { $ded += 10; $msg[] = '卖点条数异常'; }
foreach (['最', '第一', '顶级', '国家级', '唯一'] as $w) {
if (mb_strpos($title, $w) !== false) { $ded += 30; $msg[] = '含禁用词:' . $w; }
}
// 标题里的数字必须在原文出现过,防止模型自造"3年质保""5折"
if (preg_match_all('/(\d+(\.\d+)?)\s(元|折|年|天|件)/u', $title, $m)) {
foreach ($m[0] as $num) {
$src = $goods['name'] . ($goods['remark'] ?? '');
if (mb_strpos($src, $num) === false) { $ded += 20; $msg[] = '未提供的数字:' . $num; }
}
}
similar_text($title, $goods['name'], $pct);
if ($pct > 80) { $ded += 10; $msg[] = '与原标题重合度过高'; }
$score = max(0, 100 - $ded);
return [
'score' => $score,
'flag' => $score >= 80 ? 1 : ($score >= 60 ? 2 : 3),
'msg' => implode(';', $msg),
];
}
}
评分分三档很关键:80 分以上进 “自动通过” 队列,60–79 进 “人工看一眼”,60 以下直接判死。这样人工只处理中间那 20%,而不是全量。
第四步:挂队列跑批量
单商户标准版本身带 think-queue,没有就装 topthink/think-queue,队列驱动先用 database,别一上来就 Redis:
namespace app\job;
use app\common\service\ai\AiClient;
use app\common\service\ai\CopyService;
use think\facade\Db;
use think\facade\Log;
use think\queue\Job;
class AiCopyJob
{
public function fire(Job $job, array $data): void
{
$goodsId = (int) ($data['goods_id'] ?? 0);
$jobId = (int) ($data['job_id'] ?? 0);
$goods = Db::name('goods')->where('id', $goodsId)->where('status', 1)->find();
if (!$goods) { $job->delete(); return; }
$hash = md5($goodsId . CopyService::PROMPT_VER . $goods['name'] . ($goods['remark'] ?? ''));
if (Db::name('ext_ai_copy_result')->where('hash', $hash)->count() > 0) {
$job->delete(); return; // 幂等:已有结果直接跳过,不重复付费
}
try {
$svc = new CopyService();
$res = (new AiClient())->chatJson($svc->messages($goods));
$parsed = json_decode($res['content'], true);
if (!is_array($parsed)) throw new \RuntimeException('返回非JSON');
$chk = $svc->score($parsed, $goods);
Db::name('ext_ai_copy_result')->insert([
'job_id' => $jobId,
'goods_id' => $goodsId,
'hash' => $hash,
'title' => mb_substr($parsed['title'] ?? '', 0, 100),
'selling' => json_encode($parsed['selling_points'] ?? [], JSON_UNESCAPED_UNICODE),
'keywords' => implode(',', array_slice($parsed['keywords'] ?? [], 0, 5)),
'score' => $chk['score'],
'check_flag' => $chk['flag'],
'check_msg' => mb_substr($chk['msg'], 0, 255),
'old_title' => $goods['name'],
'old_remark' => $goods['remark'] ?? '',
'tokens_in' => $res['usage']['prompt_tokens'] ?? 0,
'tokens_out' => $res['usage']['completion_tokens'] ?? 0,
'create_time'=> time(),
]);
$job->delete();
} catch (\Throwable $e) {
Log::error('[ai_copy] goods=' . $goodsId . ' ' . $e->getMessage());
if ($job->attempts() >= 3) {
Db::name('ext_ai_copy_job')->where('id', $jobId)->inc('fail')->update();
$job->delete();
} else {
$job->release(30); // 30 秒后重试,避免瞬时 429 打穿
}
}
usleep((int) (config('ai.sleep_ms') 1000));
}
}
入队和进度:
// 控制器里创建任务并推队列
$ids = Db::name('goods')->where('status', 1)
->when($cateId, fn($q) => $q->where('goods_category_id', $cateId))
->order('id asc')->column('id');
$jobId = Db::name('ext_ai_copy_job')->insertGetId([
'name' => 'AI文案-' . date('mdHi'), 'cate_id' => $cateId,
'total' => count($ids), 'model' => config('ai.model'),
'prompt_ver' => \app\common\service\ai\CopyService::PROMPT_VER,
'create_time'=> time(),
]);
foreach ($ids as $gid) {
\think\facade\Queue::push(\app\job\AiCopyJob::class, ['goods_id' => $gid, 'job_id' => $jobId], 'ai_copy');
}
跑队列进程:
php think queue:work --queue ai_copy --tries 3 --timeout 60
一行 --queue ai_copy 是必须的:否则你这条 AI 任务会和订单、短信通知这些系统队列抢 worker,后台延迟立刻肉眼可见。
第五步:审核页与灰度
后台审核页只做三件事:看差异、打分排序、点采用。
namespace app\admin\controller;
use think\facade\Db;
class AiCopy extends Base // ← 换成你版本的基类名
{
public function lists()
{
$page = (int) $this->request->get('page', 1);
$flag = (int) $this->request->get('check_flag', 0);
$list = Db::name('ext_ai_copy_result')
->when($flag, fn($q) => $q->where('check_flag', $flag))
->order('score desc, id desc')
->page($page, 20)->select()->toArray();
return json(['code' => 1, 'data' => $list]);
}
public function apply()
{
$id = (int) $this->request->post('id', 0);
$row = Db::name('ext_ai_copy_result')->where('id', $id)->where('status', 0)->find();
if (!$row) return json(['code' => 0, 'msg' => '记录不存在']);
if ((int) $row['check_flag'] === 3) return json(['code' => 0, 'msg' => '评分未达标,禁止回写']);
Db::name('goods')->where('id', $row['goods_id'])->update([
'name' => $row['title'],
'remark' => mb_substr(implode('|', json_decode($row['selling'], true) ?: []), 0, 255),
'update_time' => time(),
]);
Db::name('ext_ai_copy_result')->where('id', $id)->update(['status' => 3]);
return json(['code' => 1, 'msg' => '已回写']);
}
}
灰度别图省事。上线第一周只对 10% 商品回写,按 SKU 或商品 ID 取模做稳定分流,14 天后比点击率和加购率:
-- 只挑 10% 商品进入 AI 文案灰度组
SELECT id FROM ls_goods WHERE status = 1 AND MOD(id, 10) = 0;
| 观察指标 | 看什么 | 判断 |
|---|---|---|
| 商品详情页点击率 | 搜索 / 分类列表曝光→点击 | 标题是否更有信息量 |
| 加购率 | 访问→加购 | 卖点是否说清了用途 |
| 转化率 | 加购→下单 | 文案是否与实物一致 |
| 退货 / 差评关键词 | “不符”“夸大” | 文案有没有过度承诺 |
如果 CTR 涨了但退货率也涨,说明模型把卖点写虚了,这时候该收紧 Prompt 的 “禁止编造” 约束,而不是继续放大流量。
成本与并发
| 模型 | 输入 | 输出 | 1000 条(600 in + 300 out) |
|---|---|---|---|
| DeepSeek-V4.1-Flash 闲时 | ¥1/M | ¥4/M | ≈ ¥1.8 |
| DeepSeek-V4.1-Flash 忙时 | ¥2/M | ¥8/M | ≈ ¥3.6 |
| qwen3.8-flash | ¥0.8/M | ¥2.7/M | ≈ ¥1.3 |
并发控制在 3–5 个 worker:单商户标准版的 MySQL 5.7 没有太多余量,AI 批量任务把数据库拖慢,前台下单会直接受影响。
五个必查项
- 字段先核对:标准版是 remark+goods_category_id+goods_item.price,高级版是 content+sell_price,抄错一个就报 Unknown column。
- PHP 别乱升:标准版跑 PHP 7.2 最稳,升 8.0 会出现扩展与框架兼容问题;要升先在测试环境跑通安装向导。
- 密钥进 .env,config/ai.php 只读环境变量,前端任何位置都不出现 key。
- 后台控制器别继承错基类,标准版基类名与高级版不同,加载即报错的十有八九是这里。
- 回写必须留 old_title/old_remark,线上标题出问题要能十分钟内改回去。
一句话收口:LikeShop 单商户标准版做文案智能化,技术难度远低于想象,难的是 “把版本字段对上、把官方代码绕开、把模型编造的口子堵住”。这三件事做对,它就是一个能一直跟着官方版本升级走的小工具;做错任何一件,省下的开发时间都会在下一次升级或一次投诉里加倍还回去。
likeshoplikeshop
【相关 FAQ】
问题 1:LikeShop 单商户标准版做大模型批量生成标题,商品简介字段到底叫 remark 还是 content?
回答 1:单商户标准版的 ls_goods 商品简介字段是 remark,商品名称是 name,分类是 goods_category_id;content 是单商户高级版/SaaS 版的商品详情字段,两版混用会直接报 Unknown column。
问题 2:LikeShop 单商户标准版批量生成商品卖点,为什么要用队列而不是在后台接口里直接调?
回答 2:一次大模型调用要 1–3 秒且可能返回 429,放在后台保存接口里会让页面卡死;改用 think-queue 队列后,任务可以重试、可以断点续跑、可以限制并发,前台下单不受影响。
问题 3:大模型生成的商品标题和卖点,怎么避免写出广告法禁用词?
回答 3:两道拦截。Prompt 里明确禁止 “最、第一、顶级、国家级、唯一” 等表述,服务端再用规则评分二次校验,含禁用词直接扣分到不通过档,禁止回写 ls_goods.name。
问题 4:LikeShop 单商户标准版二开新增表,为什么必须用 ls_ext_ 前缀?
回答 4:官方升级会执行自己的建表与改表 SQL,只有把 AI 文案任务表、结果表放在 ls_ext_ai_copy_job、ls_ext_ai_copy_result 这类独立前缀下,才能保证升级不冲突、出问题能整表回滚。
问题 5:AI 生成的标题上线后,怎么判断到底有没有效果?
回答 5:做 10% 灰度,按商品 ID 取模稳定分流,观察 14 天内的详情页点击率、加购率、转化率和退货差评关键词;如果点击率涨但退货率同步涨,说明卖点写虚了,应先收紧 Prompt 的 “禁止编造” 约束。
【参考资料】
- [2026-11-09](Models & Pricing 模型与计费)DeepSeek API Docs
- [2026-11-06](Chat Completions 接口与 OpenAI 兼容说明)千问 AI 平台
- [2026-10-23](LikeShop 二开代码规范:目录结构、钩子使用与版本升级兼容方案)阿里云开发者社区
- [2026-10-19](一次超卖 37 件的复盘:LikeShop 单商户版 SKU 体系的规格映射、库存扣减与并发)阿里云开发者社区
- [2026-10-13](LikeShop 单商户开源版本地部署全流程(PHPStudy 版))CSDN 博客
- [2026-10-06](数据库字典 单商户高级版)LikeShop 官方开发文档
- [2026-09-29](服务端宝塔面板部署①)LikeShop 官方开发文档
- [2026-07-26](数据库字典 单商户版商品表与 SKU 表)LikeShop 官方开发文档
- [2026-07-20](PHPstudy 环境下部署①)LikeShop 官方开发文档
【免责声明】
本文示例代码按 LikeShop 单商户标准版(PHP 7.2+MySQL 5.7、ls_ 表前缀、ls_goods.name/remark)编写,高级版与 SaaS 版在模块名、基类名、商品详情字段与 SKU 价格字段上存在差异,落地前请以你所装版本的数据库字典为准。文中价格估算基于 2026 年 11 月公开的模型价目表,实际计费随调用量、缓存命中与供应商调价变化。本文不构成对 LikeShop 商业授权、模型服务合规性的担保,生产环境上线前请自行完成安全审计与内容合规审查。
