Merge IM-gift branch: add virtual currency and recharge features

This commit is contained in:
cxytw 2026-01-04 19:16:46 +08:00
commit d234b91836
92 changed files with 3552 additions and 24084 deletions

View File

@ -1,167 +0,0 @@
# 待完成接入接口清单
> 更新时间: 2024-12-30
## 📊 总体情况
| 类别 | 后端接口数 | Android已接入 | 待接入 |
|------|-----------|--------------|--------|
| 总计 | 131 | ~85 | ~46 |
---
## ✅ 已接入模块 (无需处理)
| 模块 | 接口数 | 状态 |
|------|--------|------|
| 用户认证 | 4 | ✅ 全部接入 |
| 用户信息 | 2 | ✅ 全部接入 |
| 直播间 | 6 | ✅ 全部接入 |
| 直播弹幕 | 2 | ✅ 全部接入 |
| 礼物打赏 | 5 | ✅ 全部接入 |
| 私聊会话 | 8 | ✅ 全部接入 |
| 好友管理 | 9 | ✅ 全部接入 |
| 文件上传 | 2 | ✅ 全部接入 |
| 在线状态 | 5 | ✅ 全部接入 |
| 离线消息 | 3 | ✅ 全部接入 |
| 消息表情回应 | 4 | ✅ 全部接入 |
| 关注功能 | 7 | ✅ 全部接入 |
| 作品管理 | 13 | ✅ 全部接入 |
| 搜索功能 | 9 | ✅ 全部接入 |
| 支付集成 | 4 | ✅ 全部接入 |
| 通话功能 | 10 | ✅ 全部接入 |
---
## ❌ 待接入模块
### 1. 群组管理 (10个接口) - 🔴 高优先级
后端已完成Android端未定义接口。
| 接口 | 路径 | 说明 |
|------|------|------|
| 创建群组 | `POST /api/front/groups/create` | 创建新群组 |
| 群组列表 | `GET /api/front/groups/list` | 获取我的群组 |
| 群组详情 | `GET /api/front/groups/{groupId}` | 获取群组信息 |
| 更新群组 | `PUT /api/front/groups/{groupId}` | 修改群组信息 |
| 解散群组 | `DELETE /api/front/groups/{groupId}` | 解散群组 |
| 添加成员 | `POST /api/front/groups/{groupId}/members` | 邀请成员 |
| 移除成员 | `DELETE /api/front/groups/{groupId}/members/{userId}` | 踢出成员 |
| 成员列表 | `GET /api/front/groups/{groupId}/members` | 获取成员 |
| 退出群组 | `POST /api/front/groups/{groupId}/leave` | 主动退群 |
| 转让群主 | `POST /api/front/groups/{groupId}/transfer` | 转让群主 |
---
### 2. 群组消息 (4个接口) - 🔴 高优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 发送群消息 | `POST /api/front/groups/{groupId}/messages` | 发送消息 |
| 群消息历史 | `GET /api/front/groups/{groupId}/messages` | 获取历史 |
| 撤回消息 | `DELETE /api/front/groups/{groupId}/messages/{messageId}` | 撤回消息 |
| 转发消息 | `POST /api/front/groups/{groupId}/messages/{messageId}/forward` | 转发消息 |
---
### 3. 消息搜索 (3个接口) - 🟡 中优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 搜索会话 | `GET /api/front/messages/search/conversations` | 搜索会话 |
| 搜索消息 | `GET /api/front/messages/search/messages` | 搜索消息内容 |
| 全局搜索 | `GET /api/front/messages/search/global` | 全局搜索 |
---
### 4. 评论功能 (8个接口) - 🟡 中优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 发布评论 | `POST /api/front/works/comment/publish` | 发布评论 |
| 评论列表 | `GET /api/front/works/comment/list/{worksId}` | 获取评论 |
| 点赞评论 | `POST /api/front/works/comment/like/{commentId}` | 点赞 |
| 取消点赞 | `POST /api/front/works/comment/unlike/{commentId}` | 取消点赞 |
| 删除评论 | `POST /api/front/works/comment/delete/{commentId}` | 删除评论 |
| 回复列表 | `GET /api/front/works/comment/reply/list/{commentId}` | 获取回复 |
| 评论详情 | `GET /api/front/works/comment/detail/{commentId}` | 评论详情 |
| 检查点赞 | `GET /api/front/works/comment/check-liked/{commentId}` | 检查状态 |
---
### 5. 通知推送 (9个接口) - 🟡 中优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 通知列表 | `GET /api/front/notification/list` | 获取通知 |
| 未读数量 | `GET /api/front/notification/unread-count` | 未读数 |
| 标记已读 | `POST /api/front/notification/mark-read/{id}` | 单条已读 |
| 全部已读 | `POST /api/front/notification/mark-all-read` | 全部已读 |
| 注册FCM | `POST /api/front/notification/fcm/register` | 注册推送 |
| 移除FCM | `POST /api/front/notification/fcm/remove` | 移除推送 |
| 删除通知 | `DELETE /api/front/notification/{id}` | 删除单条 |
| 清空通知 | `DELETE /api/front/notification/clear-all` | 清空全部 |
| 按类型统计 | `GET /api/front/notification/unread-count-by-type` | 分类统计 |
---
### 6. 分类管理 (7个接口) - 🟢 低优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 直播间分类 | `GET /api/front/category/live-room` | 直播分类 |
| 作品分类 | `GET /api/front/category/work` | 作品分类 |
| 分类列表 | `GET /api/front/category/list` | 全部分类 |
| 分类详情 | `GET /api/front/category/{id}` | 分类详情 |
| 分类统计 | `GET /api/front/category/statistics` | 统计数据 |
| 热门分类 | `GET /api/front/category/hot` | 热门分类 |
| 子分类 | `GET /api/front/category/{parentId}/children` | 子分类 |
---
### 7. 文件上传补充 (3个接口) - 🟢 低优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 通用图片上传 | `POST /api/upload/image` | 通用图片 |
| 通用文件上传 | `POST /api/upload/file` | 通用文件 |
| 语音上传 | `POST /api/upload/chat/voice` | 语音消息 |
---
### 8. 直播间补充 (4个接口) - 🟢 低优先级
| 接口 | 路径 | 说明 |
|------|------|------|
| 开始直播 | `POST /api/front/live/room/{id}/start` | 开播 |
| 结束直播 | `POST /api/front/live/room/{id}/stop` | 停播 |
| 观众列表 | `GET /api/rooms/{roomId}/viewers` | 观众列表 |
| 手动广播人数 | `POST /api/live/online/broadcast/{roomId}` | 广播人数 |
---
## 📋 接入优先级建议
### 第一优先级 (核心社交)
1. **群组管理** - 10个接口
2. **群组消息** - 4个接口
### 第二优先级 (内容互动)
3. **评论功能** - 8个接口
4. **通知推送** - 9个接口
### 第三优先级 (辅助功能)
5. **消息搜索** - 3个接口
6. **分类管理** - 7个接口
7. **文件上传补充** - 3个接口
8. **直播间补充** - 4个接口
---
## 📝 备注
- 后端接口已全部完成 (131个)
- Android端已接入约85个接口
- 待接入约46个接口
- 核心功能已基本完成,待接入的主要是群组和辅助功能

View File

