Merge branch 'master' of http://115.190.64.57:8000/xiaozhang/zhibo into IM-gift

# Conflicts:
#	Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/task/live/LiveStatusSyncTask.java
#	android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java
#	android-app/app/src/main/res/layout/dialog_make_wish.xml
#	android-app/app/src/main/res/layout/dialog_view_wish.xml
This commit is contained in:
ShiQi 2025-12-31 18:07:54 +08:00
commit c2e2684f52
43 changed files with 2537 additions and 676 deletions

View File

@ -0,0 +1,304 @@
# 许愿树移动端设计规范
> 版本V2.1
> 更新日期2025-12-31
> 设计范围Android移动端许愿树功能完善
---
## 一、设计原则
1. **不修改后端**复用现有API不做任何后端改动
2. **不修改管理端**:管理端功能保持不变
3. **功能完善**:确保所有功能逻辑正确
---
## 二、现有后端API不可修改
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取节日列表 | GET | `/api/front/wishtree/festivals` | 获取所有启用的节日 |
| 获取当前节日 | GET | `/api/front/wishtree/festivals/current` | 获取当前进行中的节日 |
| 获取心愿列表 | GET | `/api/front/wishtree/wishes` | 分页获取心愿列表 |
| 获取我的心愿 | GET | `/api/front/wishtree/wishes/my` | 获取当前用户的心愿 |
| 发布心愿 | POST | `/api/front/wishtree/wishes` | 发布新心愿 |
| 获取心愿详情 | GET | `/api/front/wishtree/wishes/{id}` | 获取心愿详情 |
| 删除心愿 | DELETE | `/api/front/wishtree/wishes/{id}` | 删除我的心愿 |
| 点赞心愿 | POST | `/api/front/wishtree/wishes/{id}/like` | 点赞 |
| 取消点赞 | DELETE | `/api/front/wishtree/wishes/{id}/like` | 取消点赞 |
| 获取评论 | GET | `/api/front/wishtree/wishes/{id}/comments` | 获取评论列表 |
| 发表评论 | POST | `/api/front/wishtree/wishes/{id}/comments` | 发表评论 |
| 获取背景素材 | GET | `/api/front/wishtree/backgrounds` | 获取背景素材列表 |
---
## 三、用户故事与验收标准
### US-001: 查看许愿树主页
**作为** 用户
**我想要** 进入许愿树页面查看许愿树和我的心愿
**以便于** 了解当前活动和管理我的心愿
**验收标准:**
- [x] 页面显示许愿树背景图
- [x] 显示当前节日活动横幅(调用 `/festivals/current`
- [x] 显示我的心愿卡片(调用 `/wishes/my`最多7个
- [x] 显示"添加心愿"按钮
- [x] 底部导航栏正确高亮"许愿树"选项
- [x] 显示倒计时
- [x] 显示祈愿值统计
---
### US-002: 发布心愿
**作为** 已登录用户
**我想要** 发布一个新的心愿
**以便于** 表达我的愿望
**验收标准:**
- [x] 点击"添加心愿"或空白卡片弹出许愿对话框
- [x] 输入框限制50字显示字数统计
- [x] 未登录时提示登录并跳转登录页
- [x] 调用 `POST /wishes` 发布心愿
- [x] 提交成功后显示成功动画
- [x] 心愿列表自动刷新
---
### US-003: 查看我的心愿
**作为** 用户
**我想要** 查看我已发布的心愿详情
**以便于** 回顾我的愿望
**验收标准:**
- [x] 点击心愿卡片弹出详情对话框
- [x] 显示心愿内容、点赞数、评论数
- [x] 提供"删除心愿"和"愿望达成"操作
---
### US-004: 删除心愿
**作为** 用户
**我想要** 删除我不想要的心愿
**以便于** 管理我的心愿列表
**验收标准:**
- [x] 点击"删除心愿"弹出确认对话框
- [x] 确认后调用 `DELETE /wishes/{id}` 删除
- [x] 删除成功后刷新列表
- [x] 显示删除成功提示
---
### US-005: 愿望达成
**作为** 用户
**我想要** 标记我的愿望已达成
**以便于** 庆祝愿望实现
**验收标准:**
- [x] 点击"愿望达成"弹出确认对话框
- [x] 确认后调用 `DELETE /wishes/{id}` 删除心愿
- [x] 显示庆祝动画持续2秒
- [x] 刷新心愿列表
---
### US-006: 查看节日活动
**作为** 用户
**我想要** 看到当前进行中的节日活动
**以便于** 参与节日许愿
**验收标准:**
- [x] 页面顶部显示活动横幅
- [x] 横幅显示节日名称
- [x] 显示倒计时
- [x] 无活动时显示默认文案
---
## 四、移动端文件结构
```
android-app/app/src/main/java/com/example/livestreaming/
├── WishTreeActivity.java # 许愿树主页面
└── net/
├── ApiService.java # API接口定义
├── WishtreeRequest.java # 请求类
└── WishtreeResponse.java # 响应类
android-app/app/src/main/res/layout/
├── activity_wish_tree.xml # 主页面布局
├── dialog_make_wish.xml # 许愿对话框
├── dialog_view_wish.xml # 查看心愿对话框
├── dialog_wish_success.xml # 成功提示对话框
└── dialog_wish_complete.xml # 愿望达成对话框
```
---
## 五、数据模型
### 5.1 WishtreeResponse.java
```java
public class WishtreeResponse {
// 节日信息
public static class Festival {
public int id;
public String name;
public String icon;
public String startDate;
public String endDate;
public int isLunar;
public String themeColor;
public int status;
}
// 心愿信息
public static class Wish {
public long id;
public int uid;
public String nickname;
public String avatar;
public int festivalId;
public String festivalName;
public String festivalIcon;
public String content;
public int backgroundId;
public String backgroundImage;
public int status; // 0待审核 1通过 2拒绝
public int likeCount;
public int commentCount;
public Boolean isLiked;
public String createTime;
}
// 分页数据
public static class WishPage {
public List<Wish> list;
public int total;
public int pageNum;
public int pageSize;
}
}
```
### 5.2 WishtreeRequest.java
```java
public class WishtreeRequest {
// 发布心愿请求
public static class PublishWish {
public Integer festivalId;
public String content;
public Integer backgroundId;
}
}
```
---
## 六、核心交互流程
### 6.1 页面加载
```
onCreate()
├── 初始化ApiService
├── 启动横幅倒计时
├── 设置底部导航
├── 设置心愿卡片点击事件
├── loadCurrentFestival() → 更新横幅文字
└── loadMyWishes() → 更新心愿卡片、祈愿值
```
### 6.2 发布心愿
```
点击"添加心愿"
├── checkLogin() → 未登录跳转登录页
├── showMakeWishInputDialog()
├── 输入内容≤50字
├── publishWish() → POST /wishes
├── 成功 → showSuccessDialog() → loadMyWishes()
└── 失败 → Toast提示
```
### 6.3 删除/达成心愿
```
点击心愿卡片
├── showViewWishDialog()
├── 点击"删除心愿"
│ ├── 确认对话框
│ ├── DELETE /wishes/{id}
│ └── 成功 → Toast → loadMyWishes()
└── 点击"愿望达成"
├── 确认对话框
├── DELETE /wishes/{id}
└── 成功 → 庆祝动画 → loadMyWishes()
```
---
## 七、错误处理
| 场景 | 处理方式 |
|------|----------|
| 网络错误 | Toast "网络错误" |
| 业务错误 | Toast 显示后端返回的message |
| 未登录 | 跳转LoginActivity |
| 空数据 | 显示空列表,卡片显示空白 |
| 内容为空 | Toast "请输入心愿内容" |
| 内容超长 | Toast "心愿内容不能超过50字" |
---
## 八、实现任务清单
### Phase 1: 数据层 ✅
- [x] WishtreeResponse.java - 响应类
- [x] WishtreeRequest.java - 请求类
- [x] ApiService.java - 接口定义
### Phase 2: UI层 ✅
- [x] activity_wish_tree.xml - 主页面
- [x] dialog_make_wish.xml - 许愿对话框
- [x] dialog_view_wish.xml - 查看心愿对话框
- [x] dialog_wish_success.xml - 成功提示
- [x] dialog_wish_complete.xml - 愿望达成
### Phase 3: 逻辑层 ✅
- [x] WishTreeActivity.java - 核心逻辑
- [x] 页面加载与数据刷新
- [x] 发布心愿功能
- [x] 删除心愿功能
- [x] 愿望达成功能
- [x] 登录检查
- [x] 错误处理
---
## 九、测试要点
### 功能测试
- [x] 页面正常加载
- [x] 节日信息正确显示
- [x] 心愿卡片正确显示最多7个
- [x] 发布心愿成功
- [x] 删除心愿成功
- [x] 愿望达成功能正常
- [x] 底部导航切换正常
### 异常测试
- [x] 网络断开时的提示
- [x] 未登录时跳转登录页
- [x] 空数据时的显示
- [x] 心愿内容校验(空、超长)

View File

@ -68,14 +68,6 @@ export function wishTreeNodeListApi(params) {
});
}
// 节点详情
export function wishTreeNodeInfoApi(id) {
return request({
url: `/admin/wish/tree/node/info/${id}`,
method: 'get',
});
}
// 新增节点
export function wishTreeNodeSaveApi(data) {
return request({
@ -112,7 +104,7 @@ export function wishTreeNodeStatusApi(id) {
// ========== 用户留言管理 ==========
// 留言列表(按许愿树查询)
// 留言列表
export function wishTreeMessageListApi(params) {
return request({
url: '/admin/wish/tree/message/list',
@ -121,14 +113,6 @@ export function wishTreeMessageListApi(params) {
});
}
// 留言详情
export function wishTreeMessageInfoApi(id) {
return request({
url: `/admin/wish/tree/message/info/${id}`,
method: 'get',
});
}
// 删除留言
export function wishTreeMessageDeleteApi(id) {
return request({
@ -144,3 +128,107 @@ export function wishTreeMessageStatusApi(id) {
method: 'post',
});
}
// ========== 节日管理 ==========
// 节日列表
export function festivalList(params) {
return request({
url: '/admin/wishtree/festival/list',
method: 'get',
params,
});
}
// 保存节日
export function festivalSave(data) {
return request({
url: '/admin/wishtree/festival/save',
method: 'post',
data,
});
}
// 删除节日 - 后端使用DELETE方法
export function festivalDelete(id) {
return request({
url: `/admin/wishtree/festival/delete/${id}`,
method: 'delete',
});
}
// 修改节日状态 - 后端接收RequestBody
export function festivalStatus(id, status) {
return request({
url: '/admin/wishtree/festival/status',
method: 'post',
data: { id, status },
});
}
// ========== 心愿管理 ==========
// 心愿列表 - 后端使用POST方法
export function wishList(data) {
return request({
url: '/admin/wishtree/wish/list',
method: 'post',
data,
});
}
// 审核心愿
export function wishAudit(data) {
return request({
url: '/admin/wishtree/wish/audit',
method: 'post',
data,
});
}
// 删除心愿 - 后端使用DELETE方法
export function wishDelete(id) {
return request({
url: `/admin/wishtree/wish/delete/${id}`,
method: 'delete',
});
}
// ========== 背景管理 ==========
// 背景列表
export function backgroundList(params) {
return request({
url: '/admin/wishtree/background/list',
method: 'get',
params,
});
}
// 保存背景
export function backgroundSave(data) {
return request({
url: '/admin/wishtree/background/save',
method: 'post',
data,
});
}
// 删除背景 - 后端使用DELETE方法
export function backgroundDelete(id) {
return request({
url: `/admin/wishtree/background/delete/${id}`,
method: 'delete',
});
}
// ========== 统计 ==========
// 获取统计数据
export function getStatistics(params) {
return request({
url: '/admin/wishtree/statistics',
method: 'get',
params,
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@ -4,21 +4,20 @@
v-if="$store.state.themeConfig.themeConfig.layout !== 'columns' && !$store.state.themeConfig.themeConfig.isCollapse"
@click="onThemeConfigChange"
>
<img v-if="platMerLoginInfo" class="layout-logo-medium-img" :src="platMerLoginInfo.siteLogoSquare" />
<!-- 直接使用本地Logo图片替换Logo只需修改 logo-square.png 文件 -->
<img class="layout-logo-medium-img" src="@/assets/imgs/logo-square.png" />
</div>
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
<img v-if="platMerLoginInfo" class="layout-logo-size-img" :src="platMerLoginInfo.siteLogoLeftTop" />
<!-- 直接使用本地Logo图片替换Logo只需修改 logo-lefttop.png 文件 -->
<img class="layout-logo-size-img" src="@/assets/imgs/logo-lefttop.png" />
</div>
</template>
<script>
import Cookies from 'js-cookie';
export default {
name: 'layoutLogo',
data() {
return {
platMerLoginInfo: JSON.parse(Cookies.get('logoInfo')), //
};
return {};
},
computed: {
//

View File

@ -13,28 +13,53 @@ const wishtreeManageRouter = {
children: [
{
path: 'festival',
component: () => import('@/views/wishtree/festival/index'),
component: () => import('@/views/wishTree/festival/index'),
name: 'WishtreeFestival',
meta: { title: '节日管理' },
},
{
path: 'wish',
component: () => import('@/views/wishtree/wish/index'),
component: () => import('@/views/wishTree/wish/index'),
name: 'WishtreeWish',
meta: { title: '心愿管理' },
},
{
path: 'background',
component: () => import('@/views/wishtree/background/index'),
component: () => import('@/views/wishTree/background/index'),
name: 'WishtreeBackground',
meta: { title: '背景素材' },
},
{
path: 'statistics',
component: () => import('@/views/wishtree/statistics/index'),
component: () => import('@/views/wishTree/statistics/index'),
name: 'WishtreeStatistics',
meta: { title: '数据统计' },
},
{
path: 'tree',
component: () => import('@/views/wishTree/tree/index'),
name: 'WishTreeList',
meta: { title: '许愿树列表' },
},
{
path: 'tree/detail/:id',
component: () => import('@/views/wishTree/tree/detail'),
name: 'WishTreeDetail',
meta: { title: '许愿树详情' },
hidden: true,
},
{
path: 'node',
component: () => import('@/views/wishTree/node/index'),
name: 'WishTreeNode',
meta: { title: '节点管理' },
},
{
path: 'message',
component: () => import('@/views/wishTree/message/index'),
name: 'WishTreeMessage',
meta: { title: '留言管理' },
},
],
};

View File

@ -15,8 +15,8 @@ const VUE_APP_WS_URL =
const SettingMer = {
// 服务器地址
httpUrl: VUE_APP_API_URL,
// 接口请求地址
apiBaseURL: VUE_APP_API_URL + '/api/',
// 接口请求地址 - 不再重复添加/api
apiBaseURL: VUE_APP_API_URL,
// socket连接
wsSocketUrl: VUE_APP_WS_URL,
};

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',
@ -159,7 +159,7 @@ export default {
}
},
handleViewMessages(row) {
this.$router.push({ path: `/wishTree/tree/detail/${row.id}` });
this.$router.push({ path: `/wishtree/tree/detail/${row.id}` });
},
addNode() {
this.formData.nodes.push({ title: '', description: '', open_time: null, sort: 0, status: 1 });

View File

@ -132,7 +132,7 @@ export default {
});
},
async handleStatusChange(row) {
await festivalStatus({ id: row.id, status: row.status });
await festivalStatus(row.id, row.status);
this.$message.success('状态更新成功');
},
handleDelete(row) {

View File

@ -49,6 +49,12 @@ module.exports = {
warnings: false,
errors: true,
},
proxy: {
'/api': {
target: 'http://localhost:30001',
changeOrigin: true
}
}
},
configureWebpack: {
// provide the app's title in webpack's name field, so that

View File

@ -35,29 +35,30 @@ public class LiveStatusSyncTask {
@Value("${SRS_API_URL:http://127.0.0.1:1985}")
private String srsApiUrl;
@Value("${live.status.sync.enabled:true}")
private boolean syncEnabled;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 同步直播状态 30 秒执行一次
* 生产环境建议保留此任务确保直播状态与实际推流状态一致
* 同步直播状态 30 秒执行一次减少频率
*/
@Scheduled(fixedRate = 30000) // 改为30秒降低频率
@Scheduled(fixedRate = 30000)
public void syncLiveStatus() {
if (!syncEnabled) {
logger.debug("直播状态同步已禁用");
return;
}
try {
Set<String> liveStreamKeys = fetchLiveStreamKeysFromSrs();
updateLiveStatus(liveStreamKeys);
// 只有成功获取到 SRS 数据时才更新状态
// 如果 SRS 查询失败返回空集合不修改现有状态
if (liveStreamKeys != null && !liveStreamKeys.isEmpty()) {
updateLiveStatus(liveStreamKeys);
} else {
logger.debug("SRS 返回空流列表,保持现有直播状态不变");
}
} catch (Exception e) {
logger.error("LiveStatusSyncTask 执行失败: {}", e.getMessage());
}
}
// 标记是否成功连接过 SRS
private boolean srsConnected = false;
/**
* SRS API 获取当前正在推流的 streamKey 列表
*/
@ -71,6 +72,7 @@ public class LiveStatusSyncTask {
conn.setReadTimeout(3000);
if (conn.getResponseCode() == 200) {
srsConnected = true;
JsonNode root = objectMapper.readTree(conn.getInputStream());
JsonNode streams = root.get("streams");
if (streams != null && streams.isArray()) {
@ -88,15 +90,18 @@ public class LiveStatusSyncTask {
conn.disconnect();
} catch (Exception e) {
logger.warn("查询 SRS API 失败: {}", e.getMessage());
// 返回 null 表示查询失败不应该修改状态
return null;
}
return streamKeys;
}
/**
* 更新数据库中的直播状态
* 增加容错只有连续多次检测到断流才更新为未开播
*/
private void updateLiveStatus(Set<String> liveStreamKeys) {
if (liveStreamKeys == null) return;
List<LiveRoom> allRooms = liveRoomService.list(new LambdaQueryWrapper<>());
for (LiveRoom room : allRooms) {
String streamKey = room.getStreamKey();
@ -105,18 +110,9 @@ public class LiveStatusSyncTask {
int currentStatus = room.getIsLive() == null ? 0 : room.getIsLive();
if (newStatus != currentStatus) {
// 如果检测到推流开始立即更新为直播中
if (newStatus == 1) {
room.setIsLive(newStatus);
liveRoomService.updateById(room);
logger.info("直播状态更新: {} -> 直播中", room.getTitle());
}
// 如果检测到推流结束也立即更新SRS回调已经处理了这里是兜底
else if (currentStatus == 1) {
room.setIsLive(0);
liveRoomService.updateById(room);
logger.info("直播状态更新: {} -> 未开播(定时任务检测到断流)", room.getTitle());
}
room.setIsLive(newStatus);
liveRoomService.updateById(room);
logger.info("直播状态更新: {} -> {}", room.getTitle(), shouldBeLive ? "直播中" : "未开播");
}
}
}

View File

@ -29,6 +29,12 @@ server:
max-threads: 1000 # 最大线程数量 默认200
min-spare-threads: 30 # 初始化启动线程数量
# ============ 直播流服务器配置 ============
# 直播流始终使用远程SRS服务器
LIVE_PUBLIC_SRS_HOST: 1.15.149.240
LIVE_PUBLIC_SRS_RTMP_PORT: 25002
LIVE_PUBLIC_SRS_HTTP_PORT: 25003
spring:
profiles:
# 配置的环境

View File

@ -296,8 +296,9 @@ public class WishtreeServiceImpl implements WishtreeService {
WishtreeStatisticsVO vo = new WishtreeStatisticsVO();
// 总心愿数
vo.setTotalWishes(Long.valueOf(wishDao.selectCount(new LambdaQueryWrapper<WishtreeWish>()
.eq(WishtreeWish::getIsDelete, 0))));
Integer totalWishes = wishDao.selectCount(new LambdaQueryWrapper<WishtreeWish>()
.eq(WishtreeWish::getIsDelete, 0));
vo.setTotalWishes(totalWishes != null ? totalWishes.longValue() : 0L);
// 今日新增
vo.setTodayWishes(wishDao.countTodayWishes());
@ -306,11 +307,13 @@ public class WishtreeServiceImpl implements WishtreeService {
vo.setPendingWishes(wishDao.countPendingWishes());
// 总点赞数
vo.setTotalLikes(Long.valueOf(likeDao.selectCount(null)));
Integer totalLikes = likeDao.selectCount(null);
vo.setTotalLikes(totalLikes != null ? totalLikes.longValue() : 0L);
// 总评论数
vo.setTotalComments(Long.valueOf(commentDao.selectCount(new LambdaQueryWrapper<WishtreeComment>()
.eq(WishtreeComment::getIsDelete, 0))));
Integer totalComments = commentDao.selectCount(new LambdaQueryWrapper<WishtreeComment>()
.eq(WishtreeComment::getIsDelete, 0));
vo.setTotalComments(totalComments != null ? totalComments.longValue() : 0L);
// 心愿趋势
vo.setWishTrend(wishDao.selectWishTrend(days));

View File

@ -0,0 +1,98 @@
-- ============================================
-- 许愿树快速建表脚本
-- 执行此脚本创建许愿树所需的所有表
-- ============================================
-- 1. 节日表
CREATE TABLE IF NOT EXISTS `eb_wishtree_festival` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '节日ID',
`name` varchar(50) NOT NULL COMMENT '节日名称',
`icon` varchar(255) DEFAULT '' COMMENT '节日图标',
`start_date` varchar(20) DEFAULT '' COMMENT '开始日期',
`end_date` varchar(20) DEFAULT '' COMMENT '结束日期',
`is_lunar` tinyint DEFAULT 0 COMMENT '是否农历',
`theme_color` varchar(20) DEFAULT '#FF6B6B' COMMENT '主题色',
`sort` int DEFAULT 0 COMMENT '排序',
`status` tinyint DEFAULT 1 COMMENT '状态 0禁用 1启用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树节日表';
-- 2. 心愿表
CREATE TABLE IF NOT EXISTS `eb_wishtree_wish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '心愿ID',
`uid` int NOT NULL COMMENT '用户ID',
`festival_id` int DEFAULT 0 COMMENT '节日ID',
`content` varchar(500) NOT NULL COMMENT '心愿内容',
`background_id` int DEFAULT 0 COMMENT '背景ID',
`status` tinyint DEFAULT 1 COMMENT '状态 0待审核 1通过 2拒绝',
`audit_type` tinyint DEFAULT 0 COMMENT '审核方式 0自动 1人工',
`audit_remark` varchar(255) DEFAULT '' COMMENT '审核备注',
`like_count` int DEFAULT 0 COMMENT '点赞数',
`comment_count` int DEFAULT 0 COMMENT '评论数',
`is_delete` tinyint DEFAULT 0 COMMENT '是否删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`),
KEY `idx_festival` (`festival_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树心愿表';
-- 3. 点赞表
CREATE TABLE IF NOT EXISTS `eb_wishtree_like` (
`id` bigint NOT NULL AUTO_INCREMENT,
`uid` int NOT NULL COMMENT '用户ID',
`wish_id` bigint NOT NULL COMMENT '心愿ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_uid_wish` (`uid`, `wish_id`),
KEY `idx_wish` (`wish_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树点赞表';
-- 4. 评论表
CREATE TABLE IF NOT EXISTS `eb_wishtree_comment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`wish_id` bigint NOT NULL COMMENT '心愿ID',
`uid` int NOT NULL COMMENT '用户ID',
`content` varchar(255) NOT NULL COMMENT '评论内容',
`parent_id` bigint DEFAULT 0 COMMENT '父评论ID',
`status` tinyint DEFAULT 1 COMMENT '状态',
`is_delete` tinyint DEFAULT 0 COMMENT '是否删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_wish` (`wish_id`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树评论表';
-- 5. 背景素材表
CREATE TABLE IF NOT EXISTS `eb_wishtree_background` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '背景名称',
`image` varchar(255) NOT NULL COMMENT '背景图片',
`festival_id` int DEFAULT 0 COMMENT '关联节日ID',
`sort` int DEFAULT 0 COMMENT '排序',
`status` tinyint DEFAULT 1 COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树背景素材表';
-- ============================================
-- 初始化节日数据
-- ============================================
INSERT IGNORE INTO `eb_wishtree_festival` (`id`, `name`, `icon`, `start_date`, `end_date`, `is_lunar`, `theme_color`, `sort`, `status`) VALUES
(1, '元旦', '🎉', '12-31', '01-03', 0, '#FF6B6B', 100, 1),
(2, '春节', '🧧', '除夕', '正月十五', 1, '#E74C3C', 99, 1),
(3, '情人节', '💕', '02-13', '02-15', 0, '#FF69B4', 98, 1),
(4, '生日祝福', '🎂', '全年', '全年', 0, '#FF69B4', 50, 1),
(5, '日常祝福', '', '全年', '全年', 0, '#9B59B6', 49, 1);
-- ============================================
-- 验证表是否创建成功
-- ============================================
SELECT 'eb_wishtree_festival' AS table_name, COUNT(*) AS count FROM eb_wishtree_festival;
SELECT 'eb_wishtree_wish' AS table_name, COUNT(*) AS count FROM eb_wishtree_wish;
SELECT 'eb_wishtree_like' AS table_name, COUNT(*) AS count FROM eb_wishtree_like;
SELECT 'eb_wishtree_comment' AS table_name, COUNT(*) AS count FROM eb_wishtree_comment;
SELECT 'eb_wishtree_background' AS table_name, COUNT(*) AS count FROM eb_wishtree_background;

View File

@ -31,16 +31,25 @@ android {
}
}
// ============ 主API地址普通业务功能============
val apiBaseUrlEmulator = (localProps.getProperty("api.base_url_emulator")
?: "http://10.0.2.2:8081/").trim()
val apiBaseUrlDevice = (localProps.getProperty("api.base_url_device")
?: "http://192.168.1.164:8081/").trim()
// 模拟器使用服务器地址
buildConfigField("String", "API_BASE_URL_EMULATOR", "\"$apiBaseUrlEmulator\"")
// 真机使用服务器地址
buildConfigField("String", "API_BASE_URL_DEVICE", "\"$apiBaseUrlDevice\"")
// ============ 直播/通话服务地址(始终远程)============
val liveServerHost = (localProps.getProperty("live.server_host") ?: "1.15.149.240").trim()
val liveServerPort = (localProps.getProperty("live.server_port") ?: "8083").trim()
val turnServerHost = (localProps.getProperty("turn.server_host") ?: "1.15.149.240").trim()
val turnServerPort = (localProps.getProperty("turn.server_port") ?: "3478").trim()
buildConfigField("String", "LIVE_SERVER_HOST", "\"$liveServerHost\"")
buildConfigField("String", "LIVE_SERVER_PORT", "\"$liveServerPort\"")
buildConfigField("String", "TURN_SERVER_HOST", "\"$turnServerHost\"")
buildConfigField("String", "TURN_SERVER_PORT", "\"$turnServerPort\"")
}
buildTypes {

View File

@ -39,6 +39,7 @@ public class LiveStreamingApplication extends Application {
/**
* 如果用户已登录连接通话信令服务器
* 延迟执行避免启动时阻塞
*/
public void connectCallSignalingIfLoggedIn() {
String userId = AuthStore.getUserId(this);
@ -48,8 +49,15 @@ public class LiveStreamingApplication extends Application {
try {
int uid = (int) Double.parseDouble(userId);
if (uid > 0) {
Log.d(TAG, "用户已登录连接通话信令服务器userId: " + uid);
CallManager.getInstance(this).connect(uid);
Log.d(TAG, "用户已登录延迟连接通话信令服务器userId: " + uid);
// 延迟3秒连接避免启动时阻塞
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
try {
CallManager.getInstance(this).connect(uid);
} catch (Exception e) {
Log.e(TAG, "连接通话信令服务器失败", e);
}
}, 3000);
}
} catch (NumberFormatException e) {
Log.e(TAG, "解析用户ID失败: " + userId, e);
@ -64,7 +72,11 @@ public class LiveStreamingApplication extends Application {
*/
public void onUserLoggedIn(int userId) {
Log.d(TAG, "用户登录成功连接通话信令服务器userId: " + userId);
CallManager.getInstance(this).connect(userId);
try {
CallManager.getInstance(this).connect(userId);
} catch (Exception e) {
Log.e(TAG, "连接通话信令服务器失败", e);
}
}
/**

View File

@ -103,9 +103,17 @@ public class MainActivity extends AppCompatActivity {
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// 强制设置正确的 API 地址
ApiClient.setCustomBaseUrl(getApplicationContext(), "http://192.168.1.164:8081/");
// 清除之前缓存的自定义API地址使用BuildConfig中的配置
ApiClient.clearCustomBaseUrl(getApplicationContext());
ApiClient.getService(getApplicationContext());
// 调试打印当前使用的 API 地址
String currentApiUrl = ApiClient.getCurrentBaseUrl(getApplicationContext());
Log.d(TAG, "========== API 配置 ==========");
Log.d(TAG, "当前 API 地址: " + currentApiUrl);
Log.d(TAG, "BuildConfig EMULATOR: " + com.example.livestreaming.BuildConfig.API_BASE_URL_EMULATOR);
Log.d(TAG, "BuildConfig DEVICE: " + com.example.livestreaming.BuildConfig.API_BASE_URL_DEVICE);
Log.d(TAG, "==============================");
// 立即显示缓存数据提升启动速度
setupRecyclerView();

View File

@ -78,33 +78,36 @@ public class PlayerActivity extends AppCompatActivity {
releaseExoPlayer();
triedAltUrl = false;
// 创建低延迟播放器配置
// 优化缓冲配置平衡延迟和流畅度
androidx.media3.exoplayer.DefaultLoadControl loadControl =
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
3000, // 最小缓冲 3秒
15000, // 最大缓冲 15秒
1500, // 播放前缓冲 1.5秒
3000 // 重新缓冲 3秒
)
.setPrioritizeTimeOverSizeThresholds(true)
.build();
ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder()
// 减少缓冲区大小降低延迟
.setBufferDurationsMs(
1000, // 最小缓冲时长 1秒
3000, // 最大缓冲时长 3秒
500, // 播放缓冲时长 0.5秒
1000 // 播放后缓冲时长 1秒
)
.build())
.setLoadControl(loadControl)
.build();
// 设置播放器视图
binding.playerView.setPlayer(exo);
// 启用低延迟模式
binding.playerView.setUseController(true);
binding.playerView.setControllerAutoShow(false);
String computedAltUrl = altUrl;
if (computedAltUrl == null || computedAltUrl.trim().isEmpty()) computedAltUrl = getAltHlsUrl(url);
if (computedAltUrl == null || computedAltUrl.trim().isEmpty()) {
computedAltUrl = getAltHlsUrl(url);
}
String finalComputedAltUrl = computedAltUrl;
exo.addListener(new Player.Listener() {
@Override
public void onPlayerError(PlaybackException error) {
android.util.Log.e("PlayerActivity", "播放错误: " + error.getMessage());
if (triedAltUrl) return;
if (finalComputedAltUrl == null || finalComputedAltUrl.trim().isEmpty()) return;
triedAltUrl = true;
@ -115,6 +118,7 @@ public class PlayerActivity extends AppCompatActivity {
}
});
android.util.Log.d("PlayerActivity", "开始播放: " + url);
exo.setMediaItem(MediaItem.fromUri(url));
exo.prepare();
exo.setPlayWhenReady(true);
@ -138,47 +142,16 @@ public class PlayerActivity extends AppCompatActivity {
}
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
ensureIjkLibsLoaded();
releaseExoPlayer();
releaseIjkPlayer();
ijkUrl = flvUrl;
ijkFallbackHlsUrl = fallbackHlsUrl;
ijkFallbackTried = false;
if (binding != null) {
binding.playerView.setVisibility(android.view.View.GONE);
binding.flvTextureView.setVisibility(android.view.View.VISIBLE);
}
TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surfaceTexture, int width, int height) {
ijkSurface = new Surface(surfaceTexture);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {
}
@Override
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
releaseIjkPlayer();
return true;
}
@Override
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {
}
};
view.setSurfaceTextureListener(listener);
if (view.isAvailable() && view.getSurfaceTexture() != null) {
ijkSurface = new Surface(view.getSurfaceTexture());
prepareIjk(flvUrl);
// 禁用 IjkPlayer直接使用 HLS 播放IjkPlayer 在某些设备上会崩溃
android.util.Log.d("PlayerActivity", "FLV流转为HLS播放: " + flvUrl);
// FLV 地址转换为 HLS 地址
String hlsUrl = fallbackHlsUrl;
if (hlsUrl == null || hlsUrl.trim().isEmpty()) {
hlsUrl = flvUrl.replace(".flv", ".m3u8");
}
startHls(hlsUrl, null);
}
private void prepareIjk(String url) {

View File

@ -24,6 +24,7 @@ import androidx.media3.exoplayer.ExoPlayer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.GridLayoutManager;
import com.example.livestreaming.call.WebRTCConfig;
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
@ -105,22 +106,25 @@ public class RoomDetailActivity extends AppCompatActivity {
private WebSocket onlineCountWebSocket;
private OkHttpClient onlineCountWsClient;
// 动态获取WebSocket URL
// 动态获取WebSocket URL - 直播服务使用远程服务器
private String getWsChatBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this);
if (baseUrl == null || baseUrl.isEmpty()) {
baseUrl = "http://192.168.1.164:8081/";
try {
// 直播弹幕WebSocket使用远程服务器
return WebRTCConfig.getLiveWsUrl() + "ws/live/chat/";
} catch (Exception e) {
android.util.Log.e("RoomDetail", "获取WsChatBaseUrl失败", e);
return "ws://1.15.149.240:8083/ws/live/chat/";
}
// http:// 转换为 ws://
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/chat/";
}
private String getWsOnlineBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this);
if (baseUrl == null || baseUrl.isEmpty()) {
baseUrl = "http://192.168.1.164:8081/";
try {
// 直播在线人数WebSocket使用远程服务器
return WebRTCConfig.getLiveWsUrl() + "ws/live/";
} catch (Exception e) {
android.util.Log.e("RoomDetail", "获取WsOnlineBaseUrl失败", e);
return "ws://1.15.149.240:8083/ws/live/";
}
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/";
}
// WebSocket 心跳检测 - 弹幕
@ -150,25 +154,37 @@ public class RoomDetailActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 隐藏ActionBar使用自定义顶部栏
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
try {
// 隐藏ActionBar使用自定义顶部栏
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
if (TextUtils.isEmpty(roomId)) {
Toast.makeText(this, "直播间ID无效", Toast.LENGTH_SHORT).show();
finish();
return;
}
triedAltUrl = false;
setupUI();
setupChat();
setupGifts();
// 记录观看历史异步不阻塞
recordWatchHistory();
} catch (Exception e) {
android.util.Log.e("RoomDetail", "onCreate异常: " + e.getMessage(), e);
Toast.makeText(this, "加载直播间失败", Toast.LENGTH_SHORT).show();
finish();
}
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
triedAltUrl = false;
setupUI();
setupChat();
setupGifts();
// 记录观看历史
recordWatchHistory();
}
private void setupUI() {
@ -196,13 +212,10 @@ public class RoomDetailActivity extends AppCompatActivity {
// 全屏按钮
binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen());
// 观众列表按钮
binding.topViewerLayout.setOnClickListener(v -> showViewerListDialog());
// 关注按钮
binding.followButton.setOnClickListener(v -> {
// 检查登录状态关注主播需要登录
if (!AuthHelper.requireLogin(this, "关注/取消关注需要登录")) {
if (!AuthHelper.requireLogin(this, "关注主播需要登录")) {
return;
}
@ -211,12 +224,8 @@ public class RoomDetailActivity extends AppCompatActivity {
return;
}
// 根据当前关注状态决定是关注还是取消关注
if (room.getIsFollowing()) {
unfollowStreamerBackend();
} else {
followStreamerBackend();
}
// 调用后端接口关注主播
followStreamerBackend();
});
// 分享按钮
@ -751,36 +760,39 @@ public class RoomDetailActivity extends AppCompatActivity {
* 记录观看历史
*/
private void recordWatchHistory() {
if (!AuthHelper.isLoggedIn(this)) {
return; // 未登录用户不记录
}
if (TextUtils.isEmpty(roomId)) {
return;
}
ApiService apiService = ApiClient.getService(getApplicationContext());
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("roomId", roomId);
body.put("watchTime", System.currentTimeMillis());
Call<ApiResponse<java.util.Map<String, Object>>> call = apiService.recordWatchHistory(body);
call.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
Response<ApiResponse<java.util.Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
android.util.Log.d("RoomDetail", "观看历史记录成功");
try {
if (!AuthHelper.isLoggedIn(this)) {
return; // 未登录用户不记录
}
if (TextUtils.isEmpty(roomId)) {
return;
}
ApiService apiService = ApiClient.getService(getApplicationContext());
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("roomId", roomId);
body.put("watchTime", System.currentTimeMillis());
Call<ApiResponse<java.util.Map<String, Object>>> call = apiService.recordWatchHistory(body);
call.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
Response<ApiResponse<java.util.Map<String, Object>>> response) {
// 忽略结果接口可能不存在
}
}
@Override
public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
android.util.Log.e("RoomDetail", "观看历史记录失败: " + t.getMessage());
}
});
@Override
public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
// 忽略错误接口可能不存在
}
});
} catch (Exception e) {
// 忽略所有异常不影响直播观看
android.util.Log.w("RoomDetail", "记录观看历史失败: " + e.getMessage());
}
}
/**
@ -903,87 +915,92 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void bindRoom(Room r) {
String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
// 设置顶部标题栏
binding.topTitle.setText(title);
// 设置房间信息区域
binding.roomTitle.setText(title);
binding.streamerName.setText(streamer);
// 更新关注按钮状态
updateFollowButtonState(r.getIsFollowing());
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
try {
String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
// 在线人数已通过WebSocket实时更新这里只设置初始值
if (r.getViewerCount() > 0) {
binding.topViewerCount.setText(String.valueOf(r.getViewerCount()));
// 设置顶部标题栏
binding.topTitle.setText(title);
// 设置房间信息区域
binding.roomTitle.setText(title);
binding.streamerName.setText(streamer);
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// 在线人数已通过WebSocket实时更新这里只设置初始值
if (r.getViewerCount() > 0) {
binding.topViewerCount.setText(String.valueOf(r.getViewerCount()));
}
// 如果后端返回的viewerCount为0保持UI不变等待WebSocket推送
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
}
// 如果后端返回的viewerCount为0保持UI不变等待WebSocket推送
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
}
// 获取播放地址
String playUrl = null;
String fallbackHlsUrl = null;
if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低
playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
}
// 获取播放地址
String playUrl = null;
String fallbackHlsUrl = null;
if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低
playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
}
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl);
} else {
// 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
}
/**
* 更新关注按钮状态
*/
private void updateFollowButtonState(boolean isFollowing) {
if (isFollowing) {
binding.followButton.setText("已关注");
} else {
binding.followButton.setText("关注");
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl);
} else {
// 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
} catch (Exception e) {
android.util.Log.e("RoomDetail", "bindRoom异常: " + e.getMessage(), e);
// 不要因为绑定失败就退出显示错误状态
if (binding != null && binding.offlineLayout != null) {
binding.offlineLayout.setVisibility(View.VISIBLE);
}
}
}
private void ensurePlayer(String url, String fallbackHlsUrl) {
if (TextUtils.isEmpty(url)) return;
try {
if (TextUtils.isEmpty(url)) return;
if (url.endsWith(".flv")) {
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
startFlv(url, fallbackHlsUrl);
return;
if (url.endsWith(".flv")) {
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
startFlv(url, fallbackHlsUrl);
return;
}
if (player != null) {
MediaItem current = player.getCurrentMediaItem();
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
? current.localConfiguration.uri.toString()
: null;
if (currentUri != null && currentUri.equals(url)) return;
}
startHls(url, null);
} catch (Exception e) {
android.util.Log.e("RoomDetail", "ensurePlayer异常: " + e.getMessage(), e);
// 播放器初始化失败显示离线状态
if (binding != null && binding.offlineLayout != null) {
binding.offlineLayout.setVisibility(View.VISIBLE);
}
}
if (player != null) {
MediaItem current = player.getCurrentMediaItem();
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
? current.localConfiguration.uri.toString()
: null;
if (currentUri != null && currentUri.equals(url)) return;
}
startHls(url, null);
}
// 防止重复显示连接消息
private boolean hasShownConnectedMessage = false;
private void startHls(String url, @Nullable String altUrl) {
releaseIjkPlayer();
if (binding != null) {
@ -993,46 +1010,95 @@ public class RoomDetailActivity extends AppCompatActivity {
releaseExoPlayer();
triedAltUrl = false;
hasShownConnectedMessage = false; // 重置连接消息标志
ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
1000,
3000,
500,
1000
)
.build())
// 优化缓冲配置 - 增大缓冲区以减少卡顿
// HLS 直播通常有 2-3 秒的切片延迟需要足够的缓冲
androidx.media3.exoplayer.DefaultLoadControl loadControl =
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
10000, // 最小缓冲 10秒保证流畅播放
30000, // 最大缓冲 30秒足够应对网络波动
5000, // 播放前缓冲 5秒确保有足够数据再开始
10000 // 重新缓冲 10秒卡顿后充分缓冲再继续
)
.setPrioritizeTimeOverSizeThresholds(true)
.build();
// 创建播放器
ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(loadControl)
.build();
// 设置播放器视图
binding.playerView.setPlayer(exo);
binding.playerView.setUseController(true);
binding.playerView.setControllerAutoShow(false);
String computedAltUrl = altUrl;
if (TextUtils.isEmpty(computedAltUrl)) computedAltUrl = getAltHlsUrl(url);
String finalComputedAltUrl = computedAltUrl;
final String finalUrl = url;
exo.addListener(new Player.Listener() {
private int retryCount = 0;
private static final int MAX_RETRY = 3;
@Override
public void onPlayerError(PlaybackException error) {
if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
android.util.Log.e("ExoPlayer", "播放错误: " + error.getMessage());
// 先尝试备用地址
if (!triedAltUrl && !TextUtils.isEmpty(finalComputedAltUrl)) {
triedAltUrl = true;
android.util.Log.d("ExoPlayer", "尝试备用地址: " + finalComputedAltUrl);
exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl));
exo.prepare();
exo.setPlayWhenReady(true);
return;
}
triedAltUrl = true;
exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl));
exo.prepare();
exo.setPlayWhenReady(true);
// 自动重试
if (retryCount < MAX_RETRY) {
retryCount++;
android.util.Log.d("ExoPlayer", "自动重试 " + retryCount + "/" + MAX_RETRY);
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
exo.setMediaItem(MediaItem.fromUri(finalUrl));
exo.prepare();
exo.setPlayWhenReady(true);
}
}, 2000);
return;
}
// 重试失败显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
fetchRoom();
}
}, 5000);
}
@Override
public void onPlaybackStateChanged(int playbackState) {
if (playbackState == Player.STATE_READY) {
binding.offlineLayout.setVisibility(View.GONE);
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
retryCount = 0;
// 只显示一次连接消息
if (!hasShownConnectedMessage) {
hasShownConnectedMessage = true;
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
}
} else if (playbackState == Player.STATE_BUFFERING) {
android.util.Log.d("ExoPlayer", "正在缓冲...");
}
}
});
android.util.Log.d("ExoPlayer", "开始播放: " + url);
exo.setMediaItem(MediaItem.fromUri(url));
exo.prepare();
exo.setPlayWhenReady(true);
@ -1040,47 +1106,16 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
ensureIjkLibsLoaded();
releaseExoPlayer();
releaseIjkPlayer();
ijkUrl = flvUrl;
ijkFallbackHlsUrl = fallbackHlsUrl;
ijkFallbackTried = false;
if (binding != null) {
binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE);
}
TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(@NonNull android.graphics.SurfaceTexture surfaceTexture, int width, int height) {
ijkSurface = new Surface(surfaceTexture);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(@NonNull android.graphics.SurfaceTexture surface, int width, int height) {
}
@Override
public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) {
releaseIjkPlayer();
return true;
}
@Override
public void onSurfaceTextureUpdated(@NonNull android.graphics.SurfaceTexture surface) {
}
};
view.setSurfaceTextureListener(listener);
if (view.isAvailable() && view.getSurfaceTexture() != null) {
ijkSurface = new Surface(view.getSurfaceTexture());
prepareIjk(flvUrl);
android.util.Log.d("RoomDetail", "开始播放FLV流: " + flvUrl);
// 直接使用 HLS 播放避免 IjkPlayer 崩溃问题
// HLS 虽然延迟稍高但稳定性更好
String hlsUrl = fallbackHlsUrl;
if (TextUtils.isEmpty(hlsUrl)) {
hlsUrl = flvUrl.replace(".flv", ".m3u8");
}
android.util.Log.d("RoomDetail", "使用 HLS 播放: " + hlsUrl);
startHls(hlsUrl, null);
}
private void prepareIjk(String url) {
@ -1088,36 +1123,39 @@ public class RoomDetailActivity extends AppCompatActivity {
IjkMediaPlayer p = new IjkMediaPlayer();
// 优化缓冲设置减少卡顿
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1); // 开启缓冲
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100000); // 100ms分析时长
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 10240); // 10KB探测大小
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5); // 允许丢帧
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 3000); // 3秒缓存
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min_frames", 50); // 最小缓冲帧数
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 0); // 关闭无限缓冲
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1); // 断线重连
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100000);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 10240);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 3000);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min_frames", 50);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 0);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_streamed", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_delay_max", 5); // 最大重连延迟5秒
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_delay_max", 5);
p.setOnPreparedListener(mp -> {
binding.offlineLayout.setVisibility(View.GONE);
mp.start();
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
});
// 只显示一次连接消息
if (!hasShownConnectedMessage) {
hasShownConnectedMessage = true;
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
}
});
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
android.util.Log.e("IjkPlayer", "播放错误: what=" + what + ", extra=" + extra);
if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
// 5秒后尝试重新连接
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed() && room != null && room.isLive()) {
fetchRoom(); // 重新获取房间信息并播放
}
}, 5000);
return true;
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
android.util.Log.e("IjkPlayer", "播放错误: what=" + what + ", extra=" + extra);
if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed() && room != null && room.isLive()) {
fetchRoom();
}
}, 5000);
return true;
}
ijkFallbackTried = true;
startHls(ijkFallbackHlsUrl, null);
@ -1154,14 +1192,34 @@ public class RoomDetailActivity extends AppCompatActivity {
}
}
private static boolean ijkLibLoadFailed = false;
private static void ensureIjkLibsLoaded() {
if (ijkLibLoaded) return;
if (ijkLibLoaded || ijkLibLoadFailed) return;
try {
// 检查设备 CPU 架构
String[] abis = android.os.Build.SUPPORTED_ABIS;
boolean supported = false;
for (String abi : abis) {
if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi)) {
supported = true;
break;
}
}
if (!supported) {
android.util.Log.w("IjkPlayer", "设备 CPU 架构不支持 IjkPlayer: " + java.util.Arrays.toString(abis));
ijkLibLoadFailed = true;
return;
}
IjkMediaPlayer.loadLibrariesOnce(null);
IjkMediaPlayer.native_profileBegin("libijkplayer.so");
} catch (Throwable ignored) {
ijkLibLoaded = true;
android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功");
} catch (Throwable e) {
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage());
ijkLibLoadFailed = true;
}
ijkLibLoaded = true;
}
private void releasePlayer() {
@ -1756,49 +1814,16 @@ public class RoomDetailActivity extends AppCompatActivity {
*/
private void followStreamerBackend() {
if (room == null) {
Toast.makeText(this, "房间信息不可用", Toast.LENGTH_SHORT).show();
return;
}
// 检查主播ID是否存在
if (room.getStreamerId() == null) {
Toast.makeText(this, "主播信息不可用", Toast.LENGTH_SHORT).show();
return;
}
// 获取当前登录用户ID
String currentUserIdStr = AuthStore.getUserId(this);
android.util.Log.d("RoomDetail", "followStreamerBackend: currentUserIdStr = " + currentUserIdStr);
if (currentUserIdStr == null || currentUserIdStr.trim().isEmpty()) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
return;
}
// 验证用户ID格式
try {
int currentUserId = Integer.parseInt(currentUserIdStr.trim());
int streamerId = room.getStreamerId();
android.util.Log.d("RoomDetail", "followStreamerBackend: currentUserId = " + currentUserId + ", streamerId = " + streamerId);
// 不能关注自己
if (currentUserId == streamerId) {
Toast.makeText(this, "不能关注自己", Toast.LENGTH_SHORT).show();
return;
}
} catch (NumberFormatException e) {
android.util.Log.e("RoomDetail", "用户ID格式错误: currentUserIdStr = '" + currentUserIdStr + "'", e);
Toast.makeText(this, "用户ID格式错误请重新登录", Toast.LENGTH_SHORT).show();
return;
}
ApiService apiService = ApiClient.getService(getApplicationContext());
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("userId", room.getStreamerId()); // 使用主播的用户ID
body.put("streamerId", roomId); // 使用房间ID作为主播ID
body.put("action", "follow");
Call<ApiResponse<Map<String, Object>>> call = apiService.followUser(body);
Call<ApiResponse<Map<String, Object>>> call = apiService.followStreamer(body);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
@ -1806,26 +1831,10 @@ public class RoomDetailActivity extends AppCompatActivity {
Response<ApiResponse<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
Map<String, Object> data = apiResponse.getData();
Boolean success = (Boolean) data.get("success");
String message = (String) data.get("message");
if (Boolean.TRUE.equals(success)) {
Toast.makeText(RoomDetailActivity.this,
message != null ? message : "已关注主播",
Toast.LENGTH_SHORT).show();
// 更新UI和状态
binding.followButton.setText("已关注");
if (room != null) {
room.setIsFollowing(true);
}
} else {
Toast.makeText(RoomDetailActivity.this,
message != null ? message : "关注失败",
Toast.LENGTH_SHORT).show();
}
if (apiResponse.getCode() == 200) {
Toast.makeText(RoomDetailActivity.this, "已关注主播", Toast.LENGTH_SHORT).show();
binding.followButton.setText("已关注");
binding.followButton.setEnabled(false);
} else {
Toast.makeText(RoomDetailActivity.this,
"关注失败: " + apiResponse.getMessage(),
@ -1847,164 +1856,6 @@ public class RoomDetailActivity extends AppCompatActivity {
});
}
/**
* 取消关注主播
*/
private void unfollowStreamerBackend() {
if (room == null) {
Toast.makeText(this, "房间信息不可用", Toast.LENGTH_SHORT).show();
return;
}
// 检查主播ID是否存在
if (room.getStreamerId() == null) {
Toast.makeText(this, "主播信息不可用", Toast.LENGTH_SHORT).show();
return;
}
ApiService apiService = ApiClient.getService(getApplicationContext());
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("userId", room.getStreamerId()); // 使用主播的用户ID
Call<ApiResponse<Map<String, Object>>> call = apiService.unfollowUser(body);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
Map<String, Object> data = apiResponse.getData();
Boolean success = (Boolean) data.get("success");
String message = (String) data.get("message");
if (Boolean.TRUE.equals(success)) {
Toast.makeText(RoomDetailActivity.this,
message != null ? message : "已取消关注",
Toast.LENGTH_SHORT).show();
// 更新UI和状态
binding.followButton.setText("关注");
if (room != null) {
room.setIsFollowing(false);
}
} else {
Toast.makeText(RoomDetailActivity.this,
message != null ? message : "取消关注失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(RoomDetailActivity.this,
"取消关注失败: " + apiResponse.getMessage(),
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(RoomDetailActivity.this,
"取消关注失败",
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
/**
* 显示观众列表对话框
*/
private void showViewerListDialog() {
if (room == null || TextUtils.isEmpty(roomId)) {
Toast.makeText(this, "房间信息不可用", Toast.LENGTH_SHORT).show();
return;
}
// 创建对话框
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
builder.setTitle("在线观众 (" + binding.topViewerCount.getText() + ")");
// 显示加载中
View loadingView = getLayoutInflater().inflate(android.R.layout.simple_list_item_1, null);
((android.widget.TextView) loadingView.findViewById(android.R.id.text1)).setText("加载中...");
builder.setView(loadingView);
androidx.appcompat.app.AlertDialog dialog = builder.create();
dialog.show();
// 调用后端接口获取观众列表
ApiService apiService = ApiClient.getService(getApplicationContext());
Call<ApiResponse<Map<String, Object>>> call = apiService.getRoomOnlineUsers(roomId);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (!isFinishing() && dialog.isShowing()) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
Map<String, Object> data = apiResponse.getData();
// 获取观众列表
Object usersObj = data.get("users");
if (usersObj instanceof List) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> users = (List<Map<String, Object>>) usersObj;
if (users.isEmpty()) {
// 没有观众
((android.widget.TextView) loadingView.findViewById(android.R.id.text1))
.setText("暂无观众");
} else {
// 创建观众列表视图
String[] userNames = new String[users.size()];
for (int i = 0; i < users.size(); i++) {
Map<String, Object> user = users.get(i);
String nickname = user.get("nickname") != null ?
user.get("nickname").toString() : "匿名用户";
userNames[i] = nickname;
}
runOnUiThread(() -> {
dialog.dismiss();
new MaterialAlertDialogBuilder(RoomDetailActivity.this)
.setTitle("在线观众 (" + users.size() + ")")
.setItems(userNames, null)
.setPositiveButton("关闭", null)
.show();
});
}
} else {
((android.widget.TextView) loadingView.findViewById(android.R.id.text1))
.setText("数据格式错误");
}
} else {
((android.widget.TextView) loadingView.findViewById(android.R.id.text1))
.setText("获取失败: " + apiResponse.getMessage());
}
} else {
((android.widget.TextView) loadingView.findViewById(android.R.id.text1))
.setText("获取失败");
}
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
if (!isFinishing() && dialog.isShowing()) {
((android.widget.TextView) loadingView.findViewById(android.R.id.text1))
.setText("网络错误: " + t.getMessage());
}
}
});
}
/**
* 开始直播
* 接口: POST /api/front/live/room/{id}/start

View File

@ -1,9 +1,9 @@
package com.example.livestreaming;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
@ -16,24 +16,48 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import com.example.livestreaming.databinding.ActivityWishTreeBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.WishtreeRequest;
import com.example.livestreaming.net.WishtreeResponse;
import com.example.livestreaming.net.ApiService;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 许愿树页面
* 数据通过API实时同步到服务端
*/
public class WishTreeActivity extends AppCompatActivity {
private ActivityWishTreeBinding binding;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable timerRunnable;
private SharedPreferences prefs;
private String[] wishes = new String[7];
private ApiService apiService;
// 心愿数据从服务端获取
private List<WishtreeResponse.Wish> myWishes = new ArrayList<>();
// 当前节日
private WishtreeResponse.Festival currentFestival;
// 是否正在加载
private boolean isLoading = false;
public static void start(Context context) {
context.startActivity(new Intent(context, WishTreeActivity.class));
@ -45,36 +69,112 @@ public class WishTreeActivity extends AppCompatActivity {
binding = ActivityWishTreeBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
prefs = getSharedPreferences("wishes", MODE_PRIVATE);
loadWishes();
apiService = ApiClient.getService(this);
startBannerCountdown();
setupBottomNav();
setupWishCards();
setupMakeWishButton();
setupTopBarButtons();
// 加载数据
loadCurrentFestival();
loadMyWishes();
}
private void loadWishes() {
for (int i = 0; i < 7; i++) {
wishes[i] = prefs.getString("wish_" + i, "");
/**
* 设置顶部栏按钮
*/
private void setupTopBarButtons() {
// 搜索按钮
binding.searchButton.setOnClickListener(v -> {
SearchActivity.start(this);
});
// 通知按钮
binding.notifyButton.setOnClickListener(v -> {
NotificationsActivity.start(this);
});
}
/**
* 检查是否已登录
*/
private boolean checkLogin() {
String token = AuthStore.getToken(this);
if (token == null || token.isEmpty()) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
startActivity(new Intent(this, LoginActivity.class));
return false;
}
updateWishCards();
return true;
}
private void saveWish(int index, String wish) {
wishes[index] = wish;
prefs.edit().putString("wish_" + index, wish).apply();
updateWishCards();
/**
* 加载当前节日
*/
private void loadCurrentFestival() {
apiService.getCurrentFestival().enqueue(new Callback<ApiResponse<WishtreeResponse.Festival>>() {
@Override
public void onResponse(@NonNull Call<ApiResponse<WishtreeResponse.Festival>> call,
@NonNull Response<ApiResponse<WishtreeResponse.Festival>> response) {
if (response.isSuccessful() && response.body() != null && response.body().getData() != null) {
currentFestival = response.body().getData();
updateBannerText();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse<WishtreeResponse.Festival>> call, @NonNull Throwable t) {
// 使用默认文案
}
});
}
/**
* 更新横幅文字
*/
private void updateBannerText() {
if (currentFestival != null && binding != null) {
String text = currentFestival.name + "许愿树 | 许下心愿,愿望成真";
binding.bannerText.setText(text);
}
}
/**
* 从服务端加载我的心愿
*/
private void loadMyWishes() {
apiService.getMyWishes(1, 10).enqueue(new Callback<ApiResponse<WishtreeResponse.WishPage>>() {
@Override
public void onResponse(@NonNull Call<ApiResponse<WishtreeResponse.WishPage>> call,
@NonNull Response<ApiResponse<WishtreeResponse.WishPage>> response) {
if (response.isSuccessful() && response.body() != null && response.body().getData() != null) {
myWishes = response.body().getData().list;
if (myWishes == null) myWishes = new ArrayList<>();
updateWishCards();
updateWishCount();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse<WishtreeResponse.WishPage>> call, @NonNull Throwable t) {
Toast.makeText(WishTreeActivity.this, "加载心愿失败", Toast.LENGTH_SHORT).show();
}
});
}
/**
* 更新心愿卡片显示
*/
private void updateWishCards() {
TextView[] cards = {
binding.wishCard1, binding.wishCard2, binding.wishCard3,
binding.wishCard4, binding.wishCard5, binding.wishCard6, binding.wishCard7
};
for (int i = 0; i < cards.length; i++) {
if (wishes[i] != null && !wishes[i].isEmpty()) {
String text = wishes[i].length() > 8 ? wishes[i].substring(0, 8) + "..." : wishes[i];
if (i < myWishes.size() && myWishes.get(i) != null) {
String content = myWishes.get(i).content;
String text = content.length() > 8 ? content.substring(0, 8) + "..." : content;
cards[i].setText(text);
} else {
cards[i].setText("");
@ -82,6 +182,17 @@ public class WishTreeActivity extends AppCompatActivity {
}
}
/**
* 更新祈愿值显示
*/
private void updateWishCount() {
int count = myWishes != null ? myWishes.size() : 0;
binding.tvWishCount.setText("祈愿值:" + count + "/100");
}
/**
* 设置心愿卡片点击事件
*/
private void setupWishCards() {
TextView[] cards = {
binding.wishCard1, binding.wishCard2, binding.wishCard3,
@ -91,29 +202,36 @@ public class WishTreeActivity extends AppCompatActivity {
final int index = i;
cards[i].setOnClickListener(v -> onWishCardClick(index));
}
binding.addWishCard.setOnClickListener(v -> showMakeWishInputDialog(-1));
binding.addWishCard.setOnClickListener(v -> showMakeWishInputDialog());
}
/**
* 心愿卡片点击
*/
private void onWishCardClick(int index) {
if (wishes[index] != null && !wishes[index].isEmpty()) {
if (index < myWishes.size() && myWishes.get(index) != null) {
showViewWishDialog(index);
} else {
showMakeWishInputDialog(index);
showMakeWishInputDialog();
}
}
private void showMakeWishInputDialog(int index) {
/**
* 显示许愿输入对话框
*/
private void showMakeWishInputDialog() {
// 检查登录状态
if (!checkLogin()) return;
Dialog dialog = new Dialog(this);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
dialog.setContentView(R.layout.dialog_make_wish);
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
// 设置对话框宽度为屏幕宽度的85%
int width = (int) (getResources().getDisplayMetrics().widthPixels * 0.85);
dialog.getWindow().setLayout(width, android.view.WindowManager.LayoutParams.WRAP_CONTENT);
}
EditText input = dialog.findViewById(R.id.editWish);
TextView tvCharCount = dialog.findViewById(R.id.tvCharCount);
View btnCancel = dialog.findViewById(R.id.btnCancel);
@ -136,14 +254,11 @@ public class WishTreeActivity extends AppCompatActivity {
btnMakeWish.setOnClickListener(v -> {
String wish = input.getText().toString().trim();
if (!wish.isEmpty()) {
int saveIndex = index >= 0 ? index : findEmptySlot();
if (saveIndex >= 0) {
saveWish(saveIndex, wish);
dialog.dismiss();
showSuccessDialog();
} else {
Toast.makeText(this, "心愿牌已满", Toast.LENGTH_SHORT).show();
if (wish.length() > 50) {
Toast.makeText(this, "心愿内容不能超过50字", Toast.LENGTH_SHORT).show();
return;
}
publishWish(wish, dialog);
} else {
Toast.makeText(this, "请输入心愿内容", Toast.LENGTH_SHORT).show();
}
@ -152,13 +267,42 @@ public class WishTreeActivity extends AppCompatActivity {
dialog.show();
}
private int findEmptySlot() {
for (int i = 0; i < wishes.length; i++) {
if (wishes[i] == null || wishes[i].isEmpty()) return i;
}
return -1;
/**
* 发布心愿到服务端
*/
private void publishWish(String content, Dialog dialog) {
Integer festivalId = currentFestival != null ? currentFestival.id : null;
WishtreeRequest.PublishWish request = new WishtreeRequest.PublishWish(festivalId, content, null);
apiService.publishWish(request).enqueue(new Callback<ApiResponse<WishtreeResponse.Wish>>() {
@Override
public void onResponse(@NonNull Call<ApiResponse<WishtreeResponse.Wish>> call,
@NonNull Response<ApiResponse<WishtreeResponse.Wish>> response) {
if (response.isSuccessful() && response.body() != null) {
if (response.body().getCode() == 200) {
dialog.dismiss();
showSuccessDialog();
loadMyWishes(); // 重新加载心愿列表
} else {
Toast.makeText(WishTreeActivity.this,
response.body().getMessage() != null ? response.body().getMessage() : "发布失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(WishTreeActivity.this, "发布失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse<WishtreeResponse.Wish>> call, @NonNull Throwable t) {
Toast.makeText(WishTreeActivity.this, "网络错误", Toast.LENGTH_SHORT).show();
}
});
}
/**
* 显示成功对话框
*/
private void showSuccessDialog() {
Dialog dialog = new Dialog(this);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
@ -170,12 +314,20 @@ public class WishTreeActivity extends AppCompatActivity {
handler.postDelayed(dialog::dismiss, 1500);
}
/**
* 显示查看心愿对话框
*/
private void showViewWishDialog(int index) {
if (index >= myWishes.size()) return;
WishtreeResponse.Wish wish = myWishes.get(index);
Dialog dialog = new Dialog(this);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
dialog.setContentView(R.layout.dialog_view_wish);
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
int width = (int) (getResources().getDisplayMetrics().widthPixels * 0.85);
dialog.getWindow().setLayout(width, android.view.WindowManager.LayoutParams.WRAP_CONTENT);
}
TextView tvContent = dialog.findViewById(R.id.tvWishContent);
@ -183,27 +335,94 @@ public class WishTreeActivity extends AppCompatActivity {
TextView btnDelete = dialog.findViewById(R.id.btnDeleteWish);
TextView btnComplete = dialog.findViewById(R.id.btnCompleteWish);
tvContent.setText(wishes[index]);
tvContent.setText(wish.content);
// 显示点赞数和评论数
TextView tvLikeCount = dialog.findViewById(R.id.tvLikeCount);
TextView tvCommentCount = dialog.findViewById(R.id.tvCommentCount);
if (tvLikeCount != null) {
tvLikeCount.setText("" + wish.likeCount);
}
if (tvCommentCount != null) {
tvCommentCount.setText("💬 " + wish.commentCount);
}
btnClose.setOnClickListener(v -> dialog.dismiss());
// 删除心愿 - 添加确认对话框
btnDelete.setOnClickListener(v -> {
saveWish(index, "");
Toast.makeText(this, "心愿已删除", Toast.LENGTH_SHORT).show();
dialog.dismiss();
new AlertDialog.Builder(this)
.setTitle("确认删除")
.setMessage("确定要删除这个心愿吗?")
.setPositiveButton("删除", (d, w) -> {
deleteWish(wish.id, dialog, false);
})
.setNegativeButton("取消", null)
.show();
});
// 愿望达成 - 添加确认对话框
btnComplete.setOnClickListener(v -> {
saveWish(index, "");
Toast.makeText(this, "恭喜愿望达成!", Toast.LENGTH_SHORT).show();
dialog.dismiss();
new AlertDialog.Builder(this)
.setTitle("愿望达成")
.setMessage("恭喜!确认愿望已经达成了吗?")
.setPositiveButton("确认", (d, w) -> {
deleteWish(wish.id, dialog, true);
})
.setNegativeButton("取消", null)
.show();
});
dialog.show();
}
/**
* 删除心愿
* @param wishId 心愿ID
* @param dialog 对话框
* @param isComplete 是否是愿望达成
*/
private void deleteWish(long wishId, Dialog dialog, boolean isComplete) {
apiService.deleteMyWish(wishId).enqueue(new Callback<ApiResponse<String>>() {
@Override
public void onResponse(@NonNull Call<ApiResponse<String>> call,
@NonNull Response<ApiResponse<String>> response) {
if (response.isSuccessful() && response.body() != null && response.body().getCode() == 200) {
dialog.dismiss();
if (isComplete) {
showCompleteSuccessDialog();
} else {
Toast.makeText(WishTreeActivity.this, "心愿已删除", Toast.LENGTH_SHORT).show();
}
loadMyWishes(); // 重新加载
} else {
Toast.makeText(WishTreeActivity.this, "操作失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse<String>> call, @NonNull Throwable t) {
Toast.makeText(WishTreeActivity.this, "网络错误", Toast.LENGTH_SHORT).show();
}
});
}
/**
* 显示愿望达成成功对话框
*/
private void showCompleteSuccessDialog() {
Dialog dialog = new Dialog(this);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
dialog.setContentView(R.layout.dialog_wish_complete);
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
}
dialog.show();
handler.postDelayed(dialog::dismiss, 2000);
}
private void setupMakeWishButton() {
binding.btnMakeWish.setOnClickListener(v -> showMakeWishInputDialog(-1));
binding.btnMakeWish.setOnClickListener(v -> showMakeWishInputDialog());
}
private void setupBottomNav() {
@ -295,5 +514,7 @@ public class WishTreeActivity extends AppCompatActivity {
binding.bottomNavInclude.bottomNavigation.setSelectedItemId(R.id.nav_wish_tree);
UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation);
}
// 每次返回页面时刷新数据
loadMyWishes();
}
}

View File

@ -81,6 +81,7 @@ public class CallManager implements CallSignalingClient.SignalingListener {
/**
* 连接信令服务器
* 通话服务始终使用远程服务器地址
*/
public void connect(int userId) {
Log.d(TAG, "connect() called, userId: " + userId);
@ -88,8 +89,9 @@ public class CallManager implements CallSignalingClient.SignalingListener {
Log.d(TAG, "已经连接,跳过");
return;
}
String baseUrl = ApiClient.getCurrentBaseUrl(context);
Log.d(TAG, "连接信令服务器baseUrl: " + baseUrl);
// 通话服务使用独立的远程服务器地址
String baseUrl = WebRTCConfig.getLiveServerUrl();
Log.d(TAG, "连接信令服务器远程baseUrl: " + baseUrl);
signalingClient = new CallSignalingClient(baseUrl, userId);
signalingClient.setListener(this);
signalingClient.connect();

View File

@ -1,24 +1,23 @@
package com.example.livestreaming.call;
import com.example.livestreaming.BuildConfig;
/**
* WebRTC 配置
* 部署时修改这里的服务器地址
* 直播/通话服务始终连接远程服务器
* 配置在 local.properties 中修改
*/
public class WebRTCConfig {
// ============ STUN 服务器 ============
// STUN 用于获取设备的公网IP帮助建立P2P连接
public static final String[] STUN_SERVERS = {
"stun:stun.l.google.com:19302", // Google STUN国内可能不稳定
"stun:stun.qq.com:3478", // 腾讯 STUN国内推荐
"stun:stun.miwifi.com:3478" // 小米 STUN国内推荐
"stun:stun.l.google.com:19302",
"stun:stun.qq.com:3478",
"stun:stun.miwifi.com:3478"
};
// ============ TURN 服务器 ============
// TURN 用于在P2P连接失败时进行中继转发
// 你的服务器TURN地址
public static final String TURN_SERVER_URL = "turn:1.15.149.240:3478";
// ============ TURN 服务器从BuildConfig读取============
public static final String TURN_SERVER_URL = "turn:" + BuildConfig.TURN_SERVER_HOST + ":" + BuildConfig.TURN_SERVER_PORT;
// TURN 服务器用户名
public static final String TURN_USERNAME = "turnuser";
@ -26,27 +25,12 @@ public class WebRTCConfig {
// TURN 服务器密码
public static final String TURN_PASSWORD = "TurnPass123456";
// ============ 使用说明 ============
/*
* 局域网测试
* - 不需要修改当前配置即可使用
*
* 部署到公网服务器
* 1. 在服务器安装 coturn (TURN服务器)
* 2. 修改上面的配置
* TURN_SERVER_URL = "turn:你的服务器IP:3478"
* TURN_USERNAME = "你设置的用户名"
* TURN_PASSWORD = "你设置的密码"
*
* 宝塔安装 coturn 步骤
* 1. SSH执行: yum install -y coturn (CentOS) apt install -y coturn (Ubuntu)
* 2. 编辑 /etc/turnserver.conf:
* listening-port=3478
* external-ip=你的公网IP
* realm=你的公网IP
* lt-cred-mech
* user=用户名:密码
* 3. 宝塔放行端口: 3478(TCP/UDP), 49152-65535(UDP)
* 4. 启动: systemctl start coturn
*/
// ============ 直播/通话服务地址 ============
public static String getLiveServerUrl() {
return "http://" + BuildConfig.LIVE_SERVER_HOST + ":" + BuildConfig.LIVE_SERVER_PORT + "/";
}
public static String getLiveWsUrl() {
return "ws://" + BuildConfig.LIVE_SERVER_HOST + ":" + BuildConfig.LIVE_SERVER_PORT + "/";
}
}

View File

@ -420,4 +420,88 @@ public interface ApiService {
@POST("api/front/live/rooms/{roomId}/broadcast/online")
Call<ApiResponse<Map<String, Object>>> broadcastOnlineCount(@Path("roomId") String roomId);
// ==================== 许愿树接口 ====================
/**
* 获取节日列表
*/
@GET("api/front/wishtree/festivals")
Call<ApiResponse<List<WishtreeResponse.Festival>>> getWishtreeFestivals();
/**
* 获取当前进行中的节日
*/
@GET("api/front/wishtree/festivals/current")
Call<ApiResponse<WishtreeResponse.Festival>> getCurrentFestival();
/**
* 获取心愿列表
*/
@GET("api/front/wishtree/wishes")
Call<ApiResponse<WishtreeResponse.WishPage>> getWishtreeWishes(
@Query("festivalId") Integer festivalId,
@Query("page") int page,
@Query("limit") int limit);
/**
* 获取我的心愿列表
*/
@GET("api/front/wishtree/wishes/my")
Call<ApiResponse<WishtreeResponse.WishPage>> getMyWishes(
@Query("page") int page,
@Query("limit") int limit);
/**
* 发布心愿
*/
@POST("api/front/wishtree/wishes")
Call<ApiResponse<WishtreeResponse.Wish>> publishWish(@Body WishtreeRequest.PublishWish body);
/**
* 获取心愿详情
*/
@GET("api/front/wishtree/wishes/{id}")
Call<ApiResponse<WishtreeResponse.Wish>> getWishDetail(@Path("id") long id);
/**
* 删除我的心愿
*/
@DELETE("api/front/wishtree/wishes/{id}")
Call<ApiResponse<String>> deleteMyWish(@Path("id") long id);
/**
* 点赞心愿
*/
@POST("api/front/wishtree/wishes/{id}/like")
Call<ApiResponse<String>> likeWish(@Path("id") long id);
/**
* 取消点赞
*/
@DELETE("api/front/wishtree/wishes/{id}/like")
Call<ApiResponse<String>> unlikeWish(@Path("id") long id);
/**
* 获取评论列表
*/
@GET("api/front/wishtree/wishes/{id}/comments")
Call<ApiResponse<WishtreeResponse.CommentPage>> getWishComments(
@Path("id") long wishId,
@Query("page") int page,
@Query("limit") int limit);
/**
* 发表评论
*/
@POST("api/front/wishtree/wishes/{id}/comments")
Call<ApiResponse<WishtreeResponse.Comment>> addWishComment(
@Path("id") long wishId,
@Body WishtreeRequest.AddComment body);
/**
* 获取背景素材列表
*/
@GET("api/front/wishtree/backgrounds")
Call<ApiResponse<List<WishtreeResponse.Background>>> getWishtreeBackgrounds();
}

View File

@ -0,0 +1,50 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 许愿树相关请求类
*/
public class WishtreeRequest {
/**
* 发布心愿请求
*/
public static class PublishWish {
@SerializedName("festivalId")
public Integer festivalId;
@SerializedName("content")
public String content;
@SerializedName("backgroundId")
public Integer backgroundId;
public PublishWish(String content) {
this.content = content;
}
public PublishWish(Integer festivalId, String content, Integer backgroundId) {
this.festivalId = festivalId;
this.content = content;
this.backgroundId = backgroundId;
}
}
/**
* 发表评论请求
*/
public static class AddComment {
@SerializedName("content")
public String content;
@SerializedName("parentId")
public Long parentId;
public AddComment(String content) {
this.content = content;
}
public AddComment(String content, Long parentId) {
this.content = content;
this.parentId = parentId;
}
}
}

View File

@ -0,0 +1,136 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
import java.util.List;
/**
* 许愿树相关响应类
*/
public class WishtreeResponse {
/**
* 节日信息
*/
public static class Festival {
@SerializedName("id")
public int id;
@SerializedName("name")
public String name;
@SerializedName("icon")
public String icon;
@SerializedName("startDate")
public String startDate;
@SerializedName("endDate")
public String endDate;
@SerializedName("isLunar")
public int isLunar;
@SerializedName("themeColor")
public String themeColor;
@SerializedName("status")
public int status;
}
/**
* 心愿信息
*/
public static class Wish {
@SerializedName("id")
public long id;
@SerializedName("uid")
public int uid;
@SerializedName("nickname")
public String nickname;
@SerializedName("avatar")
public String avatar;
@SerializedName("festivalId")
public int festivalId;
@SerializedName("festivalName")
public String festivalName;
@SerializedName("festivalIcon")
public String festivalIcon;
@SerializedName("content")
public String content;
@SerializedName("backgroundId")
public int backgroundId;
@SerializedName("backgroundImage")
public String backgroundImage;
@SerializedName("status")
public int status;
@SerializedName("likeCount")
public int likeCount;
@SerializedName("commentCount")
public int commentCount;
@SerializedName("isLiked")
public Boolean isLiked;
@SerializedName("createTime")
public String createTime;
}
/**
* 背景素材
*/
public static class Background {
@SerializedName("id")
public int id;
@SerializedName("name")
public String name;
@SerializedName("image")
public String image;
@SerializedName("festivalId")
public int festivalId;
@SerializedName("status")
public int status;
}
/**
* 评论信息
*/
public static class Comment {
@SerializedName("id")
public long id;
@SerializedName("wishId")
public long wishId;
@SerializedName("uid")
public int uid;
@SerializedName("nickname")
public String nickname;
@SerializedName("avatar")
public String avatar;
@SerializedName("content")
public String content;
@SerializedName("parentId")
public long parentId;
@SerializedName("status")
public int status;
@SerializedName("createTime")
public String createTime;
}
/**
* 分页数据
*/
public static class WishPage {
@SerializedName("list")
public List<Wish> list;
@SerializedName("total")
public int total;
@SerializedName("pageNum")
public int pageNum;
@SerializedName("pageSize")
public int pageSize;
}
/**
* 评论分页数据
*/
public static class CommentPage {
@SerializedName("list")
public List<Comment> list;
@SerializedName("total")
public int total;
@SerializedName("pageNum")
public int pageNum;
@SerializedName("pageSize")
public int pageSize;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F5F5F5" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="#E0E0E0" />
</shape>

View File

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

View File

@ -3,46 +3,47 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:background="@android:color/white">
android:background="@drawable/bg_dialog_rounded"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="许下你的愿望"
android:textSize="18sp"
android:textColor="#333333"
android:textStyle="bold"
android:gravity="center"
android:textColor="@android:color/black"
android:layout_marginBottom="16dp" />
<EditText
android:id="@+id/editWish"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_height="wrap_content"
android:hint="写下你的愿望..."
android:textColorHint="#999999"
android:textColor="#333333"
android:minLines="3"
android:maxLength="50"
android:gravity="top|start"
android:gravity="top"
android:padding="12dp"
android:background="@android:drawable/edit_text"
android:textSize="14sp" />
android:background="@drawable/bg_edit_text"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvCharCount"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0/50"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_gravity="end"
android:layout_marginTop="4dp"
android:textColor="#999999"
android:gravity="end"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
android:orientation="horizontal">
<TextView
android:id="@+id/btnCancel"
@ -50,10 +51,10 @@
android:layout_height="44dp"
android:layout_weight="1"
android:text="取消"
android:gravity="center"
android:textSize="16sp"
android:textColor="@android:color/darker_gray"
android:background="?attr/selectableItemBackground"
android:textColor="#666666"
android:gravity="center"
android:background="@drawable/bg_btn_cancel"
android:layout_marginEnd="8dp" />
<TextView
@ -62,10 +63,10 @@
android:layout_height="44dp"
android:layout_weight="1"
android:text="许愿"
android:gravity="center"
android:textSize="16sp"
android:textColor="@android:color/white"
android:background="#FF6B9D"
android:textColor="#FFFFFF"
android:gravity="center"
android:background="@drawable/bg_btn_primary"
android:layout_marginStart="8dp" />
</LinearLayout>

View File

@ -3,8 +3,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:background="@android:color/white">
android:background="@drawable/bg_dialog_rounded"
android:padding="20dp">
<RelativeLayout
android:layout_width="match_parent"
@ -14,20 +14,19 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我的愿望"
android:layout_centerHorizontal="true"
android:text="我的心愿"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_centerInParent="true" />
android:textColor="#333333"
android:textStyle="bold" />
<ImageView
android:id="@+id/btnClose"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless" />
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="关闭" />
</RelativeLayout>
@ -35,29 +34,56 @@
android:id="@+id/tvWishContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="100dp"
android:minHeight="80dp"
android:text=""
android:textSize="16sp"
android:textColor="#333333"
android:padding="12dp"
android:textSize="14sp"
android:textColor="@android:color/black"
android:background="@android:drawable/edit_text"
android:layout_marginBottom="16dp" />
android:background="@drawable/bg_edit_text"
android:layout_marginBottom="12dp" />
<!-- 点赞数和评论数 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
android:layout_marginBottom="16dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvLikeCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="❤ 0"
android:textSize="14sp"
android:textColor="#FF6B6B"
android:layout_marginEnd="20dp" />
<TextView
android:id="@+id/tvCommentCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="💬 0"
android:textSize="14sp"
android:textColor="#666666" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/btnDeleteWish"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="删除"
android:gravity="center"
android:text="删除心愿"
android:textSize="16sp"
android:textColor="@android:color/holo_red_dark"
android:background="?attr/selectableItemBackground"
android:textColor="#FF6B6B"
android:gravity="center"
android:background="@drawable/bg_btn_cancel"
android:layout_marginEnd="8dp" />
<TextView
@ -65,11 +91,11 @@
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="完成"
android:gravity="center"
android:text="愿望达成"
android:textSize="16sp"
android:textColor="@android:color/white"
android:background="#FF6B9D"
android:textColor="#FFFFFF"
android:gravity="center"
android:background="@drawable/bg_btn_primary"
android:layout_marginStart="8dp" />
</LinearLayout>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/bg_dialog_rounded"
android:padding="32dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🎉"
android:textSize="48sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="恭喜愿望达成!"
android:textSize="20sp"
android:textColor="#FF6D00"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="愿你心想事成,万事如意"
android:textSize="14sp"
android:textColor="#666666" />
</LinearLayout>

View File

@ -1,8 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
# 使用 Android Studio 自带的 JDK 17
org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr
# JDK配置由Android Studio自动管理
# 请在Android Studio中编译Build -> Rebuild Project
systemProp.gradle.wrapperUser=myuser
systemProp.gradle.wrapperPassword=mypassword

View File

@ -0,0 +1,537 @@
# 许愿树移动端功能设计文档
> 版本V1.0
> 更新日期2025-12-31
> 设计范围Android移动端许愿树功能
---
## 一、功能概述
### 1.1 核心特性
- **服务端实时存储**心愿数据通过API实时同步到服务端
- **节日主题**:支持不同节日的许愿活动
- **社交互动**:支持点赞、评论功能
- **多端同步**:数据云端存储,多设备共享
### 1.2 数据流架构
```
┌─────────────────┐ API请求 ┌─────────────────┐
│ WishTreeActivity│ ◄──────────────► │ 后端服务 │
└─────────────────┘ └─────────────────┘
│ │
│ Retrofit │ MyBatis
▼ ▼
ApiService MySQL数据库
(许愿树接口) (eb_wishtree_*)
```
---
## 二、页面设计
### 2.1 主页面布局 (activity_wish_tree.xml)
```
┌─────────────────────────────────────────────────────────┐
│ [许愿树标题] [🔍] [🔔] │ ← 顶部栏
├─────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 🎉 元旦许愿树 | 许下心愿,愿望成真 [12:30 05:00] │ │ ← 活动横幅
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ │
│ [心愿1] [心愿2] [心愿3] │
│ │
│ [心愿4] [心愿+] [心愿5] │ ← 许愿树区域
│ │
│ [心愿6] [心愿7] │
│ │
│ 元 │
│ 旦 │
│ 许 │
│ 愿 │
│ 树 │
│ │
├─────────────────────────────────────────────────────────┤
│ 祈愿值3/100 │ ← 底部区域
│ [ 前往祈愿 ] │
├─────────────────────────────────────────────────────────┤
│ [首页] [缘池] [许愿树] [消息] [我的] │ ← 底部导航
└─────────────────────────────────────────────────────────┘
```
### 2.2 许愿对话框 (dialog_make_wish.xml)
```
┌─────────────────────────────────────┐
│ 许下心愿 │
├─────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ 请输入您的心愿... │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ 0/50 │
├─────────────────────────────────────┤
│ [取消] [许愿] │
└─────────────────────────────────────┘
```
### 2.3 查看心愿对话框 (dialog_view_wish.xml)
```
┌─────────────────────────────────────┐
│ 我的心愿 [✕] │
├─────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ 新年快乐,万事如意! │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ ❤ 128 💬 32 │
├─────────────────────────────────────┤
│ [删除心愿] [愿望达成] │
└─────────────────────────────────────┘
```
### 2.4 成功提示对话框 (dialog_wish_success.xml)
```
┌─────────────────────────────────────┐
│ │
│ ✨ │
│ 许愿成功! │
│ │
└─────────────────────────────────────┘
```
---
## 三、API接口设计
### 3.1 接口列表
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取节日列表 | GET | `/api/front/wishtree/festivals` | 获取所有启用的节日 |
| 获取当前节日 | GET | `/api/front/wishtree/festivals/current` | 获取当前进行中的节日 |
| 获取心愿列表 | GET | `/api/front/wishtree/wishes` | 分页获取心愿列表 |
| 获取我的心愿 | GET | `/api/front/wishtree/wishes/my` | 获取当前用户的心愿 |
| 发布心愿 | POST | `/api/front/wishtree/wishes` | 发布新心愿 |
| 获取心愿详情 | GET | `/api/front/wishtree/wishes/{id}` | 获取心愿详情 |
| 删除心愿 | DELETE | `/api/front/wishtree/wishes/{id}` | 删除我的心愿 |
| 点赞心愿 | POST | `/api/front/wishtree/wishes/{id}/like` | 点赞 |
| 取消点赞 | DELETE | `/api/front/wishtree/wishes/{id}/like` | 取消点赞 |
| 获取评论 | GET | `/api/front/wishtree/wishes/{id}/comments` | 获取评论列表 |
| 发表评论 | POST | `/api/front/wishtree/wishes/{id}/comments` | 发表评论 |
| 获取背景素材 | GET | `/api/front/wishtree/backgrounds` | 获取背景素材列表 |
### 3.2 请求/响应示例
#### 发布心愿
```json
// POST /api/front/wishtree/wishes
// Request:
{
"festivalId": 1,
"content": "新年快乐,万事如意!",
"backgroundId": 1
}
// Response:
{
"code": 200,
"message": "success",
"data": {
"id": 10001,
"uid": 1001,
"festivalId": 1,
"content": "新年快乐,万事如意!",
"status": 1,
"likeCount": 0,
"commentCount": 0,
"createTime": "2025-12-31 12:00:00"
}
}
```
#### 获取我的心愿列表
```json
// GET /api/front/wishtree/wishes/my?page=1&limit=10
// Response:
{
"code": 200,
"data": {
"list": [
{
"id": 10001,
"uid": 1001,
"nickname": "用户昵称",
"avatar": "https://xxx/avatar.jpg",
"festivalId": 1,
"festivalName": "元旦",
"festivalIcon": "🎉",
"content": "新年快乐,万事如意!",
"backgroundImage": "https://xxx/bg1.jpg",
"status": 1,
"likeCount": 128,
"commentCount": 32,
"isLiked": false,
"createTime": "2025-12-31 12:00:00"
}
],
"total": 5,
"pageNum": 1,
"pageSize": 10
}
}
```
---
## 四、代码结构
### 4.1 文件清单
```
android-app/app/src/main/java/com/example/livestreaming/
├── WishTreeActivity.java # 许愿树主页面
└── net/
├── ApiService.java # API接口定义含许愿树接口
├── WishtreeRequest.java # 请求类
│ ├── PublishWish # 发布心愿请求
│ └── AddComment # 发表评论请求
└── WishtreeResponse.java # 响应类
├── Festival # 节日信息
├── Wish # 心愿信息
├── Background # 背景素材
├── Comment # 评论信息
├── WishPage # 心愿分页数据
└── CommentPage # 评论分页数据
android-app/app/src/main/res/layout/
├── activity_wish_tree.xml # 主页面布局
├── dialog_make_wish.xml # 许愿对话框
├── dialog_view_wish.xml # 查看心愿对话框
└── dialog_wish_success.xml # 成功提示对话框
android-app/app/src/main/res/drawable/
├── bg_wish_tree_banner.xml # 横幅背景
├── bg_wish_tree_timer.xml # 倒计时背景
├── bg_wish_card.xml # 心愿卡片背景
├── bg_wish_card_add.xml # 添加心愿卡片背景
├── bg_trunk_text.xml # 树干文字背景
├── bg_dialog_rounded.xml # 对话框圆角背景
├── bg_edit_text.xml # 输入框背景
├── bg_btn_primary.xml # 主按钮背景
└── bg_btn_cancel.xml # 取消按钮背景
```
### 4.2 核心类设计
#### WishTreeActivity.java
```java
public class WishTreeActivity extends AppCompatActivity {
private ActivityWishTreeBinding binding;
private ApiService apiService;
private List<WishtreeResponse.Wish> myWishes;
private WishtreeResponse.Festival currentFestival;
// 生命周期方法
onCreate() // 初始化页面、加载数据
onResume() // 刷新数据
onStart() // 启动倒计时
onStop() // 停止倒计时
// 数据加载
loadCurrentFestival() // 加载当前节日
loadMyWishes() // 加载我的心愿
// UI更新
updateBannerText() // 更新横幅文字
updateWishCards() // 更新心愿卡片
updateWishCount() // 更新祈愿值
updateBannerTimer() // 更新倒计时
// 用户交互
showMakeWishInputDialog() // 显示许愿对话框
showViewWishDialog() // 显示查看心愿对话框
showSuccessDialog() // 显示成功提示
// API调用
publishWish() // 发布心愿
deleteWish() // 删除心愿
}
```
#### WishtreeRequest.java
```java
public class WishtreeRequest {
// 发布心愿请求
public static class PublishWish {
public Integer festivalId;
public String content;
public Integer backgroundId;
}
// 发表评论请求
public static class AddComment {
public String content;
public Long parentId;
}
}
```
#### WishtreeResponse.java
```java
public class WishtreeResponse {
// 节日信息
public static class Festival {
public int id;
public String name;
public String icon;
public String startDate;
public String endDate;
public String themeColor;
public int status;
}
// 心愿信息
public static class Wish {
public long id;
public int uid;
public String nickname;
public String avatar;
public int festivalId;
public String festivalName;
public String festivalIcon;
public String content;
public String backgroundImage;
public int status;
public int likeCount;
public int commentCount;
public Boolean isLiked;
public String createTime;
}
// 背景素材
public static class Background {
public int id;
public String name;
public String image;
public int festivalId;
public int status;
}
// 评论信息
public static class Comment {
public long id;
public long wishId;
public int uid;
public String nickname;
public String avatar;
public String content;
public long parentId;
public String createTime;
}
}
```
---
## 五、交互流程
### 5.1 页面加载流程
```
┌─────────────┐
│ onCreate │
└──────┬──────┘
┌─────────────────────────────────────────────────────┐
│ 1. 初始化ApiService │
│ 2. 设置底部导航 │
│ 3. 设置心愿卡片点击事件 │
│ 4. 启动横幅倒计时 │
│ 5. 加载当前节日 (loadCurrentFestival) │
│ 6. 加载我的心愿 (loadMyWishes) │
└─────────────────────────────────────────────────────┘
```
### 5.2 发布心愿流程
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 点击许愿按钮 │ ──► │ 显示许愿对话框│ ──► │ 输入心愿内容 │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌───────────────────────────────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 点击许愿按钮 │ ──► │ 调用API发布 │ ──► │ 显示成功提示 │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌───────────────────────────────────────┘
┌─────────────────────┐
│ 重新加载心愿列表 │
│ 更新心愿卡片显示 │
└─────────────────────┘
```
### 5.3 查看/删除心愿流程
```
┌─────────────┐ ┌─────────────┐
│ 点击心愿卡片 │ ──► │ 显示心愿详情 │
└─────────────┘ └──────┬──────┘
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 点击关闭 │ │ 点击删除 │ │ 愿望达成 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 关闭对话框 │ │ 调用API删除│ │ 调用API删除│
└───────────┘ │ 刷新列表 │ │ 显示祝贺 │
└───────────┘ └───────────┘
```
---
## 六、状态管理
### 6.1 心愿状态
| 状态值 | 说明 | 显示 |
|--------|------|------|
| 0 | 待审核 | 审核中 |
| 1 | 已通过 | 正常显示 |
| 2 | 已拒绝 | 不显示 |
### 6.2 页面状态
```java
// 心愿数据
private List<WishtreeResponse.Wish> myWishes = new ArrayList<>();
// 当前节日
private WishtreeResponse.Festival currentFestival;
// 状态更新时机
- onCreate: 初始加载
- onResume: 每次返回页面刷新
- 发布/删除心愿后: 重新加载
```
---
## 七、错误处理
### 7.1 网络错误
```java
@Override
public void onFailure(Call<...> call, Throwable t) {
Toast.makeText(context, "网络错误", Toast.LENGTH_SHORT).show();
}
```
### 7.2 业务错误
```java
if (response.body().getCode() != 200) {
String msg = response.body().getMessage();
Toast.makeText(context, msg != null ? msg : "操作失败", Toast.LENGTH_SHORT).show();
}
```
### 7.3 空数据处理
```java
if (myWishes == null) {
myWishes = new ArrayList<>();
}
```
---
## 八、后续扩展
### 8.1 待实现功能
- [ ] 心愿列表页面(查看所有用户心愿)
- [ ] 点赞功能
- [ ] 评论功能
- [ ] 背景素材选择
- [ ] 节日主题切换
- [ ] 心愿分享功能
### 8.2 优化方向
- 添加加载状态指示器
- 添加下拉刷新
- 添加心愿卡片动画效果
- 支持心愿图片上传
- 添加本地缓存减少网络请求
---
## 九、数据库表结构
### 9.1 相关表
```sql
-- 节日表
eb_wishtree_festival (id, name, icon, start_date, end_date, theme_color, status, ...)
-- 心愿表
eb_wishtree_wish (id, uid, festival_id, content, background_id, status, like_count, comment_count, ...)
-- 点赞表
eb_wishtree_like (id, uid, wish_id, create_time)
-- 评论表
eb_wishtree_comment (id, wish_id, uid, content, parent_id, status, ...)
-- 背景素材表
eb_wishtree_background (id, name, image, festival_id, status, ...)
```
---
## 十、测试要点
### 10.1 功能测试
- [ ] 页面正常加载
- [ ] 节日信息正确显示
- [ ] 心愿卡片正确显示
- [ ] 发布心愿成功
- [ ] 删除心愿成功
- [ ] 愿望达成功能正常
- [ ] 底部导航切换正常
### 10.2 异常测试
- [ ] 网络断开时的提示
- [ ] 未登录时的处理
- [ ] 空数据时的显示
- [ ] 心愿内容为空时的校验

60
friend_tables.sql Normal file
View File

@ -0,0 +1,60 @@
-- 好友系统数据库表
-- 请在服务器数据库中执行此脚本
-- 1. 好友关系表
CREATE TABLE IF NOT EXISTS `eb_friend` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int(11) NOT NULL COMMENT '用户ID',
`friend_id` int(11) NOT NULL COMMENT '好友ID',
`remark` varchar(50) DEFAULT NULL COMMENT '好友备注',
`status` int(11) 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='好友关系表';
-- 2. 好友请求表
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`from_user_id` int(11) NOT NULL COMMENT '发起请求的用户ID',
`to_user_id` int(11) NOT NULL COMMENT '接收请求的用户ID',
`message` varchar(200) DEFAULT NULL COMMENT '验证消息',
`status` int(11) 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='好友请求表';
-- 3. 用户黑名单表
CREATE TABLE IF NOT EXISTS `eb_user_blacklist` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int(11) NOT NULL COMMENT '用户ID',
`blocked_user_id` int(11) NOT NULL COMMENT '被拉黑的用户ID',
`reason` varchar(200) DEFAULT NULL COMMENT '拉黑原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_blocked` (`user_id`, `blocked_user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_blocked_user_id` (`blocked_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户黑名单表';
-- 4. 私聊会话表(如果不存在)
CREATE TABLE IF NOT EXISTS `eb_conversation` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user1_id` int(11) NOT NULL COMMENT '用户1 ID较小的ID',
`user2_id` int(11) NOT NULL COMMENT '用户2 ID较大的ID',
`last_message_id` bigint(20) DEFAULT NULL COMMENT '最后一条消息ID',
`last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间',
`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_users` (`user1_id`, `user2_id`),
KEY `idx_user1` (`user1_id`),
KEY `idx_user2` (`user2_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';

177
环境配置指南.md Normal file
View File

@ -0,0 +1,177 @@
# 环境配置指南
本项目采用分离配置架构:
- **普通业务功能**(用户、消息、商城等)→ 可切换本地/远程
- **直播/通话服务** → 始终连接远程服务器
---
## 一、APP 配置
### 配置文件位置
`android-app/local.properties`
### 配置说明
```properties
# ============ 主API地址普通业务功能============
# 本地开发时使用本地地址
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.1.164:8081/
# ============ 直播/通话服务地址(始终远程)============
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
### 切换环境
#### 本地开发(默认)
```properties
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.1.164:8081/
```
#### 全部连接远程
```properties
api.base_url_emulator=http://1.15.149.240:8083/
api.base_url_device=http://1.15.149.240:8083/
```
### 修改后需要重新编译
```bash
cd android-app
.\gradlew assembleRelease
```
---
## 二、前端管理端配置
### 配置文件位置
- 开发环境:`Zhibo/admin/.env.development`
- 生产环境:`Zhibo/admin/.env.production`
### 开发环境(本地)
```env
ENV = 'development'
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
### 生产环境(远程)
```env
ENV = 'production'
VUE_APP_BASE_API = ''
```
> 生产环境使用空字符串由Nginx代理到后端
---
## 三、后端配置
### 配置文件位置
`Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml`
### 关键配置
```yaml
server:
port: 8081 # 本地开发端口
spring:
datasource:
url: jdbc:mysql://1.15.149.240:3306/zhibo # 数据库地址
redis:
host: 127.0.0.1 # Redis地址
```
### 服务器启动脚本
```bash
#!/bin/bash
JAR_PATH="/www/wwwroot/1.15.149.240_30002/Jar"
# Front API (带SRS配置)
nohup java -Xms512m -Xmx1024m -jar ${JAR_PATH}/Crmeb-front.jar \
--server.port=8083 \
--spring.redis.host=127.0.0.1 \
--LIVE_PUBLIC_SRS_HOST=1.15.149.240 \
--LIVE_PUBLIC_SRS_RTMP_PORT=25002 \
--LIVE_PUBLIC_SRS_HTTP_PORT=25003 \
> ${JAR_PATH}/logs/front.log 2>&1 &
```
---
## 四、服务架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 远程服务器 1.15.149.240 │
├─────────────────────────────────────────────────────────────┤
│ Front API (8083) │
│ ├── WebSocket: /ws/call (通话信令) ←── APP直连 │
│ ├── WebSocket: /ws/live/* (直播弹幕) ←── APP直连 │
│ └── REST API: /api/front/* (可选) │
│ │
│ SRS流媒体服务器 │
│ ├── RTMP: 25002 │
│ └── HTTP-FLV: 25003 │
│ │
│ TURN服务器: 3478 │
└─────────────────────────────────────────────────────────────┘
直播/通话服务 │
─────────────────────┼─────────────────────
普通业务功能 │
┌─────────────────────────────────────────────────────────────┐
│ 本地开发环境 │
├─────────────────────────────────────────────────────────────┤
│ Front API (8081) │
│ └── REST API: /api/front/* ←── APP连接 │
│ │
│ Admin API (30001) │
│ └── REST API: /api/admin/* ←── 管理前端连接 │
└─────────────────────────────────────────────────────────────┘
```
---
## 五、快速切换命令
### APP切换到全远程
编辑 `android-app/local.properties`
```properties
api.base_url_device=http://1.15.149.240:8083/
```
### APP切换到本地开发
编辑 `android-app/local.properties`
```properties
api.base_url_device=http://192.168.1.164:8081/
```
### 前端切换
```bash
# 开发模式(连本地)
npm run dev
# 生产构建(连远程)
npm run build:prod
```
---
## 六、端口清单
| 服务 | 本地端口 | 远程端口 | 说明 |
|------|----------|----------|------|
| Front API | 8081 | 8083 | APP主API |
| Admin API | 30001 | 30003 | 管理后台API |
| SRS RTMP | - | 25002 | 直播推流 |
| SRS HTTP | - | 25003 | 直播播放 |
| TURN | - | 3478 | 视频通话中继 |
| MySQL | - | 3306 | 数据库 |
| Redis | 6379 | 6379 | 缓存 |

141
环境配置汇总.md Normal file
View File

@ -0,0 +1,141 @@
# 环境配置汇总
## 一、Android APP 配置
### 1. 主配置文件:`android-app/local.properties`
```properties
# ============ API 服务器地址配置 ============
# 主API地址 - 模拟器使用
api.base_url_emulator=http://1.15.149.240:8083/
# 主API地址 - 真机使用
api.base_url_device=http://1.15.149.240:8083/
# ============ 直播/通话服务地址配置 ============
# 直播/通话 WebSocket 服务地址
live.server_host=1.15.149.240
live.server_port=8083
# TURN 服务器地址(视频通话中继)
turn.server_host=1.15.149.240
turn.server_port=3478
```
**说明**
- `api.base_url_emulator` - 模拟器运行时使用的API地址
- `api.base_url_device` - 真机运行时使用的API地址
- `live.server_host/port` - 直播和通话WebSocket服务地址
- `turn.server_host/port` - WebRTC TURN中继服务器地址
### 2. 相关代码文件无需修改自动读取local.properties
| 文件 | 作用 |
|------|------|
| `app/build.gradle.kts` | 读取local.properties生成BuildConfig |
| `ApiConfig.java` | 提供统一的API地址获取方法 |
| `ApiClient.java` | 网络请求客户端,自动选择模拟器/真机地址 |
| `WebRTCConfig.java` | WebRTC/TURN服务器配置 |
---
## 二、前端 Admin 配置
### 1. 开发环境:`Zhibo/admin/.env.development`
```properties
ENV = 'development'
# 本地开发时连接本地后端
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
### 2. 生产环境:`Zhibo/admin/.env.production`
```properties
ENV = 'production'
# 生产环境使用相对路径由Nginx代理
VUE_APP_BASE_API = ''
```
### 3. 预发布环境:`Zhibo/admin/.env.staging`
```properties
ENV = 'production'
VUE_APP_BASE_API = 'http://192.168.31.35:2500'
```
**说明**
- 开发环境:`npm run dev` 使用 `.env.development`
- 生产打包:`npm run build:prod` 使用 `.env.production`
- 预发布打包:`npm run build:stage` 使用 `.env.staging`
---
## 三、环境切换指南
### 切换到生产环境(服务器 1.15.149.240
**Android APP**
```properties
# android-app/local.properties
api.base_url_emulator=http://1.15.149.240:8083/
api.base_url_device=http://1.15.149.240:8083/
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
**前端 Admin**
```properties
# Zhibo/admin/.env.production
VUE_APP_BASE_API = ''
```
然后执行 `npm run build:prod`
---
### 切换到本地开发环境
**Android APP**
```properties
# android-app/local.properties
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.x.x:8081/
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
> 注意:直播/通话服务建议始终使用远程服务器
**前端 Admin**
```properties
# Zhibo/admin/.env.development
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
然后执行 `npm run dev`
---
## 四、服务端口说明
| 服务 | 端口 | 说明 |
|------|------|------|
| Admin API | 30003 | 管理后台API |
| Front API | 8083 | 前端/APP API |
| Admin 前端 | 30002 | 管理后台网页 |
| TURN 服务 | 3478 | WebRTC中继 |
| SRS RTMP | 25002 | 直播推流 |
| SRS HTTP | 25003 | 直播拉流 |
---
## 五、修改后需要的操作
### Android APP
1. 修改 `local.properties`
2. 重新编译:`./gradlew assembleRelease`
### 前端 Admin
1. 修改对应的 `.env.*` 文件
2. 重新打包:`npm run build:prod`
3. 上传 `dist` 目录到服务器