LikeShop 单商户标准版二开实战:接入大模型批量生成商品标题与卖点(附完整代码)
一次给 1000 个商品批量重写标题、副标题和卖点,token 成本不到一杯奶茶钱。本文从表结构设计、AI 网关封装、Prompt 工程、异步队列、后台审核流到前端按钮,完整走一遍 LikeShop 单商户标准版的二次开发链路,所有代码可直接落地。
一、为什么要做这个二开
运营同学最痛苦的场景之一:供应商给了一张 Excel,几千条商品只有「品名 + 规格 + 一张糊图」,上架前要人工补标题、卖点、关键词。一个 SKU 写 3 分钟,1000 个就是 50 小时。
大模型很适合干这件事,但直接在后台商品列表里点一下按钮就调 AI,是典型的坑:
- 商品多、模型慢,HTTP 请求几十秒必超时,PHP-FPM worker 被占满;
- 模型返回不稳定,可能吐一段 Markdown 包裹的文本,甚至编造「纯棉」「国家认证」等不存在的参数;
- 直接覆盖
ls_goods.name,一旦生成质量差,没有回滚、没有留痕,运营会找你算账; - 没有批次和限流,运营连点几下按钮,一天 token 账单直接起飞。
所以我们要做的不是「加一个按钮」,而是一条带审核、可回滚、可重试、可计费的异步流水线。
最终效果:
| 能力 | 说明 |
|---|---|
| 批量选择 | 商品列表勾选 N 个商品,一键创建 AI 文案任务 |
| 异步生成 | CLI worker 后台消费,不阻塞管理后台 |
| 结构化输出 | 标题 / 副标题 / 3~5 条卖点 / SEO 关键词,全部 JSON 返回并校验 |
| 人工审核 | 生成结果先落「待审核」,运营逐条采纳或弃用,不直接改商品表 |
| 一键回滚 | 应用前保存原值快照,支持按批次还原 |
| 成本可观测 | 记录每个批次的输入/输出 token 与估算费用 |
| 多模型可切 | OpenAI 兼容协议,DeepSeek / 通义千问 / 豆包 / 自建 vLLM 换配置即可 |
二、动手前先对齐版本(最容易翻车的一步)
LikeShop 各版本技术栈差异极大,网上「LikeShop 基于 Spring Boot」「基于 ThinkPHP 8 + Vue 3」的文章和单商户标准开源版不是一回事。本文针对的是:
| 项 | 单商户标准开源版(本文目标) |
|---|---|
| 服务端 | PHP 7.2(推荐,7.4/8.0 易踩环境检测与兼容坑)+ ThinkPHP 5.1.x |
| 管理后台接口 | server/app/adminapi/(旧版本为 server/application/admin/) |
| 公共代码 | server/app/common/(模型在 common/model/,服务在 common/service/ 或 common/server/) |
| 管理后台前端 | web/(Vue 2 + Element UI)或旧版 Think 模板 + Layui + jQuery |
| 移动端 | uniapp/(Vue 2 + uni-app) |
| 数据库 | MySQL 5.7,表前缀 ls_,时间字段为 int 时间戳,多数表带 delete_time 软删 |
| 基础设施 | Nginx + PHP-FPM + MySQL + Redis |
版本自检三条命令
grep thinkphp server/composer.json # 框架大版本
ls server/app/adminapi/ 2>/dev/null || ls server/application/admin/ # 应用目录
grep -n "prefix" server/config/database.php # 表前缀如果你的是 Java 版 / SaaS 版 / TP8 版,思路完全一致,只需把示例里的门面类、命名空间和前端框架换掉。
分层原则:LikeShop 是 Controller → Logic → Model(另有 Service 承载外部集成)的分层结构。二开铁律——不改核心表、不改核心 Controller、不侵入订单/商品主链路,新增自己的 Controller / Logic / Service / Command / 表。这样以后升级官方代码,冲突面最小。
字段映射提醒:不同版本商品主表里,标题是 name,副标题/卖点简写字段有的叫 remark、有的叫 intro、有的叫 sub_title。本文统一用 remark 占位,落地前先 DESC ls_goods; 确认,改一处常量即可。同理,若 ls_goods 没有 delete_time,把示例里的 whereNull('delete_time') 去掉或换成实际软删字段。
三、整体架构
┌──────────────┐ 1.勾选商品 ┌────────────────────┐
│ 管理后台列表 │ ───────────────▶ │ AiGoods Controller │
│ (Vue / Layui)│ │ create/detail/apply│
└──────────────┘ └─────────┬──────────┘
▲ │ 2.写任务批次 + 明细(待生成)
│ 5.查看进度 / 审核 ▼
│ ┌────────────────────┐
│ │ MySQL 任务表 │
│ │ ls_ai_goods_task │
│ │ ls_ai_goods_item │
│ └─────────┬──────────┘
│ │ 3.crontab / 常驻 worker 领取
│ ▼
│ ┌────────────────────┐ 4.批量请求
│ │ AiGoodsWorker CLI │ ───────────────▶ 大模型 API
│ │ 限流/重试/并发 │ ◀─────────────── (OpenAI 兼容)
│ └─────────┬──────────┘ JSON 结构化返回
│ │ 校验/清洗/落库为「待审核」
└────────────────────────────────────┘
6.运营采纳 → 事务内更新 ls_goods(保留原值快照)
关键设计决策:
- 异步化:后台按钮只负责「建任务」,真正调用模型在 CLI,彻底避开 PHP-FPM 超时。
- 结果先落中间表:AI 输出是「建议稿」,不是「正式数据」,审核前不动
ls_goods。 - 批次 + 明细两级:批次管进度与成本,明细管单个商品状态、原始输出与错误信息,方便单条重试。
- 幂等:
(task_id, goods_id)唯一索引,重复提交不会产生重复调用;worker 用状态机认领,不会并发重复生成。 - 可回滚:
origin_name/origin_remark保存原值,回滚就是一次反向 UPDATE。
四、数据库设计
不改动 ls_goods 一行字段,新增两张表(utf8mb4 一定要加,卖点里常有 emoji 和特殊符号):
-- ① 批次任务表
CREATE TABLE `ls_ai_goods_task` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`task_no` varchar(32) NOT NULL DEFAULT '' COMMENT '批次号',
`admin_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '发起人',
`total` smallint unsigned NOT NULL DEFAULT 0 COMMENT '商品数',
`success` smallint unsigned NOT NULL DEFAULT 0,
`failed` smallint unsigned NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1处理中 2完成 3部分失败 -1作废',
`model` varchar(64) NOT NULL DEFAULT '' COMMENT '实际使用的模型',
`prompt_version` varchar(16) NOT NULL DEFAULT 'v1',
`token_input` int unsigned NOT NULL DEFAULT 0,
`token_output` int unsigned NOT NULL DEFAULT 0,
`cost` decimal(10,4) NOT NULL DEFAULT 0.0000 COMMENT '估算成本(元)',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
`finish_time` int unsigned NOT NULL DEFAULT 0,
`delete_time` int unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_no` (`task_no`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI商品文案任务批次';
-- ② 任务明细(每个商品一条)
CREATE TABLE `ls_ai_goods_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`task_id` int unsigned NOT NULL DEFAULT 0,
`goods_id` int unsigned NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待生成 1生成中 2待审核 3已应用 4失败 -1已弃用',
`worker_id` varchar(40) NOT NULL DEFAULT '' COMMENT '认领者标识,多worker防重',
`origin_name` varchar(255) NOT NULL DEFAULT '' COMMENT '应用前原标题快照',
`origin_remark` varchar(500) NOT NULL DEFAULT '' COMMENT '应用前原卖点快照',
`ai_title` varchar(120) NOT NULL DEFAULT '' COMMENT 'AI标题',
`ai_remark` varchar(255) NOT NULL DEFAULT '' COMMENT 'AI副标题/一句话卖点',
`ai_points` text COMMENT '卖点数组JSON',
`ai_keywords` varchar(255) NOT NULL DEFAULT '' COMMENT 'SEO关键词,逗号分隔',
`ai_raw` text COMMENT '模型原始返回,排障用',
`token_input` int unsigned NOT NULL DEFAULT 0,
`token_output` int unsigned NOT NULL DEFAULT 0,
`retry` tinyint unsigned NOT NULL DEFAULT 0,
`error` varchar(500) NOT NULL DEFAULT '',
`audit_admin_id` int unsigned NOT NULL DEFAULT 0,
`audit_time` int unsigned NOT NULL DEFAULT 0,
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_goods` (`task_id`,`goods_id`),
KEY `idx_status` (`status`),
KEY `idx_goods` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI商品文案任务明细';
ai_raw强烈建议保留。模型输出质量波动时,你能直接看到「它到底吐了什么」,而不是靠猜。注意定期归档,别让这张表无限膨胀。
五、配置:密钥不进代码库
server/.env 追加:
AI_API_KEY=sk-你的密钥
server/config/ai.php(没有就新建,TP5.1 会自动加载 config 目录下的配置文件):
<?php
return [
// OpenAI 兼容端点:DeepSeek / 通义千问 / 豆包 / 自建 vLLM 只需换这三行
'base_url' => 'https://api.deepseek.com',
'api_key' => \think\facade\Env::get('AI_API_KEY', ''),
'model' => 'deepseek-flash', // 以厂商当前模型名为准,旧名可能已下线
'temperature' => 0.7,
'max_tokens' => 700,
'timeout' => 40, // 单次请求超时(秒)
'connect_timeout'=> 5,
'max_retry' => 3,
'concurrency' => 4, // 单 worker 并发数,别超过账号限额
'batch_size' => 20, // worker 每轮领取条数
// 成本估算(元/百万 token),用于批次统计,实际账单以厂商为准
'price_in' => 1.10,
'price_out' => 4.40,
// 业务约束
'title_max_len' => 30, // 中文字符数,按平台展示位调整
'remark_max_len' => 60,
];
模型名会变:DeepSeek 官方在 2026 年已把
deepseek-chat/deepseek-reasoner下线,改用deepseek-flash、deepseek-v4-pro。所以模型名必须放配置而不是硬编码,别把旧教程里的名字抄进生产代码。
想接其他厂商,只改 base_url + model + api_key:
| 厂商 | OpenAI 兼容 base_url | 备注 |
|---|---|---|
| DeepSeek | https://api.deepseek.com |
用 response_format: json_object |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
兼容模式 |
| 豆包 | https://ark.cn-beijing.volces.com/api/v3 |
填接入点 ID 作为 model |
| 自建 vLLM / Ollama | http://内网地址/v1 |
数据不出内网,适合敏感商品资料 |
六、AI 网关:一个文件搞定重试、限流、结构化输出
放在 server/app/common/service/AiGateway.php(如果你的版本是 common/server/,改目录和命名空间即可)。
<?php
namespace app\common\service;
use think\facade\Config;
use think\facade\Log;
/**
* OpenAI 兼容协议的大模型网关
* 职责:请求组装 / 超时重试 / 并发控制 / 统一返回结构 / 脱敏日志
* PHP 7.2 兼容(未使用箭头函数、类型化属性等 7.4+ 语法)
*/
class AiGateway
{
private $cfg;
public function __construct()
{
$this->cfg = Config::get('ai.');
if (empty($this->cfg['api_key'])) {
throw new \RuntimeException('AI_API_KEY 未配置');
}
}
/** @return array ['ok'=>bool,'content'=>string,'usage'=>array,'error'=>string,'retryable'=>bool] */
public function chat(array $messages, array $options = []): array
{
$results = $this->batchChat([['messages' => $messages, 'options' => $options]], 1);
return $results[0];
}
/**
* 滚动并发:用 curl_multi 把 N 个请求压到同一批,避免串行等待
* @param array $jobs 每项 ['messages'=>[], 'options'=>[]]
* @return array 与 $jobs 同序
*/
public function batchChat(array $jobs, int $concurrency = 0): array
{
$limit = $concurrency > 0 ? $concurrency : (int)$this->cfg['concurrency'];
$limit = max(1, min($limit, 16));
$out = array_fill(0, count($jobs), null);
$cursor = 0;
while ($cursor < count($jobs)) {
$slice = array_slice($jobs, $cursor, $limit, true);
$mh = curl_multi_init();
$handles = [];
foreach ($slice as $idx => $job) {
$payload = $this->buildPayload(
$job['messages'],
isset($job['options']) ? $job['options'] : []
);
$ch = $this->buildHandle($payload);
curl_multi_add_handle($mh, $ch);
$handles[$idx] = ['ch' => $ch, 'payload' => $payload, 'attempt' => 1];
}
do {
$status = curl_multi_exec($mh, $running);
if ($running) {
curl_multi_select($mh, 1.0);
}
} while ($running && $status === CURLM_OK);
foreach ($handles as $idx => $h) {
$raw = curl_multi_getcontent($h['ch']);
$code = (int)curl_getinfo($h['ch'], CURLINFO_HTTP_CODE);
$errno = curl_errno($h['ch']);
curl_multi_remove_handle($mh, $h['ch']);
curl_close($h['ch']);
$out[$idx] = $this->parseResponse($raw, $code, $errno);
// 可重试错误:退避后单条补发(不阻塞整批)
if (!$out[$idx]['ok'] && $out[$idx]['retryable']
&& $h['attempt'] < (int)$this->cfg['max_retry']) {
usleep((int)(pow(2, $h['attempt']) * 200000 + mt_rand(0, 150000)));
$out[$idx] = $this->singleRetry($h['payload'], 2);
}
$this->logOnce($h['payload']['model'], $out[$idx]);
}
curl_multi_close($mh);
$cursor += count($slice);
}
return $out;
}
private function buildPayload(array $messages, array $options): array
{
return [
'model' => isset($options['model']) ? $options['model'] : $this->cfg['model'],
'messages' => $messages,
'temperature' => isset($options['temperature']) ? $options['temperature'] : $this->cfg['temperature'],
'max_tokens' => isset($options['max_tokens']) ? $options['max_tokens'] : $this->cfg['max_tokens'],
'stream' => false,
// 关键:强制 JSON 对象输出(prompt 里也必须写明「输出 JSON」)
'response_format' => ['type' => 'json_object'],
];
}
private function buildHandle(array $payload)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => rtrim($this->cfg['base_url'], '/') . '/chat/completions',
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->cfg['api_key'],
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => (int)$this->cfg['timeout'],
CURLOPT_CONNECTTIMEOUT => (int)$this->cfg['connect_timeout'],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
return $ch;
}
private function parseResponse($raw, int $code, int $errno): array
{
$fail = function ($msg, $retryable) {
return ['ok' => false, 'content' => '', 'usage' => [],
'error' => $msg, 'retryable' => $retryable];
};
if ($errno) {
return $fail('cURL 错误: ' . $errno, true);
}
if ($code === 429 || $code >= 500) {
return $fail('HTTP ' . $code . ' 限流/服务异常', true);
}
if ($code !== 200) {
// 401/402/400 属于配置、余额或参数问题,重试没意义
return $fail('HTTP ' . $code . ' ' . mb_substr((string)$raw, 0, 200), false);
}
$data = json_decode($raw, true);
if (!isset($data['choices'][0]['message']['content'])) {
return $fail('响应结构异常', true);
}
return [
'ok' => true,
'content' => $data['choices'][0]['message']['content'],
'usage' => isset($data['usage']) ? $data['usage'] : [],
'error' => '',
'retryable' => false,
];
}
private function singleRetry(array $payload, int $attempt): array
{
$ch = $this->buildHandle($payload);
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errno = curl_errno($ch);
curl_close($ch);
$res = $this->parseResponse($raw, $code, $errno);
if (!$res['ok'] && $res['retryable'] && $attempt < (int)$this->cfg['max_retry']) {
usleep((int)(pow(2, $attempt) * 250000));
return $this->singleRetry($payload, $attempt + 1);
}
return $res;
}
private function logOnce(string $model, array $res): void
{
// 只记元信息,绝不记录 API Key 与完整 prompt(可能含商品成本价)
Log::write(sprintf(
'[AiGateway] model=%s ok=%s in=%d out=%d err=%s',
$model, $res['ok'] ? 1 : 0,
isset($res['usage']['prompt_tokens']) ? $res['usage']['prompt_tokens'] : 0,
isset($res['usage']['completion_tokens']) ? $res['usage']['completion_tokens'] : 0,
mb_substr($res['error'], 0, 120)
), 'info');
}
}
几个容易忽略的点:
response_format: {"type":"json_object"}必须同时在 prompt 里写明「输出 JSON」,否则部分厂商直接 400;也有兼容端点不支持该参数,建议做成配置开关。401 / 402 / 400是配置、余额或参数问题,不要重试;429 / 5xx / cURL 超时才退避重试,且加随机抖动避免重试风暴。- 用
curl_multi做「小并发 + 分批滚动」,比在 PHP 里起多进程安全得多;concurrency建议 4~8,先看厂商账号并发上限。 - PHP 7.2 下别写
??=、箭头函数、类型化属性;think\facade\Db在 TP5.0 里要换成think\Db。
七、Prompt 工程:让模型只输出你敢用上架的文案
这是整个二开里最值钱的部分。商品文案不是聊天,需要:字数可控、结构固定、不编造参数、不踩广告法。
server/app/common/service/AiGoodsPrompt.php:
<?php
namespace app\common\service;
class AiGoodsPrompt
{
const VERSION = 'v1';
public static function system(): string
{
return <<<'TXT'
你是资深电商文案策划,负责为中文自营商城生成商品标题、副标题与卖点。
【硬性规则】
1. 只能使用输入资料中明确出现的信息。不得编造材质、成分、产地、认证、功效、保修年限、库存、价格、销量。
2. 严禁使用绝对化用语和极限词:最、第一、顶级、国家级、全网最低、绝对、100%、永久、根治、包治、特级、唯一、首选等;也不要写医疗功效或虚假承诺。
3. 标题为「核心品类词 + 关键属性 + 适用场景/人群」结构,不超过 30 个中文字符,不加标点堆砌、不加促销词(包邮、秒杀、特价)。
4. 卖点每条 8~20 个中文字符,动宾或名词短语,3~5 条,彼此不重复、不空泛(禁止「品质优良」「值得信赖」这类废话)。
5. 关键词 5~8 个,用于搜索召回,必须是真实品类词或属性词。
6. 若资料不足以写出 3 条可信卖点,宁可少写,也不要编造;把不确定的信息排除。
7. 只输出 JSON,不要输出任何解释、Markdown 代码块标记或前后缀文字。
【输出 JSON 结构】
{
"title": "商品标题",
"remark": "一句话副标题",
"points": ["卖点1", "卖点2", "卖点3"],
"keywords": ["关键词1", "关键词2"]
}
TXT;
}
/** 组装商品上下文:只喂「可见、可公开」的字段 */
public static function user(array $goods): string
{
$ctx = [
'原始品名' => isset($goods['name']) ? $goods['name'] : '',
'商品分类' => isset($goods['category_name']) ? $goods['category_name'] : '',
'品牌' => isset($goods['brand_name']) ? $goods['brand_name'] : '',
'规格取值' => isset($goods['spec_values']) ? $goods['spec_values'] : [],
'已有简介' => isset($goods['remark']) ? mb_substr(strip_tags((string)$goods['remark']), 0, 200) : '',
];
$ctx = array_filter($ctx, function ($v) {
return !(is_array($v) && empty($v)) && $v !== '' && $v !== null;
});
return "请为以下商品生成文案,只输出规定的 JSON:\n"
. json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/** few-shot 一条就够,放在 system 与 user 之间,显著提升格式稳定性 */
public static function exampleMessages(): array
{
$demo = [
'原始品名' => '折叠收纳箱',
'商品分类' => '家居收纳',
'规格取值' => ['灰色', '大号'],
'已有简介' => '可折叠,带提手',
];
return [
['role' => 'user', 'content' => "请为以下商品生成文案,只输出规定的 JSON:\n"
. json_encode($demo, JSON_UNESCAPED_UNICODE)],
['role' => 'assistant', 'content' => json_encode([
'title' => '折叠收纳箱 大号灰色 带提手可叠放',
'remark' => '衣柜换季收纳,一折即收不占地',
'points' => ['折叠设计省空间', '双侧提手方便搬运', '可叠放稳固不易倒', '灰色耐脏好打理'],
'keywords' => ['折叠收纳箱', '大号收纳箱', '衣物整理箱', '家居收纳', '可折叠'],
], JSON_UNESCAPED_UNICODE)],
];
}
}
三个细节值得展开:
- 不要把
cost_price(成本价)、供应商名、内部备注喂给模型,既省 token 也避免敏感信息出域。 - 规格值要截断。一个商品 3 个规格 × 20 个值,全塞进 prompt 会冲爆上下文,取前 20 个即可,标题用不到全部取值。
- few-shot 一条就够,多放反而让模型照抄示例里的品类词。
八、结果清洗:模型输出永远不能信
AiGoodsLogic::parseAndClean() 负责把模型返回变成「敢落库」的数据:
private static function parseAndClean(string $raw, int $titleMax, int $remarkMax): array
{
$s = trim($raw);
// 去掉模型爱加的 ```json ... ```包裹
if (strpos($s, '```') === 0) {
$s = preg_replace('/^```(?:json)?\s*|\s*```$/u', '', $s);
}
// 有些模型会在 JSON 前后加一句「好的,以下是结果:」
if (($pos = strpos($s, '{')) !== false) {
$end = strrpos($s, '}');
if ($end !== false && $end > $pos) {
$s = substr($s, $pos, $end - $pos + 1);
}
}
$data = json_decode($s, true);
if (!is_array($data) || empty($data['title'])) {
throw new \RuntimeException('JSON 解析失败或无标题');
}
// 广告法极限词兜底(Prompt 是软约束,代码才是硬约束)
$banned = ['最好','最佳','最强','第一','顶级','国家级','全网最低',
'绝对','100%','永久','根治','包治','唯一','首选','特级'];
$clean = function ($str) use ($banned) {
$str = trim(preg_replace('/\s+/u', ' ', (string)$str));
return str_replace($banned, '', $str);
};
$title = mb_substr($clean($data['title']), 0, $titleMax);
$remark = mb_substr($clean(isset($data['remark']) ? $data['remark'] : ''), 0, $remarkMax);
$points = [];
if (isset($data['points']) && is_array($data['points'])) {
foreach ($data['points'] as $p) {
$p = mb_substr($clean($p), 0, 30);
if ($p !== '' && !in_array($p, $points, true)) {
$points[] = $p;
}
if (count($points) >= 5) break;
}
}
if (count($points) < 3) {
throw new \RuntimeException('有效卖点不足 3 条');
}
$keywords = [];
if (isset($data['keywords']) && is_array($data['keywords'])) {
foreach (array_slice($data['keywords'], 0, 8) as $k) {
$k = $clean($k);
if ($k !== '') $keywords[] = mb_substr($k, 0, 20);
}
}
return [
'ai_title' => $title,
'ai_remark' => $remark,
'ai_points' => json_encode($points, JSON_UNESCAPED_UNICODE),
'ai_keywords' => implode(',', $keywords),
];
}
极限词表建议放到后台配置里由运营维护,并接入公司合规词库;命中时就直接标记为「待人工复核」而不是悄悄删掉——悄悄删可能产生「国家级」被删成「级」这种奇怪标题。
九、核心 Logic:建任务 / 跑批次 / 审核应用
server/app/adminapi/logic/AiGoodsLogic.php:
<?php
namespace app\adminapi\logic;
use think\facade\Db;
use think\facade\Config;
use app\common\service\AiGateway;
use app\common\service\AiGoodsPrompt;
class AiGoodsLogic
{
const FIELD_REMARK = 'remark'; // ← 按你的版本改成 intro / sub_title
/** 1) 创建批次(后台按钮调这个,秒回) */
public static function createTask(array $goodsIds, int $adminId): array
{
$goodsIds = array_values(array_unique(array_filter(array_map('intval', $goodsIds))));
if (empty($goodsIds)) throw new \RuntimeException('请先选择商品');
if (count($goodsIds) > 500) throw new \RuntimeException('单批次最多 500 个商品');
$goods = Db::name('goods')->whereIn('id', $goodsIds)
->whereNull('delete_time')
->field('id,name,' . self::FIELD_REMARK)->select();
if (empty($goods)) throw new \RuntimeException('商品不存在');
$now = time();
$no = 'AI' . date('YmdHis') . mt_rand(100, 999);
Db::startTrans();
try {
$taskId = Db::name('ai_goods_task')->insertGetId([
'task_no' => $no, 'admin_id' => $adminId, 'total' => count($goods),
'model' => Config::get('ai.model'),
'prompt_version' => AiGoodsPrompt::VERSION,
'create_time' => $now, 'update_time' => $now,
]);
$rows = [];
foreach ($goods as $g) {
$rows[] = [
'task_id' => $taskId, 'goods_id' => $g['id'], 'status' => 0,
'create_time' => $now, 'update_time' => $now,
];
}
// task_id 每次新建,配合 (task_id, goods_id) 唯一索引保证幂等
Db::name('ai_goods_item')->insertAll($rows);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
return ['task_id' => $taskId, 'task_no' => $no, 'total' => count($goods)];
}
/** 2) 批量预取分类/品牌/规格,避免 N+1;表名按你的版本调整 */
private static function buildContext(array $goodsRows): array
{
$ids = array_column($goodsRows, 'id');
$cats = Db::name('goods_category')->column('name', 'id'); // [id => name]
$brands = Db::name('brand')->column('name', 'id');
$specs = Db::name('goods_spec_value')->whereIn('goods_id', $ids)
->field('goods_id,spec_value_str')->limit(count($ids) * 20)->select();
$specMap = [];
foreach ($specs as $s) {
$gid = $s['goods_id'];
if (!isset($specMap[$gid])) $specMap[$gid] = [];
if (count($specMap[$gid]) < 20) $specMap[$gid][] = $s['spec_value_str'];
}
$out = [];
foreach ($goodsRows as $g) {
$out[$g['id']] = [
'name' => $g['name'],
'category_name' => isset($cats[$g['first_category_id']]) ? $cats[$g['first_category_id']] : '',
'brand_name' => isset($brands[$g['brand_id']]) ? $brands[$g['brand_id']] : '',
'spec_values' => isset($specMap[$g['id']]) ? $specMap[$g['id']] : [],
self::FIELD_REMARK => isset($g[self::FIELD_REMARK]) ? $g[self::FIELD_REMARK] : '',
];
}
return $out;
}
/** 3) worker 入口:领取待生成明细 → 批量请求 → 落库为「待审核」 */
public static function runTask(int $taskId, string $workerId = ''): int
{
$task = Db::name('ai_goods_task')->where('id', $taskId)->find();
if (!$task || !in_array((int)$task['status'], [0, 1], true)) return 0;
$workerId = $workerId ?: ('w' . getmypid() . mt_rand(100, 999));
Db::name('ai_goods_task')->where('id', $taskId)
->update(['status' => 1, 'update_time' => time()]);
$batchSize = (int)Config::get('ai.batch_size');
$done = 0;
while (true) {
// 原子认领:CAS 式 UPDATE,多 worker 下不会重复消费
Db::execute(
'UPDATE `' . Config::get('database.prefix') . 'ai_goods_item` '
. 'SET `status`=1, `worker_id`=:wid, `update_time`=:t '
. 'WHERE `task_id`=:tid AND `status`=0 LIMIT ' . (int)$batchSize,
['wid' => $workerId, 't' => time(), 'tid' => $taskId]
);
$items = Db::name('ai_goods_item')->where('task_id', $taskId)
->where('status', 1)->where('worker_id', $workerId)->select();
if (empty($items)) break;
$gids = array_column($items, 'goods_id');
$goodsRows = Db::name('goods')->whereIn('id', $gids)
->field('id,name,first_category_id,brand_id,' . self::FIELD_REMARK)->select();
$ctx = self::buildContext($goodsRows);
$gateway = new AiGateway();
$jobs = [];
foreach ($items as $it) {
$jobs[] = [
'messages' => array_merge(
[['role' => 'system', 'content' => AiGoodsPrompt::system()]],
AiGoodsPrompt::exampleMessages(),
[['role' => 'user', 'content' => AiGoodsPrompt::user(
isset($ctx[$it['goods_id']]) ? $ctx[$it['goods_id']] : []
)]]
),
];
}
$results = $gateway->batchChat($jobs);
$inTok = $outTok = 0;
foreach ($items as $i => $it) {
$r = $results[$i];
$inTok += (int)(isset($r['usage']['prompt_tokens']) ? $r['usage']['prompt_tokens'] : 0);
$outTok += (int)(isset($r['usage']['completion_tokens']) ? $r['usage']['completion_tokens'] : 0);
$update = [
'ai_raw' => mb_substr($r['ok'] ? $r['content'] : $r['error'], 0, 2000),
'token_input' => (int)(isset($r['usage']['prompt_tokens']) ? $r['usage']['prompt_tokens'] : 0),
'token_output' => (int)(isset($r['usage']['completion_tokens']) ? $r['usage']['completion_tokens'] : 0),
'update_time' => time(),
];
if ($r['ok']) {
try {
$clean = self::parseAndClean(
$r['content'],
(int)Config::get('ai.title_max_len'),
(int)Config::get('ai.remark_max_len')
);
$update = array_merge($update, $clean, ['status' => 2, 'error' => '']);
} catch (\Exception $e) {
$update = array_merge($update,
['status' => 4, 'error' => '清洗失败: ' . $e->getMessage()]);
}
} else {
$update = array_merge($update,
['status' => 4, 'error' => mb_substr($r['error'], 0, 300)]);
}
Db::name('ai_goods_item')->where('id', $it['id'])->update($update);
$done++;
}
Db::name('ai_goods_task')->where('id', $taskId)
->inc('token_input', $inTok)->inc('token_output', $outTok)
->update(['update_time' => time()]);
}
// 结算批次
$ok = Db::name('ai_goods_item')->where('task_id', $taskId)->where('status', 2)->count();
$bad = Db::name('ai_goods_item')->where('task_id', $taskId)->where('status', 4)->count();
$task = Db::name('ai_goods_task')->where('id', $taskId)->find();
$cost = ($task['token_input'] / 1000000) * (float)Config::get('ai.price_in')
+ ($task['token_output'] / 1000000) * (float)Config::get('ai.price_out');
Db::name('ai_goods_task')->where('id', $taskId)->update([
'success' => $ok, 'failed' => $bad,
'status' => $bad > 0 ? 3 : 2,
'cost' => round($cost, 4),
'finish_time' => time(), 'update_time' => time(),
]);
return $done;
}
/** 4) 审核通过:事务内写入商品表,并保存原值快照 */
public static function apply(array $itemIds, int $adminId): int
{
$itemIds = array_map('intval', $itemIds);
$items = Db::name('ai_goods_item')->whereIn('id', $itemIds)->where('status', 2)->select();
if (empty($items)) return 0;
Db::startTrans();
try {
foreach ($items as $it) {
$goods = Db::name('goods')->where('id', $it['goods_id'])->lock(true)->find();
if (!$goods) continue;
Db::name('goods')->where('id', $it['goods_id'])->update([
'name' => $it['ai_title'],
self::FIELD_REMARK => $it['ai_remark'],
'update_time' => time(),
]);
Db::name('ai_goods_item')->where('id', $it['id'])->update([
'origin_name' => $goods['name'],
'origin_remark' => mb_substr((string)(isset($goods[self::FIELD_REMARK]) ? $goods[self::FIELD_REMARK] : ''), 0, 500),
'status' => 3, 'audit_admin_id' => $adminId,
'audit_time' => time(), 'update_time' => time(),
]);
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
return count($items);
}
/** 5) 一键回滚:把某个批次已应用的商品还原 */
public static function rollback(int $taskId): int
{
$items = Db::name('ai_goods_task')->where('id', $taskId)->count()
? Db::name('ai_goods_item')->where('task_id', $taskId)->where('status', 3)->select()
: [];
Db::startTrans();
try {
foreach ($items as $it) {
Db::name('goods')->where('id', $it['goods_id'])->update([
'name' => $it['origin_name'],
self::FIELD_REMARK => $it['origin_remark'],
'update_time' => time(),
]);
Db::name('ai_goods_item')->where('id', $it['id'])
->update(['status' => 2, 'update_time' => time()]);
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
return count($items);
}
}
并发覆盖防护:
apply()用了lock(true)行锁。更稳妥的做法是创建明细时就存一份origin_name生成时快照,应用前比对商品当前名称是否变化,不一致就标「需人工确认」,避免冲掉运营在生成期间的手工修改。生产环境建议补上这层。认领 SQL 说明:上面用
UPDATE ... WHERE status=0 LIMIT N做原子认领,再用worker_id精确取回自己那一批。若你的 MySQL 版本或 TP 门面对LIMIT绑定有差异,也可以先SELECT ... FOR UPDATE再逐条 CAS,思路不变。
十、Controller 与路由
server/app/adminapi/controller/AiGoods.php:
<?php
namespace app\adminapi\controller;
use think\facade\Request;
use think\facade\Db;
use app\adminapi\logic\AiGoodsLogic;
class AiGoods
{
// 沿用 LikeShop 的返回结构
private static function ok($data = [], string $msg = ''): \think\response\Json
{
return json(['code' => 1, 'show' => 0, 'msg' => $msg, 'data' => $data]);
}
private static function fail(string $msg): \think\response\Json
{
return json(['code' => 0, 'show' => 1, 'msg' => $msg, 'data' => []]);
}
public function create()
{
try {
$ids = Request::post('goods_ids/a', []);
// TODO: 接上你项目的后台鉴权中间件,从登录态取 admin_id
$adminId = (int)Request::header('Admin-Id', 0);
return self::ok(AiGoodsLogic::createTask($ids, $adminId), '任务已创建,后台生成中');
} catch (\Exception $e) {
return self::fail($e->getMessage());
}
}
public function detail()
{
$id = (int)Request::get('id', 0);
$task = Db::name('ai_goods_task')->where('id', $id)->find();
if (!$task) return self::fail('任务不存在');
$items = Db::name('ai_goods_item')->where('task_id', $id)->order('id', 'asc')->select();
return self::ok(['task' => $task, 'items' => $items]);
}
public function apply()
{
try {
$ids = Request::post('item_ids/a', []);
return self::ok(['count' => AiGoodsLogic::apply($ids, (int)Request::header('Admin-Id', 0))]);
} catch (\Exception $e) {
return self::fail($e->getMessage());
}
}
public function rollback()
{
try {
return self::ok(['count' => AiGoodsLogic::rollback((int)Request::post('task_id', 0))]);
} catch (\Exception $e) {
return self::fail($e->getMessage());
}
}
public function retry()
{
$id = (int)Request::post('item_id', 0);
$taskId = (int)Db::name('ai_goods_item')->where('id', $id)->value('task_id');
Db::name('ai_goods_item')->where('id', $id)->where('status', 4)
->inc('retry', 1)
->update(['status' => 0, 'worker_id' => '', 'error' => '', 'update_time' => time()]);
if ($taskId) {
Db::name('ai_goods_task')->where('id', $taskId)
->update(['status' => 0, 'update_time' => time()]);
}
return self::ok([], '已重新排队');
}
}
路由:LikeShop 后台接口大多走自动注册,若你的版本用显式路由,在 server/route/route.php 补:
Route::post('adminapi/ai_goods/create', 'adminapi/AiGoods/create');
Route::get ('adminapi/ai_goods/detail', 'adminapi/AiGoods/detail');
Route::post('adminapi/ai_goods/apply', 'adminapi/AiGoods/apply');
Route::post('adminapi/ai_goods/rollback', 'adminapi/AiGoods/rollback');
Route::post('adminapi/ai_goods/retry', 'adminapi/AiGoods/retry');
别忘了加权限节点:在后台「权限管理 / 菜单」里插入 ai_goods 菜单与按钮权限,否则运营看不到按钮,或越权调用。AI 接口会花钱,必须做权限与频率限制(建议「同一管理员 10 分钟内最多创建 3 个批次」)。
十一、异步 Worker:crontab 一行搞定
server/app/common/command/AiGoodsWorker.php:
<?php
namespace app\common\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Db;
use think\facade\Cache;
use app\adminapi\logic\AiGoodsLogic;
class AiGoodsWorker extends Command
{
protected function configure()
{
$this->setName('ai:goods:run')->setDescription('消费 AI 商品文案任务');
}
protected function execute(Input $input, Output $output)
{
// 分布式锁:防止 crontab 重叠或多个 worker 抢同一批
$lockKey = 'ai_goods_worker_lock';
$redis = Cache::store('redis')->handler();
if (!$redis->set($lockKey, 1, ['nx', 'ex' => 600])) {
$output->writeln('已有 worker 在运行,跳过');
return 0;
}
try {
$workerId = 'w' . getmypid() . mt_rand(100, 999);
$ids = Db::name('ai_goods_task')->whereIn('status', [0, 1])
->order('id', 'asc')->limit(3)->column('id');
foreach ($ids as $id) {
$n = AiGoodsLogic::runTask((int)$id, $workerId);
$output->writeln(date('H:i:s') . " task#{$id} 处理 {$n} 条");
}
} finally {
$redis->del($lockKey);
}
return 0;
}
}
注册命令(server/app/command.php,旧版本是 server/application/command.php):
<?php
return [
'app\common\command\AiGoodsWorker',
];
crontab:
# 每分钟拉一次,锁保证不重叠
* * * * * cd /www/wwwroot/likeshop/server && /usr/bin/php think ai:goods:run >> /var/log/ai_goods.log 2>&1
手动跑一次验证:
cd server && php think ai:goods:run
没有 Redis?用 MySQL 的
GET_LOCK('ai_goods_worker', 600)或文件锁flock替代,别裸跑。另外 LikeShop 自带「定时任务管理 + 运行日志」功能,也可以把 worker 挂到它的计划任务里,省掉 crontab。
十二、管理后台前端:加按钮 + 审核弹窗
新版(web/ Vue 2 + Element UI):
web/src/api/aiGoods.js
import request from '@/utils/request'
export function aiCreate(goodsIds) {
return request({
url: '/adminapi/ai_goods/create', method: 'post', data: {
goods_ids: goodsIds } })
}
export function aiDetail(taskId) {
return request({
url: '/adminapi/ai_goods/detail', method: 'get', params: {
id: taskId } })
}
export function aiApply(itemIds) {
return request({
url: '/adminapi/ai_goods/apply', method: 'post', data: {
item_ids: itemIds } })
}
export function aiRollback(taskId) {
return request({
url: '/adminapi/ai_goods/rollback', method: 'post', data: {
task_id: taskId } })
}
商品列表页 web/src/views/goods/list.vue:
<el-button type="primary" plain :disabled="!multipleSelection.length"
:loading="aiLoading" @click="onAiGenerate">
AI 生成标题/卖点({
{ multipleSelection.length }})
</el-button>
<el-dialog title="AI 文案审核" :visible.sync="aiDialog" width="900px">
<el-progress :percentage="aiProgress" />
<el-table :data="aiItems" @selection-change="v => aiChecked = v">
<el-table-column type="selection" width="45" />
<el-table-column prop="goods_id" label="商品ID" width="90" />
<el-table-column label="原标题 / AI 标题">
<template slot-scope="s">
<div class="muted">{
{ s.row.origin_name || '—' }}</div>
<div>{
{ s.row.ai_title }}</div>
</template>
</el-table-column>
<el-table-column prop="ai_remark" label="副标题" />
<el-table-column label="卖点">
<template slot-scope="s">
<el-tag v-for="(p, i) in pointsOf(s.row)" :key="i" size="mini">{
{ p }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" />
</el-table>
<span slot="footer">
<el-button @click="aiDialog = false">关闭</el-button>
<el-button type="danger" plain @click="onAiRollback">回滚本批次</el-button>
<el-button type="primary" :disabled="!aiChecked.length" @click="onAiApply">采纳所选</el-button>
</span>
</el-dialog>
<script>
import { aiCreate, aiDetail, aiApply, aiRollback } from '@/api/aiGoods'
export default {
data() {
return {
aiLoading: false, aiDialog: false, aiItems: [], aiChecked: [],
aiProgress: 0, aiTaskId: 0, aiTimer: null
}
},
beforeDestroy() { clearInterval(this.aiTimer) },
methods: {
pointsOf(row) {
try { return JSON.parse(row.ai_points || '[]') } catch (e) { return [] }
},
async onAiGenerate() {
this.aiLoading = true
try {
const res = await aiCreate(this.multipleSelection.map(r => r.id))
this.aiTaskId = res.data.task_id
this.aiDialog = true
this.poll()
} finally { this.aiLoading = false }
},
poll() {
clearInterval(this.aiTimer)
this.aiTimer = setInterval(async () => {
const res = await aiDetail(this.aiTaskId)
const { task, items } = res.data
this.aiItems = items
this.aiProgress = task.total
? Math.round((task.success + task.failed) / task.total * 100) : 0
if (task.status === 2 || task.status === 3) clearInterval(this.aiTimer)
}, 3000)
},
async onAiApply() {
await aiApply(this.aiChecked.map(r => r.id))
this.$message.success('已采纳,请刷新商品列表查看')
this.poll()
},
async onAiRollback() {
await aiRollback(this.aiTaskId)
this.$message.success('已回滚')
this.poll()
}
}
}
</script>
旧版(Think 模板 + Layui + jQuery)思路一样,在商品列表视图的表格工具栏加按钮:
<button class="layui-btn layui-btn-normal" id="ai-generate">AI 生成标题/卖点</button>
<script>
$('#ai-generate').on('click', function () {
var checkStatus = table.checkStatus('goods-list'); // 你的表格 id
var ids = checkStatus.data.map(function (r) {
return r.id; });
if (!ids.length) {
layer.msg('请先勾选商品'); return; }
layer.confirm('为选中的 ' + ids.length + ' 个商品生成文案?生成后需人工审核才会生效', function (idx) {
layer.close(idx);
$.post('/adminapi/ai_goods/create', {
goods_ids: ids }, function (res) {
layer.msg(res.msg);
if (res.code === 1) {
/* 打开审核弹层 + 轮询 aiDetail */ }
}, 'json');
});
});
</script>
十三、实测效果
输入(供应商原始数据):
{
"原始品名": "折叠收纳箱",
"商品分类": "家居收纳",
"规格取值": ["灰色", "大号"],
"已有简介": "可折叠,带提手"
}
输出:
{
"title": "折叠收纳箱 大号灰色 带提手可叠放",
"remark": "衣柜换季收纳,一折即收不占地",
"points": ["折叠设计省空间", "双侧提手方便搬运", "可叠放稳固不易倒", "灰色耐脏好打理"],
"keywords": ["折叠收纳箱", "大号收纳箱", "衣物整理箱", "家居收纳", "可折叠"]
}
| 维度 | 人工撰写 | AI 生成 + 人工审核 |
|---|---|---|
| 单件耗时 | 约 3 分钟 | 生成约 2 秒,审核约 20 秒 |
| 1000 件总耗时 | 约 50 小时 | 生成约 12 分钟(并发 4),审核约 5 小时 |
| 格式一致性 | 随人波动 | JSON 强约束,字数/条数统一 |
| 风险 | 低 | 需审核拦截幻觉与极限词 |
一句话:AI 负责从 0 到 80 分,人负责 80 到 100 分,别指望全自动上架。
十四、成本估算
以 2026 年 11 月 DeepSeek 官方 deepseek-flash 非高峰价为例(每百万 token:输入 $0.15、输出 $0.60;高峰时段约翻倍,实际请以官方定价页为准):
| 项 | 单件估算 | 1000 件 |
|---|---|---|
| 输入 token(system + few-shot + 商品上下文) | ~600 | 0.6 M |
| 输出 token | ~180 | 0.18 M |
| 输入费用 | — | $0.09 |
| 输出费用 | — | $0.108 |
| 合计 | — | 约 $0.20(≈ ¥1.5) |
算上 10% 失败重试、审核后重生成、高峰时段翻倍,1000 个商品按 ¥3~6 预留预算就非常宽裕。真正的成本是运营审核的人力,不是 token。
因此成本控制重点不在模型单价,而在:
- 别重复生成:同一商品 + 同一 prompt 版本 + 同一模型,命中历史结果就直接复用;
- 限制批次大小与创建频率,防止误触;
- 记录 token 与成本,在批次详情里给运营看,超预算自动告警;
- system prompt 和 few-shot 尽量精简,它们是每条请求都要重复付费的部分。
十五、踩坑清单(都是真实会遇到的)
| # | 坑 | 解法 |
|---|---|---|
| 1 | 模型返回 ```json 包裹、或前面加「好的,以下是…」 | 先剥围栏、截取首个 {
到末个 } 再 json_decode,见 parseAndClean() |
| 2 | response_format: json_object 报 400 |
prompt 里必须同时出现「输出 JSON」字样;部分兼容端点需去掉该参数,做配置开关 |
| 3 | 后台点按钮后 504 | 生成全部放 CLI;接口只写两张表,20ms 返回 |
| 4 | 多个 worker 重复调用,账单翻倍 | Redis 锁 + UPDATE ... WHERE status=0 原子认领 + worker_id 精确取回 |
| 5 | AI 覆盖运营刚手改的标题 | 应用前比对生成时的名称快照,不一致转「需人工确认」 |
| 6 | 幻觉:编造「纯棉」「德国进口」「三年质保」 | prompt 硬约束 + parseAndClean 校验 + 人工审核是最后一道闸 |
| 7 | 极限词违规 | 代码级禁用词兜底;词库放后台配置,命中即标记复核,别静默删除 |
| 8 | 商品详情富文本塞进 prompt | strip_tags + mb_substr(0, 200),规格取值最多 20 个 |
| 9 | emoji / 特殊符号写入报 Incorrect string value |
新表统一 utf8mb4,MySQL 连接字符集也检查 |
| 10 | PHP 7.2 下 cURL 报 SSL 错误 | 更新 ca-bundle,别图省事关 CURLOPT_SSL_VERIFYPEER |
| 11 | Class 'think\Db' not found |
TP5.1 用 think\facade\Db,TP5.0 才是 think\Db |
| 12 | 模型名突然失效 | 模型名放配置;关注厂商下线公告,做好一键切换 |
| 13 | 密钥被提交进 Git | .env 加入 .gitignore,日志脱敏,生产用环境变量注入 |
| 14 | 任务表无限膨胀 | 定期归档 30 天前的 ai_raw,只留统计 |
十六、合规与边界(别跳过这一段)
- 广告法:商品标题与卖点属于商业宣传,绝对化用语、虚假功效、虚构认证都可能带来处罚。AI 生成内容必须经人工审核后发布,代码里的禁用词只是兜底。
- 生成内容标识:面向公众发布 AI 生成合成内容,按《人工智能生成合成内容标识办法》(2025 年 9 月 1 日起施行)及平台规则做好标识与日志留痕;具体合规口径以法务意见为准。
- 数据出域:商品资料、成本价、供应商信息不要随 prompt 发给第三方模型;敏感类目建议走私有化部署(vLLM / Ollama)或做字段脱敏。
- 开源协议:LikeShop 单商户标准版可自用、可二开、可去界面版权,但禁止基于其源码二次发行与销售;企业源码版需商业授权。二开功能自用没问题,别把整包源码打包出售。
- 可解释与可回滚:保留
ai_raw、prompt 版本、模型名、审核人、时间戳,出问题能定位到具体批次。
十七、还能往上加什么
- 图片理解:多模态模型读主图,自动补颜色/风格/材质词,再生成文案;
- 相似款参考:向量库检索同店高点击商品标题,作为 few-shot 喂给模型,风格自动对齐店铺调性;
- A/B 测试:同一商品生成 2~3 版标题,按曝光点击率择优,反哺 prompt;
- 多语言 / 多平台:一份商品上下文生成淘宝风、小红书风、抖音风三套文案;
- SEO 闭环:把
ai_keywords写入搜索索引,观察搜索无结果词,迭代关键词策略。
十八、文件清单与 10 分钟跑通步骤
| 文件 | 作用 |
|---|---|
server/.env |
API Key |
server/config/ai.php |
模型、并发、成本、字数配置 |
server/app/common/service/AiGateway.php |
大模型网关(重试/并发/日志) |
server/app/common/service/AiGoodsPrompt.php |
System / User / Few-shot 模板 |
server/app/adminapi/logic/AiGoodsLogic.php |
建任务、跑批次、审核、回滚 |
server/app/adminapi/controller/AiGoods.php |
后台接口 |
server/app/common/command/AiGoodsWorker.php |
CLI worker |
server/app/command.php |
注册命令 |
web/src/api/aiGoods.js + 商品列表页 |
按钮与审核弹窗 |
ls_ai_goods_task / ls_ai_goods_item |
两张新表 |
跑通顺序:
- 执行两段 DDL,确认表前缀是
ls_; DESC ls_goods;确认副标题字段与软删字段,改AiGoodsLogic::FIELD_REMARK;- 确认
think\facade\Db与你的 TP 版本匹配; - 填
.env的AI_API_KEY; - 单独写 10 行脚本调一次
new AiGateway()->chat(...),先验证模型通不通; php think ai:goods:run手动跑一次;- 后台勾选 2 个商品点按钮,看
ls_ai_goods_item是否变status=2; - 前端接审核弹窗,点「采纳所选」,核对
ls_goods; - 加 crontab、权限节点、批次创建频率限制;
- 小批量灰度 50 个商品,人工全审一遍,再放大到全量。
写在最后
把大模型接进老系统,技术难度其实不高——一个 curl、一个 JSON、一张表就够了。真正决定这个功能能不能上生产的,是那几件「不性感」的事:异步化、幂等、结构化校验、人工审核、成本可观测、一键回滚。
LikeShop 标准版的 Controller → Logic → Model 分层在这里帮了大忙:我们全程没有动商品主链路,只新增了配置、两张表、一个网关、一个 Logic、一个 CLI 命令和一组后台界面。上线后如果发现某个模型效果不好,改一行配置就能切走;如果某批文案翻车,点一下「回滚本批次」就能恢复。
如果你也在给老商城系统接 AI,欢迎在评论区交流踩坑经历——尤其是你们怎么解决「模型编造参数」这个问题,我很好奇有没有比人工全审更省力的做法。