@ -5,8 +5,8 @@
本文档详细列出了Android应用中**真实调用的所有接口**的请求参数和响应参数示例。
**文档版本**: v1.0
**更新时间**: 2024-12-30
**接口总数**: 73个真实调用的接口
**更新时间**: 2024-12-31
**接口总数**: 74个真实调用的接口
**基础URL**: `http://your-server:port`
---
@ -26,6 +26,7 @@
11. [搜索功能模块](#11-搜索功能模块)
12. [观看历史模块](#12-观看历史模块)
13. [分类管理模块](#13-分类管理模块)
14. [直播类型模块](#14-直播类型模块)
---
@ -292,7 +293,7 @@ Authorization: Bearer <token>
{
"title": "我的直播间",
"streamerName": "主播昵称",
"type": "video",
"type": "game",
"categoryId": 1,
"description": "直播间描述",
"coverImage": "https://example.com/cover.jpg",
@ -306,13 +307,25 @@ Authorization: Bearer <token>
|--------|------|------|------|
| title | String | 是 | 直播间标题 |
| streamerName | String | 是 | 主播名称 |
| type | String | 否 | 直播类型默认video |
| type | String | 否 | 直播类型编码game/talent/outdoor/music/food/chat默认game |
| categoryId | Integer | 否 | 分类ID |
| description | String | 否 | 直播间描述 |
| coverImage | String | 否 | 封面图片URL |
| tags | String | 否 | 标签,逗号分隔 |
| notice | String | 否 | 直播间公告 |
**重要说明**:
- `type` 参数应使用类型编码code而不是类型名称name
- 可用的类型编码:
- `game` - 游戏
- `talent` - 才艺
- `outdoor` - 户外
- `music` - 音乐
- `food` - 美食
- `chat` - 聊天
- 前端应先调用 `GET /api/front/live/public/types` 获取类型列表
- 如果type参数为空后端会使用默认值 `game`
**响应示例**:
```json
{
@ -321,6 +334,7 @@ Authorization: Bearer <token>
"data": {
"id": "10",
"title": "我的直播间",
"type": "game",
"streamKey": "live_stream_key_456",
"streamUrls": {
"rtmp": "rtmp://server:25002/live/stream_key_456",
@ -379,7 +393,7 @@ Authorization: Bearer <token>
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Integer | 是 | 直播间ID |
| id | String | 是 | 直播间ID |
**响应示例**:
```json
@ -405,7 +419,7 @@ Authorization: Bearer <token>
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Integer | 是 | 直播间ID |
| id | String | 是 | 直播间ID |
**响应示例**:
```json
@ -469,6 +483,11 @@ Authorization: Bearer <token>
|--------|------|------|------|
| roomId | String | 是 | 直播间ID |
**重要说明**:
- 此接口路径不包含 `/front` 前缀
- 完整路径为: `POST /api/live/online/broadcast/{roomId}`
- 用于向WebSocket客户端广播直播间在线人数更新
**响应示例**:
```json
{
@ -1558,6 +1577,7 @@ Authorization: Bearer <token>
{
"title": "我的作品标题",
"description": "作品描述内容",
"type": "VIDEO",
"coverUrl": "https://example.com/cover.jpg",
"videoUrl": "https://example.com/video.mp4",
"categoryId": 1,
@ -1603,6 +1623,7 @@ Authorization: Bearer <token>
{
"id": 10001,
"title": "修改后的标题",
"type": "VIDEO",
"description": "修改后的描述",
"coverUrl": "https://example.com/new-cover.jpg",
"categoryId": 2,
@ -1616,6 +1637,7 @@ Authorization: Bearer <token>
|--------|------|------|------|
| id | Long | 是 | 作品ID |
| title | String | 否 | 作品标题 |
| type | String | 否 | 作品类型 |
| description | String | 否 | 作品描述 |
| coverUrl | String | 否 | 封面图片URL |
| categoryId | Integer | 否 | 分类ID |
@ -2538,6 +2560,90 @@ Authorization: Bearer <token>
---
## 14. 直播类型模块
### 14.1 获取直播类型列表
**接口地址**: `GET /api/front/live/public/types`
**请求参数**: 无
**响应示例**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "游戏",
"code": "game",
"icon": "",
"description": "游戏直播",
"sort": 100
},
{
"id": 2,
"name": "才艺",
"code": "talent",
"icon": "",
"description": "才艺表演",
"sort": 90
},
{
"id": 3,
"name": "户外",
"code": "outdoor",
"icon": "",
"description": "户外直播",
"sort": 80
},
{
"id": 4,
"name": "音乐",
"code": "music",
"icon": "",
"description": "音乐直播",
"sort": 70
},
{
"id": 5,
"name": "美食",
"code": "food",
"icon": "",
"description": "美食直播",
"sort": 60
},
{
"id": 6,
"name": "聊天",
"code": "chat",
"icon": "",
"description": "聊天互动",
"sort": 50
}
]
}
```
**响应字段说明**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Integer | 类型ID |
| name | String | 类型名称(显示用) |
| code | String | 类型编码(提交用) |
| icon | String | 类型图标URL |
| description | String | 类型描述 |
| sort | Integer | 排序值,越大越靠前 |
**使用说明**:
- 此接口用于获取创建直播间时可选的类型列表
- 前端在创建直播间时,应使用 `code` 字段的值提交给后端
- 类型列表按 `sort` 字段降序排列
- 只返回状态为启用的类型
---
## 📝 附录
### A. 通用响应格式

View File

@ -1,340 +0,0 @@
# Android应用接口真实调用情况分析报告
## 📊 分析概述
本报告详细分析了Android应用中对后端接口的**真实调用情况**,区分了"已定义但未使用"和"已实际调用"的接口。
**分析时间**: 2024年12月30日
**分析范围**: android-app 和 java-backend 全部代码
**分析方法**: 代码静态分析 + 接口定义对比
---
## ✅ 已真实调用的接口(共 45+ 个)
### 1. 用户认证模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/login` | LoginActivity.java | ✅ 真实调用 |
| `POST /api/front/register` | LoginActivity.java | ✅ 真实调用 |
| `POST /api/front/sendCode` | LoginActivity.java | ✅ 真实调用 |
| `GET /api/front/logout` | SettingsPageActivity.java | ✅ 真实调用 |
| `GET /api/front/user` | ProfileActivity.java | ✅ 真实调用 |
### 2. 直播间模块 (85% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/live/public/rooms` | MainActivity.java | ✅ 真实调用 |
| `POST /api/front/live/rooms` | MainActivity.java | ✅ 真实调用 |
| `GET /api/front/live/public/rooms/{id}` | RoomDetailActivity.java | ✅ 真实调用 |
| `DELETE /api/front/live/rooms/{id}` | ProfileActivity.java | ✅ 真实调用 |
| `POST /api/front/live/follow` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/room/{id}/start` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/room/{id}/stop` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/live/online/broadcast/{roomId}` | RoomDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/live/public/rooms/{roomId}/messages` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/rooms/{roomId}/gift` | RoomDetailActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `GET /api/front/live/public/rooms/{roomId}/viewers/count` - 已定义但未使用
- ❌ `GET /api/front/live/rooms/{roomId}/viewers` - 已定义但未使用
### 3. 礼物打赏模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/gift/list` | RoomDetailActivity.java:1190 | ✅ 真实调用 |
| `GET /api/front/gift/balance` | RoomDetailActivity.java:1588 | ✅ 真实调用 |
| `POST /api/front/gift/send` | RoomDetailActivity.java:1636 | ✅ 真实调用 |
| `GET /api/front/gift/recharge/options` | RoomDetailActivity.java:1387 | ✅ 真实调用 |
| `POST /api/front/gift/recharge/create` | RoomDetailActivity.java:1460 | ✅ 真实调用 |
| `POST /api/front/pay/payment` | RoomDetailActivity.java:1537 | ✅ 真实调用 |
### 4. 私聊会话模块 (90% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/conversations` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/conversations/search` | MessagesActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/with/{otherUserId}` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/{id}/read` | ChatActivity.java | ✅ 真实调用 |
| `DELETE /api/front/conversations/{id}` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/conversations/{id}/messages` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/{id}/messages` | ChatActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `DELETE /api/front/conversations/messages/{id}` - 已定义但未使用(删除单条消息功能未实现)
### 5. 好友管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/friends` | MyFriendsActivity.java | ✅ 真实调用 |
| `DELETE /api/front/friends/{friendId}` | MyFriendsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/block/{friendId}` | MyFriendsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/unblock/{friendId}` | BlockedListActivity.java | ✅ 真实调用 |
| `GET /api/front/friends/blocked` | BlockedListActivity.java | ✅ 真实调用 |
| `GET /api/front/users/search` | AddFriendActivity.java:62 | ✅ 真实调用 |
| `POST /api/front/friends/request` | AddFriendActivity.java:142 | ✅ 真实调用 |
| `GET /api/front/friends/requests` | FriendRequestsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/requests/{requestId}/handle` | FriendRequestsActivity.java | ✅ 真实调用 |
### 6. 关注功能模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/follow/follow` | UserProfileReadOnlyActivity.java:141 | ✅ 真实调用 |
| `POST /api/front/follow/unfollow` | UserProfileReadOnlyActivity.java | ✅ 真实调用 |
| `GET /api/front/follow/status/{userId}` | UserProfileReadOnlyActivity.java:105 | ✅ 真实调用 |
| `POST /api/front/follow/status/batch` | FishPondActivity.java | ✅ 真实调用 |
| `GET /api/front/follow/following` | FollowingListActivity.java:62 | ✅ 真实调用 |
| `GET /api/front/follow/followers` | FansListActivity.java:62 | ✅ 真实调用 |
| `GET /api/front/follow/stats` | ProfileActivity.java:684 | ✅ 真实调用 |
### 7. 作品管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/works/publish` | PublishWorkActivity.java:766 | ✅ 真实调用 |
| `POST /api/front/works/update` | EditWorkActivity.java | ✅ 真实调用 |
| `POST /api/front/works/delete/{worksId}` | ProfileActivity.java | ✅ 真实调用 |
| `GET /api/front/works/detail/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/search` | SearchActivity.java | ✅ 真实调用 |
| `GET /api/front/works/user/{userId}` | ProfileActivity.java | ✅ 真实调用 |
| `POST /api/front/works/like/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/unlike/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/collect/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/uncollect/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/works/my/liked` | ProfileActivity.java | ✅ 真实调用 |
| `GET /api/front/works/my/collected` | ProfileActivity.java | ✅ 真实调用 |
### 8. 文件上传模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/user/upload/image` | PublishWorkActivity.java:642 | ✅ 真实调用 |
| `POST /api/front/upload/work/video` | PublishWorkActivity.java:691 | ✅ 真实调用 |
### 9. 在线状态模块 (80% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/online/status/{userId}` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/online/status/batch` | MyFriendsActivity.java | ✅ 真实调用 |
| `GET /api/front/online/room/{roomId}/count` | RoomDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/online/room/{roomId}/users` | RoomDetailActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `GET /api/front/online/stats` - 已定义但未使用(连接统计功能未实现)
### 10. 离线消息模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/online/offline/count/{userId}` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/online/offline/messages/{userId}` | MessagesActivity.java | ✅ 真实调用 |
| `DELETE /api/front/online/offline/messages/{userId}` | MessagesActivity.java | ✅ 真实调用 |
### 11. 搜索功能模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/search/live-rooms` | SearchActivity.java:156 | ✅ 真实调用 |
| `GET /api/front/search/hot` | SearchActivity.java:257 | ✅ 真实调用 |
| `GET /api/front/search/suggestions` | SearchActivity.java:286 | ✅ 真实调用 |
| `GET /api/front/search/history` | SearchActivity.java | ✅ 真实调用 |
| `DELETE /api/front/search/history` | SearchActivity.java | ✅ 真实调用 |
### 12. 观看历史模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/watch/history` | RoomDetailActivity.java:744 | ✅ 真实调用 |
### 13. 分类管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/category/live-room` | MainActivity.java | ✅ 真实调用 |
| `GET /api/front/category/work` | PublishWorkActivity.java | ✅ 真实调用 |
---
## ❌ 已定义但未真实调用的接口(共 8 个)
### 1. 直播间模块
```java
// ApiService.java 中已定义,但在代码中未找到调用
❌ GET /api/front/live/public/rooms/{roomId}/viewers/count
- 功能: 获取观众数量
- 原因: 使用 WebSocket 实时推送在线人数,不需要轮询
❌ GET /api/front/live/rooms/{roomId}/viewers
- 功能: 获取观众列表
- 原因: 功能未实现UI中没有显示观众列表的入口
```
### 2. 私聊模块
```java
❌ DELETE /api/front/conversations/messages/{id}
- 功能: 删除单条消息
- 原因: UI中只实现了删除整个会话未实现删除单条消息
```
### 3. 在线状态模块
```java
❌ GET /api/front/online/stats
- 功能: 获取连接统计信息
- 原因: 管理功能,前端应用不需要
```
### 4. 消息表情回应模块(整个模块未使用)
```java
❌ POST /api/front/messages/reactions/add
❌ DELETE /api/front/messages/reactions/remove
❌ GET /api/front/messages/{messageId}/reactions
❌ GET /api/front/messages/{messageId}/reactions/users
- 功能: 消息表情回应(类似微信的点赞、爱心等)
- 原因: 功能未实现UI设计中没有这个功能
```
### 5. 搜索模块
```java
❌ GET /api/front/search/users
- 功能: 全局搜索用户
- 原因: 使用了 /api/front/users/search 替代
❌ GET /api/front/search/works
- 功能: 全局搜索作品
- 原因: 使用了 /api/front/works/search 替代
❌ GET /api/front/search/all
- 功能: 综合搜索
- 原因: 功能未实现,当前只支持分类搜索
```
---
## 📈 统计数据
### 整体调用率
| 模块 | 已定义接口数 | 真实调用数 | 调用率 |
|------|------------|-----------|--------|
| 用户认证 | 5 | 5 | 100% |
| 直播间 | 12 | 10 | 83% |
| 礼物打赏 | 6 | 6 | 100% |
| 私聊会话 | 8 | 7 | 88% |
| 好友管理 | 9 | 9 | 100% |
| 关注功能 | 7 | 7 | 100% |
| 作品管理 | 12 | 12 | 100% |
| 文件上传 | 2 | 2 | 100% |
| 在线状态 | 5 | 4 | 80% |
| 离线消息 | 3 | 3 | 100% |
| 搜索功能 | 8 | 5 | 63% |
| 观看历史 | 1 | 1 | 100% |
| 分类管理 | 2 | 2 | 100% |
| 消息表情 | 4 | 0 | 0% |
| **总计** | **84** | **73** | **87%** |
### 关键发现
1. **高调用率模块** (100%):
- ✅ 用户认证、礼物打赏、好友管理、关注功能、作品管理
- ✅ 文件上传、离线消息、观看历史、分类管理
2. **中等调用率模块** (80-90%):
- ⚠️ 直播间模块 (83%) - 部分接口被 WebSocket 替代
- ⚠️ 私聊会话 (88%) - 删除单条消息功能未实现
- ⚠️ 在线状态 (80%) - 统计接口未使用
3. **低调用率模块** (< 80%):
- ⚠️ 搜索功能 (63%) - 部分接口有替代方案
- ❌ 消息表情 (0%) - 功能完全未实现
---
## 🔍 WebSocket 实时通信使用情况
### 已实现的 WebSocket 连接
| WebSocket | 用途 | 调用位置 | 状态 |
|-----------|------|---------|------|
| `ws://*/ws/live/chat/{roomId}` | 直播间弹幕 | RoomDetailActivity.java:connectChatWebSocket() | ✅ 真实使用 |
| `ws://*/ws/live/{roomId}` | 在线人数推送 | RoomDetailActivity.java:connectOnlineCountWebSocket() | ✅ 真实使用 |
| `ws://*/ws/private/chat` | 私聊消息 | ChatActivity.java:connectWebSocket() | ✅ 真实使用 |
| `ws://*/ws/online/status` | 在线状态推送 | MyFriendsActivity.java:connectWebSocket() | ✅ 真实使用 |
**WebSocket 特性**:
- ✅ 心跳检测机制 (30秒间隔)
- ✅ 自动重连机制 (最多5次)
- ✅ 连接状态管理
- ✅ 错误处理和降级
---
## 💡 结论与建议
### 结论
1. **整体调用率很高**: 87% 的接口都在真实使用中
2. **核心功能完整**: 用户认证、直播、私聊、好友、关注、作品等核心模块100%调用
3. **WebSocket 替代 HTTP**: 部分实时功能使用 WebSocket 替代了 HTTP 轮询
4. **未使用接口有原因**: 大部分未使用接口是因为功能未实现或有更好的替代方案
### 建议
#### 1. 可以删除的接口定义(降低维护成本)
```java
// ApiService.java 中可以删除以下接口定义
- GET /api/front/live/public/rooms/{roomId}/viewers/count (被 WebSocket 替代)
- GET /api/front/online/stats (前端不需要)
- 整个消息表情回应模块 (功能未实现)
```
#### 2. 建议实现的功能
```java
// 提升用户体验的功能
✨ DELETE /api/front/conversations/messages/{id}
- 实现删除单条消息功能
- 增加长按消息的操作菜单
✨ GET /api/front/search/all
- 实现综合搜索功能
- 一次搜索返回用户、直播间、作品等多种结果
```
#### 3. 代码优化建议
```java
// 统一搜索接口
- 将 /api/front/users/search 和 /api/front/search/users 合并
- 将 /api/front/works/search 和 /api/front/search/works 合并
```
---
## 📝 验证方法
本报告通过以下方法验证接口调用情况:
1. **静态代码分析**: 搜索 `apiService.` 关键字,找到所有 Retrofit 接口调用
2. **文件定位**: 记录每个接口调用的具体文件和行号
3. **对比分析**: 将 ApiService.java 中定义的接口与实际调用进行对比
4. **WebSocket 分析**: 检查 WebSocket 连接代码,确认实时通信功能
5. **交叉验证**: 检查后端 Controller 代码,确认接口实现情况
---
**报告生成时间**: 2024-12-30
**分析工具**: 代码静态分析 + 人工审查
**可信度**: ⭐⭐⭐⭐⭐ (非常高)

View File

@ -84,7 +84,11 @@
:preview-src-list="[scope.row.image]"
style="width: 50px; height: 50px"
fit="cover"
/>
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline"></i>
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="礼物名称" align="center" prop="name" />
@ -126,7 +130,8 @@
:total="total"
:page.sync="queryParams.page"
:limit.sync="queryParams.limit"
@pagination="getList"
@size-change="getList"
@current-change="getList"
/>
</el-card>
@ -190,6 +195,7 @@
<script>
import { giftListApi, giftAddApi, giftUpdateApi, giftDeleteApi, giftStatusApi } from '@/api/gift';
import { fileImageApi } from '@/api/systemSetting';
import DataPagination from '@/components/common/DataPagination';
export default {
@ -240,27 +246,37 @@ export default {
getList() {
this.loading = true;
giftListApi(this.queryParams).then(response => {
const data = response.data || {};
this.giftList = data.list || [];
this.total = data.total || 0;
console.log('礼物列表响应:', response);
// axios res.data response CommonPage
// response = { list: [], total: 0, page: 1, limit: 10, totalPage: 1 }
const list = response.list || [];
// status 1 0
this.giftList = list.map(item => ({
...item,
status: Number(item.status) //
}));
this.total = response.total || 0;
console.log('礼物列表数据:', this.giftList);
console.log('总数:', this.total);
this.loading = false;
}).catch(() => {
}).catch(error => {
console.error('获取礼物列表失败:', error);
this.$message.error('获取礼物列表失败');
this.loading = false;
});
},
getStatistics() {
//
giftListApi({ page: 1, limit: 9999 }).then(response => {
console.log('统计数据响应:', response);
const data = response.data || {};
const list = data.list || [];
console.log('礼物列表:', list);
console.log('列表长度:', list.length);
const list = response.list || [];
this.statistics.total = list.length;
this.statistics.enabled = list.filter(item => item.status === true).length;
this.statistics.disabled = list.filter(item => item.status === false).length;
this.statistics.totalValue = list.reduce((sum, item) => sum + (parseFloat(item.diamondPrice) || 0), 0);
console.log('统计结果:', this.statistics);
this.statistics.enabled = list.filter(item => item.status === 1 || item.status === true).length;
this.statistics.disabled = list.filter(item => item.status === 0 || item.status === false).length;
this.statistics.totalValue = list.reduce((sum, item) => sum + (parseFloat(item.diamondPrice) || 0), 0).toFixed(2);
}).catch(error => {
console.error('获取统计数据失败:', error);
});
@ -369,11 +385,36 @@ export default {
});
},
handleUpload(param) {
//
const formData = new FormData();
formData.append('file', param.file);
//
this.$message.info('图片上传功能需要配置上传接口');
formData.append('multipart', param.file); // multipart
//
const loading = this.$loading({
lock: true,
text: '上传中...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
fileImageApi(formData, { model: 'gift', pid: 0 })
.then(response => {
loading.close();
console.log('上传响应:', response);
//
if (response) {
// response FileResultVo URL
this.form.image = response.url || response.fileUrl || response;
this.$message.success('上传成功');
} else {
this.$message.error('上传失败,返回数据格式错误');
}
})
.catch(error => {
loading.close();
console.error('上传失败:', error);
this.$message.error('上传失败: ' + (error.message || '未知错误'));
});
},
beforeUpload(file) {
const isImage = file.type.indexOf('image/') === 0;
@ -385,6 +426,57 @@ export default {
this.$message.error('上传图片大小不能超过 2MB!');
}
return isImage && isLt2M;
},
getImageUrl(url) {
if (!url) {
console.log('图片URL为空');
return '';
}
console.log('原始图片URL:', url);
console.log('URL类型:', typeof url);
//
let urlStr = String(url).trim();
console.log('转换后的URL:', urlStr);
// URL
// http://domain/http://domain/crmebimage/... -> crmebimage/...
if (urlStr.indexOf('http://') > 0 || urlStr.indexOf('https://') > 0) {
console.log('检测到重复拼接的URL尝试修复');
// http:// https://
const secondHttpIndex = urlStr.indexOf('http://', 1);
const secondHttpsIndex = urlStr.indexOf('https://', 1);
if (secondHttpIndex > 0) {
urlStr = urlStr.substring(secondHttpIndex);
console.log('修复后的URL:', urlStr);
} else if (secondHttpsIndex > 0) {
urlStr = urlStr.substring(secondHttpsIndex);
console.log('修复后的URL:', urlStr);
}
}
// URLhttp:// https://
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
console.log('检测到完整URL直接返回');
return urlStr;
}
//
// /
let path = urlStr;
if (path.charAt(0) === '/') {
path = path.substring(1);
}
// 使
const baseUrl = window.location.origin;
const fullUrl = baseUrl + '/' + path;
console.log('拼接后的完整URL:', fullUrl);
return fullUrl;
}
}
};
@ -448,4 +540,15 @@ export default {
height: 120px;
display: block;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: #909399;
font-size: 30px;
}
</style>

View File

@ -95,7 +95,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeMessage',

View File

@ -89,7 +89,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeNodeListApi, wishTreeNodeSaveApi, wishTreeNodeUpdateApi, wishTreeNodeDeleteApi, wishTreeNodeStatusApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeNodeListApi, wishTreeNodeSaveApi, wishTreeNodeUpdateApi, wishTreeNodeDeleteApi, wishTreeNodeStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeNode',

View File

@ -93,7 +93,7 @@
</template>
<script>
import { wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishtree';
import { wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeDetail',

View File

@ -104,7 +104,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeInfoApi, wishTreeSaveApi, wishTreeUpdateApi, wishTreeDeleteApi, wishTreeActivateApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeInfoApi, wishTreeSaveApi, wishTreeUpdateApi, wishTreeDeleteApi, wishTreeActivateApi } from '@/api/wishTree';
export default {
name: 'WishTreeList',

View File

@ -61,7 +61,7 @@
</template>
<script>
import { backgroundList, backgroundSave, backgroundDelete, festivalList } from '@/api/wishtree';
import { backgroundList, backgroundSave, backgroundDelete, festivalList } from '@/api/wishTree';
export default {
name: 'WishtreeBackground',

View File

@ -78,7 +78,7 @@
</template>
<script>
import { festivalList, festivalSave, festivalDelete, festivalStatus } from '@/api/wishtree';
import { festivalList, festivalSave, festivalDelete, festivalStatus } from '@/api/wishTree';
export default {
name: 'WishtreeFestival',

View File

@ -86,7 +86,7 @@
</template>
<script>
import { getStatistics } from '@/api/wishtree';
import { getStatistics } from '@/api/wishTree';
import * as echarts from 'echarts';
export default {

View File

@ -74,7 +74,7 @@
</template>
<script>
import { wishList, wishAudit, wishDelete, festivalList } from '@/api/wishtree';
import { wishList, wishAudit, wishDelete, festivalList } from '@/api/wishTree';
export default {
name: 'WishtreeWish',

View File

@ -53,6 +53,14 @@ module.exports = {
'/api': {
target: 'http://localhost:30001',
changeOrigin: true
},
'/crmebimage': {
target: 'http://localhost:30001',
changeOrigin: true
},
'/file': {
target: 'http://localhost:30001',
changeOrigin: true
}
}
},
@ -64,6 +72,9 @@ module.exports = {
alias: {
'@': resolve('src'),
},
extensions: ['.js', '.vue', '.json'],
// 在Windows上强制不区分大小写
symlinks: false,
},
},
chainWebpack(config) {

View File

@ -92,13 +92,26 @@ public class WebConfig implements WebMvcConfigurer {
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
/** 本地文件上传路径 */
registry.addResourceHandler(UploadConstants.UPLOAD_FILE_KEYWORD + "/**")
.addResourceLocations("file:" + crmebConfig.getImagePath() + "/" + UploadConstants.UPLOAD_FILE_KEYWORD + "/");
/** 本地文件上传路径 - 使用jar包所在目录 */
String uploadPath = crmebConfig.getAbsoluteImagePath();
registry.addResourceHandler(UploadConstants.UPLOAD_AFTER_FILE_KEYWORD + "/**")
.addResourceLocations("file:" +crmebConfig.getImagePath() + "/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD + "/" );
// 添加 image 路径映射修复图片上传显示问题
registry.addResourceHandler("/image/**")
.addResourceLocations("file:" + uploadPath + "image" + File.separator);
// 添加 video 路径映射
registry.addResourceHandler("/video/**")
.addResourceLocations("file:" + uploadPath + "video" + File.separator);
// 添加 voice 路径映射
registry.addResourceHandler("/voice/**")
.addResourceLocations("file:" + uploadPath + "voice" + File.separator);
registry.addResourceHandler("/crmebimage/**")
.addResourceLocations("file:" + uploadPath + "crmebimage" + File.separator);
registry.addResourceHandler("/file/**")
.addResourceLocations("file:" + uploadPath + "file" + File.separator);
}
@Bean

View File

@ -346,6 +346,8 @@ public class GiftAdminController {
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "limit", defaultValue = "20") Integer limit) {
log.info("礼物列表查询 - 参数: name={}, status={}, page={}, limit={}", name, status, page, limit);
StringBuilder sql = new StringBuilder();
sql.append("SELECT id, name, image, diamond_price as diamondPrice, intimacy, status, ");
sql.append("is_heartbeat as isHeartbeat, buy_type as buyType, belong, remark, ");
@ -375,23 +377,32 @@ public class GiftAdminController {
countParams.add(status);
}
sql.append(" ORDER BY sort ASC, id DESC ");
sql.append(" ORDER BY sort ASC, id ASC ");
Long total = jdbcTemplate.queryForObject(countSql.toString(), Long.class, countParams.toArray());
if (total == null) {
total = 0L;
}
int offset = (page - 1) * limit;
sql.append(" LIMIT ? OFFSET ? ");
params.add(limit);
params.add(offset);
log.info("礼物列表查询 - SQL: {}", sql.toString());
log.info("礼物列表查询 - 参数: {}", params);
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString(), params.toArray());
// 打印日志用于调试
log.info("礼物列表查询 - 总数: {}, 当前页: {}, 每页: {}, offset: {}, 列表大小: {}", total, page, limit, offset, list.size());
CommonPage<Map<String, Object>> result = new CommonPage<>();
result.setList(list);
result.setTotal(total != null ? total : 0L);
result.setTotal(total);
result.setPage(page);
result.setLimit(limit);
result.setTotalPage((int) Math.ceil((double) (total != null ? total : 0) / limit));
result.setTotalPage((int) Math.ceil((double) total / limit));
return CommonResult.success(result);
}
@ -403,14 +414,19 @@ public class GiftAdminController {
@PostMapping("/add")
public CommonResult<String> addGift(@RequestBody Map<String, Object> request) {
try {
log.info("添加礼物 - 请求参数: {}", request);
String name = (String) request.get("name");
String image = (String) request.get("image");
BigDecimal diamondPrice = new BigDecimal(request.get("diamondPrice").toString());
Integer intimacy = request.get("intimacy") != null ? Integer.parseInt(request.get("intimacy").toString()) : 0;
Integer level = request.get("level") != null ? Integer.parseInt(request.get("level").toString()) : 1;
Integer isHeartbeat = request.get("isHeartbeat") != null ? Integer.parseInt(request.get("isHeartbeat").toString()) : 0;
// 处理布尔值或整数值
Integer isHeartbeat = convertToInteger(request.get("isHeartbeat"), 0);
Integer sort = request.get("sort") != null ? Integer.parseInt(request.get("sort").toString()) : 0;
Integer status = request.get("status") != null ? Integer.parseInt(request.get("status").toString()) : 1;
Integer status = convertToInteger(request.get("status"), 1);
String remark = (String) request.get("remark");
String buyType = request.get("buyType") != null ? (String) request.get("buyType") : "钻石";
String belong = request.get("belong") != null ? (String) request.get("belong") : "平台";
@ -420,6 +436,7 @@ public class GiftAdminController {
jdbcTemplate.update(sql, name, image, diamondPrice, intimacy, status, isHeartbeat,
buyType, belong, remark, level, sort);
log.info("添加礼物成功 - 名称: {}", name);
return CommonResult.success("添加成功");
} catch (Exception e) {
log.error("添加礼物失败", e);
@ -434,15 +451,20 @@ public class GiftAdminController {
@PostMapping("/update")
public CommonResult<String> updateGift(@RequestBody Map<String, Object> request) {
try {
log.info("更新礼物 - 请求参数: {}", request);
Integer id = Integer.parseInt(request.get("id").toString());
String name = (String) request.get("name");
String image = (String) request.get("image");
BigDecimal diamondPrice = new BigDecimal(request.get("diamondPrice").toString());
Integer intimacy = request.get("intimacy") != null ? Integer.parseInt(request.get("intimacy").toString()) : 0;
Integer level = request.get("level") != null ? Integer.parseInt(request.get("level").toString()) : 1;
Integer isHeartbeat = request.get("isHeartbeat") != null ? Integer.parseInt(request.get("isHeartbeat").toString()) : 0;
// 处理布尔值或整数值
Integer isHeartbeat = convertToInteger(request.get("isHeartbeat"), 0);
Integer sort = request.get("sort") != null ? Integer.parseInt(request.get("sort").toString()) : 0;
Integer status = request.get("status") != null ? Integer.parseInt(request.get("status").toString()) : 1;
Integer status = convertToInteger(request.get("status"), 1);
String remark = (String) request.get("remark");
String buyType = request.get("buyType") != null ? (String) request.get("buyType") : "钻石";
String belong = request.get("belong") != null ? (String) request.get("belong") : "平台";
@ -452,6 +474,7 @@ public class GiftAdminController {
jdbcTemplate.update(sql, name, image, diamondPrice, intimacy, status, isHeartbeat,
buyType, belong, remark, level, sort, id);
log.info("更新礼物成功 - ID: {}", id);
return CommonResult.success("更新成功");
} catch (Exception e) {
log.error("更新礼物失败", e);
@ -459,6 +482,35 @@ public class GiftAdminController {
}
}
/**
* 将对象转换为整数支持布尔值字符串整数
*/
private Integer convertToInteger(Object value, Integer defaultValue) {
if (value == null) {
return defaultValue;
}
// 如果是布尔值
if (value instanceof Boolean) {
return ((Boolean) value) ? 1 : 0;
}
// 如果是字符串
String strValue = value.toString().toLowerCase();
if ("true".equals(strValue)) {
return 1;
} else if ("false".equals(strValue)) {
return 0;
}
// 尝试解析为整数
try {
return Integer.parseInt(strValue);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 删除礼物
*/
@ -490,12 +542,15 @@ public class GiftAdminController {
@PostMapping("/status")
public CommonResult<String> updateGiftStatus(@RequestBody Map<String, Object> request) {
try {
log.info("更新礼物状态 - 请求参数: {}", request);
Integer id = Integer.parseInt(request.get("id").toString());
Integer status = Integer.parseInt(request.get("status").toString());
Integer status = convertToInteger(request.get("status"), 1);
String sql = "UPDATE eb_gift SET status = ? WHERE id = ?";
jdbcTemplate.update(sql, status, id);
log.info("更新礼物状态成功 - ID: {}, 状态: {}", id, status);
return CommonResult.success("状态更新成功");
} catch (Exception e) {
log.error("更新礼物状态失败", e);

View File

@ -39,7 +39,18 @@ public class ResponseRouter {
return data;
}
// 排除上传接口上传接口返回的URL不需要添加前缀
// 因为前端会直接保存到数据库下次查询时会再次处理
if (path.contains("/upload/image") || path.contains("/upload/file")) {
return data;
}
//根据需要处理返回值 && !data.contains("data:image/png;base64")
// 如果数据已经包含完整的URLhttp://或https://则不处理
if (data.contains("http://") || data.contains("https://")) {
return data;
}
if ((data.contains(UploadConstants.UPLOAD_FILE_KEYWORD + "/"))
|| data.contains(UploadConstants.DOWNLOAD_FILE_KEYWORD) || data.contains(UploadConstants.UPLOAD_AFTER_FILE_KEYWORD)) {
if (data.contains(UploadConstants.DOWNLOAD_FILE_KEYWORD + "/" + UploadConstants.UPLOAD_MODEL_PATH_EXCEL)) {

View File

@ -5,9 +5,9 @@ crmeb:
wechat-api-url: #请求微信接口中专服务器
wechat-js-api-debug: false #微信js api系列是否开启调试模式
wechat-js-api-beta: true #微信js api是否是beta版本
asyncConfig: true #是否同步config表数据到redis
asyncConfig: false #是否同步config表数据到redis - 改为false直接从数据库读取
asyncWeChatProgramTempList: false #是否同步小程序公共模板库
imagePath: /Users/23730/Desktop/projectr/single_java/ # 服务器图片路径配置 斜杠结尾
imagePath: upload/ # 服务器图片路径配置 - jar包同级目录的upload文件夹
demoSite: true # 是否演示站点 所有手机号码都会掩码
activityStyleCachedTime: 10 #活动边框缓存周期 秒为单位生产环境适当5-10分钟即可
ignored: #安全路径白名单

View File

@ -107,6 +107,67 @@ public class CrmebConfig {
return imagePath;
}
/**
* 获取图片存储的绝对路径
* 如果配置的是相对路径则转换为jar包所在目录的绝对路径
*/
public String getAbsoluteImagePath() {
if (imagePath == null || imagePath.isEmpty()) {
String result = getJarDirectory() + "/upload/";
System.out.println("图片路径(默认): " + result);
return result;
}
// 如果是绝对路径Windows: C:/ Linux: /直接返回
if (imagePath.startsWith("/") || imagePath.matches("^[a-zA-Z]:.*")) {
System.out.println("图片路径(绝对): " + imagePath);
return imagePath;
}
// 相对路径转换为jar包所在目录的绝对路径
String absolutePath = getJarDirectory() + "/" + imagePath;
// 确保以斜杠结尾
if (!absolutePath.endsWith("/") && !absolutePath.endsWith("\\")) {
absolutePath += "/";
}
System.out.println("图片路径(相对转绝对): " + absolutePath);
return absolutePath;
}
/**
* 获取jar包所在目录
*/
private String getJarDirectory() {
try {
String path = CrmebConfig.class.getProtectionDomain().getCodeSource().getLocation().getPath();
// 解码URL编码的路径
path = java.net.URLDecoder.decode(path, "UTF-8");
System.out.println("=== 调试信息 ===");
System.out.println("原始路径: " + path);
// 如果是jar包去掉jar文件名
if (path.endsWith(".jar")) {
path = path.substring(0, path.lastIndexOf("/"));
System.out.println("去掉jar文件名后: " + path);
}
// 去掉开头的斜杠Windows
if (path.startsWith("/") && path.matches("^/[a-zA-Z]:.*")) {
path = path.substring(1);
System.out.println("去掉开头斜杠后: " + path);
}
System.out.println("最终jar目录: " + path);
System.out.println("===============");
return path;
} catch (Exception e) {
System.out.println("获取jar目录失败使用user.dir: " + System.getProperty("user.dir"));
// 如果获取失败使用当前工作目录
return System.getProperty("user.dir");
}
}
public void setImagePath(String imagePath) {
this.imagePath = imagePath;
}

View File

@ -13,7 +13,10 @@ package com.zbkj.common.constants;
* +----------------------------------------------------------------------
*/
public class Constants {
public static final long TOKEN_EXPRESS_MINUTES = (60 * 24); //3小时
// Token有效期30天43200分钟
// 采用滑动过期策略每次访问自动刷新过期时间
// 只有用户主动退出登录时才会删除Token
public static final long TOKEN_EXPRESS_MINUTES = (60 * 24 * 30); // 30天
public static final int HTTPSTATUS_CODE_SUCCESS = 200;

View File

@ -1,6 +1,8 @@
package com.zbkj.common.model.friend;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
@ -55,11 +57,13 @@ public class FriendRequest implements Serializable {
private Integer status = 0;
@ApiModelProperty(value = "创建时间")
@TableField(fill = FieldFill.INSERT)
@Column(name = "create_time", nullable = false, updatable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date createTime;
@ApiModelProperty(value = "处理时间")
@TableField(fill = FieldFill.UPDATE)
@Column(name = "update_time")
@Temporal(TemporalType.TIMESTAMP)
private Date updateTime;

View File

@ -0,0 +1,70 @@
package com.zbkj.common.model.live;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
/**
* 直播类型实体类
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@Entity
@Table(name = "eb_live_type")
@TableName("eb_live_type")
@ApiModel(value = "LiveType对象", description = "直播类型")
public class LiveType implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主键ID")
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "类型名称")
@Column(name = "name")
private String name;
@ApiModelProperty(value = "类型编码")
@Column(name = "code")
private String code;
@ApiModelProperty(value = "类型图标")
@Column(name = "icon")
private String icon;
@ApiModelProperty(value = "类型描述")
@Column(name = "description")
private String description;
@ApiModelProperty(value = "排序(越大越靠前)")
@Column(name = "sort")
private Integer sort;
@ApiModelProperty(value = "状态 0=禁用 1=启用")
@Column(name = "status")
private Integer status;
@ApiModelProperty(value = "创建时间")
@TableField("create_time")
@Column(name = "create_time")
private Date createTime;
@ApiModelProperty(value = "更新时间")
@TableField("update_time")
@Column(name = "update_time")
private Date updateTime;
}

View File

@ -73,6 +73,9 @@ public class User implements Serializable {
@ApiModelProperty(value = "用户头像")
private String avatar;
@ApiModelProperty(value = "个人签名")
private String bio;
@ApiModelProperty(value = "手机号码")
private String phone;

View File

@ -0,0 +1,38 @@
package com.zbkj.common.request;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.util.List;
/**
* 用户批量操作Request
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="UserBatchOperationRequest对象", description="用户批量操作")
public class UserBatchOperationRequest implements Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "用户ID列表", required = true)
@NotEmpty(message = "用户ID列表不能为空")
private List<Integer> uids;
@ApiModelProperty(value = "操作类型1-启用2-禁用3-删除4-设置分组5-设置标签", required = true)
@NotNull(message = "操作类型不能为空")
private Integer operationType;
@ApiModelProperty(value = "分组ID操作类型为4时必填")
private String groupId;
@ApiModelProperty(value = "标签ID操作类型为5时必填")
private String tagId;
}

View File

@ -0,0 +1,79 @@
package com.zbkj.common.request;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import java.io.Serializable;
/**
* 用户创建Request
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="UserCreateRequest对象", description="创建用户")
public class UserCreateRequest implements Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "用户账号", required = true)
@NotBlank(message = "请填写用户账号")
@Length(max = 32, message = "用户账号不能超过32个字符")
private String account;
@ApiModelProperty(value = "用户密码", required = true)
@NotBlank(message = "请填写用户密码")
@Length(min = 6, max = 32, message = "密码长度为6-32个字符")
private String pwd;
@ApiModelProperty(value = "用户昵称", required = true)
@NotBlank(message = "请填写用户昵称")
@Length(max = 255, message = "用户昵称不能超过255个字符")
private String nickname;
@ApiModelProperty(value = "手机号码")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号码格式不正确")
private String phone;
@ApiModelProperty(value = "用户头像")
@Length(max = 256, message = "用户头像不能超过256个字符")
private String avatar;
@ApiModelProperty(value = "真实姓名")
@Length(max = 25, message = "真实姓名不能超过25个字符")
private String realName;
@ApiModelProperty(value = "生日")
private String birthday;
@ApiModelProperty(value = "性别0未知1男2女3保密")
private Integer sex;
@ApiModelProperty(value = "用户备注")
@Length(max = 255, message = "用户备注不能超过255个字符")
private String mark;
@ApiModelProperty(value = "用户分组id")
private String groupId;
@ApiModelProperty(value = "标签id")
private String tagId;
@ApiModelProperty(value = "推广员id")
private Integer spreadUid;
@ApiModelProperty(value = "是否为推广员0否1是")
private Integer isPromoter;
@ApiModelProperty(value = "用户等级")
private Integer level;
@ApiModelProperty(value = "状态1正常0禁用")
private Integer status;
}

View File

@ -0,0 +1,69 @@
package com.zbkj.common.request;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 用户查询Request
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="UserQueryRequest对象", description="用户查询条件")
public class UserQueryRequest implements Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "用户账号")
private String account;
@ApiModelProperty(value = "用户昵称")
private String nickname;
@ApiModelProperty(value = "手机号码")
private String phone;
@ApiModelProperty(value = "用户分组id")
private String groupId;
@ApiModelProperty(value = "标签id")
private String tagId;
@ApiModelProperty(value = "用户等级")
private Integer level;
@ApiModelProperty(value = "状态1正常0禁用")
private Integer status;
@ApiModelProperty(value = "是否为推广员0否1是")
private Integer isPromoter;
@ApiModelProperty(value = "推广员id")
private Integer spreadUid;
@ApiModelProperty(value = "性别0未知1男2女3保密")
private Integer sex;
@ApiModelProperty(value = "用户类型")
private String userType;
@ApiModelProperty(value = "关键字搜索(账号/昵称/手机号)")
private String keywords;
@ApiModelProperty(value = "开始时间")
private String startTime;
@ApiModelProperty(value = "结束时间")
private String endTime;
@ApiModelProperty(value = "排序字段")
private String orderBy;
@ApiModelProperty(value = "排序方式asc升序desc降序")
private String orderType;
}

View File

@ -34,6 +34,9 @@ public class SendGiftResponse implements Serializable {
@ApiModelProperty(value = "剩余钻石数")
private BigDecimal remainingDiamond;
@ApiModelProperty(value = "新余额(剩余金币数,兼容字段)")
private BigDecimal newBalance;
@ApiModelProperty(value = "增加的亲密度")
private Integer intimacy;

View File

@ -88,4 +88,19 @@ public class UserCenterResponse implements Serializable {
@ApiModelProperty(value = "用户收藏数量")
private Integer collectCount;
@ApiModelProperty(value = "个人签名")
private String bio;
@ApiModelProperty(value = "生日")
private String birthday;
@ApiModelProperty(value = "性别0未知1男2女3保密")
private Integer sex;
@ApiModelProperty(value = "详细地址/所在地")
private String addres;
@ApiModelProperty(value = "真实姓名")
private String realName;
}

View File

@ -0,0 +1,155 @@
package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 用户详情Response
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="UserDetailResponse对象", description="用户详情")
public class UserDetailResponse implements Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "用户id")
private Integer uid;
@ApiModelProperty(value = "用户账号")
private String account;
@ApiModelProperty(value = "用户昵称")
private String nickname;
@ApiModelProperty(value = "用户头像")
private String avatar;
@ApiModelProperty(value = "手机号码")
private String phone;
@ApiModelProperty(value = "真实姓名")
private String realName;
@ApiModelProperty(value = "生日")
private String birthday;
@ApiModelProperty(value = "身份证号码")
private String cardId;
@ApiModelProperty(value = "性别0未知1男2女3保密")
private Integer sex;
@ApiModelProperty(value = "用户备注")
private String mark;
@ApiModelProperty(value = "合伙人id")
private Integer partnerId;
@ApiModelProperty(value = "用户分组id")
private String groupId;
@ApiModelProperty(value = "标签id")
private String tagId;
@ApiModelProperty(value = "用户余额")
private BigDecimal nowMoney;
@ApiModelProperty(value = "佣金金额")
private BigDecimal brokeragePrice;
@ApiModelProperty(value = "用户剩余积分")
private Integer integral;
@ApiModelProperty(value = "用户剩余经验")
private Integer experience;
@ApiModelProperty(value = "连续签到天数")
private Integer signNum;
@ApiModelProperty(value = "状态1正常0禁用")
private Integer status;
@ApiModelProperty(value = "用户等级")
private Integer level;
@ApiModelProperty(value = "推广员id")
private Integer spreadUid;
@ApiModelProperty(value = "推广员昵称")
private String spreadNickname;
@ApiModelProperty(value = "推广员关联时间")
private Date spreadTime;
@ApiModelProperty(value = "用户类型")
private String userType;
@ApiModelProperty(value = "是否为推广员0否1是")
private Integer isPromoter;
@ApiModelProperty(value = "用户购买次数")
private Integer payCount;
@ApiModelProperty(value = "下级人数")
private Integer spreadCount;
@ApiModelProperty(value = "详细地址")
private String addres;
@ApiModelProperty(value = "登陆类型")
private String loginType;
@ApiModelProperty(value = "创建时间")
private Date createTime;
@ApiModelProperty(value = "更新时间")
private Date updateTime;
@ApiModelProperty(value = "最后一次登录时间")
private Date lastLoginTime;
@ApiModelProperty(value = "推广等级记录")
private String path;
@ApiModelProperty(value = "是否关注公众号0否1是")
private Integer subscribe;
@ApiModelProperty(value = "关注公众号时间")
private Date subscribeTime;
@ApiModelProperty(value = "国家")
private String country;
@ApiModelProperty(value = "成为分销员时间")
private Date promoterTime;
@ApiModelProperty(value = "是否注销0未注销1已注销")
private Integer isLogoff;
@ApiModelProperty(value = "注销时间")
private Date logoffTime;
@ApiModelProperty(value = "扩展字段1")
private String extField1;
@ApiModelProperty(value = "扩展字段2")
private String extField2;
@ApiModelProperty(value = "扩展字段3")
private String extField3;
@ApiModelProperty(value = "扩展字段4")
private Integer extField4;
@ApiModelProperty(value = "扩展字段5")
private BigDecimal extField5;
}

View File

@ -0,0 +1,58 @@
package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 用户统计Response
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@ApiModel(value="UserStatisticsResponse对象", description="用户统计数据")
public class UserStatisticsResponse implements Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "总用户数")
private Integer totalUsers;
@ApiModelProperty(value = "今日新增用户数")
private Integer todayNewUsers;
@ApiModelProperty(value = "本周新增用户数")
private Integer weekNewUsers;
@ApiModelProperty(value = "本月新增用户数")
private Integer monthNewUsers;
@ApiModelProperty(value = "正常用户数")
private Integer activeUsers;
@ApiModelProperty(value = "禁用用户数")
private Integer disabledUsers;
@ApiModelProperty(value = "推广员数量")
private Integer promoterCount;
@ApiModelProperty(value = "总余额")
private BigDecimal totalBalance;
@ApiModelProperty(value = "总佣金")
private BigDecimal totalBrokerage;
@ApiModelProperty(value = "总积分")
private Long totalIntegral;
@ApiModelProperty(value = "付费用户数")
private Integer paidUsers;
@ApiModelProperty(value = "付费率")
private String paidRate;
}

View File

@ -41,9 +41,9 @@ public class FrontTokenComponent {
private static final Long MILLIS_MINUTE = 60 * 1000L;
// 令牌有效期默认30分钟 todo 调试期改为5小时
// private static final int expireTime = 30;
private static final int expireTime = 5 * 60;
// 令牌有效期设置为30天每次访问自动刷新
// 只有用户主动退出登录时才会删除token
private static final long expireTime = 30 * 24 * 60; // 30天
/**
* 获取用户身份信息
@ -81,13 +81,16 @@ public class FrontTokenComponent {
/**
* 创建令牌
* Token有效期30天每次访问自动刷新
* 只有用户主动退出登录时才会删除
*
* @param user 用户信息
* @return 令牌
*/
public String createToken(User user) {
String token = UUID.randomUUID().toString().replace("-", "");
redisUtil.set(getTokenKey(token), user.getUid(), Constants.TOKEN_EXPRESS_MINUTES, TimeUnit.MINUTES);
// 设置30天过期时间每次访问会自动刷新
redisUtil.set(getTokenKey(token), user.getUid(), expireTime, TimeUnit.MINUTES);
return token;
}
@ -137,12 +140,16 @@ public class FrontTokenComponent {
}
/**
* 推出登录
* 退出登录
* 删除Redis中的Token使Token立即失效
*
* @param request HttpServletRequest
*/
public void logout(HttpServletRequest request) {
String token = getToken(request);
delLoginUser(token);
if (StrUtil.isNotEmpty(token)) {
delLoginUser(token);
}
}
/**
@ -208,13 +215,25 @@ public class FrontTokenComponent {
return false;
}
/**
* 检查Token是否有效
* 如果Token存在自动刷新过期时间滑动过期策略
* 这样只要用户持续使用Token就不会过期
* 只有用户主动退出登录时才会删除Token
*
* @param token Token值
* @param request 请求对象
* @return true-有效 false-无效
*/
public Boolean check(String token, HttpServletRequest request){
try {
boolean exists = redisUtil.exists(getTokenKey(token));
if(exists){
// Token存在获取用户ID
Integer uid = redisUtil.get(getTokenKey(token));
redisUtil.set(getTokenKey(token), uid, Constants.TOKEN_EXPRESS_MINUTES, TimeUnit.MINUTES);
// 自动刷新Token过期时间滑动过期
// 每次访问都重置为30天后过期
redisUtil.set(getTokenKey(token), uid, expireTime, TimeUnit.MINUTES);
}else{
//判断路由部分路由不管用户是否登录/token过期都可以访问
exists = checkRouter(RequestUtil.getUri(request));

View File

@ -274,13 +274,31 @@ public class LiveRoomController {
@Autowired
private com.zbkj.service.service.GiftRecordService giftRecordService;
@Autowired
private com.zbkj.front.service.GiftWebSocketService giftWebSocketService;
@Autowired
private com.zbkj.service.service.GiftService giftService;
@ApiOperation(value = "赠送礼物(需要登录)")
@PostMapping("/rooms/{roomId}/gift")
public CommonResult<com.zbkj.common.response.SendGiftResponse> sendGift(
@PathVariable Integer roomId,
@RequestBody @Validated com.zbkj.common.request.SendGiftRequest request) {
// 🔍 添加调试日志
log.info("========================================");
log.info("🎁 收到礼物赠送请求");
log.info("roomId: {}", roomId);
log.info("request.giftId: {}", request.getGiftId());
log.info("request.receiverId: {}", request.getReceiverId());
log.info("request.giftCount: {}", request.getGiftCount());
log.info("========================================");
// 获取当前登录用户ID
Integer currentUserId = frontTokenComponent.getUserId();
log.info("currentUserId: {}", currentUserId);
if (currentUserId == null) {
return CommonResult.failed("请先登录");
}
@ -295,19 +313,53 @@ public class LiveRoomController {
return CommonResult.failed("直播间不存在");
}
// TODO: 后续可能需要恢复此验证
// 不能给自己送礼物
if (currentUserId.equals(request.getReceiverId())) {
return CommonResult.failed("不能给自己赠送礼物");
}
// if (currentUserId.equals(request.getReceiverId())) {
// return CommonResult.failed("不能给自己赠送礼物");
// }
try {
// 调用服务层赠送礼物
com.zbkj.common.response.SendGiftResponse response =
giftRecordService.sendGift(roomId, currentUserId, request);
log.info("✅ 礼物赠送成功");
// 🎁 通过WebSocket推送礼物消息到直播间
try {
// 获取用户信息
com.zbkj.common.model.user.User sender = userService.getById(currentUserId);
com.zbkj.common.model.user.User receiver = userService.getById(request.getReceiverId());
com.zbkj.common.model.gift.Gift gift = giftService.getGiftById(request.getGiftId());
if (sender != null && receiver != null && gift != null) {
giftWebSocketService.broadcastGift(
String.valueOf(roomId),
gift.getId(),
gift.getName(),
request.getGiftCount(),
currentUserId,
sender.getNickname(),
request.getReceiverId(),
receiver.getNickname(),
response.getTotalDiamond().intValue()
);
log.info("✅ 礼物WebSocket推送成功: roomId={}, giftName={}, sender={}",
roomId, gift.getName(), sender.getNickname());
} else {
log.warn("⚠️ 无法推送礼物消息:用户或礼物信息不完整");
}
} catch (Exception e) {
log.error("❌ 礼物WebSocket推送失败", e);
// 不影响主流程继续返回成功
}
return CommonResult.success(response);
} catch (com.zbkj.common.exception.CrmebException e) {
log.error("❌ 礼物赠送失败: {}", e.getMessage());
return CommonResult.failed(e.getMessage());
} catch (Exception e) {
log.error("❌ 礼物赠送异常", e);
return CommonResult.failed("赠送礼物失败: " + e.getMessage());
}
}

View File

@ -68,10 +68,27 @@ public class UserController {
@ApiOperation(value = "修改个人资料")
@RequestMapping(value = "/user/edit", method = RequestMethod.POST)
public CommonResult<Object> personInfo(@RequestBody @Validated UserEditRequest request) {
if (userService.editUser(request)) {
return CommonResult.success();
log.info("========== 收到修改个人资料请求 ==========");
log.info("请求参数: {}", request);
log.info("昵称: {}", request.getNickname());
log.info("头像: {}", request.getAvatar());
log.info("个人签名: {}", request.getMark());
log.info("生日: {}", request.getBirthday());
log.info("性别: {}", request.getSex());
log.info("所在地: {}", request.getAddres());
try {
boolean result = userService.editUser(request);
log.info("修改结果: {}", result);
if (result) {
return CommonResult.success();
}
return CommonResult.failed();
} catch (Exception e) {
log.error("修改个人资料失败", e);
return CommonResult.failed("修改失败:" + e.getMessage());
}
return CommonResult.failed();
}
/**
@ -80,7 +97,15 @@ public class UserController {
@ApiOperation(value = "个人中心-用户信息")
@RequestMapping(value = "/user", method = RequestMethod.GET)
public CommonResult<UserCenterResponse> getUserCenter() {
return CommonResult.success(userService.getUserCenter());
log.info("========== 收到获取用户信息请求 ==========");
UserCenterResponse response = userService.getUserCenter();
log.info("========== 返回用户信息 ==========");
log.info("昵称: {}", response.getNickname());
log.info("生日: {}", response.getBirthday());
log.info("性别: {}", response.getSex());
log.info("所在地: {}", response.getAddres());
log.info("真实姓名: {}", response.getRealName());
return CommonResult.success(response);
}
/**

View File

@ -0,0 +1,35 @@
package com.zbkj.front.response.live;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 直播类型响应对象
*/
@Data
@ApiModel(value = "LiveTypeResponse", description = "直播类型响应")
public class LiveTypeResponse implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "类型ID")
private Integer id;
@ApiModelProperty(value = "类型名称")
private String name;
@ApiModelProperty(value = "类型编码")
private String code;
@ApiModelProperty(value = "类型图标")
private String icon;
@ApiModelProperty(value = "类型描述")
private String description;
@ApiModelProperty(value = "排序")
private Integer sort;
}

View File

@ -28,4 +28,12 @@ public interface FriendRequestDao extends BaseMapper<FriendRequest> {
* 检查是否已发送过请求
*/
Long checkExistingRequest(@Param("fromUserId") Integer fromUserId, @Param("toUserId") Integer toUserId);
/**
* 插入好友请求自定义插入避免字段冲突
*/
int insertFriendRequest(@Param("fromUserId") Integer fromUserId,
@Param("toUserId") Integer toUserId,
@Param("message") String message,
@Param("status") Integer status);
}

View File

@ -0,0 +1,11 @@
package com.zbkj.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zbkj.common.model.live.LiveType;
/**
* 直播类型 Mapper 接口
*/
public interface LiveTypeDao extends BaseMapper<LiveType> {
}

View File

@ -0,0 +1,25 @@
package com.zbkj.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zbkj.common.model.live.LiveType;
import java.util.List;
/**
* 直播类型服务接口
*/
public interface LiveTypeService extends IService<LiveType> {
/**
* 获取所有启用的直播类型列表
* @return 直播类型列表按sort字段降序排列
*/
List<LiveType> getEnabledList();
/**
* 根据编码获取直播类型
* @param code 类型编码
* @return 直播类型
*/
LiveType getByCode(String code);
}

View File

@ -68,7 +68,7 @@ public class GiftRecordServiceImpl extends ServiceImpl<GiftRecordDao, GiftRecord
// 5. 检查余额假设使用now_money字段作为钻石余额
if (sender.getNowMoney().compareTo(totalDiamond) < 0) {
throw new CrmebException("钻石余额不足");
throw new CrmebException("金币余额不足");
}
// 6. 扣除赠送者钻石
@ -106,7 +106,12 @@ public class GiftRecordServiceImpl extends ServiceImpl<GiftRecordDao, GiftRecord
response.setGiftName(gift.getName());
response.setGiftCount(request.getGiftCount());
response.setTotalDiamond(totalDiamond);
response.setRemainingDiamond(sender.getNowMoney().subtract(totalDiamond));
// 计算剩余余额
BigDecimal remainingBalance = sender.getNowMoney().subtract(totalDiamond);
response.setRemainingDiamond(remainingBalance);
response.setNewBalance(remainingBalance); // 兼容 Android 客户端
response.setIntimacy(record.getIntimacy());
response.setSendTime(record.getCreateTime().getTime());

View File

@ -43,12 +43,22 @@ public class LiveRoomServiceImpl extends ServiceImpl<LiveRoomDao, LiveRoom> impl
Integer categoryId, String description, String coverImage,
String tags, String notice) {
String streamKey = UUID.randomUUID().toString().replace("-", "");
// 验证和处理type参数
if (type == null || type.trim().isEmpty()) {
log.warn("createRoom - type参数为空使用默认值: game");
type = "game";
}
log.info("createRoom - 开始创建直播间: uid={}, title={}, type={}, categoryId={}",
uid, title, type, categoryId);
LiveRoom room = new LiveRoom();
room.setUid(uid);
room.setTitle(title);
room.setStreamerName(streamerName);
room.setStreamKey(streamKey);
room.setType(type != null ? type : "live");
room.setType(type);
room.setCategoryId(categoryId);
room.setDescription(description);
room.setCoverImage(coverImage);
@ -62,7 +72,12 @@ public class LiveRoomServiceImpl extends ServiceImpl<LiveRoomDao, LiveRoom> impl
room.setOnlineCount(0);
room.setCreateTime(new Date());
room.setStartedAt(null);
dao.insert(room);
log.info("createRoom - 直播间创建成功: roomId={}, type={}, streamKey={}",
room.getId(), room.getType(), streamKey);
return room;
}

View File

@ -0,0 +1,48 @@
package com.zbkj.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zbkj.common.model.live.LiveType;
import com.zbkj.service.dao.LiveTypeDao;
import com.zbkj.service.service.LiveTypeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 直播类型服务实现类
*/
@Slf4j
@Service
public class LiveTypeServiceImpl extends ServiceImpl<LiveTypeDao, LiveType> implements LiveTypeService {
/**
* 获取所有启用的直播类型列表
* @return 直播类型列表按sort字段降序排列
*/
@Override
public List<LiveType> getEnabledList() {
LambdaQueryWrapper<LiveType> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LiveType::getStatus, 1); // 只查询启用状态的
wrapper.orderByDesc(LiveType::getSort); // 按排序字段降序
wrapper.orderByAsc(LiveType::getId); // 排序相同时按ID升序
List<LiveType> list = list(wrapper);
log.info("获取启用的直播类型列表,共 {} 条", list.size());
return list;
}
/**
* 根据编码获取直播类型
* @param code 类型编码
* @return 直播类型
*/
@Override
public LiveType getByCode(String code) {
LambdaQueryWrapper<LiveType> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LiveType::getCode, code);
wrapper.eq(LiveType::getStatus, 1);
return getOne(wrapper);
}
}

View File

@ -86,12 +86,20 @@ public class SystemAttachmentServiceImpl extends ServiceImpl<SystemAttachmentDao
*/
@Override
public String prefixImage(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// 如果那些域名不需要加则跳过
return path.replace(UploadConstants.UPLOAD_FILE_KEYWORD+"/", getCdnUrl() + "/"+ UploadConstants.UPLOAD_FILE_KEYWORD+"/");
}
@Override
public String prefixUploadf(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// 如果那些域名不需要加则跳过
return path.replace("crmebimage/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD+"/", getCdnUrl() + "/" +"crmebimage/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD+"/");
}
@ -103,6 +111,10 @@ public class SystemAttachmentServiceImpl extends ServiceImpl<SystemAttachmentDao
*/
@Override
public String prefixFile(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (path.contains(Constants.WECHAT_SOURCE_CODE_FILE_NAME)) {
String cdnUrl = systemConfigService.getValueByKey("local" + "UploadUrl");
return path.replace("crmebimage/", cdnUrl + "/crmebimage/");

View File

@ -180,8 +180,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
// 模块
String modelPath = "public/" + model + "/";
// 类型
@ -356,8 +356,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
String modelPath = "public/" + model + "/";
String type = "video/";
@ -431,8 +431,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
String modelPath = "public/" + model + "/";
String type = "voice/";

View File

@ -343,7 +343,8 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
}
/**
* 更新用户金额
* 更新用户金额并发安全版本
* 使用数据库原子操作避免并发竞态条件
*
* @param user 用户
* @param price 金额
@ -352,17 +353,31 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
*/
@Override
public Boolean updateNowMoney(User user, BigDecimal price, String type) {
LambdaUpdateWrapper<User> lambdaUpdateWrapper = Wrappers.lambdaUpdate();
// 🔒 使用 UpdateWrapper 直接在 SQL 层面进行原子操作
// 这样可以避免并发时的竞态条件
// 原理UPDATE eb_user SET now_money = now_money +/- price WHERE uid = ?
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
if (type.equals("add")) {
lambdaUpdateWrapper.set(User::getNowMoney, user.getNowMoney().add(price));
// SQL: UPDATE eb_user SET now_money = now_money + price WHERE uid = ?
updateWrapper.setSql(StrUtil.format("now_money = now_money + {}", price));
} else {
lambdaUpdateWrapper.set(User::getNowMoney, user.getNowMoney().subtract(price));
// SQL: UPDATE eb_user SET now_money = now_money - price WHERE uid = ? AND now_money - price >= 0
updateWrapper.setSql(StrUtil.format("now_money = now_money - {}", price));
// 确保余额不会变成负数 SQL 层面检查
updateWrapper.last(StrUtil.format(" AND (now_money - {} >= 0)", price));
}
lambdaUpdateWrapper.eq(User::getUid, user.getUid());
if (type.equals("sub")) {
lambdaUpdateWrapper.apply(StrUtil.format(" now_money - {} >= 0", price));
updateWrapper.eq("uid", user.getUid());
boolean result = update(updateWrapper);
// 如果是扣减操作且更新失败说明余额不足
if (!result && type.equals("sub")) {
logger.warn("⚠️ 用户 {} 余额不足,无法扣除 {} 元", user.getUid(), price);
}
return update(lambdaUpdateWrapper);
return result;
}
/**
@ -576,6 +591,12 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
BeanUtils.copyProperties(currentUser, userCenterResponse);
// 设置用户ID
userCenterResponse.setUid(currentUser.getUid());
// 手动映射 mark -> bio个人签名
if (currentUser.getMark() != null) {
userCenterResponse.setBio(currentUser.getMark());
}
// 优惠券数量
userCenterResponse.setCouponCount(storeCouponUserService.getUseCount(currentUser.getUid()));
// 收藏数量

View File

@ -35,4 +35,10 @@
AND status = 0
</select>
<!-- 插入好友请求(明确指定字段,避免字段冲突) -->
<insert id="insertFriendRequest">
INSERT INTO eb_friend_request (from_user_id, to_user_id, target_uid, message, status, create_time)
VALUES (#{fromUserId}, #{toUserId}, #{toUserId}, #{message}, #{status}, NOW())
</insert>
</mapper>

File diff suppressed because one or more lines are too long

View File

@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS `eb_live_room` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`uid` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`streamer_name` varchar(255) NOT NULL,
`stream_key` varchar(64) NOT NULL,
`is_live` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime DEFAULT NULL,
`started_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_stream_key` (`stream_key`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,57 +0,0 @@
-- 好友关系表
CREATE TABLE IF NOT EXISTS `eb_friend` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL COMMENT '用户ID',
`friend_id` int(11) NOT NULL COMMENT '好友ID',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态: 1=正常, 0=已删除',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_friend_id` (`friend_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';
-- 好友请求表
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`from_user_id` int(11) NOT NULL COMMENT '发送者用户ID',
`to_user_id` int(11) NOT NULL COMMENT '接收者用户ID',
`message` varchar(255) DEFAULT '' COMMENT '请求消息',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=待处理, 1=已接受, 2=已拒绝',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_from_user` (`from_user_id`),
KEY `idx_to_user` (`to_user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友请求表';
-- 私聊会话表
CREATE TABLE IF NOT EXISTS `eb_conversation` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user1_id` int(11) NOT NULL COMMENT '用户1 ID (较小的ID)',
`user2_id` int(11) NOT NULL COMMENT '用户2 ID (较大的ID)',
`last_message_id` int(11) DEFAULT NULL COMMENT '最后一条消息ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_users` (`user1_id`, `user2_id`),
KEY `idx_user1` (`user1_id`),
KEY `idx_user2` (`user2_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';
-- 私聊消息表
CREATE TABLE IF NOT EXISTS `eb_conversation_message` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`conversation_id` int(11) NOT NULL COMMENT '会话ID',
`sender_id` int(11) NOT NULL COMMENT '发送者ID',
`content` text NOT NULL COMMENT '消息内容',
`msg_type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '消息类型: 1=文本, 2=图片, 3=语音',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=未读, 1=已读',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_conversation` (`conversation_id`),
KEY `idx_sender` (`sender_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊消息表';

View File

@ -1,6 +0,0 @@
-- 创建 zhibo 数据库
CREATE DATABASE IF NOT EXISTS `zhibo` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 使用 zhibo 数据库
USE `zhibo`;

File diff suppressed because one or more lines are too long

View File

@ -103,6 +103,9 @@ dependencies {
implementation("com.github.bumptech.glide:glide:4.16.0")
annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
// SVG支持
implementation("com.caverock:androidsvg-aar:1.4")
implementation("de.hdodenhof:circleimageview:3.1.0")
// FlexboxLayout for message reactions

View File

@ -0,0 +1,35 @@
package com.example.livestreaming;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
/**
* 余额页面的ViewPager适配器
* 包含充值记录和消费记录两个Tab
*/
public class BalancePagerAdapter extends FragmentStateAdapter {
public BalancePagerAdapter(@NonNull FragmentActivity fragmentActivity) {
super(fragmentActivity);
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case 0:
return new RechargeRecordFragment();
case 1:
return new ConsumeRecordFragment();
default:
return new RechargeRecordFragment();
}
}
@Override
public int getItemCount() {
return 2; // 充值记录 + 消费记录
}
}

View File

@ -0,0 +1,43 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* 消费记录Fragment
*/
public class ConsumeRecordFragment extends Fragment {
private RecyclerView recyclerView;
private TextView tvEmpty;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_record_list, container, false);
recyclerView = view.findViewById(R.id.recycler_view);
tvEmpty = view.findViewById(R.id.tv_empty);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
// TODO: 加载消费记录数据
showEmpty();
return view;
}
private void showEmpty() {
recyclerView.setVisibility(View.GONE);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无消费记录");
}
}

View File

@ -28,6 +28,16 @@ public class Gift {
this.level = level;
}
// 新增构造函数 - 支持URL
public Gift(String id, String name, int price, String iconUrl, int level) {
this.id = id;
this.name = name;
this.price = price;
this.iconUrl = iconUrl;
this.iconResId = R.drawable.ic_gift_24; // 默认占位图
this.level = level;
}
public String getId() {
return id;
}
@ -90,4 +100,11 @@ public class Gift {
public String getFormattedPrice() {
return price + " 金币";
}
/**
* 判断是否有图标URL
*/
public boolean hasIconUrl() {
return iconUrl != null && !iconUrl.isEmpty();
}
}

View File

@ -1,5 +1,6 @@
package com.example.livestreaming;
import android.graphics.drawable.PictureDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -9,11 +10,17 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.example.livestreaming.glide.SvgSoftwareLayerSetter;
import java.util.ArrayList;
import java.util.List;
/**
* 礼物列表适配器
* 支持PNGJPG和SVG格式图标
*/
public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder> {
@ -77,7 +84,23 @@ public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder
public void bind(Gift gift) {
giftName.setText(gift.getName());
giftPrice.setText(gift.getFormattedPrice());
giftIcon.setImageResource(gift.getIconResId());
if (gift.hasIconUrl()) {
String iconUrl = gift.getIconUrl();
android.util.Log.d("GiftAdapter", "加载图标: " + gift.getName() + ", URL: " + iconUrl);
// 检查是否是SVG格式
if (iconUrl.toLowerCase().endsWith(".svg") || iconUrl.toLowerCase().contains(".svg?")) {
// 加载SVG格式
loadSvgImage(iconUrl, gift.getName());
} else {
// 加载PNG/JPG等格式
loadRegularImage(iconUrl, gift.getName());
}
} else {
android.util.Log.w("GiftAdapter", "没有iconUrl: " + gift.getName());
giftIcon.setImageResource(gift.getIconResId());
}
// 选中状态
boolean isSelected = selectedGift != null && selectedGift.getId().equals(gift.getId());
@ -93,5 +116,69 @@ public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder
}
});
}
/**
* 加载SVG格式图片
*/
private void loadSvgImage(String iconUrl, String giftName) {
RequestBuilder<PictureDrawable> requestBuilder = Glide.with(itemView.getContext())
.as(PictureDrawable.class)
.listener(new com.bumptech.glide.request.RequestListener<PictureDrawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<PictureDrawable> target,
boolean isFirstResource) {
android.util.Log.e("GiftAdapter", "SVG加载失败: " + giftName + ", URL: " + iconUrl, e);
// 加载失败时显示默认图标
giftIcon.setImageResource(R.drawable.ic_gift_24);
return true;
}
@Override
public boolean onResourceReady(PictureDrawable resource,
Object model,
com.bumptech.glide.request.target.Target<PictureDrawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("GiftAdapter", "SVG加载成功: " + giftName);
return false;
}
});
requestBuilder.load(iconUrl).into(new SvgSoftwareLayerSetter(giftIcon));
}
/**
* 加载常规格式图片PNG/JPG等
*/
private void loadRegularImage(String iconUrl, String giftName) {
Glide.with(itemView.getContext())
.load(iconUrl)
.placeholder(R.drawable.ic_gift_24)
.error(R.drawable.ic_gift_24)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.Drawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
boolean isFirstResource) {
android.util.Log.e("GiftAdapter", "图片加载失败: " + giftName + ", URL: " + iconUrl, e);
return false;
}
@Override
public boolean onResourceReady(android.graphics.drawable.Drawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("GiftAdapter", "图片加载成功: " + giftName);
return false;
}
})
.into(giftIcon);
}
}
}

View File

@ -0,0 +1,625 @@
package com.example.livestreaming;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
/**
* 礼物动画管理器
* 负责显示不同等级的礼物特效
*/
public class GiftAnimationManager {
private final Context context;
private final View rootView;
private final Handler handler;
private final Queue<GiftAnimationData> animationQueue;
private boolean isAnimating = false;
private final Random random = new Random();
// 礼物等级定义
private static final int LEVEL_BASIC = 1; // 0-20金币
private static final int LEVEL_NORMAL = 2; // 21-100金币
private static final int LEVEL_ADVANCED = 3; // 101-1000金币
private static final int LEVEL_PREMIUM = 4; // 1001-3000金币
private static final int LEVEL_LEGENDARY = 5; // 3000+金币
public GiftAnimationManager(Context context, View rootView) {
this.context = context;
this.rootView = rootView;
this.handler = new Handler(Looper.getMainLooper());
this.animationQueue = new LinkedList<>();
}
/**
* 显示礼物动画
*/
public void showGiftAnimation(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl) {
showGiftAnimation(giftName, giftPrice, count, senderName, giftIconUrl, null);
}
/**
* 显示礼物动画带头像
*/
public void showGiftAnimation(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl, String senderAvatarUrl) {
GiftAnimationData data = new GiftAnimationData(
giftName, giftPrice, count, senderName, giftIconUrl, senderAvatarUrl
);
animationQueue.offer(data);
if (!isAnimating) {
processNextAnimation();
}
}
/**
* 处理下一个动画
*/
private void processNextAnimation() {
if (animationQueue.isEmpty()) {
isAnimating = false;
return;
}
isAnimating = true;
GiftAnimationData data = animationQueue.poll();
int level = getGiftLevel(data.giftPrice);
switch (level) {
case LEVEL_BASIC:
playBasicAnimation(data);
break;
case LEVEL_NORMAL:
playNormalAnimation(data);
break;
case LEVEL_ADVANCED:
playAdvancedAnimation(data);
break;
case LEVEL_PREMIUM:
playPremiumAnimation(data);
break;
case LEVEL_LEGENDARY:
playLegendaryAnimation(data);
break;
}
}
/**
* 获取礼物等级
*/
private int getGiftLevel(int price) {
if (price <= 20) return LEVEL_BASIC;
if (price <= 100) return LEVEL_NORMAL;
if (price <= 1000) return LEVEL_ADVANCED;
if (price <= 3000) return LEVEL_PREMIUM;
return LEVEL_LEGENDARY;
}
/**
* 基础动画 (0-20金币) - 简单滑入滑出
*/
private void playBasicAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-300f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -300f, 0f)
);
animSet.setDuration(400);
animSet.setInterpolator(new DecelerateInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
handler.postDelayed(() -> hideAnimation(effectLayout), 2000);
}
});
animSet.start();
}
/**
* 普通动画 (21-100金币) - 滑入+缩放+粒子
*/
private void playNormalAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-300f);
giftIcon.setScaleX(0.5f);
giftIcon.setScaleY(0.5f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -300f, 0f),
ObjectAnimator.ofFloat(giftIcon, "scaleX", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(giftIcon, "scaleY", 0.5f, 1.2f, 1f)
);
animSet.setDuration(500);
animSet.setInterpolator(new OvershootInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createSimpleParticles();
handler.postDelayed(() -> hideAnimation(effectLayout), 2500);
}
});
animSet.start();
}
/**
* 高级动画 (101-1000金币) - 华丽滑入+旋转+大量粒子
*/
private void playAdvancedAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-400f);
giftIcon.setScaleX(0.3f);
giftIcon.setScaleY(0.3f);
giftIcon.setRotation(-180f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -400f, 0f),
ObjectAnimator.ofFloat(giftIcon, "scaleX", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(giftIcon, "scaleY", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -180f, 0f)
);
animSet.setDuration(600);
animSet.setInterpolator(new OvershootInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createAdvancedParticles();
pulseAnimation(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 3000);
}
});
animSet.start();
}
/**
* 豪华动画 (1001-3000金币) - 震撼登场+闪光+爆炸粒子
*/
private void playPremiumAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
FrameLayout fullscreenEffect = rootView.findViewById(R.id.fullscreenEffectContainer);
setupGiftInfo(data);
// 显示全屏闪光效果
fullscreenEffect.setVisibility(View.VISIBLE);
fullscreenEffect.setBackgroundColor(Color.argb(100, 255, 215, 0));
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setScaleX(0.5f);
effectLayout.setScaleY(0.5f);
giftIcon.setRotation(-360f);
// 闪光动画
ObjectAnimator flashAnim = ObjectAnimator.ofFloat(fullscreenEffect, "alpha", 0.8f, 0f);
flashAnim.setDuration(500);
flashAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
fullscreenEffect.setVisibility(View.GONE);
}
});
flashAnim.start();
// 主动画
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleX", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleY", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -360f, 0f)
);
animSet.setDuration(700);
animSet.setInterpolator(new OvershootInterpolator(1.5f));
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createPremiumParticles();
continuousPulse(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 3500);
}
});
animSet.start();
}
/**
* 传说动画 (3000+金币) - 超级震撼全屏特效
*/
private void playLegendaryAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
FrameLayout fullscreenEffect = rootView.findViewById(R.id.fullscreenEffectContainer);
setupGiftInfo(data);
// 全屏彩虹渐变效果
fullscreenEffect.setVisibility(View.VISIBLE);
ValueAnimator colorAnim = ValueAnimator.ofArgb(
Color.argb(150, 255, 0, 0),
Color.argb(150, 255, 165, 0),
Color.argb(150, 255, 255, 0),
Color.argb(150, 0, 255, 0),
Color.argb(150, 0, 0, 255),
Color.argb(150, 139, 0, 255)
);
colorAnim.setDuration(1000);
colorAnim.addUpdateListener(animation -> {
fullscreenEffect.setBackgroundColor((int) animation.getAnimatedValue());
});
colorAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
ObjectAnimator fadeOut = ObjectAnimator.ofFloat(fullscreenEffect, "alpha", 1f, 0f);
fadeOut.setDuration(500);
fadeOut.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
fullscreenEffect.setVisibility(View.GONE);
}
});
fadeOut.start();
}
});
colorAnim.start();
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setScaleX(0.3f);
effectLayout.setScaleY(0.3f);
effectLayout.setRotation(-180f);
giftIcon.setRotation(-720f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleX", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleY", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(effectLayout, "rotation", -180f, 0f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -720f, 0f)
);
animSet.setDuration(1000);
animSet.setInterpolator(new OvershootInterpolator(2f));
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createLegendaryParticles();
explosivePulse(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 4000);
}
});
animSet.start();
}
/**
* 设置礼物信息
*/
private void setupGiftInfo(GiftAnimationData data) {
TextView senderName = rootView.findViewById(R.id.senderName);
TextView giftName = rootView.findViewById(R.id.giftName);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
ImageView senderAvatar = rootView.findViewById(R.id.senderAvatar);
TextView comboCount = rootView.findViewById(R.id.comboCount);
senderName.setText(data.senderName);
giftName.setText(data.giftName);
// 加载礼物图标优先使用网络图片
if (data.giftIconUrl != null && !data.giftIconUrl.isEmpty()) {
// 使用Glide加载网络图片
com.bumptech.glide.Glide.with(context)
.load(data.giftIconUrl)
.placeholder(getGiftIconResource(data.giftName))
.error(getGiftIconResource(data.giftName))
.into(giftIcon);
} else {
// 使用本地资源
giftIcon.setImageResource(getGiftIconResource(data.giftName));
}
// 加载用户头像如果有的话
if (data.senderAvatarUrl != null && !data.senderAvatarUrl.isEmpty()) {
com.bumptech.glide.Glide.with(context)
.load(data.senderAvatarUrl)
.placeholder(R.drawable.ic_user_24)
.error(R.drawable.ic_user_24)
.circleCrop()
.into(senderAvatar);
} else {
senderAvatar.setImageResource(R.drawable.ic_user_24);
}
if (data.count > 1) {
comboCount.setVisibility(View.VISIBLE);
comboCount.setText("x" + data.count);
} else {
comboCount.setVisibility(View.GONE);
}
}
/**
* 获取礼物图标资源
*/
private int getGiftIconResource(String giftName) {
// 根据礼物名称返回对应的图标资源
// 这里简化处理实际应该从网络加载
switch (giftName) {
case "小心心": return R.drawable.ic_gift_heart;
case "玫瑰花": return R.drawable.ic_gift_rose;
case "皇冠": return R.drawable.ic_gift_crown;
case "火箭": return R.drawable.ic_gift_rocket;
case "跑车": return R.drawable.ic_gift_car;
case "游艇": return R.drawable.ic_gift_yacht;
case "城堡": return R.drawable.ic_gift_castle;
default: return R.drawable.ic_gift_24;
}
}
/**
* 隐藏动画
*/
private void hideAnimation(View effectLayout) {
ObjectAnimator fadeOut = ObjectAnimator.ofFloat(effectLayout, "alpha", 1f, 0f);
fadeOut.setDuration(300);
fadeOut.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
effectLayout.setVisibility(View.GONE);
processNextAnimation();
}
});
fadeOut.start();
}
/**
* 创建简单粒子效果
*/
private void createSimpleParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
for (int i = 0; i < 10; i++) {
createParticle(particleContainer, Color.argb(200, 255, 215, 0), 20);
}
}
/**
* 创建高级粒子效果
*/
private void createAdvancedParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(200, 255, 215, 0),
Color.argb(200, 255, 140, 0),
Color.argb(200, 255, 69, 0)
};
for (int i = 0; i < 30; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 30);
}
}
/**
* 创建豪华粒子效果
*/
private void createPremiumParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(220, 255, 215, 0),
Color.argb(220, 255, 0, 0),
Color.argb(220, 255, 105, 180),
Color.argb(220, 138, 43, 226)
};
for (int i = 0; i < 50; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 40);
}
}
/**
* 创建传说粒子效果
*/
private void createLegendaryParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(255, 255, 0, 0),
Color.argb(255, 255, 165, 0),
Color.argb(255, 255, 255, 0),
Color.argb(255, 0, 255, 0),
Color.argb(255, 0, 0, 255),
Color.argb(255, 139, 0, 255)
};
for (int i = 0; i < 80; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 50);
}
}
/**
* 创建单个粒子
*/
private void createParticle(FrameLayout container, int color, int maxSize) {
View particle = new View(context);
int size = random.nextInt(maxSize - 10) + 10;
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size, size);
particle.setLayoutParams(params);
particle.setBackgroundColor(color);
// 随机起始位置
int startX = random.nextInt(container.getWidth());
int startY = container.getHeight() / 2;
particle.setX(startX);
particle.setY(startY);
container.addView(particle);
// 随机目标位置
float targetX = startX + (random.nextFloat() - 0.5f) * 400;
float targetY = startY - random.nextInt(300) - 100;
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(particle, "x", startX, targetX),
ObjectAnimator.ofFloat(particle, "y", startY, targetY),
ObjectAnimator.ofFloat(particle, "alpha", 1f, 0f),
ObjectAnimator.ofFloat(particle, "rotation", 0f, random.nextInt(720) - 360)
);
animSet.setDuration(random.nextInt(1000) + 1000);
animSet.setInterpolator(new DecelerateInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
container.removeView(particle);
}
});
animSet.start();
}
/**
* 脉冲动画
*/
private void pulseAnimation(View view) {
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f),
ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f)
);
animSet.setDuration(500);
animSet.start();
}
/**
* 持续脉冲动画
*/
private void continuousPulse(View view) {
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.15f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.15f, 1f);
scaleX.setDuration(400);
scaleX.setRepeatCount(3);
scaleY.setDuration(400);
scaleY.setRepeatCount(3);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(scaleX, scaleY);
animSet.start();
}
/**
* 爆炸式脉冲动画
*/
private void explosivePulse(View view) {
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.3f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.3f, 1f);
ObjectAnimator rotation = ObjectAnimator.ofFloat(view, "rotation", 0f, 360f);
scaleX.setDuration(600);
scaleX.setRepeatCount(2);
scaleY.setDuration(600);
scaleY.setRepeatCount(2);
rotation.setDuration(600);
rotation.setRepeatCount(2);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(scaleX, scaleY, rotation);
animSet.setInterpolator(new AccelerateDecelerateInterpolator());
animSet.start();
}
/**
* 礼物动画数据类
*/
private static class GiftAnimationData {
String giftName;
int giftPrice;
int count;
String senderName;
String giftIconUrl;
String senderAvatarUrl;
GiftAnimationData(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl) {
this.giftName = giftName;
this.giftPrice = giftPrice;
this.count = count;
this.senderName = senderName;
this.giftIconUrl = giftIconUrl;
this.senderAvatarUrl = null;
}
GiftAnimationData(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl, String senderAvatarUrl) {
this.giftName = giftName;
this.giftPrice = giftPrice;
this.count = count;
this.senderName = senderName;
this.giftIconUrl = giftIconUrl;
this.senderAvatarUrl = senderAvatarUrl;
}
}
}

