Compare commits

...

2 Commits

Author SHA1 Message Date
xiao12feng8
8345dca1a1 解决合并冲突:许愿树对话框布局文件 2025-12-31 14:47:07 +08:00
xiao12feng8
750f4c8c1e 更新:许愿树对话框、通话功能、环境配置等多项优化 2025-12-31 14:44:51 +08:00
14 changed files with 767 additions and 296 deletions

View File

@ -13,25 +13,25 @@ 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: '数据统计' },
},

View File

@ -38,18 +38,27 @@ public class LiveStatusSyncTask {
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 同步直播状态 5 秒执行一次
* 同步直播状态 30 秒执行一次减少频率
*/
@Scheduled(fixedRate = 5000)
@Scheduled(fixedRate = 30000)
public void syncLiveStatus() {
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 列表
*/
@ -63,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()) {
@ -80,6 +90,8 @@ public class LiveStatusSyncTask {
conn.disconnect();
} catch (Exception e) {
logger.warn("查询 SRS API 失败: {}", e.getMessage());
// 返回 null 表示查询失败不应该修改状态
return null;
}
return streamKeys;
}
@ -88,6 +100,8 @@ public class LiveStatusSyncTask {
* 更新数据库中的直播状态
*/
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();

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

@ -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() {
@ -744,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());
}
}
/**
@ -896,73 +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);
// 设置直播状态
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();
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) {
@ -972,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);
@ -1019,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) {
@ -1067,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);
@ -1133,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() {

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

@ -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>

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` 目录到服务器