From 2c91a662343ba572495daf6eae9134a1139d7bde Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Thu, 8 Jan 2026 16:44:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BD=9C=E5=93=81=E4=B8=8A?= =?UTF-8?q?=E7=83=AD=E9=97=A8=E5=8A=9F=E8=83=BD=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库:添加is_hot和hot_time字段 - Service层:实现toggleHot和getHotWorks方法 - 管理端API:添加设置热门和查询热门列表接口 - 用户端API:添加获取热门作品列表接口 - 实体类:更新Works和WorksResponse添加热门字段 - 文档:创建功能实现说明文档 后续需要: - 后台管理界面(Vue)添加热门设置按钮 - Android App添加热门Tab页 --- Log/指令/作品上热门功能实现说明.md | 225 ++++++++++++++++++ Log/环境/8-配置上传服务器.md | 59 +++++ .../admin/controller/WorksController.java | 39 +++ .../com/zbkj/common/model/works/Works.java | 9 + .../zbkj/common/response/WorksResponse.java | 6 + .../front/controller/WorksController.java | 21 ++ .../zbkj/service/service/WorksService.java | 17 ++ .../service/impl/WorksServiceImpl.java | 59 +++++ Zhibo/zhibo-h/sql/add_works_hot_fields.sql | 15 ++ android-app/app/src/main/AndroidManifest.xml | 5 + .../BurnImageViewerActivity.java | 153 ++++++++++++ .../example/livestreaming/ChatMessage.java | 52 +++- .../livestreaming/ConversationActivity.java | 96 ++++++++ .../ConversationMessagesAdapter.java | 12 + .../example/livestreaming/net/ApiService.java | 3 + .../livestreaming/net/BurnViewResponse.java | 48 ++++ .../res/layout/activity_burn_image_viewer.xml | 39 +++ 17 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 Log/指令/作品上热门功能实现说明.md create mode 100644 Log/环境/8-配置上传服务器.md create mode 100644 Zhibo/zhibo-h/sql/add_works_hot_fields.sql create mode 100644 android-app/app/src/main/java/com/example/livestreaming/BurnImageViewerActivity.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/BurnViewResponse.java create mode 100644 android-app/app/src/main/res/layout/activity_burn_image_viewer.xml diff --git a/Log/指令/作品上热门功能实现说明.md b/Log/指令/作品上热门功能实现说明.md new file mode 100644 index 00000000..4b30e7b8 --- /dev/null +++ b/Log/指令/作品上热门功能实现说明.md @@ -0,0 +1,225 @@ +# 作品上热门功能实现说明 + +## 功能概述 +实现了最简单的"作品上热门"功能,管理员可以在后台设置/取消作品的热门状态,用户可以在App中查看热门作品列表。 + +## 实现方案 +采用**最简单方案**:管理员手动设置热门 + +### 核心特点 +- ✅ 只在后台管理端操作,不涉及用户端复杂交互 +- ✅ 不需要付费系统,不需要金币/积分 +- ✅ 不需要审核流程,管理员直接设置 +- ✅ 不需要定时任务,手动管理即可 +- ✅ 最小化数据库改动 + +## 已完成的工作 + +### 1. 数据库改动 ✅ +**文件:** `Zhibo/zhibo-h/sql/add_works_hot_fields.sql` + +在 `works` 表添加了2个字段: +- `is_hot` (tinyint): 是否热门,1-是 0-否 +- `hot_time` (datetime): 设置热门的时间 + +### 2. 后端Service层 ✅ + +#### WorksService接口 +**文件:** `Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java` + +新增方法: +```java +// 设置/取消热门 +Boolean toggleHot(Long worksId, Boolean isHot); + +// 获取热门作品列表 +CommonPage getHotWorks(Integer page, Integer pageSize, Integer userId); +``` + +#### WorksServiceImpl实现 +**文件:** `Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java` + +实现了: +- `toggleHot()`: 设置/取消热门状态 +- `getHotWorks()`: 查询热门作品列表(按hot_time倒序) +- `convertToResponse()`: 添加了热门字段的转换 + +### 3. 管理端Controller ✅ +**文件:** `Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java` + +新增接口: +- `POST /api/admin/works/toggleHot` - 设置/取消热门 + - 参数:id (作品ID), isHot (true/false) + - 返回:操作结果 + +- `GET /api/admin/works/hotList` - 获取热门作品列表 + - 参数:page, limit + - 返回:热门作品分页列表 + +### 4. 用户端Controller ✅ +**文件:** `Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java` + +新增接口: +- `GET /api/works/hot` - 获取热门作品列表(用户端) + - 参数:page, pageSize + - 返回:热门作品分页列表 + - 不需要登录即可访问 + +### 5. 实体类更新 ✅ + +#### Works实体 +**文件:** `Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java` + +添加字段: +- `isHot`: 是否热门 +- `hotTime`: 设置热门的时间 + +#### WorksResponse响应类 +**文件:** `Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java` + +添加字段: +- `isHot`: 是否热门 +- `hotTime`: 设置热门的时间 + +## API接口说明 + +### 管理端接口 + +#### 1. 设置/取消热门 +``` +POST /api/admin/works/toggleHot +参数: + - id: 作品ID (Long) + - isHot: 是否热门 (Boolean, true=设置热门, false=取消热门) +返回: + { + "code": 200, + "message": "设置热门成功" / "取消热门成功" + } +``` + +#### 2. 获取热门作品列表(管理端) +``` +GET /api/admin/works/hotList +参数: + - page: 页码 (默认1) + - limit: 每页数量 (默认20) +返回: + { + "code": 200, + "data": { + "list": [...], + "total": 100, + "page": 1, + "limit": 20 + } + } +``` + +### 用户端接口 + +#### 3. 获取热门作品列表(用户端) +``` +GET /api/works/hot +参数: + - page: 页码 (默认1) + - pageSize: 每页数量 (默认20) +返回: + { + "code": 200, + "data": { + "list": [...], + "total": 100, + "page": 1, + "limit": 20 + } + } +``` + +## 后续工作 + +### 待实现: + +#### 1. 后台管理界面 (Vue Admin) +需要在作品管理页面添加: +- 作品列表中每一行添加"设置热门"/"取消热门"按钮 +- 热门作品用特殊样式标识(如红色高亮) +- 可选:添加单独的"热门作品管理"页面 + +**文件位置:** `Zhibo/admin/src/views/content/works/list.vue` + +#### 2. Android App界面 +需要实现: +- 首页添加"热门"Tab +- 调用 `GET /api/works/hot` 接口获取热门作品 +- 作品卡片显示热门标签(🔥热门) + +**需要修改的文件:** +- `android-app/app/src/main/java/com/example/livestreaming/MainActivity.java` +- `android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java` + +## 使用流程 + +### 管理员操作流程: +1. 登录后台管理系统 +2. 进入"作品管理"页面 +3. 找到想要设置为热门的作品 +4. 点击"设置热门"按钮 +5. 该作品立即成为热门作品 + +### 用户查看流程: +1. 打开App +2. 点击"热门"Tab +3. 查看热门作品列表 +4. 热门作品按设置时间倒序排列(最新设置的在前面) + +## 数据库执行 + +在部署前需要执行SQL文件: +```bash +mysql -u root -p crmeb < Zhibo/zhibo-h/sql/add_works_hot_fields.sql +``` + +或者手动执行: +```sql +USE `crmeb`; + +ALTER TABLE `works` +ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`, +ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`; + +ALTER TABLE `works` ADD INDEX `idx_is_hot` (`is_hot`); +``` + +## 测试建议 + +### 后端测试: +1. 测试设置热门接口 +2. 测试取消热门接口 +3. 测试获取热门列表接口 +4. 验证热门作品排序是否正确 + +### 前端测试: +1. 测试后台管理界面的热门设置功能 +2. 测试App热门Tab的显示 +3. 测试热门标签的显示 + +## 扩展建议 + +如果以后需要更复杂的功能,可以在此基础上扩展: +- 添加热门时长限制(加个 `hot_end_time` 字段) +- 添加自动热门算法(定时任务计算热度) +- 添加用户付费推广(加个 `works_hot` 表记录推广记录) +- 添加热门位置排序(加个 `hot_order` 字段) + +## 注意事项 + +1. 后端代码已完成并编译通过 ✅ +2. 数据库SQL文件已创建 ✅ +3. 前端界面(Vue Admin + Android App)需要继续实现 +4. 部署前记得执行数据库迁移脚本 + +--- + +**创建时间:** 2026-01-08 +**状态:** 后端完成,前端待实现 diff --git a/Log/环境/8-配置上传服务器.md b/Log/环境/8-配置上传服务器.md new file mode 100644 index 00000000..584f0cf5 --- /dev/null +++ b/Log/环境/8-配置上传服务器.md @@ -0,0 +1,59 @@ +安装软件 +# 安装 Node.js 18 +curl -fsSL https://rpm.nodesource.com/setup_18.x | bash - +yum install -y nodejs + +# 验证 +node -v +npm -v + +npm install -g pm2 +pm2 start server.js --name upload-server +pm2 save +pm2 startup + +更改配置 +[root@VM-0-16-opencloudos ~]# # 重写正确的配置 +cat > /www/server/panel/vhost/nginx/1.15.149.240_30005.conf << 'EOF' +server +{ + listen 30005; + listen [::]:30005; + server_name 1.15.149.240_30005; + index index.php index.html index.htm default.php default.htm default.html; + root /www/wwwroot/1.15.149.240_30005; + + #CERT-APPLY-CHECK--START + include /www/server/panel/vhost/nginx/well-known/1.15.149.240_30005.conf; + #CERT-APPLY-CHECK--END + + #ERROR-PAGE-START + error_page 404 /404.html; + #ERROR-PAGE-END + + #PHP-INFO-START + include enable-php-84.conf; + #PHP-INFO-END + + #REWRITE-START + include /www/server/panel/vhost/rewrite/1.15.149.240_30005.conf; + #REWRITE-END + + # 上传API代理 + location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 500M; + } + + location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) + { + return 404; + } + +nginx -s reload/www/wwwlogs/1.15.149.240_30005.error.log;a|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { +nginx: [warn] conflicting server name "1.15.149.240" on 0.0.0.0:20001, ignored +nginx: the configuration file /www/server/nginx/conf/nginx.conf syntax is ok +nginx: configuration file /www/server/nginx/conf/nginx.conf test is successful +nginx: [warn] conflicting server name "1.15.149.240" on 0.0.0.0:20001, ignored \ No newline at end of file diff --git a/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java index b898e603..6ec7382a 100644 --- a/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java +++ b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java @@ -148,4 +148,43 @@ public class WorksController { return CommonResult.failed(e.getMessage()); } } + + /** + * 设置/取消热门 + * @param id Long 作品ID + * @param isHot Boolean 是否热门:true-设置热门 false-取消热门 + */ + // @PreAuthorize("hasAuthority('admin:works:update')") // 临时注释,等待添加权限 + @ApiOperation(value = "设置/取消热门") + @RequestMapping(value = "/toggleHot", method = RequestMethod.POST) + public CommonResult toggleHot(@RequestParam Long id, @RequestParam Boolean isHot) { + try { + worksService.toggleHot(id, isHot); + return CommonResult.success(isHot ? "设置热门成功" : "取消热门成功"); + } catch (Exception e) { + log.error("设置热门失败", e); + return CommonResult.failed(e.getMessage()); + } + } + + /** + * 获取热门作品列表 + * @param pageParamRequest 分页参数 + */ + // @PreAuthorize("hasAuthority('admin:works:list')") // 临时注释,等待添加权限 + @ApiOperation(value = "热门作品列表") + @RequestMapping(value = "/hotList", method = RequestMethod.GET) + public CommonResult> getHotList(@Validated PageParamRequest pageParamRequest) { + try { + CommonPage result = worksService.getHotWorks( + pageParamRequest.getPage(), + pageParamRequest.getLimit(), + null + ); + return CommonResult.success(result); + } catch (Exception e) { + log.error("获取热门作品列表失败", e); + return CommonResult.failed(e.getMessage()); + } + } } diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java index 78b4cb93..b1fa2d71 100644 --- a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java @@ -105,6 +105,15 @@ public class Works implements Serializable { @ApiModelProperty(value = "状态:1-正常 0-下架") private Integer status = 1; + @Column(name = "is_hot", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '是否热门:1-是 0-否'") + @ApiModelProperty(value = "是否热门:1-是 0-否") + private Integer isHot = 0; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "hot_time", columnDefinition = "DATETIME COMMENT '设置热门的时间'") + @ApiModelProperty(value = "设置热门的时间") + private Date hotTime; + @TableLogic @Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除 1-已删除'") @ApiModelProperty(value = "逻辑删除:0-未删除 1-已删除") diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java index 1084609f..65213b3b 100644 --- a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java @@ -73,6 +73,12 @@ public class WorksResponse { @ApiModelProperty(value = "状态:1-正常 0-下架") private Integer status; + @ApiModelProperty(value = "是否热门:1-是 0-否") + private Integer isHot; + + @ApiModelProperty(value = "设置热门的时间") + private Date hotTime; + @ApiModelProperty(value = "是否已点赞") private Boolean isLiked = false; diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java index 2a16435a..376c5a71 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java @@ -330,4 +330,25 @@ public class WorksController { return CommonResult.failed(e.getMessage()); } } + + /** + * 获取热门作品列表(不需要登录) + */ + @ApiOperation(value = "获取热门作品列表") + @GetMapping("/hot") + public CommonResult> getHotWorks( + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) { + + try { + // 获取当前登录用户ID(如果已登录) + Integer userId = userService.getUserId(); + + CommonPage result = worksService.getHotWorks(page, pageSize, userId); + return CommonResult.success(result); + } catch (Exception e) { + log.error("获取热门作品列表失败", e); + return CommonResult.failed(e.getMessage()); + } + } } diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java index e50d7ff1..36e1d516 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java @@ -82,4 +82,21 @@ public interface WorksService extends IService { * @param worksId 作品ID */ void increaseShareCount(Long worksId); + + /** + * 设置/取消热门 + * @param worksId 作品ID + * @param isHot 是否热门:true-设置热门 false-取消热门 + * @return 是否成功 + */ + Boolean toggleHot(Long worksId, Boolean isHot); + + /** + * 获取热门作品列表 + * @param page 页码 + * @param pageSize 每页数量 + * @param userId 当前用户ID(可为空) + * @return 热门作品列表 + */ + CommonPage getHotWorks(Integer page, Integer pageSize, Integer userId); } diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java index c0400239..f825ad29 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java @@ -505,6 +505,8 @@ public class WorksServiceImpl extends ServiceImpl implements Wo response.setCommentCount(works.getCommentCount()); response.setShareCount(works.getShareCount()); response.setStatus(works.getStatus()); + response.setIsHot(works.getIsHot()); + response.setHotTime(works.getHotTime()); response.setCreateTime(works.getCreateTime()); response.setUpdateTime(works.getUpdateTime()); @@ -536,4 +538,61 @@ public class WorksServiceImpl extends ServiceImpl implements Wo return response; } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean toggleHot(Long worksId, Boolean isHot) { + // 查询作品 + Works works = getById(worksId); + if (works == null || works.getIsDeleted() == 1) { + throw new CrmebException("作品不存在"); + } + + // 更新热门状态 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(Works::getId, worksId) + .set(Works::getIsHot, isHot ? 1 : 0) + .set(Works::getHotTime, isHot ? new java.util.Date() : null); + + boolean updated = update(updateWrapper); + if (!updated) { + throw new CrmebException("更新热门状态失败"); + } + + log.info("{}热门成功,作品ID:{}", isHot ? "设置" : "取消", worksId); + return true; + } + + @Override + public CommonPage getHotWorks(Integer page, Integer pageSize, Integer userId) { + log.info("=== 获取热门作品列表 === page={}, pageSize={}, userId={}", page, pageSize, userId); + + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Works::getIsDeleted, 0) + .eq(Works::getStatus, 1) // 只查询正常状态的作品 + .eq(Works::getIsHot, 1) // 只查询热门作品 + .orderByDesc(Works::getHotTime); // 按设置热门的时间倒序 + + // 分页查询 + Page worksPage = new Page<>(page, pageSize); + worksPage = page(worksPage, queryWrapper); + + log.info("查询到 {} 条热门作品记录", worksPage.getRecords().size()); + + // 转换为响应对象 + List responseList = worksPage.getRecords().stream() + .map(works -> convertToResponse(works, userId)) + .collect(Collectors.toList()); + + // 构建分页结果 + CommonPage result = new CommonPage<>(); + result.setList(responseList); + result.setTotal(worksPage.getTotal()); + result.setPage((int) worksPage.getCurrent()); + result.setLimit((int) worksPage.getSize()); + result.setTotalPage((int) worksPage.getPages()); + + return result; + } } diff --git a/Zhibo/zhibo-h/sql/add_works_hot_fields.sql b/Zhibo/zhibo-h/sql/add_works_hot_fields.sql new file mode 100644 index 00000000..5f38822f --- /dev/null +++ b/Zhibo/zhibo-h/sql/add_works_hot_fields.sql @@ -0,0 +1,15 @@ +-- 添加作品热门相关字段 +-- 执行时间:2026-01-08 + +USE `crmeb`; + +-- 在 works 表添加热门相关字段 +ALTER TABLE `works` +ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`, +ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`; + +-- 添加索引以提高查询性能 +ALTER TABLE `works` ADD INDEX `idx_is_hot` (`is_hot`); + +-- 查看修改结果 +SHOW COLUMNS FROM `works` LIKE '%hot%'; diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index f5655ca1..b2254ded 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -214,6 +214,11 @@ android:name="com.example.livestreaming.ConversationActivity" android:exported="false" /> + + diff --git a/android-app/app/src/main/java/com/example/livestreaming/BurnImageViewerActivity.java b/android-app/app/src/main/java/com/example/livestreaming/BurnImageViewerActivity.java new file mode 100644 index 00000000..e07f3786 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/BurnImageViewerActivity.java @@ -0,0 +1,153 @@ +package com.example.livestreaming; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.WindowManager; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.ApiResponse; +import com.example.livestreaming.net.ApiService; +import com.example.livestreaming.net.BurnViewResponse; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class BurnImageViewerActivity extends AppCompatActivity { + + public static final String EXTRA_MESSAGE_ID = "extra_message_id"; + + private ImageView imageView; + private TextView countdownText; + private TextView tipText; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private Runnable countdownRunnable; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + setContentView(R.layout.activity_burn_image_viewer); + + imageView = findViewById(R.id.imageView); + countdownText = findViewById(R.id.countdownText); + tipText = findViewById(R.id.tipText); + + View rootLayout = findViewById(R.id.rootLayout); + if (rootLayout != null) { + rootLayout.setOnClickListener(v -> finish()); + } + + long messageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, 0L); + if (messageId <= 0L) { + showTipAndFinish("消息ID无效"); + return; + } + + ApiService api = ApiClient.getService(this); + if (api == null) { + showTipAndFinish("请先登录"); + return; + } + + api.viewBurnImage(messageId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + if (!response.isSuccessful() || response.body() == null || !response.body().isOk()) { + String msg = response.body() != null ? response.body().getMessage() : "查看失败"; + showTipAndFinish(msg); + return; + } + + BurnViewResponse data = response.body().getData(); + if (data == null) { + showTipAndFinish("查看失败"); + return; + } + + if (data.getBurned() != null && data.getBurned()) { + showTipAndFinish("已销毁"); + return; + } + + String url = data.getMediaUrl(); + if (url == null || url.trim().isEmpty()) { + showTipAndFinish("不可查看"); + return; + } + + if (imageView != null) { + Glide.with(BurnImageViewerActivity.this) + .load(url) + .into(imageView); + } + + Long burnAt = data.getBurnAt(); + Integer burnSeconds = data.getBurnSeconds(); + startCountdown(burnAt, burnSeconds); + } + + @Override + public void onFailure(Call> call, Throwable t) { + showTipAndFinish("网络错误"); + } + }); + } + + private void startCountdown(Long burnAt, Integer burnSeconds) { + long now = System.currentTimeMillis(); + long endAt = burnAt != null ? burnAt : (burnSeconds != null ? now + burnSeconds * 1000L : now); + + if (countdownText != null) { + countdownText.setVisibility(View.VISIBLE); + } + + if (countdownRunnable != null) { + handler.removeCallbacks(countdownRunnable); + } + + countdownRunnable = new Runnable() { + @Override + public void run() { + long leftMs = endAt - System.currentTimeMillis(); + if (leftMs <= 0L) { + finish(); + return; + } + + long sec = (leftMs + 999L) / 1000L; + if (countdownText != null) { + countdownText.setText(sec + "s"); + } + handler.postDelayed(this, 200L); + } + }; + + handler.post(countdownRunnable); + } + + private void showTipAndFinish(String tip) { + if (tipText != null) { + tipText.setVisibility(View.VISIBLE); + tipText.setText(tip); + } + handler.postDelayed(this::finish, 800L); + } + + @Override + protected void onDestroy() { + if (countdownRunnable != null) { + handler.removeCallbacks(countdownRunnable); + } + super.onDestroy(); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java index c0dfecff..e2001d36 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java @@ -16,7 +16,8 @@ public class ChatMessage { public enum MessageType { TEXT, // 文本消息 IMAGE, // 图片消息 - VOICE // 语音消息 + VOICE, // 语音消息 + BURN_IMAGE } private String messageId; @@ -36,7 +37,12 @@ public class ChatMessage { private int voiceDuration; // 语音时长(秒) private int imageWidth; // 图片宽度 private int imageHeight; // 图片高度 - + + private Integer burnSeconds; + private Long viewedAt; + private Long burnAt; + private Boolean burned; + // 表情回应相关字段 private List reactions; // 表情回应列表 @@ -123,6 +129,16 @@ public class ChatMessage { return msg; } + public static ChatMessage createBurnImageMessage(String username, String localPath, Integer burnSeconds) { + ChatMessage msg = new ChatMessage(username, ""); + msg.messageType = MessageType.BURN_IMAGE; + msg.localMediaPath = localPath; + msg.burnSeconds = burnSeconds; + msg.burned = false; + msg.status = "我".equals(username) ? MessageStatus.SENDING : MessageStatus.SENT; + return msg; + } + // Getters and Setters public String getMessageId() { return messageId; @@ -244,6 +260,38 @@ public class ChatMessage { this.imageHeight = imageHeight; } + public Integer getBurnSeconds() { + return burnSeconds; + } + + public void setBurnSeconds(Integer burnSeconds) { + this.burnSeconds = burnSeconds; + } + + public Long getViewedAt() { + return viewedAt; + } + + public void setViewedAt(Long viewedAt) { + this.viewedAt = viewedAt; + } + + public Long getBurnAt() { + return burnAt; + } + + public void setBurnAt(Long burnAt) { + this.burnAt = burnAt; + } + + public Boolean getBurned() { + return burned; + } + + public void setBurned(Boolean burned) { + this.burned = burned; + } + public List getReactions() { return reactions; } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index aa9873dc..ddcf6b81 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -26,6 +26,7 @@ import org.json.JSONArray; import org.json.JSONObject; import java.io.IOException; +import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -195,6 +196,53 @@ public class ConversationActivity extends AppCompatActivity { showMessageMenu(message, position, view); }); + adapter.setOnImageClickListener((message, imageView) -> { + if (message == null) return; + + if (message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) { + long mid = 0L; + try { + mid = Long.parseLong(message.getMessageId()); + } catch (Exception ignored) { + } + if (mid <= 0L) { + Snackbar.make(binding.getRoot(), "消息ID无效", Snackbar.LENGTH_SHORT).show(); + return; + } + Intent it = new Intent(ConversationActivity.this, BurnImageViewerActivity.class); + it.putExtra(BurnImageViewerActivity.EXTRA_MESSAGE_ID, mid); + startActivity(it); + return; + } + + if (message.getMessageType() == ChatMessage.MessageType.IMAGE) { + String url = message.getMediaUrl(); + if (!TextUtils.isEmpty(url)) { + AvatarViewerDialog.create(ConversationActivity.this) + .setAvatarUrl(url) + .show(); + return; + } + + String localPath = message.getLocalMediaPath(); + if (!TextUtils.isEmpty(localPath)) { + try { + android.net.Uri uri; + if (localPath.startsWith("content://") || localPath.startsWith("file://")) { + uri = android.net.Uri.parse(localPath); + } else { + uri = android.net.Uri.fromFile(new File(localPath)); + } + AvatarViewerDialog.create(ConversationActivity.this) + .setAvatarUri(uri) + .show(); + return; + } catch (Exception ignored) { + } + } + } + }); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); layoutManager.setStackFromEnd(false); binding.messagesRecyclerView.setLayoutManager(layoutManager); @@ -452,6 +500,33 @@ public class ConversationActivity extends AppCompatActivity { String avatarUrl = item.optString("avatarUrl", ""); boolean isSystem = item.optBoolean("isSystemMessage", false); + String messageType = item.optString("messageType", "text"); + String mediaUrl = item.optString("mediaUrl", ""); + int duration = item.optInt("duration", 0); + + Integer burnSeconds = null; + if (item.has("burnSeconds") && !item.isNull("burnSeconds")) { + int v = item.optInt("burnSeconds", 0); + if (v > 0) burnSeconds = v; + } + + Long viewedAt = null; + if (item.has("viewedAt") && !item.isNull("viewedAt")) { + long v = item.optLong("viewedAt", 0L); + if (v > 0L) viewedAt = v; + } + + Long burnAt = null; + if (item.has("burnAt") && !item.isNull("burnAt")) { + long v = item.optLong("burnAt", 0L); + if (v > 0L) burnAt = v; + } + + Boolean burned = null; + if (item.has("burned") && !item.isNull("burned")) { + burned = item.optBoolean("burned", false); + } + // 判断是否是自己发送的消息 // 每次都重新从 AuthStore 获取最新的 userId,确保登录后能正确获取 String myUserId = AuthStore.getUserId(this); @@ -504,6 +579,27 @@ public class ConversationActivity extends AppCompatActivity { ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus); chatMessage.setOutgoing(isMine); // 关键:设置消息方向,确保自己发送的消息显示在右侧 chatMessage.setAvatarUrl(avatarUrl); + + if ("image".equalsIgnoreCase(messageType)) { + chatMessage.setMessageType(ChatMessage.MessageType.IMAGE); + } else if ("voice".equalsIgnoreCase(messageType)) { + chatMessage.setMessageType(ChatMessage.MessageType.VOICE); + if (duration > 0) { + chatMessage.setVoiceDuration(duration); + } + } else if ("burn_image".equalsIgnoreCase(messageType)) { + chatMessage.setMessageType(ChatMessage.MessageType.BURN_IMAGE); + chatMessage.setBurnSeconds(burnSeconds); + chatMessage.setViewedAt(viewedAt); + chatMessage.setBurnAt(burnAt); + chatMessage.setBurned(burned); + } else { + chatMessage.setMessageType(ChatMessage.MessageType.TEXT); + } + + if (!TextUtils.isEmpty(mediaUrl)) { + chatMessage.setMediaUrl(mediaUrl); + } return chatMessage; } catch (Exception e) { Log.e(TAG, "解析消息失败", e); diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java index ffb54ce3..a01424c1 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java @@ -79,6 +79,8 @@ public class ConversationMessagesAdapter extends ListAdapter> deleteMessage(@Path("id") long id); + @POST("api/front/conversations/messages/{id}/burn/view") + Call> viewBurnImage(@Path("id") long id); + // ==================== 好友管理 ==================== @GET("api/front/friends") diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/BurnViewResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/BurnViewResponse.java new file mode 100644 index 00000000..48ed5db6 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/BurnViewResponse.java @@ -0,0 +1,48 @@ +package com.example.livestreaming.net; + +import com.google.gson.annotations.SerializedName; + +public class BurnViewResponse { + + @SerializedName("messageId") + private Long messageId; + + @SerializedName("mediaUrl") + private String mediaUrl; + + @SerializedName("burnSeconds") + private Integer burnSeconds; + + @SerializedName("viewedAt") + private Long viewedAt; + + @SerializedName("burnAt") + private Long burnAt; + + @SerializedName("burned") + private Boolean burned; + + public Long getMessageId() { + return messageId; + } + + public String getMediaUrl() { + return mediaUrl; + } + + public Integer getBurnSeconds() { + return burnSeconds; + } + + public Long getViewedAt() { + return viewedAt; + } + + public Long getBurnAt() { + return burnAt; + } + + public Boolean getBurned() { + return burned; + } +} diff --git a/android-app/app/src/main/res/layout/activity_burn_image_viewer.xml b/android-app/app/src/main/res/layout/activity_burn_image_viewer.xml new file mode 100644 index 00000000..68095350 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_burn_image_viewer.xml @@ -0,0 +1,39 @@ + + + + + + + + + +