View File

@ -1115,17 +1115,12 @@ public class MainActivity extends AppCompatActivity {
View dialogView = getLayoutInflater().inflate(R.layout.dialog_create_room, null);
DialogCreateRoomBinding dialogBinding = DialogCreateRoomBinding.bind(dialogView);
// 设置直播类型选择器
String[] liveTypes = {"游戏", "才艺", "户外", "音乐", "美食", "聊天"};
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, liveTypes);
// 使用正确的资源ID获取typeSpinner
int typeSpinnerId = getResources().getIdentifier("typeSpinner", "id", getPackageName());
MaterialAutoCompleteTextView typeSpinner = dialogView.findViewById(typeSpinnerId);
if (typeSpinner != null) {
typeSpinner.setAdapter(adapter);
}
// 从后端加载直播类型分类
loadLiveTypesForDialog(typeSpinner);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("创建直播间")
@ -1137,7 +1132,7 @@ public class MainActivity extends AppCompatActivity {
dialog.setOnShowListener(d -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String title = dialogBinding.titleEdit.getText() != null ? dialogBinding.titleEdit.getText().toString().trim() : "";
String type = (typeSpinner != null && typeSpinner.getText() != null) ? typeSpinner.getText().toString().trim() : "";
String typeDisplay = (typeSpinner != null && typeSpinner.getText() != null) ? typeSpinner.getText().toString().trim() : "";
if (TextUtils.isEmpty(title)) {
dialogBinding.titleLayout.setError("标题不能为空");
@ -1146,7 +1141,7 @@ public class MainActivity extends AppCompatActivity {
dialogBinding.titleLayout.setError(null);
}
if (TextUtils.isEmpty(type)) {
if (TextUtils.isEmpty(typeDisplay)) {
int typeLayoutId = getResources().getIdentifier("typeLayout", "id", getPackageName());
TextInputLayout typeLayout = dialogView.findViewById(typeLayoutId);
if (typeLayout != null) {
@ -1161,14 +1156,74 @@ public class MainActivity extends AppCompatActivity {
}
}
// 从后端返回的类型列表中查找对应的类型编码
String typeCode = "game"; // 默认值游戏
Object tag = typeSpinner.getTag();
Log.d(TAG, "创建直播间 - 用户选择的类型显示名称: " + typeDisplay);
Log.d(TAG, "创建直播间 - typeSpinner.getTag() 类型: " + (tag != null ? tag.getClass().getName() : "null"));
if (tag instanceof List) {
@SuppressWarnings("unchecked")
List<com.example.livestreaming.net.LiveTypeResponse> types =
(List<com.example.livestreaming.net.LiveTypeResponse>) tag;
Log.d(TAG, "创建直播间 - 从Tag中获取到 " + types.size() + " 个类型");
boolean found = false;
for (com.example.livestreaming.net.LiveTypeResponse type : types) {
Log.d(TAG, "创建直播间 - 比对类型: " + type.getName() + " vs " + typeDisplay);
if (type.getName().equals(typeDisplay)) {
typeCode = type.getCode();
found = true;
Log.d(TAG, "创建直播间 - 匹配成功!使用类型编码: " + typeCode);
break;
}
}
if (!found) {
Log.w(TAG, "创建直播间 - 未找到匹配的类型,使用默认值: " + typeCode);
}
} else {
Log.w(TAG, "创建直播间 - Tag不是List类型使用默认映射");
// 如果没有从后端获取到类型列表使用默认映射
switch (typeDisplay) {
case "游戏":
typeCode = "game";
break;
case "才艺":
typeCode = "talent";
break;
case "户外":
typeCode = "outdoor";
break;
case "音乐":
typeCode = "music";
break;
case "美食":
typeCode = "food";
break;
case "聊天":
typeCode = "chat";
break;
default:
Log.w(TAG, "创建直播间 - 未知的类型名称: " + typeDisplay + ",使用默认值: game");
typeCode = "game";
break;
}
Log.d(TAG, "创建直播间 - 默认映射结果: " + typeDisplay + " -> " + typeCode);
}
// 获取用户昵称
String streamerName = getSharedPreferences("profile_prefs", MODE_PRIVATE)
.getString("profile_name", "未知用户");
Log.d(TAG, "创建直播间 - 最终参数: title=" + title + ", streamerName=" + streamerName + ", typeCode=" + typeCode);
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
// 调用后端接口创建直播间
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamerName, "live"))
// 调用后端接口创建直播间使用从后端获取的类型编码
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamerName, typeCode))
.enqueue(new Callback<ApiResponse<Room>>() {
@Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
@ -1727,6 +1782,96 @@ public class MainActivity extends AppCompatActivity {
});
}
/**
* 为创建直播间对话框加载直播类型
* 从后端数据库获取直播类型列表游戏才艺户外音乐美食聊天等
*/
private void loadLiveTypesForDialog(MaterialAutoCompleteTextView typeSpinner) {
if (typeSpinner == null) {
Log.w(TAG, "loadLiveTypesForDialog() typeSpinner is null");
return;
}
Log.d(TAG, "loadLiveTypesForDialog() 开始从后端加载直播类型");
// 调用后端接口获取直播类型列表
ApiClient.getService(getApplicationContext()).getLiveTypes()
.enqueue(new Callback<ApiResponse<List<com.example.livestreaming.net.LiveTypeResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<com.example.livestreaming.net.LiveTypeResponse>>> call,
Response<ApiResponse<List<com.example.livestreaming.net.LiveTypeResponse>>> response) {
Log.d(TAG, "loadLiveTypesForDialog() onResponse: code=" + response.code());
ApiResponse<List<com.example.livestreaming.net.LiveTypeResponse>> body = response.body();
List<com.example.livestreaming.net.LiveTypeResponse> types =
response.isSuccessful() && body != null && body.isOk() && body.getData() != null
? body.getData()
: null;
runOnUiThread(() -> {
if (types != null && !types.isEmpty()) {
Log.d(TAG, "loadLiveTypesForDialog() 成功获取 " + types.size() + " 个直播类型");
// 打印所有类型信息便于调试
for (com.example.livestreaming.net.LiveTypeResponse type : types) {
Log.d(TAG, " 类型: " + type.getName() + " (code=" + type.getCode() + ", sort=" + type.getSort() + ")");
}
// 提取类型名称
String[] typeNames = new String[types.size()];
for (int i = 0; i < types.size(); i++) {
typeNames[i] = types.get(i).getName();
}
// 使用 simple_list_item_1 而不是 simple_dropdown_item_1line
ArrayAdapter<String> adapter = new ArrayAdapter<>(MainActivity.this,
android.R.layout.simple_list_item_1, typeNames);
typeSpinner.setAdapter(adapter);
// 设置默认选中第一项使用 false 参数避免触发过滤
if (typeNames.length > 0) {
typeSpinner.setText(typeNames[0], false);
Log.d(TAG, "loadLiveTypesForDialog() 默认选中: " + typeNames[0]);
}
// 保存类型列表供后续使用
typeSpinner.setTag(types);
Log.d(TAG, "loadLiveTypesForDialog() 类型列表已保存到Tag中");
} else {
Log.w(TAG, "loadLiveTypesForDialog() 未获取到类型数据,使用默认类型");
useDefaultLiveTypes(typeSpinner);
}
});
}
@Override
public void onFailure(Call<ApiResponse<List<com.example.livestreaming.net.LiveTypeResponse>>> call, Throwable t) {
Log.e(TAG, "loadLiveTypesForDialog() onFailure: " + t.getMessage(), t);
// 网络错误使用默认类型
runOnUiThread(() -> {
useDefaultLiveTypes(typeSpinner);
});
}
});
}
/**
* 使用默认的直播类型当后端接口失败时的备用方案
*/
private void useDefaultLiveTypes(MaterialAutoCompleteTextView typeSpinner) {
String[] defaultTypes = {"游戏", "才艺", "户外", "音乐", "美食", "聊天"};
ArrayAdapter<String> adapter = new ArrayAdapter<>(MainActivity.this,
android.R.layout.simple_list_item_1, defaultTypes);
typeSpinner.setAdapter(adapter);
// 设置默认选中第一项
if (defaultTypes.length > 0) {
typeSpinner.setText(defaultTypes[0], false);
}
Log.d(TAG, "useDefaultLiveTypes() 使用默认类型,共 " + defaultTypes.length + "");
}
private void loadCoverAssetsAsync() {
// 在后台线程加载资源文件避免阻塞UI
new Thread(() -> {

View File

@ -0,0 +1,126 @@
package com.example.livestreaming;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.cardview.widget.CardView;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 充值套餐适配器
*/
public class RechargePackageAdapter extends RecyclerView.Adapter<RechargePackageAdapter.ViewHolder> {
private List<Map<String, Object>> packages;
private int selectedPosition = -1;
private OnItemClickListener listener;
public interface OnItemClickListener {
void onItemClick(Integer packageId);
}
public RechargePackageAdapter(List<Map<String, Object>> packages) {
this.packages = packages != null ? packages : new ArrayList<>();
}
public void setOnItemClickListener(OnItemClickListener listener) {
this.listener = listener;
}
public void updateData(List<Map<String, Object>> newPackages) {
this.packages = newPackages != null ? newPackages : new ArrayList<>();
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_recharge_package, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Map<String, Object> pkg = packages.get(position);
// 获取套餐信息
Integer id = (Integer) pkg.get("id");
String name = (String) pkg.get("name");
Object amountObj = pkg.get("amount");
Object priceObj = pkg.get("price");
String label = (String) pkg.get("label");
double amount = amountObj instanceof Number ? ((Number) amountObj).doubleValue() : 0;
double price = priceObj instanceof Number ? ((Number) priceObj).doubleValue() : 0;
// 设置数据
holder.tvAmount.setText(String.format("%.0f", amount));
holder.tvPrice.setText(String.format("¥%.2f", price));
if (label != null && !label.isEmpty()) {
holder.tvLabel.setVisibility(View.VISIBLE);
holder.tvLabel.setText(label);
} else {
holder.tvLabel.setVisibility(View.GONE);
}
// 设置选中状态
boolean isSelected = position == selectedPosition;
holder.cardView.setCardBackgroundColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? R.color.colorPrimary : android.R.color.white
)
);
holder.tvAmount.setTextColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? android.R.color.white : R.color.colorPrimary
)
);
holder.tvPrice.setTextColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? android.R.color.white : R.color.text_secondary
)
);
// 点击事件
holder.itemView.setOnClickListener(v -> {
int oldPosition = selectedPosition;
selectedPosition = holder.getAdapterPosition();
if (oldPosition != -1) {
notifyItemChanged(oldPosition);
}
notifyItemChanged(selectedPosition);
if (listener != null && id != null) {
listener.onItemClick(id);
}
});
}
@Override
public int getItemCount() {
return packages.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
CardView cardView;
TextView tvAmount;
TextView tvPrice;
TextView tvLabel;
ViewHolder(@NonNull View itemView) {
super(itemView);
cardView = (CardView) itemView;
tvAmount = itemView.findViewById(R.id.tv_amount);
tvPrice = itemView.findViewById(R.id.tv_price);
tvLabel = itemView.findViewById(R.id.tv_label);
}
}
}

