diff --git a/API_DOC.md b/API_DOC.md index 514eecd..204835d 100644 --- a/API_DOC.md +++ b/API_DOC.md @@ -252,6 +252,54 @@ | `/manage/partner-card/stock` | GET | partner库存查询 | | `/manage/partner-card/list` | GET | 池子记录分页 | +### 2.22 推荐页配置 `/manage/recommend-page` + +设备厂商 1920×1080 整屏推荐页:运营选片配置 7 个固定槽位,一键导出 zip 交付包(按槽位尺寸裁好的图片 + 影片编号 + am start 拉起命令)。 + +| 路径 | 方法 | 说明 | +|------|------|------| +| `/manage/recommend-page/list` | GET | 分页列表(page/limit/name) | +| `/manage/recommend-page/info/{id}` | GET | 详情(含 slots 解析后数组) | +| `/manage/recommend-page/slot-definitions` | GET | 7 个槽位定义(code/类型/尺寸/默认动作/海报字段) | +| `/manage/recommend-page/save` | POST | 新建或更新(有 id 更新) | +| `/manage/recommend-page/delete` | POST | 软删除(body: [id]) | +| `/manage/recommend-page/export/{id}` | GET | 一键导出 zip 交付包(浏览器下载) | + +**保存请求体**(slot_01~06 必填 contentcode,slot_07 更多入口必填 posterUrl): + +```json +{ + "id": null, + "name": "2026年10月推荐", + "remark": "备注", + "slots": [ + { "slotCode": "slot_01", "contentcode": "23010012026061709581101832" }, + { "slotCode": "slot_02", "contentcode": "..." }, + { "slotCode": "slot_03", "contentcode": "..." }, + { "slotCode": "slot_04", "contentcode": "..." }, + { "slotCode": "slot_05", "contentcode": "..." }, + { "slotCode": "slot_06", "contentcode": "..." }, + { "slotCode": "slot_07", "posterUrl": "https://.../more.jpg" } + ] +} +``` + +- 槽位可选字段:`posterUrl`(自定义海报,横图槽位补横版图)、`actionParam`(自定义拉起命令,支持 `{contentcode}`/`{mediaName}` 占位符) +- 保存时后端按 contentcode 调橘视厅同步影片名与海报(无效 contentcode 报错) +- 海报默认策略:竖图槽(slot_02~04)取 posterimg 竖版,横图槽(slot_01/05/06)取 thumbimg 横版 +- 拉起命令默认值:DETAIL = `am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity --es media_id "{contentcode}"`(备选 `--es film_name "{mediaName}"`);LAUNCH = `am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity` + +**导出 zip 结构**: + +``` +推荐页_2026年10月推荐.zip +├── images/slot_01.png ~ slot_07.png # 按槽位精确尺寸居中裁剪 +├── recommend_config.json # 含 contentcode/actionType/actionParam(占位符已替换) +└── README.txt # 排版与拉起规则说明 +``` + +> 前端下载 zip 需带 JWT 请求头,用 axios blob 方式(`responseType: 'blob'`)下载,不能直接 window.open。 + --- ## 三、第三方开放接口(appKey签名鉴权) diff --git a/FE-RECOMMEND-GUIDE.md b/FE-RECOMMEND-GUIDE.md new file mode 100644 index 0000000..18a8649 --- /dev/null +++ b/FE-RECOMMEND-GUIDE.md @@ -0,0 +1,248 @@ +# 推荐页配置 · 前端对接文档 + +> 功能:设备厂商 1920×1080 整屏电影推荐页,运营在后台选片配置 7 个固定槽位,一键导出 zip 交付包发给厂商。 +> 后端已上线,本文档为前端实施所需全部信息。 + +--- + +## 1. 通用约定 + +- **鉴权**:所有接口走管理后台 JWT,请求头带 `token`(与现有 /manage 接口一致) +- **返回格式**:`{ code: 0, msg: "", data: {...} }`,code=0 成功,非 0 失败(msg 为错误信息) +- **新增菜单**:「媒资管理 → 推荐页配置」,路由 `manage/recommend-page`(后台 sys_menu 已配置 id=126,超管自动可见,其他角色需在角色管理勾选) + +--- + +## 2. 槽位定义(固定 7 个,不可增减) + +整屏排版(1920×1080 两行网格,槽位间距 20px,左右边距 70px): + +``` +┌────────────────┐ ┌─────┐ ┌─────┐ ┌─────┐ +│ slot_01 │ │slot_02│ │slot_03│ │slot_04│ ← 上行 +│ 700×400 │ │340×440│ │340×440│ │340×440│ +└────────────────┘ └─────┘ └─────┘ └─────┘ +┌──────────────┐ ┌──────────────┐ ┌─────────┐ +│ slot_05 │ │ slot_06 │ │ slot_07 │ ← 下行 +│ 700×300 │ │ 700×300 │ │ 340×300 │ +└──────────────┘ └──────────────┘ └─────────┘ +``` + +| 槽位 | 类型 | 尺寸 | 说明 | 影片 | +|---|---|---|---|---| +| slot_01 | MAIN 主推 | 700×400 | 横图 | 必填 contentcode | +| slot_02 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 | +| slot_03 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 | +| slot_04 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 | +| slot_05 | LANDSCAPE 横图 | 700×300 | 横图 | 必填 | +| slot_06 | LANDSCAPE 横图 | 700×300 | 横图 | 必填 | +| slot_07 | MORE 更多入口 | 340×300 | 无影片,点击拉起橘视厅 | 不填,必填海报 URL | + +**编辑页建议**:按 0.25 倍缩放渲染线框(175×100 / 85×110 / 175×75 / 85×75),每槽位显示海报缩略图 + 影片名 + 尺寸标注。 + +--- + +## 3. 接口清单 + +Base:`/manage/recommend-page` + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/list` | 分页列表,参数 `page`、`limit`、`name`(可选) | +| GET | `/info/{id}` | 详情(slots 已解析为数组) | +| GET | `/slot-definitions` | 槽位定义(渲染线框用,见 §3.1) | +| POST | `/save` | 新建或更新(有 id 更新,无 id 新建) | +| POST | `/delete` | 软删除,body: `[1, 2]`(id 数组) | +| GET | `/export/{id}` | 导出 zip 交付包(**必须 blob 方式下载,见 §6**) | + +### 3.1 槽位定义 GET `/slot-definitions` + +```json +{ + "code": 0, + "data": { + "slots": { + "slot_01": { "slotCode": "slot_01", "slotType": "MAIN", "width": 700, "height": 400, + "defaultActionType": "DETAIL", "mediaRequired": true, "posterField": "thumbimg" }, + "slot_02": { "slotCode": "slot_02", "slotType": "PORTRAIT", "width": 340, "height": 440, + "defaultActionType": "DETAIL", "mediaRequired": true, "posterField": "posterimg" } + // ... slot_03~07 + } + } +} +``` + +### 3.2 列表 GET `/list?page=1&limit=10` + +```json +{ + "code": 0, + "data": { + "page": { + "totalCount": 5, "pageSize": 10, "currPage": 1, "totalPage": 1, + "list": [ + { "id": 1, "name": "2026年10月推荐", "remark": "备注", "deleteFlag": "UNDEL", + "createdAt": "2026-09-26 15:00:00", "updatedAt": "2026-09-26 15:00:00" } + ] + } + } +} +``` + +### 3.3 详情 GET `/info/{id}` + +```json +{ + "code": 0, + "data": { + "recommendPage": { "id": 1, "name": "2026年10月推荐", "remark": "", "slots": "[...原始JSON字符串...]", "createdAt": "..." }, + "slots": [ + { + "slotCode": "slot_01", + "slotType": "MAIN", + "width": 700, "height": 400, + "contentcode": "23010012026061709581101832", + "mediaName": "影片名(后端从橘视厅同步)", + "posterUrl": "https://imgcms.jetsenx.huashi.tv/.../448x252_xxx.png", + "customPoster": false, + "actionType": "DETAIL", + "actionParam": "am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity --es media_id \"{contentcode}\"", + "rawMedia": { "...橘视厅完整元数据(posterimg/thumbimg/description等)..." } + }, + { + "slotCode": "slot_07", + "slotType": "MORE", + "width": 340, "height": 300, + "contentcode": null, + "mediaName": "更多影片", + "posterUrl": "https://.../more.jpg", + "customPoster": true, + "actionType": "LAUNCH", + "actionParam": "am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity", + "rawMedia": null + } + ] + } +} +``` + +> 前端渲染用 `data.slots`(已解析),按 slotCode 匹配到线框位置。 + +### 3.4 保存 POST `/save` + +**前端只需传必要字段,影片名/海报由后端从橘视厅自动同步:** + +```json +{ + "id": null, + "name": "2026年10月推荐", + "remark": "备注(可选)", + "slots": [ + { "slotCode": "slot_01", "contentcode": "23010012026061709581101832" }, + { "slotCode": "slot_02", "contentcode": "..." }, + { "slotCode": "slot_03", "contentcode": "..." }, + { "slotCode": "slot_04", "contentcode": "..." }, + { "slotCode": "slot_05", "contentcode": "..." }, + { "slotCode": "slot_06", "contentcode": "..." }, + { "slotCode": "slot_07", "posterUrl": "https://.../more.jpg" } + ] +} +``` + +**槽位可选字段:** + +| 字段 | 说明 | +|---|---| +| `posterUrl` | 自定义海报 URL。横图槽位(slot_01/05/06/07)默认用橘视厅横版图,效果不佳时运营可手动填横图 URL 覆盖 | +| `actionParam` | 自定义拉起命令(一般不填,用后端默认值) | + +**后端校验规则(错误信息会原样返回 msg):** +- 期名 name 必填 +- slots 必须 7 个、slotCode 齐全不重复 +- slot_01~06 contentcode 必填;无效 contentcode 报错 `contentcode 无效: xxx`(保存时后端实时调橘视厅校验) +- slot_07 必填 posterUrl +- 影片无海报时报错 `影片 xxx 无可用海报` + +成功返回:`{ code: 0, data: { recommendPage: { id: 1, ... } } }` + +--- + +## 4. 选片弹窗(复用已有媒资接口) + +**搜索**:`GET /manage/jetsen-media/search?keyword=xxx&pageindex=1&pagesize=20`(已存在的接口) + +```json +{ + "code": 0, + "data": { + "list": [ + { "contentcode": "23010022026090109192132084", "name": "后悔没有好好爱过你", + "posterimg": "https://...", "categoryname": "电影", "score": "8.0", + "releaseyear": "2026", "originalcountry": "中国大陆" } + ], + "totalCount": 100, "currPage": 1, "pageSize": 20, "totalPage": 5 + } +} +``` + +弹窗交互:搜索框 → 结果列表(海报缩略图 + 片名 + contentcode)→ 选中回填槽位。 +槽位预览图可直接用选中结果的 `posterimg`(保存后以 info 接口返回的 posterUrl 为准)。 + +--- + +## 5. 页面结构 + +### 5.1 列表页 `/recommend`(路由建议) + +| 列 | 说明 | +|---|---| +| 期名 | name | +| 备注 | remark | +| 创建时间 | createdAt | +| 操作 | 编辑 / **复制新一期** / 导出zip / 删除 | + +**复制新一期**:调 `info/{id}` 取 slots 预填新建表单(id 置 null),运营只改差异影片。 + +### 5.2 编辑页 + +- 顶部表单:期名(必填)、备注 +- 槽位线框区(§2 尺寸 × 0.25 缩放) +- slot_01~06:点槽位 → 打开选片弹窗 → 回填 contentcode + 预览图 +- 每个槽位可展开「自定义海报 URL」输入框(可选,横图槽位补横版图用) +- slot_07:仅海报 URL 输入(必填)+ 可选拉起参数 +- 工具栏:保存、导出 zip(保存后才可导出,需有 id) + +**前端预校验**(7 槽位齐、slot_01~06 contentcode 齐、slot_07 海报齐),后端保存时再校验一遍。 + +--- + +## 6. 导出下载(重要:不能用 window.open) + +导出接口走 JWT 请求头,`window.open` / `` 无法带 token,**必须用 axios blob 方式**: + +```javascript +axios.get(`/manage/recommend-page/export/${id}`, { + responseType: 'blob', + headers: { token: getToken() } +}).then(res => { + const url = window.URL.createObjectURL(new Blob([res.data])) + const link = document.createElement('a') + link.href = url + link.download = `推荐页_${name}.zip` + link.click() + window.URL.revokeObjectURL(url) +}) +``` + +注意:导出需实时下载 7 张海报并裁剪,耗时约 5~15 秒,建议按钮加 loading 态。失败时(如某海报下载失败)返回 JSON 错误而非 zip,前端可判断 `res.data.type === 'application/json'` 提示错误。 + +--- + +## 7. zip 交付包结构(导出结果,无需前端处理,仅供了解) + +``` +推荐页_2026年10月推荐.zip +├── images/slot_01.png ~ slot_07.png # 已按槽位精确尺寸居中裁剪 +├── recommend_config.json # contentcode + actionType + actionParam(占位符已替换真实值) +└── README.txt # 排版与拉起规则说明 +``` diff --git a/db/recommend_page.sql b/db/recommend_page.sql new file mode 100644 index 0000000..4449a5b --- /dev/null +++ b/db/recommend_page.sql @@ -0,0 +1,16 @@ +-- 整屏推荐页配置(一期一条记录,7 固定槽位存 JSON,历史全保留) +CREATE TABLE IF NOT EXISTS recommend_page ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL COMMENT '期名,如:2026年10月推荐', + slots TEXT NOT NULL COMMENT '槽位配置JSON数组,7个元素', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL', + created_at DATETIME DEFAULT NULL, + updated_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整屏推荐页配置'; + +-- 菜单:挂「媒资管理」(id=116) 下 +INSERT INTO sys_menu (id, parent_id, name, url, perms, type, icon, `show`, order_num, created_at, updated_at) +VALUES (126, 116, '推荐页配置', 'manage/recommend-page', '', 1, 'menu', '10', 2, NOW(), NOW()); diff --git a/pom.xml b/pom.xml index e3b881a..2b2c613 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,11 @@ org.springframework.boot spring-boot-starter-validation + + net.coobird + thumbnailator + 0.4.20 + org.springframework.boot spring-boot-configuration-processor diff --git a/src/main/java/io/as/modules/recommend/constants/RecommendConstants.java b/src/main/java/io/as/modules/recommend/constants/RecommendConstants.java new file mode 100644 index 0000000..2c5ad39 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/constants/RecommendConstants.java @@ -0,0 +1,42 @@ +package io.as.modules.recommend.constants; + +/** + * 整屏推荐页常量:7 个固定槽位定义(1920×1080 两行网格)与拉起命令 + * + * 排版:上行 主推(700×400) + 3 竖图(340×440),下行 2 横图(700×300) + 更多入口(340×300), + * 槽位间距 20px,左右边距 70px。 + */ +public class RecommendConstants { + + /** 橘视厅 App 包名/入口 Activity(release 实测拉起方式) */ + public static final String LAUNCH_CMD_PREFIX = + "am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity"; + + /** + * DETAIL 槽位默认拉起命令:按节目编号(contentcode)精确拉起。 + * 占位符 {contentcode} 导出时替换为真实值。 + */ + public static final String ACTION_DETAIL_BY_ID = LAUNCH_CMD_PREFIX + " --es media_id \"{contentcode}\""; + + /** + * DETAIL 槽位备选拉起命令:按影片名拉起(存在重名风险,供运营选用)。 + * 占位符 {mediaName} 导出时替换为真实值。 + */ + public static final String ACTION_DETAIL_BY_NAME = LAUNCH_CMD_PREFIX + " --es film_name \"{mediaName}\""; + + /** LAUNCH 槽位默认拉起命令:直接拉起橘视厅首页 */ + public static final String ACTION_LAUNCH_HOME = LAUNCH_CMD_PREFIX; + + /** 槽位类型 */ + public static final String TYPE_MAIN = "MAIN"; + public static final String TYPE_PORTRAIT = "PORTRAIT"; + public static final String TYPE_LANDSCAPE = "LANDSCAPE"; + public static final String TYPE_MORE = "MORE"; + + /** 点击动作类型 */ + public static final String ACTION_TYPE_DETAIL = "DETAIL"; + public static final String ACTION_TYPE_LAUNCH = "LAUNCH"; + + private RecommendConstants() { + } +} diff --git a/src/main/java/io/as/modules/recommend/constants/RecommendSlot.java b/src/main/java/io/as/modules/recommend/constants/RecommendSlot.java new file mode 100644 index 0000000..3bd8551 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/constants/RecommendSlot.java @@ -0,0 +1,99 @@ +package io.as.modules.recommend.constants; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 整屏推荐页槽位枚举(固定 7 个,不允许扩展时改表结构) + * + * 海报默认字段策略:竖图槽(PORTRAIT)取橘视厅 posterimg(448×672 竖版), + * 横图槽(MAIN/LANDSCAPE/MORE)取 thumbimg(448×252 横版);运营可用自定义 URL 覆盖。 + */ +public enum RecommendSlot { + + SLOT_01("slot_01", RecommendConstants.TYPE_MAIN, 700, 400, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_02("slot_02", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_03("slot_03", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_04("slot_04", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_05("slot_05", RecommendConstants.TYPE_LANDSCAPE, 700, 300, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_06("slot_06", RecommendConstants.TYPE_LANDSCAPE, 700, 300, RecommendConstants.ACTION_TYPE_DETAIL, true), + SLOT_07("slot_07", RecommendConstants.TYPE_MORE, 340, 300, RecommendConstants.ACTION_TYPE_LAUNCH, false); + + /** 槽位编码 */ + private final String code; + /** 槽位类型 */ + private final String type; + /** 宽 px */ + private final int width; + /** 高 px */ + private final int height; + /** 默认点击动作 */ + private final String defaultActionType; + /** 是否必须配置影片(contentcode) */ + private final boolean mediaRequired; + + RecommendSlot(String code, String type, int width, int height, String defaultActionType, boolean mediaRequired) { + this.code = code; + this.type = type; + this.width = width; + this.height = height; + this.defaultActionType = defaultActionType; + this.mediaRequired = mediaRequired; + } + + public String getCode() { + return code; + } + + public String getType() { + return type; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public String getDefaultActionType() { + return defaultActionType; + } + + public boolean isMediaRequired() { + return mediaRequired; + } + + /** 是否横图槽位(默认海报取 thumbimg 横版) */ + public boolean isLandscape() { + return !RecommendConstants.TYPE_PORTRAIT.equals(type); + } + + /** 按 slotCode 查找枚举,找不到返回 null */ + public static RecommendSlot byCode(String code) { + for (RecommendSlot slot : values()) { + if (slot.code.equals(code)) { + return slot; + } + } + return null; + } + + /** 全部槽位定义(供前端渲染配置页线框) */ + public static Map definition() { + Map def = new LinkedHashMap<>(); + for (RecommendSlot slot : values()) { + Map item = new LinkedHashMap<>(); + item.put("slotCode", slot.code); + item.put("slotType", slot.type); + item.put("width", slot.width); + item.put("height", slot.height); + item.put("defaultActionType", slot.defaultActionType); + item.put("mediaRequired", slot.mediaRequired); + item.put("posterField", slot.isLandscape() ? "thumbimg" : "posterimg"); + def.put(slot.code, item); + } + return def; + } +} diff --git a/src/main/java/io/as/modules/recommend/controller/RecommendPageController.java b/src/main/java/io/as/modules/recommend/controller/RecommendPageController.java new file mode 100644 index 0000000..8708a67 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/controller/RecommendPageController.java @@ -0,0 +1,121 @@ +package io.as.modules.recommend.controller; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import io.as.common.annotation.AuditAction; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.R; +import io.as.modules.recommend.constants.RecommendSlot; +import io.as.modules.recommend.entity.RecommendPageEntity; +import io.as.modules.recommend.form.RecommendPageSaveForm; +import io.as.modules.recommend.service.RecommendPageService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.util.HashMap; +import java.util.Map; + +/** + * 整屏推荐页配置管理 + * + * 设备厂商 1920×1080 整屏推荐页:运营选片配置 7 个固定槽位,一键导出 zip 交付包 + * (按槽位尺寸裁好的图片 + 影片编号 + am start 拉起命令)。 + */ +@Api(tags = "推荐页配置") +@RestController +@RequestMapping("/manage/recommend-page") +public class RecommendPageController { + + @Autowired + private RecommendPageService recommendPageService; + + /** + * 分页查询推荐页列表 + */ + @ApiOperation("分页查询推荐页列表") + @GetMapping("/list") + public R list(@RequestParam Map params) { + PageUtils page = recommendPageService.queryPage(params); + Map data = new HashMap<>(); + data.put("page", page); + return R.dataSuccess(data); + } + + /** + * 推荐页详情(slots JSON 解析后返回) + */ + @ApiOperation("推荐页详情") + @GetMapping("/info/{id}") + public R info(@PathVariable("id") Long id) { + RecommendPageEntity entity = recommendPageService.getById(id); + if (entity == null || "DEL".equals(entity.getDeleteFlag())) { + throw new ASException("推荐页不存在: " + id); + } + Map data = new HashMap<>(); + data.put("recommendPage", entity); + try { + data.put("slots", JSON.parseArray(entity.getSlots())); + } catch (Exception e) { + data.put("slots", new JSONArray()); + } + return R.dataSuccess(data); + } + + /** + * 槽位定义(前端渲染配置页线框用) + */ + @ApiOperation("槽位定义") + @GetMapping("/slot-definitions") + public R slotDefinitions() { + Map data = new HashMap<>(); + data.put("slots", RecommendSlot.definition()); + return R.dataSuccess(data); + } + + /** + * 新建或更新推荐页(有 id 更新,无 id 新建) + */ + @ApiOperation("新建或更新推荐页") + @AuditAction(value = "RECOMMEND_PAGE_SAVE", description = "保存推荐页配置") + @PostMapping("/save") + public R save(@RequestBody RecommendPageSaveForm form) { + RecommendPageEntity entity = recommendPageService.savePage(form); + Map data = new HashMap<>(); + data.put("recommendPage", entity); + return R.dataSuccess(data); + } + + /** + * 删除推荐页(软删除) + */ + @ApiOperation("删除推荐页") + @AuditAction(value = "RECOMMEND_PAGE_DELETE", description = "删除推荐页配置") + @PostMapping("/delete") + public R delete(@RequestBody Long[] ids) { + if (ids == null || ids.length == 0) { + throw new ASException("ids不能为空"); + } + recommendPageService.deleteByIds(ids); + return R.dataSuccess(new HashMap<>()); + } + + /** + * 一键导出 zip 交付包(浏览器下载) + */ + @ApiOperation("一键导出zip交付包") + @AuditAction(value = "RECOMMEND_PAGE_EXPORT", description = "导出推荐页交付包") + @GetMapping("/export/{id}") + public void export(@PathVariable("id") Long id, HttpServletResponse response) { + recommendPageService.exportZip(id, response); + } +} diff --git a/src/main/java/io/as/modules/recommend/dao/RecommendPageDao.java b/src/main/java/io/as/modules/recommend/dao/RecommendPageDao.java new file mode 100644 index 0000000..c5e818a --- /dev/null +++ b/src/main/java/io/as/modules/recommend/dao/RecommendPageDao.java @@ -0,0 +1,12 @@ +package io.as.modules.recommend.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import io.as.modules.recommend.entity.RecommendPageEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 整屏推荐页配置 Dao + */ +@Mapper +public interface RecommendPageDao extends BaseMapper { +} diff --git a/src/main/java/io/as/modules/recommend/entity/RecommendPageEntity.java b/src/main/java/io/as/modules/recommend/entity/RecommendPageEntity.java new file mode 100644 index 0000000..2eee45f --- /dev/null +++ b/src/main/java/io/as/modules/recommend/entity/RecommendPageEntity.java @@ -0,0 +1,33 @@ +package io.as.modules.recommend.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 整屏推荐页配置表(一期一条记录,历史全保留) + */ +@Data +@TableName("recommend_page") +public class RecommendPageEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + /** 期名,如:2026年10月推荐 */ + private String name; + /** 槽位配置JSON数组,7个元素 */ + private String slots; + /** 备注 */ + private String remark; + /** 删除标记 */ + private String deleteFlag; + /** 创建时间 */ + private Date createdAt; + /** 更新时间 */ + private Date updatedAt; +} diff --git a/src/main/java/io/as/modules/recommend/form/RecommendPageSaveForm.java b/src/main/java/io/as/modules/recommend/form/RecommendPageSaveForm.java new file mode 100644 index 0000000..c357d29 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/form/RecommendPageSaveForm.java @@ -0,0 +1,39 @@ +package io.as.modules.recommend.form; + +import lombok.Data; + +import java.util.List; + +/** + * 推荐页保存表单 + *

