LikeShop 单商户标准版二开实战:接入大模型批量生成商品标题与卖点(附完整代码)

简介: LikeShop单商户标准版AI文案二开实战:基于ThinkPHP5.1,实现商品标题/卖点批量生成。含异步队列、结构化输出、人工审核流、一键回滚与成本监控,全程低侵入、可落地,千条商品处理成本仅约一杯奶茶钱。

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(保留原值快照)

关键设计决策:

  1. 异步化:后台按钮只负责「建任务」,真正调用模型在 CLI,彻底避开 PHP-FPM 超时。
  2. 结果先落中间表:AI 输出是「建议稿」,不是「正式数据」,审核前不动 ls_goods。
  3. 批次 + 明细两级:批次管进度与成本,明细管单个商品状态、原始输出与错误信息,方便单条重试。
  4. 幂等:(task_id, goods_id) 唯一索引,重复提交不会产生重复调用;worker 用状态机认领,不会并发重复生成。
  5. 可回滚: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)],
        ];
    }
}

三个细节值得展开:

  1. 不要把 cost_price(成本价)、供应商名、内部备注喂给模型,既省 token 也避免敏感信息出域。
  2. 规格值要截断。一个商品 3 个规格 × 20 个值,全塞进 prompt 会冲爆上下文,取前 20 个即可,标题用不到全部取值。
  3. 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。

因此成本控制重点不在模型单价,而在:

  1. 别重复生成:同一商品 + 同一 prompt 版本 + 同一模型,命中历史结果就直接复用;
  2. 限制批次大小与创建频率,防止误触;
  3. 记录 token 与成本,在批次详情里给运营看,超预算自动告警;
  4. 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,只留统计

十六、合规与边界(别跳过这一段)

  1. 广告法:商品标题与卖点属于商业宣传,绝对化用语、虚假功效、虚构认证都可能带来处罚。AI 生成内容必须经人工审核后发布,代码里的禁用词只是兜底。
  2. 生成内容标识:面向公众发布 AI 生成合成内容,按《人工智能生成合成内容标识办法》(2025 年 9 月 1 日起施行)及平台规则做好标识与日志留痕;具体合规口径以法务意见为准。
  3. 数据出域:商品资料、成本价、供应商信息不要随 prompt 发给第三方模型;敏感类目建议走私有化部署(vLLM / Ollama)或做字段脱敏。
  4. 开源协议:LikeShop 单商户标准版可自用、可二开、可去界面版权,但禁止基于其源码二次发行与销售;企业源码版需商业授权。二开功能自用没问题,别把整包源码打包出售。
  5. 可解释与可回滚:保留 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 两张新表

跑通顺序:

  1. 执行两段 DDL,确认表前缀是 ls_;
  2. DESC ls_goods; 确认副标题字段与软删字段,改 AiGoodsLogic::FIELD_REMARK;
  3. 确认 think\facade\Db 与你的 TP 版本匹配;
  4. 填 .env 的 AI_API_KEY;
  5. 单独写 10 行脚本调一次 new AiGateway()->chat(...),先验证模型通不通;
  6. php think ai:goods:run 手动跑一次;
  7. 后台勾选 2 个商品点按钮,看 ls_ai_goods_item 是否变 status=2;
  8. 前端接审核弹窗,点「采纳所选」,核对 ls_goods;
  9. 加 crontab、权限节点、批次创建频率限制;
  10. 小批量灰度 50 个商品,人工全审一遍,再放大到全量。

写在最后

把大模型接进老系统,技术难度其实不高——一个 curl、一个 JSON、一张表就够了。真正决定这个功能能不能上生产的,是那几件「不性感」的事:异步化、幂等、结构化校验、人工审核、成本可观测、一键回滚。

LikeShop 标准版的 Controller → Logic → Model 分层在这里帮了大忙:我们全程没有动商品主链路,只新增了配置、两张表、一个网关、一个 Logic、一个 CLI 命令和一组后台界面。上线后如果发现某个模型效果不好,改一行配置就能切走;如果某批文案翻车,点一下「回滚本批次」就能恢复。

如果你也在给老商城系统接 AI,欢迎在评论区交流踩坑经历——尤其是你们怎么解决「模型编造参数」这个问题,我很好奇有没有比人工全审更省力的做法。

目录
相关文章
|
12天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
5230 40
|
13天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
4690 54
|
7天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
1826 4
|
13天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
2229 7
|
5天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1174 0
|
8天前
|
云安全 人工智能 安全
|
1天前
|
云安全 人工智能 安全
|
7天前
|
人工智能 并行计算 调度
本地 AI 一键部署工具 LocModel:Windows 端集成画图、视频生成、Agent 的开源模型运行方案
LocModel 是专为 Windows 设计的一站式本地 AI 工具,支持零配置启动、自动硬件检测、GGUF 模型一键加载、文生图/视频生成及 Agent 自动化任务。适配 8G 显存,内置显存分片调度,轻松实现 AI 漫剧批量创作与离线大模型调用。(239 字)

热门文章

最新文章