View File

@ -0,0 +1,43 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* 充值记录Fragment
*/
public class RechargeRecordFragment extends Fragment {
private RecyclerView recyclerView;
private TextView tvEmpty;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_record_list, container, false);
recyclerView = view.findViewById(R.id.recycler_view);
tvEmpty = view.findViewById(R.id.tv_empty);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
// TODO: 加载充值记录数据
showEmpty();
return view;
}
private void showEmpty() {
recyclerView.setVisibility(View.GONE);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无充值记录");
}
}

View File

@ -18,10 +18,12 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatActivity;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.GridLayoutManager;
@ -151,6 +153,7 @@ public class RoomDetailActivity extends AppCompatActivity {
private GiftAdapter giftAdapter;
private List<Gift> availableGifts;
private int userCoinBalance = 0; // 用户金币余额从后端加载
private GiftAnimationManager giftAnimationManager; // 礼物动画管理器
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@ -180,6 +183,20 @@ public class RoomDetailActivity extends AppCompatActivity {
setupChat();
setupGifts();
// 初始化礼物动画管理器
android.util.Log.d("RoomDetail", "=== 初始化礼物动画管理器 ===");
// 从binding的根视图中查找
View giftAnimationOverlay = binding.getRoot().findViewById(R.id.giftAnimationOverlay);
android.util.Log.d("RoomDetail", "giftAnimationOverlay = " + giftAnimationOverlay);
if (giftAnimationOverlay != null) {
giftAnimationManager = new GiftAnimationManager(this, giftAnimationOverlay);
android.util.Log.d("RoomDetail", "✅ 礼物动画管理器初始化成功");
} else {
android.util.Log.e("RoomDetail", "❌ 找不到giftAnimationOverlay布局");
}
// 加载房间信息
loadRoomInfo();
@ -452,11 +469,14 @@ public class RoomDetailActivity extends AppCompatActivity {
// 停止之前的重连任务
stopChatReconnect();
String wsUrl = getWsChatBaseUrl() + roomId;
android.util.Log.d("ChatWebSocket", "准备连接WebSocket: " + wsUrl);
chatWsClient = new OkHttpClient.Builder()
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) // OkHttp 内置 ping
.build();
Request request = new Request.Builder()
.url(getWsChatBaseUrl() + roomId)
.url(wsUrl)
.build();
chatWebSocket = chatWsClient.newWebSocket(request, new WebSocketListener() {
@ -472,9 +492,11 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onMessage(WebSocket webSocket, String text) {
// 收到消息解析并显示
android.util.Log.d("ChatWebSocket", "收到消息: " + text);
try {
JSONObject json = new JSONObject(text);
String type = json.optString("type", "");
android.util.Log.d("ChatWebSocket", "消息类型: " + type);
if ("chat".equals(type)) {
String nickname = json.optString("nickname", "匿名");
@ -490,11 +512,14 @@ public class RoomDetailActivity extends AppCompatActivity {
String senderNickname = json.optString("senderNickname", "匿名");
int totalPrice = json.optInt("totalPrice", 0);
android.util.Log.d("WebSocket", "收到礼物消息: " + json.toString());
handler.post(() -> {
String giftMsg = senderNickname + " 送出了 " + count + "" + giftName;
addChatMessage(new ChatMessage(giftMsg, true)); // 系统消息样式
// TODO: 显示礼物动画效果
// 显示礼物动画效果
android.util.Log.d("WebSocket", "准备显示礼物动画: " + giftName);
showGiftAnimation(giftName, count, senderNickname);
});
} else if ("gift_combo".equals(type)) {
@ -1189,7 +1214,7 @@ public class RoomDetailActivity extends AppCompatActivity {
// 防止重复显示连接消息
private boolean hasShownConnectedMessage = false;
private void startHls(String url, @Nullable String altUrl) {
@OptIn(markerClass = UnstableApi.class) private void startHls(String url, @Nullable String altUrl) {
releaseIjkPlayer();
if (binding != null) {
binding.flvTextureView.setVisibility(View.GONE);
@ -1566,6 +1591,8 @@ public class RoomDetailActivity extends AppCompatActivity {
* 从后端加载礼物列表
*/
private void loadGiftsFromBackend() {
android.util.Log.d("RoomDetail", "=== 开始加载礼物列表 ===");
ApiService apiService = ApiClient.getService(getApplicationContext());
Call<ApiResponse<List<GiftResponse>>> call = apiService.getGiftList();
@ -1575,32 +1602,41 @@ public class RoomDetailActivity extends AppCompatActivity {
Response<ApiResponse<List<GiftResponse>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<List<GiftResponse>> apiResponse = response.body();
android.util.Log.d("RoomDetail", "礼物列表响应码: " + apiResponse.getCode());
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
availableGifts = new ArrayList<>();
for (GiftResponse giftResponse : apiResponse.getData()) {
// 使用URL构造函数从服务器加载图标
String iconUrl = giftResponse.getIconUrl();
android.util.Log.d("RoomDetail", "礼物: " + giftResponse.getName() +
", 价格: " + giftResponse.getPrice() +
", iconUrl: " + iconUrl);
Gift gift = new Gift(
String.valueOf(giftResponse.getId()),
giftResponse.getName(),
giftResponse.getPrice().intValue(),
R.drawable.ic_gift_rose, // 默认图标实际应从URL加载
iconUrl, // 使用URL而不是硬编码
giftResponse.getLevel() != null ? giftResponse.getLevel() : 1
);
availableGifts.add(gift);
}
android.util.Log.d("RoomDetail", "成功加载 " + availableGifts.size() + " 个礼物");
android.util.Log.d("RoomDetail", "✅ 成功加载 " + availableGifts.size() + " 个礼物");
android.util.Log.d("RoomDetail", "礼物列表: " + getGiftNames());
} else {
android.util.Log.w("RoomDetail", "加载礼物列表失败: " + apiResponse.getMessage());
android.util.Log.w("RoomDetail", "加载礼物列表失败: " + apiResponse.getMessage());
setDefaultGifts();
}
} else {
android.util.Log.w("RoomDetail", "加载礼物列表失败");
android.util.Log.w("RoomDetail", "加载礼物列表失败,响应码: " + response.code());
setDefaultGifts();
}
}
@Override
public void onFailure(Call<ApiResponse<List<GiftResponse>>> call, Throwable t) {
android.util.Log.e("RoomDetail", "加载礼物列表失败: " + t.getMessage());
android.util.Log.e("RoomDetail", "❌ 加载礼物列表网络错误: " + t.getMessage(), t);
setDefaultGifts();
}
});
@ -1614,6 +1650,80 @@ public class RoomDetailActivity extends AppCompatActivity {
// 不再使用模拟数据只从后端接口获取真实礼物数据
}
/**
* 加载礼物图标到ImageView支持PNG和SVG格式
* @param imageView 目标ImageView
* @param gift 礼物对象
*/
private void loadGiftIcon(android.widget.ImageView imageView, Gift gift) {
if (gift.hasIconUrl()) {
String iconUrl = gift.getIconUrl();
android.util.Log.d("RoomDetail", "加载礼物图标: " + gift.getName() + ", URL: " + iconUrl);
// 检查是否是SVG格式
if (iconUrl.toLowerCase().endsWith(".svg") || iconUrl.toLowerCase().contains(".svg?")) {
// 加载SVG格式
com.bumptech.glide.RequestBuilder<android.graphics.drawable.PictureDrawable> requestBuilder =
com.bumptech.glide.Glide.with(this)
.as(android.graphics.drawable.PictureDrawable.class)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.PictureDrawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.PictureDrawable> target,
boolean isFirstResource) {
android.util.Log.e("RoomDetail", "SVG加载失败: " + gift.getName(), e);
imageView.setImageResource(R.drawable.ic_gift_24);
return true;
}
@Override
public boolean onResourceReady(android.graphics.drawable.PictureDrawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.PictureDrawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("RoomDetail", "SVG加载成功: " + gift.getName());
return false;
}
});
requestBuilder.load(iconUrl).into(new com.example.livestreaming.glide.SvgSoftwareLayerSetter(imageView));
} else {
// 加载PNG/JPG等格式
com.bumptech.glide.Glide.with(this)
.load(iconUrl)
.placeholder(R.drawable.ic_gift_24)
.error(R.drawable.ic_gift_24)
.diskCacheStrategy(com.bumptech.glide.load.engine.DiskCacheStrategy.ALL)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.Drawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
boolean isFirstResource) {
android.util.Log.e("RoomDetail", "图片加载失败: " + gift.getName(), e);
return false;
}
@Override
public boolean onResourceReady(android.graphics.drawable.Drawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("RoomDetail", "图片加载成功: " + gift.getName());
return false;
}
})
.into(imageView);
}
} else {
// 如果没有URL使用本地资源
imageView.setImageResource(gift.getIconResId());
}
}
/**
* 显示礼物选择弹窗
*/
@ -1657,7 +1767,10 @@ public class RoomDetailActivity extends AppCompatActivity {
// 礼物选择监听
giftAdapter.setOnGiftClickListener(gift -> {
selectedGiftLayout.setVisibility(View.VISIBLE);
selectedGiftIcon.setImageResource(gift.getIconResId());
// 使用辅助方法加载礼物图标
loadGiftIcon(selectedGiftIcon, gift);
selectedGiftName.setText(gift.getName());
selectedGiftPrice.setText(gift.getFormattedPrice());
giftCount[0] = 1;
@ -2003,14 +2116,34 @@ public class RoomDetailActivity extends AppCompatActivity {
return;
}
// 获取主播用户ID
Integer streamerId = room.getStreamerId();
if (streamerId == null) {
streamerId = room.getUid();
}
if (streamerId == null) {
Toast.makeText(this, "无法获取主播信息", Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "主播ID为空: roomId=" + roomId + ", room=" + room);
return;
}
// 🔍 详细日志
android.util.Log.d("RoomDetail", "========================================");
android.util.Log.d("RoomDetail", "📤 准备发送礼物请求");
android.util.Log.d("RoomDetail", "roomId: " + roomId);
android.util.Log.d("RoomDetail", "streamerId: " + streamerId);
android.util.Log.d("RoomDetail", "giftId: " + selectedGift.getId());
android.util.Log.d("RoomDetail", "count: " + count);
android.util.Log.d("RoomDetail", "API URL: " + ApiClient.getCurrentBaseUrl(getApplicationContext()) + "api/front/live/rooms/" + roomId + "/gift");
android.util.Log.d("RoomDetail", "========================================");
ApiService apiService = ApiClient.getService(getApplicationContext());
// 获取主播ID使用房间ID作为主播ID或者从房间信息中获取
Integer streamerId = Integer.parseInt(roomId);
SendGiftRequest request = new SendGiftRequest(
Integer.parseInt(selectedGift.getId()),
streamerId,
count
streamerId, // receiverId - 接收者用户ID主播ID
count // giftCount - 礼物数量
);
Call<ApiResponse<SendGiftResponse>> call = apiService.sendRoomGift(roomId, request);
@ -2019,20 +2152,46 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onResponse(Call<ApiResponse<SendGiftResponse>> call,
Response<ApiResponse<SendGiftResponse>> response) {
android.util.Log.d("RoomDetail", "📥 收到响应: HTTP " + response.code());
if (response.isSuccessful() && response.body() != null) {
ApiResponse<SendGiftResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
SendGiftResponse giftResponse = apiResponse.getData();
// 更新余额
userCoinBalance = giftResponse.getNewBalance().intValue();
coinBalance.setText(String.valueOf(userCoinBalance));
// 🔒 安全更新余额添加空值检查
if (giftResponse.getNewBalance() != null) {
userCoinBalance = giftResponse.getNewBalance().intValue();
coinBalance.setText(String.valueOf(userCoinBalance));
android.util.Log.d("RoomDetail", "✅ 余额更新成功: " + userCoinBalance);
} else {
android.util.Log.w("RoomDetail", "⚠️ newBalance 为 null尝试重新加载余额");
// 降级处理重新加载余额
loadUserBalance(coinBalance);
}
// 在聊天区显示赠送消息
String giftMessage = String.format("送出了 %d 个 %s", count, selectedGift.getName());
addChatMessage(new ChatMessage("", giftMessage, true));
Toast.makeText(RoomDetailActivity.this, "赠送成功!", Toast.LENGTH_SHORT).show();
// 立即显示礼物特效动画
String currentUserNickname = ""; // 可以从用户信息中获取
String currentUserAvatar = AuthStore.getAvatar(RoomDetailActivity.this); // 获取当前用户头像
android.util.Log.d("RoomDetail", "🎁 礼物发送成功,显示特效动画");
android.util.Log.d("RoomDetail", "礼物图标URL: " + selectedGift.getIconUrl());
android.util.Log.d("RoomDetail", "用户头像URL: " + currentUserAvatar);
// 使用带头像的方法
if (giftAnimationManager != null) {
giftAnimationManager.showGiftAnimation(
selectedGift.getName(),
selectedGift.getPrice(),
count,
currentUserNickname,
selectedGift.getIconUrl(),
currentUserAvatar
);
}
if (giftDialog != null) {
giftDialog.dismiss();
@ -2045,14 +2204,17 @@ public class RoomDetailActivity extends AppCompatActivity {
selectedGiftLayout.setVisibility(View.GONE);
giftCountText.setText("1");
} else {
String errorMsg = apiResponse.getMessage() != null ? apiResponse.getMessage() : "未知错误";
Toast.makeText(RoomDetailActivity.this,
"赠送失败: " + apiResponse.getMessage(),
"赠送失败: " + errorMsg,
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物失败: " + errorMsg);
}
} else {
Toast.makeText(RoomDetailActivity.this,
"赠送失败",
"赠送失败: HTTP " + response.code(),
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物HTTP错误: " + response.code());
}
}
@ -2061,6 +2223,7 @@ public class RoomDetailActivity extends AppCompatActivity {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物网络错误", t);
}
});
}
@ -2336,21 +2499,93 @@ public class RoomDetailActivity extends AppCompatActivity {
* @param senderNickname 赠送者昵称
*/
private void showGiftAnimation(String giftName, int count, String senderNickname) {
// TODO: 实现礼物动画效果
// 这里可以使用Lottie动画库或自定义动画
// 示例显示一个Toast提示
runOnUiThread(() -> {
String message = senderNickname + " 送出了 " + count + "" + giftName;
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
// 详细日志诊断问题
android.util.Log.d("GiftAnimation", "=== 开始显示礼物动画 ===");
android.util.Log.d("GiftAnimation", "礼物名称: " + giftName);
android.util.Log.d("GiftAnimation", "数量: " + count);
android.util.Log.d("GiftAnimation", "赠送者: " + senderNickname);
android.util.Log.d("GiftAnimation", "动画管理器状态: " + (giftAnimationManager != null ? "已初始化" : "未初始化"));
android.util.Log.d("GiftAnimation", "礼物列表状态: " + (availableGifts != null ? "已加载(" + availableGifts.size() + "个)" : "未加载"));
// 可以在这里添加更复杂的动画效果例如
// 1. 使用Lottie播放礼物动画
// 2. 显示礼物图标飞行动画
// 3. 播放音效
// 4. 显示特效粒子
// 检查动画管理器是否初始化
if (giftAnimationManager == null) {
android.util.Log.e("GiftAnimation", "❌ 礼物动画管理器未初始化!");
String message = senderNickname + " 送出了 " + count + "" + giftName;
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
return;
}
android.util.Log.d("GiftAnimation", "显示礼物动画: " + message);
// 检查礼物列表是否加载
if (availableGifts == null || availableGifts.isEmpty()) {
android.util.Log.w("GiftAnimation", "⚠️ 礼物列表未加载或为空,使用默认价格显示特效");
// 使用默认价格显示特效而不是降级到Toast
giftAnimationManager.showGiftAnimation(
giftName,
100, // 默认价格普通特效
count,
senderNickname,
null
);
return;
}
// 查找礼物信息
Gift gift = null;
for (Gift g : availableGifts) {
if (g.getName().equals(giftName)) {
gift = g;
break;
}
}
if (gift != null) {
// 使用礼物动画管理器显示特效
android.util.Log.d("GiftAnimation", "✅ 找到礼物信息: " + gift.getName() + ", 价格: " + gift.getPrice());
giftAnimationManager.showGiftAnimation(
giftName,
gift.getPrice(),
count,
senderNickname,
gift.getIconUrl()
);
android.util.Log.d("GiftAnimation",
String.format("✅ 显示礼物动画: %s x%d (价格:%d) - %s",
giftName, count, gift.getPrice(), senderNickname));
} else {
// 如果找不到礼物信息使用默认价格显示特效
android.util.Log.w("GiftAnimation", "⚠️ 未找到礼物信息: " + giftName);
android.util.Log.w("GiftAnimation", "可用礼物列表: " + getGiftNames());
// 使用默认价格显示特效而不是降级到Toast
giftAnimationManager.showGiftAnimation(
giftName,
100, // 默认价格普通特效
count,
senderNickname,
null
);
android.util.Log.d("GiftAnimation", "✅ 使用默认价格显示特效");
}
});
}
/**
* 获取礼物名称列表用于调试
*/
private String getGiftNames() {
if (availableGifts == null || availableGifts.isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < availableGifts.size(); i++) {
if (i > 0) sb.append(", ");
sb.append(availableGifts.get(i).getName());
}
sb.append("]");
return sb.toString();
}
}

View File

@ -0,0 +1,31 @@
package com.example.livestreaming.glide;
import android.content.Context;
import android.graphics.drawable.PictureDrawable;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
import com.caverock.androidsvg.SVG;
import java.io.InputStream;
/**
* Glide SVG模块 - 注册SVG支持
*/
@GlideModule
public class GlideSvgModule extends AppGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide,
@NonNull Registry registry) {
registry.register(SVG.class, PictureDrawable.class, new SvgDrawableTranscoder())
.append(InputStream.class, SVG.class, new SvgDecoder());
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View File

@ -0,0 +1,40 @@
package com.example.livestreaming.glide;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.ResourceDecoder;
import com.bumptech.glide.load.engine.Resource;
import com.bumptech.glide.load.resource.SimpleResource;
import com.caverock.androidsvg.SVG;
import com.caverock.androidsvg.SVGParseException;
import java.io.IOException;
import java.io.InputStream;
/**
* SVG解码器 - 将InputStream解码为SVG对象
*/
public class SvgDecoder implements ResourceDecoder<InputStream, SVG> {
@Override
public boolean handles(@NonNull InputStream source, @NonNull Options options) {
return true;
}
@Override
public Resource<SVG> decode(@NonNull InputStream source, int width, int height,
@NonNull Options options) throws IOException {
try {
SVG svg = SVG.getFromInputStream(source);
if (width != Integer.MIN_VALUE) {
svg.setDocumentWidth(width);
}
if (height != Integer.MIN_VALUE) {
svg.setDocumentHeight(height);
}
return new SimpleResource<>(svg);
} catch (SVGParseException ex) {
throw new IOException("Cannot load SVG from stream", ex);
}
}
}

View File

@ -0,0 +1,27 @@
package com.example.livestreaming.glide;
import android.graphics.Picture;
import android.graphics.drawable.PictureDrawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.engine.Resource;
import com.bumptech.glide.load.resource.SimpleResource;
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder;
import com.caverock.androidsvg.SVG;
/**
* SVG转换器 - 将SVG对象转换为PictureDrawable
*/
public class SvgDrawableTranscoder implements ResourceTranscoder<SVG, PictureDrawable> {
@Nullable
@Override
public Resource<PictureDrawable> transcode(@NonNull Resource<SVG> toTranscode,
@NonNull Options options) {
SVG svg = toTranscode.get();
Picture picture = svg.renderToPicture();
PictureDrawable drawable = new PictureDrawable(picture);
return new SimpleResource<>(drawable);
}
}

View File

@ -0,0 +1,21 @@
package com.example.livestreaming.glide;
import android.graphics.drawable.PictureDrawable;
import android.widget.ImageView;
import com.bumptech.glide.request.target.ImageViewTarget;
/**
* SVG图层设置器 - 确保SVG正确渲染
*/
public class SvgSoftwareLayerSetter extends ImageViewTarget<PictureDrawable> {
public SvgSoftwareLayerSetter(ImageView view) {
super(view);
}
@Override
protected void setResource(PictureDrawable resource) {
view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null);
view.setImageDrawable(resource);
}
}

View File

@ -327,6 +327,14 @@ public interface ApiService {
@GET("api/front/category/statistics")
Call<ApiResponse<Map<String, Object>>> getCategoryStatistics(@Query("type") int type);
// ==================== 直播类型接口 ====================
/**
* 获取直播类型列表游戏才艺户外音乐美食聊天等
*/
@GET("api/front/live/public/types")
Call<ApiResponse<List<LiveTypeResponse>>> getLiveTypes();
// ==================== 关注接口 ====================
@GET("api/front/follow/followers")
@ -381,6 +389,31 @@ public interface ApiService {
@DELETE("api/front/works/{id}/collect")
Call<ApiResponse<Boolean>> uncollectWork(@Path("id") long id);
/**
* 获取指定用户的作品列表
*/
@GET("api/front/works/user/{userId}")
Call<ApiResponse<PageResponse<WorksResponse>>> getUserWorks(
@Path("userId") int userId,
@Query("page") int page,
@Query("pageSize") int pageSize);
/**
* 获取我点赞的作品列表
*/
@GET("api/front/works/my/liked")
Call<ApiResponse<PageResponse<WorksResponse>>> getMyLikedWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
/**
* 获取我收藏的作品列表
*/
@GET("api/front/works/my/collected")
Call<ApiResponse<PageResponse<WorksResponse>>> getMyCollectedWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
// ==================== 搜索接口 ====================
@GET("api/front/live/public/rooms/search")
@ -653,6 +686,48 @@ public interface ApiService {
@Query("page") int page,
@Query("limit") int limit);
// ==================== 虚拟货币和充值接口 ====================
/**
* 获取虚拟货币余额
*/
@GET("api/front/virtual/balance")
Call<ApiResponse<Map<String, Object>>> getVirtualBalance();
/**
* 获取充值套餐列表
*/
@GET("api/front/virtual/recharge/packages")
Call<ApiResponse<List<Map<String, Object>>>> getRechargePackages();
/**
* 创建充值订单
*/
@POST("api/front/virtual/recharge/order")
Call<ApiResponse<Map<String, Object>>> createRechargeOrder(@Body Map<String, Object> body);
/**
* 模拟支付成功测试用
*/
@POST("api/front/virtual/recharge/mock-pay")
Call<ApiResponse<String>> mockPaySuccess(@Body Map<String, Object> body);
/**
* 获取充值记录
*/
@GET("api/front/virtual/recharge/records")
Call<ApiResponse<List<Map<String, Object>>>> getRechargeRecords(
@Query("page") int page,
@Query("limit") int limit);
/**
* 获取消费记录
*/
@GET("api/front/virtual/consume/records")
Call<ApiResponse<List<Map<String, Object>>>> getConsumeRecords(
@Query("page") int page,
@Query("limit") int limit);
// ==================== 用户活动记录接口 ====================
/**
@ -756,4 +831,50 @@ public interface ApiService {
*/
@DELETE("api/front/search/history/{historyId}")
Call<ApiResponse<String>> deleteSearchHistoryItem(@Path("historyId") long historyId);
// ==================== 封禁/黑名单接口 ====================
/**
* 检查当前用户封禁状态
*/
@GET("api/front/ban/check/me")
Call<ApiResponse<Map<String, Object>>> checkMyBanStatus();
/**
* 检查指定用户封禁状态
*/
@GET("api/front/ban/check/user/{userId}")
Call<ApiResponse<Map<String, Object>>> checkUserBanStatus(@Path("userId") int userId);
/**
* 检查房间封禁状态
*/
@GET("api/front/ban/check/room/{roomId}")
Call<ApiResponse<Map<String, Object>>> checkRoomBanStatus(@Path("roomId") int roomId);
/**
* 获取我的黑名单列表
*/
@GET("api/front/ban/blacklist/list")
Call<ApiResponse<PageResponse<Map<String, Object>>>> getMyBlacklist(
@Query("page") int page,
@Query("pageSize") int pageSize);
/**
* 添加用户到黑名单
*/
@POST("api/front/ban/blacklist/add")
Call<ApiResponse<Map<String, Object>>> addToBlacklist(@Body Map<String, Object> body);
/**
* 从黑名单移除用户
*/
@POST("api/front/ban/blacklist/remove")
Call<ApiResponse<Map<String, Object>>> removeFromBlacklist(@Body Map<String, Object> body);
/**
* 检查用户是否在黑名单中
*/
@GET("api/front/ban/blacklist/check/{targetUserId}")
Call<ApiResponse<Map<String, Object>>> checkBlacklistStatus(@Path("targetUserId") int targetUserId);
}

View File

@ -1,2 +0,0 @@
package com.example.livestreaming.net

View File

@ -48,28 +48,71 @@ public final class AuthStore {
private static final String KEY_USER_ID = "user_id";
private static final String KEY_NICKNAME = "nickname";
private static final String KEY_AVATAR = "avatar";
private static final String KEY_IS_STREAMER = "is_streamer";
private static final String KEY_STREAMER_MODE = "streamer_mode";
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) {
setUserInfo(context, userId, nickname, null);
}
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname, @Nullable String avatar) {
if (context == null) return;
Log.d(TAG, "setUserInfo: userId=" + userId + ", nickname=" + nickname);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_USER_ID, userId)
.putString(KEY_NICKNAME, nickname)
.apply();
// 清理和验证 userId
String cleanUserId = null;
if (userId != null) {
cleanUserId = userId.trim();
// 如果是 "null" 字符串或空字符串设置为 null
if (cleanUserId.isEmpty() || "null".equalsIgnoreCase(cleanUserId)) {
Log.w(TAG, "setUserInfo: invalid userId value: '" + userId + "', will not save");
cleanUserId = null;
}
}
Log.d(TAG, "setUserInfo: userId=" + cleanUserId + ", nickname=" + nickname + ", avatar=" + avatar);
android.content.SharedPreferences.Editor editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit();
if (cleanUserId != null) {
editor.putString(KEY_USER_ID, cleanUserId);
} else {
editor.remove(KEY_USER_ID);
}
if (nickname != null && !nickname.trim().isEmpty()) {
editor.putString(KEY_NICKNAME, nickname.trim());
} else {
editor.remove(KEY_NICKNAME);
}
if (avatar != null && !avatar.trim().isEmpty()) {
editor.putString(KEY_AVATAR, avatar.trim());
} else {
editor.remove(KEY_AVATAR);
}
editor.apply();
}
@Nullable
public static String getUserId(Context context) {
if (context == null) return null;
if (context == null) {
Log.w(TAG, "getUserId: context is null");
return null;
}
String userId = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_USER_ID, null);
// 确保空字符串也返回 null
if (userId != null && userId.trim().isEmpty()) {
userId = null;
// 确保空字符串"null" 字符串也返回 null
if (userId != null) {
userId = userId.trim();
if (userId.isEmpty() || "null".equalsIgnoreCase(userId)) {
Log.w(TAG, "getUserId: invalid userId value: '" + userId + "', returning null");
userId = null;
}
}
Log.d(TAG, "getUserId: " + userId);
return userId;
}
@ -82,6 +125,13 @@ public final class AuthStore {
return nickname != null ? nickname : "用户";
}
@Nullable
public static String getAvatar(Context context) {
if (context == null) return null;
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_AVATAR, null);
}
/**
* 设置用户是否是认证主播
*/

View File

@ -0,0 +1,89 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 直播类型响应对象
*/
public class LiveTypeResponse {
@SerializedName("id")
private Integer id;
@SerializedName("name")
private String name;
@SerializedName("code")
private String code;
@SerializedName("icon")
private String icon;
@SerializedName("description")
private String description;
@SerializedName("sort")
private Integer sort;
// Getters and Setters
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getSort() {
return sort;
}
public void setSort(Integer sort) {
this.sort = sort;
}
@Override
public String toString() {
return "LiveTypeResponse{" +
"id=" + id +
", name='" + name + '\'' +
", code='" + code + '\'' +
", icon='" + icon + '\'' +
", description='" + description + '\'' +
", sort=" + sort +
'}';
}
}

View File

@ -21,7 +21,20 @@ public class LoginResponse {
}
public String getUid() {
return uid != null ? String.valueOf(uid) : null;
if (uid == null) {
return null;
}
// 如果 uid 是数字类型直接转换
if (uid instanceof Number) {
return String.valueOf(((Number) uid).intValue());
}
// 如果是字符串类型返回字符串
String uidStr = String.valueOf(uid);
// 避免返回 "null" 字符串
if ("null".equalsIgnoreCase(uidStr) || uidStr.trim().isEmpty()) {
return null;
}
return uidStr;
}
public String getNikeName() {

View File

@ -7,19 +7,19 @@ public class SendGiftRequest {
@SerializedName("giftId")
private Integer giftId;
@SerializedName("streamerId")
private Integer streamerId;
@SerializedName("giftCount") // 后端期望的字段名是giftCount不是count
private Integer giftCount;
@SerializedName("count")
private Integer count;
@SerializedName("receiverId") // 后端期望的字段名是receiverId不是streamerId
private Integer receiverId;
public SendGiftRequest(Integer giftId, Integer streamerId, Integer count) {
public SendGiftRequest(Integer giftId, Integer receiverId, Integer giftCount) {
this.giftId = giftId;
this.streamerId = streamerId;
this.count = count;
this.receiverId = receiverId;
this.giftCount = giftCount;
}
public Integer getGiftId() { return giftId; }
public Integer getStreamerId() { return streamerId; }
public Integer getCount() { return count; }
public Integer getReceiverId() { return receiverId; }
public Integer getGiftCount() { return giftCount; }
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<corners android:radius="24dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CCCCCC" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 紫粉渐变,参考主流直播平台风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深紫到粉紫 -->
<gradient
android:angle="135"
android:startColor="#E6663399"
android:centerColor="#E68B4789"
android:endColor="#E6B565A7"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:金色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFD700" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 蓝色渐变,清新风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深蓝到浅蓝 -->
<gradient
android:angle="135"
android:startColor="#E61E3A8A"
android:centerColor="#E63B5998"
android:endColor="#E65B7BB4"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:蓝色光晕效果 -->
<stroke
android:width="1dp"
android:color="#8087CEEB" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 金色渐变,奢华风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深金到亮金 -->
<gradient
android:angle="135"
android:startColor="#E6B8860B"
android:centerColor="#E6DAA520"
android:endColor="#E6FFD700"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:白色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFFFFF" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 橙红渐变,热情风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从橙色到红色 -->
<gradient
android:angle="135"
android:startColor="#E6FF6B35"
android:centerColor="#E6FF5252"
android:endColor="#E6FF4081"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:金色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFD700" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="135"
android:startColor="#FF6B9D"
android:endColor="#C06C84"
android:type="linear" />
<corners android:radius="12dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#333333"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,17 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#2196F3"
android:pathData="M8,20l4-8h24l4,8v12H8z"/>
<path
android:fillColor="#1976D2"
android:pathData="M12,12h8v8h-8z"/>
<path
android:fillColor="#1976D2"
android:pathData="M28,12h8v8h-8z"/>
android:pathData="M8,24L12,16L36,16L40,24L40,32L8,32L8,24Z" />
<path
android:fillColor="#424242"
android:pathData="M14,28a4,4 0,1,1 0,8 4,4 0,1,1 0,-8z"/>
android:pathData="M12,28C13.1,28 14,28.9 14,30C14,31.1 13.1,32 12,32C10.9,32 10,31.1 10,30C10,28.9 10.9,28 12,28Z" />
<path
android:fillColor="#424242"
android:pathData="M34,28a4,4 0,1,1 0,8 4,4 0,1,1 0,-8z"/>
android:pathData="M36,28C37.1,28 38,28.9 38,30C38,31.1 37.1,32 36,32C34.9,32 34,31.1 34,30C34,28.9 34.9,28 36,28Z" />
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#9C27B0"
android:pathData="M8,16L8,36L40,36L40,16L36,16L36,12L32,12L32,16L28,16L28,12L24,12L24,16L20,16L20,12L16,12L16,16L12,16L12,12L8,12L8,16Z" />
<path
android:fillColor="#7B1FA2"
android:pathData="M20,24L20,36L28,36L28,24L20,24Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,17 +6,8 @@
android:viewportHeight="48">
<path
android:fillColor="#FFD700"
android:pathData="M8,36h32v4H8z"/>
android:pathData="M8,32L8,36L40,36L40,32L35,20L30,26L24,16L18,26L13,20L8,32Z" />
<path
android:fillColor="#FFC107"
android:pathData="M6,16l6,12h24l6-12-8,4-4-8-4,8-4-8-4,8-4-8z"/>
<path
android:fillColor="#FF9800"
android:pathData="M24,10a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
<path
android:fillColor="#FF9800"
android:pathData="M12,14a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
<path
android:fillColor="#FF9800"
android:pathData="M36,14a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
android:fillColor="#FFA000"
android:pathData="M24,12C25.1,12 26,12.9 26,14C26,15.1 25.1,16 24,16C22.9,16 22,15.1 22,14C22,12.9 22.9,12 24,12Z" />
</vector>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#F44336"
android:pathData="M24,40l-2.55-2.32C12.4,29.8 6,24.22 6,17.5 6,12.42 10.02,8.4 15.1,8.4c2.83,0 5.55,1.31 7.4,3.38 1.85-2.07 4.57-3.38 7.4-3.38C35.98,8.4 40,12.42 40,17.5c0,6.72-6.4,12.3-15.45,20.18L24,40z"/>
android:fillColor="#FF4444"
android:pathData="M24,42L20,38C10,29 4,23.5 4,17C4,12 7.5,8.5 12,8.5C15,8.5 18,10 20,12.5C22,10 25,8.5 28,8.5C32.5,8.5 36,12 36,17C36,23.5 30,29 20,38L24,42Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,11 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#FF5722"
android:pathData="M24,4c-4,0-8,4-10,10l-4,8 4,2 2-4c0,4 2,8 4,10v8l4,4 4-4v-8c2-2 4-6 4-10l2,4 4-2-4-8c-2-6-6-10-10-10z"/>
android:pathData="M24,4L20,12L16,20L20,20L20,28L28,28L28,20L32,20L28,12L24,4Z" />
<path
android:fillColor="#FFF"
android:pathData="M24,15a3,3 0,1,1 0,6 3,3 0,1,1 0,-6z"/>
android:fillColor="#FFC107"
android:pathData="M18,28L18,36L22,40L22,28L18,28Z" />
<path
android:fillColor="#FF9800"
android:pathData="M20,38l-2,6 2-2 4,2 4-2 2,2-2-6z"/>
android:fillColor="#FFC107"
android:pathData="M30,28L30,36L26,40L26,28L30,28Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,11 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#E91E63"
android:pathData="M24,8c-3.31,0-6,2.69-6,6 0,1.66 0.68,3.16 1.77,4.24L24,22.48l4.23-4.24C29.32,17.16 30,15.66 30,14c0-3.31-2.69-6-6-6z"/>
android:pathData="M24,8C20,8 17,11 17,15C17,19 20,22 24,22C28,22 31,19 31,15C31,11 28,8 24,8Z" />
<path
android:fillColor="#4CAF50"
android:pathData="M24,22.48l-4.23,4.24c-0.39,0.39-0.39,1.02 0,1.41l0,0c0.39,0.39 1.02,0.39 1.41,0L24,25.31l2.82,2.82c0.39,0.39 1.02,0.39 1.41,0l0,0c0.39-0.39 0.39-1.02 0-1.41L24,22.48z"/>
android:pathData="M22,22L22,40L26,40L26,22Z" />
<path
android:fillColor="#4CAF50"
android:pathData="M22,25v15h4V25z"/>
android:fillColor="#8BC34A"
android:pathData="M22,28C18,28 15,30 15,32C15,34 18,36 22,36L22,28Z" />
</vector>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#03A9F4"
android:pathData="M8,28L12,20L36,20L40,28L8,28Z" />
<path
android:fillColor="#0288D1"
android:pathData="M6,28L6,32L42,32L42,28L6,28Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,8L24,20L28,20L24,8Z" />
</vector>

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@android:color/white">
<!-- 顶部标题栏 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="@color/colorPrimary"
android:elevation="4dp">
<ImageButton
android:id="@+id/btn_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_back_24"
android:contentDescription="返回" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="充值"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
</RelativeLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 充值套餐标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选择充值套餐"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<!-- 充值套餐列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_packages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
<!-- 支付方式标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选择支付方式"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<!-- 支付方式选择 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_alipay"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="支付宝"
android:textSize="16sp"
app:cornerRadius="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_wechat"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="微信支付"
android:textSize="16sp"
app:cornerRadius="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- 底部确认按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_confirm"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_margin="16dp"
android:text="确认充值"
android:textSize="16sp"
android:textStyle="bold"
android:enabled="false"
app:cornerRadius="8dp" />
</LinearLayout>

View File

@ -363,4 +363,14 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 礼物特效覆盖层 - 显示在发送按钮上方 -->
<include
android:id="@+id/giftAnimationOverlay"
layout="@layout/gift_animation_overlay"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -23,6 +23,7 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/typeLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
@ -35,7 +36,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="选择直播类型"
android:inputType="none" />
android:inputType="none"
android:editable="false" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物特效覆盖层 - 显示在发送按钮上方 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/giftAnimationContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="60dp"
android:clipChildren="false"
android:clipToPadding="false">
<!-- 礼物特效主容器 -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/giftEffectLayout"
android:layout_width="match_parent"
android:layout_height="120dp"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false">
<!-- 左侧礼物图标和信息 - 无背景版本 -->
<LinearLayout
android:id="@+id/giftInfoCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<!-- 用户头像 -->
<ImageView
android:id="@+id/senderAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_user_24"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<!-- 礼物图标 -->
<ImageView
android:id="@+id/giftIcon"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="8dp"
android:scaleType="fitCenter" />
<!-- 礼物信息 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/senderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户名"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
<TextView
android:id="@+id/giftAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="送出了"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
<TextView
android:id="@+id/giftName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="礼物名称"
android:textColor="#FFD700"
android:textSize="18sp"
android:textStyle="bold"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
</LinearLayout>
<!-- 连击数 -->
<TextView
android:id="@+id/comboCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="x1"
android:textColor="#FF4444"
android:textSize="36sp"
android:textStyle="bold"
android:visibility="gone"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="10" />
</LinearLayout>
<!-- 粒子特效容器 - 用于显示飘散的粒子 -->
<FrameLayout
android:id="@+id/particleContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- 全屏特效容器 - 用于高级礼物的全屏动画 -->
<FrameLayout
android:id="@+id/fullscreenEffectContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<!-- 标签 -->
<TextView
android:id="@+id/tv_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:background="@drawable/bg_hot_badge"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="热门"
android:textSize="10sp"
android:textColor="#FFFFFF"
android:visibility="gone" />
<!-- 金额 -->
<TextView
android:id="@+id/tv_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="24dp"
android:text="100"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="@color/colorPrimary" />
<!-- 虚拟币单位 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_amount"
android:layout_centerHorizontal="true"
android:text="虚拟币"
android:textSize="12sp"
android:textColor="#999999"
android:layout_marginTop="4dp" />
<!-- 价格 -->
<TextView
android:id="@+id/tv_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:text="¥10.00"
android:textSize="16sp"
android:textColor="@color/text_secondary" />
</RelativeLayout>
</androidx.cardview.widget.CardView>

View File

@ -1,4 +1,8 @@
<resources>
<color name="colorPrimary">#FF6B9D</color>
<color name="colorPrimaryDark">#C06C84</color>
<color name="colorAccent">#FF6B9D</color>
<color name="purple_500">#6200EE</color>
<color name="purple_700">#3700B3</color>
<color name="teal_200">#03DAC5</color>

View File

@ -1,3 +1,3 @@
plugins {
id("com.android.application") version "8.3.0" apply false
id("com.android.application") version "8.1.2" apply false
}

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
distributionUrl=file:///D:/soft/gradle-8.1-bin.zip
networkTimeout=600000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -1,25 +0,0 @@
USE zhibo;
-- 测试手动插入一条关注记录
INSERT INTO eb_follow_record (
follower_id,
follower_nickname,
follower_phone,
followed_id,
followed_nickname,
followed_phone,
follow_status,
is_deleted
) VALUES (
120, -- 关注者ID你的测试用户
'测试用户',
'18888888888',
44, -- 被关注者ID主播
'主播',
'15637617378',
'关注', -- 明确设置为'关注'
0
);
-- 查看刚插入的记录
SELECT * FROM eb_follow_record WHERE follower_id = 120 AND followed_id = 44 ORDER BY create_time DESC LIMIT 1;