slots 由后端校验 7 个槽位齐全,并从橘视厅同步影片元数据。

+ */ +@Data +public class RecommendPageSaveForm { + + /** 有 id 更新,无 id 新建 */ + private Long id; + /** 期名 */ + private String name; + /** 备注 */ + private String remark; + /** 槽位配置,7 个元素 */ + private List slots; + + /** + * 单槽位表单(前端只传必要字段,元数据由后端同步) + */ + @Data + public static class SlotForm { + /** 槽位编码 slot_01 ~ slot_07 */ + private String slotCode; + /** 影片 contentcode(slot_07 更多入口可为空) */ + private String contentcode; + /** 影片名(冗余展示,后端以橘视厅同步为准) */ + private String mediaName; + /** 自定义海报 URL(横图槽位补横版图用;slot_07 必填) */ + private String posterUrl; + /** 自定义拉起命令(不传则按动作类型取默认值,支持 {contentcode}/{mediaName} 占位符) */ + private String actionParam; + } +} diff --git a/src/main/java/io/as/modules/recommend/service/RecommendPageService.java b/src/main/java/io/as/modules/recommend/service/RecommendPageService.java new file mode 100644 index 0000000..0c504f8 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/service/RecommendPageService.java @@ -0,0 +1,27 @@ +package io.as.modules.recommend.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import io.as.common.utils.PageUtils; +import io.as.modules.recommend.entity.RecommendPageEntity; +import io.as.modules.recommend.form.RecommendPageSaveForm; + +import javax.servlet.http.HttpServletResponse; +import java.util.Map; + +/** + * 整屏推荐页配置服务 + */ +public interface RecommendPageService extends IService { + + /** 分页查询 */ + PageUtils queryPage(Map params); + + /** 新建或更新(校验槽位 + 橘视厅元数据同步) */ + RecommendPageEntity savePage(RecommendPageSaveForm form); + + /** 软删除 */ + void deleteByIds(Long[] ids); + + /** 一键导出 zip 交付包(图片裁剪 + config.json + README.txt) */ + void exportZip(Long id, HttpServletResponse response); +} diff --git a/src/main/java/io/as/modules/recommend/service/impl/RecommendPageServiceImpl.java b/src/main/java/io/as/modules/recommend/service/impl/RecommendPageServiceImpl.java new file mode 100644 index 0000000..84eda10 --- /dev/null +++ b/src/main/java/io/as/modules/recommend/service/impl/RecommendPageServiceImpl.java @@ -0,0 +1,372 @@ +package io.as.modules.recommend.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import io.as.common.exception.ASException; +import io.as.common.utils.PageUtils; +import io.as.common.utils.Query; +import io.as.modules.media.client.JetsenMediaClient; +import io.as.modules.recommend.constants.RecommendConstants; +import io.as.modules.recommend.constants.RecommendSlot; +import io.as.modules.recommend.dao.RecommendPageDao; +import io.as.modules.recommend.entity.RecommendPageEntity; +import io.as.modules.recommend.form.RecommendPageSaveForm; +import io.as.modules.recommend.service.RecommendPageService; +import net.coobird.thumbnailator.Thumbnails; +import net.coobird.thumbnailator.geometry.Positions; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletResponse; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 整屏推荐页配置服务实现 + * + * 保存时从橘视厅同步影片元数据(getcontentlist.do 按 contentcode 直查,实测可靠), + * 导出时下载海报、居中裁剪到槽位精确尺寸后打包 zip 交付包。 + */ +@Service("recommendPageService") +public class RecommendPageServiceImpl extends ServiceImpl implements RecommendPageService { + + private static final Logger logger = LoggerFactory.getLogger(RecommendPageServiceImpl.class); + + @Autowired + private JetsenMediaClient jetsenMediaClient; + @Autowired + private RestTemplate restTemplate; + + @Override + public PageUtils queryPage(Map params) { + String name = (String) params.get("name"); + IPage page = this.page( + new Query().getPage(params), + new QueryWrapper() + .like(StringUtils.isNotBlank(name), "name", name) + .eq("delete_flag", "UNDEL") + .orderByDesc("created_at") + ); + return new PageUtils(page); + } + + @Override + public RecommendPageEntity savePage(RecommendPageSaveForm form) { + if (form == null || StringUtils.isBlank(form.getName())) { + throw new ASException("期名不能为空"); + } + if (form.getSlots() == null || form.getSlots().size() != RecommendSlot.values().length) { + throw new ASException("槽位配置必须为 " + RecommendSlot.values().length + " 个"); + } + + // 校验 slotCode 齐全且不重复 + Set codes = new HashSet<>(); + for (RecommendPageSaveForm.SlotForm slot : form.getSlots()) { + RecommendSlot def = RecommendSlot.byCode(slot.getSlotCode()); + if (def == null) { + throw new ASException("非法槽位编码: " + slot.getSlotCode()); + } + if (!codes.add(slot.getSlotCode())) { + throw new ASException("槽位编码重复: " + slot.getSlotCode()); + } + } + + // 逐槽位构建存储结构(橘视厅元数据同步 + 海报策略) + JSONArray slotArray = new JSONArray(); + for (RecommendPageSaveForm.SlotForm slotForm : form.getSlots()) { + RecommendSlot def = RecommendSlot.byCode(slotForm.getSlotCode()); + JSONObject slot = new JSONObject(true); + slot.put("slotCode", def.getCode()); + slot.put("slotType", def.getType()); + slot.put("width", def.getWidth()); + slot.put("height", def.getHeight()); + slot.put("actionType", def.getDefaultActionType()); + + String contentcode = StringUtils.trimToNull(slotForm.getContentcode()); + if (def.isMediaRequired() && contentcode == null) { + throw new ASException("槽位 " + def.getCode() + " 必须配置影片 contentcode"); + } + + if (contentcode != null) { + // 按 contentcode 直查橘视厅(getcontentlist.do),同步元数据 + JSONObject media = fetchMediaByCode(contentcode); + if (media == null) { + throw new ASException("contentcode 无效: " + contentcode); + } + slot.put("contentcode", contentcode); + slot.put("mediaName", media.getString("name")); + slot.put("rawMedia", media); + } else { + slot.put("contentcode", null); + slot.put("mediaName", StringUtils.defaultIfBlank(slotForm.getMediaName(), "更多影片")); + slot.put("rawMedia", null); + } + + // 海报策略:运营自定义 URL 优先;否则竖图槽取 posterimg、横图槽取 thumbimg(fallback posterimg) + boolean custom = StringUtils.isNotBlank(slotForm.getPosterUrl()); + if (!def.isMediaRequired() && !custom) { + throw new ASException("槽位 " + def.getCode() + "(更多入口)必须配置海报 URL"); + } + String posterUrl; + if (custom) { + posterUrl = slotForm.getPosterUrl().trim(); + } else { + JSONObject raw = slot.getJSONObject("rawMedia"); + String landscapeField = raw.getString("thumbimg"); + String portraitField = raw.getString("posterimg"); + if (def.isLandscape() && StringUtils.isNotBlank(landscapeField)) { + posterUrl = landscapeField; + } else { + posterUrl = portraitField; + } + if (StringUtils.isBlank(posterUrl)) { + throw new ASException("影片 " + slot.getString("mediaName") + " 无可用海报"); + } + } + slot.put("posterUrl", posterUrl); + slot.put("customPoster", custom); + + // 拉起命令:自定义优先,否则按动作类型填默认 + String actionParam; + if (StringUtils.isNotBlank(slotForm.getActionParam())) { + actionParam = slotForm.getActionParam().trim(); + } else if (RecommendConstants.ACTION_TYPE_LAUNCH.equals(def.getDefaultActionType())) { + actionParam = RecommendConstants.ACTION_LAUNCH_HOME; + } else { + actionParam = RecommendConstants.ACTION_DETAIL_BY_ID; + } + slot.put("actionParam", actionParam); + + slotArray.add(slot); + } + + Date now = new Date(); + RecommendPageEntity entity; + if (form.getId() != null) { + entity = this.getById(form.getId()); + if (entity == null || "DEL".equals(entity.getDeleteFlag())) { + throw new ASException("推荐页不存在: " + form.getId()); + } + } else { + entity = new RecommendPageEntity(); + entity.setDeleteFlag("UNDEL"); + entity.setCreatedAt(now); + } + entity.setName(form.getName().trim()); + entity.setRemark(form.getRemark()); + entity.setSlots(JSON.toJSONString(slotArray)); + entity.setUpdatedAt(now); + this.saveOrUpdate(entity); + return entity; + } + + @Override + public void deleteByIds(Long[] ids) { + for (Long id : ids) { + RecommendPageEntity entity = new RecommendPageEntity(); + entity.setId(id); + entity.setDeleteFlag("DEL"); + entity.setUpdatedAt(new Date()); + this.updateById(entity); + } + } + + @Override + public void exportZip(Long id, HttpServletResponse response) { + RecommendPageEntity entity = this.getById(id); + if (entity == null || "DEL".equals(entity.getDeleteFlag())) { + throw new ASException("推荐页不存在: " + id); + } + JSONArray slots = JSON.parseArray(entity.getSlots()); + + // 先在内存构建完整 zip,全部成功后再写响应,避免中途失败返回损坏的 zip 流 + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try { + try (ZipOutputStream zos = new ZipOutputStream(buffer)) { + // 1. 槽位图片:下载海报 → 居中裁剪到精确尺寸 → PNG + for (int i = 0; i < slots.size(); i++) { + JSONObject slot = slots.getJSONObject(i); + String slotCode = slot.getString("slotCode"); + int width = slot.getIntValue("width"); + int height = slot.getIntValue("height"); + byte[] poster = downloadPoster(slot.getString("posterUrl"), slotCode); + byte[] cropped = cropToSize(poster, width, height, slotCode); + zos.putNextEntry(new ZipEntry("images/" + slotCode + ".png")); + zos.write(cropped); + zos.closeEntry(); + } + + // 2. recommend_config.json + zos.putNextEntry(new ZipEntry("recommend_config.json")); + zos.write(buildConfigJson(entity, slots).getBytes("UTF-8")); + zos.closeEntry(); + + // 3. README.txt + zos.putNextEntry(new ZipEntry("README.txt")); + zos.write(buildReadme(entity, slots).getBytes("UTF-8")); + zos.closeEntry(); + } + } catch (ASException e) { + throw e; + } catch (Exception e) { + logger.error("推荐页导出失败: id={}", id, e); + throw new ASException("导出失败: " + e.getMessage()); + } + + // zip 构建成功,写响应 + try { + String fileName = "推荐页_" + entity.getName() + ".zip"; + response.setContentType("application/zip"); + response.setHeader("Content-Disposition", + "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, "UTF-8")); + response.getOutputStream().write(buffer.toByteArray()); + response.getOutputStream().flush(); + } catch (Exception e) { + logger.error("推荐页导出响应写入失败: id={}", id, e); + throw new ASException("导出失败: " + e.getMessage()); + } + } + + // ==================== 私有方法 ==================== + + /** 按 contentcode 精确查询橘视厅内容(getcontentlist.do 传 contentcode,电影返回自身) */ + private JSONObject fetchMediaByCode(String contentcode) { + JSONObject resp = jetsenMediaClient.getEpisodes(contentcode, 1, 10); + Object result = resp.get("result"); + if (result instanceof JSONArray) { + for (Object o : (JSONArray) result) { + if (o instanceof JSONObject) { + JSONObject item = (JSONObject) o; + if (contentcode.equals(item.getString("contentcode")) + || contentcode.equals(item.getString("code"))) { + return item; + } + } + } + } + return null; + } + + /** 下载海报,失败中止导出 */ + private byte[] downloadPoster(String url, String slotCode) { + try { + byte[] data = restTemplate.getForObject(url, byte[].class); + if (data == null || data.length == 0) { + throw new ASException("槽位 " + slotCode + " 海报下载失败: 响应为空"); + } + return data; + } catch (ASException e) { + throw e; + } catch (Exception e) { + throw new ASException("槽位 " + slotCode + " 海报下载失败: " + e.getMessage()); + } + } + + /** 居中裁剪到槽位精确尺寸(cover 模式:短边贴齐、长边居中裁) */ + private byte[] cropToSize(byte[] source, int width, int height, String slotCode) { + try { + BufferedImage cropped = Thumbnails.of(new ByteArrayInputStream(source)) + .size(width, height) + .crop(Positions.CENTER) + .asBufferedImage(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(cropped, "png", baos); + return baos.toByteArray(); + } catch (Exception e) { + throw new ASException("槽位 " + slotCode + " 图片裁剪失败: " + e.getMessage()); + } + } + + /** 构建 recommend_config.json(占位符替换为真实值) */ + private String buildConfigJson(RecommendPageEntity entity, JSONArray slots) { + Map config = new LinkedHashMap<>(); + config.put("name", entity.getName()); + config.put("layout", "FULL_SCREEN_7"); + config.put("exportedAt", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + + List> slotList = new ArrayList<>(); + for (int i = 0; i < slots.size(); i++) { + JSONObject slot = slots.getJSONObject(i); + Map item = new LinkedHashMap<>(); + item.put("slotCode", slot.getString("slotCode")); + item.put("slotType", slot.getString("slotType")); + item.put("width", slot.getIntValue("width")); + item.put("height", slot.getIntValue("height")); + item.put("image", "images/" + slot.getString("slotCode") + ".png"); + item.put("actionType", slot.getString("actionType")); + item.put("contentcode", slot.getString("contentcode")); + item.put("mediaName", slot.getString("mediaName")); + item.put("actionParam", resolveActionParam(slot)); + slotList.add(item); + } + config.put("slots", slotList); + return JSON.toJSONString(config, true); + } + + /** 构建交付说明 README.txt */ + private String buildReadme(RecommendPageEntity entity, JSONArray slots) { + StringBuilder sb = new StringBuilder(); + sb.append("整屏推荐页交付说明(期名:").append(entity.getName()).append(")\n\n"); + sb.append("排版:1920×1080 两行网格,上行 主推(700×400) + 3 竖图(340×440),\n"); + sb.append("下行 2 横图(700×300) + 更多入口(340×300),槽位间距 20px,左右边距 70px。\n\n"); + sb.append("动作规则:\n"); + sb.append("- actionType=DETAIL:按节目编号拉起影片,推荐使用 media_id 方式(编号精确,无重名风险):\n"); + sb.append(" ").append(RecommendConstants.ACTION_DETAIL_BY_ID).append("\n"); + sb.append(" 备选按影片名拉起(存在重名风险):\n"); + sb.append(" ").append(RecommendConstants.ACTION_DETAIL_BY_NAME).append("\n"); + sb.append("- actionType=LAUNCH:直接拉起橘视厅应用:\n"); + sb.append(" ").append(RecommendConstants.ACTION_LAUNCH_HOME).append("\n"); + sb.append("- actionParam 为完整可执行命令,已替换真实值,可直接使用\n\n"); + sb.append("槽位清单:\n"); + for (int i = 0; i < slots.size(); i++) { + JSONObject slot = slots.getJSONObject(i); + sb.append(slot.getString("slotCode")).append(" ") + .append(slot.getString("slotType")).append(" "); + if (slot.getString("contentcode") != null) { + sb.append("影片《").append(slot.getString("mediaName")).append("》 ") + .append("contentcode=").append(slot.getString("contentcode")); + } else { + sb.append("动作=拉起橘视厅"); + } + sb.append(" 图片=images/").append(slot.getString("slotCode")).append(".png\n"); + } + if (StringUtils.isNotBlank(entity.getRemark())) { + sb.append("\n备注:").append(entity.getRemark()).append("\n"); + } + return sb.toString(); + } + + /** 导出时替换拉起命令占位符 {contentcode} / {mediaName} */ + private String resolveActionParam(JSONObject slot) { + String actionParam = slot.getString("actionParam"); + if (actionParam == null) { + return null; + } + String contentcode = slot.getString("contentcode"); + String mediaName = slot.getString("mediaName"); + return actionParam + .replace("{contentcode}", contentcode == null ? "" : contentcode) + .replace("{mediaName}", mediaName == null ? "" : mediaName); + } +}